From fd5345d623c38f6ebaafd400896dc91b48895b79 Mon Sep 17 00:00:00 2001 From: JeffreyChen Date: Tue, 23 Jun 2026 10:41:27 +0800 Subject: [PATCH] Add colour-histogram fingerprint and change detection --- README/WHATS_NEW_zh-CN.md | 6 ++ README/WHATS_NEW_zh-TW.md | 6 ++ WHATS_NEW.md | 6 ++ .../doc/new_features/v145_features_doc.rst | 44 +++++++++ docs/source/Eng/eng_index.rst | 1 + .../Zh/doc/new_features/v145_features_doc.rst | 36 +++++++ docs/source/Zh/zh_index.rst | 1 + je_auto_control/__init__.py | 7 ++ .../gui/script_builder/command_schema.py | 30 ++++++ .../utils/executor/action_executor.py | 33 +++++++ .../utils/img_histogram/__init__.py | 6 ++ .../utils/img_histogram/img_histogram.py | 93 +++++++++++++++++++ .../utils/mcp_server/tools/_factories.py | 40 +++++++- .../utils/mcp_server/tools/_handlers.py | 11 +++ .../headless/test_img_histogram_batch.py | 80 ++++++++++++++++ 15 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 docs/source/Eng/doc/new_features/v145_features_doc.rst create mode 100644 docs/source/Zh/doc/new_features/v145_features_doc.rst create mode 100644 je_auto_control/utils/img_histogram/__init__.py create mode 100644 je_auto_control/utils/img_histogram/img_histogram.py create mode 100644 test/unit_test/headless/test_img_histogram_batch.py diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 3d816aa7..f5adeeb6 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 色彩直方图指纹与变化检测 + +判断画面在光照 / 缩放下是否仍是「同一个」。完整参考:[`docs/source/Zh/doc/new_features/v145_features_doc.rst`](../docs/source/Zh/doc/new_features/v145_features_doc.rst)。 + +- **`image_histogram` / `compare_histograms` / `histogram_changed`**(`AC_image_histogram`、`AC_histogram_changed`):`image_dedup` 的感知哈希是空间性的(对颜色/主题脆弱)、`color_stats` 只有单一颜色。归一化色彩直方图是耐光照/缩放的「同一画面、还是调色板变了?」信号(主题切换、重载、旋转横幅)。`image_histogram` 返回逐通道直方图(`hsv`/`rgb`/`gray`);`compare_histograms` 提供 correlation/chisqr/intersection/bhattacharyya;`histogram_changed` 比较参考与实际屏幕。可注入图像 → 无头可测;OpenCV 核心(`cv2.calcHist`/`compareHist`)。 + ## 本次更新 (2026-06-23) — 丰富剪贴板(HTML / CF_HTML) 把*格式化*的 HTML 复制粘贴到 Word / Outlook。完整参考:[`docs/source/Zh/doc/new_features/v144_features_doc.rst`](../docs/source/Zh/doc/new_features/v144_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index 267c06c2..e1e56127 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 色彩直方圖指紋與變化偵測 + +判斷畫面在光照 / 縮放下是否仍是「同一個」。完整參考:[`docs/source/Zh/doc/new_features/v145_features_doc.rst`](../docs/source/Zh/doc/new_features/v145_features_doc.rst)。 + +- **`image_histogram` / `compare_histograms` / `histogram_changed`**(`AC_image_histogram`、`AC_histogram_changed`):`image_dedup` 的感知雜湊是空間性的(對顏色/主題脆弱)、`color_stats` 只有單一顏色。正規化色彩直方圖是耐光照/縮放的「同一畫面、還是調色盤變了?」訊號(主題切換、重載、旋轉橫幅)。`image_histogram` 回傳逐通道直方圖(`hsv`/`rgb`/`gray`);`compare_histograms` 提供 correlation/chisqr/intersection/bhattacharyya;`histogram_changed` 比較參考與實際螢幕。可注入影像 → 無頭可測;OpenCV 核心(`cv2.calcHist`/`compareHist`)。 + ## 本次更新 (2026-06-23) — 豐富剪貼簿(HTML / CF_HTML) 把*格式化*的 HTML 複製貼上到 Word / Outlook。完整參考:[`docs/source/Zh/doc/new_features/v144_features_doc.rst`](../docs/source/Zh/doc/new_features/v144_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index ec24f090..1d20c196 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Colour-Histogram Fingerprint & Change Detection + +Tell whether the view is "the same" despite lighting / scale. Full reference: [`docs/source/Eng/doc/new_features/v145_features_doc.rst`](docs/source/Eng/doc/new_features/v145_features_doc.rst). + +- **`image_histogram` / `compare_histograms` / `histogram_changed`** (`AC_image_histogram`, `AC_histogram_changed`): `image_dedup`'s perceptual hash is spatial (brittle to colour/theme) and `color_stats` is one colour. A normalized colour histogram is the illumination/scale-robust "same view, or palette shifted?" signal (theme switch, reload, rotated banner). `image_histogram` returns a per-channel histogram (`hsv`/`rgb`/`gray`); `compare_histograms` does correlation/chisqr/intersection/bhattacharyya; `histogram_changed` compares a reference vs the live screen. Injectable image → headless-testable; base OpenCV (`cv2.calcHist`/`compareHist`). + ## What's new (2026-06-23) — Rich Clipboard (HTML / CF_HTML) Copy and paste *formatted* HTML into Word / Outlook. Full reference: [`docs/source/Eng/doc/new_features/v144_features_doc.rst`](docs/source/Eng/doc/new_features/v144_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v145_features_doc.rst b/docs/source/Eng/doc/new_features/v145_features_doc.rst new file mode 100644 index 00000000..524c4d33 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v145_features_doc.rst @@ -0,0 +1,44 @@ +Colour-Histogram Fingerprint & Change Detection +=============================================== + +``image_dedup`` fingerprints with a perceptual aHash / dHash — a *spatial* 64-bit +hash that is brittle to colour and theme shifts — and ``color_stats`` reports a single +average / dominant colour. A normalized colour *histogram* is the standard +illumination- and scale-robust signal for "is this the same view, or has the palette +shifted?": a theme switch, a content reload, a rotated banner — which neither hashing +nor one dominant colour captures. + +Every function runs on an injectable image (ndarray / path / PIL, RGB), so it is +headless-testable on synthetic arrays. ``cv2.calcHist`` / ``cv2.compareHist`` are base +OpenCV; OpenCV + NumPy come in via ``je_open_cv``. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import (image_histogram, compare_histograms, + histogram_changed) + + baseline = image_histogram("golden.png") # 3 * bins floats (HSV) + if histogram_changed("golden.png"): # current = live screen + print("the view changed") + + score = compare_histograms(baseline, image_histogram()) # 1.0 == identical + +``image_histogram`` returns a per-channel normalized histogram as a flat list +(``space`` = ``hsv`` / ``rgb`` / ``gray``; each channel adds ``bins`` values). +``compare_histograms`` supports ``correlation`` / ``chisqr`` / ``intersection`` / +``bhattacharyya`` (for correlation / intersection higher is more similar; for the +distance methods higher is more different). ``histogram_changed`` compares a +``reference`` against ``current`` (default: the screen) and returns a bool, flipping +the threshold comparison automatically for similarity vs distance methods. + +Executor commands +----------------- + +``AC_image_histogram`` (``source`` / ``bins`` / ``space`` / ``region`` → +``{bins, space, histogram}``) and ``AC_histogram_changed`` (``reference`` / +``current`` / ``method`` / ``threshold`` / ``space`` / ``region`` → +``{changed, score}``). They are exposed as the MCP tools ``ac_image_histogram`` / +``ac_histogram_changed`` and as Script Builder commands under **Image**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index 9a52e556..04c8050d 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -167,6 +167,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v142_features_doc doc/new_features/v143_features_doc doc/new_features/v144_features_doc + doc/new_features/v145_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/docs/source/Zh/doc/new_features/v145_features_doc.rst b/docs/source/Zh/doc/new_features/v145_features_doc.rst new file mode 100644 index 00000000..c6423bf0 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v145_features_doc.rst @@ -0,0 +1,36 @@ +色彩直方圖指紋與變化偵測 +======================== + +``image_dedup`` 以感知 aHash / dHash 做指紋——那是*空間性*的 64 位元雜湊,對顏色與主題變化很脆弱——而 +``color_stats`` 只回傳單一平均 / 主要顏色。正規化的色彩*直方圖*是判斷「這是不是同一個畫面、調色盤是否改變」的標準 +耐光照、耐縮放訊號:主題切換、內容重載、旋轉的橫幅——這些雜湊與單一主色都捕捉不到。 + +每個函式都在可注入的影像(ndarray / 路徑 / PIL,RGB)上執行,因此可對合成陣列做無頭測試。``cv2.calcHist`` / +``cv2.compareHist`` 屬於 OpenCV 核心;OpenCV + NumPy 透過 ``je_open_cv`` 引入。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import (image_histogram, compare_histograms, + histogram_changed) + + baseline = image_histogram("golden.png") # 3 * bins 個浮點數(HSV) + if histogram_changed("golden.png"): # current = 實際螢幕 + print("the view changed") + + score = compare_histograms(baseline, image_histogram()) # 1.0 == 完全相同 + +``image_histogram`` 回傳逐通道正規化直方圖的平面清單(``space`` = ``hsv`` / ``rgb`` / ``gray``;每通道貢獻 +``bins`` 個值)。``compare_histograms`` 支援 ``correlation`` / ``chisqr`` / ``intersection`` / +``bhattacharyya``(correlation / intersection 越高越相似;距離方法越高越不同)。``histogram_changed`` 比較 +``reference`` 與 ``current``(預設為螢幕)並回傳布林值,會依相似 vs 距離方法自動翻轉門檻比較方向。 + +執行器命令 +---------- + +``AC_image_histogram``(``source`` / ``bins`` / ``space`` / ``region`` → ``{bins, space, histogram}``)與 +``AC_histogram_changed``(``reference`` / ``current`` / ``method`` / ``threshold`` / ``space`` / ``region`` → +``{changed, score}``)。它們以 MCP 工具 ``ac_image_histogram`` / ``ac_histogram_changed`` 以及 Script Builder 中 +**Image** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index d35b4ac7..795108c9 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -167,6 +167,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v142_features_doc doc/new_features/v143_features_doc doc/new_features/v144_features_doc + doc/new_features/v145_features_doc doc/ocr_backends/ocr_backends_doc doc/observability/observability_doc doc/operations_layer/operations_layer_doc diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index a4d59a66..a5be33b7 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -339,6 +339,10 @@ from je_auto_control.utils.rich_clipboard import ( build_cf_html, get_clipboard_html, parse_cf_html, set_clipboard_html, ) +# Colour-histogram fingerprint & change detection (illumination-robust) +from je_auto_control.utils.img_histogram import ( + compare_histograms, histogram_changed, image_histogram, +) # CI workflow annotations (GitHub Actions) from je_auto_control.utils.ci_annotations import ( emit_annotations, format_annotation, @@ -1195,6 +1199,9 @@ def start_autocontrol_gui(*args, **kwargs): "parse_cf_html", "get_clipboard_html", "set_clipboard_html", + "image_histogram", + "compare_histograms", + "histogram_changed", "emit_annotations", "format_annotation", "ClipboardHistory", "default_clipboard_history", "analyze_heal_log", "heal_stats", "scan_secrets", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index 5bd7d1ff..2e683994 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -502,6 +502,36 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: ), description="Refine candidate boxes (within / filter / reading / nth / …).", )) + specs.append(CommandSpec( + "AC_image_histogram", "Image", "Image Histogram", + fields=( + FieldSpec("source", FieldType.FILE_PATH, optional=True), + FieldSpec("bins", FieldType.INT, optional=True, default=32), + FieldSpec("space", FieldType.ENUM, optional=True, default="hsv", + choices=("hsv", "rgb", "gray")), + FieldSpec("region", FieldType.STRING, optional=True, + placeholder=_REGION_PLACEHOLDER), + ), + description="Colour-histogram fingerprint of an image / the screen.", + )) + specs.append(CommandSpec( + "AC_histogram_changed", "Image", "Histogram Changed?", + fields=( + FieldSpec("reference", FieldType.FILE_PATH), + FieldSpec("current", FieldType.FILE_PATH, optional=True), + FieldSpec("method", FieldType.ENUM, optional=True, + default="correlation", + choices=("correlation", "chisqr", "intersection", + "bhattacharyya")), + FieldSpec("threshold", FieldType.FLOAT, optional=True, default=0.9, + min_value=0.0, max_value=1.0), + FieldSpec("space", FieldType.ENUM, optional=True, default="hsv", + choices=("hsv", "rgb", "gray")), + FieldSpec("region", FieldType.STRING, optional=True, + placeholder=_REGION_PLACEHOLDER), + ), + description="Detect a palette/view change vs a reference (illumination-robust).", + )) def _add_ocr_specs(specs: List[CommandSpec]) -> None: diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 874f1477..c3ebc40a 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3676,6 +3676,37 @@ def _get_clipboard_html() -> Dict[str, Any]: return {"found": html is not None, "html": html} +def _image_histogram(source: Any = None, bins: Any = 32, space: str = "hsv", + region: Any = None) -> Dict[str, Any]: + """Adapter: per-channel colour histogram of an image / the screen.""" + import json + from je_auto_control.utils.img_histogram import image_histogram + if isinstance(region, str): + region = json.loads(region) if region.strip() else None + hist = image_histogram(source, region=region, bins=int(bins), space=str(space)) + return {"bins": int(bins), "space": str(space), "histogram": hist} + + +def _histogram_changed(reference: str, current: Any = None, method: str = + "correlation", threshold: Any = 0.9, space: str = "hsv", + region: Any = None) -> Dict[str, Any]: + """Adapter: whether the screen / current image differs from a reference.""" + import json + from je_auto_control.utils.img_histogram import (compare_histograms, + histogram_changed, + image_histogram) + if isinstance(region, str): + region = json.loads(region) if region.strip() else None + changed = histogram_changed(reference, current, region=region, + method=str(method), threshold=float(threshold), + space=str(space)) + ref_hist = image_histogram(reference, space=str(space)) + cur_hist = (image_histogram(current, space=str(space)) if current is not None + else image_histogram(region=region, space=str(space))) + return {"changed": changed, + "score": compare_histograms(ref_hist, cur_hist, method=str(method))} + + def _with_modifiers(modifiers: Any, actions: Any) -> Dict[str, Any]: """Adapter: run nested actions while modifier keys are held down.""" import json @@ -5419,6 +5450,8 @@ def __init__(self): "AC_locate_chain": _locate_chain, "AC_set_clipboard_html": _set_clipboard_html, "AC_get_clipboard_html": _get_clipboard_html, + "AC_image_histogram": _image_histogram, + "AC_histogram_changed": _histogram_changed, "AC_tile_rect": _tile_rect, "AC_grid_rects": _grid_rects, "AC_cascade_rects": _cascade_rects, diff --git a/je_auto_control/utils/img_histogram/__init__.py b/je_auto_control/utils/img_histogram/__init__.py new file mode 100644 index 00000000..aa69f874 --- /dev/null +++ b/je_auto_control/utils/img_histogram/__init__.py @@ -0,0 +1,6 @@ +"""Colour-histogram fingerprint & change detection (illumination-robust).""" +from je_auto_control.utils.img_histogram.img_histogram import ( + compare_histograms, histogram_changed, image_histogram, +) + +__all__ = ["compare_histograms", "histogram_changed", "image_histogram"] diff --git a/je_auto_control/utils/img_histogram/img_histogram.py b/je_auto_control/utils/img_histogram/img_histogram.py new file mode 100644 index 00000000..f22a1f47 --- /dev/null +++ b/je_auto_control/utils/img_histogram/img_histogram.py @@ -0,0 +1,93 @@ +"""Colour-histogram fingerprint & change detection — "is this the same view?". + +``image_dedup`` fingerprints with a perceptual aHash/dHash (a *spatial* 64-bit hash, +brittle to colour / theme shifts) and ``color_stats`` reports a single average / +dominant colour. A normalized colour *histogram* is the standard illumination- and +scale-robust signal for "is this the same view, or has the palette shifted" — a theme +switch, a content reload, a rotated ad — which neither hashing nor one dominant colour +captures. + +Every function runs on an injectable image (ndarray / path / PIL, RGB) so it is +headless-testable on synthetic arrays. ``cv2.calcHist`` / ``cv2.compareHist`` are base +OpenCV; OpenCV + NumPy come in via ``je_open_cv``. Imports no ``PySide6``. +""" +from typing import Any, List, Optional, Sequence + +# Reuse the RGB loader / screen grab (single source of truth, no copy). +from je_auto_control.utils.color_region.color_region import _grab_rgb, _to_rgb + +ImageSource = Any +_SIMILARITY_METHODS = ("correlation", "intersection") + + +def _convert(rgb, space: str): + import cv2 + if space == "hsv": + return (cv2.cvtColor(rgb, cv2.COLOR_RGB2HSV), + [[0, 180], [0, 256], [0, 256]]) + if space == "rgb": + return rgb, [[0, 256], [0, 256], [0, 256]] + if space == "gray": + return cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY), [[0, 256]] + raise ValueError(f"unknown space: {space!r}") + + +def image_histogram(haystack: Optional[ImageSource] = None, *, + region: Optional[Sequence[int]] = None, bins: int = 32, + space: str = "hsv") -> List[float]: + """Return a per-channel normalized colour histogram as a flat list of floats. + + ``space`` is ``hsv`` / ``rgb`` / ``gray``; each channel contributes ``bins`` + values (so HSV/RGB give ``3 * bins``). ``haystack`` defaults to a screen grab of + the optional ``region``. + """ + import cv2 + rgb = _to_rgb(haystack) if haystack is not None else _grab_rgb(region) + image, ranges = _convert(rgb, space) + channels = 1 if image.ndim == 2 else image.shape[2] + out: List[float] = [] + for channel in range(channels): + hist = cv2.calcHist([image], [channel], None, [int(bins)], ranges[channel]) + cv2.normalize(hist, hist, 0.0, 1.0, cv2.NORM_MINMAX) + out.extend(float(value) for value in hist.flatten()) + return out + + +def compare_histograms(hist_a: Sequence[float], hist_b: Sequence[float], *, + method: str = "correlation") -> float: + """Compare two histograms. ``method``: correlation / chisqr / intersection / bhattacharyya. + + For correlation / intersection higher means more similar; for chisqr / + bhattacharyya higher means more different. + """ + import cv2 + import numpy as np + methods = {"correlation": cv2.HISTCMP_CORREL, + "chisqr": cv2.HISTCMP_CHISQR, + "intersection": cv2.HISTCMP_INTERSECT, + "bhattacharyya": cv2.HISTCMP_BHATTACHARYYA} + if method not in methods: + raise ValueError(f"unknown method: {method!r}") + array_a = np.asarray(hist_a, dtype=np.float32) + array_b = np.asarray(hist_b, dtype=np.float32) + return round(float(cv2.compareHist(array_a, array_b, methods[method])), 4) + + +def histogram_changed(reference: ImageSource, + current: Optional[ImageSource] = None, *, + region: Optional[Sequence[int]] = None, + method: str = "correlation", threshold: float = 0.9, + space: str = "hsv") -> bool: + """Return whether ``current`` (default: screen) differs from ``reference``. + + Compares their histograms with ``method``; for similarity methods (correlation / + intersection) it is "changed" when the score drops below ``threshold``, for + distance methods (chisqr / bhattacharyya) when it rises above ``threshold``. + """ + reference_hist = image_histogram(reference, space=space) + current_hist = (image_histogram(current, space=space) if current is not None + else image_histogram(region=region, space=space)) + score = compare_histograms(reference_hist, current_hist, method=method) + if method in _SIMILARITY_METHODS: + return score < float(threshold) + return score > float(threshold) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 116fd9d7..fef81026 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3086,6 +3086,43 @@ def rich_clipboard_tools() -> List[MCPTool]: ] +def img_histogram_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_image_histogram", + description=("Per-channel normalized colour histogram of 'source' " + "(image path; default screen grab of 'region'). 'space' " + "hsv/rgb/gray, 'bins' per channel. Returns {bins, space, " + "histogram}. A scale/illumination-robust view fingerprint."), + input_schema=schema({ + "source": {"type": "string"}, + "bins": {"type": "integer"}, + "space": {"type": "string"}, + "region": {"type": "array", "items": {"type": "integer"}}}, + required=[]), + handler=h.image_histogram, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_histogram_changed", + description=("Whether the screen / 'current' image differs from " + "'reference' by colour histogram (theme switch, reload). " + "'method' correlation/chisqr/intersection/bhattacharyya, " + "'threshold', 'space'. Returns {changed, score}."), + input_schema=schema({ + "reference": {"type": "string"}, + "current": {"type": "string"}, + "method": {"type": "string"}, + "threshold": {"type": "number"}, + "space": {"type": "string"}, + "region": {"type": "array", "items": {"type": "integer"}}}, + required=["reference"]), + handler=h.histogram_changed, + annotations=READ_ONLY, + ), + ] + + def ssim_tools() -> List[MCPTool]: return [ MCPTool( @@ -6591,7 +6628,8 @@ def media_assert_tools() -> List[MCPTool]: window_layout_tools, window_arrange_tools, preprocess_tools, monitor_layout_tools, actionability_tools, element_parse_tools, hsv_segment_tools, text_regions_tools, edge_lines_tools, expect_poll_tools, - locator_chain_tools, rich_clipboard_tools, plugin_sdk_tools, governance_tools, + locator_chain_tools, rich_clipboard_tools, img_histogram_tools, + plugin_sdk_tools, governance_tools, credential_lease_tools, egress_tools, approval_testing_tools, trajectory_eval_tools, compliance_tools, agent_trace_tools, video_report_tools, fuzzy_tools, artifact_store_tools, image_dedup_tools, diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index b6378854..4ab87274 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2247,6 +2247,17 @@ def get_clipboard_html(): return _get_clipboard_html() +def image_histogram(source=None, bins=32, space="hsv", region=None): + from je_auto_control.utils.executor.action_executor import _image_histogram + return _image_histogram(source, bins, space, region) + + +def histogram_changed(reference, current=None, method="correlation", + threshold=0.9, space="hsv", region=None): + from je_auto_control.utils.executor.action_executor import _histogram_changed + return _histogram_changed(reference, current, method, threshold, space, region) + + def detect_drift(reference, current, threshold=0.25, bins=10): from je_auto_control.utils.executor.action_executor import _detect_drift return _detect_drift(reference, current, threshold, bins) diff --git a/test/unit_test/headless/test_img_histogram_batch.py b/test/unit_test/headless/test_img_histogram_batch.py new file mode 100644 index 00000000..906bab4e --- /dev/null +++ b/test/unit_test/headless/test_img_histogram_batch.py @@ -0,0 +1,80 @@ +"""Headless tests for colour-histogram fingerprint / change detection. No Qt.""" +import pytest + +import je_auto_control as ac + +np = pytest.importorskip("numpy") +pytest.importorskip("cv2") + +from je_auto_control.utils.img_histogram import ( # noqa: E402 + compare_histograms, histogram_changed, image_histogram, +) + + +def _palette_a(): + img = np.zeros((60, 80, 3), dtype=np.uint8) + img[:, :40] = (200, 0, 0) # red | green + img[:, 40:] = (0, 200, 0) + return img + + +def _palette_b(): + img = np.zeros((60, 80, 3), dtype=np.uint8) + img[:, :40] = (0, 0, 200) # blue | yellow (same shapes, new palette) + img[:, 40:] = (200, 200, 0) + return img + + +def test_histogram_length_per_channel(): + assert len(image_histogram(_palette_a(), bins=32, space="hsv")) == 96 + assert len(image_histogram(_palette_a(), bins=16, space="gray")) == 16 + + +def test_identical_correlation_is_one(): + hist = image_histogram(_palette_a()) + assert compare_histograms(hist, hist) == pytest.approx(1.0) + + +def test_different_palette_lowers_correlation(): + score = compare_histograms(image_histogram(_palette_a()), + image_histogram(_palette_b())) + assert score < 0.9 + + +def test_changed_detects_palette_shift(): + assert histogram_changed(_palette_a(), _palette_b()) is True + assert histogram_changed(_palette_a(), _palette_a().copy()) is False + + +def test_distance_method_semantics(): + score = compare_histograms(image_histogram(_palette_a()), + image_histogram(_palette_b()), + method="bhattacharyya") + assert score > 0.3 + assert histogram_changed(_palette_a(), _palette_b(), method="bhattacharyya", + threshold=0.3) is True + + +def test_unknown_space_and_method_raise(): + with pytest.raises(ValueError): + image_histogram(_palette_a(), space="cmyk") + with pytest.raises(ValueError): + compare_histograms([1.0], [1.0], method="cosine") + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_image_histogram", "AC_histogram_changed"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_image_histogram", "ac_histogram_changed"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_image_histogram", "AC_histogram_changed"} <= specs + + +def test_facade_exports(): + for attr in ("image_histogram", "compare_histograms", "histogram_changed"): + assert hasattr(ac, attr) and attr in ac.__all__