diff --git a/README/WHATS_NEW_zh-CN.md b/README/WHATS_NEW_zh-CN.md index 4e70958d..d895d39f 100644 --- a/README/WHATS_NEW_zh-CN.md +++ b/README/WHATS_NEW_zh-CN.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 感知式(YIQ)图像比对含反锯齿抑制 + +会忽略反锯齿边缘的视觉回归比对。完整参考:[`docs/source/Zh/doc/new_features/v149_features_doc.rst`](../docs/source/Zh/doc/new_features/v149_features_doc.rst)。 + +- **`perceptual_diff` / `assert_perceptual`**(`AC_perceptual_diff`):`image_difference` 计算原始逐通道差、`ssim_compare` 是整体分数——两者都未使用感知式度量也不忽略反锯齿(视觉比对误报的首要来源)。本功能在 YIQ 空间比较(pixelmatch 的色彩度量),并预设以形态学开运算移除单像素反锯齿细边差异,只计算实心变化(`include_aa=True` 保留)。返回 `{diff_pixels, diff_ratio, regions}`;`assert_perceptual` / `max_diff_ratio` 把关回归测试。可注入图像配对 → 无头可测(1px 细边 → 0、实心区块 → 计入)。 + ## 本次更新 (2026-06-23) — 软性断言(汇整所有失败) 验证很多项,一次报告每一个失败。完整参考:[`docs/source/Zh/doc/new_features/v148_features_doc.rst`](../docs/source/Zh/doc/new_features/v148_features_doc.rst)。 diff --git a/README/WHATS_NEW_zh-TW.md b/README/WHATS_NEW_zh-TW.md index b23336e5..851c7e13 100644 --- a/README/WHATS_NEW_zh-TW.md +++ b/README/WHATS_NEW_zh-TW.md @@ -1,5 +1,11 @@ # 本次更新 — AutoControl +## 本次更新 (2026-06-23) — 感知式(YIQ)影像比對含反鋸齒抑制 + +會忽略反鋸齒邊緣的視覺回歸比對。完整參考:[`docs/source/Zh/doc/new_features/v149_features_doc.rst`](../docs/source/Zh/doc/new_features/v149_features_doc.rst)。 + +- **`perceptual_diff` / `assert_perceptual`**(`AC_perceptual_diff`):`image_difference` 計算原始逐通道差、`ssim_compare` 是整體分數——兩者都未使用感知式度量也不忽略反鋸齒(視覺比對誤報的首要來源)。本功能在 YIQ 空間比較(pixelmatch 的色彩度量),並預設以形態學開運算移除單像素反鋸齒細邊差異,只計算實心變化(`include_aa=True` 保留)。回傳 `{diff_pixels, diff_ratio, regions}`;`assert_perceptual` / `max_diff_ratio` 把關回歸測試。可注入影像配對 → 無頭可測(1px 細邊 → 0、實心區塊 → 計入)。 + ## 本次更新 (2026-06-23) — 軟性斷言(彙整所有失敗) 驗證很多項,一次回報每一個失敗。完整參考:[`docs/source/Zh/doc/new_features/v148_features_doc.rst`](../docs/source/Zh/doc/new_features/v148_features_doc.rst)。 diff --git a/WHATS_NEW.md b/WHATS_NEW.md index 6e664e2d..07848db3 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,5 +1,11 @@ # What's New — AutoControl +## What's new (2026-06-23) — Perceptual (YIQ) Image Diff with Anti-Alias Suppression + +Visual-regression diffing that ignores anti-aliased edges. Full reference: [`docs/source/Eng/doc/new_features/v149_features_doc.rst`](docs/source/Eng/doc/new_features/v149_features_doc.rst). + +- **`perceptual_diff` / `assert_perceptual`** (`AC_perceptual_diff`): `image_difference` counts raw per-channel deltas and `ssim_compare` is a global score — neither uses a perceptual metric or ignores anti-aliasing, the #1 source of false-positive visual-diff failures. This compares in YIQ space (pixelmatch's colour metric) and, by default, removes thin 1px anti-aliased edge diffs via a morphological open so only solid changes count (`include_aa=True` keeps them). Returns `{diff_pixels, diff_ratio, regions}`; `assert_perceptual` / `max_diff_ratio` gate a regression test. Injectable image pair → headless-testable (a 1px fringe → 0, a solid block → counted). + ## What's new (2026-06-23) — Soft Assertions (Aggregate Failures) Verify many things, report every failure at once. Full reference: [`docs/source/Eng/doc/new_features/v148_features_doc.rst`](docs/source/Eng/doc/new_features/v148_features_doc.rst). diff --git a/docs/source/Eng/doc/new_features/v149_features_doc.rst b/docs/source/Eng/doc/new_features/v149_features_doc.rst new file mode 100644 index 00000000..2ed717df --- /dev/null +++ b/docs/source/Eng/doc/new_features/v149_features_doc.rst @@ -0,0 +1,42 @@ +Perceptual (YIQ) Image Diff with Anti-Alias Suppression +======================================================= + +``visual_regression.image_difference`` counts raw per-channel max-delta pixels and +``ssim_compare`` gives a global structural score. Neither uses a *perceptual* colour +metric, and neither ignores **anti-aliased edges** — the #1 source of false-positive +visual-diff failures across DPI and font-hinting. ``perceptual_diff`` compares pixels +in YIQ space (the pixelmatch colour metric, far closer to human perception than RGB) +and, by default, removes the thin one-pixel edge differences that anti-aliasing +produces (a morphological open), so only *solid* changed regions count. + +Runs on an injectable image pair (ndarray / path / PIL), so it is headless-testable on +synthetic arrays. OpenCV + NumPy come in via ``je_open_cv``; reuses the shared +connected-component helper and RGB loader. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import perceptual_diff, assert_perceptual + + result = perceptual_diff("actual.png", "golden.png", threshold=0.1) + print(result.diff_pixels, result.diff_ratio, result.regions) + + # Gate a visual-regression test (raises if the ratio is exceeded). + assert_perceptual("actual.png", "golden.png", max_diff_ratio=0.01) + +``perceptual_diff`` returns a ``PerceptualDiffResult`` (``diff_pixels``, +``total_pixels``, ``diff_ratio``, and the ``regions`` boxes of the changed clusters). +``threshold`` (0..1) is the pixelmatch sensitivity. ``include_aa=True`` keeps the thin +edge differences instead of suppressing them. ``assert_perceptual`` raises +``AutoControlActionException`` when ``diff_ratio`` exceeds ``max_diff_ratio``. Images of +different sizes raise ``ValueError``. + +Executor command +---------------- + +``AC_perceptual_diff`` (``actual`` / ``expected`` / ``threshold`` / ``include_aa`` / +``max_diff_ratio`` → ``{diff_pixels, total_pixels, diff_ratio, regions}``; raises when +``max_diff_ratio`` is given and exceeded). It is exposed as the MCP tool +``ac_perceptual_diff`` and as a Script Builder command under **Image**. diff --git a/docs/source/Eng/eng_index.rst b/docs/source/Eng/eng_index.rst index cec95c77..9f04af6d 100644 --- a/docs/source/Eng/eng_index.rst +++ b/docs/source/Eng/eng_index.rst @@ -171,6 +171,7 @@ Comprehensive guides for all AutoControl features. doc/new_features/v146_features_doc doc/new_features/v147_features_doc doc/new_features/v148_features_doc + doc/new_features/v149_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/v149_features_doc.rst b/docs/source/Zh/doc/new_features/v149_features_doc.rst new file mode 100644 index 00000000..689e716f --- /dev/null +++ b/docs/source/Zh/doc/new_features/v149_features_doc.rst @@ -0,0 +1,35 @@ +感知式(YIQ)影像比對含反鋸齒抑制 +==================================== + +``visual_regression.image_difference`` 計算原始逐通道最大差像素數,``ssim_compare`` 給出整體結構分數。兩者都未使用 +*感知式*色彩度量,也都不忽略**反鋸齒邊緣**——那是跨 DPI 與字體微調時視覺比對誤報的首要來源。``perceptual_diff`` +在 YIQ 空間比較像素(pixelmatch 的色彩度量,比 RGB 更接近人眼感知),並預設移除反鋸齒造成的單像素細邊差異 +(形態學開運算),因此只計算*實心*變化區域。 + +在可注入的影像配對(ndarray / 路徑 / PIL)上執行,因此可對合成陣列做無頭測試。OpenCV + NumPy 透過 ``je_open_cv`` +引入;沿用共用的連通元件輔助與 RGB 載入器。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import perceptual_diff, assert_perceptual + + result = perceptual_diff("actual.png", "golden.png", threshold=0.1) + print(result.diff_pixels, result.diff_ratio, result.regions) + + # 把關視覺回歸測試(超出比例則拋例外)。 + assert_perceptual("actual.png", "golden.png", max_diff_ratio=0.01) + +``perceptual_diff`` 回傳 ``PerceptualDiffResult``(``diff_pixels``、``total_pixels``、``diff_ratio``,以及變化叢集 +的 ``regions`` 方框)。``threshold``(0..1)是 pixelmatch 靈敏度。``include_aa=True`` 會保留細邊差異而不抑制。 +``assert_perceptual`` 在 ``diff_ratio`` 超過 ``max_diff_ratio`` 時丟出 ``AutoControlActionException``。不同尺寸的 +影像會丟出 ``ValueError``。 + +執行器命令 +---------- + +``AC_perceptual_diff``(``actual`` / ``expected`` / ``threshold`` / ``include_aa`` / ``max_diff_ratio`` → +``{diff_pixels, total_pixels, diff_ratio, regions}``;給定 ``max_diff_ratio`` 且超出時拋例外)。它以 MCP 工具 +``ac_perceptual_diff`` 以及 Script Builder 中 **Image** 分類下的命令提供。 diff --git a/docs/source/Zh/zh_index.rst b/docs/source/Zh/zh_index.rst index ea787926..90a9311e 100644 --- a/docs/source/Zh/zh_index.rst +++ b/docs/source/Zh/zh_index.rst @@ -171,6 +171,7 @@ AutoControl 所有功能的完整使用指南。 doc/new_features/v146_features_doc doc/new_features/v147_features_doc doc/new_features/v148_features_doc + doc/new_features/v149_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 7c23de29..dbffe17b 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -353,6 +353,10 @@ ) # Soft assertions (accumulate checks, raise the aggregate at block end) from je_auto_control.utils.soft_assert import SoftAssertions +# Perceptual (YIQ) image diff with anti-alias edge suppression +from je_auto_control.utils.perceptual_diff import ( + PerceptualDiffResult, assert_perceptual, perceptual_diff, +) # CI workflow annotations (GitHub Actions) from je_auto_control.utils.ci_annotations import ( emit_annotations, format_annotation, @@ -1220,6 +1224,9 @@ def start_autocontrol_gui(*args, **kwargs): "bring_to_front", "send_to_back", "SoftAssertions", + "perceptual_diff", + "assert_perceptual", + "PerceptualDiffResult", "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 2821e82c..4328e3b3 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -553,6 +553,19 @@ def _add_image_specs(specs: List[CommandSpec]) -> None: ), description="Whether anything moved between two frames (+ activity score).", )) + specs.append(CommandSpec( + "AC_perceptual_diff", "Image", "Perceptual Diff (YIQ)", + fields=( + FieldSpec("actual", FieldType.FILE_PATH), + FieldSpec("expected", FieldType.FILE_PATH), + FieldSpec("threshold", FieldType.FLOAT, optional=True, default=0.1, + min_value=0.0, max_value=1.0), + FieldSpec("include_aa", FieldType.BOOL, optional=True, default=False), + FieldSpec("max_diff_ratio", FieldType.FLOAT, optional=True, + min_value=0.0, max_value=1.0), + ), + description="Perceptual image diff that ignores anti-aliased edges.", + )) 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 2bf082da..366d2bd5 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -3784,6 +3784,20 @@ def _soft_assert(checks: Any, raise_on_fail: Any = False) -> Dict[str, Any]: "failures": soft.failures} +def _perceptual_diff(actual: str, expected: str, threshold: Any = 0.1, + include_aa: Any = False, + max_diff_ratio: Any = None) -> Dict[str, Any]: + """Adapter: perceptual (YIQ) image diff with anti-alias suppression.""" + from je_auto_control.utils.perceptual_diff import perceptual_diff + result = perceptual_diff(actual, expected, threshold=float(threshold), + include_aa=bool(include_aa)) + if max_diff_ratio is not None and result.diff_ratio > float(max_diff_ratio): + raise AutoControlActionException( + f"perceptual diff {result.diff_ratio} exceeds {max_diff_ratio}") + return {"diff_pixels": result.diff_pixels, "total_pixels": result.total_pixels, + "diff_ratio": result.diff_ratio, "regions": result.regions} + + def _with_modifiers(modifiers: Any, actions: Any) -> Dict[str, Any]: """Adapter: run nested actions while modifier keys are held down.""" import json @@ -5535,6 +5549,7 @@ def __init__(self): "AC_bring_to_front": _bring_to_front, "AC_send_to_back": _send_to_back, "AC_soft_assert": _soft_assert, + "AC_perceptual_diff": _perceptual_diff, "AC_tile_rect": _tile_rect, "AC_grid_rects": _grid_rects, "AC_cascade_rects": _cascade_rects, diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index 35b69ba5..dfe4b00d 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -3209,6 +3209,28 @@ def soft_assert_tools() -> List[MCPTool]: ] +def perceptual_diff_tools() -> List[MCPTool]: + return [ + MCPTool( + name="ac_perceptual_diff", + description=("Perceptual (YIQ) diff of 'actual' vs 'expected' image " + "paths. By default suppresses anti-aliased edge diffs " + "(include_aa=true to keep them). Returns {diff_pixels, " + "total_pixels, diff_ratio, regions}; pass 'max_diff_ratio' " + "to raise when exceeded. 'threshold' 0..1 sensitivity."), + input_schema=schema({ + "actual": {"type": "string"}, + "expected": {"type": "string"}, + "threshold": {"type": "number"}, + "include_aa": {"type": "boolean"}, + "max_diff_ratio": {"type": "number"}}, + required=["actual", "expected"]), + handler=h.perceptual_diff, + annotations=READ_ONLY, + ), + ] + + def ssim_tools() -> List[MCPTool]: return [ MCPTool( @@ -6716,7 +6738,7 @@ def media_assert_tools() -> List[MCPTool]: hsv_segment_tools, text_regions_tools, edge_lines_tools, expect_poll_tools, locator_chain_tools, rich_clipboard_tools, img_histogram_tools, motion_regions_tools, window_zorder_tools, soft_assert_tools, - plugin_sdk_tools, governance_tools, + perceptual_diff_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 fd16d66d..652b7f12 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -2288,6 +2288,12 @@ def soft_assert(checks, raise_on_fail=False): return _soft_assert(checks, raise_on_fail) +def perceptual_diff(actual, expected, threshold=0.1, include_aa=False, + max_diff_ratio=None): + from je_auto_control.utils.executor.action_executor import _perceptual_diff + return _perceptual_diff(actual, expected, threshold, include_aa, max_diff_ratio) + + 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/je_auto_control/utils/perceptual_diff/__init__.py b/je_auto_control/utils/perceptual_diff/__init__.py new file mode 100644 index 00000000..2a6e7776 --- /dev/null +++ b/je_auto_control/utils/perceptual_diff/__init__.py @@ -0,0 +1,6 @@ +"""Perceptual (YIQ) image diff with anti-alias edge suppression.""" +from je_auto_control.utils.perceptual_diff.perceptual_diff import ( + PerceptualDiffResult, assert_perceptual, perceptual_diff, +) + +__all__ = ["PerceptualDiffResult", "assert_perceptual", "perceptual_diff"] diff --git a/je_auto_control/utils/perceptual_diff/perceptual_diff.py b/je_auto_control/utils/perceptual_diff/perceptual_diff.py new file mode 100644 index 00000000..3de32fb9 --- /dev/null +++ b/je_auto_control/utils/perceptual_diff/perceptual_diff.py @@ -0,0 +1,94 @@ +"""Perceptual (YIQ) image diff with anti-alias edge suppression. + +``visual_regression.image_difference`` counts raw per-channel max-delta pixels and +``ssim`` gives a global structural score. Neither uses a *perceptual* colour metric, and +neither ignores **anti-aliased edges** — the #1 source of false-positive visual-diff +failures across DPI / font-hinting. This compares pixels in YIQ space (the pixelmatch +colour metric, far closer to human perception than RGB) and, by default, suppresses the +thin one-pixel edge differences that anti-aliasing produces via a morphological open, so +only *solid* changed regions count. + +Runs on an injectable image pair (ndarray / path / PIL), so it is headless-testable on +synthetic arrays. OpenCV + NumPy come in via ``je_open_cv``; reuses the shared +connected-component helper and the RGB loader. Imports no ``PySide6``. +""" +from dataclasses import dataclass +from typing import Any, Dict, List + +# Reuse the RGB loader (single source of truth, no copy). +from je_auto_control.utils.color_region.color_region import _to_rgb + +ImageSource = Any +_MAX_YIQ_DELTA = 35215.0 # pixelmatch: max possible YIQ delta for 255 diff + + +@dataclass(frozen=True) +class PerceptualDiffResult: + """The outcome of a perceptual diff: changed-pixel count, ratio and regions.""" + + diff_pixels: int + total_pixels: int + diff_ratio: float + regions: List[Dict[str, Any]] + + +def _yiq_delta(first, second): + """Return the per-pixel squared YIQ colour distance between two RGB float images.""" + weights_y = (0.29889531, 0.58662247, 0.11448223) + weights_i = (0.59597799, -0.27417610, -0.32180189) + weights_q = (0.21147017, -0.52261711, 0.31114694) + + def channel(image, weights): + return (image[..., 0] * weights[0] + image[..., 1] * weights[1] + + image[..., 2] * weights[2]) + + delta_y = channel(first, weights_y) - channel(second, weights_y) + delta_i = channel(first, weights_i) - channel(second, weights_i) + delta_q = channel(first, weights_q) - channel(second, weights_q) + return 0.5053 * delta_y ** 2 + 0.299 * delta_i ** 2 + 0.1957 * delta_q ** 2 + + +def perceptual_diff(actual: ImageSource, expected: ImageSource, *, + threshold: float = 0.1, include_aa: bool = False, + min_area: int = 1) -> PerceptualDiffResult: + """Compare two images perceptually; return the changed pixels, ratio and regions. + + ``threshold`` (0..1) is the pixelmatch sensitivity — higher tolerates more colour + difference before a pixel counts as changed. When ``include_aa`` is False (default) + a morphological open removes thin anti-aliased edge differences so only solid + changes remain. Different-sized images raise ``ValueError``. + """ + import cv2 + import numpy as np + from je_auto_control.utils.cv2_utils.blobs import connected_boxes + first = _to_rgb(actual).astype(np.float64) + second = _to_rgb(expected).astype(np.float64) + if first.shape != second.shape: + raise ValueError(f"images must be the same size: {first.shape} vs " + f"{second.shape}") + max_delta = _MAX_YIQ_DELTA * float(threshold) * float(threshold) + mask = (_yiq_delta(first, second) > max_delta).astype(np.uint8) + if not include_aa: + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel) + diff_pixels = int(np.count_nonzero(mask)) + total = int(mask.size) + regions = connected_boxes(mask * 255, int(min_area)) + return PerceptualDiffResult(diff_pixels, total, + round(diff_pixels / total, 6) if total else 0.0, + regions) + + +def assert_perceptual(actual: ImageSource, expected: ImageSource, *, + threshold: float = 0.1, include_aa: bool = False, + max_diff_ratio: float = 0.0) -> PerceptualDiffResult: + """Like :func:`perceptual_diff` but raise when the diff ratio exceeds ``max_diff_ratio``.""" + from je_auto_control.utils.exception.exceptions import ( + AutoControlActionException) + result = perceptual_diff(actual, expected, threshold=threshold, + include_aa=include_aa) + if result.diff_ratio > float(max_diff_ratio): + raise AutoControlActionException( + f"perceptual diff {result.diff_ratio} exceeds {max_diff_ratio} " + f"({result.diff_pixels} pixels changed)") + return result diff --git a/test/unit_test/headless/test_perceptual_diff_batch.py b/test/unit_test/headless/test_perceptual_diff_batch.py new file mode 100644 index 00000000..7cbe513b --- /dev/null +++ b/test/unit_test/headless/test_perceptual_diff_batch.py @@ -0,0 +1,75 @@ +"""Headless tests for perceptual (YIQ) image diff. No Qt.""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.exception.exceptions import AutoControlActionException + +np = pytest.importorskip("numpy") +pytest.importorskip("cv2") + +from je_auto_control.utils.perceptual_diff import ( # noqa: E402 + PerceptualDiffResult, assert_perceptual, perceptual_diff, +) + + +def _base(): + return np.full((100, 120, 3), 128, dtype=np.uint8) + + +def _block(): + img = _base() + img[30:60, 40:80] = (255, 0, 0) # 40x30 solid change + return img + + +def test_identical_has_no_diff(): + result = perceptual_diff(_base(), _base().copy()) + assert result.diff_pixels == 0 and result.diff_ratio == pytest.approx(0.0) + + +def test_solid_block_is_counted(): + result = perceptual_diff(_base(), _block()) + assert isinstance(result, PerceptualDiffResult) + assert result.diff_pixels == 1200 and len(result.regions) == 1 + assert result.diff_ratio == pytest.approx(0.1) + + +def test_thin_fringe_suppressed_as_antialiasing(): + fringe = _base() + fringe[:, 60:61] = (200, 200, 200) # 1px-wide vertical edge difference + assert perceptual_diff(_base(), fringe, include_aa=False).diff_pixels == 0 + assert perceptual_diff(_base(), fringe, include_aa=True).diff_pixels == 100 + + +def test_threshold_tolerates_small_colour_shift(): + shifted = _base().copy() + shifted[:, :] = (132, 132, 132) # small uniform shift + assert perceptual_diff(_base(), shifted, threshold=0.2).diff_pixels == 0 + + +def test_size_mismatch_raises(): + with pytest.raises(ValueError): + perceptual_diff(_base(), np.zeros((10, 10, 3), dtype=np.uint8)) + + +def test_assert_perceptual_raises_over_budget(): + with pytest.raises(AutoControlActionException): + assert_perceptual(_base(), _block(), max_diff_ratio=0.0) + assert assert_perceptual(_base(), _base().copy()).diff_pixels == 0 + + +# --- wiring --------------------------------------------------------------- + +def test_wiring(): + assert "AC_perceptual_diff" in set(ac.executor.known_commands()) + 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_perceptual_diff" in names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert "AC_perceptual_diff" in specs + + +def test_facade_exports(): + for attr in ("perceptual_diff", "assert_perceptual", "PerceptualDiffResult"): + assert hasattr(ac, attr) and attr in ac.__all__