修改引用逻辑、新增Pattern更改界面、新增Calman灰阶界面
This commit is contained in:
987
app/views/panels/calman_panel.py
Normal file
987
app/views/panels/calman_panel.py
Normal file
@@ -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("<Button-1>", 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("<MouseWheel>", 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("<Configure>", 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
|
||||
Reference in New Issue
Block a user