diff --git a/app/config_io.py b/app/config_io.py index 5fa6781..5fa0341 100644 --- a/app/config_io.py +++ b/app/config_io.py @@ -1,4 +1,4 @@ -"""配置文件 I/O(Step 4 重构)。 +"""配置文件 I/O(Step 4 重构)。 从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app` 以保留原有 `self.xxx` 属性访问不变。 @@ -8,7 +8,13 @@ import json import os import sys -def get_config_path(self): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def get_config_path(self: "PQAutomationApp"): """获取配置文件的完整路径(兼容打包后的程序)""" # 判断是否是打包后的程序 @@ -30,7 +36,7 @@ def get_config_path(self): return config_file -def load_pq_config(self): +def load_pq_config(self: "PQAutomationApp"): """加载PQ配置(兼容打包后的程序)""" try: # 使用 self.config_file(已经是动态路径) @@ -48,7 +54,7 @@ def load_pq_config(self): self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error") -def save_pq_config(self): +def save_pq_config(self: "PQAutomationApp"): """保存PQ配置(兼容打包后的程序)""" try: # 确保目录存在 @@ -61,7 +67,7 @@ def save_pq_config(self): self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error") -def clear_config_file(self): +def clear_config_file(self: "PQAutomationApp"): """清理配置文件(兼容打包后的程序)""" from tkinter import messagebox @@ -82,3 +88,13 @@ def clear_config_file(self): self.log_gui.log(f"配置文件清理失败: {str(e)}", level="error") + + +class ConfigIOMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + get_config_path = get_config_path + load_pq_config = load_pq_config + save_pq_config = save_pq_config + clear_config_file = clear_config_file diff --git a/app/device/connection.py b/app/device/connection.py index f97528e..a4e0e70 100644 --- a/app/device/connection.py +++ b/app/device/connection.py @@ -1,4 +1,4 @@ -"""设备连接管理(UCD323 / CA410)。 +"""设备连接管理(UCD323 / CA410)。 重构目标 --------- @@ -26,6 +26,12 @@ from app.ucd_domain import ConnectionChanged, UcdError from drivers.caSerail import CASerail from drivers.ucd_driver import DeviceInfo +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + if TYPE_CHECKING: from app.ucd_domain import EventBus from drivers.ucd_driver import UCD323Device @@ -231,38 +237,38 @@ class ConnectionController: # 挂接一并删除,让 GUI 直接调用 ``self.connection.xxx``。 -def get_available_ucd_ports(self): +def get_available_ucd_ports(self: "PQAutomationApp"): return self.connection.list_ucd_devices() -def get_available_com_ports(self): +def get_available_com_ports(self: "PQAutomationApp"): return self.connection.list_com_ports() -def refresh_com_ports(self): +def refresh_com_ports(self: "PQAutomationApp"): self.connection.refresh_ports() -def check_com_connections(self): +def check_com_connections(self: "PQAutomationApp"): self.connection.check_all_async() -def update_connection_indicator(self, indicator, connected): +def update_connection_indicator(self: "PQAutomationApp", indicator, connected): indicator.config(bg="green" if connected else "red") -def check_port_connection(self, is_ucd=True): +def check_port_connection(self: "PQAutomationApp", is_ucd=True): """[已弃用] 旗参数反模式;保留仅为兼容旧调用点。""" if is_ucd: return self.connection.connect_ucd(self.ucd_list_var.get()) return self.connection.connect_ca() -def enable_com_widgets(self): +def enable_com_widgets(self: "PQAutomationApp"): self.connection._enable_widgets() -def disconnect_com_connections(self): +def disconnect_com_connections(self: "PQAutomationApp"): self.connection.disconnect_all() @@ -278,3 +284,17 @@ __all__ = [ "enable_com_widgets", "disconnect_com_connections", ] + + +class DeviceConnectionMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + get_available_ucd_ports = get_available_ucd_ports + get_available_com_ports = get_available_com_ports + refresh_com_ports = refresh_com_ports + check_com_connections = check_com_connections + update_connection_indicator = update_connection_indicator + check_port_connection = check_port_connection + enable_com_widgets = enable_com_widgets + disconnect_com_connections = disconnect_com_connections diff --git a/app/plots/plot_accuracy.py b/app/plots/plot_accuracy.py index b060437..18e9abe 100644 --- a/app/plots/plot_accuracy.py +++ b/app/plots/plot_accuracy.py @@ -5,8 +5,14 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_accuracy 原样搬迁 from matplotlib.patches import Rectangle +from typing import TYPE_CHECKING -def plot_accuracy(self, accuracy_data, test_type): +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + + +def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type): """绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma)""" self.accuracy_ax.clear() @@ -319,3 +325,10 @@ def plot_accuracy(self, accuracy_data, test_type): self.accuracy_canvas.draw() self.chart_notebook.select(self.accuracy_chart_frame) + + +class PlotAccuracyMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + plot_accuracy = plot_accuracy diff --git a/app/plots/plot_cct.py b/app/plots/plot_cct.py index 72599f9..b826cba 100644 --- a/app/plots/plot_cct.py +++ b/app/plots/plot_cct.py @@ -1,12 +1,18 @@ -"""CCT / 色度一致性绘制。 +"""CCT / 色度一致性绘制。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。 """ import numpy as np +from typing import TYPE_CHECKING -def plot_cct(self, test_type): +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + + +def plot_cct(self: "PQAutomationApp", test_type): """绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值""" self.cct_fig.clear() @@ -322,3 +328,10 @@ def plot_cct(self, test_type): self.chart_notebook.select(self.cct_chart_frame) self.log_gui.log("xy 色度坐标图绘制完成", level="success") + + +class PlotCctMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + plot_cct = plot_cct diff --git a/app/plots/plot_contrast.py b/app/plots/plot_contrast.py index 5ecbe89..8189a73 100644 --- a/app/plots/plot_contrast.py +++ b/app/plots/plot_contrast.py @@ -5,8 +5,14 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_contrast 原样搬迁 from matplotlib.patches import Rectangle +from typing import TYPE_CHECKING -def plot_contrast(self, contrast_data, test_type): +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + + +def plot_contrast(self: "PQAutomationApp", contrast_data, test_type): """绘制对比度测试结果 - 固定布局版本""" # 清空并重置 @@ -165,3 +171,10 @@ def plot_contrast(self, contrast_data, test_type): self.contrast_canvas.draw() self.chart_notebook.select(self.contrast_chart_frame) + + +class PlotContrastMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + plot_contrast = plot_contrast diff --git a/app/plots/plot_eotf.py b/app/plots/plot_eotf.py index a966775..7ee19c7 100644 --- a/app/plots/plot_eotf.py +++ b/app/plots/plot_eotf.py @@ -1,12 +1,18 @@ -"""EOTF 曲线绘制(HDR)。 +"""EOTF 曲线绘制(HDR)。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。 """ import numpy as np +from typing import TYPE_CHECKING -def plot_eotf(self, L_bar, results_with_eotf_list, test_type): +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + + +def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type): """绘制 EOTF 曲线 + 数据表格(HDR 专用,包含实测亮度)""" # ========== 1. 清空并重置左侧曲线 ========== @@ -146,3 +152,10 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type): pass self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success") + + +class PlotEotfMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + plot_eotf = plot_eotf diff --git a/app/plots/plot_gamma.py b/app/plots/plot_gamma.py index 0864fc4..f8dd016 100644 --- a/app/plots/plot_gamma.py +++ b/app/plots/plot_gamma.py @@ -1,12 +1,18 @@ -"""Gamma 曲线绘制。 +"""Gamma 曲线绘制。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。 """ import numpy as np +from typing import TYPE_CHECKING -def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type): +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + + +def plot_gamma(self: "PQAutomationApp", L_bar, results_with_gamma_list, target_gamma, test_type): """绘制Gamma曲线 + 数据表格(包含实测亮度)""" # ========== 1. 清空并重置左侧曲线 ========== @@ -140,3 +146,10 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type): self.chart_notebook.select(self.gamma_chart_frame) self.log_gui.log("Gamma曲线 + 数据表格绘制完成", level="success") + + +class PlotGammaMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + plot_gamma = plot_gamma diff --git a/app/plots/plot_gamut.py b/app/plots/plot_gamut.py index acbf167..a1249c9 100644 --- a/app/plots/plot_gamut.py +++ b/app/plots/plot_gamut.py @@ -27,6 +27,11 @@ from app.plots.gamut_background import ( get_cie1976_background, ) +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + # ============ 参考色域定义(CIE 1931 xy)============ _REF_GAMUTS_XY = { @@ -193,7 +198,7 @@ def _blit_background(ax, background, bbox): # 主入口 # ============================================================ -def plot_gamut(self, results, coverage, test_type): +def plot_gamut(self: "PQAutomationApp", results, coverage, test_type): """绘制色域图(图像层 + 框架层分离架构)。""" ax_xy = self.gamut_ax_xy @@ -408,3 +413,10 @@ def plot_gamut(self, results, coverage, test_type): self.sync_gamut_toolbar() self.log_gui.log("色域图绘制完成", level="success") + + +class PlotGamutMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + plot_gamut = plot_gamut diff --git a/app/pq/pq_config.py b/app/pq/pq_config.py index 73b2dda..3518446 100644 --- a/app/pq/pq_config.py +++ b/app/pq/pq_config.py @@ -221,6 +221,401 @@ def get_pattern(name: str) -> dict: return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json") +def reload_gray_pattern() -> dict: + """重新从 ``settings/patterns/gray.json`` 加载灰阶 pattern。 + + 原地更新 ``_PATTERN_GRAY``,让 ``PQConfig.default_pattern_gray`` + 与 ``_KNOWN_PATTERNS['gray']`` 等所有现有引用同步生效, + 无需重启程序即可应用新 pattern 列表。 + """ + new_data = _load_pattern_or_empty( + _PATTERNS_DIR / "gray.json", default=_PATTERN_GRAY_FALLBACK + ) + _PATTERN_GRAY.clear() + _PATTERN_GRAY.update(new_data) + return copy.deepcopy(_PATTERN_GRAY) + + +def get_gray_pattern_fallback() -> dict: + """返回硬编码默认 11 点灰阶 pattern 的深拷贝(用于 UI 的"恢复默认")。""" + return copy.deepcopy(_PATTERN_GRAY_FALLBACK) + + +# ============================================================================= +# 灰阶 Pattern 预设管理(settings/patterns/presets/gray/*.json) +# ============================================================================= +# +# 设计要点: +# - 每个预设独立 JSON 文件,文件名(不含 .json)即预设名。 +# - 内置预设以 ``_builtin_`` 前缀命名,并在 _meta.locked=True,UI 禁止删除/改名/覆盖。 +# - 当前激活预设记录在 settings/patterns/presets/_active.json,便于 UI 显示。 +# - 应用某预设 = 把它复制写入 settings/patterns/gray.json + reload_gray_pattern()。 +# - gamma/cct/contrast/eotf 共用同一份 gray 预设(与 runner 现有共享灰阶采集对齐)。 +# + +_PRESETS_DIR = _PATTERNS_DIR / "presets" +_ACTIVE_INDEX_FILE = _PRESETS_DIR / "_active.json" + + +def _gray_presets_dir() -> Path: + p = _PRESETS_DIR / "gray" + p.mkdir(parents=True, exist_ok=True) + return p + + +def _safe_preset_name(name: str) -> str: + """清洗预设名,去掉文件系统危险字符。""" + name = (name or "").strip() + bad = '<>:"/\\|?*\n\r\t' + for ch in bad: + name = name.replace(ch, "_") + return name[:80] or "untitled" + + +def _preset_path(test_kind: str, name: str) -> Path: + if test_kind != "gray": + raise ValueError(f"暂仅支持 test_kind='gray',收到: {test_kind}") + return _gray_presets_dir() / f"{_safe_preset_name(name)}.json" + + +def _load_active_index() -> dict: + try: + with open(_ACTIVE_INDEX_FILE, encoding="utf-8") as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (FileNotFoundError, json.JSONDecodeError): + return {} + + +def _save_active_index(data: dict) -> None: + _PRESETS_DIR.mkdir(parents=True, exist_ok=True) + with open(_ACTIVE_INDEX_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + +def list_presets(test_kind: str = "gray") -> list[dict]: + """ + 列出指定类别下的所有预设。 + + Returns: + list of {name, locked, description, point_count, file_path, generator} + """ + d = _gray_presets_dir() if test_kind == "gray" else None + if d is None: + return [] + items: list[dict] = [] + for fp in sorted(d.glob("*.json")): + try: + data = load_pattern_file(fp) + except (json.JSONDecodeError, OSError): + continue + meta = data.get("_meta", {}) or {} + items.append({ + "name": fp.stem, + "locked": bool(meta.get("locked", False)), + "description": meta.get("description", ""), + "generator": meta.get("generator", ""), + "created": meta.get("created", ""), + "point_count": len(data.get("pattern_params") or []), + "file_path": str(fp), + }) + # 内置 _builtin_ 排前,其余按名字排序 + items.sort(key=lambda x: (not x["name"].startswith("_builtin_"), x["name"])) + return items + + +def load_preset(test_kind: str, name: str) -> dict: + """加载预设的完整 pattern 数据(含 _meta)。""" + return load_pattern_file(_preset_path(test_kind, name)) + + +def save_preset( + test_kind: str, + name: str, + pattern: dict, + *, + description: str = "", + generator: str = "", + locked: bool = False, + overwrite: bool = True, +) -> Path: + """保存预设。若 overwrite=False 且文件已存在或目标为锁定预设则抛错。""" + from datetime import datetime + name = _safe_preset_name(name) + path = _preset_path(test_kind, name) + + if path.exists(): + try: + existing = load_pattern_file(path) + if (existing.get("_meta") or {}).get("locked"): + raise PermissionError(f"预设 '{name}' 已锁定,不可覆盖") + except (json.JSONDecodeError, OSError): + pass + if not overwrite: + raise FileExistsError(f"预设 '{name}' 已存在") + + data = { + "pattern_mode": pattern.get("pattern_mode", "SolidColor"), + "measurement_bit_depth": pattern.get("measurement_bit_depth", 8), + "measurement_max_value": max(0, len(pattern.get("pattern_params") or []) - 1), + "pattern_params": [list(map(int, rgb)) for rgb in (pattern.get("pattern_params") or [])], + "_meta": { + "description": description, + "generator": generator, + "locked": locked, + "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + }, + } + save_pattern_file(path, data) + return path + + +def delete_preset(test_kind: str, name: str) -> None: + """删除预设;锁定预设不可删除。""" + path = _preset_path(test_kind, name) + if not path.exists(): + raise FileNotFoundError(f"预设 '{name}' 不存在") + data = load_pattern_file(path) + if (data.get("_meta") or {}).get("locked"): + raise PermissionError(f"预设 '{name}' 已锁定,不可删除") + path.unlink() + # 若被删的恰是激活预设,清理记录 + idx = _load_active_index() + if idx.get(test_kind) == name: + idx.pop(test_kind, None) + _save_active_index(idx) + + +def rename_preset(test_kind: str, old: str, new: str) -> Path: + """重命名;锁定预设不可改名。""" + src = _preset_path(test_kind, old) + if not src.exists(): + raise FileNotFoundError(f"预设 '{old}' 不存在") + data = load_pattern_file(src) + if (data.get("_meta") or {}).get("locked"): + raise PermissionError(f"预设 '{old}' 已锁定,不可重命名") + dst = _preset_path(test_kind, new) + if dst.exists(): + raise FileExistsError(f"目标预设 '{new}' 已存在") + src.rename(dst) + idx = _load_active_index() + if idx.get(test_kind) == old: + idx[test_kind] = dst.stem + _save_active_index(idx) + return dst + + +def duplicate_preset(test_kind: str, src_name: str, new_name: str) -> Path: + """复制一个预设为新副本(解除锁定)。""" + data = load_preset(test_kind, src_name) + meta = dict(data.get("_meta") or {}) + meta["locked"] = False + meta["description"] = f"复制自 {src_name}" + ( + f";{meta.get('description', '')}" if meta.get("description") else "" + ) + data["_meta"] = meta + return save_preset( + test_kind, + new_name, + data, + description=meta["description"], + generator=meta.get("generator", ""), + locked=False, + overwrite=False, + ) + + +def activate_preset(test_kind: str, name: str) -> dict: + """ + 将指定预设应用为当前 gray pattern: + - 写入 settings/patterns/gray.json(剥离 _meta 以保持原格式) + - reload_gray_pattern() 让运行时立即生效 + - 在 _active.json 记录激活预设名 + """ + data = load_preset(test_kind, name) + clean = { + "pattern_mode": data.get("pattern_mode", "SolidColor"), + "measurement_bit_depth": data.get("measurement_bit_depth", 8), + "measurement_max_value": data.get("measurement_max_value", 0), + "pattern_params": data.get("pattern_params") or [], + } + save_pattern_file(_PATTERNS_DIR / "gray.json", clean) + reload_gray_pattern() + idx = _load_active_index() + idx[test_kind] = _safe_preset_name(name) + _save_active_index(idx) + return clean + + +def get_active_preset_name(test_kind: str = "gray") -> str | None: + """返回当前激活预设名(来自 _active.json);不存在则返回 None。""" + return _load_active_index().get(test_kind) + + +def import_preset_from_file(test_kind: str, src_file, *, name: str | None = None) -> Path: + """从外部 JSON 文件导入为预设。""" + data = load_pattern_file(src_file) + if not data.get("pattern_params"): + raise ValueError("文件中未找到 pattern_params") + preset_name = name or Path(src_file).stem + return save_preset( + test_kind, + preset_name, + data, + description=(data.get("_meta") or {}).get("description", "导入"), + generator=(data.get("_meta") or {}).get("generator", ""), + locked=False, + overwrite=True, + ) + + +def export_preset_to_file(test_kind: str, name: str, dst_file) -> Path: + """将预设导出到指定外部 JSON 文件。""" + data = load_preset(test_kind, name) + save_pattern_file(dst_file, data) + return Path(dst_file) + + +# ---- 内置预设生成器 ---------------------------------------------------------- + +def _gen_even_gray(n: int) -> list[list[int]]: + """N 点等分 (100%→0%)。""" + if n < 2: + n = 2 + out = [] + for i in range(n): + pct = 100.0 - (100.0 / (n - 1)) * i + v = int(round(pct / 100.0 * 255)) + out.append([v, v, v]) + return out + + +def _gen_pq_gray(n: int) -> list[list[int]]: + """在 PQ 编码空间 N 点等分(亮度按 PQ 曲线均匀分布,低端更密集)。 + + 采样规则:取 PQ 信号值从 1.0 到 0.0 等分,转 8-bit RGB 灰阶。 + (PQ 信号本身在感知亮度上即为线性,故"等分编码值"≈"等分感知亮度"。) + """ + if n < 2: + n = 2 + out = [] + for i in range(n): + v_pq = 1.0 - i / (n - 1) + v = int(round(v_pq * 255)) + out.append([v, v, v]) + return out + + +def _gen_gamma_gray(n: int, gamma: float = 2.2) -> list[list[int]]: + """在线性光强空间 N 点等分,再用 gamma 编码到 8-bit 灰阶(暗端更密集)。""" + if n < 2: + n = 2 + out = [] + for i in range(n): + lin = 1.0 - i / (n - 1) # 线性光 1→0 + code = lin ** (1.0 / gamma) # gamma 编码 + v = int(round(code * 255)) + out.append([v, v, v]) + return out + + +_BUILTIN_GRAY_PRESETS = [ + ("_builtin_even_11pt", + "11 点等分 (100%→0%),行业标准灰阶", + "even-11", + _gen_even_gray(11)), + ("_builtin_even_21pt", + "21 点等分 (5% 步长),更精细的 SDR 灰阶", + "even-21", + _gen_even_gray(21)), + ("_builtin_gamma22_17pt", + "17 点 Gamma 2.2 分布(暗端更密集),用于 SDR Gamma 拟合", + "gamma2.2-17", + _gen_gamma_gray(17, 2.2)), + ("_builtin_pq_17pt", + "17 点 PQ 编码等分,用于 HDR EOTF 评估", + "pq-17", + _gen_pq_gray(17)), +] + + +def ensure_builtin_presets() -> None: + """启动时调用:确保内置预设存在;若已存在则跳过(不覆盖用户修改)。 + + 同时迁移:若 presets/ 目录为空但 settings/patterns/gray.json 存在, + 将其作为 ``user_current`` 预设引入,避免用户原有自定义丢失。 + """ + presets_dir = _gray_presets_dir() + for name, desc, gen, params in _BUILTIN_GRAY_PRESETS: + path = presets_dir / f"{name}.json" + if path.exists(): + continue + save_preset( + "gray", + name, + { + "pattern_mode": "SolidColor", + "measurement_bit_depth": 8, + "measurement_max_value": len(params) - 1, + "pattern_params": params, + }, + description=desc, + generator=gen, + locked=True, + ) + + # 迁移:用户原有 gray.json 入库为 user_current(仅在 presets 目录还没有任何用户预设时) + user_presets = [p for p in presets_dir.glob("*.json") if not p.stem.startswith("_builtin_")] + if not user_presets: + gray_file = _PATTERNS_DIR / "gray.json" + if gray_file.exists(): + try: + cur = load_pattern_file(gray_file) + if cur.get("pattern_params"): + save_preset( + "gray", + "user_current", + cur, + description="迁移自原 gray.json", + generator="migrated", + locked=False, + overwrite=False, + ) + idx = _load_active_index() + idx.setdefault("gray", "user_current") + _save_active_index(idx) + except (json.JSONDecodeError, OSError, FileExistsError): + pass + + # 若无激活预设记录,按规则推断(优先匹配当前 gray.json 内容) + idx = _load_active_index() + if "gray" not in idx: + try: + cur = load_pattern_file(_PATTERNS_DIR / "gray.json") + cur_params = cur.get("pattern_params") or [] + except (FileNotFoundError, json.JSONDecodeError, OSError): + cur_params = [] + match: str | None = None + for fp in presets_dir.glob("*.json"): + try: + if (load_pattern_file(fp).get("pattern_params") or []) == cur_params: + match = fp.stem + break + except (json.JSONDecodeError, OSError): + continue + if match: + idx["gray"] = match + _save_active_index(idx) + + +# 自动确保内置预设存在(首次启动会创建文件) +try: + ensure_builtin_presets() +except OSError: + # 启动期目录不可写则跳过;UI 层有兜底 + pass + + class PQConfig: def __init__(self, current_test_type="screen_module"): diff --git a/app/runner/test_runner.py b/app/runner/test_runner.py index b7c1b73..1a16612 100644 --- a/app/runner/test_runner.py +++ b/app/runner/test_runner.py @@ -1,4 +1,4 @@ -"""测试执行(runner)相关逻辑(Step 5 重构)。 +"""测试执行(runner)相关逻辑(Step 5 重构)。 从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app` 以保留原有 `self.xxx` 属性访问不变。 @@ -15,7 +15,13 @@ import numpy as np import algorithm.pq_algorithm as pq_algorithm from app.pq.pq_result import PQResult -def new_pq_results(self, test_type, test_name): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def new_pq_results(self: "PQAutomationApp", test_type, test_name): # 通过 PQResultStore 创建/替换指定 test_type 的结果,并设为当前活跃 self.results.new(test_type, test_name) # 设置配置 @@ -36,7 +42,7 @@ def new_pq_results(self, test_type, test_name): ) -def run_test(self, test_type, test_items): +def run_test(self: "PQAutomationApp", test_type, test_items): """执行测试""" try: self.log_gui.log(f"开始执行{self.get_test_type_name(test_type)}测试", level="info") @@ -63,7 +69,7 @@ def run_test(self, test_type, test_items): self._dispatch_ui(self.on_test_error) -def run_screen_module_test(self, test_items): +def run_screen_module_test(self: "PQAutomationApp", test_items): """执行屏模组性能测试 - 优化版""" self.log_gui.log("执行屏模组性能测试...", level="info") @@ -138,7 +144,7 @@ def run_screen_module_test(self, test_items): self.test_contrast("screen_module", shared_gray_data) -def run_custom_sdr_test(self, test_items): +def run_custom_sdr_test(self: "PQAutomationApp", test_items): """执行客户定制 SDR 测试 - 升级版""" self.log_gui.log("执行客户定制 SDR 测试...", level="info") # 获取信号格式设置 @@ -154,7 +160,7 @@ def run_custom_sdr_test(self, test_items): self._dispatch_ui(self.on_custom_template_test_completed) -def run_sdr_movie_test(self, test_items): +def run_sdr_movie_test(self: "PQAutomationApp", test_items): """执行SDR Movie测试""" self.log_gui.log("执行SDR Movie测试...", level="info") @@ -225,7 +231,7 @@ def run_sdr_movie_test(self, test_items): self.test_color_accuracy("sdr_movie") -def run_hdr_movie_test(self, test_items): +def run_hdr_movie_test(self: "PQAutomationApp", test_items): """执行HDR Movie测试""" self.log_gui.log("执行HDR Movie测试...", level="info") @@ -300,7 +306,7 @@ def run_hdr_movie_test(self, test_items): self.test_color_accuracy("hdr_movie") -def send_fix_pattern(self, mode): +def send_fix_pattern(self: "PQAutomationApp", mode): """发送固定图案并采集数据 - 支持不同测试类型的信号格式""" results = [] @@ -460,7 +466,7 @@ def send_fix_pattern(self, mode): return None -def test_custom_sdr(self): +def test_custom_sdr(self: "PQAutomationApp"): """执行客户定制 SDR 测试 - 升级版""" self.log_gui.log("执行客户定制 SDR 测试...", level="info") results = self.send_fix_pattern("custom") @@ -471,7 +477,7 @@ def test_custom_sdr(self): self.log_gui.log(f"客户模板采集完成,共 {len(results)} 组数据", level="success") -def test_gamut(self, test_type): +def test_gamut(self: "PQAutomationApp", test_type): """测试色域""" self.log_gui.log("开始测试色域...", level="info") self.results.start_test_item("gamut") @@ -629,7 +635,7 @@ def test_gamut(self, test_type): raise -def test_gamma(self, test_type, gray_data=None): +def test_gamma(self: "PQAutomationApp", test_type, gray_data=None): """测试Gamma曲线 Args: @@ -720,7 +726,7 @@ def test_gamma(self, test_type, gray_data=None): raise -def test_eotf(self, test_type, gray_data=None): +def test_eotf(self: "PQAutomationApp", test_type, gray_data=None): """测试 EOTF 曲线(HDR 专用) Args: @@ -803,7 +809,7 @@ def test_eotf(self, test_type, gray_data=None): raise -def test_cct(self, test_type, gray_data=None): +def test_cct(self: "PQAutomationApp", test_type, gray_data=None): """测试色度一致性""" self.log_gui.log("开始测试色度一致性...", level="info") self.results.start_test_item("cct") @@ -843,7 +849,7 @@ def test_cct(self, test_type, gray_data=None): raise -def test_contrast(self, test_type, gray_data=None): +def test_contrast(self: "PQAutomationApp", test_type, gray_data=None): """测试对比度 Args: @@ -907,7 +913,7 @@ def test_contrast(self, test_type, gray_data=None): raise -def test_color_accuracy(self, test_type): +def test_color_accuracy(self: "PQAutomationApp", test_type): """测试色准 - 使用手工实现的 ΔE 2000(应用 Gamma)""" # ========== Gamma 参考值 ========== @@ -1067,7 +1073,7 @@ def test_color_accuracy(self, test_type): self.log_gui.log("色准测试完成", level="success") -def on_test_completed(self): +def on_test_completed(self: "PQAutomationApp"): """测试完成后的UI更新""" self.testing = False self.start_btn.config(state=tk.NORMAL) @@ -1210,7 +1216,7 @@ def on_test_completed(self): messagebox.showinfo("完成", "测试已完成!") -def on_custom_template_test_completed(self): +def on_custom_template_test_completed(self: "PQAutomationApp"): """客户模板测试完成后的UI更新""" self.testing = False self.set_custom_result_table_locked(False) @@ -1231,7 +1237,7 @@ def on_custom_template_test_completed(self): messagebox.showinfo("完成", "客户模板测试已完成!") -def get_current_test_result(self): +def get_current_test_result(self: "PQAutomationApp"): """获取当前测试结果""" test_type = self.test_type_var.get() test_items = self.get_selected_test_items() @@ -1263,7 +1269,7 @@ def get_current_test_result(self): return result -def on_test_error(self): +def on_test_error(self: "PQAutomationApp"): """测试出错后的UI更新""" self.testing = False self.set_custom_result_table_locked(False) @@ -1284,3 +1290,27 @@ def on_test_error(self): messagebox.showerror("错误", "测试过程中发生错误,请查看日志") + + +class TestRunnerMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + new_pq_results = new_pq_results + run_test = run_test + run_screen_module_test = run_screen_module_test + run_custom_sdr_test = run_custom_sdr_test + run_sdr_movie_test = run_sdr_movie_test + run_hdr_movie_test = run_hdr_movie_test + send_fix_pattern = send_fix_pattern + test_custom_sdr = test_custom_sdr + test_gamut = test_gamut + test_gamma = test_gamma + test_eotf = test_eotf + test_cct = test_cct + test_contrast = test_contrast + test_color_accuracy = test_color_accuracy + on_test_completed = on_test_completed + on_custom_template_test_completed = on_custom_template_test_completed + get_current_test_result = get_current_test_result + on_test_error = on_test_error diff --git a/app/tests/local_dimming.py b/app/tests/local_dimming.py index 1736d30..d53a775 100644 --- a/app/tests/local_dimming.py +++ b/app/tests/local_dimming.py @@ -1,4 +1,4 @@ -"""Local Dimming 测试逻辑(应用层)。 +"""Local Dimming 测试逻辑(应用层)。 整合自原 drivers/local_dimming_test.py:窗口图片生成与测试主循环 直接落在本模块,UCD 通用操作通过 SignalService 完成。 @@ -18,6 +18,12 @@ from tkinter import filedialog, messagebox import numpy as np from PIL import Image +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + # -------------------------------------------------------------------------- @@ -87,7 +93,7 @@ def _ensure_window_image(width, height, percentage): # GUI 入口(绑定为 PQAutomationApp 方法) # -------------------------------------------------------------------------- -def start_local_dimming_test(self): +def start_local_dimming_test(self: "PQAutomationApp"): """开始 Local Dimming 测试。""" if not self.ca or not self.signal_service.is_connected: messagebox.showerror("错误", "请先连接 CA410 和 UCD323") @@ -163,7 +169,7 @@ def start_local_dimming_test(self): threading.Thread(target=worker, daemon=True).start() -def update_ld_results(self, results): +def update_ld_results(self: "PQAutomationApp", results): """把批量测试结果填入 Treeview。""" for percentage, x, y, lv, _X, _Y, _Z in results: self.ld_tree.insert( @@ -172,14 +178,14 @@ def update_ld_results(self, results): ) -def stop_local_dimming_test(self): +def stop_local_dimming_test(self: "PQAutomationApp"): """请求停止当前 Local Dimming 测试。""" ev = getattr(self, "ld_stop_event", None) if ev: ev.set() -def send_ld_window(self, percentage): +def send_ld_window(self: "PQAutomationApp", percentage): """发送指定百分比的白色窗口(手动模式)。""" if not self.signal_service.is_connected: messagebox.showwarning("警告", "请先连接 UCD323 设备") @@ -209,7 +215,7 @@ def send_ld_window(self, percentage): threading.Thread(target=send, daemon=True).start() -def measure_ld_luminance(self): +def measure_ld_luminance(self: "PQAutomationApp"): """测量当前显示的亮度并追加一行到 Treeview。""" if not self.ca: messagebox.showwarning("警告", "请先连接 CA410 色度计") @@ -246,7 +252,7 @@ def measure_ld_luminance(self): threading.Thread(target=measure, daemon=True).start() -def clear_ld_records(self): +def clear_ld_records(self: "PQAutomationApp"): """清空 Treeview 中的测试记录。""" for item in self.ld_tree.get_children(): self.ld_tree.delete(item) @@ -255,7 +261,7 @@ def clear_ld_records(self): self.log_gui.log("测试记录已清空", level="info") -def save_local_dimming_results(self): +def save_local_dimming_results(self: "PQAutomationApp"): """把 Treeview 中的全部记录导出为 CSV。""" if len(self.ld_tree.get_children()) == 0: messagebox.showinfo("提示", "没有可保存的数据") @@ -284,3 +290,16 @@ def save_local_dimming_results(self): except Exception as e: self.log_gui.log(f"保存失败: {str(e)}", level="error") messagebox.showerror("错误", f"保存失败: {str(e)}") + + +class LocalDimmingMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + start_local_dimming_test = start_local_dimming_test + update_ld_results = update_ld_results + stop_local_dimming_test = stop_local_dimming_test + send_ld_window = send_ld_window + measure_ld_luminance = measure_ld_luminance + clear_ld_records = clear_ld_records + save_local_dimming_results = save_local_dimming_results diff --git a/app/views/chart_frame.py b/app/views/chart_frame.py index bd0b62d..3da4c97 100644 --- a/app/views/chart_frame.py +++ b/app/views/chart_frame.py @@ -1,4 +1,4 @@ -"""图表框架相关逻辑(Step 3 重构)。 +"""图表框架相关逻辑(Step 3 重构)。 从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app` 以保留原有 `self.xxx` 属性访问不变。 @@ -10,7 +10,13 @@ import matplotlib.pyplot as plt from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from app.views.pq_debug_panel import PQDebugPanel -def init_gamut_chart(self): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def init_gamut_chart(self: "PQAutomationApp"): """初始化色域图表 - 手动设置subplot位置,完全避免重叠""" container = ttk.Frame(self.gamut_chart_frame) container.pack(expand=True, fill=tk.BOTH) @@ -65,7 +71,7 @@ def init_gamut_chart(self): self.gamut_canvas.draw() -def sync_gamut_toolbar(self): +def sync_gamut_toolbar(self: "PQAutomationApp"): """将工具栏参考标准按钮同步为当前测试类型的 ref var 值。""" if not hasattr(self, "_gamut_ref_toolbar_var"): return @@ -80,7 +86,7 @@ def sync_gamut_toolbar(self): self._gamut_ref_toolbar_var.set(getattr(self, attr).get()) -def _on_gamut_toolbar_changed(self, std): +def _on_gamut_toolbar_changed(self: "PQAutomationApp", std): """用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。""" test_type = self.config.current_test_type var_map = { @@ -105,7 +111,7 @@ def _on_gamut_toolbar_changed(self, std): self.recalculate_gamut() -def init_gamma_chart(self): +def init_gamma_chart(self: "PQAutomationApp"): """初始化Gamma曲线图表 - 左侧曲线 + 右侧表格(4列 + 通用说明)""" container = ttk.Frame(self.gamma_chart_frame) container.pack(expand=True, fill=tk.BOTH) @@ -214,7 +220,7 @@ def init_gamma_chart(self): self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98) self.gamma_canvas.draw() -def init_eotf_chart(self): +def init_eotf_chart(self: "PQAutomationApp"): """初始化 EOTF 曲线图表(HDR 专用)- 左侧曲线 + 右侧表格(4列)""" container = ttk.Frame(self.eotf_chart_frame) container.pack(expand=True, fill=tk.BOTH) @@ -319,7 +325,7 @@ def init_eotf_chart(self): self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98) self.eotf_canvas.draw() -def init_cct_chart(self): +def init_cct_chart(self: "PQAutomationApp"): """初始化色度坐标图表 - 正向横坐标,标题居中最上方""" container = ttk.Frame(self.cct_chart_frame) container.pack(expand=True) @@ -364,7 +370,7 @@ def init_cct_chart(self): self.cct_canvas.draw() -def init_contrast_chart(self): +def init_contrast_chart(self: "PQAutomationApp"): """初始化对比度图表 - 固定大小,居中显示""" container = ttk.Frame(self.contrast_chart_frame) container.pack(expand=True) @@ -399,7 +405,7 @@ def init_contrast_chart(self): self.contrast_canvas.draw() -def init_accuracy_chart(self): +def init_accuracy_chart(self: "PQAutomationApp"): """初始化色准图表 - 固定大小,居中显示""" container = ttk.Frame(self.accuracy_chart_frame) container.pack(expand=True) @@ -434,7 +440,7 @@ def init_accuracy_chart(self): self.accuracy_canvas.draw() -def clear_chart(self): +def clear_chart(self: "PQAutomationApp"): """清空所有图表""" # ========== 1. 清空色域图表 ========== @@ -729,7 +735,7 @@ def clear_chart(self): self.accuracy_canvas.draw() -def update_chart_tabs_state(self): +def update_chart_tabs_state(self: "PQAutomationApp"): """根据测试项目复选框状态动态增删图表 Tab(保持规范顺序)。 - 色域 / Gamma 或 EOTF / 色度一致性 / 对比度 / 色准 全部走动态 add/forget @@ -801,7 +807,7 @@ def update_chart_tabs_state(self): if hasattr(self, "log_gui"): self.log_gui.log(f"更新Tab状态失败: {str(e)}", level="error") -def create_result_chart_frame(self): +def create_result_chart_frame(self: "PQAutomationApp"): """创建结果图表区域 - 6个独立Tab(Gamma 和 EOTF 分离)""" # 创建Notebook用于图表切换 self.chart_notebook = ttk.Notebook(self.result_frame) @@ -859,7 +865,7 @@ def create_result_chart_frame(self): # 创建单步调试面板实例 self.debug_panel = PQDebugPanel(self.debug_container, self) -def on_chart_tab_changed(self, event): +def on_chart_tab_changed(self: "PQAutomationApp", event): """Tab切换时的事件处理""" try: self._last_tab_index = self.chart_notebook.index( @@ -868,3 +874,21 @@ def on_chart_tab_changed(self, event): except Exception as e: self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error") + + +class ChartFrameMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + init_gamut_chart = init_gamut_chart + sync_gamut_toolbar = sync_gamut_toolbar + _on_gamut_toolbar_changed = _on_gamut_toolbar_changed + init_gamma_chart = init_gamma_chart + init_eotf_chart = init_eotf_chart + init_cct_chart = init_cct_chart + init_contrast_chart = init_contrast_chart + init_accuracy_chart = init_accuracy_chart + clear_chart = clear_chart + update_chart_tabs_state = update_chart_tabs_state + create_result_chart_frame = create_result_chart_frame + on_chart_tab_changed = on_chart_tab_changed diff --git a/app/views/panel_manager.py b/app/views/panel_manager.py index abada51..b215c19 100644 --- a/app/views/panel_manager.py +++ b/app/views/panel_manager.py @@ -5,7 +5,13 @@ register_panel / show_panel / hide_all_panels —— 在右侧栏不同浮动面 import tkinter as tk -def register_panel(self, panel_name, frame, button, visible_attr): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def register_panel(self: "PQAutomationApp", panel_name, frame, button, visible_attr): """注册一个面板到管理系统""" self.panels[panel_name] = { "frame": frame, @@ -14,7 +20,7 @@ def register_panel(self, panel_name, frame, button, visible_attr): } -def show_panel(self, panel_name): +def show_panel(self: "PQAutomationApp", panel_name): """显示指定面板,隐藏其他所有面板""" if panel_name not in self.panels: return @@ -47,7 +53,7 @@ def show_panel(self, panel_name): self.current_panel = panel_name -def hide_all_panels(self): +def hide_all_panels(self: "PQAutomationApp"): """隐藏所有面板,显示主内容区域""" # 隐藏所有注册的面板 for panel_name, panel_info in self.panels.items(): @@ -70,3 +76,12 @@ def hide_all_panels(self): self.current_panel = None + + +class PanelManagerMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + register_panel = register_panel + show_panel = show_panel + hide_all_panels = hide_all_panels diff --git a/app/views/panels/ai_image_panel.py b/app/views/panels/ai_image_panel.py index fab2396..8d9d4a7 100644 --- a/app/views/panels/ai_image_panel.py +++ b/app/views/panels/ai_image_panel.py @@ -14,13 +14,19 @@ from PIL import Image, ImageTk from app.services import ai_image as _svc +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + # ---------------- 面板创建 ---------------- -def create_ai_image_panel(self): +def create_ai_image_panel(self: "PQAutomationApp"): """创建 AI 图片对话面板,并注册到面板管理。""" frame = ttk.Frame(self.content_frame) self.ai_image_frame = frame @@ -190,12 +196,12 @@ def create_ai_image_panel(self): reload_ai_image_list(self) -def toggle_ai_image_panel(self): +def toggle_ai_image_panel(self: "PQAutomationApp"): """切换 AI 图片面板显隐。""" self.show_panel("ai_image") -def _get_app_base_dir(self) -> str: +def _get_app_base_dir(self: "PQAutomationApp") -> str: """返回应用根目录(settings 的上一级)。""" if getattr(self, "config_file", None): return os.path.dirname(os.path.dirname(self.config_file)) @@ -207,7 +213,7 @@ def _get_app_base_dir(self) -> str: # ---------------- 列表 / 选中 ---------------- -def reload_ai_image_list(self, auto_select_first=True): +def reload_ai_image_list(self: "PQAutomationApp", auto_select_first=True): """重新扫描缓存并刷新列表。 按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``), @@ -289,7 +295,7 @@ def _format_list_label(rec: _svc.AIImageRecord) -> str: return f"{size_tag}{name_line}" -def _on_list_select(self): +def _on_list_select(self: "PQAutomationApp"): sel = self.ai_image_listbox.curselection() if not sel: return @@ -311,7 +317,7 @@ def _on_list_select(self): _select_record(self, rec) -def _select_record(self, rec: _svc.AIImageRecord): +def _select_record(self: "PQAutomationApp", rec: _svc.AIImageRecord): self.ai_image_current = rec self.ai_image_meta_var.set( f"{os.path.basename(rec.image_path)} | {rec.created_at}" @@ -322,7 +328,7 @@ def _select_record(self, rec: _svc.AIImageRecord): # ---------------- 预览绘制 ---------------- -def _redraw_preview(self): +def _redraw_preview(self: "PQAutomationApp"): rec = getattr(self, "ai_image_current", None) canvas = self.ai_image_canvas canvas.delete("all") @@ -347,7 +353,7 @@ def _redraw_preview(self): # ---------------- 发送 / 保存 / 删除 ---------------- -def _start_new_session(self): +def _start_new_session(self: "PQAutomationApp"): """开启新的对话会话,后续生成将使用新的 session_id。""" if getattr(self, "_ai_image_requesting", False): messagebox.showinfo("提示", "请等待当前请求完成") @@ -357,14 +363,14 @@ def _start_new_session(self): reload_ai_image_list(self, auto_select_first=False) -def _session_id_for_row(self, row: int) -> str: +def _session_id_for_row(self: "PQAutomationApp", row: int) -> str: session_map = getattr(self, "_ai_image_row_session_map", None) or [] if row < 0 or row >= len(session_map): return "" return session_map[row] or "" -def _switch_to_session(self, session_id: str, show_message: bool = True, target_record_id: str = ""): +def _switch_to_session(self: "PQAutomationApp", session_id: str, show_message: bool = True, target_record_id: str = ""): sid = (session_id or "").strip() if not sid: return @@ -389,7 +395,7 @@ def _switch_to_session(self, session_id: str, show_message: bool = True, target_ messagebox.showinfo("提示", "已切换到所选历史对话") -def _update_request_progress(self): +def _update_request_progress(self: "PQAutomationApp"): if not getattr(self, "_ai_image_requesting", False): self._ai_image_progress_job = None return @@ -398,7 +404,7 @@ def _update_request_progress(self): self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self)) -def _send_prompt(self): +def _send_prompt(self: "PQAutomationApp"): if getattr(self, "_ai_image_requesting", False): return prompt = self.ai_image_input.get("1.0", tk.END).strip() @@ -439,7 +445,7 @@ def _send_prompt(self): ) -def _set_requesting(self, flag: bool): +def _set_requesting(self: "PQAutomationApp", flag: bool): self._ai_image_requesting = flag try: self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL) @@ -465,7 +471,7 @@ def _set_requesting(self, flag: bool): pass -def _on_request_done(self, record, exc, req_seq): +def _on_request_done(self: "PQAutomationApp", record, exc, req_seq): # 旧请求回调(例如用户已点击停止后)直接忽略 if req_seq != getattr(self, "_ai_image_active_seq", 0): return @@ -493,7 +499,7 @@ def _on_request_done(self, record, exc, req_seq): break -def _stop_request(self): +def _stop_request(self: "PQAutomationApp"): """停止当前生成任务(协作取消:屏蔽后续回调并恢复 UI)。""" if not getattr(self, "_ai_image_requesting", False): return @@ -505,7 +511,7 @@ def _stop_request(self): self.ai_image_status_var.set("已停止生成") -def _save_current(self): +def _save_current(self: "PQAutomationApp"): rec = getattr(self, "ai_image_current", None) if rec is None: messagebox.showinfo("提示", "请先选择一张图片") @@ -526,7 +532,7 @@ def _save_current(self): messagebox.showerror("保存失败", str(exc)) -def _delete_current(self): +def _delete_current(self: "PQAutomationApp"): rec = getattr(self, "ai_image_current", None) if rec is None: messagebox.showinfo("提示", "请先选择一张图片") @@ -537,7 +543,7 @@ def _delete_current(self): reload_ai_image_list(self) -def _rename_current(self): +def _rename_current(self: "PQAutomationApp"): """弹窗让用户修改当前记录的展示标题(保存到侧车 ``title`` 字段,原始 prompt 不变)。""" rec = getattr(self, "ai_image_current", None) if rec is None: @@ -580,7 +586,7 @@ def _rename_current(self): # ---------------- 发送到 UCD ---------------- -def _show_list_context_menu(self, event): +def _show_list_context_menu(self: "PQAutomationApp", event): """在图片列表上显示右键菜单,并根据状态启用/禁用项。""" try: row = self.ai_image_listbox.nearest(event.y) @@ -619,7 +625,7 @@ def _show_list_context_menu(self, event): self.ai_image_menu.grab_release() -def _send_to_ucd(self): +def _send_to_ucd(self: "PQAutomationApp"): """把当前选中的 AI 图片通过 UCD 发送到显示设备。""" rec = getattr(self, "ai_image_current", None) if rec is None: @@ -730,3 +736,29 @@ def _build_ucd_resized_image(image_path: str, target_w: int, target_h: int) -> s resized = img.convert("RGB").resize((target_w, target_h), Image.LANCZOS) resized.save(out_path, format="PNG") return out_path + + +class AIImagePanelMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_ai_image_panel = create_ai_image_panel + toggle_ai_image_panel = toggle_ai_image_panel + _get_app_base_dir = _get_app_base_dir + reload_ai_image_list = reload_ai_image_list + _on_list_select = _on_list_select + _select_record = _select_record + _redraw_preview = _redraw_preview + _start_new_session = _start_new_session + _session_id_for_row = _session_id_for_row + _switch_to_session = _switch_to_session + _update_request_progress = _update_request_progress + _send_prompt = _send_prompt + _set_requesting = _set_requesting + _on_request_done = _on_request_done + _stop_request = _stop_request + _save_current = _save_current + _delete_current = _delete_current + _rename_current = _rename_current + _show_list_context_menu = _show_list_context_menu + _send_to_ucd = _send_to_ucd diff --git a/app/views/panels/calman_panel.py b/app/views/panels/calman_panel.py new file mode 100644 index 0000000..3b1d1ba --- /dev/null +++ b/app/views/panels/calman_panel.py @@ -0,0 +1,987 @@ +"""CALMAN 风格灰阶测试面板(持续演进版)。 + +布局尽量贴近 Calman Grayscale - Multi: +- 顶部暗色四图:DeltaE、RGB Balance 线图、RGB Balance 条图、Gamma Log/Log; +- 中部双行灰阶条:Actual(实测亮度映射)+ Target(目标灰阶),可点击发送图案; +- 底部左:Current Reading + CIE 1931 xy 散点; +- 底部右:按灰阶展开的矩阵表(x/y/Y/Gamma/CCT/DeltaE 等)。 +""" + +from __future__ import annotations + +import datetime +import math +import threading +import time +import tkinter as tk +from tkinter import messagebox +from typing import TYPE_CHECKING + +import ttkbootstrap as ttk +from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg +from matplotlib.figure import Figure + +from app.tests.color_accuracy import calculate_delta_e_2000 + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +# 默认灰阶档位(百分比) +DEFAULT_LEVELS_PCT: list[int] = list(range(0, 101, 5)) + +# 目标白点 D65(CIE 1931) +D65_X = 0.3127 +D65_Y = 0.3290 +TARGET_CCT = 6504 +TARGET_GAMMA = 2.2 +_DARK_BG = "#2f2f2f" +_AX_BG = "#262626" +_FG = "#d8d8d8" +_GRID = "#5b5b5b" + +DE_FORMULAS = ["2000", "94", "76"] + + +def _pct_to_gray_rgb(pct: int) -> tuple[int, int, int]: + value = int(round(pct * 255 / 100)) + return value, value, value + + +def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: + r, g, b = rgb + return f"#{r:02x}{g:02x}{b:02x}" + + +def _contrast_fg(gray_value: int) -> str: + return "#ffffff" if gray_value < 128 else "#000000" + + +def _set_canvas_patch(canvas: tk.Canvas, color: str, text: str) -> None: + """统一设置色块 Canvas 颜色与文字,避免主题对 Label 背景渲染的干扰。""" + gray = int(color[1:3], 16) + canvas.configure(bg=color, highlightbackground="#666666") + canvas.itemconfigure("patch_bg", fill=color, outline=color) + canvas.itemconfigure("patch_text", text=text, fill=_contrast_fg(gray)) + + +def _xy_to_cct_mccamy(x: float, y: float) -> float: + """McCamy 近似公式计算 CCT。对极暗灰阶 (xy 噪声大) 仅做参考。""" + denom = 0.1858 - y + if denom == 0: + return float("nan") + n = (x - 0.3320) / denom + return 437 * n ** 3 + 3601 * n ** 2 + 6861 * n + 5517 + + +def _safe_float(value, fmt="{:.4f}", placeholder="-"): + try: + if value is None or value != value: # NaN + return placeholder + return fmt.format(value) + except Exception: + return placeholder + + +def _xy_to_upvp(x: float, y: float) -> tuple[float, float]: + denom = (-2.0 * x) + (12.0 * y) + 3.0 + if denom == 0: + return float("nan"), float("nan") + up = (4.0 * x) / denom + vp = (9.0 * y) / denom + return up, vp + + +def _xyY_to_rgb_balance(x: float, y: float, big_y: float) -> tuple[float, float, float]: + """把 xyY 近似映射到 RGB 比例,并归一到平均值 100。""" + if y <= 0 or big_y <= 0: + return float("nan"), float("nan"), float("nan") + + big_x = (x * big_y) / y + big_z = ((1.0 - x - y) * big_y) / y + + r = (3.2406 * big_x) + (-1.5372 * big_y) + (-0.4986 * big_z) + g = (-0.9689 * big_x) + (1.8758 * big_y) + (0.0415 * big_z) + b = (0.0557 * big_x) + (-0.2040 * big_y) + (1.0570 * big_z) + + r = max(r, 0.0) + g = max(g, 0.0) + b = max(b, 0.0) + + avg = (r + g + b) / 3.0 + if avg <= 0: + return float("nan"), float("nan"), float("nan") + return (r / avg) * 100.0, (g / avg) * 100.0, (b / avg) * 100.0 + + +def _style_axes(ax, title: str) -> None: + ax.set_title(title, color=_FG, fontsize=9, pad=4) + ax.set_facecolor(_AX_BG) + ax.grid(True, color=_GRID, alpha=0.35, linewidth=0.6) + ax.tick_params(colors=_FG, labelsize=8) + for spine in ax.spines.values(): + spine.set_color("#8a8a8a") + + +def create_calman_panel(self: "PQAutomationApp") -> None: + """创建 CALMAN 风格灰阶测试面板,注册到 panel_manager。""" + self.calman_frame = ttk.Frame(self.content_frame) + self.calman_visible = False + self.calman_levels = list(DEFAULT_LEVELS_PCT) + # level_pct -> dict(pct, x, y, Y, X, Z, cct, gamma, de2000, rgb_r, rgb_g, rgb_b, time) + self.calman_results = {} + self.calman_stop_event = threading.Event() + self.calman_running = False + self.calman_current_level = None + self.calman_last_record = None + self.calman_last_step_seconds = None + + root = ttk.Frame(self.calman_frame, padding=8) + root.pack(fill=tk.BOTH, expand=True) + root.rowconfigure(0, weight=4) + root.rowconfigure(1, weight=0) + root.rowconfigure(2, weight=3) + root.columnconfigure(0, weight=1) + root.columnconfigure(1, weight=0) + + # ---------------------------- 顶部:图表区(暗色) ---------------------------- + chart_frame = ttk.LabelFrame(root, text="Grayscale - Multi", padding=4) + chart_frame.grid(row=0, column=0, sticky=tk.NSEW) + chart_frame.rowconfigure(0, weight=4) + chart_frame.rowconfigure(1, weight=0) + chart_frame.rowconfigure(2, weight=0) + chart_frame.columnconfigure(0, weight=1) + + fig = Figure(figsize=(10.5, 3.4), dpi=90, facecolor=_DARK_BG) + self.calman_fig = fig + self.calman_ax_de = fig.add_subplot(141) + self.calman_ax_rgb_line = fig.add_subplot(142) + self.calman_ax_rgb_bar = fig.add_subplot(143) + self.calman_ax_gamma = fig.add_subplot(144) + fig.subplots_adjust( + left=0.045, right=0.985, top=0.90, bottom=0.18, wspace=0.30 + ) + canvas = FigureCanvasTkAgg(fig, master=chart_frame) + canvas_widget = canvas.get_tk_widget() + canvas_widget.configure(bg=_DARK_BG, highlightthickness=0) + canvas_widget.grid(row=0, column=0, sticky=tk.NSEW) + self.calman_canvas = canvas + + control_row = ttk.Frame(chart_frame) + control_row.grid(row=1, column=0, sticky=tk.EW, pady=(2, 2)) + ttk.Label(control_row, text="dE Formula:").pack(side=tk.LEFT) + self.calman_de_formula_var = tk.StringVar(value="2000") + de_combo = ttk.Combobox( + control_row, + values=DE_FORMULAS, + textvariable=self.calman_de_formula_var, + width=8, + state="readonly", + ) + de_combo.pack(side=tk.LEFT, padx=(4, 10)) + + self.calman_elapsed_var = tk.StringVar(value="Step: -- s | Total: -- s") + ttk.Label( + control_row, + textvariable=self.calman_elapsed_var, + foreground="#d0d0d0", + ).pack(side=tk.LEFT) + + metrics_row = ttk.Frame(chart_frame) + metrics_row.grid(row=2, column=0, sticky=tk.EW, pady=(2, 0)) + metrics_row.columnconfigure((0, 1, 2, 3), weight=1) + self.calman_avg_de_var = tk.StringVar(value="Avg dE2000: --") + self.calman_avg_cct_var = tk.StringVar(value="Avg CCT: --") + self.calman_contrast_var = tk.StringVar(value="Contrast Ratio: --") + self.calman_avg_gamma_var = tk.StringVar(value="Average Gamma: --") + for idx, v in enumerate( + ( + self.calman_avg_de_var, + self.calman_avg_cct_var, + self.calman_contrast_var, + self.calman_avg_gamma_var, + ) + ): + tk.Label( + metrics_row, + textvariable=v, + anchor=tk.CENTER, + fg="#f2f2f2", + bg="#373737", + font=("微软雅黑", 10, "bold"), + ).grid(row=0, column=idx, sticky=tk.EW, padx=2) + + # ---------------------------- 顶部右:按钮列 ---------------------------- + btn_col = ttk.LabelFrame(root, text="操作", padding=6) + btn_col.grid(row=0, column=1, sticky=tk.NS, padx=(8, 0)) + + ttk.Button( + btn_col, + text="停止", + bootstyle="danger", + width=18, + command=lambda: stop_sequence_test(self), + ).pack(fill=tk.X, pady=2) + ttk.Button( + btn_col, + text="测试该色块", + bootstyle="primary", + width=18, + command=lambda: measure_current_patch(self), + ).pack(fill=tk.X, pady=2) + ttk.Button( + btn_col, + text="连续测试列表", + bootstyle="success", + width=18, + command=lambda: start_sequence_test(self), + ).pack(fill=tk.X, pady=2) + ttk.Separator(btn_col, orient="horizontal").pack(fill=tk.X, pady=6) + ttk.Button( + btn_col, + text="清空结果", + bootstyle="warning-outline", + width=18, + command=lambda: clear_results(self), + ).pack(fill=tk.X, pady=2) + + self.calman_status_var = tk.StringVar(value="待机") + ttk.Label( + btn_col, + textvariable=self.calman_status_var, + foreground="#555", + wraplength=150, + justify=tk.LEFT, + ).pack(fill=tk.X, pady=(8, 0)) + + self.calman_progress_var = tk.StringVar(value="0 / 0") + self.calman_progress = ttk.Progressbar( + btn_col, + orient="horizontal", + mode="determinate", + maximum=100, + value=0, + length=160, + ) + self.calman_progress.pack(fill=tk.X, pady=(8, 2)) + ttk.Label(btn_col, textvariable=self.calman_progress_var).pack(anchor=tk.W) + + self.calman_reading_var = tk.StringVar( + value="x: -- y: -- Y: --\nCCT: -- ΔE: --" + ) + ttk.Label( + btn_col, + textvariable=self.calman_reading_var, + font=("Consolas", 9), + foreground="#1f6fb2", + wraplength=160, + justify=tk.LEFT, + ).pack(fill=tk.X, pady=(8, 0)) + + # ---------------------------- 中部:灰阶色块(Target / Actual) ---------------------------- + patch_outer = ttk.LabelFrame(root, text="灰阶色块(点击可直接输出 Pattern)", padding=6) + patch_outer.grid(row=1, column=0, columnspan=2, sticky=tk.EW, pady=(8, 4)) + patch_outer.columnconfigure(0, weight=0) + patch_outer.columnconfigure(1, weight=1) + + lbl_col = ttk.Frame(patch_outer) + lbl_col.grid(row=0, column=0, sticky=tk.NS, padx=(0, 6)) + ttk.Label(lbl_col, text="Actual", width=7).pack(fill=tk.X, pady=(1, 2)) + ttk.Label(lbl_col, text="Target", width=7).pack(fill=tk.X, pady=(1, 2)) + + patch_holder = ttk.Frame(patch_outer) + patch_holder.grid(row=0, column=1, sticky=tk.EW) + patch_holder.columnconfigure(tuple(range(len(self.calman_levels))), weight=1) + + self.calman_patch_cells = [] + self.calman_actual_cells = [] + self.calman_actual_patch_cells = [] + self.calman_target_patch_canvases = [] + self.calman_target_hexes = [] + for idx, pct in enumerate(self.calman_levels): + rgb = _pct_to_gray_rgb(pct) + color = _rgb_to_hex(rgb) + rgb_val = rgb[0] + text_color = _contrast_fg(rgb_val) + self.calman_target_hexes.append(color) + patch_holder.columnconfigure(idx, weight=1, uniform="patch") + + actual_cell = tk.Frame( + patch_holder, + bd=1, + relief="solid", + highlightthickness=1, + highlightbackground="#808080", + cursor="hand2", + ) + actual_cell.grid(row=0, column=idx, padx=1, pady=1, sticky=tk.NSEW) + actual_canvas = tk.Canvas( + actual_cell, + bg=color, + highlightthickness=0, + width=3, + height=16, + ) + actual_canvas.pack(fill=tk.BOTH, expand=True) + actual_canvas.create_rectangle(0, 0, 400, 40, fill=color, outline=color, tags="patch_bg") + actual_canvas.create_text( + 18, + 8, + text=f"{pct}", + fill=text_color, + font=("Consolas", 6, "bold"), + tags="patch_text", + ) + + cell = tk.Frame( + patch_holder, + bd=1, + relief="solid", + highlightthickness=1, + highlightbackground="#9c9c9c", + cursor="hand2", + ) + cell.grid(row=1, column=idx, padx=1, pady=1, sticky=tk.NSEW) + + target_canvas = tk.Canvas( + cell, + bg=color, + highlightthickness=0, + width=3, + height=30, + ) + target_canvas.pack(fill=tk.BOTH, expand=True) + target_canvas.create_rectangle(0, 0, 400, 40, fill=color, outline=color, tags="patch_bg") + target_canvas.create_text( + 18, + 8, + text=f"{pct}", + fill=text_color, + font=("Consolas", 7, "bold"), + tags="patch_text", + ) + + def _bind_click(widget, p=pct): + widget.bind("", lambda _e, pp=p: send_patch(self, pp)) + + for w in (cell, target_canvas): + _bind_click(w) + for w in (actual_cell, actual_canvas): + _bind_click(w) + + self.calman_patch_cells.append(cell) + self.calman_actual_cells.append(actual_cell) + self.calman_actual_patch_cells.append(actual_canvas) + self.calman_target_patch_canvases.append(target_canvas) + + # ---------------------------- 底部:Current Reading + xy + 数据矩阵 ---------------------------- + bottom = ttk.Frame(root) + bottom.grid(row=2, column=0, columnspan=2, sticky=tk.NSEW, pady=(4, 0)) + bottom.columnconfigure(0, weight=0) + bottom.columnconfigure(1, weight=1) + bottom.rowconfigure(0, weight=1) + + left = ttk.LabelFrame(bottom, text="Current Reading", padding=6) + left.grid(row=0, column=0, sticky=tk.NS, padx=(0, 6)) + + self.calman_reading_var.set( + "x: -- y: --\n" + "u': -- v': --\n" + "cd/m²: --\n" + "ΔE2000: --" + ) + tk.Label( + left, + textvariable=self.calman_reading_var, + justify=tk.LEFT, + font=("Consolas", 10), + fg="#e5e5e5", + bg="#323232", + width=22, + padx=4, + pady=4, + ).pack(fill=tk.X) + + xy_fig = Figure(figsize=(2.6, 2.2), dpi=90, facecolor=_DARK_BG) + self.calman_xy_ax = xy_fig.add_subplot(111) + xy_fig.subplots_adjust(left=0.20, right=0.96, top=0.90, bottom=0.18) + xy_canvas = FigureCanvasTkAgg(xy_fig, master=left) + xy_widget = xy_canvas.get_tk_widget() + xy_widget.configure(bg=_DARK_BG, highlightthickness=0) + xy_widget.pack(fill=tk.BOTH, expand=True, pady=(6, 0)) + self.calman_xy_canvas = xy_canvas + + right = ttk.LabelFrame(bottom, text="测量矩阵", padding=4) + right.grid(row=0, column=1, sticky=tk.NSEW) + right.rowconfigure(0, weight=1) + right.rowconfigure(1, weight=0) + right.columnconfigure(0, weight=0) + right.columnconfigure(1, weight=1) + right.columnconfigure(2, weight=0) + + metric_tree = ttk.Treeview( + right, + columns=("metric",), + show="headings", + height=9, + selectmode="none", + ) + metric_tree.heading("metric", text="Metric") + metric_tree.column("metric", width=118, anchor=tk.W, stretch=False) + metric_tree.grid(row=0, column=0, sticky=tk.NS) + + data_columns = [str(p) for p in self.calman_levels] + data_tree = ttk.Treeview( + right, + columns=data_columns, + show="headings", + height=9, + selectmode="none", + ) + for p in self.calman_levels: + cid = str(p) + data_tree.heading(cid, text=cid) + data_tree.column(cid, width=50, anchor=tk.CENTER, stretch=False) + data_tree.grid(row=0, column=1, sticky=tk.NSEW) + + ysb = ttk.Scrollbar(right, orient="vertical", command=lambda *a: _matrix_yview(self, *a)) + ysb.grid(row=0, column=2, sticky=tk.NS) + xsb = ttk.Scrollbar(right, orient="horizontal", command=data_tree.xview) + xsb.grid(row=1, column=1, sticky=tk.EW) + data_tree.configure(xscrollcommand=xsb.set) + + self.calman_metric_tree = metric_tree + self.calman_data_tree = data_tree + self.calman_table_ysb = ysb + self.calman_tree = data_tree + + for widget in (metric_tree, data_tree): + widget.bind("", lambda e: _matrix_mousewheel(self, e)) + + style = ttk.Style() + style.configure("Calman.Treeview", rowheight=22, font=("Consolas", 9)) + style.configure("Calman.Treeview.Heading", font=("微软雅黑", 9, "bold")) + self.calman_metric_tree.configure(style="Calman.Treeview") + self.calman_data_tree.configure(style="Calman.Treeview") + + right.bind("", lambda _e: _adaptive_matrix_columns(self)) + + _refresh_metric_table(self) + _update_target_strip(self) + _update_actual_strip(self) + _redraw_calman_charts(self) + + # 注册到统一面板管理(按钮稍后由 main_layout 注入) + self.register_panel("calman", self.calman_frame, None, "calman_visible") + + +def toggle_calman_panel(self: "PQAutomationApp") -> None: + """切换 CALMAN 灰阶面板显示。""" + self.show_panel("calman") + + +# --------------------------------------------------------------------------- +# 发送 / 测量 +# --------------------------------------------------------------------------- + + +def send_patch(self: "PQAutomationApp", pct: int) -> None: + """点击色块时,发送对应灰阶图案到信号发生器。""" + if not self.signal_service.is_connected: + messagebox.showwarning("提示", "请先连接 UCD323 设备") + return + + rgb_val = int(round(pct * 255 / 100)) + self.calman_current_level = pct + self.calman_status_var.set(f"发送 {pct}%(RGB={rgb_val})...") + _highlight_patch(self, pct) + + def worker(): + try: + self.signal_service.send_solid_rgb((rgb_val, rgb_val, rgb_val)) + self._dispatch_ui( + self.log_gui.log, f"CALMAN: 已发送 {pct}% 灰阶 (RGB={rgb_val})", + "info", + ) + self._dispatch_ui(self.calman_status_var.set, f"{pct}% 已发送") + except Exception as exc: + self._dispatch_ui(self.log_gui.log, f"发送失败: {exc}", "error") + self._dispatch_ui(self.calman_status_var.set, f"发送失败: {exc}") + + threading.Thread(target=worker, daemon=True).start() + + +def _measure_once(self: "PQAutomationApp", pct: int) -> dict | None: + """采集一次 CA410,并组装一条记录(含 CCT/Gamma/ΔE2000)。""" + try: + x, y, lv, X, Y, Z = self.ca.readAllDisplay() + except Exception as exc: + self._dispatch_ui(self.log_gui.log, f"CA410 采集异常: {exc}", "error") + return None + if lv is None: + return None + + # CCT:对很暗的色块意义不大,按阈值过滤 + cct = _xy_to_cct_mccamy(x, y) if lv >= 0.5 else float("nan") + + # Gamma 需要 100% 作为参考亮度 + ref = self.calman_results.get(100, {}).get("Y") + gamma = float("nan") + if ref and ref > 0 and 0 < pct < 100 and lv > 0: + nv = pct / 100.0 + ny = lv / ref + if ny > 0: + try: + gamma = math.log(ny) / math.log(nv) + except (ValueError, ZeroDivisionError): + gamma = float("nan") + + # ΔE:根据下拉框切换公式(当前 94/76 先复用 2000,保留接口) + formula = getattr(self, "calman_de_formula_var", None) + formula_value = formula.get() if formula is not None else "2000" + try: + de = calculate_delta_e_2000(x, y, lv, D65_X, D65_Y) + except Exception: + de = float("nan") + + # 未来接入 76/94 时可在此切换实现。 + if formula_value in ("94", "76"): + pass + + rr, gg, bb = _xyY_to_rgb_balance(x, y, lv) + + return { + "pct": pct, + "x": x, + "y": y, + "Y": lv, + "X": X, + "Z": Z, + "cct": cct, + "gamma": gamma, + "de2000": de, + "rgb_r": rr, + "rgb_g": gg, + "rgb_b": bb, + "time": datetime.datetime.now().strftime("%H:%M:%S"), + } + + +def measure_current_patch(self: "PQAutomationApp") -> None: + """采集当前已发送色块对应的 CA410 数据。""" + if not getattr(self, "ca", None): + messagebox.showwarning("提示", "请先连接 CA410 色度计") + return + pct = self.calman_current_level + if pct is None: + messagebox.showinfo("提示", "请先点击一个灰阶色块发送") + return + + def worker(): + t0 = time.perf_counter() + self._dispatch_ui(self.calman_status_var.set, f"采集 {pct}% 中...") + rec = _measure_once(self, pct) + if rec is None: + self._dispatch_ui(self.calman_status_var.set, "采集失败") + return + step_s = time.perf_counter() - t0 + self.calman_last_step_seconds = step_s + self.calman_results[pct] = rec + self._dispatch_ui(_apply_record_to_ui, self, rec) + self._dispatch_ui( + self.calman_status_var.set, f"{pct}% 采集完成 ({step_s:.2f}s)" + ) + self._dispatch_ui( + self.calman_elapsed_var.set, + f"Step: {step_s:.2f} s | Total: -- s", + ) + + threading.Thread(target=worker, daemon=True).start() + + +def start_sequence_test(self: "PQAutomationApp") -> None: + """从 100% 到 0% 连续发送并采集(先测 100% 以确定 gamma 参考)。""" + if not getattr(self, "ca", None) or not self.signal_service.is_connected: + messagebox.showerror("错误", "请先连接 CA410 和 UCD323") + return + if self.calman_running: + return + + self.calman_running = True + self.calman_stop_event.clear() + settle = float(getattr(self, "pattern_settle_time", 0.4)) + self.calman_progress["value"] = 0 + self.calman_progress_var.set("0 / 0") + + def worker(): + seq_t0 = time.perf_counter() + try: + order = sorted(self.calman_levels, reverse=True) + total = len(order) + self._dispatch_ui(self.calman_progress_var.set, f"0 / {total}") + for i, pct in enumerate(order, 1): + if self.calman_stop_event.is_set(): + self._dispatch_ui(self.log_gui.log, "已停止连续测试", "warning") + break + step_t0 = time.perf_counter() + rgb_val = int(round(pct * 255 / 100)) + self._dispatch_ui( + self.calman_status_var.set, f"[{i}/{total}] 发送 {pct}%" + ) + self._dispatch_ui(_highlight_patch, self, pct) + try: + self.signal_service.send_solid_rgb( + (rgb_val, rgb_val, rgb_val) + ) + except Exception as exc: + self._dispatch_ui( + self.log_gui.log, f"发送 {pct}% 失败: {exc}", "error" + ) + continue + self.calman_current_level = pct + # 等待稳定,停止事件触发时尽快退出 + if self.calman_stop_event.wait(settle): + break + rec = _measure_once(self, pct) + if rec is None: + continue + self.calman_results[pct] = rec + self._dispatch_ui(_apply_record_to_ui, self, rec) + step_s = time.perf_counter() - step_t0 + total_s = time.perf_counter() - seq_t0 + self._dispatch_ui( + _set_sequence_progress, + self, + i, + total, + step_s, + total_s, + ) + else: + self._dispatch_ui(self.calman_status_var.set, "连续测试完成") + self._dispatch_ui(self.log_gui.log, "CALMAN: 连续测试完成", "success") + return + self._dispatch_ui(self.calman_status_var.set, "已停止") + finally: + self.calman_running = False + + threading.Thread(target=worker, daemon=True).start() + + +def stop_sequence_test(self: "PQAutomationApp") -> None: + """请求停止连续测试。""" + if self.calman_running: + self.calman_stop_event.set() + self.calman_status_var.set("正在停止...") + else: + self.calman_status_var.set("当前没有运行中的连续测试") + + +def clear_results(self: "PQAutomationApp") -> None: + """清空结果表和图表。""" + self.calman_results.clear() + self.calman_last_record = None + self.calman_reading_var.set( + "x: -- y: --\n" + "u': -- v': --\n" + "cd/m²: --\n" + "ΔE2000: --" + ) + _refresh_metric_table(self) + _update_actual_strip(self) + _redraw_calman_charts(self) + self.calman_progress["value"] = 0 + self.calman_progress_var.set("0 / 0") + self.calman_elapsed_var.set("Step: -- s | Total: -- s") + self.calman_status_var.set("已清空") + + +# --------------------------------------------------------------------------- +# UI 更新辅助 +# --------------------------------------------------------------------------- + + +def _highlight_patch(self: "PQAutomationApp", pct: int) -> None: + """高亮当前选中色块。""" + try: + idx = self.calman_levels.index(pct) + except ValueError: + return + for i, cell in enumerate(self.calman_patch_cells): + if i == idx: + cell.configure(highlightbackground="#1f6fb2", highlightthickness=2) + else: + cell.configure(highlightbackground="#9c9c9c", highlightthickness=1) + for i, cell in enumerate(self.calman_actual_cells): + if i == idx: + cell.configure(highlightbackground="#1f6fb2", highlightthickness=2) + else: + cell.configure(highlightbackground="#808080", highlightthickness=1) + + total_cols = len(self.calman_levels) + 1 # 含 metric 列 + col_index = idx + 1 + left_fraction = max(0.0, min(1.0, (col_index - 2) / max(1, total_cols - 1))) + try: + self.calman_data_tree.xview_moveto(left_fraction) + except Exception: + pass + + +def _apply_record_to_ui(self: "PQAutomationApp", rec: dict) -> None: + """把一条测量结果写入 Treeview,并刷新图表与 Current Reading。""" + self.calman_last_record = rec + _refresh_metric_table(self) + _update_actual_strip(self) + + up, vp = _xy_to_upvp(rec["x"], rec["y"]) + + self.calman_reading_var.set( + f"x: {_safe_float(rec['x'])} y: {_safe_float(rec['y'])}\n" + f"u': {_safe_float(up)} v': {_safe_float(vp)}\n" + f"cd/m²: {_safe_float(rec['Y'], '{:.3f}')}\n" + f"ΔE2000: {_safe_float(rec['de2000'], '{:.3f}')}" + ) + + _redraw_calman_charts(self) + + +def _set_sequence_progress( + self: "PQAutomationApp", + finished: int, + total: int, + step_seconds: float, + total_seconds: float, +) -> None: + percent = (finished / total) * 100 if total > 0 else 0 + self.calman_progress["value"] = percent + self.calman_progress_var.set(f"{finished} / {total}") + self.calman_elapsed_var.set( + f"Step: {step_seconds:.2f} s | Total: {total_seconds:.1f} s" + ) + + +def _matrix_yview(self: "PQAutomationApp", *args) -> None: + self.calman_metric_tree.yview(*args) + self.calman_data_tree.yview(*args) + first, last = self.calman_data_tree.yview() + self.calman_table_ysb.set(first, last) + + +def _matrix_mousewheel(self: "PQAutomationApp", event) -> str: + delta = -1 if event.delta > 0 else 1 + self.calman_metric_tree.yview_scroll(delta, "units") + self.calman_data_tree.yview_scroll(delta, "units") + first, last = self.calman_data_tree.yview() + self.calman_table_ysb.set(first, last) + return "break" + + +def _adaptive_matrix_columns(self: "PQAutomationApp") -> None: + """按可用宽度自适应数据列宽;空间不足时保留横向滚动。""" + try: + available = self.calman_data_tree.winfo_width() + except Exception: + return + if available <= 40: + return + + col_count = max(1, len(self.calman_levels)) + min_w = 44 + ideal = int(available / col_count) + width = max(min_w, ideal) + + for p in self.calman_levels: + self.calman_data_tree.column(str(p), width=width, minwidth=min_w, stretch=False) + + +def _redraw_calman_charts(self: "PQAutomationApp") -> None: + """根据 calman_results 重绘四张图和 xy 散点。""" + recs = sorted(self.calman_results.values(), key=lambda r: r["pct"]) + pcts = [r["pct"] for r in recs] + de_vals = [r["de2000"] if r["de2000"] == r["de2000"] else 0 for r in recs] + lum_vals = [r["Y"] if r["Y"] == r["Y"] else 0 for r in recs] + rgb_r = [r["rgb_r"] for r in recs if r["rgb_r"] == r["rgb_r"]] + rgb_g = [r["rgb_g"] for r in recs if r["rgb_g"] == r["rgb_g"]] + rgb_b = [r["rgb_b"] for r in recs if r["rgb_b"] == r["rgb_b"]] + rgb_pcts = [r["pct"] for r in recs if r["rgb_r"] == r["rgb_r"]] + gamma_pcts = [r["pct"] for r in recs if r["gamma"] == r["gamma"]] + gamma_vals = [r["gamma"] for r in recs if r["gamma"] == r["gamma"]] + cct_vals = [r["cct"] for r in recs if r["cct"] == r["cct"]] + + if de_vals: + avg_de = sum(de_vals) / len(de_vals) + self.calman_avg_de_var.set(f"Avg dE2000: {avg_de:.2f}") + else: + self.calman_avg_de_var.set("Avg dE2000: --") + if cct_vals: + avg_cct = sum(cct_vals) / len(cct_vals) + self.calman_avg_cct_var.set(f"Avg CCT: {avg_cct:.0f}") + else: + self.calman_avg_cct_var.set("Avg CCT: --") + if gamma_vals: + avg_gamma = sum(gamma_vals) / len(gamma_vals) + self.calman_avg_gamma_var.set(f"Average Gamma: {avg_gamma:.2f}") + else: + self.calman_avg_gamma_var.set("Average Gamma: --") + if len(lum_vals) >= 2 and min(v for v in lum_vals if v > 0) > 0: + max_lum = max(lum_vals) + min_lum = min(v for v in lum_vals if v > 0) + contrast = max_lum / min_lum + self.calman_contrast_var.set(f"Contrast Ratio: {contrast:.0f}") + else: + self.calman_contrast_var.set("Contrast Ratio: --") + + # ΔE2000 + a1 = self.calman_ax_de + a1.clear() + _style_axes(a1, "DeltaE 2000") + if pcts: + a1.bar(pcts, de_vals, color="#ffcf57", width=3.5) + a1.set_xlim(-2, 102) + a1.set_ylim(bottom=0) + a1.set_xlabel("", fontsize=8) + + # RGB Balance 线图 + a2 = self.calman_ax_rgb_line + a2.clear() + _style_axes(a2, "RGB Balance") + if rgb_pcts: + a2.plot(rgb_pcts, rgb_r, "-", color="#ff4d4d", linewidth=1.2) + a2.plot(rgb_pcts, rgb_g, "-", color="#4caf50", linewidth=1.2) + a2.plot(rgb_pcts, rgb_b, "-", color="#4a73ff", linewidth=1.2) + a2.axhline(100, color="#9e9e9e", lw=0.8, ls="--") + a2.set_xlim(-2, 102) + a2.set_ylim(95, 105) + a2.set_xlabel("", fontsize=8) + + # RGB Balance 条图(用最后一个点) + a3 = self.calman_ax_rgb_bar + a3.clear() + _style_axes(a3, "RGB Balance") + if recs: + last = recs[-1] + bars = [ + last["rgb_r"] if last["rgb_r"] == last["rgb_r"] else 100, + last["rgb_g"] if last["rgb_g"] == last["rgb_g"] else 100, + last["rgb_b"] if last["rgb_b"] == last["rgb_b"] else 100, + ] + a3.bar([0, 1, 2], bars, color=["#ff4d4d", "#4caf50", "#4a73ff"], width=0.7) + a3.set_xticks([0, 1, 2], ["R", "G", "B"]) + else: + a3.set_xticks([0, 1, 2], ["R", "G", "B"]) + a3.set_ylim(95, 105) + a3.set_xlabel("", fontsize=8) + + # Gamma + a4 = self.calman_ax_gamma + a4.clear() + _style_axes(a4, "Gamma Log/Log") + if gamma_pcts: + a4.plot(gamma_pcts, gamma_vals, "-", color="#ffe24d", linewidth=1.3) + a4.axhline(TARGET_GAMMA, color="#9e9e9e", lw=0.8, ls="--") + a4.set_xlim(-2, 102) + a4.set_ylim(1.6, 2.8) + a4.set_xlabel("", fontsize=8) + + self.calman_canvas.draw_idle() + + _redraw_xy_chart(self) + + +def _redraw_xy_chart(self: "PQAutomationApp") -> None: + ax = self.calman_xy_ax + ax.clear() + _style_axes(ax, "CIE 1931 xy") + ax.set_xlim(0.29, 0.34) + ax.set_ylim(0.31, 0.35) + ax.plot([D65_X], [D65_Y], marker="x", color="#ffffff", markersize=7) + + recs = sorted(self.calman_results.values(), key=lambda r: r["pct"]) + if recs: + xs = [r["x"] for r in recs] + ys = [r["y"] for r in recs] + ax.plot(xs, ys, "o-", color="#000000", linewidth=1.0, markersize=3) + last = recs[-1] + ax.plot([last["x"]], [last["y"]], marker="o", color="#ffcc00", markersize=5) + ax.plot([last["x"], D65_X], [last["y"], D65_Y], color="#c7c7c7", linewidth=0.8) + self.calman_xy_canvas.draw_idle() + + +def _update_actual_strip(self: "PQAutomationApp") -> None: + """把实测亮度归一后映射到 Actual 色条。""" + y_map = {pct: rec["Y"] for pct, rec in self.calman_results.items() if rec.get("Y") is not None} + if not y_map: + for idx, w in enumerate(self.calman_actual_patch_cells): + base = self.calman_target_hexes[idx] + _set_canvas_patch(w, base, f"{self.calman_levels[idx]}") + return + + max_y = max(y_map.values()) + if max_y <= 0: + max_y = 1.0 + for idx, pct in enumerate(self.calman_levels): + yy = y_map.get(pct) + if yy is None: + base = self.calman_target_hexes[idx] + _set_canvas_patch(self.calman_actual_patch_cells[idx], base, f"{pct}") + continue + norm = max(0.0, min(1.0, yy / max_y)) + g = int(round(norm * 255)) + _set_canvas_patch(self.calman_actual_patch_cells[idx], f"#{g:02x}{g:02x}{g:02x}", f"{pct}") + + +def _update_target_strip(self: "PQAutomationApp") -> None: + for idx, canvas in enumerate(self.calman_target_patch_canvases): + _set_canvas_patch(canvas, self.calman_target_hexes[idx], f"{self.calman_levels[idx]}") + + +def _refresh_metric_table(self: "PQAutomationApp") -> None: + """重绘下方矩阵表。""" + metrics = [ + ("x CIE31", lambda r: _safe_float(r.get("x")) if r else "-"), + ("y CIE31", lambda r: _safe_float(r.get("y")) if r else "-"), + ("Y", lambda r: _safe_float(r.get("Y"), "{:.3f}") if r else "-"), + ( + "Target Y", + lambda _r, pctx=None: _safe_float((pctx / 100.0) ** TARGET_GAMMA, "{:.4f}") if pctx and pctx > 0 else ("0.0000" if pctx == 0 else "-"), + ), + ("Gamma Log/Log", lambda r: _safe_float(r.get("gamma"), "{:.3f}") if r else "-"), + ("CCT", lambda r: _safe_float(r.get("cct"), "{:.0f}") if r else "-"), + ("dE 2000", lambda r: _safe_float(r.get("de2000"), "{:.3f}") if r else "-"), + ("RGB R", lambda r: _safe_float(r.get("rgb_r"), "{:.2f}") if r else "-"), + ("RGB G", lambda r: _safe_float(r.get("rgb_g"), "{:.2f}") if r else "-"), + ("RGB B", lambda r: _safe_float(r.get("rgb_b"), "{:.2f}") if r else "-"), + ] + + for iid in self.calman_metric_tree.get_children(): + self.calman_metric_tree.delete(iid) + for iid in self.calman_data_tree.get_children(): + self.calman_data_tree.delete(iid) + + for row_idx, (name, func) in enumerate(metrics): + values = [] + for pct in self.calman_levels: + rec = self.calman_results.get(pct) + if name == "Target Y": + values.append(func(rec, pctx=pct)) + else: + values.append(func(rec)) + iid = f"row_{row_idx}" + tags = ("odd",) if row_idx % 2 else ("even",) + self.calman_metric_tree.insert("", tk.END, iid=iid, values=(name,), tags=tags) + self.calman_data_tree.insert("", tk.END, iid=iid, values=values, tags=tags) + + self.calman_metric_tree.tag_configure("odd", background="#f5f7fa") + self.calman_metric_tree.tag_configure("even", background="#ffffff") + self.calman_data_tree.tag_configure("odd", background="#f5f7fa") + self.calman_data_tree.tag_configure("even", background="#ffffff") + + first, last = self.calman_data_tree.yview() + self.calman_table_ysb.set(first, last) + + +class CalmanPanelMixin: + """挂载本模块的自由函数到 PQAutomationApp。""" + + create_calman_panel = create_calman_panel + toggle_calman_panel = toggle_calman_panel diff --git a/app/views/panels/cct_panel.py b/app/views/panels/cct_panel.py index 7105b3b..48c974f 100644 --- a/app/views/panels/cct_panel.py +++ b/app/views/panels/cct_panel.py @@ -1,4 +1,4 @@ -"""CCT 参数面板及其处理函数(与主文件重构版本保持同步)。""" +"""CCT 参数面板及其处理函数(与主文件重构版本保持同步)。""" import time import traceback @@ -8,8 +8,14 @@ import ttkbootstrap as ttk import algorithm.pq_algorithm as pq_algorithm +from typing import TYPE_CHECKING -def create_cct_params_frame(self): +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + + +def create_cct_params_frame(self: "PQAutomationApp"): """创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)""" # ==================== 屏模组色度参数 Frame ==================== @@ -330,7 +336,7 @@ def create_cct_params_frame(self): ).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5) -def _get_cct_var_dict(self, test_type): +def _get_cct_var_dict(self: "PQAutomationApp", test_type): """按测试类型返回 CCT 变量映射。""" if test_type == "sdr_movie": return { @@ -354,7 +360,7 @@ def _get_cct_var_dict(self, test_type): } -def _parse_cct_float(self, var, default): +def _parse_cct_float(self: "PQAutomationApp", var, default): """读取并解析 CCT 输入值,失败时回落默认值。""" try: value = var.get().strip() @@ -365,7 +371,7 @@ def _parse_cct_float(self, var, default): return default -def _save_cct_params_for(self, test_type): +def _save_cct_params_for(self: "PQAutomationApp", test_type): """保存指定测试类型的 CCT 参数。""" try: default_params = self.config.get_default_cct_params(test_type) @@ -384,7 +390,7 @@ def _save_cct_params_for(self, test_type): pass -def _handle_cct_focus_out(self, var, default_value, save_func, label): +def _handle_cct_focus_out(self: "PQAutomationApp", var, default_value, save_func, label): """统一处理 CCT 参数失焦校验并保存。""" try: value = var.get().strip() @@ -414,27 +420,27 @@ def _handle_cct_focus_out(self, var, default_value, save_func, label): self.log_gui.log(f"处理 {label} 参数失败: {str(e)}", level="error") -def on_sdr_cct_param_focus_out(self, var, default_value): +def on_sdr_cct_param_focus_out(self: "PQAutomationApp", var, default_value): """SDR 色度参数失去焦点时的处理。""" _handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "SDR") -def save_sdr_cct_params(self): +def save_sdr_cct_params(self: "PQAutomationApp"): """保存 SDR 色度参数。""" _save_cct_params_for(self, "sdr_movie") -def on_hdr_cct_param_focus_out(self, var, default_value): +def on_hdr_cct_param_focus_out(self: "PQAutomationApp", var, default_value): """HDR 色度参数失去焦点时的处理。""" _handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "HDR") -def save_hdr_cct_params(self): +def save_hdr_cct_params(self: "PQAutomationApp"): """保存 HDR 色度参数。""" _save_cct_params_for(self, "hdr_movie") -def recalculate_cct(self): +def recalculate_cct(self: "PQAutomationApp"): """重新计算并绘制色度图""" try: # 1. 保存新参数 @@ -496,7 +502,7 @@ def recalculate_cct(self): messagebox.showerror("错误", f"重新计算失败: {str(e)}") -def recalculate_gamut(self): +def recalculate_gamut(self: "PQAutomationApp"): """重新计算并绘制色域图(使用新的参考标准)""" try: # 1. 收起配置项 @@ -644,17 +650,17 @@ def recalculate_gamut(self): messagebox.showerror("错误", f"重新计算失败: {str(e)}") -def on_cct_param_focus_out(self, var, default_value): +def on_cct_param_focus_out(self: "PQAutomationApp", var, default_value): """色度参数失去焦点时的处理 - 空值恢复默认""" _handle_cct_focus_out(self, var, default_value, self.save_cct_params, "屏模组") -def save_cct_params(self): +def save_cct_params(self: "PQAutomationApp"): """保存色度参数 - 简化版""" _save_cct_params_for(self, self.config.current_test_type) -def reload_cct_params(self): +def reload_cct_params(self: "PQAutomationApp"): """切换测试类型时重新加载色度参数""" try: current_type = self.config.current_test_type @@ -676,7 +682,7 @@ def reload_cct_params(self): self.log_gui.log(f"重新加载色度参数失败: {str(e)}", level="error") -def toggle_cct_params_frame(self): +def toggle_cct_params_frame(self: "PQAutomationApp"): """根据测试类型和测试项的选中状态显示对应参数框""" selected_items = self.get_selected_test_items() current_test_type = self.config.current_test_type @@ -718,7 +724,7 @@ _GAMUT_REF_CONFIGS = { } -def _on_gamut_ref_changed(self, test_type, event=None): +def _on_gamut_ref_changed(self: "PQAutomationApp", test_type, event=None): cfg = _GAMUT_REF_CONFIGS[test_type] try: new_ref = getattr(self, cfg["var_attr"]).get() @@ -732,13 +738,38 @@ def _on_gamut_ref_changed(self, test_type, event=None): self.log_gui.log(f"保存 {cfg['label']} 色域参考标准失败: {str(e)}", level="error") -def on_screen_gamut_ref_changed(self, event=None): +def on_screen_gamut_ref_changed(self: "PQAutomationApp", event=None): _on_gamut_ref_changed(self, "screen_module", event) -def on_sdr_gamut_ref_changed(self, event=None): +def on_sdr_gamut_ref_changed(self: "PQAutomationApp", event=None): _on_gamut_ref_changed(self, "sdr_movie", event) -def on_hdr_gamut_ref_changed(self, event=None): +def on_hdr_gamut_ref_changed(self: "PQAutomationApp", event=None): _on_gamut_ref_changed(self, "hdr_movie", event) + + +class CctPanelMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_cct_params_frame = create_cct_params_frame + _get_cct_var_dict = _get_cct_var_dict + _parse_cct_float = _parse_cct_float + _save_cct_params_for = _save_cct_params_for + _handle_cct_focus_out = _handle_cct_focus_out + on_sdr_cct_param_focus_out = on_sdr_cct_param_focus_out + save_sdr_cct_params = save_sdr_cct_params + on_hdr_cct_param_focus_out = on_hdr_cct_param_focus_out + save_hdr_cct_params = save_hdr_cct_params + recalculate_cct = recalculate_cct + recalculate_gamut = recalculate_gamut + on_cct_param_focus_out = on_cct_param_focus_out + save_cct_params = save_cct_params + reload_cct_params = reload_cct_params + toggle_cct_params_frame = toggle_cct_params_frame + _on_gamut_ref_changed = _on_gamut_ref_changed + on_screen_gamut_ref_changed = on_screen_gamut_ref_changed + on_sdr_gamut_ref_changed = on_sdr_gamut_ref_changed + on_hdr_gamut_ref_changed = on_hdr_gamut_ref_changed diff --git a/app/views/panels/custom_template_panel.py b/app/views/panels/custom_template_panel.py index be084c4..b36b5ac 100644 --- a/app/views/panels/custom_template_panel.py +++ b/app/views/panels/custom_template_panel.py @@ -1,4 +1,4 @@ -"""自定义模板结果面板(Step 6 重构)。""" +"""自定义模板结果面板(Step 6 重构)。""" import threading import time @@ -11,7 +11,13 @@ import numpy as np from app.data_range_converter import convert_pattern_params -def create_custom_template_result_panel(self): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def create_custom_template_result_panel(self: "PQAutomationApp"): """创建客户模板结果显示区域(黑底表格)""" self.custom_result_frame = ttk.LabelFrame( self.custom_template_tab_frame, text="客户模板结果显示" @@ -151,7 +157,7 @@ def create_custom_template_result_panel(self): table_container.grid_columnconfigure(0, weight=1) -def show_custom_result_context_menu(self, event): +def show_custom_result_context_menu(self: "PQAutomationApp", event): """显示客户模板结果右键菜单""" if not hasattr(self, "custom_result_tree") or not hasattr( self, "custom_result_menu" @@ -197,7 +203,7 @@ def show_custom_result_context_menu(self, event): self.custom_result_menu.grab_release() -def set_custom_result_table_locked(self, locked): +def set_custom_result_table_locked(self: "PQAutomationApp", locked): """锁定/解锁客户模板结果表(测试期间禁选择、禁右键)""" if not hasattr(self, "custom_result_tree"): return @@ -208,7 +214,7 @@ def set_custom_result_table_locked(self, locked): pass -def start_custom_row_single_step(self): +def start_custom_row_single_step(self: "PQAutomationApp"): """单步测试当前选中行:发送该行 pattern 并覆盖该行测量结果""" if not hasattr(self, "custom_result_tree"): return @@ -252,7 +258,7 @@ def start_custom_row_single_step(self): ).start() -def _clear_custom_result_row(self, item_id, row_no): +def _clear_custom_result_row(self: "PQAutomationApp", item_id, row_no): """单步测试开始前清空指定行的测量数据""" if not hasattr(self, "custom_result_tree"): return @@ -281,7 +287,7 @@ def _clear_custom_result_row(self, item_id, row_no): self.custom_result_tree.see(item_id) -def _run_custom_row_single_step(self, item_id, row_no): +def _run_custom_row_single_step(self: "PQAutomationApp", item_id, row_no): """后台执行客户模板单步测试""" try: self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...") @@ -352,7 +358,7 @@ def _run_custom_row_single_step(self, item_id, row_no): self._dispatch_ui(self.status_var.set, "单步测试失败") -def _update_custom_result_row(self, item_id, row_no, result_data): +def _update_custom_result_row(self: "PQAutomationApp", item_id, row_no, result_data): """覆盖更新客户模板结果表中指定行""" def fmt(value, digits=4): @@ -394,7 +400,7 @@ def _update_custom_result_row(self, item_id, row_no, result_data): self.custom_result_tree.item(item_id, values=new_values) -def copy_custom_result_table(self): +def copy_custom_result_table(self: "PQAutomationApp"): """复制客户模板结果表格到剪贴板(不含标题行/No./Pattern)""" if not hasattr(self, "custom_result_tree"): return @@ -434,7 +440,7 @@ def copy_custom_result_table(self): if hasattr(self, "log_gui"): self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)", level="success") -def clear_custom_template_results(self): +def clear_custom_template_results(self: "PQAutomationApp"): """清空客户模板结果表格""" if not hasattr(self, "custom_result_tree"): return @@ -442,7 +448,7 @@ def clear_custom_template_results(self): self.custom_result_tree.delete(item) -def auto_expand_custom_result_view(self): +def auto_expand_custom_result_view(self: "PQAutomationApp"): """当客户模板表格有数据时,自动扩展窗口以尽量完整显示所有列""" if not hasattr(self, "custom_result_tree"): return @@ -480,7 +486,7 @@ def auto_expand_custom_result_view(self): self.log_gui.log(f"自动扩展客户模板窗口失败: {str(e)}", level="error") -def append_custom_template_result(self, row_no, result_data): +def append_custom_template_result(self: "PQAutomationApp", row_no, result_data): """追加一条客户模板结果到表格""" def fmt(value, digits=4): @@ -523,7 +529,7 @@ def append_custom_template_result(self, row_no, result_data): self.auto_expand_custom_result_view() -def start_custom_template_test(self): +def start_custom_template_test(self: "PQAutomationApp"): """开始客户模板测试(SDR)""" if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"): @@ -571,7 +577,7 @@ def start_custom_template_test(self): self.test_thread.daemon = True self.test_thread.start() -def update_custom_button_visibility(self): +def update_custom_button_visibility(self: "PQAutomationApp"): """只在 SDR 测试时显示客户模版按钮""" if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"): return @@ -625,7 +631,7 @@ def update_custom_button_visibility(self): # self.log_gui.log("已填充 147 行客户模板测试数据", level="success") -def export_custom_template_excel(self): +def export_custom_template_excel(self: "PQAutomationApp"): """将客户模板结果表导出为 Excel 文件(14 列完整数据)""" if not hasattr(self, "custom_result_tree"): return @@ -773,7 +779,7 @@ def export_custom_template_excel(self): messagebox.showerror("错误", f"导出失败:{str(e)}") -def export_custom_template_charts(self): +def export_custom_template_charts(self: "PQAutomationApp"): """生成客户模板图表:xy 色度散点图 + Lv 亮度曲线图,保存为 PNG""" if not hasattr(self, "custom_result_tree"): return @@ -910,3 +916,24 @@ def export_custom_template_charts(self): if hasattr(self, "log_gui"): self.log_gui.log(f"生成图表失败: {str(e)}", level="error") messagebox.showerror("错误", f"生成图表失败:{str(e)}") + + +class CustomTemplatePanelMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_custom_template_result_panel = create_custom_template_result_panel + show_custom_result_context_menu = show_custom_result_context_menu + set_custom_result_table_locked = set_custom_result_table_locked + start_custom_row_single_step = start_custom_row_single_step + _clear_custom_result_row = _clear_custom_result_row + _run_custom_row_single_step = _run_custom_row_single_step + _update_custom_result_row = _update_custom_result_row + copy_custom_result_table = copy_custom_result_table + clear_custom_template_results = clear_custom_template_results + auto_expand_custom_result_view = auto_expand_custom_result_view + append_custom_template_result = append_custom_template_result + start_custom_template_test = start_custom_template_test + update_custom_button_visibility = update_custom_button_visibility + export_custom_template_excel = export_custom_template_excel + export_custom_template_charts = export_custom_template_charts diff --git a/app/views/panels/gamma_pattern_panel.py b/app/views/panels/gamma_pattern_panel.py new file mode 100644 index 0000000..c6167f9 --- /dev/null +++ b/app/views/panels/gamma_pattern_panel.py @@ -0,0 +1,1075 @@ +"""灰阶 Pattern 配置面板(Gamma / CCT / 对比度 / EOTF 共用)。 + +特性: +- 多预设管理:内置 + 用户保存,支持 新建 / 另存为 / 复制 / 重命名 / 删除 / 导入 / 导出 +- 内置预设锁定,不可覆盖、删除、改名 +- 行内色块预览,前景色根据亮度自适应 +- 生成器:N 点等分 / PQ 编码 / Gamma 2.2/2.4 曲线 +- 剪贴板批量粘贴(每行 "R,G,B" / "R G B" / "灰度%") +- 自动校验:重复 / 越界 / 非灰阶 / 非单调 + +为保持向后兼容,仍导出 ``create_gamma_pattern_panel`` / ``toggle_gamma_pattern_panel``。 +""" + +from __future__ import annotations + +import json +import os +import sys +import tkinter as tk +from pathlib import Path +from tkinter import filedialog, messagebox, simpledialog +from typing import TYPE_CHECKING + +import ttkbootstrap as ttk + +from app.pq import pq_config + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +# ============================================================ +# 列定义 +# ============================================================ + +_COLUMNS = ("idx", "pct", "r", "g", "b", "hex") +_HEADINGS = { + "idx": "#", + "pct": "灰度 %", + "r": "R", + "g": "G", + "b": "B", + "hex": "HEX", +} +_WIDTHS = {"idx": 40, "pct": 70, "r": 60, "g": 60, "b": 60, "hex": 80} + +_TEST_KIND = "gray" # 当前仅管理灰阶系测试共用的 pattern + + +# ============================================================ +# 工具 +# ============================================================ + +def _gray_pct_of(rgb) -> str: + try: + r = int(rgb[0]) + except Exception: + return "" + return str(int(round(r / 255 * 100))) + + +def _hex_of(rgb) -> str: + try: + r, g, b = int(rgb[0]), int(rgb[1]), int(rgb[2]) + except Exception: + return "" + return f"#{r:02X}{g:02X}{b:02X}" + + +def _luminance(rgb) -> float: + r, g, b = rgb + return 0.2126 * r + 0.7152 * g + 0.0722 * b + + +def _fg_for_bg(rgb) -> str: + return "#000000" if _luminance(rgb) >= 140 else "#ffffff" + + +def _get_settings_dir(self: "PQAutomationApp") -> str: + if getattr(self, "config_file", None): + return os.path.dirname(self.config_file) + if getattr(sys, "frozen", False): + base_dir = os.path.dirname(sys.executable) + else: + base_dir = os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + ) + return os.path.join(base_dir, "settings") + + +# ============================================================ +# 内置生成器 +# ============================================================ + +def _gen_even(n: int) -> list[list[int]]: + n = max(2, n) + out = [] + for i in range(n): + pct = 100.0 - (100.0 / (n - 1)) * i + v = int(round(pct / 100.0 * 255)) + out.append([v, v, v]) + return out + + +def _gen_pq(n: int) -> list[list[int]]: + """N 点:线性 nits(0..10000)等分后 PQ 编码到 8-bit(低端更密集)。""" + n = max(2, n) + c1, c2, c3 = 0.8359375, 18.8515625, 18.6875 + m1, m2 = 0.1593017578125, 78.84375 + out = [] + for i in range(n): + nits = 10000.0 * (1.0 - i / (n - 1)) + L = max(0.0, nits / 10000.0) + if L <= 0: + v_pq = 0.0 + else: + Lm1 = L ** m1 + v_pq = ((c1 + c2 * Lm1) / (1.0 + c3 * Lm1)) ** m2 + v = int(round(max(0.0, min(1.0, v_pq)) * 255)) + out.append([v, v, v]) + return out + + +def _gen_gamma(n: int, gamma: float = 2.2) -> list[list[int]]: + """N 点:线性光 0..1 等分后 ^(1/gamma) 编码到 8-bit(暗端更密集)。""" + n = max(2, n) + out = [] + for i in range(n): + lin = 1.0 - i / (n - 1) + v = int(round((lin ** (1.0 / gamma)) * 255)) + out.append([v, v, v]) + return out + + +_GENERATORS = { + "等分 (linear code)": _gen_even, + "PQ 编码 (HDR)": _gen_pq, + "Gamma 2.2 (SDR)": lambda n: _gen_gamma(n, 2.2), + "Gamma 2.4 (SDR)": lambda n: _gen_gamma(n, 2.4), +} + + +# ============================================================ +# 面板入口 +# ============================================================ + +def create_gamma_pattern_panel(self: "PQAutomationApp"): + """创建灰阶 Pattern 配置面板。""" + frame = ttk.Frame(self.content_frame) + self.gamma_pattern_frame = frame + self.gamma_pattern_visible = False + + # 内部状态 + self._gamma_pattern_params: list[list[int]] = [] + self._gamma_current_preset: str | None = None + self._gamma_preset_locked: bool = False + self._gamma_dirty: bool = False + self._gamma_preset_list: list[dict] = [] + + root = ttk.Frame(frame, padding=10) + root.pack(fill=tk.BOTH, expand=True) + + # ===== 标题 ===== + title_row = ttk.Frame(root) + title_row.pack(fill=tk.X, pady=(0, 6)) + ttk.Label( + title_row, text="灰阶 Pattern 配置", font=("微软雅黑", 14, "bold") + ).pack(side=tk.LEFT) + ttk.Label( + title_row, + text="(Gamma / CCT / 对比度 / EOTF 共用此列表)", + foreground="#888", + ).pack(side=tk.LEFT, padx=(8, 0)) + + # ===== 预设管理行 ===== + preset_box = ttk.LabelFrame(root, text="预设", padding=8) + preset_box.pack(fill=tk.X, pady=(0, 8)) + + preset_row1 = ttk.Frame(preset_box) + preset_row1.pack(fill=tk.X) + + ttk.Label(preset_row1, text="选择:").pack(side=tk.LEFT) + self._gamma_preset_var = tk.StringVar() + self._gamma_preset_combo = ttk.Combobox( + preset_row1, + textvariable=self._gamma_preset_var, + state="readonly", + width=32, + ) + self._gamma_preset_combo.pack(side=tk.LEFT, padx=(4, 6)) + self._gamma_preset_combo.bind( + "<>", lambda e: _on_preset_selected(self) + ) + + ttk.Button( + preset_row1, text="加载", + bootstyle="info-outline", width=8, + command=lambda: _load_selected_preset(self), + ).pack(side=tk.LEFT, padx=2) + + ttk.Button( + preset_row1, text="应用为当前", + bootstyle="success", width=12, + command=lambda: _activate_selected_preset(self), + ).pack(side=tk.LEFT, padx=2) + + self._gamma_active_label = ttk.Label( + preset_row1, text="", foreground="#0a8", font=("微软雅黑", 9, "bold") + ) + self._gamma_active_label.pack(side=tk.LEFT, padx=(10, 0)) + + # 第二行:CRUD + preset_row2 = ttk.Frame(preset_box) + preset_row2.pack(fill=tk.X, pady=(6, 0)) + + for txt, style, cmd in [ + ("新建空预设", "secondary-outline", lambda: _new_preset(self)), + ("另存为...", "primary-outline", lambda: _save_as_preset(self)), + ("复制...", "secondary-outline", lambda: _duplicate_preset(self)), + ("重命名...", "secondary-outline", lambda: _rename_preset(self)), + ("删除", "danger-outline", lambda: _delete_preset(self)), + ("导入...", "secondary-outline", lambda: _import_preset(self)), + ("导出...", "secondary-outline", lambda: _export_preset(self)), + ]: + ttk.Button( + preset_row2, text=txt, bootstyle=style, width=11, command=cmd + ).pack(side=tk.LEFT, padx=2) + + # 描述行 + self._gamma_meta_label = ttk.Label( + preset_box, text="", foreground="#666", font=("微软雅黑", 9) + ) + self._gamma_meta_label.pack(anchor=tk.W, pady=(6, 0)) + + # ===== 中部:左表格 + 右编辑区 ===== + mid = ttk.Frame(root) + mid.pack(fill=tk.BOTH, expand=True) + mid.columnconfigure(0, weight=1) + mid.columnconfigure(1, weight=0) + mid.rowconfigure(0, weight=1) + + # ---- 左:表格 ---- + table_frame = ttk.LabelFrame(mid, text="灰阶点列表", padding=6) + table_frame.grid(row=0, column=0, sticky=tk.NSEW, padx=(0, 8)) + + tree_container = ttk.Frame(table_frame) + tree_container.pack(fill=tk.BOTH, expand=True) + + self.gamma_pattern_tree = ttk.Treeview( + tree_container, + columns=_COLUMNS, + show="headings", + height=18, + selectmode="browse", + ) + for col in _COLUMNS: + self.gamma_pattern_tree.heading(col, text=_HEADINGS[col]) + self.gamma_pattern_tree.column(col, width=_WIDTHS[col], anchor=tk.CENTER) + self.gamma_pattern_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) + + scrollbar = ttk.Scrollbar( + tree_container, orient=tk.VERTICAL, command=self.gamma_pattern_tree.yview + ) + scrollbar.pack(side=tk.RIGHT, fill=tk.Y) + self.gamma_pattern_tree.configure(yscrollcommand=scrollbar.set) + + self.gamma_pattern_tree.bind( + "<>", lambda e: _on_select(self) + ) + + # ---- 右:编辑区 ---- + right = ttk.Frame(mid) + right.grid(row=0, column=1, sticky=tk.NS) + + edit_frame = ttk.LabelFrame(right, text="编辑选中点", padding=8) + edit_frame.pack(fill=tk.X) + + self._gamma_edit_r_var = tk.StringVar() + self._gamma_edit_g_var = tk.StringVar() + self._gamma_edit_b_var = tk.StringVar() + self._gamma_edit_pct_var = tk.StringVar() + + ttk.Label(edit_frame, text="灰度 %").grid(row=0, column=0, sticky=tk.W, pady=3) + ttk.Entry(edit_frame, textvariable=self._gamma_edit_pct_var, width=10).grid( + row=0, column=1, sticky=tk.W, pady=3, padx=(4, 0) + ) + ttk.Button( + edit_frame, text="按 % 填充", + bootstyle="secondary-outline", width=10, + command=lambda: _fill_rgb_from_pct(self), + ).grid(row=0, column=2, padx=(6, 0), pady=3) + + for i, ch in enumerate(("R", "G", "B"), start=1): + ttk.Label(edit_frame, text=ch).grid(row=i, column=0, sticky=tk.W, pady=3) + var = getattr(self, f"_gamma_edit_{ch.lower()}_var") + ttk.Entry(edit_frame, textvariable=var, width=10).grid( + row=i, column=1, sticky=tk.W, pady=3, padx=(4, 0) + ) + + btn_row1 = ttk.Frame(edit_frame) + btn_row1.grid(row=4, column=0, columnspan=3, sticky=tk.EW, pady=(8, 0)) + for txt, style, cmd in [ + ("更新选中", "primary", lambda: _update_selected(self)), + ("新增", "success", lambda: _add_new(self)), + ("删除", "danger-outline", lambda: _delete_selected(self)), + ]: + ttk.Button(btn_row1, text=txt, bootstyle=style, width=8, command=cmd).pack( + side=tk.LEFT, padx=(0, 4) + ) + + btn_row2 = ttk.Frame(edit_frame) + btn_row2.grid(row=5, column=0, columnspan=3, sticky=tk.EW, pady=(4, 0)) + ttk.Button( + btn_row2, text="↑ 上移", + bootstyle="secondary-outline", width=8, + command=lambda: _move_selected(self, -1), + ).pack(side=tk.LEFT, padx=(0, 4)) + ttk.Button( + btn_row2, text="↓ 下移", + bootstyle="secondary-outline", width=8, + command=lambda: _move_selected(self, 1), + ).pack(side=tk.LEFT) + + # ---- 生成器 ---- + gen_frame = ttk.LabelFrame(right, text="按曲线生成", padding=8) + gen_frame.pack(fill=tk.X, pady=(10, 0)) + + ttk.Label(gen_frame, text="类型").grid(row=0, column=0, sticky=tk.W) + self._gamma_gen_type_var = tk.StringVar(value=next(iter(_GENERATORS))) + ttk.Combobox( + gen_frame, textvariable=self._gamma_gen_type_var, + values=list(_GENERATORS.keys()), state="readonly", width=18, + ).grid(row=0, column=1, sticky=tk.W, padx=(4, 0)) + + ttk.Label(gen_frame, text="点数 N").grid(row=1, column=0, sticky=tk.W, pady=(6, 0)) + self._gamma_n_points_var = tk.StringVar(value="11") + ttk.Entry(gen_frame, textvariable=self._gamma_n_points_var, width=10).grid( + row=1, column=1, sticky=tk.W, padx=(4, 0), pady=(6, 0) + ) + ttk.Button( + gen_frame, text="生成", + bootstyle="info-outline", + command=lambda: _generate_by_curve(self), + ).grid(row=2, column=0, columnspan=2, sticky=tk.EW, pady=(6, 0)) + + # ---- 剪贴板粘贴 ---- + paste_frame = ttk.LabelFrame(right, text="批量粘贴", padding=8) + paste_frame.pack(fill=tk.X, pady=(10, 0)) + ttk.Label( + paste_frame, text="每行:R,G,B 或 R G B\n或:灰度% (如 50%)", + foreground="#888", justify=tk.LEFT, + ).pack(anchor=tk.W) + ttk.Button( + paste_frame, text="从剪贴板导入", + bootstyle="secondary-outline", + command=lambda: _paste_from_clipboard(self), + ).pack(fill=tk.X, pady=(6, 0)) + + # ===== 底部 ===== + bottom = ttk.LabelFrame(root, text="校验与保存", padding=8) + bottom.pack(fill=tk.X, pady=(10, 0)) + + self._gamma_validate_label = ttk.Label( + bottom, text="", foreground="#666", justify=tk.LEFT + ) + self._gamma_validate_label.pack(anchor=tk.W) + + save_row = ttk.Frame(bottom) + save_row.pack(fill=tk.X, pady=(6, 0)) + ttk.Button( + save_row, text="保存改动到当前预设", + bootstyle="primary", + command=lambda: _save_to_current_preset(self), + ).pack(side=tk.LEFT) + ttk.Button( + save_row, text="应用到运行时 (gray.json)", + bootstyle="success", + command=lambda: _apply_current_to_runtime(self), + ).pack(side=tk.LEFT, padx=(6, 0)) + ttk.Button( + save_row, text="另存为新预设...", + bootstyle="info-outline", + command=lambda: _save_as_preset(self), + ).pack(side=tk.RIGHT) + + # 注册并初始化 + self.register_panel("gamma_pattern", frame, None, "gamma_pattern_visible") + _refresh_preset_combo(self) + _load_initial(self) + + +def toggle_gamma_pattern_panel(self: "PQAutomationApp"): + """切换面板显隐。""" + self.show_panel("gamma_pattern") + + +# ============================================================ +# 预设管理 +# ============================================================ + +def _refresh_preset_combo(self: "PQAutomationApp", select: str | None = None): + presets = pq_config.list_presets(_TEST_KIND) + self._gamma_preset_list = presets + names = [p["name"] for p in presets] + labels = [ + f"{'🔒 ' if p['locked'] else ''}{p['name']} ({p['point_count']}点)" + for p in presets + ] + self._gamma_preset_combo["values"] = labels + + target = select or self._gamma_current_preset + if target and target in names: + self._gamma_preset_var.set(labels[names.index(target)]) + elif labels: + self._gamma_preset_var.set(labels[0]) + + _update_active_label(self) + + +def _selected_preset_name(self: "PQAutomationApp") -> str | None: + label = self._gamma_preset_var.get() + if not label: + return None + for p in self._gamma_preset_list: + prefix = "🔒 " if p["locked"] else "" + if label.startswith(prefix + p["name"]): + return p["name"] + return None + + +def _update_active_label(self: "PQAutomationApp"): + active = pq_config.get_active_preset_name(_TEST_KIND) + current = self._gamma_current_preset + if active and current == active and not self._gamma_dirty: + self._gamma_active_label.config( + text=f"✔ 当前激活:{active}", foreground="#0a8" + ) + elif active: + extra = "(有未保存改动)" if self._gamma_dirty else "" + self._gamma_active_label.config( + text=f"● 激活:{active} 编辑中:{current or '-'}{extra}", + foreground="#a60" if self._gamma_dirty else "#888", + ) + else: + self._gamma_active_label.config(text="● 未激活任何预设", foreground="#888") + + +def _on_preset_selected(self: "PQAutomationApp"): + """选中下拉项时直接加载(含未保存提醒)。""" + if self._gamma_dirty: + if not messagebox.askyesno( + "未保存改动", "当前预设有未保存改动,确定切换?切换后改动将丢失。" + ): + _refresh_preset_combo(self, select=self._gamma_current_preset) + return + _load_selected_preset(self) + + +def _load_selected_preset(self: "PQAutomationApp"): + name = _selected_preset_name(self) + if not name: + return + try: + data = pq_config.load_preset(_TEST_KIND, name) + except (FileNotFoundError, json.JSONDecodeError) as exc: + messagebox.showerror("加载失败", str(exc)) + return + self._gamma_current_preset = name + self._gamma_preset_locked = bool((data.get("_meta") or {}).get("locked")) + self._gamma_pattern_params = [ + list(map(int, rgb)) for rgb in (data.get("pattern_params") or []) + ] + self._gamma_dirty = False + _refresh_tree(self) + _update_meta_label(self, data) + _update_active_label(self) + + +def _activate_selected_preset(self: "PQAutomationApp"): + name = _selected_preset_name(self) + if not name: + messagebox.showinfo("提示", "请先选择一个预设") + return + if self._gamma_dirty and self._gamma_current_preset == name: + if not messagebox.askyesno( + "未保存改动", "当前编辑未保存,将使用磁盘上原始预设进行激活。继续?" + ): + return + try: + pq_config.activate_preset(_TEST_KIND, name) + except Exception as exc: + messagebox.showerror("激活失败", str(exc)) + return + if hasattr(self, "log_gui"): + self.log_gui.log(f"已激活灰阶预设:{name}", level="success") + _update_active_label(self) + messagebox.showinfo("成功", f"已应用预设 '{name}' 到运行时。") + + +def _new_preset(self: "PQAutomationApp"): + name = simpledialog.askstring( + "新建预设", "预设名(建议英文/数字/下划线):", + parent=self.gamma_pattern_frame, + ) + if not name: + return + try: + path = pq_config.save_preset( + _TEST_KIND, name, + {"pattern_params": _gen_even(11)}, + description="新建空白预设", + generator="manual", + overwrite=False, + ) + except (FileExistsError, PermissionError, ValueError) as exc: + messagebox.showerror("创建失败", str(exc)) + return + _refresh_preset_combo(self, select=Path(path).stem) + _load_selected_preset(self) + + +def _save_as_preset(self: "PQAutomationApp"): + if not self._gamma_pattern_params: + messagebox.showerror("错误", "当前列表为空,无法另存") + return + name = simpledialog.askstring( + "另存为预设", "新预设名:", parent=self.gamma_pattern_frame, + initialvalue=(self._gamma_current_preset or "untitled").lstrip("_") + "_copy", + ) + if not name: + return + try: + path = pq_config.save_preset( + _TEST_KIND, name, + {"pattern_params": self._gamma_pattern_params}, + description="用户另存", + generator="manual", + overwrite=False, + ) + except (FileExistsError, PermissionError, ValueError) as exc: + messagebox.showerror("保存失败", str(exc)) + return + self._gamma_dirty = False + _refresh_preset_combo(self, select=Path(path).stem) + _load_selected_preset(self) + + +def _duplicate_preset(self: "PQAutomationApp"): + src = _selected_preset_name(self) + if not src: + return + new_name = simpledialog.askstring( + "复制预设", "新名字:", parent=self.gamma_pattern_frame, + initialvalue=src.lstrip("_") + "_copy", + ) + if not new_name: + return + try: + path = pq_config.duplicate_preset(_TEST_KIND, src, new_name) + except (FileExistsError, FileNotFoundError, ValueError) as exc: + messagebox.showerror("复制失败", str(exc)) + return + _refresh_preset_combo(self, select=Path(path).stem) + + +def _rename_preset(self: "PQAutomationApp"): + src = _selected_preset_name(self) + if not src: + return + new_name = simpledialog.askstring( + "重命名预设", "新名字:", parent=self.gamma_pattern_frame, initialvalue=src + ) + if not new_name or new_name == src: + return + try: + path = pq_config.rename_preset(_TEST_KIND, src, new_name) + except (FileExistsError, FileNotFoundError, PermissionError, ValueError) as exc: + messagebox.showerror("重命名失败", str(exc)) + return + if self._gamma_current_preset == src: + self._gamma_current_preset = Path(path).stem + _refresh_preset_combo(self, select=Path(path).stem) + + +def _delete_preset(self: "PQAutomationApp"): + src = _selected_preset_name(self) + if not src: + return + if not messagebox.askyesno("确认", f"确定删除预设 '{src}' ?"): + return + try: + pq_config.delete_preset(_TEST_KIND, src) + except (FileNotFoundError, PermissionError) as exc: + messagebox.showerror("删除失败", str(exc)) + return + if self._gamma_current_preset == src: + self._gamma_current_preset = None + self._gamma_pattern_params = [] + _refresh_tree(self) + _refresh_preset_combo(self) + + +def _import_preset(self: "PQAutomationApp"): + fp = filedialog.askopenfilename( + title="选择 Pattern JSON 文件", + filetypes=[("JSON", "*.json"), ("All", "*.*")], + initialdir=_get_settings_dir(self), + ) + if not fp: + return + name = simpledialog.askstring( + "导入预设", "保存为预设名:", parent=self.gamma_pattern_frame, + initialvalue=Path(fp).stem, + ) + if not name: + return + try: + path = pq_config.import_preset_from_file(_TEST_KIND, fp, name=name) + except (ValueError, json.JSONDecodeError, OSError) as exc: + messagebox.showerror("导入失败", str(exc)) + return + _refresh_preset_combo(self, select=Path(path).stem) + _load_selected_preset(self) + + +def _export_preset(self: "PQAutomationApp"): + src = _selected_preset_name(self) + if not src: + return + fp = filedialog.asksaveasfilename( + title="导出预设到文件", + filetypes=[("JSON", "*.json")], + defaultextension=".json", + initialfile=f"{src}.json", + initialdir=_get_settings_dir(self), + ) + if not fp: + return + try: + pq_config.export_preset_to_file(_TEST_KIND, src, fp) + except Exception as exc: + messagebox.showerror("导出失败", str(exc)) + return + messagebox.showinfo("导出成功", f"已导出到\n{fp}") + + +def _save_to_current_preset(self: "PQAutomationApp"): + name = self._gamma_current_preset + if not name: + messagebox.showinfo("提示", "没有当前预设,请先'新建'或'另存为'") + return + if self._gamma_preset_locked: + messagebox.showwarning( + "预设已锁定", + f"'{name}' 是内置预设,不能直接覆盖。请使用'另存为'保存为新预设。", + ) + return + if len(self._gamma_pattern_params) < 2: + messagebox.showerror("错误", "至少需要 2 个灰阶点") + return + try: + existing_meta = (pq_config.load_preset(_TEST_KIND, name).get("_meta") or {}) + except Exception: + existing_meta = {} + try: + pq_config.save_preset( + _TEST_KIND, name, + {"pattern_params": self._gamma_pattern_params}, + description=existing_meta.get("description", ""), + generator=existing_meta.get("generator", "manual"), + overwrite=True, + ) + except (PermissionError, ValueError) as exc: + messagebox.showerror("保存失败", str(exc)) + return + self._gamma_dirty = False + _refresh_preset_combo(self, select=name) + if hasattr(self, "log_gui"): + self.log_gui.log(f"已保存预设:{name}", level="success") + messagebox.showinfo("成功", f"已保存到预设 '{name}'。\n如需生效请点'应用为当前'或'应用到运行时'。") + + +def _apply_current_to_runtime(self: "PQAutomationApp"): + """把当前编辑结果直接写入 gray.json 并热加载(不必先保存为预设)。""" + if len(self._gamma_pattern_params) < 2: + messagebox.showerror("错误", "至少需要 2 个灰阶点") + return + pattern = { + "pattern_mode": "SolidColor", + "measurement_bit_depth": 8, + "measurement_max_value": len(self._gamma_pattern_params) - 1, + "pattern_params": [list(map(int, rgb)) for rgb in self._gamma_pattern_params], + } + path = Path(_get_settings_dir(self)) / "patterns" / "gray.json" + try: + pq_config.save_pattern_file(path, pattern) + pq_config.reload_gray_pattern() + except Exception as exc: + messagebox.showerror("应用失败", str(exc)) + return + + # 若当前编辑与磁盘上同名预设内容一致,则把该预设标记为激活 + if self._gamma_current_preset: + try: + disk = pq_config.load_preset(_TEST_KIND, self._gamma_current_preset) + if (disk.get("pattern_params") or []) == self._gamma_pattern_params: + pq_config.activate_preset(_TEST_KIND, self._gamma_current_preset) + except Exception: + pass + + if hasattr(self, "log_gui"): + self.log_gui.log( + f"已应用 {len(self._gamma_pattern_params)} 点灰阶 pattern 到运行时", + level="success", + ) + _update_active_label(self) + messagebox.showinfo("成功", "已应用到运行时,立即生效。") + + +# ============================================================ +# 表格 / 编辑 +# ============================================================ + +def _refresh_tree(self: "PQAutomationApp"): + tree = self.gamma_pattern_tree + tree.delete(*tree.get_children()) + for i, rgb in enumerate(self._gamma_pattern_params): + try: + r, g, b = int(rgb[0]), int(rgb[1]), int(rgb[2]) + except (TypeError, ValueError, IndexError): + r = g = b = 0 + tag = f"row_{i}" + bg = f"#{r:02x}{g:02x}{b:02x}" + fg = _fg_for_bg([r, g, b]) + tree.tag_configure(tag, background=bg, foreground=fg) + tree.insert( + "", tk.END, iid=str(i), + values=(i, _gray_pct_of([r, g, b]), r, g, b, _hex_of([r, g, b])), + tags=(tag,), + ) + _run_validation(self) + + +def _selected_index(self: "PQAutomationApp"): + sel = self.gamma_pattern_tree.selection() + if not sel: + return None + try: + return int(sel[0]) + except ValueError: + return None + + +def _on_select(self: "PQAutomationApp"): + idx = _selected_index(self) + if idx is None or idx >= len(self._gamma_pattern_params): + return + r, g, b = self._gamma_pattern_params[idx] + self._gamma_edit_r_var.set(str(r)) + self._gamma_edit_g_var.set(str(g)) + self._gamma_edit_b_var.set(str(b)) + self._gamma_edit_pct_var.set(_gray_pct_of([r, g, b])) + + +def _parse_rgb_from_form(self: "PQAutomationApp"): + try: + r = int(self._gamma_edit_r_var.get().strip()) + g = int(self._gamma_edit_g_var.get().strip()) + b = int(self._gamma_edit_b_var.get().strip()) + except ValueError: + messagebox.showerror("输入错误", "R/G/B 必须为整数") + return None + if not all(0 <= v <= 255 for v in (r, g, b)): + messagebox.showerror("输入错误", "R/G/B 必须在 0~255 范围内") + return None + return [r, g, b] + + +def _fill_rgb_from_pct(self: "PQAutomationApp"): + raw = self._gamma_edit_pct_var.get().strip().rstrip("%") + try: + pct = float(raw) + except ValueError: + messagebox.showerror("输入错误", "灰度 % 必须为数字") + return + pct = max(0.0, min(100.0, pct)) + v = int(round(pct / 100.0 * 255)) + self._gamma_edit_r_var.set(str(v)) + self._gamma_edit_g_var.set(str(v)) + self._gamma_edit_b_var.set(str(v)) + self._gamma_edit_pct_var.set(str(int(round(pct)))) + + +def _mark_dirty(self: "PQAutomationApp"): + self._gamma_dirty = True + _update_active_label(self) + + +def _check_locked_or_prompt(self: "PQAutomationApp") -> bool: + """锁定预设编辑前提示用户。返回 True 表示允许继续编辑。""" + if not self._gamma_preset_locked: + return True + return messagebox.askyesno( + "预设已锁定", + "当前是内置预设,改动无法直接覆盖(保存时需'另存为')。仍要编辑吗?", + ) + + +def _update_selected(self: "PQAutomationApp"): + idx = _selected_index(self) + if idx is None: + messagebox.showinfo("提示", "请先在列表中选中一行") + return + rgb = _parse_rgb_from_form(self) + if rgb is None: + return + if not _check_locked_or_prompt(self): + return + self._gamma_pattern_params[idx] = rgb + _mark_dirty(self) + _refresh_tree(self) + try: + self.gamma_pattern_tree.selection_set(str(idx)) + except Exception: + pass + + +def _add_new(self: "PQAutomationApp"): + rgb = _parse_rgb_from_form(self) + if rgb is None: + return + if not _check_locked_or_prompt(self): + return + idx = _selected_index(self) + insert_at = (idx + 1) if idx is not None else len(self._gamma_pattern_params) + self._gamma_pattern_params.insert(insert_at, rgb) + _mark_dirty(self) + _refresh_tree(self) + try: + self.gamma_pattern_tree.selection_set(str(insert_at)) + except Exception: + pass + + +def _delete_selected(self: "PQAutomationApp"): + idx = _selected_index(self) + if idx is None: + messagebox.showinfo("提示", "请先在列表中选中一行") + return + if len(self._gamma_pattern_params) <= 2: + messagebox.showwarning("提示", "至少保留 2 个灰阶点") + return + if not _check_locked_or_prompt(self): + return + del self._gamma_pattern_params[idx] + _mark_dirty(self) + _refresh_tree(self) + + +def _move_selected(self: "PQAutomationApp", delta: int): + idx = _selected_index(self) + if idx is None: + return + new_idx = idx + delta + if new_idx < 0 or new_idx >= len(self._gamma_pattern_params): + return + if not _check_locked_or_prompt(self): + return + params = self._gamma_pattern_params + params[idx], params[new_idx] = params[new_idx], params[idx] + _mark_dirty(self) + _refresh_tree(self) + try: + self.gamma_pattern_tree.selection_set(str(new_idx)) + except Exception: + pass + + +def _generate_by_curve(self: "PQAutomationApp"): + try: + n = int(self._gamma_n_points_var.get().strip()) + except ValueError: + messagebox.showerror("输入错误", "点数 N 必须为整数") + return + if n < 2 or n > 256: + messagebox.showerror("输入错误", "点数 N 必须在 2~256 之间") + return + gen_name = self._gamma_gen_type_var.get() + func = _GENERATORS.get(gen_name) + if func is None: + return + if not _check_locked_or_prompt(self): + return + if not messagebox.askyesno("确认", f"将用 [{gen_name}] N={n} 替换当前列表,继续?"): + return + self._gamma_pattern_params = func(n) + _mark_dirty(self) + _refresh_tree(self) + + +def _paste_from_clipboard(self: "PQAutomationApp"): + try: + raw = self.gamma_pattern_frame.clipboard_get() + except tk.TclError: + messagebox.showinfo("提示", "剪贴板为空") + return + rows: list[list[int]] = [] + errors: list[str] = [] + for ln_no, line in enumerate(raw.splitlines(), 1): + s = line.strip() + if not s: + continue + if s.endswith("%"): + try: + pct = float(s.rstrip("%")) + v = int(round(max(0, min(100, pct)) / 100.0 * 255)) + rows.append([v, v, v]) + continue + except ValueError: + errors.append(f"第 {ln_no} 行无法解析:{s}") + continue + parts = [p for p in s.replace(",", " ").replace(";", " ").split() if p] + if len(parts) == 1: + try: + v = int(parts[0]) + if 0 <= v <= 255: + rows.append([v, v, v]) + continue + except ValueError: + pass + errors.append(f"第 {ln_no} 行无法解析:{s}") + continue + if len(parts) < 3: + errors.append(f"第 {ln_no} 行字段不足:{s}") + continue + try: + r, g, b = int(parts[0]), int(parts[1]), int(parts[2]) + except ValueError: + errors.append(f"第 {ln_no} 行非整数:{s}") + continue + if not all(0 <= v <= 255 for v in (r, g, b)): + errors.append(f"第 {ln_no} 行越界:{s}") + continue + rows.append([r, g, b]) + if not rows: + messagebox.showerror("粘贴失败", "未识别到任何有效行\n" + "\n".join(errors[:5])) + return + if not _check_locked_or_prompt(self): + return + msg = f"识别到 {len(rows)} 行有效数据。" + if errors: + msg += f"\n忽略 {len(errors)} 行错误(示例):\n" + "\n".join(errors[:3]) + msg += "\n\n是 = 替换当前列表 / 否 = 追加到末尾 / 取消" + choice = messagebox.askyesnocancel("确认", msg) + if choice is None: + return + if choice: + self._gamma_pattern_params = rows + else: + self._gamma_pattern_params.extend(rows) + _mark_dirty(self) + _refresh_tree(self) + + +# ============================================================ +# 元信息显示 & 校验 +# ============================================================ + +def _update_meta_label(self: "PQAutomationApp", data: dict): + meta = data.get("_meta") or {} + parts = [] + if meta.get("locked"): + parts.append("🔒 内置(只读,需另存为修改)") + if meta.get("description"): + parts.append(meta["description"]) + if meta.get("generator"): + parts.append(f"生成器:{meta['generator']}") + if meta.get("created"): + parts.append(f"创建:{meta['created']}") + self._gamma_meta_label.config(text=" | ".join(parts) if parts else "") + + +def _run_validation(self: "PQAutomationApp"): + params = self._gamma_pattern_params + msgs: list[str] = [] + + if len(params) < 2: + msgs.append("⚠ 至少需要 2 个灰阶点") + + bad = [ + i for i, rgb in enumerate(params) + if not all(0 <= int(v) <= 255 for v in rgb) + ] + if bad: + msgs.append(f"⚠ 越界点 (索引): {bad}") + + non_gray = [ + i for i, (r, g, b) in enumerate(params) + if not (int(r) == int(g) == int(b)) + ] + if non_gray: + msgs.append(f"ⓘ 非纯灰点 (R≠G≠B): {non_gray}(gamma 计算按 R 通道)") + + seen: dict[tuple, int] = {} + dups: list[int] = [] + for i, rgb in enumerate(params): + key = tuple(rgb) + if key in seen: + dups.append(i) + else: + seen[key] = i + if dups: + msgs.append(f"⚠ 重复点 (索引): {dups}") + + r_seq = [int(rgb[0]) for rgb in params] + if len(r_seq) >= 2: + is_desc = all(a >= b for a, b in zip(r_seq, r_seq[1:])) + is_asc = all(a <= b for a, b in zip(r_seq, r_seq[1:])) + if not (is_desc or is_asc): + msgs.append("ⓘ R 通道非单调递增/递减") + + if not msgs: + text = f"✔ 校验通过(共 {len(params)} 点)" + color = "#0a8" + else: + text = f"共 {len(params)} 点 | " + " ".join(msgs) + color = "#a60" if any(m.startswith("⚠") for m in msgs) else "#666" + self._gamma_validate_label.config(text=text, foreground=color) + + +# ============================================================ +# 初始加载 +# ============================================================ + +def _load_initial(self: "PQAutomationApp"): + """启动时优先加载激活预设;如无则加载当前 gray.json。""" + active = pq_config.get_active_preset_name(_TEST_KIND) + if active: + try: + data = pq_config.load_preset(_TEST_KIND, active) + self._gamma_current_preset = active + self._gamma_preset_locked = bool((data.get("_meta") or {}).get("locked")) + self._gamma_pattern_params = [ + list(map(int, rgb)) for rgb in (data.get("pattern_params") or []) + ] + _refresh_tree(self) + _refresh_preset_combo(self, select=active) + _update_meta_label(self, data) + _update_active_label(self) + return + except Exception: + pass + + pattern = pq_config.get_pattern("gray") + self._gamma_pattern_params = [ + list(map(int, rgb)) for rgb in (pattern.get("pattern_params") or []) + ] + self._gamma_current_preset = None + self._gamma_preset_locked = False + _refresh_tree(self) + _update_meta_label(self, {}) + _update_active_label(self) + + +# ============================================================ +# Mixin +# ============================================================ + +class GammaPatternPanelMixin: + """挂到 PQAutomationApp 上的方法集合。""" + + create_gamma_pattern_panel = create_gamma_pattern_panel + toggle_gamma_pattern_panel = toggle_gamma_pattern_panel diff --git a/app/views/panels/main_layout.py b/app/views/panels/main_layout.py index c7a1355..062bd20 100644 --- a/app/views/panels/main_layout.py +++ b/app/views/panels/main_layout.py @@ -1,4 +1,4 @@ -"""主布局面板创建函数(Step 6 重构)。""" +"""主布局面板创建函数(Step 6 重构)。""" import re import tkinter as tk @@ -8,7 +8,13 @@ from drivers.UCD323_Enum import UCDEnum from app.views.collapsing_frame import CollapsingFrame from app.resources import load_icon -def create_floating_config_panel(self): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def create_floating_config_panel(self: "PQAutomationApp"): """创建右上角悬浮配置框""" cf = CollapsingFrame(self.control_frame_top) cf.pack(fill="both") @@ -53,7 +59,7 @@ def create_floating_config_panel(self): self.config_panel_frame.btn.configure(image="closed") -def create_test_items_content(self): +def create_test_items_content(self: "PQAutomationApp"): """创建测试项目选项卡内容""" # 创建测试项目字典,用于管理不同测试类型的选项 self.test_items = { @@ -96,7 +102,7 @@ def create_test_items_content(self): self.create_cct_params_frame() -def create_signal_format_content(self): +def create_signal_format_content(self: "PQAutomationApp"): """创建信号格式选项卡内容""" self.signal_tabs = ttk.Notebook(self.signal_format_frame) self.signal_tabs.pack(fill=tk.BOTH, expand=True) @@ -314,7 +320,7 @@ def create_signal_format_content(self): self.signal_tabs.tab(2, state="disabled") # 禁用 HDR -def create_connection_content(self): +def create_connection_content(self: "PQAutomationApp"): """创建设备连接区域""" # 创建设备连接区域的主框架 com_frame = ttk.Frame(self.connection_frame) @@ -424,7 +430,7 @@ def create_connection_content(self): ca_channel_combo.bind("<>", self.update_config) -def create_test_type_frame(self): +def create_test_type_frame(self: "PQAutomationApp"): """创建测试类型选择区域(侧边栏形式)""" # 设置测试类型变量 self.test_type_var = tk.StringVar(value="screen_module") @@ -503,6 +509,26 @@ def create_test_type_frame(self): ) self.pantone_baseline_btn.pack(fill=tk.X, padx=0, pady=1) + # Gamma 测试图案配置按钮 + self.gamma_pattern_btn = ttk.Button( + self.sidebar_frame, + text="Gamma 图案配置", + style="Sidebar.TButton", + command=self.toggle_gamma_pattern_panel, + takefocus=False, + ) + self.gamma_pattern_btn.pack(fill=tk.X, padx=0, pady=1) + + # CALMAN 风格灰阶测试按钮 + self.calman_btn = ttk.Button( + self.sidebar_frame, + text="CALMAN 灰阶", + style="Sidebar.TButton", + command=self.toggle_calman_panel, + takefocus=False, + ) + self.calman_btn.pack(fill=tk.X, padx=0, pady=1) + # 测试版水印标签(版本 x.x.0.0 时显示) from app_version import is_beta_version, APP_VERSION if is_beta_version(): @@ -528,9 +554,13 @@ def create_test_type_frame(self): self.panels["single_step"]["button"] = self.single_step_btn if "pantone_baseline" in self.panels: self.panels["pantone_baseline"]["button"] = self.pantone_baseline_btn + if "gamma_pattern" in self.panels: + self.panels["gamma_pattern"]["button"] = self.gamma_pattern_btn + if "calman" in self.panels: + self.panels["calman"]["button"] = self.calman_btn -def update_config_info_display(self): +def update_config_info_display(self: "PQAutomationApp"): """更新配置信息显示""" if hasattr(self, "config") and hasattr(self.config, "get_current_config"): current_config = self.config.get_current_config() @@ -547,7 +577,7 @@ def update_config_info_display(self): self.update_sidebar_selection() -def create_operation_frame(self): +def create_operation_frame(self: "PQAutomationApp"): """创建操作按钮区域""" operation_frame = ttk.Frame(self.control_frame_top) operation_frame.pack(fill=tk.X, padx=5, pady=10) @@ -594,7 +624,7 @@ def create_operation_frame(self): self.update_custom_button_visibility() -def on_screen_module_timing_changed(self, event=None): +def on_screen_module_timing_changed(self: "PQAutomationApp", event=None): """屏模组信号格式改变时的回调""" try: selected_timing = self.screen_module_timing_var.get() @@ -632,7 +662,7 @@ def on_screen_module_timing_changed(self, event=None): self.log_gui.log(f"屏模组信号格式更改失败: {str(e)}", level="error") -def on_sdr_timing_changed(self, event=None): +def on_sdr_timing_changed(self: "PQAutomationApp", event=None): """SDR测试分辨率改变时的回调""" try: selected_timing = self.sdr_timing_var.get() @@ -650,7 +680,7 @@ def on_sdr_timing_changed(self, event=None): self.log_gui.log(f"SDR测试分辨率更改失败: {str(e)}", level="error") -def on_sdr_output_format_changed(self, event=None): +def on_sdr_output_format_changed(self: "PQAutomationApp", event=None): """SDR 色彩格式改变时的回调""" try: fmt = self.sdr_output_format_var.get() @@ -674,7 +704,7 @@ def on_sdr_output_format_changed(self, event=None): self.log_gui.log(f"SDR色彩格式更改失败: {str(e)}", level="error") -def on_hdr_output_format_changed(self, event=None): +def on_hdr_output_format_changed(self: "PQAutomationApp", event=None): """HDR 色彩格式改变时的回调""" try: fmt = self.hdr_output_format_var.get() @@ -700,7 +730,7 @@ def on_hdr_output_format_changed(self, event=None): self.log_gui.log(f"HDR色彩格式更改失败: {str(e)}", level="error") -def update_test_items(self): +def update_test_items(self: "PQAutomationApp"): """根据当前测试类型更新测试项目复选框""" # 先隐藏所有测试项目框架 for config in self.test_items.values(): @@ -747,7 +777,7 @@ def update_test_items(self): self.toggle_cct_params_frame() -def on_test_type_change(self): +def on_test_type_change(self: "PQAutomationApp"): """根据测试类型更新内容区域""" # 更新配置信息显示 if hasattr(self, "config") and hasattr(self.config, "get_current_config"): @@ -756,3 +786,22 @@ def on_test_type_change(self): # SDR 选中时显示客户模版按钮 self.update_custom_button_visibility() + + +class MainLayoutMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_floating_config_panel = create_floating_config_panel + create_test_items_content = create_test_items_content + create_signal_format_content = create_signal_format_content + create_connection_content = create_connection_content + create_test_type_frame = create_test_type_frame + update_config_info_display = update_config_info_display + create_operation_frame = create_operation_frame + on_screen_module_timing_changed = on_screen_module_timing_changed + on_sdr_timing_changed = on_sdr_timing_changed + on_sdr_output_format_changed = on_sdr_output_format_changed + on_hdr_output_format_changed = on_hdr_output_format_changed + update_test_items = update_test_items + on_test_type_change = on_test_type_change diff --git a/app/views/panels/pantone_baseline_panel.py b/app/views/panels/pantone_baseline_panel.py index 856cc70..5727a67 100644 --- a/app/views/panels/pantone_baseline_panel.py +++ b/app/views/panels/pantone_baseline_panel.py @@ -11,11 +11,17 @@ from tkinter import filedialog, messagebox import ttkbootstrap as ttk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + _TEMPLATE_FILE = "pantone_2670_colors.xlsx" -def create_pantone_baseline_panel(self): +def create_pantone_baseline_panel(self: "PQAutomationApp"): """创建 Pantone 认证摸底测试面板。""" frame = ttk.Frame(self.content_frame) self.pantone_baseline_frame = frame @@ -149,12 +155,12 @@ def create_pantone_baseline_panel(self): _set_button_states(self) -def toggle_pantone_baseline_panel(self): +def toggle_pantone_baseline_panel(self: "PQAutomationApp"): """切换 Pantone 认证摸底测试面板。""" self.show_panel("pantone_baseline") -def _get_settings_dir(self): +def _get_settings_dir(self: "PQAutomationApp"): """返回 settings 绝对目录,避免依赖当前工作目录。""" if getattr(self, "config_file", None): return os.path.dirname(self.config_file) @@ -168,7 +174,7 @@ def _get_settings_dir(self): return os.path.join(base_dir, "settings") -def _load_patterns(self): +def _load_patterns(self: "PQAutomationApp"): path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE) if not os.path.isfile(path): raise FileNotFoundError(f"未找到模板文件: {path}") @@ -201,7 +207,7 @@ def _load_patterns(self): return patterns -def _start_pantone_baseline(self): +def _start_pantone_baseline(self: "PQAutomationApp"): if self._pantone_running: messagebox.showinfo("提示", "Pantone 任务正在执行") return @@ -247,7 +253,7 @@ def _start_pantone_baseline(self): _launch_worker(self, start_index=0, settle=settle) -def _resume_pantone_baseline(self): +def _resume_pantone_baseline(self: "PQAutomationApp"): if self._pantone_running: messagebox.showinfo("提示", "Pantone 任务正在执行") return @@ -291,7 +297,7 @@ def _resume_pantone_baseline(self): _launch_worker(self, start_index=self._pantone_next_index, settle=settle) -def _launch_worker(self, start_index, settle): +def _launch_worker(self: "PQAutomationApp", start_index, settle): total = self._pantone_target_count or len(self.pantone_patterns) def worker(): @@ -401,7 +407,7 @@ def _launch_worker(self, start_index, settle): threading.Thread(target=worker, daemon=True).start() -def _append_result_row(self, record, total): +def _append_result_row(self: "PQAutomationApp", record, total): self.pantone_tree.insert( "", tk.END, @@ -423,7 +429,7 @@ def _append_result_row(self, record, total): self.pantone_tree.see(children[-1]) -def _pause_pantone_baseline(self): +def _pause_pantone_baseline(self: "PQAutomationApp"): if not self._pantone_running: messagebox.showinfo("提示", "当前没有运行中的任务") return @@ -433,7 +439,7 @@ def _pause_pantone_baseline(self): self._pantone_control_event.set() -def _end_pantone_baseline(self): +def _end_pantone_baseline(self: "PQAutomationApp"): if self._pantone_running: self._pantone_stop_requested = True self.pantone_status_var.set("结束中...") @@ -448,7 +454,7 @@ def _end_pantone_baseline(self): _set_button_states(self) -def _clear_results(self): +def _clear_results(self: "PQAutomationApp"): if self._pantone_running: messagebox.showinfo("提示", "任务执行中,无法清空") return @@ -463,7 +469,7 @@ def _clear_results(self): _set_button_states(self) -def _set_button_states(self): +def _set_button_states(self: "PQAutomationApp"): if self._pantone_running: self.pantone_start_btn.configure(state=tk.DISABLED) self.pantone_pause_btn.configure(state=tk.NORMAL) @@ -479,7 +485,7 @@ def _set_button_states(self): self.pantone_resume_btn.configure(state=tk.NORMAL if can_resume else tk.DISABLED) -def _save_as_template(self): +def _save_as_template(self: "PQAutomationApp"): if not self.pantone_results: messagebox.showinfo("提示", "暂无可导出的结果") return @@ -502,7 +508,7 @@ def _save_as_template(self): messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}") -def _resolve_results_dir(self): +def _resolve_results_dir(self: "PQAutomationApp"): if getattr(self, "config_file", None): root_dir = os.path.dirname(os.path.dirname(self.config_file)) else: @@ -514,7 +520,7 @@ def _resolve_results_dir(self): return results_dir -def _auto_save_template(self): +def _auto_save_template(self: "PQAutomationApp"): results_dir = _resolve_results_dir(self) target_count = len(self.pantone_results) filename = ( @@ -526,7 +532,7 @@ def _auto_save_template(self): return path -def _write_template_xlsx(self, path): +def _write_template_xlsx(self: "PQAutomationApp", path): # 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。 template_path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE) from openpyxl import load_workbook, Workbook @@ -560,3 +566,25 @@ def _write_template_xlsx(self, path): ws.cell(row=idx, column=6, value=float(item["y"])) wb.save(path) + + +class PantoneBaselinePanelMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_pantone_baseline_panel = create_pantone_baseline_panel + toggle_pantone_baseline_panel = toggle_pantone_baseline_panel + _get_settings_dir = _get_settings_dir + _load_patterns = _load_patterns + _start_pantone_baseline = _start_pantone_baseline + _resume_pantone_baseline = _resume_pantone_baseline + _launch_worker = _launch_worker + _append_result_row = _append_result_row + _pause_pantone_baseline = _pause_pantone_baseline + _end_pantone_baseline = _end_pantone_baseline + _clear_results = _clear_results + _set_button_states = _set_button_states + _save_as_template = _save_as_template + _resolve_results_dir = _resolve_results_dir + _auto_save_template = _auto_save_template + _write_template_xlsx = _write_template_xlsx diff --git a/app/views/panels/side_panels.py b/app/views/panels/side_panels.py index 42e5758..804f39c 100644 --- a/app/views/panels/side_panels.py +++ b/app/views/panels/side_panels.py @@ -1,4 +1,4 @@ -"""侧边面板(日志 / Local Dimming / 调试)""" +"""侧边面板(日志 / Local Dimming / 调试)""" import traceback import tkinter as tk @@ -7,7 +7,13 @@ import ttkbootstrap as ttk from app.views.pq_log_gui import PQLogGUI from app.views.pq_debug_panel import PQDebugPanel -def create_log_panel(self): +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + +def create_log_panel(self: "PQAutomationApp"): """创建日志面板""" self.log_frame = ttk.Frame(self.content_frame) self.log_gui = PQLogGUI(self.log_frame) @@ -22,7 +28,7 @@ def create_log_panel(self): ) # button会在后面设置 -def create_local_dimming_panel(self): +def create_local_dimming_panel(self: "PQAutomationApp"): """创建 Local Dimming 测试面板 - 手动控制版""" self.local_dimming_frame = ttk.Frame(self.content_frame) @@ -172,12 +178,12 @@ def create_local_dimming_panel(self): self.current_ld_percentage = None -def toggle_local_dimming_panel(self): +def toggle_local_dimming_panel(self: "PQAutomationApp"): """切换 Local Dimming 面板显示""" self.show_panel("local_dimming") -def toggle_log_panel(self): +def toggle_log_panel(self: "PQAutomationApp"): """切换日志面板的显示状态""" self.show_panel("log") @@ -226,7 +232,7 @@ DEBUG_PANEL_CONFIGS = { } -def _toggle_debug_panel(self, test_type): +def _toggle_debug_panel(self: "PQAutomationApp", test_type): """打开/关闭对应测试类型的单步调试面板(独立窗口)。""" cfg = DEBUG_PANEL_CONFIGS[test_type] win_attr = cfg["window_attr"] @@ -288,20 +294,20 @@ def _toggle_debug_panel(self, test_type): win.update_idletasks() -def toggle_screen_debug_panel(self): +def toggle_screen_debug_panel(self: "PQAutomationApp"): _toggle_debug_panel(self, "screen_module") -def toggle_sdr_debug_panel(self): +def toggle_sdr_debug_panel(self: "PQAutomationApp"): _toggle_debug_panel(self, "sdr_movie") -def toggle_hdr_debug_panel(self): +def toggle_hdr_debug_panel(self: "PQAutomationApp"): _toggle_debug_panel(self, "hdr_movie") -def update_sidebar_selection(self): +def update_sidebar_selection(self: "PQAutomationApp"): """更新侧边栏按钮的选中状态""" # 重置所有按钮样式为默认 self.screen_module_btn.configure(style="Sidebar.TButton") @@ -316,3 +322,18 @@ def update_sidebar_selection(self): self.sdr_movie_btn.configure(style="SidebarSelected.TButton") elif current_type == "hdr_movie": self.hdr_movie_btn.configure(style="SidebarSelected.TButton") + + +class SidePanelsMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_log_panel = create_log_panel + create_local_dimming_panel = create_local_dimming_panel + toggle_local_dimming_panel = toggle_local_dimming_panel + toggle_log_panel = toggle_log_panel + _toggle_debug_panel = _toggle_debug_panel + toggle_screen_debug_panel = toggle_screen_debug_panel + toggle_sdr_debug_panel = toggle_sdr_debug_panel + toggle_hdr_debug_panel = toggle_hdr_debug_panel + update_sidebar_selection = update_sidebar_selection diff --git a/app/views/panels/single_step_panel.py b/app/views/panels/single_step_panel.py index 98eed6f..31814f9 100644 --- a/app/views/panels/single_step_panel.py +++ b/app/views/panels/single_step_panel.py @@ -13,6 +13,12 @@ from tkinter import filedialog, messagebox import ttkbootstrap as ttk from PIL import Image +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pqAutomationApp import PQAutomationApp + + @@ -27,7 +33,7 @@ _DEFAULT_SAMPLES = [ ] -def create_single_step_panel(self): +def create_single_step_panel(self: "PQAutomationApp"): """创建单步调试面板。""" frame = ttk.Frame(self.content_frame) self.single_step_frame = frame @@ -246,12 +252,12 @@ def create_single_step_panel(self): _load_default_samples(self) -def toggle_single_step_panel(self): +def toggle_single_step_panel(self: "PQAutomationApp"): """切换单步调试面板。""" self.show_panel("single_step") -def _load_default_samples(self): +def _load_default_samples(self: "PQAutomationApp"): self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES] _refresh_sample_list(self, select_index=0 if self.single_step_samples else None) self.single_step_status_var.set( @@ -259,7 +265,7 @@ def _load_default_samples(self): ) -def _refresh_sample_list(self, select_index=None): +def _refresh_sample_list(self: "PQAutomationApp", select_index=None): self.single_step_listbox.delete(0, tk.END) for sample in self.single_step_samples: self.single_step_listbox.insert( @@ -280,14 +286,14 @@ def _refresh_sample_list(self, select_index=None): self.single_step_status_var.set("样本列表为空") -def _on_sample_select(self): +def _on_sample_select(self: "PQAutomationApp"): selection = self.single_step_listbox.curselection() if not selection: return _select_sample(self, selection[0]) -def _select_sample(self, index): +def _select_sample(self: "PQAutomationApp", index): sample = self.single_step_samples[index] self.single_step_current_index = index self.single_step_name_var.set(sample["name"]) @@ -297,7 +303,7 @@ def _select_sample(self, index): self.single_step_status_var.set(f"当前样本: {sample['name']}") -def _import_samples_csv(self): +def _import_samples_csv(self: "PQAutomationApp"): path = filedialog.askopenfilename( title="选择单步调试样本 CSV", filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")], @@ -334,7 +340,7 @@ def _import_samples_csv(self): self.log_gui.log(f"单步调试样本已导入: {len(samples)} 条", level="success") -def _delete_current_sample(self): +def _delete_current_sample(self: "PQAutomationApp"): if self.single_step_current_index is None: return removed = self.single_step_samples.pop(self.single_step_current_index) @@ -343,7 +349,7 @@ def _delete_current_sample(self): self.single_step_status_var.set(f"已删除样本: {removed['name']}") -def _upsert_sample(self): +def _upsert_sample(self: "PQAutomationApp"): try: sample = { "name": self.single_step_name_var.get().strip(), @@ -387,7 +393,7 @@ def _format_float(value): return f"{number:.4f}" -def _build_color_patch(self, hex_value): +def _build_color_patch(self: "PQAutomationApp", hex_value): if not self.signal_service.is_connected: raise RuntimeError("请先连接 UCD323 设备") width, height = self.signal_service.current_resolution() @@ -401,7 +407,7 @@ def _build_color_patch(self, hex_value): return file_path -def _send_current_patch(self): +def _send_current_patch(self: "PQAutomationApp"): if self.single_step_current_index is None: messagebox.showinfo("提示", "请先选择一个样本") return @@ -428,7 +434,7 @@ def _send_current_patch(self): threading.Thread(target=worker, daemon=True).start() -def _measure_current_sample(self): +def _measure_current_sample(self: "PQAutomationApp"): if self.single_step_current_index is None: messagebox.showinfo("提示", "请先选择一个样本") return @@ -457,7 +463,7 @@ def _measure_current_sample(self): threading.Thread(target=worker, daemon=True).start() -def _commit_result(self): +def _commit_result(self: "PQAutomationApp"): if self.single_step_current_index is None: messagebox.showinfo("提示", "请先选择一个样本") return @@ -509,14 +515,14 @@ def _commit_result(self): self.single_step_status_var.set(f"已记录结果,ΔE2000={record['delta_e']}") -def _clear_results(self): +def _clear_results(self: "PQAutomationApp"): self.single_step_results = [] for item in self.single_step_result_tree.get_children(): self.single_step_result_tree.delete(item) self.single_step_status_var.set("结果已清空") -def _export_results_csv(self): +def _export_results_csv(self: "PQAutomationApp"): if not self.single_step_results: messagebox.showinfo("提示", "暂无可导出的调试结果") return @@ -547,4 +553,25 @@ def _export_results_csv(self): self.log_gui.log(f"单步调试结果已导出: {path}", level="success") self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}") except Exception as exc: - messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}") \ No newline at end of file + messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}") + + +class SingleStepPanelMixin: + """由 tools/refactor_to_mixins.py 自动生成。 + 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。 + """ + create_single_step_panel = create_single_step_panel + toggle_single_step_panel = toggle_single_step_panel + _load_default_samples = _load_default_samples + _refresh_sample_list = _refresh_sample_list + _on_sample_select = _on_sample_select + _select_sample = _select_sample + _import_samples_csv = _import_samples_csv + _delete_current_sample = _delete_current_sample + _upsert_sample = _upsert_sample + _build_color_patch = _build_color_patch + _send_current_patch = _send_current_patch + _measure_current_sample = _measure_current_sample + _commit_result = _commit_result + _clear_results = _clear_results + _export_results_csv = _export_results_csv diff --git a/pqAutomationApp.py b/pqAutomationApp.py index 3fa8eb8..9c2a548 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -19,14 +19,16 @@ from app.export import ( export_excel_report as _export_excel_report_impl, EXCEL_EXPORT_CONFIG as _EXCEL_EXPORT_CONFIG, ) -from app.views.panels import custom_template_panel as _ctp -from app.views.panels import side_panels as _sp -from app.views.panels import cct_panel as _ccp -from app.views.panels import main_layout as _main -from app.views.panels import ai_image_panel as _aip -from app.views.panels import single_step_panel as _ssp -from app.views.panels import pantone_baseline_panel as _pbp -from app.views import panel_manager as PM +from app.views.panels.custom_template_panel import CustomTemplatePanelMixin +from app.views.panels.side_panels import SidePanelsMixin +from app.views.panels.cct_panel import CctPanelMixin +from app.views.panels.main_layout import MainLayoutMixin +from app.views.panels.ai_image_panel import AIImagePanelMixin +from app.views.panels.single_step_panel import SingleStepPanelMixin +from app.views.panels.pantone_baseline_panel import PantoneBaselinePanelMixin +from app.views.panels.gamma_pattern_panel import GammaPatternPanelMixin +from app.views.panels.calman_panel import CalmanPanelMixin +from app.views.panel_manager import PanelManagerMixin from app.logging_setup import setup_logging, attach_gui_handler # Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持 @@ -44,77 +46,46 @@ from app.tests.color_accuracy import ( from app.tests.eotf import calculate_pq_curve as _calc_pq_curve from app.tests.gamma import calculate_gamma as _calc_gamma from app.tests.gamut import calculate_gamut_coverage as _calc_gamut_coverage -from app.plots.plot_accuracy import plot_accuracy as _plot_accuracy -from app.plots.plot_cct import plot_cct as _plot_cct -from app.plots.plot_contrast import plot_contrast as _plot_contrast -from app.plots.plot_eotf import plot_eotf as _plot_eotf -from app.plots.plot_gamma import plot_gamma as _plot_gamma -from app.plots.plot_gamut import plot_gamut as _plot_gamut -from app.views.chart_frame import ( - clear_chart as _cf_clear_chart, - create_result_chart_frame as _cf_create_result_chart_frame, - init_accuracy_chart as _cf_init_accuracy_chart, - init_cct_chart as _cf_init_cct_chart, - init_contrast_chart as _cf_init_contrast_chart, - init_eotf_chart as _cf_init_eotf_chart, - init_gamma_chart as _cf_init_gamma_chart, - init_gamut_chart as _cf_init_gamut_chart, - on_chart_tab_changed as _cf_on_chart_tab_changed, - sync_gamut_toolbar as _cf_sync_gamut_toolbar, - _on_gamut_toolbar_changed as _cf_on_gamut_toolbar_changed, - update_chart_tabs_state as _cf_update_chart_tabs_state, -) -from app.config_io import ( - clear_config_file as _cfg_clear_config_file, - get_config_path as _cfg_get_config_path, - load_pq_config as _cfg_load_pq_config, - save_pq_config as _cfg_save_pq_config, -) -from app.tests.local_dimming import ( - clear_ld_records as _ld_clear_ld_records, - measure_ld_luminance as _ld_measure_ld_luminance, - save_local_dimming_results as _ld_save_local_dimming_results, - send_ld_window as _ld_send_ld_window, - start_local_dimming_test as _ld_start_local_dimming_test, - stop_local_dimming_test as _ld_stop_local_dimming_test, - update_ld_results as _ld_update_ld_results, -) +from app.plots.plot_accuracy import PlotAccuracyMixin +from app.plots.plot_cct import PlotCctMixin +from app.plots.plot_contrast import PlotContrastMixin +from app.plots.plot_eotf import PlotEotfMixin +from app.plots.plot_gamma import PlotGammaMixin +from app.plots.plot_gamut import PlotGamutMixin +from app.views.chart_frame import ChartFrameMixin +from app.config_io import ConfigIOMixin +from app.tests.local_dimming import LocalDimmingMixin from app.services import PatternService -from app.device.connection import ( - check_com_connections as _dev_check_com_connections, - check_port_connection as _dev_check_port_connection, - disconnect_com_connections as _dev_disconnect_com_connections, - enable_com_widgets as _dev_enable_com_widgets, - get_available_com_ports as _dev_get_available_com_ports, - get_available_ucd_ports as _dev_get_available_ucd_ports, - refresh_com_ports as _dev_refresh_com_ports, - update_connection_indicator as _dev_update_connection_indicator, -) -from app.runner.test_runner import ( - get_current_test_result as _run_get_current_test_result, - new_pq_results as _run_new_pq_results, - on_custom_template_test_completed as _run_on_custom_template_test_completed, - on_test_completed as _run_on_test_completed, - on_test_error as _run_on_test_error, - run_custom_sdr_test as _run_run_custom_sdr_test, - run_hdr_movie_test as _run_run_hdr_movie_test, - run_screen_module_test as _run_run_screen_module_test, - run_sdr_movie_test as _run_run_sdr_movie_test, - run_test as _run_run_test, - send_fix_pattern as _run_send_fix_pattern, - test_cct as _run_test_cct, - test_color_accuracy as _run_test_color_accuracy, - test_contrast as _run_test_contrast, - test_custom_sdr as _run_test_custom_sdr, - test_eotf as _run_test_eotf, - test_gamma as _run_test_gamma, - test_gamut as _run_test_gamut, -) +from app.device.connection import DeviceConnectionMixin +from app.runner.test_runner import TestRunnerMixin plt.rcParams["font.family"] = ["sans-serif"] plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] -class PQAutomationApp: + +class PQAutomationApp( + ConfigIOMixin, + ChartFrameMixin, + MainLayoutMixin, + CctPanelMixin, + DeviceConnectionMixin, + CustomTemplatePanelMixin, + SidePanelsMixin, + AIImagePanelMixin, + SingleStepPanelMixin, + PantoneBaselinePanelMixin, + GammaPatternPanelMixin, + CalmanPanelMixin, + LocalDimmingMixin, + PanelManagerMixin, + TestRunnerMixin, + PlotGamutMixin, + PlotGammaMixin, + PlotEotfMixin, + PlotCctMixin, + PlotContrastMixin, + PlotAccuracyMixin, +): def __init__(self, root): self.root = root self.root.title(get_app_title()) @@ -228,6 +199,10 @@ class PQAutomationApp: # self.create_single_step_panel() # 创建 Pantone 认证摸底测试面板 self.create_pantone_baseline_panel() + # 创建 Gamma 测试图案配置面板 + self.create_gamma_pattern_panel() + # 创建 CALMAN 风格灰阶测试面板 + self.create_calman_panel() # 创建测试类型选择区域 self.create_test_type_frame() # 创建操作按钮区域 @@ -277,111 +252,6 @@ class PQAutomationApp: if hasattr(self, "log_gui"): self.log_gui.log(f"初始化默认测试类型失败: {str(e)}", level="error") - get_config_path = _cfg_get_config_path - load_pq_config = _cfg_load_pq_config - save_pq_config = _cfg_save_pq_config - - register_panel = PM.register_panel - show_panel = PM.show_panel - hide_all_panels = PM.hide_all_panels - - init_gamut_chart = _cf_init_gamut_chart - init_gamma_chart = _cf_init_gamma_chart - init_eotf_chart = _cf_init_eotf_chart - init_cct_chart = _cf_init_cct_chart - init_contrast_chart = _cf_init_contrast_chart - init_accuracy_chart = _cf_init_accuracy_chart - clear_chart = _cf_clear_chart - create_result_chart_frame = _cf_create_result_chart_frame - on_chart_tab_changed = _cf_on_chart_tab_changed - sync_gamut_toolbar = _cf_sync_gamut_toolbar - _on_gamut_toolbar_changed = _cf_on_gamut_toolbar_changed - - create_floating_config_panel = _main.create_floating_config_panel - create_test_items_content = _main.create_test_items_content - create_signal_format_content = _main.create_signal_format_content - create_connection_content = _main.create_connection_content - create_operation_frame = _main.create_operation_frame - create_test_type_frame = _main.create_test_type_frame - update_config_info_display = _main.update_config_info_display - on_screen_module_timing_changed = _main.on_screen_module_timing_changed - on_sdr_timing_changed = _main.on_sdr_timing_changed - update_test_items = _main.update_test_items - on_test_type_change = _main.on_test_type_change - on_sdr_output_format_changed = _main.on_sdr_output_format_changed - on_hdr_output_format_changed = _main.on_hdr_output_format_changed - - create_cct_params_frame = _ccp.create_cct_params_frame - on_sdr_cct_param_focus_out = _ccp.on_sdr_cct_param_focus_out - save_sdr_cct_params = _ccp.save_sdr_cct_params - on_hdr_cct_param_focus_out = _ccp.on_hdr_cct_param_focus_out - save_hdr_cct_params = _ccp.save_hdr_cct_params - recalculate_cct = _ccp.recalculate_cct - recalculate_gamut = _ccp.recalculate_gamut - on_cct_param_focus_out = _ccp.on_cct_param_focus_out - save_cct_params = _ccp.save_cct_params - reload_cct_params = _ccp.reload_cct_params - toggle_cct_params_frame = _ccp.toggle_cct_params_frame - on_screen_gamut_ref_changed = _ccp.on_screen_gamut_ref_changed - on_sdr_gamut_ref_changed = _ccp.on_sdr_gamut_ref_changed - on_hdr_gamut_ref_changed = _ccp.on_hdr_gamut_ref_changed - - get_available_ucd_ports = _dev_get_available_ucd_ports - get_available_com_ports = _dev_get_available_com_ports - refresh_com_ports = _dev_refresh_com_ports - check_com_connections = _dev_check_com_connections - update_connection_indicator = _dev_update_connection_indicator - check_port_connection = _dev_check_port_connection - enable_com_widgets = _dev_enable_com_widgets - disconnect_com_connections = _dev_disconnect_com_connections - - create_custom_template_result_panel = _ctp.create_custom_template_result_panel - show_custom_result_context_menu = _ctp.show_custom_result_context_menu - set_custom_result_table_locked = _ctp.set_custom_result_table_locked - start_custom_row_single_step = _ctp.start_custom_row_single_step - copy_custom_result_table = _ctp.copy_custom_result_table - clear_custom_template_results = _ctp.clear_custom_template_results - auto_expand_custom_result_view = _ctp.auto_expand_custom_result_view - append_custom_template_result = _ctp.append_custom_template_result - start_custom_template_test = _ctp.start_custom_template_test - update_custom_button_visibility = _ctp.update_custom_button_visibility - export_custom_template_excel = _ctp.export_custom_template_excel - export_custom_template_charts = _ctp.export_custom_template_charts - - create_log_panel = _sp.create_log_panel - create_local_dimming_panel = _sp.create_local_dimming_panel - toggle_local_dimming_panel = _sp.toggle_local_dimming_panel - toggle_log_panel = _sp.toggle_log_panel - update_sidebar_selection = _sp.update_sidebar_selection - - # ---- AI 图片对话面板 ---- - create_ai_image_panel = _aip.create_ai_image_panel - toggle_ai_image_panel = _aip.toggle_ai_image_panel - reload_ai_image_list = _aip.reload_ai_image_list - - # ---- 单步调试面板 ---- - create_single_step_panel = _ssp.create_single_step_panel - toggle_single_step_panel = _ssp.toggle_single_step_panel - - # ---- Pantone 认证摸底测试面板 ---- - create_pantone_baseline_panel = _pbp.create_pantone_baseline_panel - toggle_pantone_baseline_panel = _pbp.toggle_pantone_baseline_panel - - # ---- 单步调试面板(统一实现,委托到 side_panels 模块) ---- - _toggle_debug_panel = _sp._toggle_debug_panel - toggle_screen_debug_panel = _sp.toggle_screen_debug_panel - toggle_sdr_debug_panel = _sp.toggle_sdr_debug_panel - toggle_hdr_debug_panel = _sp.toggle_hdr_debug_panel - - clear_config_file = _cfg_clear_config_file - start_local_dimming_test = _ld_start_local_dimming_test - update_ld_results = _ld_update_ld_results - stop_local_dimming_test = _ld_stop_local_dimming_test - send_ld_window = _ld_send_ld_window - measure_ld_luminance = _ld_measure_ld_luminance - clear_ld_records = _ld_clear_ld_records - save_local_dimming_results = _ld_save_local_dimming_results - def _save_current_cct_params(self, swallow_errors=True): """按当前测试类型分发保存对应的 CCT 参数。""" try: @@ -550,6 +420,7 @@ class PQAutomationApp: "ai_image", "single_step", "pantone_baseline", + "gamma_pattern", ): self.hide_all_panels() self._save_cct_params_before_test_type_switch() @@ -843,21 +714,8 @@ class PQAutomationApp: self.log_gui.log(traceback.format_exc(), level="error") messagebox.showerror("错误", f"保存测试结果失败: {str(e)}") - new_pq_results = _run_new_pq_results - run_test = _run_run_test - run_screen_module_test = _run_run_screen_module_test - run_custom_sdr_test = _run_run_custom_sdr_test - run_sdr_movie_test = _run_run_sdr_movie_test - run_hdr_movie_test = _run_run_hdr_movie_test - send_fix_pattern = _run_send_fix_pattern - test_custom_sdr = _run_test_custom_sdr - test_gamut = _run_test_gamut - test_gamma = _run_test_gamma - test_eotf = _run_test_eotf - test_cct = _run_test_cct - test_contrast = _run_test_contrast - test_color_accuracy = _run_test_color_accuracy - + # 纯算法函数:作为 staticmethod 保留在主类(不依赖 self,且 calculate_xxx + # 的命名空间由历史代码以 self.calculate_xxx 调用)。 calculate_delta_e_2000 = staticmethod(_calc_delta_e_2000) get_accuracy_color_standards = staticmethod(_get_accuracy_color_standards) calculate_gamut_coverage = staticmethod(_calc_gamut_coverage) @@ -865,20 +723,6 @@ class PQAutomationApp: calculate_color_accuracy = staticmethod(_calc_color_accuracy) calculate_pq_curve = staticmethod(_calc_pq_curve) - plot_gamut = _plot_gamut - plot_gamma = _plot_gamma - plot_eotf = _plot_eotf - plot_cct = _plot_cct - plot_contrast = _plot_contrast - plot_accuracy = _plot_accuracy - - on_test_completed = _run_on_test_completed - on_custom_template_test_completed = _run_on_custom_template_test_completed - get_current_test_result = _run_get_current_test_result - on_test_error = _run_on_test_error - - update_chart_tabs_state = _cf_update_chart_tabs_state - def get_test_type_name(self, test_type): """获取测试类型的显示名称""" if test_type == "screen_module": diff --git a/tools/refactor_to_mixins.py b/tools/refactor_to_mixins.py new file mode 100644 index 0000000..503a115 --- /dev/null +++ b/tools/refactor_to_mixins.py @@ -0,0 +1,224 @@ +"""一次性脚本:将外置模块中的 `def f(self, ...)` 自由函数转换为 Mixin 方式。 + +操作: +1. 给所有顶层 `def f(self, ...)` 加 `self: "PQAutomationApp"` 注解(仅注解,不移动)。 +2. 在文件顶部(首个 def/class 之前、import 块之后)插入 TYPE_CHECKING 块。 +3. 在文件末尾追加 `class XxxMixin:` 把这些函数作为类属性挂上。 + +不会改变: +- 函数体(包括内部 `_xxx(self, ...)` 直接调用)。 +- 已存在的类、模块级常量。 +""" +from __future__ import annotations + +import ast +import io +import os +import re +import sys +import tokenize +from dataclasses import dataclass + + +# 文件 -> Mixin 类名 +TARGETS: dict[str, str] = { + "app/config_io.py": "ConfigIOMixin", + "app/views/chart_frame.py": "ChartFrameMixin", + "app/views/panels/main_layout.py": "MainLayoutMixin", + "app/views/panels/cct_panel.py": "CctPanelMixin", + "app/device/connection.py": "DeviceConnectionMixin", + "app/views/panels/custom_template_panel.py": "CustomTemplatePanelMixin", + "app/views/panels/side_panels.py": "SidePanelsMixin", + "app/views/panels/ai_image_panel.py": "AIImagePanelMixin", + "app/views/panels/single_step_panel.py": "SingleStepPanelMixin", + "app/views/panels/pantone_baseline_panel.py": "PantoneBaselinePanelMixin", + "app/tests/local_dimming.py": "LocalDimmingMixin", + "app/views/panel_manager.py": "PanelManagerMixin", + "app/runner/test_runner.py": "TestRunnerMixin", + "app/plots/plot_gamut.py": "PlotGamutMixin", + "app/plots/plot_gamma.py": "PlotGammaMixin", + "app/plots/plot_eotf.py": "PlotEotfMixin", + "app/plots/plot_cct.py": "PlotCctMixin", + "app/plots/plot_contrast.py": "PlotContrastMixin", + "app/plots/plot_accuracy.py": "PlotAccuracyMixin", +} + + +TYPE_CHECKING_BLOCK = ( + "from typing import TYPE_CHECKING\n" + "\n" + "if TYPE_CHECKING:\n" + " from pqAutomationApp import PQAutomationApp\n" + "\n" +) + + +@dataclass +class SelfFunc: + name: str + lineno: int # 1-based, line of `def` + col_offset: int + end_lineno: int + def_line_idx: int # 0-based line index of `def ...` line + self_token_line_idx: int # 0-based line index of `self` + self_token_col: int + + +def _read(path: str) -> str: + with open(path, "rb") as f: + data = f.read() + # 去 BOM + if data.startswith(b"\xef\xbb\xbf"): + data = data[3:] + return data.decode("utf-8") + + +def _write(path: str, text: str) -> None: + with open(path, "w", encoding="utf-8", newline="\n") as f: + f.write(text) + + +def _locate_self_token(src_lines: list[str], def_line_idx: int) -> tuple[int, int] | None: + """在 def 行(可能多行签名)中定位首个参数 `self` 的位置。""" + # 用 tokenize 解析从 def 行开始的片段 + snippet = "".join(src_lines[def_line_idx:]) + try: + tokens = list(tokenize.generate_tokens(io.StringIO(snippet).readline)) + except tokenize.TokenizeError: + return None + saw_open_paren = False + for tok in tokens: + if tok.type == tokenize.OP and tok.string == "(": + saw_open_paren = True + continue + if saw_open_paren and tok.type == tokenize.NAME and tok.string == "self": + # tok.start 是 (row, col) 相对于 snippet(1-based row) + row_in_snippet = tok.start[0] - 1 + col = tok.start[1] + return (def_line_idx + row_in_snippet, col) + if saw_open_paren and tok.type == tokenize.OP and tok.string in (")", ","): + # 第一个参数不是 self —— 跳过 + return None + return None + + +def collect_self_funcs(src: str) -> tuple[ast.Module, list[SelfFunc]]: + tree = ast.parse(src) + src_lines = src.splitlines(keepends=True) + results: list[SelfFunc] = [] + for node in tree.body: + if isinstance(node, ast.FunctionDef) and node.args.args and node.args.args[0].arg == "self": + def_idx = node.lineno - 1 + pos = _locate_self_token(src_lines, def_idx) + if pos is None: + continue + results.append(SelfFunc( + name=node.name, + lineno=node.lineno, + col_offset=node.col_offset, + end_lineno=node.end_lineno or node.lineno, + def_line_idx=def_idx, + self_token_line_idx=pos[0], + self_token_col=pos[1], + )) + return tree, results + + +def annotate_self(src: str, funcs: list[SelfFunc]) -> str: + """把每个 def 的首个 `self` 形参替换为 `self: "PQAutomationApp"`。""" + lines = src.splitlines(keepends=True) + # 从后往前替换,避免行号变动 + for fn in sorted(funcs, key=lambda f: -f.self_token_line_idx): + line = lines[fn.self_token_line_idx] + col = fn.self_token_col + # 检查后续是否已经有注解 + after = line[col + len("self"):] + # 已经注解过则跳过 + m = re.match(r"\s*:", after) + if m: + continue + new_line = line[:col] + 'self: "PQAutomationApp"' + line[col + len("self"):] + lines[fn.self_token_line_idx] = new_line + return "".join(lines) + + +def ensure_type_checking_block(src: str) -> str: + if "from pqAutomationApp import PQAutomationApp" in src: + return src + # 找到首个非 docstring / 非注释 / 非 import 的位置: + # 简单策略:在最后一个 import 行之后插入;若没有 import,则在 docstring 之后插入。 + lines = src.splitlines(keepends=True) + insert_idx = 0 + in_docstring = False + doc_quote: str | None = None + last_import_idx = -1 + for i, line in enumerate(lines): + stripped = line.lstrip() + if i == 0 and (stripped.startswith('"""') or stripped.startswith("'''")): + q = stripped[:3] + doc_quote = q + if stripped.count(q) >= 2 and len(stripped) > 3: + # 单行 docstring + last_import_idx = max(last_import_idx, i) + continue + in_docstring = True + continue + if in_docstring: + if doc_quote and doc_quote in line: + in_docstring = False + last_import_idx = max(last_import_idx, i) + continue + if stripped.startswith("import ") or stripped.startswith("from "): + last_import_idx = i + continue + if stripped == "" or stripped.startswith("#"): + continue + # 遇到第一个真实代码 + break + insert_idx = last_import_idx + 1 + new_lines = lines[:insert_idx] + ["\n", TYPE_CHECKING_BLOCK] + lines[insert_idx:] + return "".join(new_lines) + + +def append_mixin(src: str, mixin_name: str, func_names: list[str]) -> str: + if f"class {mixin_name}" in src: + return src + body_lines = [] + body_lines.append("") + body_lines.append("") + body_lines.append(f"class {mixin_name}:") + body_lines.append(f' """由 tools/refactor_to_mixins.py 自动生成。') + body_lines.append(" 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。") + body_lines.append(' """') + for name in func_names: + body_lines.append(f" {name} = {name}") + text = "\n".join(body_lines) + "\n" + if not src.endswith("\n"): + src += "\n" + return src + text + + +def process(path: str, mixin_name: str) -> None: + src = _read(path) + tree, funcs = collect_self_funcs(src) + if not funcs: + print(f" -> skip (no self-funcs)") + return + func_names = [f.name for f in funcs] + new_src = annotate_self(src, funcs) + new_src = ensure_type_checking_block(new_src) + new_src = append_mixin(new_src, mixin_name, func_names) + _write(path, new_src) + print(f" -> {mixin_name} with {len(func_names)} methods") + + +def main(): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + os.chdir(root) + for rel, mixin in TARGETS.items(): + print(f"Processing {rel}") + process(rel, mixin) + + +if __name__ == "__main__": + main()