Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions WHATS_NEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## What's new (2026-06-26)

### Colour-Vision-Deficiency Simulation + Collision Check

Check whether your red/green status colours are distinguishable to colour-blind users. Full reference: [`docs/source/Eng/doc/new_features/v214_features_doc.rst`](docs/source/Eng/doc/new_features/v214_features_doc.rst).

- **`simulate_cvd` / `colors_collide` / `color_distance`** (`AC_simulate_cvd`, `AC_colors_collide`): status UIs lean on colour (green "ok" vs red "error"), but for the ~8% of men with a colour-vision deficiency those can be indistinguishable — and nothing in the framework could check it. `simulate_cvd` maps an RGB colour through a dichromat simulation matrix (protanopia/deuteranopia/tritanopia) at a given `severity`; `colors_collide` simulates two colours and reports whether they become confusable (a perceptual `redmean` distance below `threshold`); `color_distance` is the underlying metric. Pure standard library — no numpy/OpenCV, operating on plain RGB tuples, fully testable. First feature of the ROUND-15 perception lane. No `PySide6`.

### Wait Until the App Is Idle

Hold off the next click until the busy/wait cursor settles — don't act mid-churn. Full reference: [`docs/source/Eng/doc/new_features/v213_features_doc.rst`](docs/source/Eng/doc/new_features/v213_features_doc.rst).
Expand Down
48 changes: 48 additions & 0 deletions docs/source/Eng/doc/new_features/v214_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
Colour-Vision-Deficiency Simulation + Collision Check
=====================================================

Status UIs lean on colour — a green "ok" vs a red "error" dot, a colour-coded
chart legend. For the ~8% of men with a colour-vision deficiency (CVD) those can
be indistinguishable, and nothing in the framework could check it.
``cvd_simulate`` adds the two primitives an accessibility / design check needs.

* :func:`simulate_cvd` — map an ``(r, g, b)`` colour through a dichromat
simulation matrix (``protanopia`` / ``deuteranopia`` / ``tritanopia``) at a
given ``severity`` (0 = unaffected, 1 = full dichromacy).
* :func:`colors_collide` — simulate two colours under a CVD type and report
whether they become too similar to tell apart (a perceptual ``redmean``
distance below ``threshold``).
* :func:`color_distance` — the underlying ``redmean`` colour-difference metric.

Pure standard library — no numpy / OpenCV — operating on plain RGB tuples, so it
is fully testable. Imports no ``PySide6``.

Headless API
------------

.. code-block:: python

from je_auto_control import simulate_cvd, colors_collide

# How does the "error red" look to a deuteranope?
simulate_cvd((220, 40, 40), "deuteranopia") # -> (r, g, b)

# Are my ok-green and error-red distinguishable for them?
report = colors_collide((60, 200, 60), (220, 60, 60), kind="deuteranopia")
report["collide"] # True if the two are confusable
report["distance"] # the perceptual distance after simulation

``simulate_cvd`` accepts friendly aliases (``protan`` / ``deutan`` / ``tritan``,
or ``red`` / ``green`` / ``blue``). ``severity`` interpolates between the
original colour and the full dichromat simulation, for the milder anomalous
trichromacies. ``colors_collide`` returns ``{collide, distance, kind, severity,
simulated_left, simulated_right}``.

Executor commands
-----------------

``AC_simulate_cvd`` (``rgb`` ``[r, g, b]`` + ``kind`` / ``severity`` →
``{rgb}``) and ``AC_colors_collide`` (``left`` / ``right`` ``[r, g, b]`` +
``kind`` / ``severity`` / ``threshold`` → the report). RGB inputs accept a JSON
list. They are the matching read-only ``ac_*`` MCP tools and Script Builder
commands under **Image**.
42 changes: 42 additions & 0 deletions docs/source/Zh/doc/new_features/v214_features_doc.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
色覺辨認障礙模擬 + 碰撞檢查
==========================

狀態 UI 仰賴顏色——綠色「正常」對紅色「錯誤」的圓點、以顏色編碼的圖表圖例。對約 8% 有色覺辨認障礙
(CVD)的男性而言,這些可能難以分辨,而框架原本無從檢查。``cvd_simulate`` 補上無障礙 / 設計檢查
所需的兩個原語。

* :func:`simulate_cvd` ——把 ``(r, g, b)`` 顏色透過二色覺模擬矩陣(``protanopia`` /
``deuteranopia`` / ``tritanopia``)在給定 ``severity``(0 = 不受影響,1 = 完全二色覺)下映射。
* :func:`colors_collide` ——在某 CVD 類型下模擬兩個顏色,並回報它們是否變得太相似而難以區分
(模擬後的感知 ``redmean`` 距離低於 ``threshold``)。
* :func:`color_distance` ——底層的 ``redmean`` 色差度量。

