"""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
from app.views.modern_styles import get_theme_palette

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

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=get_theme_palette()["border"])
    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
    theme_palette = get_theme_palette()
    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 = _mix(fg, "#ffffff", 0.10)
        d65_mark = _mix(fg, "#ffffff", 0.04)
    else:
        figure_bg = _mix(bg, "#dfe7ef", 0.45)
        axes_bg = _mix(bg, "#eff4f9", 0.72)
        grid = _mix("#5f6f82", axes_bg, 0.55)
        tree_bg = theme_palette["input_bg"]
        tree_even = theme_palette["input_bg"]
        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 = _mix(fg, bg, 0.18)
        d65_mark = _mix(fg, bg, 0.28)

    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),
        "patch_border": theme_palette["border"],
        "patch_border_alt": _mix(theme_palette["border"], theme_palette["fg"], 0.12),
        "patch_focus": theme_palette["focus"],
        "xy_series": xy_series,
        "d65_mark": d65_mark,
    }


def _xyY_to_rgb_balance(x: float, y: float, big_y: float) -> tuple[float, float, float]:
    """按 D65 同亮度参考计算 RGB Balance（Calman 常见口径）。"""
    if y <= 0 or big_y <= 0:
        return float("nan"), float("nan"), float("nan")

    def _xyY_to_xyz(cx: float, cy: float, cy_big: float) -> tuple[float, float, float]:
        if cy <= 0:
            return float("nan"), float("nan"), float("nan")
        cx_big = (cx * cy_big) / cy
        cz_big = ((1.0 - cx - cy) * cy_big) / cy
        return cx_big, cy_big, cz_big

    def _xyz_to_linear_rgb(cx_big: float, cy_big: float, cz_big: float) -> tuple[float, float, float]:
        rr = (3.2406 * cx_big) + (-1.5372 * cy_big) + (-0.4986 * cz_big)
        gg = (-0.9689 * cx_big) + (1.8758 * cy_big) + (0.0415 * cz_big)
        bb = (0.0557 * cx_big) + (-0.2040 * cy_big) + (1.0570 * cz_big)
        return rr, gg, bb

    mx, my, mz = _xyY_to_xyz(x, y, big_y)
    tx, ty, tz = _xyY_to_xyz(D65_X, D65_Y, big_y)
    mr, mg, mb = _xyz_to_linear_rgb(mx, my, mz)
    tr, tg, tb = _xyz_to_linear_rgb(tx, ty, tz)

    eps = 1e-9
    if tr <= eps or tg <= eps or tb <= eps:
        return float("nan"), float("nan"), float("nan")

    rr = (mr / tr) * 100.0
    gg = (mg / tg) * 100.0
    bb = (mb / tb) * 100.0

    # 明显异常值视为无效，避免图表被离群点拉坏。
    if not (math.isfinite(rr) and math.isfinite(gg) and math.isfinite(bb)):
        return float("nan"), float("nan"), float("nan")
    if rr < 0 or gg < 0 or bb < 0:
        return float("nan"), float("nan"), float("nan")

    return rr, gg, bb


def _target_gamma_loglog_curve(pct: int) -> float:
    """Calman风格目标曲线：低灰阶从 1.8 过渡并逐步逼近 2.2。"""
    if pct <= 0:
        return 1.8
    return TARGET_GAMMA - 0.4 * math.exp(-pct / 6.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.view_stack)
    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 = []
    patch_palette = _get_calman_palette()
    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=patch_palette["patch_border_alt"],
            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=patch_palette["patch_border"],
            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("<Button-1>", _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("<MouseWheel>", lambda e: _matrix_mousewheel(self, e))

    _apply_calman_tree_style(self)

    right.bind("<Configure>", lambda _e: _schedule_adaptive_matrix_columns(self))

    self.root.after_idle(lambda: _finish_calman_panel_init(self))

    # 注册到统一面板管理（按钮稍后由 main_layout 注入）
    self.register_panel("calman", self.calman_frame, None, "calman_visible")


def _finish_calman_panel_init(self: "PQAutomationApp") -> None:
    """延迟初始化 CALMAN 图表与表格，避免阻塞主窗口首屏。"""
    _refresh_metric_table(self)
    _refresh_calman_config_summary(self)
    _update_target_strip(self)
    _update_actual_strip(self)
    _redraw_calman_charts(self)
    self._calman_panel_initialized = True


def _on_calman_panel_shown(self: "PQAutomationApp") -> None:
    """面板切到前台时只做轻量刷新，不重绘 Matplotlib。"""
    _refresh_calman_config_summary(self)
    _schedule_adaptive_matrix_columns(self)


def _schedule_adaptive_matrix_columns(self: "PQAutomationApp") -> None:
    job = getattr(self, "_calman_matrix_col_job", None)
    if job is not None:
        try:
            self.root.after_cancel(job)
        except Exception:
            pass
    self._calman_matrix_col_job = self.root.after_idle(
        lambda: _adaptive_matrix_columns(self)
    )


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
    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.read_ca_xyLv()
    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:
    """高亮当前选中色块。"""
    palette = _get_calman_palette()
    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=palette["patch_focus"], highlightthickness=2)
        else:
            cell.configure(highlightbackground=palette["patch_border"], highlightthickness=1)
    for i, cell in enumerate(self.calman_actual_cells):
        if i == idx:
            cell.configure(highlightbackground=palette["patch_focus"], highlightthickness=2)
        else:
            cell.configure(highlightbackground=palette["patch_border_alt"], 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_recs = [
        r for r in recs
        if (
            r.get("rgb_r") == r.get("rgb_r")
            and r.get("rgb_g") == r.get("rgb_g")
            and r.get("rgb_b") == r.get("rgb_b")
        )
    ]
    rgb_pcts = [r["pct"] for r in rgb_recs]
    rgb_r = [r["rgb_r"] for r in rgb_recs]
    rgb_g = [r["rgb_g"] for r in rgb_recs]
    rgb_b = [r["rgb_b"] for r in rgb_recs]
    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_ylim_low = 95.0
    rgb_ylim_high = 105.0
    if rgb_recs:
        rgb_values = rgb_r + rgb_g + rgb_b
        rgb_min = min(rgb_values + [100.0])
        rgb_max = max(rgb_values + [100.0])
        pad = max(0.8, (rgb_max - rgb_min) * 0.15)
        rgb_ylim_low = min(95.0, rgb_min - pad)
        rgb_ylim_high = max(105.0, rgb_max + pad)

    # 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(rgb_ylim_low, rgb_ylim_high)
    a2.set_xlabel("", fontsize=8)

    # RGB Balance 条图（用最后一个点）
    a3 = self.calman_ax_rgb_bar
    a3.clear()
    _style_axes(self, a3, "RGB Balance")
    if rgb_recs:
        last = rgb_recs[-1]
        bars = [
            last["rgb_r"],
            last["rgb_g"],
            last["rgb_b"],
        ]
        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(rgb_ylim_low, rgb_ylim_high)
    a3.set_xlabel("", fontsize=8)

    # Gamma
    a4 = self.calman_ax_gamma
    a4.clear()
    _style_axes(self, a4, "Gamma Log/Log")
    target_pcts = list(self.calman_levels)
    target_vals = [_target_gamma_loglog_curve(p) for p in target_pcts]
    a4.plot(target_pcts, target_vals, "-", color="#f4ff00", linewidth=1.8)
    if gamma_pcts:
        a4.plot(gamma_pcts, gamma_vals, "-", color="#8f8f8f", linewidth=2.0)
    a4.set_xlim(-2, 102)
    a4.set_ylim(1.8, 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()
    ref_white_y = self.calman_results.get(100, {}).get("Y")

    def _target_y_abs(pctx):
        if pctx is None:
            return "-"
        if ref_white_y is None or ref_white_y != ref_white_y or ref_white_y <= 0:
            return "-"
        return _safe_float(ref_white_y * ((pctx / 100.0) ** TARGET_GAMMA), "{:.3f}")

    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: _target_y_abs(pctx)),
        ("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
    _on_calman_panel_shown = _on_calman_panel_shown
