"""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 _hex_to_rgb(hex_color: str) -> tuple[int, int, int]: c = hex_color.lstrip("#") return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16) def _mix(hex_a: str, hex_b: str, ratio: float) -> str: r1, g1, b1 = _hex_to_rgb(hex_a) r2, g2, b2 = _hex_to_rgb(hex_b) r = int(r1 * (1 - ratio) + r2 * ratio) g = int(g1 * (1 - ratio) + g2 * ratio) b = int(b1 * (1 - ratio) + b2 * ratio) return f"#{r:02x}{g:02x}{b:02x}" def _is_dark_hex(hex_color: str) -> bool: r, g, b = _hex_to_rgb(hex_color) return (r * 299 + g * 587 + b * 114) / 1000 < 128 def _get_calman_palette() -> dict[str, str]: """根据当前主题生成 Calman 调试面板色板。""" style = ttk.Style() colors = style.colors bg = colors.bg fg = colors.fg dark_mode = _is_dark_hex(bg) if dark_mode: figure_bg = _mix(bg, "#000000", 0.18) axes_bg = _mix(bg, "#000000", 0.25) grid = _mix(fg, axes_bg, 0.55) tree_bg = _mix(bg, "#000000", 0.10) tree_even = _mix(bg, "#ffffff", 0.03) tree_odd = _mix(bg, "#ffffff", 0.07) heading_bg = _mix(bg, "#ffffff", 0.12) reading_bg = _mix(bg, "#ffffff", 0.06) reading_fg = _mix(fg, "#ffffff", 0.06) status_fg = _mix(fg, bg, 0.35) reading_accent = colors.info xy_series = "#d7dce4" d65_mark = "#ffffff" else: figure_bg = _mix(bg, "#dfe7ef", 0.45) axes_bg = _mix(bg, "#eff4f9", 0.72) grid = _mix("#5f6f82", axes_bg, 0.55) tree_bg = "#ffffff" tree_even = "#ffffff" tree_odd = "#f3f7fb" heading_bg = _mix(colors.primary, "#ffffff", 0.82) reading_bg = _mix(bg, "#e7eef5", 0.58) reading_fg = fg status_fg = _mix(fg, bg, 0.25) reading_accent = _mix(colors.info, "#000000", 0.25) xy_series = "#1f2a36" d65_mark = "#253142" return { "figure_bg": figure_bg, "axes_bg": axes_bg, "fg": fg, "grid": grid, "metric_tile_bg": _mix(figure_bg, "#ffffff", 0.08 if dark_mode else 0.25), "metric_tile_fg": reading_fg, "status_fg": status_fg, "reading_accent": reading_accent, "reading_bg": reading_bg, "reading_fg": reading_fg, "tree_bg": tree_bg, "tree_fg": reading_fg, "tree_even": tree_even, "tree_odd": tree_odd, "tree_heading_bg": heading_bg, "tree_heading_fg": reading_fg, "tree_select": _mix(colors.info, figure_bg, 0.35), "xy_series": xy_series, "d65_mark": d65_mark, } 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(self: "PQAutomationApp", ax, title: str) -> None: palette = _get_calman_palette() ax.set_title(title, color=palette["fg"], fontsize=9, pad=4) ax.set_facecolor(palette["axes_bg"]) ax.grid(True, color=palette["grid"], alpha=0.35, linewidth=0.6) ax.tick_params(colors=palette["fg"], labelsize=8) for spine in ax.spines.values(): spine.set_color(_mix(palette["fg"], palette["axes_bg"], 0.55)) def _apply_calman_tree_style(self: "PQAutomationApp") -> None: """应用矩阵 Treeview 的深浅色样式。""" palette = _get_calman_palette() style = ttk.Style() style.configure( "Calman.Treeview", rowheight=22, font=("Consolas", 9), background=palette["tree_bg"], fieldbackground=palette["tree_bg"], foreground=palette["tree_fg"], ) style.map( "Calman.Treeview", background=[("selected", palette["tree_select"])], foreground=[("selected", palette["tree_fg"])], ) style.configure( "Calman.Treeview.Heading", font=("微软雅黑", 9, "bold"), background=palette["tree_heading_bg"], foreground=palette["tree_heading_fg"], ) if hasattr(self, "calman_metric_tree"): self.calman_metric_tree.configure(style="Calman.Treeview") if hasattr(self, "calman_data_tree"): self.calman_data_tree.configure(style="Calman.Treeview") def _calman_log(self: "PQAutomationApp", message: str, level: str = "info") -> None: """统一输出 Calman 面板日志。""" logger = getattr(self, "log_gui", None) if logger is None: return self._dispatch_ui(self.log_gui.log, f"CALMAN: {message}", level) def _build_calman_config_summary(self: "PQAutomationApp") -> str: """生成顶部配置摘要,跟随当前测试类型展示 UCD 参数。""" cfg = getattr(self, "config", None) test_type = getattr(cfg, "current_test_type", "screen_module") test_cfg = {} if cfg is not None: test_cfg = getattr(cfg, "current_test_types", {}).get(test_type, {}) if test_type == "screen_module": color_space = getattr(getattr(self, "screen_module_color_space_var", None), "get", lambda: test_cfg.get("colorimetry", "-"))() output_format = getattr(getattr(self, "screen_module_output_format_var", None), "get", lambda: test_cfg.get("color_format", "-"))() bit_depth = getattr(getattr(self, "screen_module_bit_depth_var", None), "get", lambda: f"{int(test_cfg.get('bpc', 8))}bit")() data_range = getattr(getattr(self, "screen_module_data_range_var", None), "get", lambda: test_cfg.get("data_range", "Full"))() timing = test_cfg.get("timing", "-") profile_name = "Screen" elif test_type == "sdr_movie": color_space = getattr(getattr(self, "sdr_color_space_var", None), "get", lambda: test_cfg.get("colorimetry", "-"))() output_format = getattr(getattr(self, "sdr_output_format_var", None), "get", lambda: test_cfg.get("color_format", "-"))() bit_depth = getattr(getattr(self, "sdr_bit_depth_var", None), "get", lambda: f"{int(test_cfg.get('bpc', 8))}bit")() data_range = getattr(getattr(self, "sdr_data_range_var", None), "get", lambda: test_cfg.get("data_range", "Full"))() timing = test_cfg.get("timing", "-") profile_name = "SDR" elif test_type == "hdr_movie": color_space = getattr(getattr(self, "hdr_color_space_var", None), "get", lambda: test_cfg.get("colorimetry", "-"))() output_format = getattr(getattr(self, "hdr_output_format_var", None), "get", lambda: test_cfg.get("color_format", "-"))() bit_depth = getattr(getattr(self, "hdr_bit_depth_var", None), "get", lambda: f"{int(test_cfg.get('bpc', 10))}bit")() data_range = getattr(getattr(self, "hdr_data_range_var", None), "get", lambda: test_cfg.get("data_range", "Limited"))() timing = test_cfg.get("timing", "-") profile_name = "HDR" else: color_space = test_cfg.get("colorimetry", "-") output_format = test_cfg.get("color_format", "-") bit_depth = test_cfg.get("bpc", "-") data_range = test_cfg.get("data_range", "-") timing = test_cfg.get("timing", "-") profile_name = test_type return ( f"Profile: {profile_name} | Timing: {timing} | CS: {color_space} | " f"Fmt: {output_format} | Depth: {bit_depth} | Range: {data_range}" ) def _refresh_calman_config_summary(self: "PQAutomationApp") -> None: if hasattr(self, "calman_config_summary_var"): self.calman_config_summary_var.set(_build_calman_config_summary(self)) def create_calman_panel(self: "PQAutomationApp") -> None: """创建 CALMAN 风格灰阶测试面板,注册到 panel_manager。""" palette = _get_calman_palette() 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_patch_send_busy = 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=palette["figure_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=palette["figure_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") self.calman_elapsed_label = ttk.Label( control_row, textvariable=self.calman_elapsed_var, foreground=palette["status_fg"], ) self.calman_elapsed_label.pack(side=tk.LEFT) self.calman_config_summary_var = tk.StringVar(value="") self.calman_config_summary_label = ttk.Label( control_row, textvariable=self.calman_config_summary_var, foreground=palette["status_fg"], anchor=tk.W, ) self.calman_config_summary_label.pack(side=tk.LEFT, padx=(12, 0), fill=tk.X, expand=True) 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: --") self.calman_metric_labels = [] for idx, v in enumerate( ( self.calman_avg_de_var, self.calman_avg_cct_var, self.calman_contrast_var, self.calman_avg_gamma_var, ) ): lbl = tk.Label( metrics_row, textvariable=v, anchor=tk.CENTER, fg=palette["metric_tile_fg"], bg=palette["metric_tile_bg"], font=("微软雅黑", 10, "bold"), ) lbl.grid(row=0, column=idx, sticky=tk.EW, padx=2) self.calman_metric_labels.append(lbl) # ---------------------------- 顶部右:按钮列 ---------------------------- 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="待机") self.calman_status_label = ttk.Label( btn_col, textvariable=self.calman_status_var, foreground=palette["status_fg"], wraplength=150, justify=tk.LEFT, ) self.calman_status_label.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: --" ) self.calman_reading_summary_label = ttk.Label( btn_col, textvariable=self.calman_reading_var, font=("Consolas", 9), foreground=palette["reading_accent"], wraplength=160, justify=tk.LEFT, ) self.calman_reading_summary_label.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): def _on_click(_e, pp=p): send_patch(self, pp) # Prevent event bubbling from canvas -> parent cell, which would # otherwise trigger duplicated sends for a single click. return "break" widget.bind("", _on_click) 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: --" ) self.calman_reading_detail_label = tk.Label( left, textvariable=self.calman_reading_var, justify=tk.LEFT, font=("Consolas", 10), fg=palette["reading_fg"], bg=palette["reading_bg"], width=22, padx=4, pady=4, ) self.calman_reading_detail_label.pack(fill=tk.X) xy_fig = Figure(figsize=(2.6, 2.2), dpi=90, facecolor=palette["figure_bg"]) self.calman_xy_fig = xy_fig 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=palette["figure_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)) _apply_calman_tree_style(self) right.bind("", lambda _e: _adaptive_matrix_columns(self)) _refresh_metric_table(self) _refresh_calman_config_summary(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") _refresh_calman_config_summary(self) # --------------------------------------------------------------------------- # 发送 / 测量 # --------------------------------------------------------------------------- def send_patch(self: "PQAutomationApp", pct: int) -> None: """点击色块时,发送对应灰阶图案到信号发生器。""" if not self.signal_service.is_connected: messagebox.showwarning("提示", "请先连接 UCD323 设备") return if getattr(self, "calman_patch_send_busy", False): _calman_log(self, f"send busy, ignore click pct={pct}", "warning") self.calman_status_var.set("发送进行中,请稍候...") 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) _refresh_calman_config_summary(self) _calman_log(self, f"click patch pct={pct}, rgb=({rgb_val}, {rgb_val}, {rgb_val})") self.calman_patch_send_busy = True def worker(): try: _calman_log(self, f"send_solid_rgb start pct={pct}") test_type = getattr(self.config, "current_test_type", "screen_module") if hasattr(self, "pattern_service") and self.pattern_service is not None: self.pattern_service.send_rgb( (rgb_val, rgb_val, rgb_val), test_type=test_type, ) else: self.signal_service.send_solid_rgb((rgb_val, rgb_val, rgb_val)) _calman_log(self, f"send_solid_rgb success pct={pct}") _calman_log(self, f"ucd profile applied test_type={test_type}") 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: _calman_log(self, f"send_solid_rgb failed pct={pct}: {exc}", "error") self._dispatch_ui(self.log_gui.log, f"发送失败: {exc}", "error") self._dispatch_ui(self.calman_status_var.set, f"发送失败: {exc}") finally: self.calman_patch_send_busy = False 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() _calman_log(self, f"measure start pct={pct}") self._dispatch_ui(self.calman_status_var.set, f"采集 {pct}% 中...") rec = _measure_once(self, pct) if rec is None: _calman_log(self, f"measure failed pct={pct}", "error") 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 _calman_log( self, ( "measure success pct={pct}, x={x:.4f}, y={y:.4f}, Y={Y:.3f}, " "cct={cct:.0f}, gamma={gamma:.3f}, de2000={de:.3f}, step={step:.2f}s" ).format( pct=pct, x=rec["x"], y=rec["y"], Y=rec["Y"], cct=rec["cct"] if rec["cct"] == rec["cct"] else float("nan"), gamma=rec["gamma"] if rec["gamma"] == rec["gamma"] else float("nan"), de=rec["de2000"] if rec["de2000"] == rec["de2000"] else float("nan"), step=step_s, ), ) 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") _refresh_calman_config_summary(self) _calman_log(self, f"sequence start levels={len(self.calman_levels)}, settle={settle:.2f}s") def worker(): seq_t0 = time.perf_counter() try: test_type = getattr(self.config, "current_test_type", "screen_module") rgb_session = None if hasattr(self, "pattern_service") and self.pattern_service is not None: rgb_session = self.pattern_service.prepare_session( "rgb", test_type=test_type, log_details=False, ) _calman_log(self, f"sequence ucd profile applied test_type={test_type}") 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(): _calman_log(self, f"sequence stop requested at step={i-1}/{total}", "warning") 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}%" ) _calman_log(self, f"sequence send step={i}/{total}, pct={pct}, rgb={rgb_val}") self._dispatch_ui(_highlight_patch, self, pct) try: if rgb_session is not None: self.pattern_service.send_rgb( (rgb_val, rgb_val, rgb_val), session=rgb_session, ) else: self.signal_service.send_solid_rgb( (rgb_val, rgb_val, rgb_val) ) except Exception as exc: _calman_log(self, f"sequence send failed step={i}/{total}, pct={pct}: {exc}", "error") self._dispatch_ui( self.log_gui.log, f"发送 {pct}% 失败: {exc}", "error" ) continue self.calman_current_level = pct # 等待稳定,停止事件触发时尽快退出 if self.calman_stop_event.wait(settle): _calman_log(self, f"sequence interrupted during settle step={i}/{total}, pct={pct}", "warning") break rec = _measure_once(self, pct) if rec is None: _calman_log(self, f"sequence measure failed step={i}/{total}, pct={pct}", "error") continue self.calman_results[pct] = rec _calman_log( self, ( "sequence measure step={i}/{total}, pct={pct}, x={x:.4f}, y={y:.4f}, Y={Y:.3f}, " "cct={cct:.0f}, gamma={gamma:.3f}, de2000={de:.3f}" ).format( i=i, total=total, pct=pct, x=rec["x"], y=rec["y"], Y=rec["Y"], cct=rec["cct"] if rec["cct"] == rec["cct"] else float("nan"), gamma=rec["gamma"] if rec["gamma"] == rec["gamma"] else float("nan"), de=rec["de2000"] if rec["de2000"] == rec["de2000"] else float("nan"), ), ) 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: _calman_log(self, f"sequence complete total={total}") self._dispatch_ui(self.calman_status_var.set, "连续测试完成") self._dispatch_ui(self.log_gui.log, "CALMAN: 连续测试完成", "success") return _calman_log(self, "sequence stopped", "warning") 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: _calman_log(self, "stop requested", "warning") self.calman_stop_event.set() self.calman_status_var.set("正在停止...") else: _calman_log(self, "stop requested but no sequence is running", "warning") self.calman_status_var.set("当前没有运行中的连续测试") def clear_results(self: "PQAutomationApp") -> None: """清空结果表和图表。""" _calman_log(self, "clear results") 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 散点。""" palette = _get_calman_palette() if hasattr(self, "calman_fig"): self.calman_fig.patch.set_facecolor(palette["figure_bg"]) if hasattr(self, "calman_canvas"): try: self.calman_canvas.get_tk_widget().configure( bg=palette["figure_bg"], highlightthickness=0 ) except Exception: pass 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(self, 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(self, 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(self, 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(self, 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: palette = _get_calman_palette() if hasattr(self, "calman_xy_fig"): self.calman_xy_fig.patch.set_facecolor(palette["figure_bg"]) if hasattr(self, "calman_xy_canvas"): try: self.calman_xy_canvas.get_tk_widget().configure( bg=palette["figure_bg"], highlightthickness=0 ) except Exception: pass ax = self.calman_xy_ax ax.clear() _style_axes(self, 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=palette["d65_mark"], 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=palette["xy_series"], 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=_mix(palette["fg"], palette["axes_bg"], 0.4), 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: """重绘下方矩阵表。""" _apply_calman_tree_style(self) palette = _get_calman_palette() 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=palette["tree_odd"], foreground=palette["tree_fg"] ) self.calman_metric_tree.tag_configure( "even", background=palette["tree_even"], foreground=palette["tree_fg"] ) self.calman_data_tree.tag_configure( "odd", background=palette["tree_odd"], foreground=palette["tree_fg"] ) self.calman_data_tree.tag_configure( "even", background=palette["tree_even"], foreground=palette["tree_fg"] ) first, last = self.calman_data_tree.yview() self.calman_table_ysb.set(first, last) def refresh_calman_theme(self: "PQAutomationApp") -> None: """主题切换后刷新 Calman 灰阶调试面板的表格与图表颜色。""" if not hasattr(self, "calman_frame"): return palette = _get_calman_palette() if hasattr(self, "calman_elapsed_label"): self.calman_elapsed_label.configure(foreground=palette["status_fg"]) if hasattr(self, "calman_config_summary_label"): self.calman_config_summary_label.configure(foreground=palette["status_fg"]) if hasattr(self, "calman_status_label"): self.calman_status_label.configure(foreground=palette["status_fg"]) if hasattr(self, "calman_reading_summary_label"): self.calman_reading_summary_label.configure(foreground=palette["reading_accent"]) if hasattr(self, "calman_reading_detail_label"): self.calman_reading_detail_label.configure( fg=palette["reading_fg"], bg=palette["reading_bg"], ) for lbl in getattr(self, "calman_metric_labels", []): lbl.configure( fg=palette["metric_tile_fg"], bg=palette["metric_tile_bg"], ) _refresh_metric_table(self) _refresh_calman_config_summary(self) _redraw_calman_charts(self) class CalmanPanelMixin: """挂载本模块的自由函数到 PQAutomationApp。""" create_calman_panel = create_calman_panel toggle_calman_panel = toggle_calman_panel refresh_calman_theme = refresh_calman_theme