純標準函式庫——不需 numpy / OpenCV——以單純的 RGB tuple 運作,故能完整測試。不匯入 ``PySide6``。

無頭 API
--------

.. code-block:: python

from je_auto_control import simulate_cvd, colors_collide

# 「錯誤紅」在綠色弱者眼中看起來如何?
simulate_cvd((220, 40, 40), "deuteranopia") # -> (r, g, b)

# 我的正常綠與錯誤紅對他們是否可區分?
report = colors_collide((60, 200, 60), (220, 60, 60), kind="deuteranopia")
report["collide"] # 若兩者易混淆則為 True
report["distance"] # 模擬後的感知距離

``simulate_cvd`` 接受友善別名(``protan`` / ``deutan`` / ``tritan``,或
``red`` / ``green`` / ``blue``)。``severity`` 在原色與完全二色覺模擬之間插值,
用於較輕微的異常三色覺。``colors_collide`` 回傳 ``{collide, distance, kind, severity,
simulated_left, simulated_right}``。

執行器指令
----------

``AC_simulate_cvd``(``rgb`` ``[r, g, b]`` 加上 ``kind`` / ``severity`` →
``{rgb}``)與 ``AC_colors_collide``(``left`` / ``right`` ``[r, g, b]`` 加上
``kind`` / ``severity`` / ``threshold`` → 報告)。RGB 輸入接受 JSON 清單。皆以對應的唯讀
``ac_*`` MCP 工具及 Script Builder 指令(位於 **Image** 分類下)形式提供。
5 changes: 5 additions & 0 deletions je_auto_control/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,10 @@
from je_auto_control.utils.ensure_state import ensure_state, ensure_toggle
# Wait until an application stops being busy before the next step
from je_auto_control.utils.app_idle import idle_point, wait_until_app_idle
# Colour-vision-deficiency simulation + colour-collision check
from je_auto_control.utils.cvd_simulate import (
color_distance, colors_collide, simulate_cvd,
)
# Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set
from je_auto_control.utils.clipboard_rich_formats import (
build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv,
Expand Down Expand Up @@ -1755,6 +1759,7 @@ def start_autocontrol_gui(*args, **kwargs):
"recommend_timeout", "timeout_stats",
"ensure_state", "ensure_toggle",
"wait_until_app_idle", "idle_point",
"simulate_cvd", "colors_collide", "color_distance",
"build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows",
"set_clipboard_rtf", "get_clipboard_rtf",
"set_clipboard_csv", "get_clipboard_csv",
Expand Down
25 changes: 25 additions & 0 deletions je_auto_control/gui/script_builder/command_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -4522,6 +4522,31 @@
),
description="Index where a busy/idle series first settles idle.",
))
specs.append(CommandSpec(
"AC_simulate_cvd", "Image", "Simulate Colour-Vision Deficiency",
fields=(
FieldSpec("rgb", FieldType.STRING, placeholder="[r, g, b]"),

Check failure on line 4528 in je_auto_control/gui/script_builder/command_schema.py

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Define a constant instead of duplicating this literal "[r, g, b]" 3 times.

See more on https://sonarcloud.io/project/issues?id=Integration-Automation_AutoControlGUI&issues=AZ8BIo6i901sLPN1crVX&open=AZ8BIo6i901sLPN1crVX&pullRequest=442
FieldSpec("kind", FieldType.STRING, optional=True,
default="deuteranopia",
placeholder="protanopia / deuteranopia / tritanopia"),
FieldSpec("severity", FieldType.FLOAT, optional=True, default=1.0),
),
description="Map an RGB colour through a CVD simulation matrix.",
))
specs.append(CommandSpec(
"AC_colors_collide", "Image", "Colours Collide Under CVD",
fields=(
FieldSpec("left", FieldType.STRING, placeholder="[r, g, b]"),
FieldSpec("right", FieldType.STRING, placeholder="[r, g, b]"),
FieldSpec("kind", FieldType.STRING, optional=True,
default="deuteranopia",
placeholder="protanopia / deuteranopia / tritanopia"),
FieldSpec("severity", FieldType.FLOAT, optional=True, default=1.0),
FieldSpec("threshold", FieldType.FLOAT, optional=True,
default=40.0),
),
description="Whether two colours become confusable under a CVD type.",
))
specs.append(CommandSpec(
"AC_normalize_ext", "Shell", "Normalize Extension",
fields=(
Expand Down
6 changes: 6 additions & 0 deletions je_auto_control/utils/cvd_simulate/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""Simulate colour-vision deficiency and flag colours that collide under it."""
from je_auto_control.utils.cvd_simulate.cvd_simulate import (
CVD_KINDS, color_distance, colors_collide, simulate_cvd,
)

__all__ = ["simulate_cvd", "colors_collide", "color_distance", "CVD_KINDS"]
119 changes: 119 additions & 0 deletions je_auto_control/utils/cvd_simulate/cvd_simulate.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Simulate colour-vision deficiency and flag colours that collide under it.

Status UIs lean on colour — a green "ok" vs a red "error" dot, a colour-coded
chart legend. For the ~8% of men with a colour-vision deficiency (CVD) those can
be indistinguishable, and nothing in the framework could check it. ``cvd_simulate``
adds the two primitives an accessibility / design check needs:

* :func:`simulate_cvd` — map an ``(r, g, b)`` colour through a dichromat
simulation matrix (protanopia / deuteranopia / tritanopia) at a given
``severity`` (0 = unaffected, 1 = full dichromacy).
* :func:`colors_collide` — simulate two colours under a CVD type and report
whether they become too similar to tell apart (a perceptual ``redmean``
distance below ``threshold``).

Pure standard library (no numpy / OpenCV) — operates on plain RGB tuples, so it
is fully testable. Imports no ``PySide6``.
"""
import math
from typing import Any, Dict, List, Sequence, Tuple

RGB = Tuple[int, int, int]

# Dichromat simulation matrices (sRGB-space approximation, Brettel/Viénot
# lineage as used by daltonize tooling). Applied at severity 1.0; lower
# severities interpolate toward the identity.
_MATRICES: Dict[str, List[List[float]]] = {
"protanopia": [[0.567, 0.433, 0.000],
[0.558, 0.442, 0.000],
[0.000, 0.242, 0.758]],
"deuteranopia": [[0.625, 0.375, 0.000],
[0.700, 0.300, 0.000],
[0.000, 0.300, 0.700]],
"tritanopia": [[0.950, 0.050, 0.000],
[0.000, 0.433, 0.567],
[0.000, 0.475, 0.525]],
}

# Friendly aliases for the canonical CVD kinds.
_ALIASES = {
"protan": "protanopia", "protanope": "protanopia", "red": "protanopia",
"deuter": "deuteranopia", "deutan": "deuteranopia",
"deuteranope": "deuteranopia", "green": "deuteranopia",
"tritan": "tritanopia", "tritanope": "tritanopia", "blue": "tritanopia",
}

CVD_KINDS = tuple(_MATRICES)


def _canonical_kind(kind: str) -> str:
"""Resolve a CVD kind name / alias to its canonical key."""
key = str(kind).strip().lower()
canonical = _ALIASES.get(key, key)
if canonical not in _MATRICES:
raise ValueError(f"unknown CVD kind: {kind!r}")
return canonical


def _clamp_byte(value: float) -> int:
"""Clamp a channel value to an integer in ``[0, 255]``."""
return max(0, min(255, int(round(value))))


def simulate_cvd(rgb: Sequence[float], kind: str = "deuteranopia",
severity: float = 1.0) -> RGB:
"""Return ``rgb`` as seen under ``kind`` colour-vision deficiency.

``severity`` interpolates between the original colour (0) and the full
dichromat simulation (1). ``rgb`` channels are ``0..255``.
"""
matrix = _MATRICES[_canonical_kind(kind)]
strength = max(0.0, min(1.0, float(severity)))
channels = [float(rgb[0]), float(rgb[1]), float(rgb[2])]
result = []
for index in range(3):
row = matrix[index]
simulated = row[0] * channels[0] + row[1] * channels[1] \
+ row[2] * channels[2]
blended = channels[index] * (1.0 - strength) + simulated * strength
result.append(_clamp_byte(blended))
return result[0], result[1], result[2]


def color_distance(left: Sequence[float], right: Sequence[float]) -> float:
"""Perceptual ``redmean`` distance between two RGB colours (pure).

A low-cost approximation of perceived colour difference that weights the
channels by the average red level.
"""
red_mean = (float(left[0]) + float(right[0])) / 2.0
delta_r = float(left[0]) - float(right[0])
delta_g = float(left[1]) - float(right[1])
delta_b = float(left[2]) - float(right[2])
return math.sqrt((2 + red_mean / 256) * delta_r * delta_r
+ 4 * delta_g * delta_g
+ (2 + (255 - red_mean) / 256) * delta_b * delta_b)


def colors_collide(left: Sequence[float], right: Sequence[float], *,
kind: str = "deuteranopia", severity: float = 1.0,
threshold: float = 40.0) -> Dict[str, Any]:
"""Report whether two colours become confusable under ``kind`` CVD.

Simulates both colours and compares them with :func:`color_distance`;
``collide`` is ``True`` when that distance is below ``threshold``. Returns
``{collide, distance, kind, severity, simulated_left, simulated_right}``.
"""
canonical = _canonical_kind(kind)
strength = max(0.0, min(1.0, float(severity)))
sim_left = simulate_cvd(left, canonical, strength)
sim_right = simulate_cvd(right, canonical, strength)
distance = color_distance(sim_left, sim_right)
return {
"collide": distance < float(threshold),
"distance": round(distance, 3),
"kind": canonical,
"severity": strength,
"simulated_left": list(sim_left),
"simulated_right": list(sim_right),
}
26 changes: 26 additions & 0 deletions je_auto_control/utils/executor/action_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -2839,6 +2839,30 @@ def _idle_point(busy_samples: Any, quiet_samples: Any = 3) -> Dict[str, Any]:
return {"index": idle_point(samples, quiet_samples=int(quiet_samples))}


def _coerce_rgb(value: Any) -> tuple:
"""Normalise an RGB argument (JSON '[r,g,b]' string / list) to (r, g, b)."""
seq = _coerce_list(value) if isinstance(value, str) else list(value)
return (int(seq[0]), int(seq[1]), int(seq[2]))


def _simulate_cvd(rgb: Any, kind: Any = "deuteranopia",
severity: Any = 1.0) -> Dict[str, Any]:
"""Adapter: map an RGB colour through a CVD simulation matrix (pure)."""
from je_auto_control.utils.cvd_simulate import simulate_cvd
result = simulate_cvd(_coerce_rgb(rgb), str(kind), float(severity))
return {"rgb": list(result)}


def _colors_collide(left: Any, right: Any, kind: Any = "deuteranopia",
severity: Any = 1.0, threshold: Any = 40.0
) -> Dict[str, Any]:
"""Adapter: whether two colours become confusable under a CVD type (pure)."""
from je_auto_control.utils.cvd_simulate import colors_collide
return colors_collide(_coerce_rgb(left), _coerce_rgb(right),
kind=str(kind), severity=float(severity),
threshold=float(threshold))


def _normalize_ext(target: str) -> Dict[str, Any]:
"""Adapter: the lowercased extension of a path / bare ext (pure)."""
from je_auto_control.utils.file_assoc import normalize_ext
Expand Down Expand Up @@ -6870,6 +6894,8 @@ def __init__(self):
"AC_ensure_field_value": _ensure_field_value,
"AC_wait_until_app_idle": _wait_until_app_idle,
"AC_idle_point": _idle_point,
"AC_simulate_cvd": _simulate_cvd,
"AC_colors_collide": _colors_collide,
"AC_normalize_ext": _normalize_ext,
"AC_file_association": _file_association,
"AC_get_control_text": _get_control_text,
Expand Down
30 changes: 30 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -4000,6 +4000,36 @@ def img_histogram_tools() -> List[MCPTool]:
handler=h.most_salient,
annotations=READ_ONLY,
),
MCPTool(
name="ac_simulate_cvd",
description=("Map an 'rgb' colour [r,g,b] through a colour-vision-"
"deficiency simulation (kind=protanopia/deuteranopia/"
"tritanopia, severity 0..1). Returns {rgb}."),
input_schema=schema({"rgb": {"type": "array",
"items": {"type": "integer"}},
"kind": {"type": "string"},
"severity": {"type": "number"}},
required=["rgb"]),
handler=h.simulate_cvd,
annotations=READ_ONLY,
),
MCPTool(
name="ac_colors_collide",
description=("Whether two colours 'left'/'right' [r,g,b] become "
"confusable under a CVD 'kind' (distance below "
"'threshold'). Returns {collide, distance, kind, "
"severity, simulated_left, simulated_right}."),
input_schema=schema({"left": {"type": "array",
"items": {"type": "integer"}},
"right": {"type": "array",
"items": {"type": "integer"}},
"kind": {"type": "string"},
"severity": {"type": "number"},
"threshold": {"type": "number"}},
required=["left", "right"]),
handler=h.colors_collide,
annotations=READ_ONLY,
),
]


Expand Down
11 changes: 11 additions & 0 deletions je_auto_control/utils/mcp_server/tools/_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,6 +733,17 @@ def idle_point(busy_samples, quiet_samples=3):
return _idle_point(busy_samples, quiet_samples)


def simulate_cvd(rgb, kind="deuteranopia", severity=1.0):
from je_auto_control.utils.executor.action_executor import _simulate_cvd
return _simulate_cvd(rgb, kind, severity)


def colors_collide(left, right, kind="deuteranopia", severity=1.0,
threshold=40.0):
from je_auto_control.utils.executor.action_executor import _colors_collide
return _colors_collide(left, right, kind, severity, threshold)


def normalize_ext(target):
from je_auto_control.utils.executor.action_executor import _normalize_ext
return _normalize_ext(target)
Expand Down
Loading
Loading