Compare commits

..

18 Commits

Author SHA1 Message Date
xinzhu.yin
e9a591bf6e 修改深色模式下结果图片显示异常 2026-06-05 16:58:46 +08:00
xinzhu.yin
49d82da8b9 修改Calman灰阶中结果图显示、修改UI主题样式应用 2026-06-04 10:36:15 +08:00
xinzhu.yin
3aa975c4d3 修改calman灰阶点击异常、修改色准结果显示异常 2026-06-02 17:34:46 +08:00
xinzhu.yin
85ac47e8de 修改AI生图接口、修改设备连接UI、修改LocalDimming逻辑和UI 2026-05-29 14:40:39 +08:00
xinzhu.yin
21455f3916 修复日志模块深色显示不正确 2026-05-29 08:32:21 +08:00
xinzhu.yin
4498ec501e 继续优化AI图片列表UI显示 2026-05-28 17:34:51 +08:00
xinzhu.yin
64764524aa 添加Local Dimming图像 2026-05-28 17:02:22 +08:00
xinzhu.yin
c173e2338d 继续优化深色模式显示 2026-05-28 16:41:52 +08:00
xinzhu.yin
cf724d60d7 添加深色模式 2026-05-28 10:50:52 +08:00
xinzhu.yin
f8f2d471e5 屏模组添加colorinfo设置、修改配置项UI样式 2026-05-28 10:20:17 +08:00
xinzhu.yin
c63b9ef615 继续优化色准测试结果显示模块 2026-05-27 16:26:19 +08:00
xinzhu.yin
59c9424218 修改色准测试结果显示 2026-05-27 14:58:44 +08:00
xinzhu.yin
dff4e0df4d 修改引用逻辑、新增Pattern更改界面、新增Calman灰阶界面 2026-05-27 11:26:28 +08:00
xinzhu.yin
a903c17cb3 修复切换config后异常显示pattern问题 2026-05-24 11:37:45 +08:00
xinzhu.yin
29f7d39fe9 删除所有外部旧引用 2026-05-24 11:21:30 +08:00
xinzhu.yin
1b66fff35b 迁移 GUI 调用点 2026-05-24 11:02:37 +08:00
xinzhu.yin
a855ba7157 Merge branch 'master' of http://szmoka.tclking.com:8081/Windows.Python/pqAutomationApp 2026-05-24 10:49:40 +08:00
xinzhu.yin
3ce1574320 新建UCD模块DDD重构文件 2026-05-24 10:49:28 +08:00
50 changed files with 10025 additions and 1731 deletions

View File

@@ -1,4 +1,4 @@
"""配置文件 I/OStep 4 重构)。
"""配置文件 I/OStep 4 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。
@@ -8,7 +8,13 @@ import json
import os
import sys
def get_config_path(self):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def get_config_path(self: "PQAutomationApp"):
"""获取配置文件的完整路径(兼容打包后的程序)"""
# 判断是否是打包后的程序
@@ -30,7 +36,7 @@ def get_config_path(self):
return config_file
def load_pq_config(self):
def load_pq_config(self: "PQAutomationApp"):
"""加载PQ配置兼容打包后的程序"""
try:
# 使用 self.config_file已经是动态路径
@@ -48,7 +54,7 @@ def load_pq_config(self):
self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error")
def save_pq_config(self):
def save_pq_config(self: "PQAutomationApp"):
"""保存PQ配置兼容打包后的程序"""
try:
# 确保目录存在
@@ -61,7 +67,7 @@ def save_pq_config(self):
self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error")
def clear_config_file(self):
def clear_config_file(self: "PQAutomationApp"):
"""清理配置文件(兼容打包后的程序)"""
from tkinter import messagebox
@@ -82,3 +88,13 @@ def clear_config_file(self):
self.log_gui.log(f"配置文件清理失败: {str(e)}", level="error")
class ConfigIOMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
get_config_path = get_config_path
load_pq_config = load_pq_config
save_pq_config = save_pq_config
clear_config_file = clear_config_file

View File

@@ -1,198 +1,387 @@
"""设备连接UCD323 / CA410相关逻辑Step 4 重构)
"""设备连接管理UCD323 / CA410
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。
重构目标
---------
- 用 :class:`ConnectionController` 类封装连接生命周期,替代旧的
"把模块级函数当类方法装到 PQAutomationApp 上" 的写法。
- 拆掉 ``check_port_connection(is_ucd)`` 的布尔旗参数反模式,
分离为 ``connect_ucd`` / ``connect_ca`` 两条独立路径。
- UCD 这一侧不再直接调用旧 ``UCDController``,而是通过
:class:`UCD323Device` + :class:`EventBus`;指示器更新由订阅
:class:`ConnectionChanged` 事件触发,与 GUI 解耦。
模块底部仍保留 7 个旧名字函数作为薄兼容层(``check_com_connections``
等),供 ``pqAutomationApp.PQAutomationApp`` 类体继续以同名属性挂接,
保证调用点(按钮 command、_dispatch_ui 引用)零修改。
"""
from __future__ import annotations
import threading
import time
from tkinter import messagebox
from typing import TYPE_CHECKING
from app.ucd_domain import ConnectionChanged, UcdError
from drivers.caSerail import CASerail
from drivers.ucd_driver import DeviceInfo
from app.views.modern_styles import get_theme_palette
def get_available_ucd_ports(self):
"""获取可用的UCD端口列表"""
return self.ucd.search_device()
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def get_available_com_ports(self):
"""获取可用的COM端口列表"""
try:
import serial.tools.list_ports
ports = serial.tools.list_ports.comports()
return [port.device for port in ports]
except Exception as e:
self.log_gui.log(f"获取COM端口列表出错: {e}", level="error")
return []
if TYPE_CHECKING:
from app.ucd_domain import EventBus
from drivers.ucd_driver import UCD323Device
def refresh_com_ports(self):
"""刷新COM端口列表"""
available_ports = self.get_available_com_ports()
available_list = self.get_available_ucd_ports()
# 更新UCD列表的下拉框选项
ucd_list_current = self.ucd_list_var.get()
if ucd_list_current not in available_list:
self.ucd_list_var.set(available_list[0] if available_list else "")
self.ucd_list_combo.config(values=available_list)
# 更新CA端口的下拉框选项
ca_com_current = self.ca_com_var.get()
if ca_com_current not in available_ports:
self.ca_com_var.set(
available_ports[1]
if len(available_ports) > 1
else (available_ports[0] if available_ports else "")
)
self.ca_com_combo.config(values=available_ports)
# 重置连接状态指示器为灰色
if hasattr(self, "ucd_status_indicator"):
self.ucd_status_indicator.config(bg="gray")
if hasattr(self, "ca_status_indicator"):
self.ca_status_indicator.config(bg="gray")
self.update_config()
# ─── ConnectionController ────────────────────────────────────────
def check_com_connections(self):
"""检测COM端口连接状态"""
# 禁用连接按钮,防止重复点击
self.check_button.configure(state="disabled")
self.refresh_button.configure(state="disabled")
class ConnectionController:
"""统一管理 CA410 / UCD323 的连接、断开与可用端口枚举。
# 更新状态栏
self.status_var.set("正在检测连接...")
self.root.update()
设计要点:
- 不持有 :mod:`tkinter` 控件;所有 GUI 更新由订阅事件总线后回调完成。
- UCD 连接经由 :class:`UCD323Device`,自动发布
:class:`ConnectionChanged` 事件。
- CA 连接同样发布 :class:`ConnectionChanged` 事件(带 ``serial=None``
指示器订阅时只看 ``connected`` 字段)。
"""
# 使用线程进行连接检测
def check_connections():
def __init__(self, app):
self._app = app
self._bus: "EventBus" = app.event_bus
self._device: "UCD323Device" = app.ucd_device
# 旧 GUI 仍用 self.ca 直接访问 CA410 对象,过渡期保留同步赋值。
if not hasattr(app, "ca") or app.ca is None:
self._app.ca = None
# -- 端口枚举 ------------------------------------------------
def list_ucd_devices(self) -> list[str]:
"""返回 SDK 给出的设备显示字符串列表。"""
try:
# 检测TV连接
ucd_connected = self.check_port_connection(is_ucd=True)
self._dispatch_ui(
self.update_connection_indicator,
self.ucd_status_indicator, ucd_connected,
)
return self._device.raw_controller.search_device() or []
except Exception as exc: # noqa: BLE001
self._log(f"枚举 UCD 设备失败: {exc}", level="error")
return []
# 检测CA连接
ca_connected = self.check_port_connection(is_ucd=False)
self._dispatch_ui(
self.update_connection_indicator,
self.ca_status_indicator, ca_connected,
)
def list_com_ports(self) -> list[str]:
try:
import serial.tools.list_ports
# 更新状态栏
self._dispatch_ui(self.status_var.set, "连接检测完成")
return [p.device for p in serial.tools.list_ports.comports()]
except Exception as exc: # noqa: BLE001
self._log(f"获取COM端口列表出错: {exc}", level="error")
return []
# 重新启用所有控件
self._dispatch_ui(self.enable_com_widgets)
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"连接检测出错: {e}")
self._dispatch_ui(self.enable_com_widgets)
# -- UCD 连接 ------------------------------------------------
# 启动线程
threading.Thread(target=check_connections, daemon=True).start()
def update_connection_indicator(self, indicator, connected):
"""更新连接状态指示器颜色"""
if connected:
indicator.config(bg="green")
else:
indicator.config(bg="red")
def check_port_connection(self, is_ucd=True):
"""检测指定端口是否可以连接"""
try:
if is_ucd:
if self.ucd.status:
try:
self.ucd.close()
except:
pass
if not self.ucd.open(self.ucd_list_var.get()):
self.log_gui.log(
f"设备 {self.ucd_list_var.get()} 异常UCD323连接失败"
, level="error")
return False
else:
return True
else:
# 如果CA对象已存在先关闭
if self.ca is not None:
try:
self.ca.close()
except:
pass
channel_value = self.ca_channel_var.get()
str_channel = f"{int(channel_value):02d}"
self.ca = CASerail()
self.ca.open(self.config.device_config["ca_com"], 19200, 7, "E", 2)
# data = self.ca.set_xyLv_Display()
data = self.ca.set_all_Display()
if data:
data = self.ca.setSynchMode(3)
data = self.ca.setMeasureSpeed(1)
if True:
time.sleep(0.5)
data = self.ca.setZeroCalibration()
channel_value = self.ca_channel_var.get()
str_channel = f"{int(channel_value):02d}"
data = self.ca.setChannel(str_channel)
return True
else:
self.log_gui.log(
f"端口 {self.config.device_config["ca_com"]} 异常,色温仪连接失败"
, level="error")
self.ca.close()
self.ca = None
return False
except Exception as e:
self.log_gui.log(f"端口连接失败: {e}", level="error")
return False
def enable_com_widgets(self):
"""重新启用所有控件"""
self.check_button.configure(state="normal")
self.refresh_button.configure(state="normal")
def disconnect_com_connections(self):
"""断开所有串口连接"""
try:
# 断开TV连接
if self.ucd.status:
def connect_ucd(self, display: str) -> bool:
"""打开指定 UCD 设备。成功返回 True。"""
if self._device.state.name != "CLOSED":
try:
self.ucd.close()
except:
self._device.close()
except Exception: # noqa: BLE001
pass
finally:
self.ucd.status = False
self.log_gui.log("UCD连接已断开", level="info")
try:
self._device.open(DeviceInfo.parse(display))
return True
except UcdError as exc:
self._log(f"设备 {display} 异常UCD323 连接失败: {exc}", level="error")
return False
except Exception as exc: # noqa: BLE001
self._log(f"UCD323 连接异常: {exc}", level="error")
return False
# 断开CA连接
if self.ca is not None:
def disconnect_ucd(self) -> None:
try:
self._device.close()
except Exception: # noqa: BLE001
pass
# 旧 controller.status 也要清零,兼容仍读取它的代码
try:
self._app.ucd.status = False
except Exception: # noqa: BLE001
pass
self._log("UCD连接已断开", level="info")
# -- CA 连接 -------------------------------------------------
def connect_ca(self) -> bool:
"""打开 CA410。成功返回 True 并设置 ``app.ca``。"""
if self._app.ca is not None:
try:
self.ca.close()
except:
self._app.ca.close()
except Exception: # noqa: BLE001
pass
finally:
self.ca = None
self.log_gui.log("CA连接已断开", level="info")
self._app.ca = None
# 重新启用相关控件
self.enable_com_widgets()
self.ucd_status_indicator.config(bg="gray")
self.ca_status_indicator.config(bg="gray")
self.status_var.set("串口连接已断开")
try:
ca = CASerail()
ca.open(self._app.config.device_config["ca_com"], 19200, 7, "E", 2)
if not ca.set_all_Display():
self._log(
f"端口 {self._app.config.device_config['ca_com']} 异常,色温仪连接失败",
level="error",
)
ca.close()
return False
ca.setSynchMode(3)
ca.setMeasureSpeed(1)
time.sleep(0.5)
ca.setZeroCalibration()
channel_value = self._app.ca_channel_var.get()
ca.setChannel(f"{int(channel_value):02d}")
self._app.ca = ca
self._bus.publish(ConnectionChanged(True, None))
return True
except Exception as exc: # noqa: BLE001
self._log(f"CA410 连接失败: {exc}", level="error")
return False
except Exception as e:
self.log_gui.log(f"断开连接时发生错误: {str(e)}", level="info")
messagebox.showerror("错误", f"断开连接失败: {str(e)}")
def disconnect_ca(self) -> None:
if self._app.ca is None:
return
try:
self._app.ca.close()
except Exception: # noqa: BLE001
pass
self._app.ca = None
self._bus.publish(ConnectionChanged(False, None))
self._log("CA连接已断开", level="info")
# -- 一次性入口 ----------------------------------------------
def check_all_async(self) -> None:
"""异步并联检测 UCD + CA通过 ``_dispatch_ui`` 回主线程更新 UI。"""
app = self._app
app.check_button.configure(state="disabled")
app.refresh_button.configure(state="disabled")
app.status_var.set("正在检测连接...")
app.root.update()
def worker():
try:
ucd_ok = self.connect_ucd(app.ucd_list_var.get())
app._dispatch_ui(
app.update_connection_indicator,
app.ucd_status_indicator, ucd_ok,
)
ca_ok = self.connect_ca()
app._dispatch_ui(
app.update_connection_indicator,
app.ca_status_indicator, ca_ok,
)
app._dispatch_ui(app.status_var.set, "连接检测完成")
app._dispatch_ui(self._enable_widgets)
except Exception as exc: # noqa: BLE001
app._dispatch_ui(app.log_gui.log, f"连接检测出错: {exc}")
app._dispatch_ui(self._enable_widgets)
threading.Thread(target=worker, daemon=True).start()
def disconnect_all(self) -> None:
try:
self.disconnect_ucd()
self.disconnect_ca()
self._enable_widgets()
self._app.refresh_connection_indicators()
self._app.status_var.set("串口连接已断开")
except Exception as exc: # noqa: BLE001
self._log(f"断开连接时发生错误: {exc}", level="info")
messagebox.showerror("错误", f"断开连接失败: {exc}")
def refresh_ports(self) -> None:
"""刷新 UCD + COM 端口下拉框;指示器复位。"""
app = self._app
com_ports = self.list_com_ports()
ucd_list = self.list_ucd_devices()
if app.ucd_list_var.get() not in ucd_list:
app.ucd_list_var.set(ucd_list[0] if ucd_list else "")
app.ucd_list_combo.config(values=ucd_list)
if app.ca_com_var.get() not in com_ports:
app.ca_com_var.set(
com_ports[1] if len(com_ports) > 1 else (com_ports[0] if com_ports else "")
)
app.ca_com_combo.config(values=com_ports)
app.refresh_connection_indicators()
app.update_config()
# -- 内部 ----------------------------------------------------
def _enable_widgets(self) -> None:
self._app.check_button.configure(state="normal")
self._app.refresh_button.configure(state="normal")
def _log(self, msg: str, *, level: str = "info") -> None:
log_gui = getattr(self._app, "log_gui", None)
if log_gui is not None:
log_gui.log(msg, level=level)
# ─── 旧名字兼容层 ────────────────────────────────────────────────
# pqAutomationApp 类体仍以同名属性挂接这些函数;它们都委托给
# ``self.connection``。Phase 3 完成后这些 shim 可以连同类体的属性
# 挂接一并删除,让 GUI 直接调用 ``self.connection.xxx``。
def get_available_ucd_ports(self: "PQAutomationApp"):
return self.connection.list_ucd_devices()
def get_available_com_ports(self: "PQAutomationApp"):
return self.connection.list_com_ports()
def refresh_com_ports(self: "PQAutomationApp"):
self.connection.refresh_ports()
def check_com_connections(self: "PQAutomationApp"):
self.connection.check_all_async()
def update_connection_indicator(self: "PQAutomationApp", indicator, connected):
_draw_connection_indicator(indicator, "green" if connected else "red")
def refresh_connection_indicators(self: "PQAutomationApp"):
"""根据当前设备状态重画 UCD / CA 指示灯。"""
if hasattr(self, "ucd_status_indicator"):
ucd_connected = bool(getattr(self.ucd, "status", False))
_draw_connection_indicator(
self.ucd_status_indicator,
"green" if ucd_connected else "gray",
)
if hasattr(self, "ca_status_indicator"):
ca_connected = getattr(self, "ca", None) is not None
_draw_connection_indicator(
self.ca_status_indicator,
"green" if ca_connected else "gray",
)
def _draw_connection_indicator(canvas, state: str) -> None:
palette = get_theme_palette()
color_map = {
"green": "#2ECC71",
"red": "#E74C3C",
"gray": "#9AA3AD",
}
fill = color_map.get(state, state)
border = palette["border"]
bg = palette["card_bg"]
try:
canvas.configure(bg=bg, highlightbackground=border, highlightcolor=border)
canvas.delete("all")
# 保持原有视觉:方形状态灯(红/绿/灰)
canvas.create_rectangle(0, 0, 15, 15, fill=fill, outline=border, width=1)
except Exception:
try:
canvas.config(bg=fill)
except Exception:
pass
def check_port_connection(self: "PQAutomationApp", is_ucd=True):
"""[已弃用] 旗参数反模式;保留仅为兼容旧调用点。"""
if is_ucd:
return self.connection.connect_ucd(self.ucd_list_var.get())
return self.connection.connect_ca()
def enable_com_widgets(self: "PQAutomationApp"):
self.connection._enable_widgets()
def disconnect_com_connections(self: "PQAutomationApp"):
self.connection.disconnect_all()
def _get_ca_measure_lock(self: "PQAutomationApp"):
lock = getattr(self, "_ca_measure_lock", None)
if lock is None:
lock = threading.RLock()
self._ca_measure_lock = lock
return lock
def _read_ca_display(self: "PQAutomationApp", mode: int):
"""在锁内切换 CA410 Display 模式并立即读取,避免模式串扰。"""
if getattr(self, "ca", None) is None:
raise RuntimeError("请先连接 CA410 色度计")
with _get_ca_measure_lock(self):
self.ca.set_Display(mode)
return self.ca.readAllDisplay()
def read_ca_xyLv(self: "PQAutomationApp"):
"""读取 xy/Lv/XYZDisplay 0"""
return _read_ca_display(self, 0)
def read_ca_tcp_duv(self: "PQAutomationApp"):
"""读取 Tcp/duv/Lv/XYZDisplay 1"""
return _read_ca_display(self, 1)
def read_ca_uvLv(self: "PQAutomationApp"):
"""读取 u'/v'/Lv/XYZDisplay 5"""
return _read_ca_display(self, 5)
def read_ca_xyz(self: "PQAutomationApp"):
"""读取 XYZDisplay 7"""
return _read_ca_display(self, 7)
def read_ca_lambda_pe(self: "PQAutomationApp"):
"""读取 λd/Pe/Lv/XYZDisplay 8"""
return _read_ca_display(self, 8)
__all__ = [
"ConnectionController",
# 兼容层
"get_available_ucd_ports",
"get_available_com_ports",
"refresh_com_ports",
"check_com_connections",
"update_connection_indicator",
"refresh_connection_indicators",
"check_port_connection",
"enable_com_widgets",
"disconnect_com_connections",
]
class DeviceConnectionMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
get_available_ucd_ports = get_available_ucd_ports
get_available_com_ports = get_available_com_ports
refresh_com_ports = refresh_com_ports
check_com_connections = check_com_connections
update_connection_indicator = update_connection_indicator
refresh_connection_indicators = refresh_connection_indicators
check_port_connection = check_port_connection
enable_com_widgets = enable_com_widgets
disconnect_com_connections = disconnect_com_connections
_get_ca_measure_lock = _get_ca_measure_lock
_read_ca_display = _read_ca_display
read_ca_xyLv = read_ca_xyLv
read_ca_tcp_duv = read_ca_tcp_duv
read_ca_uvLv = read_ca_uvLv
read_ca_xyz = read_ca_xyz
read_ca_lambda_pe = read_ca_lambda_pe

View File

@@ -2,6 +2,21 @@
import os
_EXPORT_BG_COLOR = "#FFFFFF"
def _save_with_light_background(fig, path, *, dpi=300, bbox_inches=None):
"""导出统一浅色背景,避免深色主题下图片背景变暗。"""
kwargs = {
"dpi": dpi,
"facecolor": _EXPORT_BG_COLOR,
"edgecolor": _EXPORT_BG_COLOR,
}
if bbox_inches is not None:
kwargs["bbox_inches"] = bbox_inches
fig.savefig(path, **kwargs)
def _gamut_refs_for_type(test_type):
"""按测试类型返回需要导出的参考色域列表。"""
if test_type == "sdr_movie":
@@ -70,7 +85,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue
per_ref_name = f"色域测试结果_{ref}.png"
path = os.path.join(result_dir, per_ref_name)
fig.savefig(path, dpi=300)
_save_with_light_background(fig, path, dpi=300)
log(f"已保存: {per_ref_name}")
finally:
ref_var.set(original_ref)
@@ -82,7 +97,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue
path = os.path.join(result_dir, filename)
if default_bbox:
fig.savefig(path, dpi=300)
_save_with_light_background(fig, path, dpi=300)
else:
fig.savefig(path, dpi=300, bbox_inches="tight")
_save_with_light_background(fig, path, dpi=300, bbox_inches="tight")
log(f"已保存: {filename}")

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import logging
import os
import threading
from datetime import datetime
from typing import Optional
@@ -93,6 +94,11 @@ class TkLogHandler(logging.Handler):
def emit(self, record: logging.LogRecord) -> None: # noqa: D401
if getattr(record, _FROM_GUI_FLAG, False):
return
# Tkinter widgets are not thread-safe. Forwarding background-thread logs
# into GUI controls may block/hang the worker thread. Keep those logs in
# file handlers only, and only mirror main-thread logs to GUI.
if threading.current_thread() is not threading.main_thread():
return
try:
message = self.format(record)
except Exception:

View File

@@ -104,6 +104,7 @@ def _render_chromaticity(kind: str, bbox: _BBox) -> np.ndarray:
fig.canvas.draw()
# 从 canvas 抓取 RGBA 数组
buf = np.asarray(fig.canvas.buffer_rgba()).copy()
buf = np.flipud(buf)
plt.close(fig)
try:

View File

@@ -1,321 +1,431 @@
"""色准测试结果绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_accuracy 原样搬迁。
布局:
- 左侧:大尺寸 ColorChecker 条形图(每个条形使用对应颜色)。
- 右侧CIE 1976 u'v' 色度图(目标点/实测点/偏移连线)。
"""
from typing import TYPE_CHECKING
from matplotlib.patches import Rectangle
from matplotlib.lines import Line2D
import matplotlib.colors as mcolors
import numpy as np
from app.views.modern_styles import get_theme_palette
from app.plots.gamut_background import get_cie1976_background
from app.tests.color_accuracy import get_accuracy_color_standards
from app.pq.color_patch_map import get_patch_color
from app.pq.color_patch_map import get_patch_color_from_xy
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_accuracy(self, accuracy_data, test_type):
"""绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma"""
# ============================================================
# 常量
# ============================================================
self.accuracy_ax.clear()
self.accuracy_ax.set_xlim(0, 1)
self.accuracy_ax.set_ylim(0, 1)
self.accuracy_ax.axis("off")
_COLOR_MAP = {
"White": "#FFFFFF",
"Gray 80": "#E6E6E6",
"Gray 65": "#D1D1D1",
"Gray 50": "#BABABA",
"Gray 35": "#9E9E9E",
"Black": "#000000",
"Dark Skin": "#735242",
"Light Skin": "#C29682",
"Blue Sky": "#5E7A9C",
"Foliage": "#596B42",
"Blue Flower": "#8280B0",
"Bluish Green": "#63BDA8",
"Orange": "#D97829",
"Purplish Blue": "#4A5CA3",
"Moderate Red": "#C25461",
"Purple": "#5C3D6B",
"Yellow Green": "#9EBA40",
"Orange Yellow": "#E6A12E",
"Blue (Legacy)": "#333D96",
"Green (Legacy)": "#479447",
"Red (Legacy)": "#B0303B",
"Yellow (Legacy)": "#EDC721",
"Magenta (Legacy)": "#BA5491",
"Cyan (Legacy)": "#0085A3",
"100% Red": "#FF0000",
"100% Green": "#00FF00",
"100% Blue": "#0000FF",
"100% Cyan": "#00FFFF",
"100% Magenta": "#FF00FF",
"100% Yellow": "#FFFF00",
}
self.accuracy_fig.subplots_adjust(
left=0.05,
right=0.95,
top=0.95,
bottom=0.02,
def _xy_to_uv(x: float, y: float):
"""CIE 1931 xy → CIE 1976 u'v'"""
denom = -2.0 * x + 12.0 * y + 3.0
if abs(denom) < 1e-10:
return 0.0, 0.0
return (4.0 * x) / denom, (9.0 * y) / denom
# ============================================================
# 子图:左侧 Calman 风格面板
# ============================================================
def _draw_left_panel(ax, color_patches, delta_e_values, font_scale=1.0, dark_mode=False):
"""左侧仅保留大条形图"""
ax.clear()
n = len(color_patches)
if n == 0:
ax.set_axis_off()
return
y_pos = list(range(n))
bar_colors = [_COLOR_MAP.get(name, "#888888") for name in color_patches]
ax.barh(
y_pos,
delta_e_values,
height=0.72,
color=bar_colors,
edgecolor="#202020",
linewidth=0.5,
zorder=3,
)
# 获取色准数据
color_patches = accuracy_data.get("color_patches", [])
delta_e_values = accuracy_data.get("delta_e_values", [])
avg_delta_e = accuracy_data.get("avg_delta_e", 0)
max_delta_e = accuracy_data.get("max_delta_e", 0)
min_delta_e = accuracy_data.get("min_delta_e", 0)
excellent_count = accuracy_data.get("excellent_count", 0)
good_count = accuracy_data.get("good_count", 0)
poor_count = accuracy_data.get("poor_count", 0)
text_color = "#F3F5F7" if dark_mode else "#111111"
bg_color = "#0F1115" if dark_mode else "#FFFFFF"
spine_color = "#8C8F94" if dark_mode else "#9A9A9A"
# 获取 Gamma 值
target_gamma = accuracy_data.get("target_gamma", 2.2)
ax.set_yticks(y_pos)
ax.set_yticklabels(color_patches, fontsize=max(5, 7 * font_scale), color=text_color)
ax.invert_yaxis()
x_max = max(15.0, max(delta_e_values) * 1.15)
ax.set_xlim(0, x_max)
ax.tick_params(axis="x", labelsize=max(6, 8 * font_scale), colors=text_color)
ax.grid(axis="x", linestyle="-", linewidth=0.6, alpha=0.3, zorder=0)
ax.grid(axis="y", linestyle=":", linewidth=0.35, alpha=0.15, zorder=0)
ax.set_facecolor(bg_color)
for spine in ax.spines.values():
spine.set_color(spine_color)
spine.set_linewidth(0.9)
# ============================================================
# 子图CIE 1976 u'v' 色度图(目标 vs 实测)
# ============================================================
def _draw_uv_diagram(ax, color_patches, measurements, standards, font_scale=1.0, dark_mode=False):
"""绘制 CIE 1976 u'v' 上的色准对比。"""
ax.clear()
try:
bg, bbox = get_cie1976_background()
if bg.shape[-1] == 4:
bg = bg[:, :, :3]
xmin, xmax, ymin, ymax = bbox
ax.imshow(
bg,
extent=(xmin, xmax, ymin, ymax),
origin="lower",
interpolation="bilinear",
zorder=0,
aspect="auto",
)
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
except Exception:
ax.set_xlim(0.0, 0.65)
ax.set_ylim(0.0, 0.60)
text_color = "#F3F5F7" if dark_mode else "#111111"
sub_text_color = "#D3D7DD" if dark_mode else "#222222"
tick_color = "#D3D7DD" if dark_mode else "#222222"
legend_label_color = "#FFF" if dark_mode else "#111"
legend_bg = "#111" if dark_mode else "#FFFFFF"
legend_edge = "#FFF" if dark_mode else "#333"
outer_edge = "#FFFFFF" if dark_mode else "#222222"
ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
ax.set_aspect("equal", adjustable="box")
ax.set_title(
"CIE 1976 u'v'",
fontsize=max(8, 11 * font_scale),
fontweight="bold",
color=text_color,
pad=4,
)
ax.set_xlabel("u'", fontsize=max(7, 9 * font_scale), color=sub_text_color, labelpad=1)
ax.set_ylabel("v'", fontsize=max(7, 9 * font_scale), color=sub_text_color, labelpad=1)
ax.tick_params(axis="both", labelsize=max(6, 8 * font_scale), colors=tick_color)
for sp in ax.spines.values():
sp.set_color(outer_edge)
sp.set_linewidth(0.9)
for name, meas in zip(color_patches, measurements):
if meas is None or len(meas) < 2:
continue
mx, my = meas[0], meas[1]
sxy = standards.get(name)
if sxy is None:
continue
sx, sy = sxy
m_u, m_v = _xy_to_uv(mx, my)
s_u, s_v = _xy_to_uv(sx, sy)
# face = get_patch_color_from_xy(name, (sx, sy)).strip().upper()
face = _COLOR_MAP.get(name, "#FFFFFF")
# face = get_patch_color_from_xy(name, (mx, my))
# face = "#FF0000"
# 目标点Target 空心方框
ax.scatter(
s_u,
s_v,
s=90,
marker="s",
facecolors="none",
edgecolors=outer_edge,
linewidths=1.6,
zorder=18,
)
# 实测点Actual 彩色实心 + 白色描边
ax.scatter(
[m_u],
[m_v],
s=80,
marker="o",
color=face,
edgecolors=outer_edge,
linewidths=1.2,
zorder=20,
)
# # Δu'v' 偏差连线
# ax.plot(
# [s_u, m_u],
# [s_v, m_v],
# color=face,
# linewidth=1.0,
# alpha=0.8,
# zorder=15,
# )
legend_handles = [
Line2D(
[0],
[0],
marker="s",
linestyle="none",
markerfacecolor="none",
markeredgecolor=outer_edge,
markersize=9,
markeredgewidth=1.4,
label="目标 (Target)",
),
Line2D(
[0],
[0],
marker="o",
linestyle="none",
markerfacecolor="#AAAAAA",
markeredgecolor=outer_edge,
markersize=9,
markeredgewidth=1.2,
label="实测 (Actual)",
),
]
leg = ax.legend(
handles=legend_handles,
loc="lower right",
fontsize=max(6, 8 * font_scale),
framealpha=0.9,
labelcolor=legend_label_color,
)
if leg:
leg.get_frame().set_facecolor(legend_bg)
leg.get_frame().set_edgecolor(legend_edge)
leg.set_zorder(50)
def _draw_result_judgement(ax, accuracy_data, font_scale=1.0, dark_mode=False):
"""底部结果条"""
ax.clear()
ax.set_xlim(0, 1)
ax.set_ylim(0, 1)
ax.axis("off")
avg = accuracy_data.get("avg_delta_e", 0.0)
mx = accuracy_data.get("max_delta_e", 0.0)
panel_bg = "#1A1E24" if dark_mode else "#FFFFFF"
panel_edge = "#4A5058" if dark_mode else "#C6C6C6"
text_color = "#F3F5F7" if dark_mode else "#111111"
ax.add_patch(Rectangle(
(0.0, 0.10), 1.0, 0.80,
transform=ax.transAxes,
facecolor=panel_bg, edgecolor=panel_edge, linewidth=1.0,
))
ax.text(
0.03, 0.50,
f"Avg dE2000: {avg:.2f}",
ha="left", va="center",
fontsize=max(11, 20 * font_scale), fontweight="normal", color=text_color,
transform=ax.transAxes,
)
ax.text(
0.52, 0.50,
f"Max dE2000: {mx:.2f}",
ha="left", va="center",
fontsize=max(11, 20 * font_scale), fontweight="normal", color=text_color,
transform=ax.transAxes,
)
# ============================================================
# 主入口
# ============================================================
def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
"""绘制色准测试结果 - Calman 风格(色块 + CIE 1976 u'v' + 统计)。"""
palette = get_theme_palette()
try:
from app.views.theme_manager import is_dark
dark_mode = is_dark()
except Exception:
dark_mode = False
fig = self.accuracy_fig
fig.clear()
try:
fig.set_layout_engine(None)
except Exception:
try:
fig.set_tight_layout(False)
except Exception:
pass
fig.patch.set_facecolor(palette["bg"])
# 根据当前画布像素尺寸动态缩放字体,避免窗口缩小时文字挤压重叠。
font_scale = 1.0
try:
canvas_widget = self.accuracy_canvas.get_tk_widget()
cw = max(1, int(canvas_widget.winfo_width()))
ch = max(1, int(canvas_widget.winfo_height()))
font_scale = min(cw / 1000.0, ch / 600.0)
font_scale = max(0.60, min(1.0, font_scale))
except Exception:
font_scale = 1.0
color_patches = accuracy_data.get("color_patches", []) or []
delta_e_values = accuracy_data.get("delta_e_values", []) or []
measurements = accuracy_data.get("color_measurements", []) or []
try:
target_gamma = float(accuracy_data.get("target_gamma", 2.2))
except (TypeError, ValueError):
target_gamma = 2.2
test_type_name = self.get_test_type_name(test_type)
# ========== 标题(动态显示 Gamma==========
if test_type == "sdr_movie":
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma}"
elif test_type == "hdr_movie":
title = f"{test_type_name} - 色准测试(全 29色 | PQ EOTF"
else: # screen_module
else:
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma}"
self.accuracy_fig.suptitle(
fig.suptitle(
title,
fontsize=11,
y=0.98,
fontsize=max(8, 11 * font_scale),
y=0.975,
fontweight="bold",
color="#111111",
color=palette["fg"],
)
# ========== 29色6行5列布局 ==========
cols = 5
rows = 6
patch_width = 0.135
patch_height = 0.085
x_start = 0.08
y_start = 0.90
x_gap = 0.035
y_gap = 0.050
# ========== 绘制色块 ==========
for i, (color_name, delta_e) in enumerate(zip(color_patches, delta_e_values)):
row = i // cols
col = i % cols
x = x_start + col * (patch_width + x_gap)
y = y_start - row * (patch_height + y_gap)
# 颜色映射
color_map = {
# 灰阶
"White": "#FFFFFF",
"Gray 80": "#E6E6E6",
"Gray 65": "#D1D1D1",
"Gray 50": "#BABABA",
"Gray 35": "#9E9E9E",
# 饱和色
"100% Red": "#FF0000",
"100% Green": "#00FF00",
"100% Blue": "#0000FF",
"100% Cyan": "#00FFFF",
"100% Magenta": "#FF00FF",
"100% Yellow": "#FFFF00",
# ColorChecker 颜色
"Dark Skin": "#735242",
"Light Skin": "#C29682",
"Blue Sky": "#5E7A9C",
"Foliage": "#596B42",
"Blue Flower": "#8280B0",
"Bluish Green": "#63BDA8",
"Orange": "#D97829",
"Purplish Blue": "#4A5CA3",
"Moderate Red": "#C25461",
"Purple": "#5C3D6B",
"Yellow Green": "#9EBA40",
"Orange Yellow": "#E6A12E",
"Blue (Legacy)": "#333D96",
"Green (Legacy)": "#479447",
"Red (Legacy)": "#B0303B",
"Yellow (Legacy)": "#EDC721",
"Magenta (Legacy)": "#BA5491",
"Cyan (Legacy)": "#0085A3",
}
patch_color = color_map.get(color_name, "#808080")
# ΔE 等级颜色
if delta_e < 3:
edge_color = "green"
elif delta_e < 5:
edge_color = "orange"
else:
edge_color = "red"
# 绘制色块
rect = Rectangle(
(x, y),
patch_width,
patch_height,
transform=self.accuracy_ax.transAxes,
facecolor=patch_color,
edgecolor=edge_color,
linewidth=1.8,
)
self.accuracy_ax.add_patch(rect)
# ========== 标注色块名称(上方)==========
self.accuracy_ax.text(
x + patch_width / 2,
y + patch_height + 0.015,
color_name,
ha="center",
va="bottom",
fontsize=5.5,
fontweight="bold",
transform=self.accuracy_ax.transAxes,
clip_on=False,
)
# ========== 标注 ΔE 值(中心)==========
dark_colors = [
"100% Red",
"100% Green",
"100% Blue",
"Gray 35",
"Dark Skin",
"Foliage",
"Purple",
"Purplish Blue",
"Blue (Legacy)",
"Green (Legacy)",
"Red (Legacy)",
"Magenta (Legacy)",
"Cyan (Legacy)",
]
text_color = "white" if color_name in dark_colors else "black"
self.accuracy_ax.text(
x + patch_width / 2,
y + patch_height / 2,
f"ΔE\n{delta_e:.2f}",
ha="center",
va="center",
fontsize=5.2,
fontweight="bold",
color=text_color,
transform=self.accuracy_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.22",
facecolor="white" if text_color == "black" else "black",
alpha=0.75,
edgecolor=edge_color,
linewidth=1.0,
),
)
# ========== 统计信息卡片(只保留外框)==========
card_width = 0.84
card_height = 0.15
card_x = 0.08
card_y = 0.01
info_card = Rectangle(
(card_x, card_y),
card_width,
card_height,
transform=self.accuracy_ax.transAxes,
facecolor="#F0F0F0",
edgecolor="black",
linewidth=1.5,
)
self.accuracy_ax.add_patch(info_card)
# ========== 标题(带说明)==========
self.accuracy_ax.text(
card_x + card_width / 2,
card_y + card_height - 0.008,
"色准统计5灰阶 + 18 ColorChecker + 6饱和色 | ΔE 2000 标准)",
ha="center",
va="top",
fontsize=7.5,
fontweight="bold",
color="#111111",
transform=self.accuracy_ax.transAxes,
gs = fig.add_gridspec(
2, 2,
width_ratios=[1.12, 1.0],
height_ratios=[4.8, 0.48],
left=0.08, right=0.985,
top=0.92, bottom=0.05,
wspace=0.14, hspace=0.08,
)
# ========== 统计内容(无内部框)==========
stats_y = card_y + card_height * 0.55
ax_left = fig.add_subplot(gs[0, 0])
ax_uv = fig.add_subplot(gs[0, 1])
ax_judge = fig.add_subplot(gs[1, :])
for ax in (ax_left, ax_uv, ax_judge):
ax.set_facecolor(palette["card_bg"])
# 左侧ΔE 统计
left_x = card_x + 0.02
stats_text = [
f"平均 ΔE: {avg_delta_e:.2f}",
f"最大 ΔE: {max_delta_e:.2f}",
f"最小 ΔE: {min_delta_e:.2f}",
]
# 兼容外部对 self.accuracy_ax 的引用
self.accuracy_ax = ax_judge
for i, text in enumerate(stats_text):
self.accuracy_ax.text(
left_x,
stats_y - i * 0.030,
text,
ha="left",
va="center",
fontsize=7,
fontweight="bold",
color="#111111",
transform=self.accuracy_ax.transAxes,
)
# 中间:色块统计
middle_x = card_x + card_width * 0.32
self.accuracy_ax.text(
middle_x,
stats_y,
f"优秀 (ΔE<3): {excellent_count}",
ha="left",
va="center",
fontsize=7,
color="green",
fontweight="bold",
transform=self.accuracy_ax.transAxes,
_draw_left_panel(
ax_left,
color_patches,
delta_e_values,
font_scale=font_scale,
dark_mode=dark_mode,
)
self.accuracy_ax.text(
middle_x,
stats_y - 0.030,
f"良好 (3≤ΔE<5): {good_count}",
ha="left",
va="center",
fontsize=7,
color="orange",
fontweight="bold",
transform=self.accuracy_ax.transAxes,
try:
standards = get_accuracy_color_standards(test_type)
except Exception:
standards = {}
_draw_uv_diagram(
ax_uv,
color_patches,
measurements,
standards,
font_scale=font_scale,
dark_mode=dark_mode,
)
_draw_result_judgement(
ax_judge,
accuracy_data,
font_scale=font_scale,
dark_mode=dark_mode,
)
self.accuracy_ax.text(
middle_x,
stats_y - 0.060,
f"偏差 (ΔE≥5): {poor_count}",
ha="left",
va="center",
fontsize=7,
color="red",
fontweight="bold",
transform=self.accuracy_ax.transAxes,
)
try:
self.update_accuracy_result_table(accuracy_data, standards)
except Exception:
pass
# 右侧:总体评价
right_x = card_x + card_width - 0.02
if avg_delta_e < 2:
grade = "专业级"
grade_icon = "★★★"
grade_color = "darkgreen"
elif avg_delta_e < 3:
grade = "优秀"
grade_icon = "OK"
grade_color = "green"
elif avg_delta_e < 5:
grade = "良好"
grade_icon = "PASS"
grade_color = "orange"
else:
grade = "需要校准"
grade_icon = "[Error]"
grade_color = "red"
self.accuracy_ax.text(
right_x,
stats_y + 0.020,
"总体评价:",
ha="right",
va="bottom",
fontsize=7,
fontweight="bold",
color="#111111",
transform=self.accuracy_ax.transAxes,
)
self.accuracy_ax.text(
right_x,
stats_y - 0.025,
f"{grade} {grade_icon}",
ha="right",
va="top",
fontsize=11,
fontweight="bold",
color=grade_color,
transform=self.accuracy_ax.transAxes,
)
self.accuracy_canvas.draw()
# Select the tab first so the canvas is visible and winfo_width/height
# return real pixel dimensions before the figure is rendered.
self.chart_notebook.select(self.accuracy_chart_frame)
self.accuracy_canvas.get_tk_widget().update_idletasks()
self.accuracy_canvas.draw()
class PlotAccuracyMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_accuracy = plot_accuracy

View File

@@ -1,15 +1,47 @@
"""CCT / 色度一致性绘制。
"""CCT / 色度一致性绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。
"""
import numpy as np
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_cct(self, test_type):
def _is_dark_palette(palette: dict[str, str]) -> bool:
"""根据主题背景色亮度判断是否深色主题。"""
bg = palette.get("bg", "#FFFFFF").lstrip("#")
try:
r = int(bg[0:2], 16)
g = int(bg[2:4], 16)
b = int(bg[4:6], 16)
except Exception:
return False
return (r * 299 + g * 587 + b * 114) / 1000 < 128
def plot_cct(self: "PQAutomationApp", test_type):
"""绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值"""
palette = get_theme_palette()
dark_mode = _is_dark_palette(palette)
x_line_color = "#2F8BFF" if dark_mode else "#0A4BFF"
y_line_color = "#FF4D4D" if dark_mode else "#D90429"
ideal_line_color = "#00C853" if dark_mode else "#198754"
x_tol_color = "#FF7070" if dark_mode else "#C0392B"
y_tol_color = "#FFB74D" if dark_mode else "#D68910"
grid_color = "#566070" if dark_mode else "#B8BDC3"
axis_text = "#EDF2FA" if dark_mode else palette["fg"]
axis_sub_text = "#C8D2E0" if dark_mode else "#222222"
legend_bg = "#131821" if dark_mode else "#FFFFFF"
legend_edge = "#A4B2C6" if dark_mode else palette["border"]
self.cct_fig.clear()
self.cct_fig.patch.set_facecolor(palette["bg"])
gray_data = self.results.get_intermediate_data("shared", "gray")
if not gray_data:
@@ -25,7 +57,7 @@ def plot_cct(self, test_type):
ha="center",
va="center",
fontsize=14,
color="red",
color=palette["danger"],
)
ax.axis("off")
self.cct_canvas.draw()
@@ -105,12 +137,20 @@ def plot_cct(self, test_type):
# 为所有测试类型创建子图
ax1 = self.cct_fig.add_subplot(211)
ax2 = self.cct_fig.add_subplot(212)
for ax in (ax1, ax2):
ax.set_facecolor(palette["card_bg"])
for spine in ax.spines.values():
spine.set_color(palette["border"])
ax.tick_params(labelsize=8, colors=axis_sub_text)
ax.xaxis.label.set_color(axis_text)
ax.yaxis.label.set_color(axis_text)
# ========== 上图x coordinates ==========
ax1.plot(
grayscale,
x_measured,
"b-o",
color=x_line_color,
marker="o",
label="屏本体",
linewidth=2,
markersize=4,
@@ -127,13 +167,13 @@ def plot_cct(self, test_type):
ha="center",
va="bottom",
fontsize=7,
color="blue",
color=x_line_color,
bbox=dict(
boxstyle="round,pad=0.2",
facecolor="white",
edgecolor="blue",
alpha=0.8,
linewidth=0.5,
facecolor=palette["card_bg"],
edgecolor=x_line_color,
alpha=0.92 if dark_mode else 0.85,
linewidth=0.8,
),
)
@@ -141,7 +181,7 @@ def plot_cct(self, test_type):
full_grayscale = np.linspace(0, 100, 100)
ax1.axhline(
y=x_ideal,
color="green",
color=ideal_line_color,
linestyle="--",
linewidth=1.5,
label=f"x-ideal ({x_ideal:.4f})",
@@ -149,30 +189,34 @@ def plot_cct(self, test_type):
)
ax1.axhline(
y=x_low,
color="red",
color=x_tol_color,
linestyle=":",
linewidth=1,
alpha=0.7,
alpha=0.95 if dark_mode else 0.7,
label=f"x-low ({x_low:.4f})",
zorder=2,
)
ax1.axhline(
y=x_high,
color="red",
color=x_tol_color,
linestyle=":",
linewidth=1,
alpha=0.7,
alpha=0.95 if dark_mode else 0.7,
label=f"x-high ({x_high:.4f})",
zorder=2,
)
ax1.fill_between(
full_grayscale, x_low, x_high, alpha=0.15, color="blue", zorder=1
full_grayscale,
x_low,
x_high,
alpha=0.22 if dark_mode else 0.15,
color=x_line_color,
zorder=1,
)
ax1.set_xlabel("灰阶 (%)", fontsize=9)
ax1.set_ylabel("CIE x", fontsize=9)
ax1.grid(True, linestyle="--", alpha=0.3)
ax1.tick_params(labelsize=8)
ax1.set_xlabel("灰阶 (%)", fontsize=9, color=axis_text)
ax1.set_ylabel("CIE x", fontsize=9, color=axis_text)
ax1.grid(True, linestyle="--", alpha=0.45 if dark_mode else 0.3, color=grid_color)
ax1.set_xlim(0, 105)
# 纵坐标范围由用户参数控制
@@ -205,7 +249,8 @@ def plot_cct(self, test_type):
ax2.plot(
grayscale,
y_measured,
"r-o",
color=y_line_color,
marker="o",
label="屏本体",
linewidth=2,
markersize=4,
@@ -222,19 +267,19 @@ def plot_cct(self, test_type):
ha="center",
va="bottom",
fontsize=7,
color="red",
color=y_line_color,
bbox=dict(
boxstyle="round,pad=0.2",
facecolor="white",
edgecolor="red",
alpha=0.8,
linewidth=0.5,
facecolor=palette["card_bg"],
edgecolor=y_line_color,
alpha=0.92 if dark_mode else 0.85,
linewidth=0.8,
),
)
ax2.axhline(
y=y_ideal,
color="green",
color=ideal_line_color,
linestyle="--",
linewidth=1.5,
label=f"y-ideal ({y_ideal:.4f})",
@@ -242,30 +287,34 @@ def plot_cct(self, test_type):
)
ax2.axhline(
y=y_low,
color="orange",
color=y_tol_color,
linestyle=":",
linewidth=1,
alpha=0.7,
alpha=0.95 if dark_mode else 0.7,
label=f"y-low ({y_low:.4f})",
zorder=2,
)
ax2.axhline(
y=y_high,
color="orange",
color=y_tol_color,
linestyle=":",
linewidth=1,
alpha=0.7,
alpha=0.95 if dark_mode else 0.7,
label=f"y-high ({y_high:.4f})",
zorder=2,
)
ax2.fill_between(
full_grayscale, y_low, y_high, alpha=0.15, color="orange", zorder=1
full_grayscale,
y_low,
y_high,
alpha=0.22 if dark_mode else 0.15,
color=y_tol_color,
zorder=1,
)
ax2.set_xlabel("灰阶 (%)", fontsize=9)
ax2.set_ylabel("CIE y", fontsize=9)
ax2.grid(True, linestyle="--", alpha=0.3)
ax2.tick_params(labelsize=8)
ax2.set_xlabel("灰阶 (%)", fontsize=9, color=axis_text)
ax2.set_ylabel("CIE y", fontsize=9, color=axis_text)
ax2.grid(True, linestyle="--", alpha=0.45 if dark_mode else 0.3, color=grid_color)
ax2.set_xlim(0, 105)
# 纵坐标范围由用户参数控制
@@ -301,6 +350,7 @@ def plot_cct(self, test_type):
fontsize=12,
y=0.98,
fontweight="bold",
color=palette["fg"],
)
self.cct_fig.subplots_adjust(
@@ -311,14 +361,34 @@ def plot_cct(self, test_type):
hspace=0.30,
)
ax1.legend(
fontsize=7, loc="center left", bbox_to_anchor=(1.05, 0.5), framealpha=1.0
legend1 = ax1.legend(
fontsize=7,
loc="center left",
bbox_to_anchor=(1.05, 0.5),
framealpha=0.92 if dark_mode else 1.0,
facecolor=legend_bg,
edgecolor=legend_edge,
)
ax2.legend(
fontsize=7, loc="center left", bbox_to_anchor=(1.05, 0.5), framealpha=1.0
legend2 = ax2.legend(
fontsize=7,
loc="center left",
bbox_to_anchor=(1.05, 0.5),
framealpha=0.92 if dark_mode else 1.0,
facecolor=legend_bg,
edgecolor=legend_edge,
)
for legend in (legend1, legend2):
for text in legend.get_texts():
text.set_color(axis_text)
self.cct_canvas.draw()
self.chart_notebook.select(self.cct_chart_frame)
self.log_gui.log("xy 色度坐标图绘制完成", level="success")
class PlotCctMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_cct = plot_cct

View File

@@ -4,13 +4,23 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_contrast 原样搬迁
"""
from matplotlib.patches import Rectangle
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_contrast(self, contrast_data, test_type):
def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
"""绘制对比度测试结果 - 固定布局版本"""
palette = get_theme_palette()
# 清空并重置
self.contrast_ax.clear()
self.contrast_fig.patch.set_facecolor(palette["bg"])
self.contrast_ax.set_facecolor(palette["card_bg"])
self.contrast_ax.set_xlim(0, 1)
self.contrast_ax.set_ylim(0, 1)
self.contrast_ax.axis("off")
@@ -45,6 +55,7 @@ def plot_contrast(self, contrast_data, test_type):
fontsize=12,
y=0.98,
fontweight="bold",
color=palette["fg"],
)
# ========== 中央大对比度卡片 ==========
@@ -101,16 +112,16 @@ def plot_contrast(self, contrast_data, test_type):
"title": "白场亮度",
"value": f"{max_lum:.2f}",
"unit": "cd/m²",
"color": "#E3F2FD",
"edge_color": "#2196F3",
"color": palette["surface_alt_bg"],
"edge_color": palette["primary"],
},
{
"x": start_x + card_width + gap,
"title": "黑场亮度",
"value": f"{min_lum:.4f}",
"unit": "cd/m²",
"color": "#F3E5F5",
"edge_color": "#9C27B0",
"color": palette["card_bg"],
"edge_color": palette["secondary"],
},
]
@@ -136,6 +147,7 @@ def plot_contrast(self, contrast_data, test_type):
va="top",
fontsize=10,
fontweight="bold",
color=palette["fg"],
transform=self.contrast_ax.transAxes,
)
@@ -148,6 +160,7 @@ def plot_contrast(self, contrast_data, test_type):
va="center",
fontsize=16,
fontweight="bold",
color=palette["fg"],
transform=self.contrast_ax.transAxes,
)
@@ -159,9 +172,16 @@ def plot_contrast(self, contrast_data, test_type):
ha="center",
va="bottom",
fontsize=9,
color="gray",
color=palette["muted_fg"],
transform=self.contrast_ax.transAxes,
)
self.contrast_canvas.draw()
self.chart_notebook.select(self.contrast_chart_frame)
class PlotContrastMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_contrast = plot_contrast

View File

@@ -1,22 +1,35 @@
"""EOTF 曲线绘制HDR
"""EOTF 曲线绘制HDR
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。
"""
import numpy as np
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type):
"""绘制 EOTF 曲线 + 数据表格HDR 专用,包含实测亮度)"""
palette = get_theme_palette()
# ========== 1. 清空并重置左侧曲线 ==========
self.eotf_ax.clear()
self.eotf_fig.patch.set_facecolor(palette["bg"])
self.eotf_ax.set_facecolor(palette["card_bg"])
self.eotf_ax.set_xlim(0, 105)
self.eotf_ax.set_ylim(0, 1.1)
self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.eotf_ax.set_ylabel("L_bar", fontsize=10)
self.eotf_ax.grid(True, linestyle="--", alpha=0.3)
self.eotf_ax.tick_params(labelsize=9)
self.eotf_ax.tick_params(colors=palette["fg"])
for spine in self.eotf_ax.spines.values():
spine.set_color(palette["border"])
# 生成横坐标(灰阶百分比)
x_values = np.linspace(0, 100, len(L_bar))
@@ -114,17 +127,17 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white")
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"])
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# ========== 3. 总标题 ==========
test_type_name = self.get_test_type_name(test_type)
@@ -133,6 +146,7 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
fontsize=12,
y=0.98,
fontweight="bold",
color=palette["fg"],
)
# 选中 EOTF Tab
@@ -146,3 +160,10 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
pass
self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success")
class PlotEotfMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_eotf = plot_eotf

View File

@@ -1,22 +1,55 @@
"""Gamma 曲线绘制。
"""Gamma 曲线绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。
"""
import numpy as np
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
def _is_dark_palette(palette: dict[str, str]) -> bool:
"""根据主题背景色亮度判断是否深色主题。"""
bg = palette.get("bg", "#FFFFFF").lstrip("#")
try:
r = int(bg[0:2], 16)
g = int(bg[2:4], 16)
b = int(bg[4:6], 16)
except Exception:
return False
return (r * 299 + g * 587 + b * 114) / 1000 < 128
def plot_gamma(self: "PQAutomationApp", L_bar, results_with_gamma_list, target_gamma, test_type):
"""绘制Gamma曲线 + 数据表格(包含实测亮度)"""
palette = get_theme_palette()
dark_mode = _is_dark_palette(palette)
line_actual = "#3FA7FF" if dark_mode else "#0A4BFF"
line_ideal = "#FF6B6B" if dark_mode else "#D62828"
grid_color = "#566070" if dark_mode else "#B8BDC3"
legend_bg = "#131821" if dark_mode else "#FFFFFF"
legend_edge = "#A4B2C6" if dark_mode else palette["border"]
table_edge = "#738196" if dark_mode else palette["border"]
table_body_fg = "#EEF3FA" if dark_mode else palette["fg"]
# ========== 1. 清空并重置左侧曲线 ==========
self.gamma_ax.clear()
self.gamma_fig.patch.set_facecolor(palette["bg"])
self.gamma_ax.set_facecolor(palette["card_bg"])
self.gamma_ax.set_xlim(0, 105)
self.gamma_ax.set_ylim(0, 1.1)
self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.gamma_ax.set_ylabel("L_bar", fontsize=10)
self.gamma_ax.grid(True, linestyle="--", alpha=0.3)
self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10, color=palette["fg"])
self.gamma_ax.set_ylabel("L_bar", fontsize=10, color=palette["fg"])
self.gamma_ax.grid(True, linestyle="--", alpha=0.45 if dark_mode else 0.3, color=grid_color)
self.gamma_ax.tick_params(labelsize=9)
self.gamma_ax.tick_params(colors=palette["fg"])
for spine in self.gamma_ax.spines.values():
spine.set_color(palette["border"])
# 生成横坐标(灰阶百分比)
x_values = np.linspace(0, 100, len(L_bar))
@@ -40,7 +73,8 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
self.gamma_ax.plot(
x_values,
L_bar,
"b-o",
color=line_actual,
marker="o",
label=f"实测 (平均γ={avg_gamma:.2f})",
linewidth=2,
markersize=4,
@@ -52,15 +86,24 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
self.gamma_ax.plot(
x_values,
ideal_L_bar,
"r--",
color=line_ideal,
linestyle="--",
label=f"理想 (γ={target_gamma})",
linewidth=2,
alpha=0.7,
alpha=0.9 if dark_mode else 0.7,
zorder=3,
)
# 图例
self.gamma_ax.legend(fontsize=9, loc="upper left", framealpha=0.95)
legend = self.gamma_ax.legend(
fontsize=9,
loc="upper left",
framealpha=0.9 if dark_mode else 0.95,
facecolor=legend_bg,
edgecolor=legend_edge,
)
for text in legend.get_texts():
text.set_color(palette["fg"])
# ========== 2. 清空并绘制右侧表格 ==========
self.gamma_table_ax.clear()
@@ -114,17 +157,22 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white")
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"])
cell.set_edgecolor(table_edge)
cell.set_linewidth(0.8)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
cell.set_text_props(color=table_body_fg)
cell.set_edgecolor(table_edge)
cell.set_linewidth(0.6)
# ========== 3. 总标题 ==========
test_type_name = self.get_test_type_name(test_type)
@@ -133,6 +181,7 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
fontsize=12,
y=0.98,
fontweight="bold",
color=palette["fg"],
)
# ========== 4. 绘制到画布 ==========
@@ -140,3 +189,10 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
self.chart_notebook.select(self.gamma_chart_frame)
self.log_gui.log("Gamma曲线 + 数据表格绘制完成", level="success")
class PlotGammaMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_gamma = plot_gamma

View File

@@ -27,6 +27,11 @@ from app.plots.gamut_background import (
get_cie1976_background,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
# ============ 参考色域定义CIE 1931 xy============
_REF_GAMUTS_XY = {
@@ -143,35 +148,41 @@ def _draw_measured_triangle(ax, vertices, *, uv_space=False):
# )
def _draw_coverage_box(ax, x_pos, y_pos, current_ref, coverage):
def _draw_coverage_box(ax, x_pos, y_pos, current_ref, coverage, *, dark_mode):
text_color = "#FFF" if dark_mode else "#111"
box_face = "#111" if dark_mode else "#FFFFFF"
box_edge = "#FFF" if dark_mode else "#333"
ax.text(
x_pos, y_pos,
f"{current_ref}\n覆盖率: {coverage:.1f}%",
ha="right", va="bottom",
fontsize=11, fontweight="bold",
color="#FFF",
color=text_color,
bbox=dict(
boxstyle="round,pad=0.38",
facecolor="#111",
edgecolor="#FFF",
facecolor=box_face,
edgecolor=box_edge,
linewidth=1.7,
alpha=0.98,
),
zorder=30,
)
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim):
ax.set_facecolor("#000")
ax.set_title(title, fontsize=12, fontweight="bold", color="#FFF", pad=8)
ax.set_xlabel(xlabel, fontsize=10, color="#FFF")
ax.set_ylabel(ylabel, fontsize=10, color="#FFF")
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim, dark_mode):
text = "#F4F6F8" if dark_mode else "#111"
grid = "#444" if dark_mode else "#B8BDC3"
spine_color = "#888" if dark_mode else "#666"
ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
ax.set_title(title, fontsize=12, fontweight="bold", color=text, pad=8)
ax.set_xlabel(xlabel, fontsize=10, color=text)
ax.set_ylabel(ylabel, fontsize=10, color=text)
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_aspect("equal", adjustable="datalim")
ax.grid(True, linestyle=":", linewidth=0.7, color="#444", alpha=0.32)
ax.tick_params(axis="both", labelsize=9, colors="#FFF")
ax.set_aspect("equal", adjustable="box")
ax.grid(True, linestyle=":", linewidth=0.7, color=grid, alpha=0.32)
ax.tick_params(axis="both", labelsize=9, colors=text)
for spine in ax.spines.values():
spine.set_color("#888")
spine.set_color(spine_color)
spine.set_linewidth(0.8)
ax.set_clip_on(False)
@@ -182,8 +193,10 @@ def _blit_background(ax, background, bbox):
ax.imshow(
background,
extent=(xmin, xmax, ymin, ymax),
origin="upper", # canvas.buffer_rgba 行 0 为顶部
interpolation="bicubic",
# gamut_background._render_chromaticity 已做过 np.flipud
# 这里必须使用 lower 才能与真实色度坐标方向一致。
origin="lower",
interpolation="bilinear",
zorder=0,
aspect="auto", # 由 _style_axes 的 set_aspect("equal") 控制
)
@@ -193,16 +206,22 @@ def _blit_background(ax, background, bbox):
# 主入口
# ============================================================
def plot_gamut(self, results, coverage, test_type):
def plot_gamut(self: "PQAutomationApp", results, coverage, test_type):
"""绘制色域图(图像层 + 框架层分离架构)。"""
try:
from app.views.theme_manager import is_dark
dark_mode = is_dark()
except Exception:
dark_mode = False
ax_xy = self.gamut_ax_xy
ax_uv = self.gamut_ax_uv
ax_xy.clear()
ax_uv.clear()
# 全局黑色背景
self.gamut_fig.patch.set_facecolor("#000")
# 全局背景跟随浅/深色主题
self.gamut_fig.patch.set_facecolor("#0D1014" if dark_mode else "#FFFFFF")
# ========== 读取用户选择的参考标准 ==========
if test_type == "screen_module":
@@ -260,6 +279,7 @@ def plot_gamut(self, results, coverage, test_type):
xlabel="x", ylabel="y",
xlim=(bbox_xy[0], bbox_xy[1]),
ylim=(bbox_xy[2], bbox_xy[3]),
dark_mode=dark_mode,
)
for ref_name in other_refs:
@@ -284,7 +304,8 @@ def plot_gamut(self, results, coverage, test_type):
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
_draw_coverage_box(
ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage
ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage,
dark_mode=dark_mode,
)
# 暗化三角形外部区域(黑色半透明遮罩)
@@ -298,18 +319,19 @@ def plot_gamut(self, results, coverage, test_type):
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
path = Path(verts, codes)
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
mask_face = (0, 0, 0, 0.65) if dark_mode else (1, 1, 1, 0.50)
patch = PathPatch(path, facecolor=mask_face, lw=0, zorder=7)
ax_xy.add_patch(patch)
legend = ax_xy.legend(
loc="upper right", fontsize=8.5,
framealpha=0.0, edgecolor="#000", fancybox=True,
labelcolor="#FFF"
labelcolor="#FFF" if dark_mode else "#111"
)
legend.set_zorder(200)
legend.get_frame().set_facecolor("#000")
legend.get_frame().set_alpha(0.5)
legend.get_frame().set_edgecolor("#FFF")
legend.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
legend.get_frame().set_alpha(0.5 if dark_mode else 0.78)
legend.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
ax_xy.add_artist(legend)
except Exception as e:
@@ -329,6 +351,7 @@ def plot_gamut(self, results, coverage, test_type):
xlabel="u'", ylabel="v'",
xlim=(bbox_uv[0], bbox_uv[1]),
ylim=(bbox_uv[2], bbox_uv[3]),
dark_mode=dark_mode,
)
measured_uv = None
@@ -362,7 +385,8 @@ def plot_gamut(self, results, coverage, test_type):
_draw_measured_triangle(ax_uv, measured_uv, uv_space=True)
_draw_coverage_box(
ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage
ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage,
dark_mode=dark_mode,
)
u0, u1 = bbox_uv[0], bbox_uv[1]
@@ -374,18 +398,19 @@ def plot_gamut(self, results, coverage, test_type):
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
path = Path(verts, codes)
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
mask_face = (0, 0, 0, 0.65) if dark_mode else (1, 1, 1, 0.50)
patch = PathPatch(path, facecolor=mask_face, lw=0, zorder=7)
ax_uv.add_patch(patch)
legend_uv = ax_uv.legend(
loc="upper right", fontsize=8.5,
framealpha=0.0, edgecolor="#000", fancybox=True,
labelcolor="#FFF"
labelcolor="#FFF" if dark_mode else "#111"
)
legend_uv.set_zorder(200)
legend_uv.get_frame().set_facecolor("#000")
legend_uv.get_frame().set_alpha(0.72)
legend_uv.get_frame().set_edgecolor("#FFF")
legend_uv.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
legend_uv.get_frame().set_alpha(0.72 if dark_mode else 0.82)
legend_uv.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
ax_uv.add_artist(legend_uv)
except Exception as e:
@@ -408,3 +433,10 @@ def plot_gamut(self, results, coverage, test_type):
self.sync_gamut_toolbar()
self.log_gui.log("色域图绘制完成", level="success")
class PlotGamutMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_gamut = plot_gamut

101
app/pq/color_patch_map.py Normal file
View File

@@ -0,0 +1,101 @@
import re
import numpy as np
# ============================================================
# 标准 ColorChecker / TV Patch 颜色
# ============================================================
COLOR_PATCH_MAP = {
"White": "#FFFFFF",
"Gray 80": "#E6E6E6",
"Gray 65": "#D1D1D1",
"Gray 50": "#BABABA",
"Gray 35": "#9E9E9E",
"Black": "#000000",
"Dark Skin": "#735242",
"Light Skin": "#C29682",
"Blue Sky": "#5E7A9C",
"Foliage": "#596B42",
"Blue Flower": "#8280B0",
"Bluish Green": "#63BDA8",
"Orange": "#D97829",
"Purplish Blue": "#4A5CA3",
"Moderate Red": "#C25461",
"Purple": "#5C3D6B",
"Yellow Green": "#9EBA40",
"Orange Yellow": "#E6A12E",
"Blue (Legacy)": "#333D96",
"Green (Legacy)": "#479447",
"Red (Legacy)": "#B0303B",
"Yellow (Legacy)": "#EDC721",
"Magenta (Legacy)": "#BA5491",
"Cyan (Legacy)": "#0085A3",
"100% Red": "#FF0000",
"100% Green": "#00FF00",
"100% Blue": "#0000FF",
"100% Cyan": "#00FFFF",
"100% Magenta": "#FF00FF",
"100% Yellow": "#FFFF00",
}
# ============================================================
# 名称标准化
# ============================================================
def normalize_patch_name(name: str) -> str:
if not isinstance(name, str):
return ""
name = name.strip().lower()
# collapse spaces
name = " ".join(name.split())
return name
# ============================================================
# 获取 patch 颜色
# ============================================================
def get_patch_color(name: str):
norm = normalize_patch_name(name)
for key, color in COLOR_PATCH_MAP.items():
if normalize_patch_name(key) == norm:
return color
return None
# ============================================================
# xy → sRGB fallback
# ============================================================
def xy_to_srgb(x, y, Y=1.0):
if y == 0:
return "#AAAAAA"
X = (x * Y) / y
Z = (1 - x - y) * Y / y
rgb = np.dot(
[[3.2406, -1.5372, -0.4986],
[-0.9689, 1.8758, 0.0415],
[0.0557, -0.2040, 1.0570]],
[X, Y, Z]
)
rgb = np.clip(rgb, 0, 1)
rgb = (rgb ** (1 / 2.2)) * 255
r, g, b = rgb.astype(int)
return "#{:02X}{:02X}{:02X}".format(r, g, b)
def get_patch_color_from_xy(name, xy=None):
color = get_patch_color(name)
if color:
return color
if xy is not None:
x, y = xy
return xy_to_srgb(x, y)
return "#AAAAAA"

View File

@@ -74,12 +74,19 @@ _DEFAULT_CCT_PARAMS = {
"y_ideal": 0.3290,
"y_tolerance": 0.003,
},
"local_dimming": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.3290,
"y_tolerance": 0.003,
},
}
_DEFAULT_GAMUT_REFERENCE = {
"screen_module": "DCI-P3",
"sdr_movie": "BT.709",
"hdr_movie": "BT.2020",
"local_dimming": "DCI-P3",
}
_DEFAULT_TEST_TYPES = {
@@ -87,6 +94,7 @@ _DEFAULT_TEST_TYPES = {
"name": "屏模组性能测试",
"test_items": ["gamut", "gamma", "cct", "contrast"],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
@@ -96,6 +104,7 @@ _DEFAULT_TEST_TYPES = {
"name": "SDR Movie测试",
"test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
@@ -105,11 +114,22 @@ _DEFAULT_TEST_TYPES = {
"name": "HDR Movie测试",
"test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
},
"local_dimming": {
"name": "Local Dimming",
"test_items": [],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"patterns": {},
},
}
_PATTERN_RGB = {
@@ -221,6 +241,401 @@ def get_pattern(name: str) -> dict:
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json")
def reload_gray_pattern() -> dict:
"""重新从 ``settings/patterns/gray.json`` 加载灰阶 pattern。
原地更新 ``_PATTERN_GRAY``,让 ``PQConfig.default_pattern_gray``
与 ``_KNOWN_PATTERNS['gray']`` 等所有现有引用同步生效,
无需重启程序即可应用新 pattern 列表。
"""
new_data = _load_pattern_or_empty(
_PATTERNS_DIR / "gray.json", default=_PATTERN_GRAY_FALLBACK
)
_PATTERN_GRAY.clear()
_PATTERN_GRAY.update(new_data)
return copy.deepcopy(_PATTERN_GRAY)
def get_gray_pattern_fallback() -> dict:
"""返回硬编码默认 11 点灰阶 pattern 的深拷贝(用于 UI 的"恢复默认")。"""
return copy.deepcopy(_PATTERN_GRAY_FALLBACK)
# =============================================================================
# 灰阶 Pattern 预设管理settings/patterns/presets/gray/*.json
# =============================================================================
#
# 设计要点:
# - 每个预设独立 JSON 文件,文件名(不含 .json即预设名。
# - 内置预设以 ``_builtin_`` 前缀命名,并在 _meta.locked=TrueUI 禁止删除/改名/覆盖。
# - 当前激活预设记录在 settings/patterns/presets/_active.json便于 UI 显示。
# - 应用某预设 = 把它复制写入 settings/patterns/gray.json + reload_gray_pattern()。
# - gamma/cct/contrast/eotf 共用同一份 gray 预设(与 runner 现有共享灰阶采集对齐)。
#
_PRESETS_DIR = _PATTERNS_DIR / "presets"
_ACTIVE_INDEX_FILE = _PRESETS_DIR / "_active.json"
def _gray_presets_dir() -> Path:
p = _PRESETS_DIR / "gray"
p.mkdir(parents=True, exist_ok=True)
return p
def _safe_preset_name(name: str) -> str:
"""清洗预设名,去掉文件系统危险字符。"""
name = (name or "").strip()
bad = '<>:"/\\|?*\n\r\t'
for ch in bad:
name = name.replace(ch, "_")
return name[:80] or "untitled"
def _preset_path(test_kind: str, name: str) -> Path:
if test_kind != "gray":
raise ValueError(f"暂仅支持 test_kind='gray',收到: {test_kind}")
return _gray_presets_dir() / f"{_safe_preset_name(name)}.json"
def _load_active_index() -> dict:
try:
with open(_ACTIVE_INDEX_FILE, encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _save_active_index(data: dict) -> None:
_PRESETS_DIR.mkdir(parents=True, exist_ok=True)
with open(_ACTIVE_INDEX_FILE, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
def list_presets(test_kind: str = "gray") -> list[dict]:
"""
列出指定类别下的所有预设。
Returns:
list of {name, locked, description, point_count, file_path, generator}
"""
d = _gray_presets_dir() if test_kind == "gray" else None
if d is None:
return []
items: list[dict] = []
for fp in sorted(d.glob("*.json")):
try:
data = load_pattern_file(fp)
except (json.JSONDecodeError, OSError):
continue
meta = data.get("_meta", {}) or {}
items.append({
"name": fp.stem,
"locked": bool(meta.get("locked", False)),
"description": meta.get("description", ""),
"generator": meta.get("generator", ""),
"created": meta.get("created", ""),
"point_count": len(data.get("pattern_params") or []),
"file_path": str(fp),
})
# 内置 _builtin_ 排前,其余按名字排序
items.sort(key=lambda x: (not x["name"].startswith("_builtin_"), x["name"]))
return items
def load_preset(test_kind: str, name: str) -> dict:
"""加载预设的完整 pattern 数据(含 _meta"""
return load_pattern_file(_preset_path(test_kind, name))
def save_preset(
test_kind: str,
name: str,
pattern: dict,
*,
description: str = "",
generator: str = "",
locked: bool = False,
overwrite: bool = True,
) -> Path:
"""保存预设。若 overwrite=False 且文件已存在或目标为锁定预设则抛错。"""
from datetime import datetime
name = _safe_preset_name(name)
path = _preset_path(test_kind, name)
if path.exists():
try:
existing = load_pattern_file(path)
if (existing.get("_meta") or {}).get("locked"):
raise PermissionError(f"预设 '{name}' 已锁定,不可覆盖")
except (json.JSONDecodeError, OSError):
pass
if not overwrite:
raise FileExistsError(f"预设 '{name}' 已存在")
data = {
"pattern_mode": pattern.get("pattern_mode", "SolidColor"),
"measurement_bit_depth": pattern.get("measurement_bit_depth", 8),
"measurement_max_value": max(0, len(pattern.get("pattern_params") or []) - 1),
"pattern_params": [list(map(int, rgb)) for rgb in (pattern.get("pattern_params") or [])],
"_meta": {
"description": description,
"generator": generator,
"locked": locked,
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
},
}
save_pattern_file(path, data)
return path
def delete_preset(test_kind: str, name: str) -> None:
"""删除预设;锁定预设不可删除。"""
path = _preset_path(test_kind, name)
if not path.exists():
raise FileNotFoundError(f"预设 '{name}' 不存在")
data = load_pattern_file(path)
if (data.get("_meta") or {}).get("locked"):
raise PermissionError(f"预设 '{name}' 已锁定,不可删除")
path.unlink()
# 若被删的恰是激活预设,清理记录
idx = _load_active_index()
if idx.get(test_kind) == name:
idx.pop(test_kind, None)
_save_active_index(idx)
def rename_preset(test_kind: str, old: str, new: str) -> Path:
"""重命名;锁定预设不可改名。"""
src = _preset_path(test_kind, old)
if not src.exists():
raise FileNotFoundError(f"预设 '{old}' 不存在")
data = load_pattern_file(src)
if (data.get("_meta") or {}).get("locked"):
raise PermissionError(f"预设 '{old}' 已锁定,不可重命名")
dst = _preset_path(test_kind, new)
if dst.exists():
raise FileExistsError(f"目标预设 '{new}' 已存在")
src.rename(dst)
idx = _load_active_index()
if idx.get(test_kind) == old:
idx[test_kind] = dst.stem
_save_active_index(idx)
return dst
def duplicate_preset(test_kind: str, src_name: str, new_name: str) -> Path:
"""复制一个预设为新副本(解除锁定)。"""
data = load_preset(test_kind, src_name)
meta = dict(data.get("_meta") or {})
meta["locked"] = False
meta["description"] = f"复制自 {src_name}" + (
f"{meta.get('description', '')}" if meta.get("description") else ""
)
data["_meta"] = meta
return save_preset(
test_kind,
new_name,
data,
description=meta["description"],
generator=meta.get("generator", ""),
locked=False,
overwrite=False,
)
def activate_preset(test_kind: str, name: str) -> dict:
"""
将指定预设应用为当前 gray pattern
- 写入 settings/patterns/gray.json剥离 _meta 以保持原格式)
- reload_gray_pattern() 让运行时立即生效
- 在 _active.json 记录激活预设名
"""
data = load_preset(test_kind, name)
clean = {
"pattern_mode": data.get("pattern_mode", "SolidColor"),
"measurement_bit_depth": data.get("measurement_bit_depth", 8),
"measurement_max_value": data.get("measurement_max_value", 0),
"pattern_params": data.get("pattern_params") or [],
}
save_pattern_file(_PATTERNS_DIR / "gray.json", clean)
reload_gray_pattern()
idx = _load_active_index()
idx[test_kind] = _safe_preset_name(name)
_save_active_index(idx)
return clean
def get_active_preset_name(test_kind: str = "gray") -> str | None:
"""返回当前激活预设名(来自 _active.json不存在则返回 None。"""
return _load_active_index().get(test_kind)
def import_preset_from_file(test_kind: str, src_file, *, name: str | None = None) -> Path:
"""从外部 JSON 文件导入为预设。"""
data = load_pattern_file(src_file)
if not data.get("pattern_params"):
raise ValueError("文件中未找到 pattern_params")
preset_name = name or Path(src_file).stem
return save_preset(
test_kind,
preset_name,
data,
description=(data.get("_meta") or {}).get("description", "导入"),
generator=(data.get("_meta") or {}).get("generator", ""),
locked=False,
overwrite=True,
)
def export_preset_to_file(test_kind: str, name: str, dst_file) -> Path:
"""将预设导出到指定外部 JSON 文件。"""
data = load_preset(test_kind, name)
save_pattern_file(dst_file, data)
return Path(dst_file)
# ---- 内置预设生成器 ----------------------------------------------------------
def _gen_even_gray(n: int) -> list[list[int]]:
"""N 点等分 (100%→0%)。"""
if n < 2:
n = 2
out = []
for i in range(n):
pct = 100.0 - (100.0 / (n - 1)) * i
v = int(round(pct / 100.0 * 255))
out.append([v, v, v])
return out
def _gen_pq_gray(n: int) -> list[list[int]]:
"""在 PQ 编码空间 N 点等分(亮度按 PQ 曲线均匀分布,低端更密集)。
采样规则:取 PQ 信号值从 1.0 到 0.0 等分,转 8-bit RGB 灰阶。
PQ 信号本身在感知亮度上即为线性,故"等分编码值""等分感知亮度"。)
"""
if n < 2:
n = 2
out = []
for i in range(n):
v_pq = 1.0 - i / (n - 1)
v = int(round(v_pq * 255))
out.append([v, v, v])
return out
def _gen_gamma_gray(n: int, gamma: float = 2.2) -> list[list[int]]:
"""在线性光强空间 N 点等分,再用 gamma 编码到 8-bit 灰阶(暗端更密集)。"""
if n < 2:
n = 2
out = []
for i in range(n):
lin = 1.0 - i / (n - 1) # 线性光 1→0
code = lin ** (1.0 / gamma) # gamma 编码
v = int(round(code * 255))
out.append([v, v, v])
return out
_BUILTIN_GRAY_PRESETS = [
("_builtin_even_11pt",
"11 点等分 (100%→0%),行业标准灰阶",
"even-11",
_gen_even_gray(11)),
("_builtin_even_21pt",
"21 点等分 (5% 步长),更精细的 SDR 灰阶",
"even-21",
_gen_even_gray(21)),
("_builtin_gamma22_17pt",
"17 点 Gamma 2.2 分布(暗端更密集),用于 SDR Gamma 拟合",
"gamma2.2-17",
_gen_gamma_gray(17, 2.2)),
("_builtin_pq_17pt",
"17 点 PQ 编码等分,用于 HDR EOTF 评估",
"pq-17",
_gen_pq_gray(17)),
]
def ensure_builtin_presets() -> None:
"""启动时调用:确保内置预设存在;若已存在则跳过(不覆盖用户修改)。
同时迁移:若 presets/ 目录为空但 settings/patterns/gray.json 存在,
将其作为 ``user_current`` 预设引入,避免用户原有自定义丢失。
"""
presets_dir = _gray_presets_dir()
for name, desc, gen, params in _BUILTIN_GRAY_PRESETS:
path = presets_dir / f"{name}.json"
if path.exists():
continue
save_preset(
"gray",
name,
{
"pattern_mode": "SolidColor",
"measurement_bit_depth": 8,
"measurement_max_value": len(params) - 1,
"pattern_params": params,
},
description=desc,
generator=gen,
locked=True,
)
# 迁移:用户原有 gray.json 入库为 user_current仅在 presets 目录还没有任何用户预设时)
user_presets = [p for p in presets_dir.glob("*.json") if not p.stem.startswith("_builtin_")]
if not user_presets:
gray_file = _PATTERNS_DIR / "gray.json"
if gray_file.exists():
try:
cur = load_pattern_file(gray_file)
if cur.get("pattern_params"):
save_preset(
"gray",
"user_current",
cur,
description="迁移自原 gray.json",
generator="migrated",
locked=False,
overwrite=False,
)
idx = _load_active_index()
idx.setdefault("gray", "user_current")
_save_active_index(idx)
except (json.JSONDecodeError, OSError, FileExistsError):
pass
# 若无激活预设记录,按规则推断(优先匹配当前 gray.json 内容)
idx = _load_active_index()
if "gray" not in idx:
try:
cur = load_pattern_file(_PATTERNS_DIR / "gray.json")
cur_params = cur.get("pattern_params") or []
except (FileNotFoundError, json.JSONDecodeError, OSError):
cur_params = []
match: str | None = None
for fp in presets_dir.glob("*.json"):
try:
if (load_pattern_file(fp).get("pattern_params") or []) == cur_params:
match = fp.stem
break
except (json.JSONDecodeError, OSError):
continue
if match:
idx["gray"] = match
_save_active_index(idx)
# 自动确保内置预设存在(首次启动会创建文件)
try:
ensure_builtin_presets()
except OSError:
# 启动期目录不可写则跳过UI 层有兜底
pass
class PQConfig:
def __init__(self, current_test_type="screen_module"):
@@ -300,7 +715,16 @@ class PQConfig:
def from_dict(self, config_dict):
"""从字典加载配置"""
self.current_test_type = config_dict.get("current_test_type", "screen_module")
self.current_test_types = config_dict.get("test_types", self.current_test_types)
# 以默认模板为底,叠加历史配置,保证新字段(如 data_range在旧配置下也有值。
loaded_test_types = config_dict.get("test_types", {})
merged_test_types = copy.deepcopy(_DEFAULT_TEST_TYPES)
if isinstance(loaded_test_types, dict):
for test_type, loaded_cfg in loaded_test_types.items():
if test_type in merged_test_types and isinstance(loaded_cfg, dict):
merged_test_types[test_type].update(loaded_cfg)
self.current_test_types = merged_test_types
self.device_config = config_dict.get("device_config", self.device_config)
self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern)

View File

@@ -45,11 +45,7 @@ def load_icon(png_path):
def backgroud_style_set():
style = ttk.Style()
# 移除背景色设置,使用默认背景色
style.configure(
"SidebarSelected.TButton",
# anchor="w",
padding=10,
background="#005470",
)
style = ttk.Style() # noqa: F841 - 保持原副作用:确保 Style 实例化
# 现代化样式集中注册Card / ConfigHeader / Toolbar / StatusBar / Sidebar 等)
from app.views.modern_styles import apply_modern_styles
apply_modern_styles()

View File

@@ -1,4 +1,4 @@
"""测试执行runner相关逻辑Step 5 重构)。
"""测试执行runner相关逻辑Step 5 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。
@@ -15,7 +15,13 @@ import numpy as np
import algorithm.pq_algorithm as pq_algorithm
from app.pq.pq_result import PQResult
def new_pq_results(self, test_type, test_name):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def new_pq_results(self: "PQAutomationApp", test_type, test_name):
# 通过 PQResultStore 创建/替换指定 test_type 的结果,并设为当前活跃
self.results.new(test_type, test_name)
# 设置配置
@@ -36,7 +42,7 @@ def new_pq_results(self, test_type, test_name):
)
def run_test(self, test_type, test_items):
def run_test(self: "PQAutomationApp", test_type, test_items):
"""执行测试"""
try:
self.log_gui.log(f"开始执行{self.get_test_type_name(test_type)}测试", level="info")
@@ -51,6 +57,11 @@ def run_test(self, test_type, test_items):
self.run_sdr_movie_test(test_items)
elif test_type == "hdr_movie":
self.run_hdr_movie_test(test_items)
elif test_type == "local_dimming":
self.log_gui.log(
"Local Dimming 为手动模式,请在 Local Dimming 面板发送图案并采集亮度",
level="info",
)
# 测试完成后更新UI状态
if self.testing: # 如果没有被中途停止
@@ -63,7 +74,7 @@ def run_test(self, test_type, test_items):
self._dispatch_ui(self.on_test_error)
def run_screen_module_test(self, test_items):
def run_screen_module_test(self: "PQAutomationApp", test_items):
"""执行屏模组性能测试 - 优化版"""
self.log_gui.log("执行屏模组性能测试...", level="info")
@@ -138,7 +149,7 @@ def run_screen_module_test(self, test_items):
self.test_contrast("screen_module", shared_gray_data)
def run_custom_sdr_test(self, test_items):
def run_custom_sdr_test(self: "PQAutomationApp", test_items):
"""执行客户定制 SDR 测试 - 升级版"""
self.log_gui.log("执行客户定制 SDR 测试...", level="info")
# 获取信号格式设置
@@ -154,7 +165,7 @@ def run_custom_sdr_test(self, test_items):
self._dispatch_ui(self.on_custom_template_test_completed)
def run_sdr_movie_test(self, test_items):
def run_sdr_movie_test(self: "PQAutomationApp", test_items):
"""执行SDR Movie测试"""
self.log_gui.log("执行SDR Movie测试...", level="info")
@@ -225,7 +236,7 @@ def run_sdr_movie_test(self, test_items):
self.test_color_accuracy("sdr_movie")
def run_hdr_movie_test(self, test_items):
def run_hdr_movie_test(self: "PQAutomationApp", test_items):
"""执行HDR Movie测试"""
self.log_gui.log("执行HDR Movie测试...", level="info")
@@ -300,7 +311,7 @@ def run_hdr_movie_test(self, test_items):
self.test_color_accuracy("hdr_movie")
def send_fix_pattern(self, mode):
def send_fix_pattern(self: "PQAutomationApp", mode):
"""发送固定图案并采集数据 - 支持不同测试类型的信号格式"""
results = []
@@ -309,9 +320,26 @@ def send_fix_pattern(self, mode):
self.log_gui.log("=" * 50, level="separator")
# 信号格式设置后等待电视重新锁定 HDMI 信号
# format_changed=True 表示本次 set_video_mode 的参数与上次不同TV 需要重新锁定
format_changed = getattr(getattr(self, "ucd", None), "format_changed", True)
# 判定信号是否变化(决定 settle 长度)。
# - SDR/HDRprepare_session 内部已调用 ``apply_signal_format`` → ``set_video_mode``
# 此时 ``format_changed`` 反映本次 vs 上次的差异,可直接读取。
# - screen_moduleprepare_session 只 stage 了 color_info/timing未调用
# ``set_video_mode````format_changed`` 仍是上次测试的陈旧值,按"潜在变化"处理。
# 注意:必须在 prime 提交之前读取,否则 prime 的 set_video_mode 会拿当次
# 参数与刚写入的 ``_last_sent_config`` 比对,得到 False把这个标志覆盖掉。
if mode == "screen_module":
format_changed = True
else:
format_changed = bool(
getattr(getattr(self, "ucd", None), "format_changed", True)
)
# 预热提交prepare_session 仅 stage 了新的 color/timing/pattern
# 真正的 ``pg.apply()`` 要到第一次发图时才发生。提前发送首个 pattern
# 让 TV 在 signal_settle 期间就开始重新锁定信号;
# 否则前 1~2 个 pattern 会落在 TV 锁定窗口里导致测量错误。
self.pattern_service.send_session_pattern(session, 0)
if format_changed:
signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 5.0)))
self.log_gui.log(
@@ -355,14 +383,18 @@ def send_fix_pattern(self, mode):
else:
self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info")
self.pattern_service.send_session_pattern(session, i)
time.sleep(settle_time)
# 首图已在 prime 阶段发送并经 signal_settle 稳定,无需重发也无需再等
# settle_time后续 pattern 走正常发图 + 等待。
if i == 0:
pass
else:
self.pattern_service.send_session_pattern(session, i)
time.sleep(settle_time)
# 测量数据
if mode == "custom":
result = []
self.ca.set_Display(1)
tcp, duv, lv, X, Y, Z = self.ca.readAllDisplay()
tcp, duv, lv, X, Y, Z = self.read_ca_tcp_duv()
if should_log_detail:
self.log_gui.log(
@@ -370,8 +402,7 @@ def send_fix_pattern(self, mode):
f"X={X:.4f}, Y={Y:.4f}, Z={Z:.4f}"
, level="success")
self.ca.set_Display(8)
lambda_, Pe, lv, X, Y, Z = self.ca.readAllDisplay()
lambda_, Pe, lv, X, Y, Z = self.read_ca_lambda_pe()
if should_log_detail:
self.log_gui.log(
@@ -416,9 +447,7 @@ def send_fix_pattern(self, mode):
self.log_gui.log(f"{i+1} 行实时结果写入失败: {str(e)}", level="error")
else:
self.ca.set_xyLv_Display()
x, y, lv, X, Y, Z = self.ca.readAllDisplay()
x, y, lv, X, Y, Z = self.read_ca_xyLv()
results.append([x, y, lv, X, Y, Z])
if should_log_detail:
@@ -438,7 +467,7 @@ def send_fix_pattern(self, mode):
return None
def test_custom_sdr(self):
def test_custom_sdr(self: "PQAutomationApp"):
"""执行客户定制 SDR 测试 - 升级版"""
self.log_gui.log("执行客户定制 SDR 测试...", level="info")
results = self.send_fix_pattern("custom")
@@ -449,7 +478,7 @@ def test_custom_sdr(self):
self.log_gui.log(f"客户模板采集完成,共 {len(results)} 组数据", level="success")
def test_gamut(self, test_type):
def test_gamut(self: "PQAutomationApp", test_type):
"""测试色域"""
self.log_gui.log("开始测试色域...", level="info")
self.results.start_test_item("gamut")
@@ -607,7 +636,7 @@ def test_gamut(self, test_type):
raise
def test_gamma(self, test_type, gray_data=None):
def test_gamma(self: "PQAutomationApp", test_type, gray_data=None):
"""测试Gamma曲线
Args:
@@ -698,7 +727,7 @@ def test_gamma(self, test_type, gray_data=None):
raise
def test_eotf(self, test_type, gray_data=None):
def test_eotf(self: "PQAutomationApp", test_type, gray_data=None):
"""测试 EOTF 曲线HDR 专用)
Args:
@@ -781,7 +810,7 @@ def test_eotf(self, test_type, gray_data=None):
raise
def test_cct(self, test_type, gray_data=None):
def test_cct(self: "PQAutomationApp", test_type, gray_data=None):
"""测试色度一致性"""
self.log_gui.log("开始测试色度一致性...", level="info")
self.results.start_test_item("cct")
@@ -821,7 +850,7 @@ def test_cct(self, test_type, gray_data=None):
raise
def test_contrast(self, test_type, gray_data=None):
def test_contrast(self: "PQAutomationApp", test_type, gray_data=None):
"""测试对比度
Args:
@@ -885,7 +914,7 @@ def test_contrast(self, test_type, gray_data=None):
raise
def test_color_accuracy(self, test_type):
def test_color_accuracy(self: "PQAutomationApp", test_type):
"""测试色准 - 使用手工实现的 ΔE 2000应用 Gamma"""
# ========== Gamma 参考值 ==========
@@ -1045,7 +1074,7 @@ def test_color_accuracy(self, test_type):
self.log_gui.log("色准测试完成", level="success")
def on_test_completed(self):
def on_test_completed(self: "PQAutomationApp"):
"""测试完成后的UI更新"""
self.testing = False
self.start_btn.config(state=tk.NORMAL)
@@ -1188,7 +1217,7 @@ def on_test_completed(self):
messagebox.showinfo("完成", "测试已完成!")
def on_custom_template_test_completed(self):
def on_custom_template_test_completed(self: "PQAutomationApp"):
"""客户模板测试完成后的UI更新"""
self.testing = False
self.set_custom_result_table_locked(False)
@@ -1209,7 +1238,7 @@ def on_custom_template_test_completed(self):
messagebox.showinfo("完成", "客户模板测试已完成!")
def get_current_test_result(self):
def get_current_test_result(self: "PQAutomationApp"):
"""获取当前测试结果"""
test_type = self.test_type_var.get()
test_items = self.get_selected_test_items()
@@ -1241,7 +1270,7 @@ def get_current_test_result(self):
return result
def on_test_error(self):
def on_test_error(self: "PQAutomationApp"):
"""测试出错后的UI更新"""
self.testing = False
self.set_custom_result_table_locked(False)
@@ -1262,3 +1291,27 @@ def on_test_error(self):
messagebox.showerror("错误", "测试过程中发生错误,请查看日志")
class TestRunnerMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
new_pq_results = new_pq_results
run_test = run_test
run_screen_module_test = run_screen_module_test
run_custom_sdr_test = run_custom_sdr_test
run_sdr_movie_test = run_sdr_movie_test
run_hdr_movie_test = run_hdr_movie_test
send_fix_pattern = send_fix_pattern
test_custom_sdr = test_custom_sdr
test_gamut = test_gamut
test_gamma = test_gamma
test_eotf = test_eotf
test_cct = test_cct
test_contrast = test_contrast
test_color_accuracy = test_color_accuracy
on_test_completed = on_test_completed
on_custom_template_test_completed = on_custom_template_test_completed
get_current_test_result = get_current_test_result
on_test_error = on_test_error

View File

@@ -1,9 +1,14 @@
"""AI 图片生成服务:后端请求 + 本地缓存管理。
后端接口(测试环境):
POST {API_BASE_URL}{API_PATH}
body: {"user_message": str, "session_id": str}
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
后端接口(生产/测试环境):
POST {API_BASE_URL}{API_GENERATE_PATH}
body: {"user_message": str, "session_id": str, "upload_image_url"?: str}
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
POST {API_BASE_URL}{API_UPLOAD_PATH} # multipart/form-data, field: file
resp: {"code": 200, "message": "", "data": {"upload_image_url": "..."}}
带 ``upload_image_url`` 启用"图生图"模式;多轮对话需将上一轮返回的 imageUrl
作为下一轮请求的 upload_image_url由 panel 通过会话级缓存自动维护)。
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
"""
@@ -41,10 +46,20 @@ _META_SUFFIX = ".json"
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
# 测试环境后端
# API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
# API_BASE_URL = "http://10.201.44.70:9008/ai-agent/"
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
API_PATH = "api/v1/pqtest/generate"
API_TIMEOUT = 300.0 # 后端最长 60s留余量
API_GENERATE_PATH = "api/v1/pqtest/generate"
API_UPLOAD_PATH = "api/v1/pqtest/upload"
API_TIMEOUT = 300.0 # 后端最长 120s留余量
API_UPLOAD_TIMEOUT = 60.0
# 上传接口限制(来自接口文档)
UPLOAD_MAX_BYTES = 10 * 1024 * 1024
UPLOAD_MAX_PIXELS = 4096
UPLOAD_ALLOWED_EXT = (".png", ".jpg", ".jpeg")
# 兼容旧名(如其他模块仍引用)
API_PATH = API_GENERATE_PATH
# 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置
_session_id: str = str(uuid.uuid4())
@@ -133,9 +148,9 @@ class AIImageRecord:
# ---------- 后端 API ----------
def _api_endpoint() -> str:
def _api_endpoint(path: str = API_GENERATE_PATH) -> str:
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
return base + API_PATH.lstrip("/")
return base + path.lstrip("/")
def _pretty_json_text(value) -> str:
@@ -150,22 +165,33 @@ def _pretty_json_text(value) -> str:
return "" if value is None else str(value)
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str:
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。"""
payload = json.dumps(
{"user_message": user_message,
"session_id": session_id},
ensure_ascii=False,
).encode("utf-8")
def _call_pqtest_generate(
user_message: str,
session_id: str,
upload_image_url: Optional[str] = None,
timeout: float = API_TIMEOUT,
) -> str:
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。
``upload_image_url`` 传入时启用"图生图"模式。
"""
body: dict = {"user_message": user_message, "session_id": session_id}
if upload_image_url:
body["upload_image_url"] = upload_image_url
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
request_headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
}
endpoint = _api_endpoint()
endpoint = _api_endpoint(API_GENERATE_PATH)
logger.info(
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
"[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s",
_mask_sid(session_id),
"img2img" if upload_image_url else "txt2img",
len(user_message or ""),
_truncate(user_message),
upload_image_url or "-",
)
logger.info(
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
@@ -250,6 +276,182 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
return image_url
def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, auto_resize: bool = True) -> str:
"""以 multipart/form-data 上传本地图片,返回 ``upload_image_url``。失败抛异常。
参数:
auto_resize: True 时,若图片超过 4096×4096 或 10MB 则自动缩放/重压
"""
if not file_path or not os.path.isfile(file_path):
raise FileNotFoundError(f"图片文件不存在: {file_path}")
ext = os.path.splitext(file_path)[1].lower()
if ext not in UPLOAD_ALLOWED_EXT:
raise ValueError(f"不支持的图片格式 ({ext}),仅支持 PNG/JPG/JPEG")
try:
with Image.open(file_path) as img:
iw, ih = img.size
except Exception as exc:
raise ValueError(f"无法读取图片: {exc}") from exc
size = os.path.getsize(file_path)
needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES)
file_stem = os.path.splitext(os.path.basename(file_path))[0]
upload_ext = ext
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
if needs_resize:
if not auto_resize:
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
raise ValueError(f"分辨率超过 4096×4096当前 {iw}×{ih}")
raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB")
logger.info(
"[AIImage][UPLOAD] 自动处理超限图片 %dx%d (%.2fMB)",
iw,
ih,
size / 1024 / 1024,
)
with Image.open(file_path) as img:
working = img.copy()
# 先做一次分辨率约束,避免后续压缩开销过大。
scale = min(UPLOAD_MAX_PIXELS / max(1, working.width), UPLOAD_MAX_PIXELS / max(1, working.height), 1.0)
if scale < 1.0:
working = working.resize(
(max(1, int(working.width * scale)), max(1, int(working.height * scale))),
Image.LANCZOS,
)
best_bytes = b""
best_mime = mime
best_ext = upload_ext
# 第一优先:保持原格式。
try:
raw_io = BytesIO()
if ext == ".png":
working.save(raw_io, format="PNG", optimize=True)
raw_mime, raw_ext = "image/png", ".png"
else:
rgb = working.convert("RGB") if working.mode not in {"RGB", "L"} else working
rgb.save(raw_io, format="JPEG", quality=95, optimize=True)
raw_mime, raw_ext = "image/jpeg", ".jpg"
best_bytes = raw_io.getvalue()
best_mime = raw_mime
best_ext = raw_ext
except Exception as exc:
logger.warning("[AIImage][UPLOAD] 原格式编码失败,准备转 JPEG: %s", exc)
# 仍超限时,转 JPEG + 渐进压缩;如仍超限则继续降分辨率。
if len(best_bytes) > UPLOAD_MAX_BYTES:
if best_ext != ".jpg":
logger.info("[AIImage][UPLOAD] 原格式仍超限,切换 JPEG 压缩")
working_jpg = working.convert("RGB") if working.mode != "RGB" else working
while True:
compressed = b""
for q in (95, 90, 85, 80, 75, 70, 65, 60, 55, 50):
tmp = BytesIO()
working_jpg.save(tmp, format="JPEG", quality=q, optimize=True)
data = tmp.getvalue()
compressed = data
if len(data) <= UPLOAD_MAX_BYTES:
break
best_bytes = compressed
best_mime = "image/jpeg"
best_ext = ".jpg"
if len(best_bytes) <= UPLOAD_MAX_BYTES:
break
next_w = max(256, int(working_jpg.width * 0.9))
next_h = max(256, int(working_jpg.height * 0.9))
if next_w == working_jpg.width and next_h == working_jpg.height:
break
if next_w <= 256 or next_h <= 256:
break
working_jpg = working_jpg.resize((next_w, next_h), Image.LANCZOS)
if len(best_bytes) > UPLOAD_MAX_BYTES:
raise ValueError(
f"自动压缩后仍超过 10MB当前 {len(best_bytes)/1024/1024:.2f}MB请更换图片"
)
file_bytes = best_bytes
mime = best_mime
upload_ext = best_ext
iw, ih = working.width, working.height
logger.info(
"[AIImage][UPLOAD] 自动处理完成 %dx%d %.2fMB (%s)",
iw,
ih,
len(file_bytes) / 1024 / 1024,
mime,
)
else:
with open(file_path, "rb") as f:
file_bytes = f.read()
filename = f"{file_stem}{upload_ext}"
boundary = "----pqAuto" + uuid.uuid4().hex
crlf = b"\r\n"
body = b"".join([
b"--", boundary.encode("ascii"), crlf,
b'Content-Disposition: form-data; name="file"; filename="',
filename.encode("utf-8"), b'"', crlf,
b"Content-Type: ", mime.encode("ascii"), crlf, crlf,
file_bytes, crlf,
b"--", boundary.encode("ascii"), b"--", crlf,
])
endpoint = _api_endpoint(API_UPLOAD_PATH)
headers = {
"Content-Type": f"multipart/form-data; boundary={boundary}",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
"Content-Length": str(len(body)),
}
logger.info(
"[AIImage][UPLOAD] file=%s size=%dB mime=%s wh=%dx%d -> %s",
filename, len(file_bytes), mime, iw, ih, endpoint,
)
request = Request(endpoint, data=body, method="POST", headers=headers)
t0 = time.monotonic()
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
http_status = response.status
raw_text = raw.decode("utf-8", errors="replace")
logger.info("[AIImage][UPLOAD_RESP]\nstatus=%s\nbody=%s",
http_status, _pretty_json_text(raw_text))
except HTTPError as exc:
err_raw = b""
try:
err_raw = exc.read() or b""
except Exception:
pass
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
logger.error("[AIImage][UPLOAD_ERR] status=%s reason=%s body=%s",
getattr(exc, "code", "?"), str(exc), _pretty_json_text(err_text))
raise
except Exception as exc:
logger.error("[AIImage][UPLOAD_ERR] %s: %s", type(exc).__name__, exc)
raise
elapsed = time.monotonic() - t0
try:
result = json.loads(raw.decode("utf-8"))
except Exception as exc:
raise RuntimeError(f"上传接口返回非 JSON{raw_text}") from exc
code = result.get("code")
message = result.get("message") or ""
data = result.get("data") or {}
url = (data.get("upload_image_url") or "").strip()
if code != 200 or not url:
raise RuntimeError(f"上传失败 code={code} msg={message or '未知错误'}")
logger.info("[AIImage][UPLOAD_OK] elapsed=%.2fs url=%s", elapsed, url)
return url
# ---------- 缓存路径工具 ----------
@@ -504,6 +706,7 @@ def request_image_async(
base_dir: Optional[str] = None,
session_id: Optional[str] = None,
cancel_event: Optional[threading.Event] = None,
upload_image_url: Optional[str] = None,
) -> threading.Thread:
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
@@ -511,24 +714,33 @@ def request_image_async(
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
``session_id`` 留空则使用进程级会话 id保证多轮对话上下文
``upload_image_url`` 传入后启用"图生图"模式。
"""
sid = session_id or get_session_id()
cancel = cancel_event
ref_url = (upload_image_url or "").strip() or None
def _worker():
try:
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
return
image_url = _call_pqtest_generate(prompt, sid)
image_url = _call_pqtest_generate(prompt, sid, upload_image_url=ref_url)
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
return
extra = {
"source": "ai-api",
"session_id": sid,
"mode": "img2img" if ref_url else "txt2img",
}
if ref_url:
extra["upload_image_url"] = ref_url
record = import_image_from_url(
image_url=image_url,
prompt=prompt,
extra={"source": "ai-api", "session_id": sid},
extra=extra,
base_dir=base_dir,
)
if cancel is not None and cancel.is_set():
@@ -593,6 +805,38 @@ def import_image_from_url_async(
return t
def upload_image_async(
file_path: str,
on_success: Callable[[str], None],
on_error: Callable[[Exception], None],
cancel_event: Optional[threading.Event] = None,
timeout: float = API_UPLOAD_TIMEOUT,
auto_resize: bool = True,
) -> threading.Thread:
"""后台上传本地图片到后端,成功回调返回 ``upload_image_url``。
参数:
auto_resize: True 时自动缩放超大图片
"""
def _worker():
try:
if cancel_event is not None and cancel_event.is_set():
return
url = _call_pqtest_upload(file_path, timeout=timeout, auto_resize=auto_resize)
if cancel_event is not None and cancel_event.is_set():
return
on_success(url)
except Exception as exc:
if cancel_event is not None and cancel_event.is_set():
return
on_error(exc)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t
def is_remote_image_url(value: str) -> bool:
"""判断输入是否为 http/https 图片地址。"""
url = (value or "").strip()

View File

@@ -5,7 +5,6 @@ from dataclasses import dataclass
from app.data_range_converter import convert_pattern_params
from app.pq.pq_config import get_pattern
from drivers.ucd_helpers import send_solid_rgb_pattern
@dataclass
@@ -22,8 +21,23 @@ class PatternService:
def __init__(self, app):
self.app = app
def _build_apply_config_error(self, test_type):
timing = self.app.config.current_test_types.get(test_type, {}).get("timing", "-")
detail = ""
try:
ctrl = getattr(self.app.signal_service.device, "raw_controller", None)
if ctrl is not None:
d = getattr(ctrl, "last_error", None)
if d:
detail = f", detail={d}"
except Exception:
pass
return f"UCD profile apply_config failed for {test_type}, timing={timing}{detail}"
def prepare_session(self, mode, *, test_type=None, log_details=False):
test_type = test_type or self.app.config.current_test_type
if hasattr(self.app.config, "set_current_test_type"):
self.app.config.set_current_test_type(test_type)
if not self.app.config.set_current_pattern(mode):
raise ValueError(f"未知的图案模式: {mode}")
@@ -31,15 +45,53 @@ class PatternService:
source_params = self._get_source_pattern_params(mode)
if test_type == "screen_module":
screen_cfg = self.app.config.current_test_types.get("screen_module", {})
color_space = (
self.app.screen_module_color_space_var.get()
if hasattr(self.app, "screen_module_color_space_var")
else screen_cfg.get("colorimetry", "sRGB")
)
data_range = (
self.app.screen_module_data_range_var.get()
if hasattr(self.app, "screen_module_data_range_var")
else screen_cfg.get("data_range", "Full")
)
bit_depth = (
self.app.screen_module_bit_depth_var.get()
if hasattr(self.app, "screen_module_bit_depth_var")
else f"{int(screen_cfg.get('bpc', 8))}bit"
)
output_format = (
self.app.screen_module_output_format_var.get()
if hasattr(self.app, "screen_module_output_format_var")
else screen_cfg.get("color_format", "RGB")
)
if log_details:
self._log("=" * 50, "separator")
self._log("设置屏模组信号格式:", "info")
self._log("=" * 50, "separator")
for label, value in [
("色彩空间", color_space),
("色彩格式", output_format),
("数据范围", data_range),
("编码位深", bit_depth),
("Timing", self.app.config.current_test_types[test_type]["timing"]),
]:
self._log(f" {label}: {value}", "info")
if not self.app.signal_service.apply_config(active_config):
raise RuntimeError(self._build_apply_config_error(test_type))
success = self.app.signal_service.update_signal_format(
color_space=color_space,
data_range=data_range,
bit_depth=bit_depth,
output_format=output_format,
)
if log_details:
self._log(
f" Timing: {self.app.config.current_test_types[test_type]['timing']}",
"info",
f"屏模组信号格式设置{'成功' if success else '失败'}",
"success" if success else "error",
)
self.app.ucd.set_ucd_params(active_config)
elif test_type == "sdr_movie":
data_range = self.app.sdr_data_range_var.get()
@@ -61,12 +113,15 @@ class PatternService:
active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
self.app.ucd.set_ucd_params(active_config)
success = self.app.ucd.apply_signal_format(
if hasattr(active_config, "set_current_test_type"):
active_config.set_current_test_type(test_type)
if not self.app.signal_service.apply_config(active_config):
raise RuntimeError(self._build_apply_config_error(test_type))
success = self.app.signal_service.update_signal_format(
color_space=self.app.sdr_color_space_var.get(),
data_range=data_range,
bit_depth=self.app.sdr_bit_depth_var.get(),
color_format=self.app.sdr_output_format_var.get(),
output_format=self.app.sdr_output_format_var.get(),
)
if log_details:
self._log(f"SDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error")
@@ -93,12 +148,15 @@ class PatternService:
active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
self.app.ucd.set_ucd_params(active_config)
success = self.app.ucd.apply_signal_format(
if hasattr(active_config, "set_current_test_type"):
active_config.set_current_test_type(test_type)
if not self.app.signal_service.apply_config(active_config):
raise RuntimeError(self._build_apply_config_error(test_type))
success = self.app.signal_service.update_signal_format(
color_space=self.app.hdr_color_space_var.get(),
data_range=data_range,
bit_depth=self.app.hdr_bit_depth_var.get(),
color_format=self.app.hdr_output_format_var.get(),
output_format=self.app.hdr_output_format_var.get(),
max_cll=self.app.hdr_maxcll_var.get(),
max_fall=self.app.hdr_maxfall_var.get(),
)
@@ -124,7 +182,7 @@ class PatternService:
raise IndexError(f"pattern 索引越界: {index}")
pattern_param = session.pattern_params[index]
if not self.app.ucd.send_current_pattern_params(pattern_param):
if not self.app.signal_service.send_pattern_params(pattern_param):
raise RuntimeError(f"发送 pattern 失败: {index}")
return pattern_param
@@ -135,7 +193,7 @@ class PatternService:
log_details=False,
)
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type)
send_solid_rgb_pattern(self.app.ucd, converted_rgb, raise_on_error=True)
self.app.signal_service.send_solid_rgb(converted_rgb)
return True
def _get_source_pattern_params(self, mode):

290
app/services/ucd_service.py Normal file
View File

@@ -0,0 +1,290 @@
"""UCD 信号 / 图案应用服务层。
服务层是 GUI ↔ Driver 的唯一通道,负责:
- 将 UI 字符串("BT.709""10bit""YCbCr 4:4:4" 等)翻译成 :class:`SignalFormat`
- 将各 panel 的 timing 字符串翻译成 :class:`TimingSpec`
- 协调 :meth:`IUcdDevice.configure` / ``set_pattern`` / ``apply`` 的调用顺序;
- 通过 :class:`EventBus` 让 GUI 订阅状态变化,而非主动轮询。
本层不直接 import UniTAP也不读取 :mod:`tkinter` 变量;
所有输入都是显式参数,便于单测。
"""
from __future__ import annotations
from contextlib import contextmanager
import logging
import sys
import threading
from app.ucd_domain import (
Colorimetry,
DynamicRange,
EventBus,
PatternKind,
PatternSpec,
SignalFormat,
TimingSpec,
UcdError,
bit_depth_str_to_bpc,
color_space_to_colorimetry,
data_range_to_dynamic_range,
output_format_to_color_format,
parse_timing_str,
)
from drivers.ucd_driver import IUcdDevice
log = logging.getLogger(__name__)
_LOCK_TIMEOUT_SECONDS = 8.0
_DEBUG_LOCK_TIMEOUT_SECONDS = 0.3
_PATTERN_LOCK_TIMEOUT_SECONDS = 0.8
# ─── 视图字符串 → 值对象 转换工具 ────────────────────────────────
def build_signal_format(
*,
color_space: str,
output_format: str,
bit_depth: str,
data_range: str = "Full",
) -> SignalFormat:
"""根据下拉框字符串组装 :class:`SignalFormat`。
各参数解析失败抛 :class:`UcdConfigError`。
"""
return SignalFormat(
color_format=output_format_to_color_format(output_format),
colorimetry=color_space_to_colorimetry(color_space),
bpc=bit_depth_str_to_bpc(bit_depth),
dynamic_range=data_range_to_dynamic_range(data_range),
)
def build_timing(timing_str: str) -> TimingSpec:
"""``"DMT 3840x2160@60Hz"`` → :class:`TimingSpec`。"""
return parse_timing_str(timing_str)
def solid_rgb_pattern(rgb: tuple[int, int, int] | list[int]) -> PatternSpec:
r, g, b = rgb[0], rgb[1], rgb[2]
return PatternSpec(kind=PatternKind.SOLID, solid_rgb=(int(r), int(g), int(b)))
def image_pattern(path: str) -> PatternSpec:
return PatternSpec(kind=PatternKind.IMAGE, image_path=path)
# ─── 服务 ────────────────────────────────────────────────────────
class SignalService:
"""协调 SignalFormat / Timing / Pattern 的写入与提交。
使用线程锁串行化所有对外的 ``apply_*`` 调用,避免多个测试线程
同时操作 UCD 造成 SDK 状态错乱。
"""
def __init__(self, device: IUcdDevice, bus: EventBus):
self._dev = device
self._bus = bus
self._lock = threading.RLock()
self._lock_owner_tid: int | None = None
self._lock_owner_name: str | None = None
def _effective_lock_timeout(self, timeout_override: float | None = None) -> float:
"""调试模式下缩短锁等待,避免单步时表现为 UI 长时间无响应。"""
if timeout_override is not None:
return timeout_override
if sys.gettrace() is not None:
return _DEBUG_LOCK_TIMEOUT_SECONDS
return _LOCK_TIMEOUT_SECONDS
@contextmanager
def _acquire_service_lock(self, op_name: str, timeout_override: float | None = None):
timeout = self._effective_lock_timeout(timeout_override)
current = threading.current_thread()
log.info(
"SignalService.%s acquiring lock timeout=%.1fs tid=%s thread=%s owner_tid=%s owner_thread=%s",
op_name,
timeout,
threading.get_ident(),
current.name,
self._lock_owner_tid,
self._lock_owner_name,
)
acquired = self._lock.acquire(timeout=timeout)
if not acquired:
raise UcdError(
"UCD busy: lock timeout in "
f"SignalService.{op_name} ({timeout:.1f}s), "
f"owner_tid={self._lock_owner_tid}, owner_thread={self._lock_owner_name}"
)
prev_owner_tid = self._lock_owner_tid
prev_owner_name = self._lock_owner_name
self._lock_owner_tid = threading.get_ident()
self._lock_owner_name = current.name
log.info(
"SignalService.%s lock acquired tid=%s thread=%s",
op_name,
self._lock_owner_tid,
self._lock_owner_name,
)
try:
yield
finally:
self._lock_owner_tid = prev_owner_tid
self._lock_owner_name = prev_owner_name
self._lock.release()
# -- 高层接口 ------------------------------------------------
def apply(
self,
*,
signal: SignalFormat,
timing: TimingSpec,
pattern: PatternSpec,
) -> bool:
"""一次性提交信号格式 + timing + 图案。
Returns:
``format_changed``——本次相对上一次 :meth:`apply` 是否变化。
"""
with self._acquire_service_lock("apply"):
log.info(
"SignalService.apply signal=%s timing=%s pattern=%s",
signal,
timing,
pattern.kind.value,
)
changed = self._dev.configure(signal, timing)
self._dev.set_pattern(pattern)
self._dev.apply()
return changed
def send_pattern(self, pattern: PatternSpec) -> None:
"""在已 configure 的信号上仅更新图案后 apply。"""
with self._acquire_service_lock("send_pattern", _PATTERN_LOCK_TIMEOUT_SECONDS):
log.info("SignalService.send_pattern pattern=%s", pattern.kind.value)
self._dev.set_pattern(pattern)
self._dev.apply()
def send_solid_rgb(self, rgb: tuple[int, int, int] | list[int]) -> None:
self.send_pattern(solid_rgb_pattern(rgb))
def send_image(self, path: str) -> None:
self.send_pattern(image_pattern(path))
# -- 过渡期 APIPhase 2-----------------------------------
# 现有 GUI 回调以"仅更新信号格式、不切换图案"的方式调用
# ``ucd.apply_signal_format(color_space=..., color_format=..., bit_depth=...)``。
# 新代码统一通过本方法走 SignalService内部仍委托给底层
# controller 的同名旧接口,迁移完成后将替换为纯净实现。
def update_signal_format(
self,
*,
color_space: str,
output_format: str,
bit_depth: str,
data_range: str = "Full",
max_cll: int | None = None,
max_fall: int | None = None,
) -> bool:
"""仅将信号格式 stage 到 SDK沿用上一次的 timing不切换图案。
UI 字符串先经域层解析做参数校验;解析失败抛 :class:`UcdConfigError`。
"""
# 解析仅做校验;当前实现走 raw controller 的旧 API
_ = build_signal_format(
color_space=color_space,
output_format=output_format,
bit_depth=bit_depth,
data_range=data_range,
)
ctrl = getattr(self._dev, "raw_controller", None)
if ctrl is None:
raise UcdError("update_signal_format 暂仅支持 UCD323Device")
with self._acquire_service_lock("update_signal_format"):
return bool(
ctrl.apply_signal_format(
color_space=color_space,
color_format=output_format,
bit_depth=bit_depth,
data_range=data_range,
max_cll=max_cll,
max_fall=max_fall,
)
)
# -- 透传给上层的查询 ---------------------------------------
@property
def device(self) -> IUcdDevice:
return self._dev
def current_resolution(self) -> tuple[int, int]:
return self._dev.current_resolution()
@property
def is_connected(self) -> bool:
"""UCD 设备是否已打开。供 GUI 做前置校验。"""
ctrl = getattr(self._dev, "raw_controller", None)
return bool(ctrl and getattr(ctrl, "status", False))
# -- 过渡期 APIPhase 5config 驱动的写入 -----------------
# 现有 GUI / Service 通过 ``PQConfig`` 对象描述当次测试参数,
# 由 :class:`UCDController.set_ucd_params` 翻译为色彩/Timing/Pattern。
# 在配置层重构落地前,这两个方法作为 SignalService 的统一入口,
# 让上层不再直接接触 ``self.app.ucd``。
def apply_config(self, config) -> bool:
"""按 :class:`PQConfig` 写入色彩 / Timing / 当前 Pattern不 apply 输出)。"""
ctrl = getattr(self._dev, "raw_controller", None)
if ctrl is None:
raise UcdError("apply_config 暂仅支持 UCD323Device")
with self._acquire_service_lock("apply_config"):
return bool(ctrl.set_ucd_params(config))
def send_pattern_params(self, params) -> bool:
"""以 ``params`` 更新当前 pattern 的参数并 apply。"""
ctrl = getattr(self._dev, "raw_controller", None)
if ctrl is None:
raise UcdError("send_pattern_params 暂仅支持 UCD323Device")
with self._acquire_service_lock("send_pattern_params"):
return bool(ctrl.send_current_pattern_params(params))
def apply_and_run(self, config, pattern_params) -> bool:
"""``set_ucd_params`` + ``set_pattern`` + ``run`` 的复合操作。
服务于 custom_template_panel 单步流程。
"""
ctrl = getattr(self._dev, "raw_controller", None)
if ctrl is None:
raise UcdError("apply_and_run 暂仅支持 UCD323Device")
with self._acquire_service_lock("apply_and_run"):
if not ctrl.set_ucd_params(config):
return False
if not ctrl.set_pattern(ctrl.current_pattern, pattern_params):
return False
return bool(ctrl.run())
__all__ = [
"SignalService",
"build_signal_format",
"build_timing",
"solid_rgb_pattern",
"image_pattern",
# 重导出常用域类型方便上层 import 一次到位
"SignalFormat",
"TimingSpec",
"PatternSpec",
"PatternKind",
"Colorimetry",
"DynamicRange",
"UcdError",
]

View File

@@ -1,7 +1,7 @@
"""Local Dimming 测试逻辑(应用层)。
"""Local Dimming 测试逻辑(应用层)。
整合自原 drivers/local_dimming_test.py窗口图片生成与测试主循环
直接落在本模块UCD 通用操作下沉到 drivers.ucd_helpers
直接落在本模块UCD 通用操作通过 SignalService 完成
"""
import atexit
@@ -18,7 +18,12 @@ from tkinter import filedialog, messagebox
import numpy as np
from PIL import Image
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
# --------------------------------------------------------------------------
@@ -26,6 +31,9 @@ from drivers.ucd_helpers import get_current_resolution, send_image_pattern
# --------------------------------------------------------------------------
DEFAULT_WINDOW_PERCENTAGES = [1, 2, 5, 10, 18, 25, 50, 75, 100]
DEFAULT_CHESSBOARD_GRID = 5
INSTANT_PEAK_WINDOW_PERCENTAGE = 10
INSTANT_PEAK_CAPTURE_DELAY = 0.5
_TEMP_DIR = None
_IMAGE_CACHE = {} # {(width, height, percentage): file_path}
@@ -84,117 +92,325 @@ def _ensure_window_image(width, height, percentage):
return path
def _ensure_solid_image(width, height, rgb, name):
"""生成或复用纯色 PNG 文件,返回路径。"""
rgb = tuple(int(v) for v in rgb)
key = ("solid", width, height, rgb)
cached = _IMAGE_CACHE.get(key)
if cached and os.path.exists(cached):
return cached
arr = np.zeros((height, width, 3), dtype=np.uint8)
arr[:, :] = rgb
path = os.path.join(_get_temp_dir(), f"{name}_{width}x{height}.png")
Image.fromarray(arr, mode="RGB").save(path, format="PNG")
_IMAGE_CACHE[key] = path
return path
def _make_checkerboard_image_array(width, height, grid_size, center_white):
"""生成棋盘格图像,保证中心块可切换黑/白。"""
image = np.zeros((height, width, 3), dtype=np.uint8)
y_edges = np.linspace(0, height, grid_size + 1, dtype=int)
x_edges = np.linspace(0, width, grid_size + 1, dtype=int)
center_index = grid_size // 2
for row in range(grid_size):
for col in range(grid_size):
block_is_white = (row + col) % 2 == 0
if not center_white:
block_is_white = not block_is_white
value = 255 if block_is_white else 0
image[
y_edges[row]:y_edges[row + 1],
x_edges[col]:x_edges[col + 1],
] = value
center_value = 255 if center_white else 0
image[
y_edges[center_index]:y_edges[center_index + 1],
x_edges[center_index]:x_edges[center_index + 1],
] = center_value
return image
def _ensure_checkerboard_image(width, height, grid_size, center_white):
"""生成或复用棋盘格 PNG 文件,返回路径。"""
key = ("checkerboard", width, height, grid_size, center_white)
cached = _IMAGE_CACHE.get(key)
if cached and os.path.exists(cached):
return cached
arr = _make_checkerboard_image_array(width, height, grid_size, center_white)
center_name = "white_center" if center_white else "black_center"
path = os.path.join(
_get_temp_dir(),
f"checkerboard_{grid_size}x{grid_size}_{center_name}_{width}x{height}.png",
)
Image.fromarray(arr, mode="RGB").save(path, format="PNG")
_IMAGE_CACHE[key] = path
return path
def _build_ld_result_row(test_item, pattern_label, value, x="--", y="--"):
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
if isinstance(value, (int, float, np.floating)):
display_value = f"{float(value):.4f}"
else:
display_value = str(value)
return {
"test_item": test_item,
"pattern": pattern_label,
"value": display_value,
"x": x if isinstance(x, str) else f"{x:.4f}",
"y": y if isinstance(y, str) else f"{y:.4f}",
"time": timestamp,
}
def _measure_ld_row(self: "PQAutomationApp", test_item, pattern_label):
"""读取一次 CA410 数据并包装为表格行。"""
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
if lv is None:
raise RuntimeError(f"{pattern_label} 采集失败")
return _build_ld_result_row(test_item, pattern_label, lv, x, y), lv
def _send_ld_image(self: "PQAutomationApp", image_path):
self.signal_service.send_image(image_path)
def _apply_ld_ucd_params(self: "PQAutomationApp") -> bool:
"""发送 Local Dimming 图案前,按当前测试类型写入 UCD 参数。"""
test_type = getattr(self.config, "current_test_type", "screen_module")
cfg = self.config.current_test_types.get(test_type, {})
try:
self.signal_service.apply_config(self.config)
if test_type == "screen_module":
ok = self.signal_service.update_signal_format(
color_space=(
self.screen_module_color_space_var.get()
if hasattr(self, "screen_module_color_space_var")
else cfg.get("colorimetry", "sRGB")
),
data_range=(
self.screen_module_data_range_var.get()
if hasattr(self, "screen_module_data_range_var")
else cfg.get("data_range", "Full")
),
bit_depth=(
self.screen_module_bit_depth_var.get()
if hasattr(self, "screen_module_bit_depth_var")
else f"{int(cfg.get('bpc', 8))}bit"
),
output_format=(
self.screen_module_output_format_var.get()
if hasattr(self, "screen_module_output_format_var")
else cfg.get("color_format", "RGB")
),
)
elif test_type == "sdr_movie":
ok = self.signal_service.update_signal_format(
color_space=(
self.sdr_color_space_var.get()
if hasattr(self, "sdr_color_space_var")
else cfg.get("colorimetry", "sRGB")
),
data_range=(
self.sdr_data_range_var.get()
if hasattr(self, "sdr_data_range_var")
else cfg.get("data_range", "Full")
),
bit_depth=(
self.sdr_bit_depth_var.get()
if hasattr(self, "sdr_bit_depth_var")
else f"{int(cfg.get('bpc', 8))}bit"
),
output_format=(
self.sdr_output_format_var.get()
if hasattr(self, "sdr_output_format_var")
else cfg.get("color_format", "RGB")
),
)
elif test_type == "hdr_movie":
ok = self.signal_service.update_signal_format(
color_space=(
self.hdr_color_space_var.get()
if hasattr(self, "hdr_color_space_var")
else cfg.get("colorimetry", "sRGB")
),
data_range=(
self.hdr_data_range_var.get()
if hasattr(self, "hdr_data_range_var")
else cfg.get("data_range", "Full")
),
bit_depth=(
self.hdr_bit_depth_var.get()
if hasattr(self, "hdr_bit_depth_var")
else f"{int(cfg.get('bpc', 8))}bit"
),
output_format=(
self.hdr_output_format_var.get()
if hasattr(self, "hdr_output_format_var")
else cfg.get("color_format", "RGB")
),
max_cll=(
self.hdr_maxcll_var.get()
if hasattr(self, "hdr_maxcll_var")
else None
),
max_fall=(
self.hdr_maxfall_var.get()
if hasattr(self, "hdr_maxfall_var")
else None
),
)
elif test_type == "local_dimming":
ok = self.signal_service.update_signal_format(
color_space=(
self.local_dimming_color_space_var.get()
if hasattr(self, "local_dimming_color_space_var")
else cfg.get("colorimetry", "sRGB")
),
data_range=(
self.local_dimming_data_range_var.get()
if hasattr(self, "local_dimming_data_range_var")
else cfg.get("data_range", "Full")
),
bit_depth=(
self.local_dimming_bit_depth_var.get()
if hasattr(self, "local_dimming_bit_depth_var")
else f"{int(cfg.get('bpc', 8))}bit"
),
output_format=(
self.local_dimming_output_format_var.get()
if hasattr(self, "local_dimming_output_format_var")
else cfg.get("color_format", "RGB")
),
)
else:
self._dispatch_ui(
self.log_gui.log,
f"Local Dimming 不支持的测试类型: {test_type}",
"error",
)
return False
if not ok:
self._dispatch_ui(self.log_gui.log, "Local Dimming UCD 参数设置失败", "error")
return False
return True
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"Local Dimming UCD 参数设置异常: {e}", "error")
return False
def _run_ld_measurement_step(self: "PQAutomationApp", width, height, wait_time, step, log):
label = step["label"]
test_item = step["test_item"]
kind = step["kind"]
if kind == "window":
percentage = step["percentage"]
image_path = _ensure_window_image(width, height, percentage)
_send_ld_image(self, image_path)
settle_time = wait_time
elif kind == "black":
image_path = _ensure_solid_image(width, height, (0, 0, 0), "black")
_send_ld_image(self, image_path)
settle_time = wait_time
elif kind == "checkerboard":
image_path = _ensure_checkerboard_image(
width,
height,
DEFAULT_CHESSBOARD_GRID,
step["center_white"],
)
_send_ld_image(self, image_path)
settle_time = wait_time
elif kind == "instant_peak":
black_image = _ensure_solid_image(width, height, (0, 0, 0), "black")
peak_image = _ensure_window_image(
width,
height,
step["percentage"],
)
_send_ld_image(self, black_image)
log(f" 黑场预置 {wait_time:.1f}", level="info")
time.sleep(wait_time)
_send_ld_image(self, peak_image)
settle_time = min(wait_time, INSTANT_PEAK_CAPTURE_DELAY)
else:
raise ValueError(f"未知 Local Dimming 测试步骤: {kind}")
log(f" 等待 {settle_time:.1f} 秒后采集...", level="info")
time.sleep(settle_time)
return _measure_ld_row(self, test_item, label)
def _set_current_ld_pattern(self: "PQAutomationApp", test_item, pattern_label, percentage=None):
self.current_ld_test_item = test_item
self.current_ld_pattern_label = pattern_label
self.current_ld_percentage = percentage
# --------------------------------------------------------------------------
# GUI 入口(绑定为 PQAutomationApp 方法)
# --------------------------------------------------------------------------
def start_local_dimming_test(self):
"""开始 Local Dimming 测试"""
if not self.ca or not self.ucd.status:
messagebox.showerror("错误", "请先连接 CA410 和 UCD323")
return
self.ld_start_btn.config(state=tk.DISABLED)
self.ld_stop_btn.config(state=tk.NORMAL)
self.ld_save_btn.config(state=tk.DISABLED)
for item in self.ld_tree.get_children():
self.ld_tree.delete(item)
wait_time = float(self.ld_wait_time_var.get())
stop_event = threading.Event()
self.ld_stop_event = stop_event
def worker():
log = self.log_gui.log
log("=" * 60, level="separator")
log("开始 Local Dimming 测试", level="info")
log("=" * 60, level="separator")
width, height = get_current_resolution(self.ucd)
total = len(DEFAULT_WINDOW_PERCENTAGES)
log(f" 分辨率: {width}x{height}", level="info")
log(f" 测试窗口: {DEFAULT_WINDOW_PERCENTAGES}", level="info")
log(f" 等待时间: {wait_time}", level="info")
results = []
for i, percentage in enumerate(DEFAULT_WINDOW_PERCENTAGES, 1):
if stop_event.is_set():
log("测试已停止", level="error")
break
log(f"[{i}/{total}] 测试 {percentage}% 窗口...", level="info")
try:
image_path = _ensure_window_image(width, height, percentage)
except Exception as e:
log(f" 图像生成失败: {e}", level="error")
continue
if not send_image_pattern(self.ucd, image_path):
log(f" {percentage}% 窗口发送失败,跳过", level="error")
continue
log(f"等待 {wait_time} 秒...", level="info")
time.sleep(wait_time)
try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
except Exception as e:
log(f" 采集亮度异常: {e}", level="error")
continue
if lv is None:
log(f" {percentage}% 窗口采集失败", level="error")
continue
log(f"采集亮度: {lv:.2f} cd/m²", level="info")
results.append((percentage, x, y, lv, _X, _Y, _Z))
log("=" * 60, level="separator")
log(f"Local Dimming 测试完成 ({len(results)}/{total})", level="success")
log("=" * 60, level="separator")
self.ld_test_results = results
self._dispatch_ui(self.update_ld_results, results)
self._dispatch_ui(self.ld_start_btn.config, state=tk.NORMAL)
self._dispatch_ui(self.ld_stop_btn.config, state=tk.DISABLED)
self._dispatch_ui(self.ld_save_btn.config, state=tk.NORMAL)
threading.Thread(target=worker, daemon=True).start()
def start_local_dimming_test(self: "PQAutomationApp"):
"""Local Dimming 不再提供自动测试,保留接口仅提示用户使用手动模式"""
messagebox.showinfo("提示", "Local Dimming 请使用手动发送图案后再采集亮度")
def update_ld_results(self, results):
def update_ld_results(self: "PQAutomationApp", results):
"""把批量测试结果填入 Treeview。"""
for percentage, x, y, lv, _X, _Y, _Z in results:
for row in results:
self.ld_tree.insert(
"", tk.END,
values=(f"{percentage}%", f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}"),
values=(
row["test_item"],
row["pattern"],
row["value"],
row["x"],
row["y"],
row["time"],
),
)
def stop_local_dimming_test(self):
"""请求停止当前 Local Dimming 测试"""
ev = getattr(self, "ld_stop_event", None)
if ev:
ev.set()
def stop_local_dimming_test(self: "PQAutomationApp"):
"""兼容旧接口,无操作"""
return
def send_ld_window(self, percentage):
def send_ld_window(self: "PQAutomationApp", percentage):
"""发送指定百分比的白色窗口(手动模式)。"""
if not self.ucd.status:
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info")
self.current_ld_percentage = percentage
_set_current_ld_pattern(self, "峰值亮度", f"{percentage}%窗口", percentage)
def send():
width, height = get_current_resolution(self.ucd)
if not _apply_ld_ucd_params(self):
return
width, height = self.signal_service.current_resolution()
try:
image_path = _ensure_window_image(width, height, percentage)
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
return
ok = send_image_pattern(self.ucd, image_path)
try:
self.signal_service.send_image(image_path)
ok = True
except Exception:
ok = False
msg = (
f"{percentage}% 窗口已发送" if ok
else f"{percentage}% 窗口发送失败"
@@ -204,12 +420,128 @@ def send_ld_window(self, percentage):
threading.Thread(target=send, daemon=True).start()
def measure_ld_luminance(self):
def send_ld_checkerboard(self: "PQAutomationApp", center_white):
"""发送棋盘格图案(手动模式)。"""
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
pattern_label = "棋盘格(中心白)" if center_white else "棋盘格(中心黑)"
self.log_gui.log(f"🔲 发送 {pattern_label}...", level="info")
_set_current_ld_pattern(self, "棋盘格对比度", pattern_label)
def send():
if not _apply_ld_ucd_params(self):
return
width, height = self.signal_service.current_resolution()
try:
image_path = _ensure_checkerboard_image(
width,
height,
DEFAULT_CHESSBOARD_GRID,
center_white,
)
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
return
try:
self.signal_service.send_image(image_path)
ok = True
except Exception:
ok = False
msg = f"{pattern_label} 已发送" if ok else f"{pattern_label} 发送失败"
self._dispatch_ui(self.log_gui.log, msg)
threading.Thread(target=send, daemon=True).start()
def send_ld_black_pattern(self: "PQAutomationApp"):
"""发送全黑图案(手动模式)。"""
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
self.log_gui.log("⚫ 发送全黑画面...", level="info")
_set_current_ld_pattern(self, "黑电平", "全黑画面")
def send():
if not _apply_ld_ucd_params(self):
return
width, height = self.signal_service.current_resolution()
try:
image_path = _ensure_solid_image(width, height, (0, 0, 0), "black")
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
return
try:
self.signal_service.send_image(image_path)
ok = True
except Exception:
ok = False
msg = "全黑画面已发送" if ok else "全黑画面发送失败"
self._dispatch_ui(self.log_gui.log, msg)
threading.Thread(target=send, daemon=True).start()
def send_ld_instant_peak(self: "PQAutomationApp"):
"""发送瞬时峰值亮度图案:先黑场,再切到 10% 窗口并保持。"""
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
pattern_label = f"黑场后切 {INSTANT_PEAK_WINDOW_PERCENTAGE}%窗口"
self.log_gui.log(f"⚡ 发送瞬时峰值图案: {pattern_label}", level="info")
_set_current_ld_pattern(
self,
"瞬时峰值亮度",
pattern_label,
INSTANT_PEAK_WINDOW_PERCENTAGE,
)
def send():
if not _apply_ld_ucd_params(self):
return
width, height = self.signal_service.current_resolution()
try:
black_image = _ensure_solid_image(width, height, (0, 0, 0), "black")
peak_image = _ensure_window_image(
width,
height,
INSTANT_PEAK_WINDOW_PERCENTAGE,
)
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
return
try:
self.signal_service.send_image(black_image)
time.sleep(INSTANT_PEAK_CAPTURE_DELAY)
self.signal_service.send_image(peak_image)
ok = True
except Exception:
ok = False
msg = (
f"瞬时峰值图案已发送,当前保持 {INSTANT_PEAK_WINDOW_PERCENTAGE}%窗口"
if ok else
"瞬时峰值图案发送失败"
)
self._dispatch_ui(self.log_gui.log, msg)
threading.Thread(target=send, daemon=True).start()
def measure_ld_luminance(self: "PQAutomationApp"):
"""测量当前显示的亮度并追加一行到 Treeview。"""
if not self.ca:
messagebox.showwarning("警告", "请先连接 CA410 色度计")
return
if self.current_ld_percentage is None:
if getattr(self, "current_ld_pattern_label", None) is None:
messagebox.showinfo("提示", "请先发送一个窗口图案")
return
@@ -217,7 +549,7 @@ def measure_ld_luminance(self):
def measure():
try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"采集异常: {str(e)}")
return
@@ -232,8 +564,12 @@ def measure_ld_luminance(self):
self._dispatch_ui(
self.ld_tree.insert, "", tk.END,
values=(
f"{self.current_ld_percentage}%",
f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}", timestamp,
getattr(self, "current_ld_test_item", "手动采集"),
self.current_ld_pattern_label,
f"{lv:.4f}",
f"{x:.4f}",
f"{y:.4f}",
timestamp,
),
)
self._dispatch_ui(self.log_gui.log, f"采集完成: {lv:.2f} cd/m²")
@@ -241,16 +577,18 @@ def measure_ld_luminance(self):
threading.Thread(target=measure, daemon=True).start()
def clear_ld_records(self):
def clear_ld_records(self: "PQAutomationApp"):
"""清空 Treeview 中的测试记录。"""
for item in self.ld_tree.get_children():
self.ld_tree.delete(item)
self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --")
self.current_ld_percentage = None
self.current_ld_test_item = None
self.current_ld_pattern_label = None
self.log_gui.log("测试记录已清空", level="info")
def save_local_dimming_results(self):
def save_local_dimming_results(self: "PQAutomationApp"):
"""把 Treeview 中的全部记录导出为 CSV。"""
if len(self.ld_tree.get_children()) == 0:
messagebox.showinfo("提示", "没有可保存的数据")
@@ -271,7 +609,7 @@ def save_local_dimming_results(self):
try:
with open(save_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["窗口百分比", "亮度 (cd/m²)", "x", "y", "时间"])
writer.writerow(["测试项目", "图案", "亮度/结果", "x", "y", "时间"])
for item in self.ld_tree.get_children():
writer.writerow(self.ld_tree.item(item, "values"))
self.log_gui.log(f"测试结果已保存: {save_path}", level="success")
@@ -279,3 +617,19 @@ def save_local_dimming_results(self):
except Exception as e:
self.log_gui.log(f"保存失败: {str(e)}", level="error")
messagebox.showerror("错误", f"保存失败: {str(e)}")
class LocalDimmingMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
start_local_dimming_test = start_local_dimming_test
update_ld_results = update_ld_results
stop_local_dimming_test = stop_local_dimming_test
send_ld_window = send_ld_window
send_ld_checkerboard = send_ld_checkerboard
send_ld_black_pattern = send_ld_black_pattern
send_ld_instant_peak = send_ld_instant_peak
measure_ld_luminance = measure_ld_luminance
clear_ld_records = clear_ld_records
save_local_dimming_results = save_local_dimming_results

388
app/ucd_domain.py Normal file
View File

@@ -0,0 +1,388 @@
"""UCD 控制 Domain 层。
纯数据 + 纯函数:枚举、值对象、状态机、错误体系、事件总线、
业务字符串解析与映射。本模块**不**依赖 UniTAP / 任何硬件;
可用纯单测覆盖。
文件分区:
§1 枚举与值对象
§2 状态机
§3 错误体系
§4 事件总线
§5 业务字符串解析 / 映射
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Callable
log = logging.getLogger(__name__)
# ─── §1 枚举与值对象 ──────────────────────────────────────────────
class Interface(str, Enum):
"""UCD 物理输出接口。"""
HDMI = "HDMI"
DP = "DP"
USBC = "Type-C"
class ColorFormat(str, Enum):
"""像素颜色格式(与 UI 显示字符串解耦的内部表示)。"""
RGB = "rgb"
YCBCR_444 = "ycbcr444"
YCBCR_422 = "ycbcr422"
YCBCR_420 = "ycbcr420"
Y_ONLY = "yonly"
IDO_DEFINED = "ido_defined"
RAW = "raw"
DSC = "dsc"
class Colorimetry(str, Enum):
"""色度空间。"""
SRGB = "sRGB"
BT709 = "BT.709"
BT601 = "BT.601"
BT2020 = "BT.2020"
DCI_P3 = "DCI-P3"
ADOBE_RGB = "AdobeRGB"
class DynamicRange(str, Enum):
FULL = "Full"
LIMITED = "Limited"
class TimingStandard(str, Enum):
DMT = "dmt"
CTA = "cta"
CVT = "cvt"
OVT = "ovt"
class PatternKind(str, Enum):
"""高层图案种类。
与 UCD 内部 VideoPattern 枚举区分:仅暴露业务上真正用到的几种。
"""
DISABLED = "disabled"
SOLID = "solidcolor"
SOLID_WHITE = "solidwhite"
SOLID_RED = "solidred"
SOLID_GREEN = "solidgreen"
SOLID_BLUE = "solidblue"
COLOR_BARS = "colorbars"
CHESSBOARD = "chessboard"
WHITE_VSTRIPS = "whitevstrips"
GRADIENT_RGB_STRIPES = "gradientrgbstripes"
COLOR_RAMP = "colorramp"
COLOR_SQUARES = "coloursquares"
MOTION = "motionpattern"
SQUARE_WINDOW = "squarewindow"
IMAGE = "image" # 来自文件路径
@dataclass(frozen=True)
class SignalFormat:
"""信号格式color_info 部分)。"""
color_format: ColorFormat
colorimetry: Colorimetry
bpc: int # 8 / 10 / 12
dynamic_range: DynamicRange = DynamicRange.FULL
@dataclass(frozen=True)
class TimingSpec:
"""显示 Timing 描述。"""
standard: TimingStandard
width: int
height: int
refresh_hz: float
def __str__(self) -> str: # 便于日志
return f"{self.standard.value.upper()} {self.width}x{self.height}@{self.refresh_hz:g}Hz"
@dataclass(frozen=True)
class PatternSpec:
"""图案描述。
联合字段含义:
SOLID → solid_rgb=(r,g,b)
IMAGE → image_path
其它预定义图案 → extras 视具体类型
"""
kind: PatternKind
solid_rgb: tuple[int, int, int] | None = None
image_path: str | None = None
extras: tuple = field(default_factory=tuple)
# ─── §2 状态机 ───────────────────────────────────────────────────
class UcdState(Enum):
CLOSED = 0
OPENED = 1 # 设备已打开,未配置信号
CONFIGURED = 2 # SignalFormat + Timing 已写入,未 apply
APPLIED = 3 # 已 apply硬件正在输出
_ALLOWED: dict[UcdState, set[UcdState]] = {
UcdState.CLOSED: {UcdState.OPENED, UcdState.CLOSED},
UcdState.OPENED: {UcdState.CONFIGURED, UcdState.CLOSED, UcdState.OPENED},
UcdState.CONFIGURED: {UcdState.CONFIGURED, UcdState.APPLIED, UcdState.OPENED, UcdState.CLOSED},
UcdState.APPLIED: {UcdState.CONFIGURED, UcdState.APPLIED, UcdState.OPENED, UcdState.CLOSED},
}
def assert_transition(curr: UcdState, nxt: UcdState) -> None:
"""校验状态转移。非法转移抛 :class:`UcdStateError`。"""
if nxt not in _ALLOWED[curr]:
raise UcdStateError(f"非法状态转移: {curr.name} -> {nxt.name}")
# ─── §3 错误体系 ─────────────────────────────────────────────────
class UcdError(Exception):
"""UCD 控制相关错误的基类。"""
class UcdNotConnected(UcdError):
"""设备未打开/未连接。"""
class UcdStateError(UcdError):
"""状态机非法转移或前置条件不满足。"""
class UcdConfigError(UcdError):
"""业务配置参数不合法(如不支持的 color_format、解析失败"""
class UcdApplyFailed(UcdError):
"""SDK ``apply`` 返回失败或超时。"""
class UcdSdkError(UcdError):
"""UniTAP SDK 内部异常的包装;原始异常保存在 ``__cause__``。"""
# ─── §4 事件总线 ─────────────────────────────────────────────────
@dataclass(frozen=True)
class UcdEvent:
"""事件基类。"""
@dataclass(frozen=True)
class ConnectionChanged(UcdEvent):
connected: bool
serial: str | None = None
@dataclass(frozen=True)
class SignalApplied(UcdEvent):
signal: SignalFormat
timing: TimingSpec
format_changed: bool # 与上一次 apply 相比 (signal,timing) 是否改变
@dataclass(frozen=True)
class PatternApplied(UcdEvent):
pattern: PatternSpec
class EventBus:
"""极简同步事件总线(按事件类型分发)。
单线程使用;如需跨线程派发,由订阅者自行 marshal 到 UI 线程。
"""
def __init__(self) -> None:
self._subs: dict[type, list[Callable[[Any], None]]] = {}
def subscribe(self, evt_type: type, cb: Callable[[Any], None]) -> None:
self._subs.setdefault(evt_type, []).append(cb)
def publish(self, evt: UcdEvent) -> None:
for cb in self._subs.get(type(evt), []):
try:
cb(evt)
except Exception: # noqa: BLE001 - 订阅者错误不应影响发布者
log.exception("UCD 事件处理器抛出异常: %r", evt)
# ─── §5 业务字符串解析 / 映射 ────────────────────────────────────
_OUTPUT_FORMAT_TO_COLOR_FORMAT: dict[str, ColorFormat] = {
"RGB": ColorFormat.RGB,
"YCbCr 4:4:4": ColorFormat.YCBCR_444,
"YCbCr 4:2:2": ColorFormat.YCBCR_422,
"YCbCr 4:2:0": ColorFormat.YCBCR_420,
"Y Only": ColorFormat.Y_ONLY,
"IDO Defined": ColorFormat.IDO_DEFINED,
"RAW": ColorFormat.RAW,
"DSC": ColorFormat.DSC,
}
_COLOR_SPACE_TO_COLORIMETRY: dict[str, Colorimetry] = {
"sRGB": Colorimetry.SRGB,
"BT.709": Colorimetry.BT709,
"BT.601": Colorimetry.BT601,
"BT.2020": Colorimetry.BT2020,
"DCI-P3": Colorimetry.DCI_P3,
"AdobeRGB": Colorimetry.ADOBE_RGB,
}
_BIT_DEPTH_STR_TO_BPC: dict[str, int] = {
"8bit": 8,
"10bit": 10,
"12bit": 12,
}
_DATA_RANGE_TO_DYNAMIC_RANGE: dict[str, DynamicRange] = {
"Full": DynamicRange.FULL,
"Limited": DynamicRange.LIMITED,
}
def output_format_to_color_format(s: str) -> ColorFormat:
"""显示用 ``"YCbCr 4:4:4"`` → :class:`ColorFormat`。未知值视为 RGB。"""
return _OUTPUT_FORMAT_TO_COLOR_FORMAT.get(s, ColorFormat.RGB)
def color_space_to_colorimetry(s: str) -> Colorimetry:
"""显示用 ``"BT.709"`` → :class:`Colorimetry`。未知抛 :class:`UcdConfigError`。"""
if s in _COLOR_SPACE_TO_COLORIMETRY:
return _COLOR_SPACE_TO_COLORIMETRY[s]
raise UcdConfigError(f"未知色彩空间: {s!r}")
def bit_depth_str_to_bpc(s: str) -> int:
"""``"10bit"`` → 10。未知抛 :class:`UcdConfigError`。"""
if s in _BIT_DEPTH_STR_TO_BPC:
return _BIT_DEPTH_STR_TO_BPC[s]
raise UcdConfigError(f"未知位深: {s!r}")
def data_range_to_dynamic_range(s: str) -> DynamicRange:
if s in _DATA_RANGE_TO_DYNAMIC_RANGE:
return _DATA_RANGE_TO_DYNAMIC_RANGE[s]
raise UcdConfigError(f"未知数据范围: {s!r}")
def is_ycbcr(color_format: ColorFormat | str | None) -> bool:
"""判断输出是否为 YCbCr 系列。接受 :class:`ColorFormat` 或 UI 字符串。"""
if color_format is None:
return False
if isinstance(color_format, ColorFormat):
return color_format in {
ColorFormat.YCBCR_444,
ColorFormat.YCBCR_422,
ColorFormat.YCBCR_420,
}
return "YCbCr" in str(color_format)
def parse_timing_str(timing_str: str) -> TimingSpec:
"""解析 ``"DMT 3840x2160@60Hz"`` 风格的字符串为 :class:`TimingSpec`。
宽容处理空格 / 大小写 / ``Hz`` 后缀大小写。
解析失败抛 :class:`UcdConfigError`。
"""
if not isinstance(timing_str, str):
raise UcdConfigError(f"timing_str 必须是字符串: {timing_str!r}")
s = " ".join(timing_str.strip().split())
s = s.replace(" x", "x").replace("x ", "x")
parts = s.split(" ", 1)
if len(parts) < 2:
raise UcdConfigError(f"无法解析 timing: {timing_str!r}")
type_str, rest = parts[0].strip().upper(), parts[1].strip()
if "@" not in rest:
raise UcdConfigError(f"无法解析 timing (缺少 '@'): {timing_str!r}")
left, right = (p.strip() for p in rest.split("@", 1))
if "x" not in left:
raise UcdConfigError(f"无法解析分辨率 (缺少 'x'): {timing_str!r}")
wh = left.split("x")
if len(wh) != 2:
raise UcdConfigError(f"无法解析分辨率: {timing_str!r}")
try:
width, height = int(wh[0]), int(wh[1])
except ValueError as exc:
raise UcdConfigError(f"分辨率数字解析失败: {timing_str!r}") from exc
hz_str = right.replace("Hz", "").replace("HZ", "").strip()
try:
refresh_hz = float(hz_str)
except ValueError as exc:
raise UcdConfigError(f"刷新率解析失败: {timing_str!r}") from exc
try:
standard = TimingStandard(type_str.lower())
except ValueError as exc:
raise UcdConfigError(f"未知的分辨率类型: {type_str!r}") from exc
return TimingSpec(
standard=standard,
width=width,
height=height,
refresh_hz=refresh_hz,
)
__all__ = [
# §1
"Interface",
"ColorFormat",
"Colorimetry",
"DynamicRange",
"TimingStandard",
"PatternKind",
"SignalFormat",
"TimingSpec",
"PatternSpec",
# §2
"UcdState",
"assert_transition",
# §3
"UcdError",
"UcdNotConnected",
"UcdStateError",
"UcdConfigError",
"UcdApplyFailed",
"UcdSdkError",
# §4
"UcdEvent",
"ConnectionChanged",
"SignalApplied",
"PatternApplied",
"EventBus",
# §5
"output_format_to_color_format",
"color_space_to_colorimetry",
"bit_depth_str_to_bpc",
"data_range_to_dynamic_range",
"is_ycbcr",
"parse_timing_str",
]

View File

@@ -1,4 +1,4 @@
"""图表框架相关逻辑Step 3 重构)。
"""图表框架相关逻辑Step 3 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。
@@ -9,8 +9,66 @@ import ttkbootstrap as ttk
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from app.views.pq_debug_panel import PQDebugPanel
from app.views.modern_styles import get_theme_palette
def init_gamut_chart(self):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def _result_bg_color() -> str:
"""根据当前主题返回结果图背景色。"""
try:
return get_theme_palette()["bg"]
except Exception:
return "#FFFFFF"
def apply_result_chart_theme(self: "PQAutomationApp"):
"""统一刷新结果图画布背景,使其跟随浅/深色主题。"""
bg = _result_bg_color()
chart_pairs = [
("gamut_fig", "gamut_canvas"),
("gamma_fig", "gamma_canvas"),
("eotf_fig", "eotf_canvas"),
("cct_fig", "cct_canvas"),
("contrast_fig", "contrast_canvas"),
("accuracy_fig", "accuracy_canvas"),
]
for fig_attr, canvas_attr in chart_pairs:
fig = getattr(self, fig_attr, None)
canvas = getattr(self, canvas_attr, None)
if fig is not None:
fig.patch.set_facecolor(bg)
if canvas is not None:
try:
widget = canvas.get_tk_widget()
widget.configure(bg=bg, highlightthickness=0)
except Exception:
pass
try:
canvas.draw_idle()
except Exception:
pass
def _apply_axes_theme(ax, palette, *, title=None, xlabel=None, ylabel=None):
ax.set_facecolor(palette["card_bg"])
for spine in ax.spines.values():
spine.set_color(palette["border"])
if title is not None:
ax.set_title(title, color=palette["fg"])
if xlabel is not None:
ax.set_xlabel(xlabel, color=palette["fg"])
if ylabel is not None:
ax.set_ylabel(ylabel, color=palette["fg"])
ax.tick_params(axis="both", colors=palette["fg"])
def init_gamut_chart(self: "PQAutomationApp"):
"""初始化色域图表 - 手动设置subplot位置完全避免重叠"""
container = ttk.Frame(self.gamut_chart_frame)
container.pack(expand=True, fill=tk.BOTH)
@@ -65,7 +123,7 @@ def init_gamut_chart(self):
self.gamut_canvas.draw()
def sync_gamut_toolbar(self):
def sync_gamut_toolbar(self: "PQAutomationApp"):
"""将工具栏参考标准按钮同步为当前测试类型的 ref var 值。"""
if not hasattr(self, "_gamut_ref_toolbar_var"):
return
@@ -80,7 +138,7 @@ def sync_gamut_toolbar(self):
self._gamut_ref_toolbar_var.set(getattr(self, attr).get())
def _on_gamut_toolbar_changed(self, std):
def _on_gamut_toolbar_changed(self: "PQAutomationApp", std):
"""用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。"""
test_type = self.config.current_test_type
var_map = {
@@ -105,12 +163,14 @@ def _on_gamut_toolbar_changed(self, std):
self.recalculate_gamut()
def init_gamma_chart(self):
def init_gamma_chart(self: "PQAutomationApp"):
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)"""
container = ttk.Frame(self.gamma_chart_frame)
container.pack(expand=True, fill=tk.BOTH)
palette = get_theme_palette()
self.gamma_fig = plt.Figure(figsize=(12, 6), dpi=100, constrained_layout=False)
self.gamma_fig.patch.set_facecolor(palette["bg"])
self.gamma_canvas = FigureCanvasTkAgg(self.gamma_fig, master=container)
canvas_widget = self.gamma_canvas.get_tk_widget()
@@ -118,6 +178,7 @@ def init_gamma_chart(self):
# 左侧Gamma 曲线
self.gamma_ax = self.gamma_fig.add_axes([0.08, 0.12, 0.50, 0.78])
_apply_axes_theme(self.gamma_ax, palette, xlabel="灰阶 (%)", ylabel="L_bar")
self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.gamma_ax.set_ylabel("L_bar", fontsize=10)
self.gamma_ax.set_xlim(0, 105)
@@ -137,10 +198,13 @@ def init_gamma_chart(self):
ha="center",
va="center",
fontsize=10,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1", facecolor="white", edgecolor="gray", alpha=0.8
boxstyle="round,pad=1",
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -178,17 +242,17 @@ def init_gamma_chart(self):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.gamma_table_ax.text(
@@ -201,25 +265,27 @@ def init_gamma_chart(self):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.gamma_canvas.draw()
def init_eotf_chart(self):
def init_eotf_chart(self: "PQAutomationApp"):
"""初始化 EOTF 曲线图表HDR 专用)- 左侧曲线 + 右侧表格4列"""
container = ttk.Frame(self.eotf_chart_frame)
container.pack(expand=True, fill=tk.BOTH)
palette = get_theme_palette()
self.eotf_fig = plt.Figure(figsize=(12, 6), dpi=100, constrained_layout=False)
self.eotf_fig.patch.set_facecolor(palette["bg"])
self.eotf_canvas = FigureCanvasTkAgg(self.eotf_fig, master=container)
canvas_widget = self.eotf_canvas.get_tk_widget()
@@ -227,6 +293,7 @@ def init_eotf_chart(self):
# 左侧EOTF 曲线
self.eotf_ax = self.eotf_fig.add_axes([0.08, 0.12, 0.50, 0.78])
_apply_axes_theme(self.eotf_ax, palette, xlabel="灰阶 (%)", ylabel="L_bar (归一化亮度)")
self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.eotf_ax.set_ylabel("L_bar (归一化亮度)", fontsize=10)
self.eotf_ax.set_xlim(0, 105)
@@ -242,10 +309,13 @@ def init_eotf_chart(self):
ha="center",
va="center",
fontsize=11,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1", facecolor="white", edgecolor="gray", alpha=0.8
boxstyle="round,pad=1",
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -283,17 +353,17 @@ def init_eotf_chart(self):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.eotf_table_ax.text(
@@ -306,25 +376,27 @@ def init_eotf_chart(self):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.eotf_canvas.draw()
def init_cct_chart(self):
def init_cct_chart(self: "PQAutomationApp"):
"""初始化色度坐标图表 - 正向横坐标,标题居中最上方"""
container = ttk.Frame(self.cct_chart_frame)
container.pack(expand=True)
palette = get_theme_palette()
self.cct_fig = plt.Figure(figsize=(8, 6), dpi=100, tight_layout=False)
self.cct_fig.patch.set_facecolor(palette["bg"])
self.cct_canvas = FigureCanvasTkAgg(self.cct_fig, master=container)
canvas_widget = self.cct_canvas.get_tk_widget()
@@ -333,7 +405,9 @@ def init_cct_chart(self):
canvas_widget.pack_propagate(False)
self.cct_ax1 = self.cct_fig.add_subplot(211)
self.cct_ax1.set_facecolor(palette["card_bg"])
self.cct_ax2 = self.cct_fig.add_subplot(212)
self.cct_ax2.set_facecolor(palette["card_bg"])
# 上图x coordinates
self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9)
@@ -352,7 +426,7 @@ def init_cct_chart(self):
self.cct_ax2.tick_params(labelsize=8)
# 调整标题位置y=0.985(比色域/Gamma略高
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985)
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985, color=palette["fg"])
self.cct_fig.subplots_adjust(
left=0.12,
@@ -364,16 +438,18 @@ def init_cct_chart(self):
self.cct_canvas.draw()
def init_contrast_chart(self):
def init_contrast_chart(self: "PQAutomationApp"):
"""初始化对比度图表 - 固定大小,居中显示"""
container = ttk.Frame(self.contrast_chart_frame)
container.pack(expand=True)
palette = get_theme_palette()
self.contrast_fig = plt.Figure(
figsize=(6, 6),
dpi=100,
tight_layout=False,
)
self.contrast_fig.patch.set_facecolor(palette["bg"])
self.contrast_canvas = FigureCanvasTkAgg(self.contrast_fig, master=container)
canvas_widget = self.contrast_canvas.get_tk_widget()
@@ -383,12 +459,13 @@ def init_contrast_chart(self):
canvas_widget.pack_propagate(False)
self.contrast_ax = self.contrast_fig.add_subplot(111)
self.contrast_ax.set_facecolor(palette["card_bg"])
self.contrast_ax.set_xlim(0, 1)
self.contrast_ax.set_ylim(0, 1)
self.contrast_ax.axis("off")
# 调整标题位置y=0.985
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985)
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985, color=palette["fg"])
self.contrast_fig.subplots_adjust(
left=0.02,
@@ -399,31 +476,48 @@ def init_contrast_chart(self):
self.contrast_canvas.draw()
def init_accuracy_chart(self):
def init_accuracy_chart(self: "PQAutomationApp"):
"""初始化色准图表 - 固定大小,居中显示"""
container = ttk.Frame(self.accuracy_chart_frame)
container.pack(expand=True)
container.pack(expand=True, fill=tk.BOTH)
palette = get_theme_palette()
container.grid_rowconfigure(0, weight=1)
container.grid_rowconfigure(1, weight=0, minsize=220)
container.grid_columnconfigure(0, weight=1)
# 上方图表优先显示;下方表格固定高度,避免挤占图表区域。
plot_container = ttk.Frame(container)
plot_container.grid(row=0, column=0, sticky="nsew")
table_container = ttk.LabelFrame(container, text="色准明细")
table_container.grid(row=1, column=0, sticky="ew", padx=4, pady=(2, 4))
self.accuracy_fig = plt.Figure(
figsize=(10, 6),
dpi=100,
tight_layout=False,
)
self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=container)
self.accuracy_fig.patch.set_facecolor(palette["bg"])
try:
self.accuracy_fig.set_layout_engine(None)
except Exception:
try:
self.accuracy_fig.set_tight_layout(False)
except Exception:
pass
self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=plot_container)
canvas_widget = self.accuracy_canvas.get_tk_widget()
canvas_widget.pack()
canvas_widget.config(width=1000, height=600)
canvas_widget.pack_propagate(False)
canvas_widget.pack(fill=tk.BOTH, expand=True)
self.accuracy_ax = self.accuracy_fig.add_subplot(111)
self.accuracy_ax.set_facecolor(palette["card_bg"])
self.accuracy_ax.set_xlim(0, 1)
self.accuracy_ax.set_ylim(0, 1)
self.accuracy_ax.axis("off")
# 调整标题位置
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985)
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985, color=palette["fg"])
self.accuracy_fig.subplots_adjust(
left=0.05,
@@ -433,9 +527,138 @@ def init_accuracy_chart(self):
)
self.accuracy_canvas.draw()
self._init_accuracy_result_table(table_container)
def clear_chart(self):
def _init_accuracy_result_table(self: "PQAutomationApp", parent):
"""创建色准结果表格(支持横向/纵向滚动)。"""
table_wrap = ttk.Frame(parent)
table_wrap.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
self.accuracy_result_table = ttk.Treeview(
table_wrap,
show="headings",
height=7,
)
x_scroll = ttk.Scrollbar(
table_wrap,
orient=tk.HORIZONTAL,
command=self.accuracy_result_table.xview,
)
y_scroll = ttk.Scrollbar(
table_wrap,
orient=tk.VERTICAL,
command=self.accuracy_result_table.yview,
)
self.accuracy_result_table.configure(
xscrollcommand=x_scroll.set,
yscrollcommand=y_scroll.set,
)
self.accuracy_result_table.grid(row=0, column=0, sticky="nsew")
y_scroll.grid(row=0, column=1, sticky="ns")
x_scroll.grid(row=1, column=0, sticky="ew")
table_wrap.grid_rowconfigure(0, weight=1)
table_wrap.grid_columnconfigure(0, weight=1)
self.clear_accuracy_result_table()
def clear_accuracy_result_table(self: "PQAutomationApp"):
"""清空色准表格并恢复占位内容。"""
if not hasattr(self, "accuracy_result_table"):
return
tree = self.accuracy_result_table
tree.delete(*tree.get_children())
columns = ("metric", "value")
tree.configure(columns=columns)
tree.heading("metric", text="项目")
tree.heading("value", text="")
tree.column("metric", width=150, anchor="w", stretch=False)
tree.column("value", width=300, anchor="w", stretch=True)
tree.insert("", tk.END, values=("状态", "等待色准测试数据..."))
def update_accuracy_result_table(self: "PQAutomationApp", accuracy_data, standards):
"""更新色准表格:按指标行 + 色块列展示,可横向滚动浏览。"""
if not hasattr(self, "accuracy_result_table"):
return
tree = self.accuracy_result_table
tree.delete(*tree.get_children())
color_patches = accuracy_data.get("color_patches", []) or []
measurements = accuracy_data.get("color_measurements", []) or []
delta_e_values = accuracy_data.get("delta_e_values", []) or []
delta_e_itp_values = accuracy_data.get("delta_e_itp_values", []) or []
if not color_patches:
self.clear_accuracy_result_table()
return
columns = ["metric"] + [f"c{i}" for i in range(len(color_patches))]
tree.configure(columns=columns)
tree.heading("metric", text="项目")
tree.column("metric", width=140, anchor="w", stretch=False)
for i, name in enumerate(color_patches):
col = f"c{i}"
tree.heading(col, text=name)
tree.column(col, width=96, anchor="center", stretch=False)
def fmt(v, digits=4):
if isinstance(v, (int, float)):
return f"{v:.{digits}f}"
return "N/A"
row_x = ["x: CIE31"]
row_y = ["y: CIE31"]
row_Y = ["Y"]
row_tx = ["Target x:CIE31"]
row_ty = ["Target y:CIE31"]
row_de2000 = ["ΔE 2000"]
include_itp = bool(delta_e_itp_values)
row_deitp = ["ΔE ITP"] if include_itp else None
for i, name in enumerate(color_patches):
m = measurements[i] if i < len(measurements) else None
sx, sy = standards.get(name, (None, None))
if m is not None and len(m) >= 3:
row_x.append(fmt(m[0], 4))
row_y.append(fmt(m[1], 4))
row_Y.append(fmt(m[2], 4))
else:
row_x.append("N/A")
row_y.append("N/A")
row_Y.append("N/A")
row_tx.append(fmt(sx, 4))
row_ty.append(fmt(sy, 4))
de = delta_e_values[i] if i < len(delta_e_values) else None
row_de2000.append(fmt(de, 4))
if include_itp and row_deitp is not None:
ditp = delta_e_itp_values[i] if i < len(delta_e_itp_values) else None
row_deitp.append(fmt(ditp, 4))
rows = [row_x, row_y, row_Y, row_tx, row_ty, row_de2000]
if include_itp and row_deitp is not None:
rows.append(row_deitp)
for row in rows:
tree.insert("", tk.END, values=row)
def clear_chart(self: "PQAutomationApp"):
"""清空所有图表"""
palette = get_theme_palette()
# ========== 1. 清空色域图表 ==========
if hasattr(self, "gamut_ax_xy") and hasattr(self, "gamut_ax_uv"):
@@ -460,12 +683,17 @@ def clear_chart(self):
if hasattr(self, "gamma_ax") and hasattr(self, "gamma_table_ax"):
# 清空左侧曲线
self.gamma_ax.clear()
self.gamma_fig.patch.set_facecolor(palette["bg"])
self.gamma_ax.set_facecolor(palette["card_bg"])
self.gamma_ax.set_xlim(0, 105)
self.gamma_ax.set_ylim(0, 1.1)
self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.gamma_ax.set_ylabel("L_bar", fontsize=10)
self.gamma_ax.grid(True, linestyle="--", alpha=0.3)
self.gamma_ax.tick_params(labelsize=9)
self.gamma_ax.tick_params(colors=palette["fg"])
for spine in self.gamma_ax.spines.values():
spine.set_color(palette["border"])
# 左侧提示
self.gamma_ax.text(
@@ -479,13 +707,13 @@ def clear_chart(self):
ha="center",
va="center",
fontsize=10,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1",
facecolor="white",
edgecolor="gray",
alpha=0.8,
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -523,17 +751,17 @@ def clear_chart(self):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.gamma_table_ax.text(
@@ -546,29 +774,34 @@ def clear_chart(self):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.gamma_canvas.draw()
# ========== 3. 清空EOTF图表4列==========
if hasattr(self, "eotf_ax") and hasattr(self, "eotf_table_ax"):
# 清空左侧曲线
self.eotf_ax.clear()
self.eotf_fig.patch.set_facecolor(palette["bg"])
self.eotf_ax.set_facecolor(palette["card_bg"])
self.eotf_ax.set_xlim(0, 105)
self.eotf_ax.set_ylim(0, 1.1)
self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.eotf_ax.set_ylabel("L_bar (归一化亮度)", fontsize=10)
self.eotf_ax.grid(True, linestyle="--", alpha=0.3)
self.eotf_ax.tick_params(labelsize=9)
self.eotf_ax.tick_params(colors=palette["fg"])
for spine in self.eotf_ax.spines.values():
spine.set_color(palette["border"])
# 左侧提示
self.eotf_ax.text(
@@ -578,13 +811,13 @@ def clear_chart(self):
ha="center",
va="center",
fontsize=11,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1",
facecolor="white",
edgecolor="gray",
alpha=0.8,
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -622,17 +855,17 @@ def clear_chart(self):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.eotf_table_ax.text(
@@ -645,17 +878,17 @@ def clear_chart(self):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.eotf_canvas.draw()
# ========== 4. 清空色度图表 ==========
@@ -663,8 +896,10 @@ def clear_chart(self):
# 过期引用。因此清空时必须同样重建,并更新引用,否则清不干净。
if hasattr(self, "cct_fig") and hasattr(self, "cct_canvas"):
self.cct_fig.clear()
self.cct_fig.patch.set_facecolor(palette["bg"])
self.cct_ax1 = self.cct_fig.add_subplot(211)
self.cct_ax1.set_facecolor(palette["card_bg"])
self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9)
self.cct_ax1.set_ylabel("CIE x", fontsize=9)
self.cct_ax1.set_xlim(0, 105)
@@ -673,6 +908,7 @@ def clear_chart(self):
self.cct_ax1.tick_params(labelsize=8)
self.cct_ax2 = self.cct_fig.add_subplot(212)
self.cct_ax2.set_facecolor(palette["card_bg"])
self.cct_ax2.set_xlabel("灰阶 (%)", fontsize=9)
self.cct_ax2.set_ylabel("CIE y", fontsize=9)
self.cct_ax2.set_xlim(0, 105)
@@ -680,7 +916,7 @@ def clear_chart(self):
self.cct_ax2.grid(True, linestyle="--", alpha=0.3)
self.cct_ax2.tick_params(labelsize=8)
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985)
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985, color=palette["fg"])
self.cct_fig.subplots_adjust(
left=0.12,
right=0.88,
@@ -693,11 +929,13 @@ def clear_chart(self):
# ========== 5. 清空对比度图表 ==========
if hasattr(self, "contrast_ax"):
self.contrast_ax.clear()
self.contrast_fig.patch.set_facecolor(palette["bg"])
self.contrast_ax.set_facecolor(palette["card_bg"])
self.contrast_ax.set_xlim(0, 1)
self.contrast_ax.set_ylim(0, 1)
self.contrast_ax.axis("off")
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985)
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985, color=palette["fg"])
# 重置布局
self.contrast_fig.subplots_adjust(
@@ -712,12 +950,14 @@ def clear_chart(self):
# ========== 6. 清空色准图表 ==========
if hasattr(self, "accuracy_ax"):
self.accuracy_ax.clear()
self.accuracy_fig.patch.set_facecolor(palette["bg"])
self.accuracy_ax.set_facecolor(palette["card_bg"])
self.accuracy_ax.set_xlim(0, 1)
self.accuracy_ax.set_ylim(0, 1)
self.accuracy_ax.axis("off")
# 标题
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985)
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985, color=palette["fg"])
# 重置布局
self.accuracy_fig.subplots_adjust(
@@ -729,7 +969,10 @@ def clear_chart(self):
self.accuracy_canvas.draw()
def update_chart_tabs_state(self):
# 清空色准明细表格
self.clear_accuracy_result_table()
def update_chart_tabs_state(self: "PQAutomationApp"):
"""根据测试项目复选框状态动态增删图表 Tab保持规范顺序
- 色域 / Gamma 或 EOTF / 色度一致性 / 对比度 / 色准 全部走动态 add/forget
@@ -801,7 +1044,7 @@ def update_chart_tabs_state(self):
if hasattr(self, "log_gui"):
self.log_gui.log(f"更新Tab状态失败: {str(e)}", level="error")
def create_result_chart_frame(self):
def create_result_chart_frame(self: "PQAutomationApp"):
"""创建结果图表区域 - 6个独立TabGamma 和 EOTF 分离)"""
# 创建Notebook用于图表切换
self.chart_notebook = ttk.Notebook(self.result_frame)
@@ -844,6 +1087,7 @@ def create_result_chart_frame(self):
self.init_cct_chart()
self.init_contrast_chart()
self.init_accuracy_chart()
self.apply_result_chart_theme()
# 绑定Tab切换事件
self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed)
@@ -859,12 +1103,36 @@ def create_result_chart_frame(self):
# 创建单步调试面板实例
self.debug_panel = PQDebugPanel(self.debug_container, self)
def on_chart_tab_changed(self, event):
def on_chart_tab_changed(self: "PQAutomationApp", event):
"""Tab切换时的事件处理"""
try:
self._last_tab_index = self.chart_notebook.index(
self.chart_notebook.select()
)
selected_tab = self.chart_notebook.select()
# 在动态 add/forget tab 的过程中,可能短暂出现“无选中页签”。
if not selected_tab:
return
self._last_tab_index = self.chart_notebook.index(selected_tab)
except Exception as e:
self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error")
class ChartFrameMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
init_gamut_chart = init_gamut_chart
sync_gamut_toolbar = sync_gamut_toolbar
_on_gamut_toolbar_changed = _on_gamut_toolbar_changed
init_gamma_chart = init_gamma_chart
init_eotf_chart = init_eotf_chart
init_cct_chart = init_cct_chart
init_contrast_chart = init_contrast_chart
init_accuracy_chart = init_accuracy_chart
apply_result_chart_theme = apply_result_chart_theme
_init_accuracy_result_table = _init_accuracy_result_table
clear_accuracy_result_table = clear_accuracy_result_table
update_accuracy_result_table = update_accuracy_result_table
clear_chart = clear_chart
update_chart_tabs_state = update_chart_tabs_state
create_result_chart_frame = create_result_chart_frame
on_chart_tab_changed = on_chart_tab_changed

View File

@@ -1,104 +1,149 @@
import ttkbootstrap as ttk
"""现代化的可折叠面板(取代 v1 的图标按钮版本)。
升级要点(保留 ``add(child, title=...)`` 旧签名兼容):
- header 整条可点击切换展开/收起;
- 使用 Unicode chevron (▾/▸),无需 PNG 资源;
- 新增 ``preview_textvariable``:折叠时在 header 显示当前配置摘要;
- 新增 ``header_actions``:在 header 右侧注入自定义按钮(如顶部工具条)。
"""
import tkinter
from tkinter import ttk
from pathlib import Path
from ttkbootstrap import Style
import sys
import os
def get_resource_path(relative_path):
"""
获取资源文件的绝对路径(兼容开发环境和打包后)
Args:
relative_path: 相对路径,如 "assets/icons8_double_up_24px.png"
Returns:
str: 资源文件的绝对路径
"""
try:
# PyInstaller 打包后的临时文件夹路径
base_path = sys._MEIPASS
except AttributeError:
# 开发环境:使用项目根目录
# 当前文件: app/views/collapsing_frame.py
# 项目根目录: app/views 的祖父目录
current_file = os.path.abspath(__file__)
views_dir = os.path.dirname(current_file)
app_dir = os.path.dirname(views_dir)
base_path = os.path.dirname(app_dir)
return os.path.join(base_path, relative_path)
class CollapsingFrame(ttk.Frame):
"""
A collapsible frame widget that opens and closes with a button click.
"""
"""A modern collapsible frame widget."""
CHEVRON_OPEN = "\u25be" # ▾
CHEVRON_CLOSED = "\u25b8" # ▸
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.columnconfigure(0, weight=1)
self.cumulative_rows = 0
p = Path(__file__).parent
self.images = [
tkinter.PhotoImage(
name="open", file=get_resource_path("assets/icons8_double_up_24px.png")
),
tkinter.PhotoImage(
name="closed",
file=get_resource_path("assets/icons8_double_right_24px.png"),
),
]
# 兼容旧代码可能引用 self.images
self.images: list = []
def add(self, child, title="", style="primary.TButton", **kwargs):
"""Add a child to the collapsible frame
# ------------------------------------------------------------------
# 公共 API
# ------------------------------------------------------------------
def add(
self,
child,
title: str = "",
style: str = "primary.TButton", # 兼容旧签名(不再使用)
preview_textvariable=None,
header_actions=None,
**kwargs,
):
"""添加一个子区段到折叠面板。
:param ttk.Frame child: the child frame to add to the widget
:param str title: the title appearing on the collapsible section header
:param str style: the ttk style to apply to the collapsible section header
:param child: 必须是一个 ttk.Frame
:param title: 标题文本;
:param preview_textvariable: 折叠时显示在 header 上的状态摘要 StringVar
:param header_actions: 回调 ``fn(actions_frame)``,可在 header 右侧添加按钮。
"""
if child.winfo_class() != "TFrame": # must be a frame
if child.winfo_class() != "TFrame":
return
style_color = style.split(".")[0]
frm = ttk.Frame(self, style=f"{style_color}.TFrame")
frm.grid(row=self.cumulative_rows, column=0, sticky="ew")
# header title
lbl = ttk.Label(frm, text=title, style=f"{style_color}.Inverse.TLabel")
if kwargs.get("textvariable"):
lbl.configure(textvariable=kwargs.get("textvariable"))
lbl.pack(side="left", fill="both", padx=10)
header = ttk.Frame(self, style="ConfigHeader.TFrame", padding=(12, 6))
header.grid(row=self.cumulative_rows, column=0, sticky="ew")
header.columnconfigure(1, weight=1)
# header toggle button
btn = ttk.Button(
frm,
image="open",
style=style,
command=lambda c=child: self._toggle_open_close(child),
# chevron + 标题
title_box = ttk.Frame(header, style="ConfigHeader.TFrame")
title_box.grid(row=0, column=0, sticky="w")
chevron = ttk.Label(
title_box, text=self.CHEVRON_OPEN, style="ConfigChevron.TLabel"
)
btn.pack(side="right")
chevron.pack(side="left", padx=(0, 8))
title_lbl = ttk.Label(title_box, text=title, style="ConfigHeader.TLabel")
if kwargs.get("textvariable"):
title_lbl.configure(textvariable=kwargs.get("textvariable"))
title_lbl.pack(side="left")
# 中:折叠状态预览
preview_lbl = None
if preview_textvariable is not None:
preview_lbl = ttk.Label(
header,
textvariable=preview_textvariable,
style="ConfigPreview.TLabel",
)
preview_lbl.grid(row=0, column=1, sticky="w", padx=(16, 8))
# 右actions如顶部工具条按钮
actions_frame = ttk.Frame(header, style="ConfigHeader.TFrame")
actions_frame.grid(row=0, column=2, sticky="e")
if callable(header_actions):
try:
header_actions(actions_frame)
except Exception:
# 注入失败不应影响整体折叠面板渲染
pass
# 整条 header 点击切换
clickable = [header, title_box, chevron, title_lbl]
if preview_lbl is not None:
clickable.append(preview_lbl)
for w in clickable:
w.bind(
"<Button-1>",
lambda _e, c=child: self._toggle_open_close(c),
)
try:
w.configure(cursor="hand2")
except tkinter.TclError:
pass
child._chevron = chevron
child._header = header
child._preview_lbl = preview_lbl
# 兼容旧代码 child.btn.invoke() / child.btn.configure(image=...)
child.btn = _HeaderToggleProxy(self, child, chevron)
# assign toggle button to child so that it's accesible when toggling (need to change image)
child.btn = btn
child.grid(row=self.cumulative_rows + 1, column=0, sticky="news")
# increment the row assignment
self.cumulative_rows += 2
# ------------------------------------------------------------------
# 内部实现
# ------------------------------------------------------------------
def _toggle_open_close(self, child):
"""
Open or close the section and change the toggle button image accordingly
:param ttk.Frame child: the child element to add or remove from grid manager
"""
if child.winfo_viewable():
child.grid_remove()
child.btn.configure(image="closed")
try:
child._chevron.configure(text=self.CHEVRON_CLOSED)
except (AttributeError, tkinter.TclError):
pass
else:
child.grid()
child.btn.configure(image="open")
try:
child._chevron.configure(text=self.CHEVRON_OPEN)
except (AttributeError, tkinter.TclError):
pass
class _HeaderToggleProxy:
"""兼容旧代码:``child.btn.invoke()`` / ``child.btn.configure(image=...)``。"""
def __init__(self, owner: "CollapsingFrame", child, chevron):
self._owner = owner
self._child = child
self._chevron = chevron
def invoke(self):
self._owner._toggle_open_close(self._child)
def configure(self, **kwargs):
image = kwargs.get("image")
if image == "closed":
self._chevron.configure(text=CollapsingFrame.CHEVRON_CLOSED)
elif image == "open":
self._chevron.configure(text=CollapsingFrame.CHEVRON_OPEN)
config = configure
# class Application(tkinter.Tk):

432
app/views/modern_styles.py Normal file
View File

@@ -0,0 +1,432 @@
"""现代化 UI 样式注册(跟随 ttkbootstrap 当前主题)。
由 backgroud_style_set() 调用一次。这里集中定义"配置项卡片化"
"现代化标题栏""工具条""状态栏" 等所需的所有 ttk Style
保持主题切换时颜色自动跟随。
"""
from __future__ import annotations
import ttkbootstrap as ttk
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
h = h.lstrip("#")
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
def _rgb_to_hex(r: int, g: int, b: int) -> str:
return f"#{r:02x}{g:02x}{b:02x}"
def _mix(c1: str, c2: str, ratio: float) -> str:
"""按 ratio (0~1) 将 c1 与 c2 线性混合。"""
r1, g1, b1 = _hex_to_rgb(c1)
r2, g2, b2 = _hex_to_rgb(c2)
return _rgb_to_hex(
int(r1 * (1 - ratio) + r2 * ratio),
int(g1 * (1 - ratio) + g2 * ratio),
int(b1 * (1 - ratio) + b2 * ratio),
)
def _is_dark(color: str) -> bool:
r, g, b = _hex_to_rgb(color)
# ITU-R BT.601 亮度
return (r * 299 + g * 587 + b * 114) / 1000 < 128
def _contrast_text(color: str, *, dark_text: str, light_text: str) -> str:
return dark_text if _is_dark(color) else light_text
def get_theme_palette() -> dict[str, str]:
"""返回当前主题的语义色板,供 ttk / tk 自定义控件共用。"""
style = ttk.Style()
theme = style.colors
bg = theme.bg
fg = theme.fg
primary = theme.primary
secondary = theme.secondary
success = theme.success
info = theme.info
warning = theme.warning
danger = theme.danger
dark = theme.dark
border = theme.border
inputbg = theme.inputbg
inputfg = getattr(theme, "inputfg", fg)
dark_theme = _is_dark(bg)
select_bg = getattr(theme, "selectbg", _mix(primary, bg, 0.30 if dark_theme else 0.12))
select_fg = getattr(theme, "selectfg", "#ffffff" if _is_dark(select_bg) else fg)
if dark_theme:
card_bg = _mix(bg, "#ffffff", 0.04)
card_border = _mix(bg, fg, 0.18)
header_fg = _contrast_text(
"#444A51",
dark_text="#ffffff",
light_text="#1a1a1a",
)
sidebar_bg = _mix(dark, bg, 0.18)
sidebar_hover = _mix(sidebar_bg, "#ffffff", 0.07)
sidebar_selected = _mix(sidebar_bg, "#ffffff", 0.14)
sidebar_fg = _mix(fg, "#ffffff", 0.04)
sidebar_muted = _mix(sidebar_fg, sidebar_bg, 0.45)
muted_fg = _mix(fg, bg, 0.32)
disabled_fg = _mix(fg, bg, 0.42)
disabled_bg = _mix(inputbg, bg, 0.18)
disabled_border = _mix(border, fg, 0.22)
readonly_bg = _mix(inputbg, "#ffffff", 0.06)
success_fg = _mix(success, "#ffffff", 0.08)
warning_fg = _mix(warning, "#ffffff", 0.06)
info_fg = _mix(info, "#ffffff", 0.06)
statusbar_bg = _mix(bg, "#ffffff", 0.06)
tooltip_bg = _mix(inputbg, bg, 0.08)
tooltip_fg = inputfg
tooltip_border = _mix(border, fg, 0.20)
surface_alt_bg = _mix(card_bg, "#ffffff", 0.05)
surface_hover_bg = _mix(card_bg, "#ffffff", 0.09)
badge_bg = _mix(danger, bg, 0.12)
badge_fg = "#ffffff"
focus = _mix(primary, "#ffffff", 0.18)
config_bg = _mix("#444A51", bg, 0.30)
else:
card_bg = inputbg
card_border = border
header_fg = bg
config_bg = _mix(primary, bg, 0.25)
sidebar_bg = _mix(primary, bg, 0.82)
sidebar_hover = _mix(primary, bg, 0.72)
sidebar_selected = primary
sidebar_fg = fg
sidebar_muted = _mix(fg, sidebar_bg, 0.35)
muted_fg = _mix(fg, bg, 0.38)
disabled_fg = _mix(fg, bg, 0.55)
disabled_bg = _mix(bg, border, 0.18)
disabled_border = _mix(border, bg, 0.18)
readonly_bg = _mix(inputbg, primary, 0.04)
success_fg = success
warning_fg = _mix(warning, fg, 0.18)
info_fg = info
statusbar_bg = _mix(bg, dark, 0.04)
tooltip_bg = inputbg
tooltip_fg = inputfg
tooltip_border = border
surface_alt_bg = _mix(bg, dark, 0.03)
surface_hover_bg = _mix(bg, dark, 0.05)
badge_bg = danger
badge_fg = "#ffffff"
focus = _mix(primary, bg, 0.20)
return {
"bg": bg,
"fg": fg,
"primary": primary,
"secondary": secondary,
"success": success,
"info": info,
"warning": warning,
"danger": danger,
"border": border,
"input_bg": inputbg,
"input_fg": inputfg,
"select_bg": select_bg,
"select_fg": select_fg,
"card_bg": card_bg,
"card_border": card_border,
"header_fg": header_fg,
"sidebar_bg": sidebar_bg,
"sidebar_hover": sidebar_hover,
"sidebar_selected": sidebar_selected,
"sidebar_fg": sidebar_fg,
"sidebar_muted": sidebar_muted,
"muted_fg": muted_fg,
"disabled_fg": disabled_fg,
"disabled_bg": disabled_bg,
"disabled_border": disabled_border,
"readonly_bg": readonly_bg,
"success_fg": success_fg,
"warning_fg": warning_fg,
"info_fg": info_fg,
"statusbar_bg": statusbar_bg,
"tooltip_bg": tooltip_bg,
"tooltip_fg": tooltip_fg,
"tooltip_border": tooltip_border,
"surface_alt_bg": surface_alt_bg,
"surface_hover_bg": surface_hover_bg,
"badge_bg": badge_bg,
"badge_fg": badge_fg,
"focus": focus,
"config_bg": config_bg,
}
def apply_listbox_theme(widget) -> None:
"""将 tk.Listbox 颜色同步到当前主题。"""
palette = get_theme_palette()
widget.configure(
background=palette["input_bg"],
foreground=palette["input_fg"],
highlightbackground=palette["border"],
highlightcolor=palette["focus"],
selectbackground=palette["select_bg"],
selectforeground=palette["select_fg"],
disabledforeground=palette["disabled_fg"],
)
def apply_tooltip_theme(toplevel, label) -> None:
"""将 tooltip 的 tk.Toplevel / Label 同步到当前主题。"""
palette = get_theme_palette()
toplevel.configure(background=palette["tooltip_border"])
label.configure(
bg=palette["tooltip_bg"],
fg=palette["tooltip_fg"],
highlightbackground=palette["tooltip_border"],
)
def apply_modern_styles() -> None:
"""注册或刷新现代化样式集。可在主题切换后再次调用。"""
style = ttk.Style()
palette = get_theme_palette()
bg = palette["bg"]
fg = palette["fg"]
primary = palette["primary"]
secondary = palette["secondary"]
info = palette["info"]
card_bg = palette["card_bg"]
card_border = palette["card_border"]
header_bg = palette["config_bg"]
header_fg = palette["header_fg"]
dark_theme = _is_dark(bg)
header_hover_bg = _mix(secondary, "#ffffff", 0.08) if _is_dark(secondary) else _mix(secondary, "#000000", 0.08)
preview_fg = _mix(header_fg, header_bg, 0.35)
sidebar_bg = palette["sidebar_bg"]
sidebar_hover = palette["sidebar_hover"]
sidebar_selected = palette["sidebar_selected"]
sidebar_fg = palette["sidebar_fg"]
sidebar_muted = palette["sidebar_muted"]
muted_fg = palette["muted_fg"]
disabled_fg = palette["disabled_fg"]
disabled_bg = palette["disabled_bg"]
disabled_border = palette["disabled_border"]
readonly_bg = palette["readonly_bg"]
success_fg = palette["success_fg"]
warning_fg = palette["warning_fg"]
# ---------------- 卡片 ----------------
style.configure(
"Card.TFrame",
background=card_bg,
bordercolor=card_border,
relief="solid",
borderwidth=1,
)
style.configure(
"CardTitle.TLabel",
background=card_bg,
foreground=fg,
font=("Segoe UI", 10, "bold"),
)
style.configure(
"CardBody.TLabel",
background=card_bg,
foreground=fg,
font=("Segoe UI", 9),
)
style.configure(
"CardIcon.TLabel",
background=card_bg,
foreground=info if dark_theme else primary,
font=("Segoe UI", 9, "bold"),
)
# 内嵌于 Card 的容器(与 Card.TFrame 同背景,无边框)
style.configure("CardInner.TFrame", background=card_bg, borderwidth=0)
# ---------------- 配置项 Header ----------------
style.configure(
"ConfigHeader.TFrame",
background=header_bg,
borderwidth=0,
)
style.configure(
"ConfigHeaderHover.TFrame",
background=header_hover_bg,
borderwidth=0,
)
style.configure(
"ConfigHeader.TLabel",
background=header_bg,
foreground=header_fg,
font=("Segoe UI", 10, "bold"),
)
style.configure(
"ConfigHeaderHover.TLabel",
background=header_hover_bg,
foreground=header_fg,
font=("Segoe UI", 10, "bold"),
)
style.configure(
"ConfigChevron.TLabel",
background=header_bg,
foreground=header_fg,
font=("Segoe UI Symbol", 12, "bold"),
)
style.configure(
"ConfigPreview.TLabel",
background=header_bg,
foreground=preview_fg,
font=("Segoe UI", 9),
)
# ---------------- 通用文字语义 ----------------
style.configure("Muted.TLabel", background=bg, foreground=muted_fg)
style.configure("SuccessState.TLabel", background=bg, foreground=success_fg)
style.configure("WarningState.TLabel", background=bg, foreground=warning_fg)
style.configure("InfoState.TLabel", background=bg, foreground=palette["info_fg"])
# ---------------- 顶部工具条 ----------------
style.configure("Toolbar.TFrame", background=bg, borderwidth=0)
# 工具条上的次要按钮(清理配置等)
style.configure(
"ToolbarMuted.TButton",
font=("Segoe UI", 9),
padding=(10, 5),
)
# ---------------- 区段标题(侧栏 / 卡片外) ----------------
style.configure(
"SectionTitle.TLabel",
background=bg,
foreground=_mix(fg, bg, 0.45),
font=("Segoe UI", 8, "bold"),
)
style.configure("Sidebar.TFrame", background=sidebar_bg, borderwidth=0)
# 侧栏内的小区段标题(侧栏背景是 primary
style.configure(
"SidebarSection.TLabel",
background=sidebar_bg,
foreground=sidebar_muted,
font=("Segoe UI", 8, "bold"),
)
# 侧栏顶部品牌区
brand_bg = _mix(sidebar_bg, "#ffffff", 0.05) if dark_theme else _mix(sidebar_bg, "#000000", 0.05)
style.configure(
"SidebarBrand.TFrame",
background=brand_bg,
borderwidth=0,
)
style.configure(
"SidebarBrand.TLabel",
background=brand_bg,
foreground=palette["badge_fg"],
font=("Segoe UI Semibold", 12),
)
style.configure(
"SidebarBadge.TLabel",
background=palette["badge_bg"],
foreground=palette["badge_fg"],
font=("微软雅黑", 8, "bold"),
anchor="center",
padding=(6, 2),
)
# ---------------- 结果区无边框标题行 ----------------
style.configure("ResultHeader.TFrame", background=bg, borderwidth=0)
style.configure(
"ResultHeader.TLabel",
background=bg,
foreground=fg,
font=("Segoe UI", 11, "bold"),
)
# ---------------- 状态栏 ----------------
statusbar_bg = palette["statusbar_bg"]
statusbar_fg = _mix(fg, bg, 0.15)
style.configure(
"StatusBar.TFrame",
background=statusbar_bg,
borderwidth=0,
)
style.configure(
"StatusBar.TLabel",
background=statusbar_bg,
foreground=statusbar_fg,
font=("Segoe UI", 9),
padding=(10, 4),
)
style.configure(
"StatusBarAccent.TLabel",
background=statusbar_bg,
foreground=info if dark_theme else primary,
font=("Segoe UI", 9, "bold"),
padding=(10, 4),
)
# ---------------- 深色禁用态 / 只读态增强 ----------------
style.map(
"TLabel",
foreground=[("disabled", disabled_fg)],
)
style.map(
"TButton",
foreground=[("disabled", disabled_fg)],
background=[("disabled", disabled_bg)],
bordercolor=[("disabled", disabled_border)],
darkcolor=[("disabled", disabled_bg)],
lightcolor=[("disabled", disabled_bg)],
)
style.map(
"TEntry",
foreground=[("disabled", disabled_fg)],
fieldbackground=[("disabled", disabled_bg), ("readonly", readonly_bg)],
bordercolor=[("disabled", disabled_border), ("readonly", disabled_border)],
)
style.map(
"TCombobox",
foreground=[("disabled", disabled_fg), ("readonly", fg)],
fieldbackground=[("disabled", disabled_bg), ("readonly", readonly_bg)],
bordercolor=[("disabled", disabled_border), ("readonly", disabled_border)],
arrowcolor=[("disabled", disabled_fg), ("readonly", muted_fg)],
)
# ---------------- Sidebar 按钮(保留兼容名) ----------------
style.configure(
"Sidebar.TButton",
background=sidebar_bg,
foreground=sidebar_fg,
font=("Segoe UI", 10),
padding=(18, 9),
borderwidth=0,
anchor="w",
)
style.map(
"Sidebar.TButton",
background=[
("active", sidebar_hover),
("pressed", sidebar_selected),
],
foreground=[("active", "#ffffff" if _is_dark(sidebar_hover) else sidebar_fg)],
)
style.configure(
"SidebarSelected.TButton",
background=sidebar_selected,
foreground=_contrast_text(sidebar_selected, dark_text=palette["badge_fg"], light_text=sidebar_fg),
font=("Segoe UI Semibold", 10),
padding=(18, 9),
borderwidth=0,
anchor="w",
)
style.map(
"SidebarSelected.TButton",
background=[
("active", _mix(sidebar_selected, "#ffffff", 0.06) if dark_theme else _mix(sidebar_selected, "#000000", 0.06)),
("pressed", _mix(sidebar_selected, "#000000", 0.08)),
],
)

View File

@@ -5,7 +5,13 @@ register_panel / show_panel / hide_all_panels —— 在右侧栏不同浮动面
import tkinter as tk
def register_panel(self, panel_name, frame, button, visible_attr):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def register_panel(self: "PQAutomationApp", panel_name, frame, button, visible_attr):
"""注册一个面板到管理系统"""
self.panels[panel_name] = {
"frame": frame,
@@ -14,7 +20,7 @@ def register_panel(self, panel_name, frame, button, visible_attr):
}
def show_panel(self, panel_name):
def show_panel(self: "PQAutomationApp", panel_name):
"""显示指定面板,隐藏其他所有面板"""
if panel_name not in self.panels:
return
@@ -30,10 +36,22 @@ def show_panel(self, panel_name):
# 显示指定面板
panel_info = self.panels[panel_name]
# 隐藏主内容区域
self.control_frame_top.pack_forget()
self.control_frame_middle.pack_forget()
self.control_frame_bottom.pack_forget()
# 隐藏主内容区域
# Local Dimming 作为并列测试类型时,需要保留顶部配置区,
# 让用户在面板上方直接看到并修改配置项。
if panel_name == "local_dimming":
# 重新按“自适应高度”布局顶部配置区,避免其占用可扩展空间把
# Local Dimming 主面板整体向下挤出大块空白。
self.control_frame_top.pack_forget()
self.control_frame_top.pack(
side=tk.TOP, fill=tk.X, expand=False, padx=0, pady=5
)
self.control_frame_middle.pack_forget()
self.control_frame_bottom.pack_forget()
else:
self.control_frame_top.pack_forget()
self.control_frame_middle.pack_forget()
self.control_frame_bottom.pack_forget()
# 显示目标面板
panel_info["frame"].pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
@@ -47,7 +65,7 @@ def show_panel(self, panel_name):
self.current_panel = panel_name
def hide_all_panels(self):
def hide_all_panels(self: "PQAutomationApp"):
"""隐藏所有面板,显示主内容区域"""
# 隐藏所有注册的面板
for panel_name, panel_info in self.panels.items():
@@ -70,3 +88,12 @@ def hide_all_panels(self):
self.current_panel = None
class PanelManagerMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
register_panel = register_panel
show_panel = show_panel
hide_all_panels = hide_all_panels

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
"""CCT 参数面板及其处理函数(与主文件重构版本保持同步)。"""
"""CCT 参数面板及其处理函数(与主文件重构版本保持同步)。"""
import time
import traceback
@@ -8,8 +8,22 @@ import ttkbootstrap as ttk
import algorithm.pq_algorithm as pq_algorithm
from typing import TYPE_CHECKING
def create_cct_params_frame(self):
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def _theme_colors():
style = ttk.Style()
colors = style.colors
return {
"muted": colors.secondary,
}
def create_cct_params_frame(self: "PQAutomationApp"):
"""创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)"""
# ==================== 屏模组色度参数 Frame ====================
@@ -116,7 +130,7 @@ def create_cct_params_frame(self):
self.cct_params_frame,
text="提示: 清空输入框将恢复默认值",
font=("SimHei", 8),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
# ==================== SDR 色度参数 Frame ====================
@@ -221,7 +235,7 @@ def create_cct_params_frame(self):
self.sdr_cct_params_frame,
text="提示: 清空输入框将恢复默认值",
font=("SimHei", 8),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
# ==================== HDR 色度参数 Frame ====================
@@ -326,11 +340,11 @@ def create_cct_params_frame(self):
self.hdr_cct_params_frame,
text="提示: 清空输入框将恢复默认值",
font=("SimHei", 8),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
def _get_cct_var_dict(self, test_type):
def _get_cct_var_dict(self: "PQAutomationApp", test_type):
"""按测试类型返回 CCT 变量映射。"""
if test_type == "sdr_movie":
return {
@@ -354,7 +368,7 @@ def _get_cct_var_dict(self, test_type):
}
def _parse_cct_float(self, var, default):
def _parse_cct_float(self: "PQAutomationApp", var, default):
"""读取并解析 CCT 输入值,失败时回落默认值。"""
try:
value = var.get().strip()
@@ -365,7 +379,7 @@ def _parse_cct_float(self, var, default):
return default
def _save_cct_params_for(self, test_type):
def _save_cct_params_for(self: "PQAutomationApp", test_type):
"""保存指定测试类型的 CCT 参数。"""
try:
default_params = self.config.get_default_cct_params(test_type)
@@ -384,7 +398,7 @@ def _save_cct_params_for(self, test_type):
pass
def _handle_cct_focus_out(self, var, default_value, save_func, label):
def _handle_cct_focus_out(self: "PQAutomationApp", var, default_value, save_func, label):
"""统一处理 CCT 参数失焦校验并保存。"""
try:
value = var.get().strip()
@@ -414,27 +428,27 @@ def _handle_cct_focus_out(self, var, default_value, save_func, label):
self.log_gui.log(f"处理 {label} 参数失败: {str(e)}", level="error")
def on_sdr_cct_param_focus_out(self, var, default_value):
def on_sdr_cct_param_focus_out(self: "PQAutomationApp", var, default_value):
"""SDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "SDR")
def save_sdr_cct_params(self):
def save_sdr_cct_params(self: "PQAutomationApp"):
"""保存 SDR 色度参数。"""
_save_cct_params_for(self, "sdr_movie")
def on_hdr_cct_param_focus_out(self, var, default_value):
def on_hdr_cct_param_focus_out(self: "PQAutomationApp", var, default_value):
"""HDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "HDR")
def save_hdr_cct_params(self):
def save_hdr_cct_params(self: "PQAutomationApp"):
"""保存 HDR 色度参数。"""
_save_cct_params_for(self, "hdr_movie")
def recalculate_cct(self):
def recalculate_cct(self: "PQAutomationApp"):
"""重新计算并绘制色度图"""
try:
# 1. 保存新参数
@@ -496,7 +510,7 @@ def recalculate_cct(self):
messagebox.showerror("错误", f"重新计算失败: {str(e)}")
def recalculate_gamut(self):
def recalculate_gamut(self: "PQAutomationApp"):
"""重新计算并绘制色域图(使用新的参考标准)"""
try:
# 1. 收起配置项
@@ -628,33 +642,23 @@ def recalculate_gamut(self):
# 10. 重新绘制色域图
self.plot_gamut(rgb_data, coverage_xy, test_type)
self.log_gui.log("色域图已重新绘制", level="success")
self.log_gui.log("=" * 50, level="separator")
messagebox.showinfo(
"成功",
f"色域图已根据新参考标准 {reference_standard} 重新绘制!\n\n"
f"XY 覆盖率: {coverage_xy:.1f}%\n"
f"UV 覆盖率: {coverage_uv:.1f}%",
)
except Exception as e:
self.log_gui.log(f"重新计算失败: {str(e)}", level="error")
self.log_gui.log(traceback.format_exc(), level="error")
messagebox.showerror("错误", f"重新计算失败: {str(e)}")
def on_cct_param_focus_out(self, var, default_value):
def on_cct_param_focus_out(self: "PQAutomationApp", var, default_value):
"""色度参数失去焦点时的处理 - 空值恢复默认"""
_handle_cct_focus_out(self, var, default_value, self.save_cct_params, "屏模组")
def save_cct_params(self):
def save_cct_params(self: "PQAutomationApp"):
"""保存色度参数 - 简化版"""
_save_cct_params_for(self, self.config.current_test_type)
def reload_cct_params(self):
def reload_cct_params(self: "PQAutomationApp"):
"""切换测试类型时重新加载色度参数"""
try:
current_type = self.config.current_test_type
@@ -676,7 +680,7 @@ def reload_cct_params(self):
self.log_gui.log(f"重新加载色度参数失败: {str(e)}", level="error")
def toggle_cct_params_frame(self):
def toggle_cct_params_frame(self: "PQAutomationApp"):
"""根据测试类型和测试项的选中状态显示对应参数框"""
selected_items = self.get_selected_test_items()
current_test_type = self.config.current_test_type
@@ -718,7 +722,7 @@ _GAMUT_REF_CONFIGS = {
}
def _on_gamut_ref_changed(self, test_type, event=None):
def _on_gamut_ref_changed(self: "PQAutomationApp", test_type, event=None):
cfg = _GAMUT_REF_CONFIGS[test_type]
try:
new_ref = getattr(self, cfg["var_attr"]).get()
@@ -732,13 +736,38 @@ def _on_gamut_ref_changed(self, test_type, event=None):
self.log_gui.log(f"保存 {cfg['label']} 色域参考标准失败: {str(e)}", level="error")
def on_screen_gamut_ref_changed(self, event=None):
def on_screen_gamut_ref_changed(self: "PQAutomationApp", event=None):
_on_gamut_ref_changed(self, "screen_module", event)
def on_sdr_gamut_ref_changed(self, event=None):
def on_sdr_gamut_ref_changed(self: "PQAutomationApp", event=None):
_on_gamut_ref_changed(self, "sdr_movie", event)
def on_hdr_gamut_ref_changed(self, event=None):
def on_hdr_gamut_ref_changed(self: "PQAutomationApp", event=None):
_on_gamut_ref_changed(self, "hdr_movie", event)
class CctPanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_cct_params_frame = create_cct_params_frame
_get_cct_var_dict = _get_cct_var_dict
_parse_cct_float = _parse_cct_float
_save_cct_params_for = _save_cct_params_for
_handle_cct_focus_out = _handle_cct_focus_out
on_sdr_cct_param_focus_out = on_sdr_cct_param_focus_out
save_sdr_cct_params = save_sdr_cct_params
on_hdr_cct_param_focus_out = on_hdr_cct_param_focus_out
save_hdr_cct_params = save_hdr_cct_params
recalculate_cct = recalculate_cct
recalculate_gamut = recalculate_gamut
on_cct_param_focus_out = on_cct_param_focus_out
save_cct_params = save_cct_params
reload_cct_params = reload_cct_params
toggle_cct_params_frame = toggle_cct_params_frame
_on_gamut_ref_changed = _on_gamut_ref_changed
on_screen_gamut_ref_changed = on_screen_gamut_ref_changed
on_sdr_gamut_ref_changed = on_sdr_gamut_ref_changed
on_hdr_gamut_ref_changed = on_hdr_gamut_ref_changed

View File

@@ -1,4 +1,4 @@
"""自定义模板结果面板Step 6 重构)。"""
"""自定义模板结果面板Step 6 重构)。"""
import threading
import time
@@ -10,8 +10,15 @@ import colour
import numpy as np
from app.data_range_converter import convert_pattern_params
from app.views.modern_styles import get_theme_palette
def create_custom_template_result_panel(self):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def create_custom_template_result_panel(self: "PQAutomationApp"):
"""创建客户模板结果显示区域(黑底表格)"""
self.custom_result_frame = ttk.LabelFrame(
self.custom_template_tab_frame, text="客户模板结果显示"
@@ -22,37 +29,12 @@ def create_custom_template_result_panel(self):
table_container = tk.Frame(
self.custom_result_frame,
bg="#000000",
highlightthickness=1,
highlightbackground="#5a5a5a",
)
table_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.custom_result_table_container = table_container
style = ttk.Style()
style.configure(
"CustomResult.Treeview",
background="#000000",
fieldbackground="#000000",
foreground="#ffffff",
rowheight=28,
borderwidth=0,
)
style.configure(
"CustomResult.Treeview.Heading",
background="#2f2f2f",
foreground="#f5f5f5",
font=("Microsoft YaHei", 10, "bold"),
relief="flat",
)
style.map(
"CustomResult.Treeview",
background=[("selected", "#1f4e79")],
foreground=[("selected", "#ffffff")],
)
style.map(
"CustomResult.Treeview.Heading",
background=[("active", "#3b3b3b")],
)
_apply_custom_result_theme(self)
columns = (
"Pattern",
@@ -151,7 +133,71 @@ def create_custom_template_result_panel(self):
table_container.grid_columnconfigure(0, weight=1)
def show_custom_result_context_menu(self, event):
def _apply_custom_result_theme(self: "PQAutomationApp"):
palette = get_theme_palette()
container = getattr(self, "custom_result_table_container", None)
if container is not None:
container.configure(
bg=palette["input_bg"],
highlightbackground=palette["border"],
highlightcolor=palette["border"],
)
style = ttk.Style()
style.configure(
"CustomResult.Treeview",
background=palette["input_bg"],
fieldbackground=palette["input_bg"],
foreground=palette["input_fg"],
rowheight=28,
borderwidth=0,
)
style.configure(
"CustomResult.Treeview.Heading",
background=palette["surface_alt_bg"],
foreground=palette["muted_fg"],
font=("Microsoft YaHei", 10, "bold"),
relief="flat",
)
style.map(
"CustomResult.Treeview",
background=[("selected", palette["select_bg"])],
foreground=[("selected", palette["select_fg"])],
)
style.map(
"CustomResult.Treeview.Heading",
background=[("active", palette["surface_hover_bg"])],
)
def refresh_custom_template_theme(self: "PQAutomationApp"):
"""刷新客户模板结果表的主题色。"""
_apply_custom_result_theme(self)
def _set_custom_template_tab_visible(self: "PQAutomationApp", visible: bool):
"""控制客户模板结果 TAB 的显示与隐藏。"""
if not hasattr(self, "chart_notebook") or not hasattr(self, "custom_template_tab_frame"):
return
tab_id = str(self.custom_template_tab_frame)
current_tabs = list(self.chart_notebook.tabs())
if visible:
if tab_id not in current_tabs:
self.chart_notebook.add(self.custom_template_tab_frame, text="客户模板结果显示")
self.chart_notebook.select(self.custom_template_tab_frame)
return
if tab_id in current_tabs:
current_selected = self.chart_notebook.select()
self.chart_notebook.forget(self.custom_template_tab_frame)
remaining_tabs = list(self.chart_notebook.tabs())
if current_selected == tab_id and remaining_tabs:
self.chart_notebook.select(remaining_tabs[0])
def show_custom_result_context_menu(self: "PQAutomationApp", event):
"""显示客户模板结果右键菜单"""
if not hasattr(self, "custom_result_tree") or not hasattr(
self, "custom_result_menu"
@@ -197,7 +243,7 @@ def show_custom_result_context_menu(self, event):
self.custom_result_menu.grab_release()
def set_custom_result_table_locked(self, locked):
def set_custom_result_table_locked(self: "PQAutomationApp", locked):
"""锁定/解锁客户模板结果表(测试期间禁选择、禁右键)"""
if not hasattr(self, "custom_result_tree"):
return
@@ -208,7 +254,7 @@ def set_custom_result_table_locked(self, locked):
pass
def start_custom_row_single_step(self):
def start_custom_row_single_step(self: "PQAutomationApp"):
"""单步测试当前选中行:发送该行 pattern 并覆盖该行测量结果"""
if not hasattr(self, "custom_result_tree"):
return
@@ -252,7 +298,7 @@ def start_custom_row_single_step(self):
).start()
def _clear_custom_result_row(self, item_id, row_no):
def _clear_custom_result_row(self: "PQAutomationApp", item_id, row_no):
"""单步测试开始前清空指定行的测量数据"""
if not hasattr(self, "custom_result_tree"):
return
@@ -281,7 +327,7 @@ def _clear_custom_result_row(self, item_id, row_no):
self.custom_result_tree.see(item_id)
def _run_custom_row_single_step(self, item_id, row_no):
def _run_custom_row_single_step(self: "PQAutomationApp", item_id, row_no):
"""后台执行客户模板单步测试"""
try:
self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...")
@@ -310,19 +356,15 @@ def _run_custom_row_single_step(self, item_id, row_no):
self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围")
return
self.ucd.set_ucd_params(temp_config)
pattern_param = converted_params[row_no - 1]
self.ucd.set_pattern(self.ucd.current_pattern, pattern_param)
self.ucd.run()
self.signal_service.apply_and_run(temp_config, pattern_param)
time.sleep(self.pattern_settle_time)
# 测量显示模式1读取 Tcp/duv/Lv显示模式8读取 λd/Pe/Lv 与 XYZ。
self.ca.set_Display(1)
tcp, duv, lv, _, _, _ = self.ca.readAllDisplay()
tcp, duv, lv, _, _, _ = self.read_ca_tcp_duv()
self.ca.set_Display(8)
lambda_d, pe, lv, X, Y, Z = self.ca.readAllDisplay()
lambda_d, pe, lv, X, Y, Z = self.read_ca_lambda_pe()
xy = colour.XYZ_to_xy(np.array([X, Y, Z]))
u_prime, v_prime, _ = colour.XYZ_to_CIE1976UCS(np.array([X, Y, Z]))
@@ -354,7 +396,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
self._dispatch_ui(self.status_var.set, "单步测试失败")
def _update_custom_result_row(self, item_id, row_no, result_data):
def _update_custom_result_row(self: "PQAutomationApp", item_id, row_no, result_data):
"""覆盖更新客户模板结果表中指定行"""
def fmt(value, digits=4):
@@ -396,7 +438,7 @@ def _update_custom_result_row(self, item_id, row_no, result_data):
self.custom_result_tree.item(item_id, values=new_values)
def copy_custom_result_table(self):
def copy_custom_result_table(self: "PQAutomationApp"):
"""复制客户模板结果表格到剪贴板(不含标题行/No./Pattern"""
if not hasattr(self, "custom_result_tree"):
return
@@ -436,7 +478,7 @@ def copy_custom_result_table(self):
if hasattr(self, "log_gui"):
self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)", level="success")
def clear_custom_template_results(self):
def clear_custom_template_results(self: "PQAutomationApp"):
"""清空客户模板结果表格"""
if not hasattr(self, "custom_result_tree"):
return
@@ -444,7 +486,7 @@ def clear_custom_template_results(self):
self.custom_result_tree.delete(item)
def auto_expand_custom_result_view(self):
def auto_expand_custom_result_view(self: "PQAutomationApp"):
"""当客户模板表格有数据时,自动扩展窗口以尽量完整显示所有列"""
if not hasattr(self, "custom_result_tree"):
return
@@ -482,7 +524,7 @@ def auto_expand_custom_result_view(self):
self.log_gui.log(f"自动扩展客户模板窗口失败: {str(e)}", level="error")
def append_custom_template_result(self, row_no, result_data):
def append_custom_template_result(self: "PQAutomationApp", row_no, result_data):
"""追加一条客户模板结果到表格"""
def fmt(value, digits=4):
@@ -525,12 +567,9 @@ def append_custom_template_result(self, row_no, result_data):
self.auto_expand_custom_result_view()
def start_custom_template_test(self):
def start_custom_template_test(self: "PQAutomationApp"):
"""开始客户模板测试SDR"""
if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
self.chart_notebook.select(self.custom_template_tab_frame)
if self.ca is None or self.ucd is None:
messagebox.showerror("错误", "请先连接CA410和信号发生器")
return
@@ -565,15 +604,17 @@ def start_custom_template_test(self):
self.custom_btn.config(state=tk.NORMAL)
self.status_var.set("测试已取消")
self.set_custom_result_table_locked(False)
_set_custom_template_tab_visible(self, False)
return
_set_custom_template_tab_visible(self, True)
self.set_custom_result_table_locked(True)
self.test_thread = threading.Thread(target=self.run_custom_sdr_test, args=([],))
self.test_thread.daemon = True
self.test_thread.start()
def update_custom_button_visibility(self):
def update_custom_button_visibility(self: "PQAutomationApp"):
"""只在 SDR 测试时显示客户模版按钮"""
if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"):
return
@@ -627,7 +668,7 @@ def update_custom_button_visibility(self):
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success")
def export_custom_template_excel(self):
def export_custom_template_excel(self: "PQAutomationApp"):
"""将客户模板结果表导出为 Excel 文件14 列完整数据)"""
if not hasattr(self, "custom_result_tree"):
return
@@ -775,7 +816,7 @@ def export_custom_template_excel(self):
messagebox.showerror("错误", f"导出失败:{str(e)}")
def export_custom_template_charts(self):
def export_custom_template_charts(self: "PQAutomationApp"):
"""生成客户模板图表xy 色度散点图 + Lv 亮度曲线图,保存为 PNG"""
if not hasattr(self, "custom_result_tree"):
return
@@ -912,3 +953,26 @@ def export_custom_template_charts(self):
if hasattr(self, "log_gui"):
self.log_gui.log(f"生成图表失败: {str(e)}", level="error")
messagebox.showerror("错误", f"生成图表失败:{str(e)}")
class CustomTemplatePanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_custom_template_result_panel = create_custom_template_result_panel
_set_custom_template_tab_visible = _set_custom_template_tab_visible
show_custom_result_context_menu = show_custom_result_context_menu
set_custom_result_table_locked = set_custom_result_table_locked
start_custom_row_single_step = start_custom_row_single_step
_clear_custom_result_row = _clear_custom_result_row
_run_custom_row_single_step = _run_custom_row_single_step
_update_custom_result_row = _update_custom_result_row
copy_custom_result_table = copy_custom_result_table
clear_custom_template_results = clear_custom_template_results
auto_expand_custom_result_view = auto_expand_custom_result_view
append_custom_template_result = append_custom_template_result
start_custom_template_test = start_custom_template_test
update_custom_button_visibility = update_custom_button_visibility
export_custom_template_excel = export_custom_template_excel
export_custom_template_charts = export_custom_template_charts
refresh_custom_template_theme = refresh_custom_template_theme

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,11 +11,17 @@ from tkinter import filedialog, messagebox
import ttkbootstrap as ttk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
_TEMPLATE_FILE = "pantone_2670_colors.xlsx"
def create_pantone_baseline_panel(self):
def create_pantone_baseline_panel(self: "PQAutomationApp"):
"""创建 Pantone 认证摸底测试面板。"""
frame = ttk.Frame(self.content_frame)
self.pantone_baseline_frame = frame
@@ -57,7 +63,7 @@ def create_pantone_baseline_panel(self):
ttk.Label(config_row, textvariable=self.pantone_progress_var).pack(
side=tk.RIGHT, padx=(8, 0)
)
ttk.Label(config_row, textvariable=self.pantone_status_var, foreground="#666").pack(
ttk.Label(config_row, textvariable=self.pantone_status_var, style="Muted.TLabel").pack(
side=tk.RIGHT
)
@@ -149,12 +155,12 @@ def create_pantone_baseline_panel(self):
_set_button_states(self)
def toggle_pantone_baseline_panel(self):
def toggle_pantone_baseline_panel(self: "PQAutomationApp"):
"""切换 Pantone 认证摸底测试面板。"""
self.show_panel("pantone_baseline")
def _get_settings_dir(self):
def _get_settings_dir(self: "PQAutomationApp"):
"""返回 settings 绝对目录,避免依赖当前工作目录。"""
if getattr(self, "config_file", None):
return os.path.dirname(self.config_file)
@@ -168,7 +174,7 @@ def _get_settings_dir(self):
return os.path.join(base_dir, "settings")
def _load_patterns(self):
def _load_patterns(self: "PQAutomationApp"):
path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
if not os.path.isfile(path):
raise FileNotFoundError(f"未找到模板文件: {path}")
@@ -201,11 +207,11 @@ def _load_patterns(self):
return patterns
def _start_pantone_baseline(self):
def _start_pantone_baseline(self: "PQAutomationApp"):
if self._pantone_running:
messagebox.showinfo("提示", "Pantone 任务正在执行")
return
if not getattr(self, "ucd", None) or not self.ucd.status:
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323")
return
if not getattr(self, "ca", None):
@@ -247,14 +253,14 @@ def _start_pantone_baseline(self):
_launch_worker(self, start_index=0, settle=settle)
def _resume_pantone_baseline(self):
def _resume_pantone_baseline(self: "PQAutomationApp"):
if self._pantone_running:
messagebox.showinfo("提示", "Pantone 任务正在执行")
return
if not self._pantone_paused:
messagebox.showinfo("提示", "当前没有可继续的暂停任务")
return
if not getattr(self, "ucd", None) or not self.ucd.status:
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323")
return
if not getattr(self, "ca", None):
@@ -291,7 +297,7 @@ def _resume_pantone_baseline(self):
_launch_worker(self, start_index=self._pantone_next_index, settle=settle)
def _launch_worker(self, start_index, settle):
def _launch_worker(self: "PQAutomationApp", start_index, settle):
total = self._pantone_target_count or len(self.pantone_patterns)
def worker():
@@ -330,7 +336,7 @@ def _launch_worker(self, start_index, settle):
end_state = "paused"
break
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
if lv is None:
raise RuntimeError(f"{i + 1} 组 CA410 采集失败")
@@ -401,7 +407,7 @@ def _launch_worker(self, start_index, settle):
threading.Thread(target=worker, daemon=True).start()
def _append_result_row(self, record, total):
def _append_result_row(self: "PQAutomationApp", record, total):
self.pantone_tree.insert(
"",
tk.END,
@@ -423,7 +429,7 @@ def _append_result_row(self, record, total):
self.pantone_tree.see(children[-1])
def _pause_pantone_baseline(self):
def _pause_pantone_baseline(self: "PQAutomationApp"):
if not self._pantone_running:
messagebox.showinfo("提示", "当前没有运行中的任务")
return
@@ -433,7 +439,7 @@ def _pause_pantone_baseline(self):
self._pantone_control_event.set()
def _end_pantone_baseline(self):
def _end_pantone_baseline(self: "PQAutomationApp"):
if self._pantone_running:
self._pantone_stop_requested = True
self.pantone_status_var.set("结束中...")
@@ -448,7 +454,7 @@ def _end_pantone_baseline(self):
_set_button_states(self)
def _clear_results(self):
def _clear_results(self: "PQAutomationApp"):
if self._pantone_running:
messagebox.showinfo("提示", "任务执行中,无法清空")
return
@@ -463,7 +469,7 @@ def _clear_results(self):
_set_button_states(self)
def _set_button_states(self):
def _set_button_states(self: "PQAutomationApp"):
if self._pantone_running:
self.pantone_start_btn.configure(state=tk.DISABLED)
self.pantone_pause_btn.configure(state=tk.NORMAL)
@@ -479,7 +485,7 @@ def _set_button_states(self):
self.pantone_resume_btn.configure(state=tk.NORMAL if can_resume else tk.DISABLED)
def _save_as_template(self):
def _save_as_template(self: "PQAutomationApp"):
if not self.pantone_results:
messagebox.showinfo("提示", "暂无可导出的结果")
return
@@ -502,7 +508,7 @@ def _save_as_template(self):
messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}")
def _resolve_results_dir(self):
def _resolve_results_dir(self: "PQAutomationApp"):
if getattr(self, "config_file", None):
root_dir = os.path.dirname(os.path.dirname(self.config_file))
else:
@@ -514,7 +520,7 @@ def _resolve_results_dir(self):
return results_dir
def _auto_save_template(self):
def _auto_save_template(self: "PQAutomationApp"):
results_dir = _resolve_results_dir(self)
target_count = len(self.pantone_results)
filename = (
@@ -526,7 +532,7 @@ def _auto_save_template(self):
return path
def _write_template_xlsx(self, path):
def _write_template_xlsx(self: "PQAutomationApp", path):
# 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。
template_path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
from openpyxl import load_workbook, Workbook
@@ -560,3 +566,25 @@ def _write_template_xlsx(self, path):
ws.cell(row=idx, column=6, value=float(item["y"]))
wb.save(path)
class PantoneBaselinePanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_pantone_baseline_panel = create_pantone_baseline_panel
toggle_pantone_baseline_panel = toggle_pantone_baseline_panel
_get_settings_dir = _get_settings_dir
_load_patterns = _load_patterns
_start_pantone_baseline = _start_pantone_baseline
_resume_pantone_baseline = _resume_pantone_baseline
_launch_worker = _launch_worker
_append_result_row = _append_result_row
_pause_pantone_baseline = _pause_pantone_baseline
_end_pantone_baseline = _end_pantone_baseline
_clear_results = _clear_results
_set_button_states = _set_button_states
_save_as_template = _save_as_template
_resolve_results_dir = _resolve_results_dir
_auto_save_template = _auto_save_template
_write_template_xlsx = _write_template_xlsx

View File

@@ -1,4 +1,4 @@
"""侧边面板(日志 / Local Dimming / 调试)"""
"""侧边面板(日志 / Local Dimming / 调试)"""
import traceback
import tkinter as tk
@@ -7,7 +7,13 @@ import ttkbootstrap as ttk
from app.views.pq_log_gui import PQLogGUI
from app.views.pq_debug_panel import PQDebugPanel
def create_log_panel(self):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def create_log_panel(self: "PQAutomationApp"):
"""创建日志面板"""
self.log_frame = ttk.Frame(self.content_frame)
self.log_gui = PQLogGUI(self.log_frame)
@@ -22,8 +28,8 @@ def create_log_panel(self):
) # button会在后面设置
def create_local_dimming_panel(self):
"""创建 Local Dimming 测试面板 - 手动控制版"""
def create_local_dimming_panel(self: "PQAutomationApp"):
"""创建 Local Dimming 测试面板"""
self.local_dimming_frame = ttk.Frame(self.content_frame)
# 主容器
@@ -51,7 +57,7 @@ def create_local_dimming_panel(self):
window_frame,
text="点击按钮发送对应百分比的白色窗口(黑色背景 + 居中白色矩形)",
font=("", 9),
foreground="#28a745",
style="SuccessState.TLabel",
).pack(pady=(0, 8))
# 第一行1%, 2%, 5%, 10%, 18%
@@ -82,6 +88,52 @@ def create_local_dimming_panel(self):
width=12,
).pack(side=tk.LEFT, padx=3)
# ==================== 3. 其他手动图案 ====================
pattern_frame = ttk.LabelFrame(main_container, text="🧩 其他测试图案", padding=10)
pattern_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(
pattern_frame,
text="手动发送棋盘格、瞬时峰值、黑场图案,再点击采集当前亮度",
font=("", 9),
style="SuccessState.TLabel",
).pack(pady=(0, 8))
pattern_row = ttk.Frame(pattern_frame)
pattern_row.pack(fill=tk.X)
ttk.Button(
pattern_row,
text="棋盘格(中心白)",
command=lambda: self.send_ld_checkerboard(True),
bootstyle="secondary",
width=14,
).pack(side=tk.LEFT, padx=3)
ttk.Button(
pattern_row,
text="棋盘格(中心黑)",
command=lambda: self.send_ld_checkerboard(False),
bootstyle="secondary",
width=14,
).pack(side=tk.LEFT, padx=3)
ttk.Button(
pattern_row,
text="瞬时峰值",
command=self.send_ld_instant_peak,
bootstyle="warning",
width=12,
).pack(side=tk.LEFT, padx=3)
ttk.Button(
pattern_row,
text="全黑画面",
command=self.send_ld_black_pattern,
bootstyle="dark",
width=12,
).pack(side=tk.LEFT, padx=3)
# ==================== 4. CA410 采集按钮 ====================
measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10)
measure_frame.pack(fill=tk.X, pady=(0, 10))
@@ -103,7 +155,7 @@ def create_local_dimming_panel(self):
measure_btn_frame,
text="亮度: -- cd/m² | x: -- | y: --",
font=("Consolas", 10),
foreground="#007bff",
style="InfoState.TLabel",
)
self.ld_result_label.pack(side=tk.LEFT, padx=(10, 0))
@@ -112,15 +164,19 @@ def create_local_dimming_panel(self):
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Treeview
columns = ("窗口百分比", "亮度 (cd/m²)", "x", "y", "时间")
columns = ("测试项目", "图案", "亮度/结果", "x", "y", "时间")
self.ld_tree = ttk.Treeview(
result_frame, columns=columns, show="headings", height=10
)
for col in columns:
self.ld_tree.heading(col, text=col)
if col == "窗口百分比":
self.ld_tree.column(col, width=100, anchor=tk.CENTER)
if col == "测试项目":
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
elif col == "图案":
self.ld_tree.column(col, width=140, anchor=tk.CENTER)
elif col == "亮度/结果":
self.ld_tree.column(col, width=110, anchor=tk.CENTER)
elif col == "时间":
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
else:
@@ -170,14 +226,16 @@ def create_local_dimming_panel(self):
# 初始化当前窗口百分比(用于记录)
self.current_ld_percentage = None
self.current_ld_test_item = None
self.current_ld_pattern_label = None
def toggle_local_dimming_panel(self):
def toggle_local_dimming_panel(self: "PQAutomationApp"):
"""切换 Local Dimming 面板显示"""
self.show_panel("local_dimming")
def toggle_log_panel(self):
def toggle_log_panel(self: "PQAutomationApp"):
"""切换日志面板的显示状态"""
self.show_panel("log")
@@ -226,7 +284,7 @@ DEBUG_PANEL_CONFIGS = {
}
def _toggle_debug_panel(self, test_type):
def _toggle_debug_panel(self: "PQAutomationApp", test_type):
"""打开/关闭对应测试类型的单步调试面板(独立窗口)。"""
cfg = DEBUG_PANEL_CONFIGS[test_type]
win_attr = cfg["window_attr"]
@@ -288,25 +346,26 @@ def _toggle_debug_panel(self, test_type):
win.update_idletasks()
def toggle_screen_debug_panel(self):
def toggle_screen_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "screen_module")
def toggle_sdr_debug_panel(self):
def toggle_sdr_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "sdr_movie")
def toggle_hdr_debug_panel(self):
def toggle_hdr_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "hdr_movie")
def update_sidebar_selection(self):
def update_sidebar_selection(self: "PQAutomationApp"):
"""更新侧边栏按钮的选中状态"""
# 重置所有按钮样式为默认
self.screen_module_btn.configure(style="Sidebar.TButton")
self.sdr_movie_btn.configure(style="Sidebar.TButton")
self.hdr_movie_btn.configure(style="Sidebar.TButton")
self.local_dimming_btn.configure(style="Sidebar.TButton")
# 设置当前选中按钮的样式
current_type = self.test_type_var.get()
@@ -316,3 +375,20 @@ def update_sidebar_selection(self):
self.sdr_movie_btn.configure(style="SidebarSelected.TButton")
elif current_type == "hdr_movie":
self.hdr_movie_btn.configure(style="SidebarSelected.TButton")
elif current_type == "local_dimming":
self.local_dimming_btn.configure(style="SidebarSelected.TButton")
class SidePanelsMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_log_panel = create_log_panel
create_local_dimming_panel = create_local_dimming_panel
toggle_local_dimming_panel = toggle_local_dimming_panel
toggle_log_panel = toggle_log_panel
_toggle_debug_panel = _toggle_debug_panel
toggle_screen_debug_panel = toggle_screen_debug_panel
toggle_sdr_debug_panel = toggle_sdr_debug_panel
toggle_hdr_debug_panel = toggle_hdr_debug_panel
update_sidebar_selection = update_sidebar_selection

View File

@@ -13,7 +13,16 @@ from tkinter import filedialog, messagebox
import ttkbootstrap as ttk
from PIL import Image
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
from app.views.modern_styles import apply_listbox_theme
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
_DEFAULT_SAMPLES = [
@@ -26,7 +35,7 @@ _DEFAULT_SAMPLES = [
]
def create_single_step_panel(self):
def create_single_step_panel(self: "PQAutomationApp"):
"""创建单步调试面板。"""
frame = ttk.Frame(self.content_frame)
self.single_step_frame = frame
@@ -52,7 +61,7 @@ def create_single_step_panel(self):
ttk.Label(
title_row,
text="录入目标色块,发送纯色色块并采集 xyY / ΔE2000。",
foreground="#666",
style="Muted.TLabel",
).pack(side=tk.LEFT, padx=(12, 0))
left = ttk.LabelFrame(root, text="样本列表", padding=8)
@@ -66,11 +75,8 @@ def create_single_step_panel(self):
activestyle="none",
font=("微软雅黑", 9),
highlightthickness=1,
highlightbackground="#d8d8d8",
highlightcolor="#4a90e2",
selectbackground="#2b6cb0",
selectforeground="#ffffff",
)
apply_listbox_theme(self.single_step_listbox)
self.single_step_listbox.pack(fill=tk.BOTH, expand=True)
self.single_step_listbox.bind(
"<<ListboxSelect>>", lambda e: _on_sample_select(self)
@@ -147,7 +153,7 @@ def create_single_step_panel(self):
ttk.Label(
form_frame,
textvariable=self.single_step_status_var,
foreground="#666",
style="Muted.TLabel",
).grid(row=2, column=2, columnspan=4, sticky=tk.W, pady=4)
action_row = ttk.Frame(form_frame)
@@ -245,12 +251,12 @@ def create_single_step_panel(self):
_load_default_samples(self)
def toggle_single_step_panel(self):
def toggle_single_step_panel(self: "PQAutomationApp"):
"""切换单步调试面板。"""
self.show_panel("single_step")
def _load_default_samples(self):
def _load_default_samples(self: "PQAutomationApp"):
self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES]
_refresh_sample_list(self, select_index=0 if self.single_step_samples else None)
self.single_step_status_var.set(
@@ -258,7 +264,7 @@ def _load_default_samples(self):
)
def _refresh_sample_list(self, select_index=None):
def _refresh_sample_list(self: "PQAutomationApp", select_index=None):
self.single_step_listbox.delete(0, tk.END)
for sample in self.single_step_samples:
self.single_step_listbox.insert(
@@ -279,14 +285,14 @@ def _refresh_sample_list(self, select_index=None):
self.single_step_status_var.set("样本列表为空")
def _on_sample_select(self):
def _on_sample_select(self: "PQAutomationApp"):
selection = self.single_step_listbox.curselection()
if not selection:
return
_select_sample(self, selection[0])
def _select_sample(self, index):
def _select_sample(self: "PQAutomationApp", index):
sample = self.single_step_samples[index]
self.single_step_current_index = index
self.single_step_name_var.set(sample["name"])
@@ -296,7 +302,7 @@ def _select_sample(self, index):
self.single_step_status_var.set(f"当前样本: {sample['name']}")
def _import_samples_csv(self):
def _import_samples_csv(self: "PQAutomationApp"):
path = filedialog.askopenfilename(
title="选择单步调试样本 CSV",
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")],
@@ -333,7 +339,7 @@ def _import_samples_csv(self):
self.log_gui.log(f"单步调试样本已导入: {len(samples)}", level="success")
def _delete_current_sample(self):
def _delete_current_sample(self: "PQAutomationApp"):
if self.single_step_current_index is None:
return
removed = self.single_step_samples.pop(self.single_step_current_index)
@@ -342,7 +348,7 @@ def _delete_current_sample(self):
self.single_step_status_var.set(f"已删除样本: {removed['name']}")
def _upsert_sample(self):
def _upsert_sample(self: "PQAutomationApp"):
try:
sample = {
"name": self.single_step_name_var.get().strip(),
@@ -386,10 +392,10 @@ def _format_float(value):
return f"{number:.4f}"
def _build_color_patch(self, hex_value):
if not getattr(self, "ucd", None) or not self.ucd.status:
def _build_color_patch(self: "PQAutomationApp", hex_value):
if not self.signal_service.is_connected:
raise RuntimeError("请先连接 UCD323 设备")
width, height = get_current_resolution(self.ucd)
width, height = self.signal_service.current_resolution()
rgb = tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5))
temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches")
os.makedirs(temp_dir, exist_ok=True)
@@ -400,7 +406,7 @@ def _build_color_patch(self, hex_value):
return file_path
def _send_current_patch(self):
def _send_current_patch(self: "PQAutomationApp"):
if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本")
return
@@ -409,9 +415,7 @@ def _send_current_patch(self):
def worker():
try:
image_path = _build_color_patch(self, sample["hex"])
ok = send_image_pattern(self.ucd, image_path)
if not ok:
raise RuntimeError("UCD323 发送失败")
self.signal_service.send_image(image_path)
self.single_step_current_image_path = image_path
self._dispatch_ui(
self.single_step_status_var.set,
@@ -429,7 +433,7 @@ def _send_current_patch(self):
threading.Thread(target=worker, daemon=True).start()
def _measure_current_sample(self):
def _measure_current_sample(self: "PQAutomationApp"):
if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本")
return
@@ -439,7 +443,7 @@ def _measure_current_sample(self):
def worker():
try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
if lv is None:
raise RuntimeError("CA410 未返回有效亮度")
self._dispatch_ui(self.single_step_measured_x_var.set, f"{x:.4f}")
@@ -458,7 +462,7 @@ def _measure_current_sample(self):
threading.Thread(target=worker, daemon=True).start()
def _commit_result(self):
def _commit_result(self: "PQAutomationApp"):
if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本")
return
@@ -510,14 +514,14 @@ def _commit_result(self):
self.single_step_status_var.set(f"已记录结果ΔE2000={record['delta_e']}")
def _clear_results(self):
def _clear_results(self: "PQAutomationApp"):
self.single_step_results = []
for item in self.single_step_result_tree.get_children():
self.single_step_result_tree.delete(item)
self.single_step_status_var.set("结果已清空")
def _export_results_csv(self):
def _export_results_csv(self: "PQAutomationApp"):
if not self.single_step_results:
messagebox.showinfo("提示", "暂无可导出的调试结果")
return
@@ -548,4 +552,32 @@ def _export_results_csv(self):
self.log_gui.log(f"单步调试结果已导出: {path}", level="success")
self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}")
except Exception as exc:
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
def refresh_single_step_theme(self: "PQAutomationApp"):
"""刷新单步调试中 tk.Listbox 的主题色。"""
if hasattr(self, "single_step_listbox"):
apply_listbox_theme(self.single_step_listbox)
class SingleStepPanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_single_step_panel = create_single_step_panel
toggle_single_step_panel = toggle_single_step_panel
_load_default_samples = _load_default_samples
_refresh_sample_list = _refresh_sample_list
_on_sample_select = _on_sample_select
_select_sample = _select_sample
_import_samples_csv = _import_samples_csv
_delete_current_sample = _delete_current_sample
_upsert_sample = _upsert_sample
_build_color_patch = _build_color_patch
_send_current_patch = _send_current_patch
_measure_current_sample = _measure_current_sample
_commit_result = _commit_result
_clear_results = _clear_results
_export_results_csv = _export_results_csv
refresh_single_step_theme = refresh_single_step_theme

View File

@@ -10,6 +10,18 @@ import threading
import time
def _theme_colors():
style = ttk.Style()
colors = style.colors
return {
"fg": colors.fg,
"muted": colors.secondary,
"info": colors.info,
"warning": colors.warning,
"error": colors.danger,
}
class PQDebugPanel:
"""PQ 单步调试面板 - 支持 Gamma/EOTF/色准单步测试"""
@@ -72,7 +84,7 @@ class PQDebugPanel:
self.screen_gamma_frame,
text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# 灰阶选择
@@ -137,7 +149,7 @@ class PQDebugPanel:
self.screen_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择
@@ -210,7 +222,7 @@ class PQDebugPanel:
self.sdr_gamma_frame,
text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.sdr_gamma_frame, text="选择灰阶:").grid(
@@ -272,7 +284,7 @@ class PQDebugPanel:
self.sdr_accuracy_frame,
text="测试完成后可用,选择色块进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.sdr_accuracy_frame, text="选择色块:").grid(
@@ -334,7 +346,7 @@ class PQDebugPanel:
self.sdr_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择
@@ -407,7 +419,7 @@ class PQDebugPanel:
self.hdr_eotf_frame,
text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.hdr_eotf_frame, text="选择灰阶:").grid(
@@ -469,7 +481,7 @@ class PQDebugPanel:
self.hdr_accuracy_frame,
text="测试完成后可用,选择色块进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.hdr_accuracy_frame, text="选择色块:").grid(
@@ -531,7 +543,7 @@ class PQDebugPanel:
self.hdr_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择
@@ -790,7 +802,7 @@ class PQDebugPanel:
time.sleep(1.5)
# 测量数据
x, y, lv, X, Y, Z = self.app.ca.readAllDisplay()
x, y, lv, X, Y, Z = self.app.read_ca_xyLv()
self.app.log_gui.log(
f"测量完成: x={x:.4f}, y={y:.4f}, lv={lv:.2f}, "
@@ -1007,12 +1019,13 @@ class PQDebugPanel:
)
# 设置标签样式
tree.tag_configure("header", background="#E3F2FD", font=("SimHei", 9, "bold"))
tree.tag_configure("normal", foreground="black")
tree.tag_configure("warning", foreground="red")
tree.tag_configure("highlight", foreground="blue", font=("SimHei", 9, "bold"))
palette = _theme_colors()
tree.tag_configure("header", background=palette["info"], font=("SimHei", 9, "bold"))
tree.tag_configure("normal", foreground=palette["fg"])
tree.tag_configure("warning", foreground=palette["warning"])
tree.tag_configure("highlight", foreground=palette["info"], font=("SimHei", 9, "bold"))
tree.tag_configure(
"highlight_warning", foreground="red", font=("SimHei", 9, "bold")
"highlight_warning", foreground=palette["warning"], font=("SimHei", 9, "bold")
)
def _disable_test_button(self, test_type, test_item):

View File

@@ -17,6 +17,71 @@ _GUI_LEVEL_TO_LOG = {
}
def _theme_colors():
style = ttk.Style()
colors = style.colors
return {
"bg": colors.bg,
"fg": colors.fg,
"muted": colors.secondary,
"accent": colors.info,
"warning": colors.warning,
"error": colors.danger,
"success": colors.success,
"text_bg": colors.inputbg,
"text_fg": colors.inputfg,
}
def _normalize_hex(hex_color: str, fallback: str = "#808080") -> str:
"""把颜色字符串规范化为 #RRGGBB非法输入回退到 fallback。"""
if not isinstance(hex_color, str):
return fallback
c = hex_color.strip()
if not c:
return fallback
if c.startswith("#"):
c = c[1:]
if len(c) == 3:
c = "".join(ch * 2 for ch in c)
if len(c) != 6:
return fallback
try:
int(c, 16)
except ValueError:
return fallback
return f"#{c}"
def _hex_to_rgb(hex_color: str):
c = _normalize_hex(hex_color, fallback="#808080").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:
ratio = max(0.0, min(1.0, ratio))
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_color: str) -> bool:
r, g, b = _hex_to_rgb(hex_color)
return (r * 299 + g * 587 + b * 114) / 1000 < 128
def _auto_text_color(bg_hex: str, fg_hint: str) -> str:
"""根据背景亮度给出稳定可读的文本颜色。"""
bg_hex = _normalize_hex(bg_hex, fallback="#f5f5f5")
fg_hint = _normalize_hex(fg_hint, fallback="#202020")
if _is_dark(bg_hex):
return _mix("#ffffff", fg_hint, 0.25)
return _mix("#000000", fg_hint, 0.25)
class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
@@ -53,21 +118,22 @@ class PQLogGUI(ttk.Frame):
text_container = ttk.Frame(log_frame)
text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
palette = _theme_colors()
self.log_text = tk.Text(
text_container,
height=10,
width=50,
wrap=tk.WORD,
font=("Consolas", 10),
bg="#fbfcfe",
fg="#1f2937",
bg=palette["text_bg"],
fg=palette["text_fg"],
relief=tk.FLAT,
bd=0,
padx=10,
pady=8,
spacing1=2,
spacing3=2,
insertbackground="#1f2937",
insertbackground=palette["text_fg"],
)
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
@@ -114,21 +180,45 @@ class PQLogGUI(ttk.Frame):
self._update_summary()
def _configure_tags(self):
self.log_text.tag_configure("timestamp", foreground="#6b7280")
self.log_text.tag_configure("level_info", foreground="#2563eb")
self.log_text.tag_configure("level_success", foreground="#0f766e")
self.log_text.tag_configure("level_warning", foreground="#b45309")
self.log_text.tag_configure("level_error", foreground="#b91c1c")
self.log_text.tag_configure("level_debug", foreground="#7c3aed")
self.log_text.tag_configure("message", foreground="#1f2937")
self.log_text.tag_configure("message_success", foreground="#0f766e")
self.log_text.tag_configure("message_warning", foreground="#b45309")
self.log_text.tag_configure("message_error", foreground="#991b1b")
self.log_text.tag_configure("message_debug", foreground="#6d28d9")
self.log_text.tag_configure("separator", foreground="#94a3b8")
self.log_text.tag_configure("traceback", foreground="#7f1d1d")
palette = _theme_colors()
bg = self.log_text.cget("bg") or palette["text_bg"] or palette["bg"]
base_fg = _auto_text_color(bg, palette["fg"])
muted_fg = _mix(base_fg, bg, 0.45)
debug_level_color = _mix(palette["accent"], base_fg, 0.35)
debug_msg_color = _mix(palette["accent"], base_fg, 0.50)
self.log_text.tag_configure("timestamp", foreground=palette["muted"])
self.log_text.tag_configure("level_info", foreground=palette["accent"])
self.log_text.tag_configure("level_success", foreground=palette["success"])
self.log_text.tag_configure("level_warning", foreground=palette["warning"])
self.log_text.tag_configure("level_error", foreground=palette["error"])
self.log_text.tag_configure("level_debug", foreground=debug_level_color)
self.log_text.tag_configure("message", foreground=base_fg)
self.log_text.tag_configure("message_success", foreground=palette["success"])
self.log_text.tag_configure("message_warning", foreground=palette["warning"])
self.log_text.tag_configure("message_error", foreground=palette["error"])
self.log_text.tag_configure("message_debug", foreground=debug_msg_color)
self.log_text.tag_configure("separator", foreground=muted_fg)
self.log_text.tag_configure("traceback", foreground=palette["error"])
self.log_text.tag_configure("blank", spacing1=4, spacing3=4)
def refresh_log_theme(self):
"""主题切换后刷新日志控件的背景和字体颜色。"""
if threading.current_thread() is not threading.main_thread():
self.after(0, self.refresh_log_theme)
return
palette = _theme_colors()
bg = palette["text_bg"]
fg_hint = palette["text_fg"] if palette["text_fg"] else palette["fg"]
fg = _auto_text_color(bg, fg_hint)
self.log_text.configure(
bg=bg,
fg=fg,
insertbackground=fg,
)
self._configure_tags()
def _append_message(self, message, level):
lines = message.splitlines() or [""]
for line in lines:

172
app/views/theme_manager.py Normal file
View File

@@ -0,0 +1,172 @@
"""主题管理:注册 Calman 风格深色主题 + 提供运行时切换。
主题在启动时通过 ``apply_initial_theme(root_style)`` 注入到 ttkbootstrap
当前选择持久化到 ``settings/ui_preferences.json``。运行时调用
``toggle_theme(root_style)`` / ``set_theme(root_style, name)`` 可即时切换,
并自动重新调用 ``apply_modern_styles()`` 让自定义样式跟上新色板。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Optional
from ttkbootstrap.style import Style, ThemeDefinition
from app.views.modern_styles import apply_modern_styles
_PREFS_PATH = Path("settings/ui_preferences.json")
# 浅色主题:自定义轻量蓝灰色板,恢复旧版浅色观感
LIGHT_THEME = "calman_light"
# 深色主题:自定义 Calman 风格
DARK_THEME = "calman_dark"
_LEGACY_LIGHT_THEMES = {"yeti"}
_CALMAN_LIGHT_COLORS = {
"primary": "#1755a6",
"secondary": "#2B6CB0",
"success": "#2F9E44",
"info": "#247BA0",
"warning": "#C98700",
"danger": "#CC3300",
"light": "#F7FAFC",
"dark": "#1F2A36",
"bg": "#F5F8FB",
"fg": "#1F2933",
"selectbg": "#2B6CB0",
"selectfg": "#FFFFFF",
"border": "#C8D4E3",
"inputfg": "#243240",
"inputbg": "#FFFFFF",
"active": "#D9E6F2",
}
# ----------------------------------------------------------------------
# Calman 风格深色主题色板
# ----------------------------------------------------------------------
_CALMAN_DARK_COLORS = {
# "primary": "#2A2F36",
# "secondary": "#444A51",
"primary": "#6FAFCC",
"secondary": "#AEAEAE",
"success": "#4FB960",
"info": "#6FAFCC",
"warning": "#F2A93B",
"danger": "#E0524A",
"light": "#BFC6CE", # 高亮文本
"dark": "#0D1014", # 最深背景(侧栏底色)
"bg": "#1B1F24", # 主窗口背景
"fg": "#E4E8EE", # 主文本颜色
"selectbg": "#5A6169",
"selectfg": "#E4E8EE",
"border": "#2A2F36",
"inputfg": "#E4E8EE",
"inputbg": "#24292F",
"active": "#2A2F36",
}
def register_themes() -> None:
"""把自定义深色主题注册到 ttkbootstrap可重复调用幂等"""
style = Style()
if LIGHT_THEME not in style.theme_names():
light_def = ThemeDefinition(
name=LIGHT_THEME,
themetype="light",
colors=_CALMAN_LIGHT_COLORS,
)
style.register_theme(light_def)
if DARK_THEME in style.theme_names():
return
dark_def = ThemeDefinition(
name=DARK_THEME,
themetype="dark",
colors=_CALMAN_DARK_COLORS,
)
style.register_theme(dark_def)
def _normalize_theme_name(name: Optional[str]) -> str:
if not name or name in _LEGACY_LIGHT_THEMES:
return LIGHT_THEME
return name
# ----------------------------------------------------------------------
# 偏好持久化
# ----------------------------------------------------------------------
def _read_prefs() -> dict:
try:
return json.loads(_PREFS_PATH.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError):
return {}
def _write_prefs(data: dict) -> None:
try:
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
_PREFS_PATH.write_text(
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
)
except OSError:
# 写入失败不应影响 UI
pass
def get_saved_theme() -> Optional[str]:
return _read_prefs().get("theme")
def save_theme(name: str) -> None:
prefs = _read_prefs()
prefs["theme"] = name
_write_prefs(prefs)
# ----------------------------------------------------------------------
# 主题应用 / 切换
# ----------------------------------------------------------------------
def apply_initial_theme() -> str:
"""启动时调用:注册主题 + 加载偏好 + 切到对应主题。
返回最终生效的主题名。
"""
register_themes()
name = _normalize_theme_name(get_saved_theme())
style = Style()
if name not in style.theme_names():
name = LIGHT_THEME
style.theme_use(name)
apply_modern_styles()
return name
def set_theme(name: str) -> str:
"""切换到指定主题,持久化偏好,并刷新自定义样式。"""
register_themes()
name = _normalize_theme_name(name)
style = Style()
if name not in style.theme_names():
name = LIGHT_THEME
style.theme_use(name)
apply_modern_styles()
save_theme(name)
return name
def toggle_theme() -> str:
"""在浅 / 深之间切换。返回新主题名。"""
style = Style()
current = style.theme.name
target = DARK_THEME if current != DARK_THEME else LIGHT_THEME
return set_theme(target)
def is_dark() -> bool:
return Style().theme.name == DARK_THEME

131
cache/pq_ai_api_v21_extracted.txt vendored Normal file
View File

@@ -0,0 +1,131 @@
===== PAGE 1 =====
接口文档
域名地址
测试环境:
http://10.201.44.70:9008/ai-agent/
生产环境:
https://r d-mokadisplay .tcl.com/ai-agent/
一、上传图片接口:
1. 接口明细
接口路径api/v1/pqt est/uplo ad
Cont ent-Typemultip art/form-data
请求方式POST
2. 请求参数说明
参数名 参数类型 是否必填 参数描述
file File二进制文件是待上传图片的二进制文件。请求体格式必须为multipart/form-data ,且包含一个名
为file 的文件字段(如:@"D:\Desktop\PQtest\3-O.png"
备注:仅支持 PNG/JP G/JPEG 格式图片,大小不超过 10MB分辨率最大为 4096×4096 p x
3. 请求及响应示例
上传图片请求示例
curl -X POST "https://rd-mokadisplay.tcl.com/ai-agent/api/v1/pqtest/upload" \
-F "file=@D:\Desktop\PQtest\3-O.png"1 / 5
===== PAGE 2 =====
上传图片响应示例
{
"code": 200,
"message": "",
"data": {
"upload_image_url": "https://ai.file.qhmoka.com/prod-ai-portal/pq-
image/input/2026-05-28/a2264f477d4d487493058306701cdb44/3-O.png"
}
}
报错响应示例
{
"code":400,
"message":"不支持的图片格式,仅支持 PNG/JPG/JPEG",
"data":{"upload_image_url": ""}
}
二、生图接口
1. 接口明细
接口路径api/v1/pqt est/generat e
Cont ent-Typeapplication/json
请求方式POST
2. 请求参数说明
参数名 参数类型是否必填 参数描述
user_message String 是 用户的自然语言需求/指令文本,作为本次生成 PQ 测试图的输入内容2 / 5
===== PAGE 3 =====
参数名 参数类型是否必填 参数描述
session_id String 是会话标识,用于把多次请求归到同一会话(便于复用/关联上下文)。在同一会话窗口
下请使用同一session_id。使用 uuid 生成唯一字符串
upload_image_urlString 否 上传的参考图片地址,来自上传接口返回的 URL
备注1相较于上一版本本版本新增了 uplo ad_image _url 字段。未传入时,默认为“文生图”模
式(与上版本一致);传入时,则启用“图生图”模式。
备注2在多轮对话的“图生图”模式下需将上一轮对话返回的 imageUrl 作为第二轮对话的
upload_image _url 传入。
3. 请求及响应示例
单轮对话生图请求示例
{
"user_message":"请复刻这张图,生成标准 PQ 测试图 ",
"session_id":"48f1a351-67f2-40db-a57d-20d66249bc93",
"upload_image_url":"https://test.file.qhmoka.com/test-ai-portal/pq-
image/input/2026-05-21/681136ecf35549bcb7969905fb728991/05213.png"
}
单轮对话生图响应示例
{
"code":200,
"message":"",
"data":{"imageUrl":"https://test.file.qhmoka.com/test-ai-portal/pq-
image/2026-05-22/19/05be786be62eb9aa.png"}
}3 / 5
===== PAGE 4 =====
多轮对话生图请求示例
# 第一轮请求
{
"user_message":"请复刻这张图,生成标准 PQ 测试图 ",
"session_id":"48f1a351-67f2-40db-a57d-20d66249bc93",
"upload_image_url":"https://test.file.qhmoka.com/test-ai-portal/pq-
image/input/2026-05-21/681136ecf35549bcb7969905fb728991/05213.png"
}
# 第二轮请求
{
"user_message":"把图片上部分的白和红圆圈对换位置,把图片下部分蓝和绿圆圈对换位置,同时把
背景颜色换为黄色 ",
"session_id":"48f1a351-67f2-40db-a57d-20d66249bc93", # 确保两轮 session_id
相同
"upload_image_url":"https://test.file.qhmoka.com/test-ai-portal/pq-
image/2026-05-22/19/05be786be62eb9aa.png" # 这里是第一轮响应返回的 imageUrl
}
多轮对话生图响应示例
# 第一轮响应
{
"code":200,
"message":"",
"data":{"imageUrl":"https://test.file.qhmoka.com/test-ai-portal/pq-
image/2026-05-22/19/05be786be62eb9aa.png"}
}
# 第二轮响应
{
"code":200,
"message":"",
"data":{"imageUrl":"https://test.file.qhmoka.com/test-ai-portal/pq-
image/2026-05-22/19/703f0896ffcc8c57.png"}
}
报错响应示例
# 大模型调用超时超过120s
{
"code":500,
"message":"生成失败 ",4 / 5
===== PAGE 5 =====
"data":{"imageUrl":""}
}
三、关于多轮对话(图片修改)的图片传参逻辑补充说明
核心规则
从第二轮请求开始,每一轮都必须把"最近一次成功返回的图片"作为本轮请求的输入图片传入。
详细说明
1.首轮请求
•用户可传图、也可不传图(无强制要求)。
2.后续每一轮请求(第 2 轮及以后)
•必须带上 上一轮成功返回的图片 作为输入。
•与首轮用户是否传过图无关,只要是后续轮次就要带。
3.异常情况处理
•如果上一轮请求失败 / 没有返回图片,则向前回溯,使用 最近一次成功返回图片的那一
轮的结果作为本轮输入。
•简单说:始终使用"最近一张成功生成的图片"。5 / 5

17
cache/pq_ai_api_v21_summary.txt vendored Normal file
View File

@@ -0,0 +1,17 @@
[upload] 示例 上传图片请求示例 curl -X POST "https://rd-mokadisplay.tcl.com/ai-agent/api/v1/pqtest/upload" \ -F "file=@D:\Desktop\PQtest\3-O.png"1 / 5 ===== PAGE 2 ===== 上传图片响应示例 { "code": 200, "message": "", "data": { "upload_image_url": "https://a
[session_id] 是 用户的自然语言需求/指令文本,作为本次生成 PQ 测试图的输入内容2 / 5 ===== PAGE 3 ===== 参数名 参数类型是否必填 参数描述 session_id String 是会话标识,用于把多次请求归到同一会话(便于复用/关联上下文)。在同一会话窗口 下请使用同一session_id。使用 uuid 生成唯一字符串 upload_image_urlString 否 上传的参考图片地址,来自上传接口返回的 URL 备注1相较于上一版本本版本新增了 uplo ad_ima
[upload_image_url] === PAGE 2 ===== 上传图片响应示例 { "code": 200, "message": "", "data": { "upload_image_url": "https://ai.file.qhmoka.com/prod-ai-portal/pq- image/input/2026-05-28/a2264f477d4d487493058306701cdb44/3-O.png" } } 报错响应示例 { "code":400, "message":
[imageUrl] _url 字段。未传入时,默认为“文生图”模 式(与上版本一致);传入时,则启用“图生图”模式。 备注2在多轮对话的“图生图”模式下需将上一轮对话返回的 imageUrl 作为第二轮对话的 upload_image _url 传入。 3. 请求及响应示例 单轮对话生图请求示例 { "user_message":"请复刻这张图,生成标准 PQ 测试图 ", "session_id":"48f1a351-67f2-40db-a57d-20d66249bc93",
[请求示例] png" 备注:仅支持 PNG/JP G/JPEG 格式图片,大小不超过 10MB分辨率最大为 4096×4096 p x 3. 请求及响应示例 上传图片请求示例 curl -X POST "https://rd-mokadisplay.tcl.com/ai-agent/api/v1/pqtest/upload" \ -F "file=@D:\Desktop\PQtest\3-O.png"1 / 5 ===== PAGE 2 ===== 上传图片响应示例 { "
[响应示例] test\3-O.png" 备注:仅支持 PNG/JP G/JPEG 格式图片,大小不超过 10MB分辨率最大为 4096×4096 p x 3. 请求及响应示例 上传图片请求示例 curl -X POST "https://rd-mokadisplay.tcl.com/ai-agent/api/v1/pqtest/upload" \ -F "file=@D:\Desktop\PQtest\3-O.png"1 / 5 ===== PAGE 2 ===== 上传图片响
[code] -F "file=@D:\Desktop\PQtest\3-O.png"1 / 5 ===== PAGE 2 ===== 上传图片响应示例 { "code": 200, "message": "", "data": { "upload_image_url": "https://ai.file.qhmoka.com/prod-ai-portal/pq- image/input/2026-05-28/a2264f477d4d487493058306701cd
[message] esktop\PQtest\3-O.png"1 / 5 ===== PAGE 2 ===== 上传图片响应示例 { "code": 200, "message": "", "data": { "upload_image_url": "https://ai.file.qhmoka.com/prod-ai-portal/pq- image/input/2026-05-28/a2264f477d4d487493058306701cdb44/3-O.png" } }
[data] nt/ 一、上传图片接口: 1. 接口明细 接口路径api/v1/pqt est/uplo ad Cont ent-Typemultip art/form-data 请求方式POST 2. 请求参数说明 参数名 参数类型 是否必填 参数描述 file File二进制文件是待上传图片的二进制文件。请求体格式必须为multipart/form-data ,且包含一个名 为file 的文件字段(如:@"D:\Desktop\PQtest\3-O.png" 备注:仅支持 PNG

Binary file not shown.

Binary file not shown.

View File

@@ -110,7 +110,16 @@ class UCDEnum:
}
if not colorimetry_str:
return None
return colorimetry_map.get(colorimetry_str.lower(), None)
# Normalize: strip hyphens, spaces, dots, underscores so that
# "DCI-P3" → "dcip3", "BT.709" → "bt709", "BT.2020 YCbCr" → "bt2020ycbcr"
normalized = (
colorimetry_str.lower()
.replace("-", "")
.replace(" ", "")
.replace(".", "")
.replace("_", "")
)
return colorimetry_map.get(normalized, colorimetry_map.get(colorimetry_str.lower(), None))
class VideoPatternInfo:
class VideoPattern(IntEnum):

View File

@@ -1,10 +1,14 @@
# -*- coding: UTF-8 -*-
import logging
import UniTAP
import time
import gc
from drivers.UCD323_Enum import UCDEnum
log = logging.getLogger(__name__)
class UCDController:
"""UCD323信号发生器控制类"""
@@ -23,6 +27,7 @@ class UCDController:
self.current_pattern_param = None
self.current_pattern_params = None
self.current_pattern_index = 0
self.last_error = None
def search_device(self):
"""搜索可用设备"""
@@ -140,6 +145,7 @@ class UCDController:
raise RuntimeError("UCD 未打开,无法获取 TX 模块")
interface = getattr(self, "current_interface", None)
log.info("UCDController.get_tx_modules interface=%s", interface)
if interface in (None, "HDMI"):
return self.role.hdtx.pg, self.role.hdtx.ag
if interface in ("DP", "Type-C"):
@@ -189,6 +195,7 @@ class UCDController:
def set_ucd_params(self, config):
"""设置UCD323参数"""
self.last_error = None
self.config = config
test_type = self.config.current_test_type
@@ -200,16 +207,34 @@ class UCDController:
colorimetry = self.config.current_test_types[test_type]["colorimetry"]
if not self.set_color_mode(color_format, bpc, colorimetry):
self.last_error = (
f"set_color_mode failed: color_format={color_format}, bpc={bpc}, colorimetry={colorimetry}"
)
log.error(
"UCDController.set_ucd_params set_color_mode failed test_type=%s color_format=%s bpc=%s colorimetry=%s",
test_type,
color_format,
bpc,
colorimetry,
)
return False
timing_str = self.config.current_test_types[test_type]["timing"]
self.set_timing_from_string(timing_str)
if not self.set_timing_from_string(timing_str):
self.last_error = f"set_timing_from_string failed: timing={timing_str}"
log.error(
"UCDController.set_ucd_params set_timing_from_string failed test_type=%s timing=%s",
test_type,
timing_str,
)
return False
self.current_pattern_index = 0
pattern_mode = self.config.current_pattern["pattern_mode"]
pattern = UCDEnum.VideoPatternInfo.get_video_pattern(pattern_mode)
if pattern is None:
self.last_error = f"get_video_pattern failed: pattern_mode={pattern_mode}"
return False
self.current_pattern = pattern
@@ -219,10 +244,17 @@ class UCDController:
def run(self):
"""运行设备"""
log.info(
"UCDController.run start current_pattern=%s has_pattern_param=%s",
getattr(self.current_pattern, "name", self.current_pattern),
self.current_pattern_param is not None,
)
self.apply_video_mode()
self.apply_pattern()
pg, _ = self.get_tx_modules()
log.info("UCDController.run calling pg.apply()")
pg.apply()
log.info("UCDController.run done")
return True
def send_image_pattern(self, image_path):
@@ -245,12 +277,15 @@ class UCDController:
return False
try:
log.info("UCDController.send_solid_rgb_pattern rgb=%s", rgb)
self.current_pattern = UCDEnum.VideoPatternInfo.get_video_pattern("solidcolor")
if self.current_pattern is None:
log.error("UCDController.send_solid_rgb_pattern failed: solidcolor pattern not found")
return False
return self.send_current_pattern_params(list(rgb))
except Exception:
log.exception("UCDController.send_solid_rgb_pattern exception")
return False
def send_current_pattern_params(self, pattern_params):
@@ -260,17 +295,27 @@ class UCDController:
try:
if self.current_pattern is None:
log.error("UCDController.send_current_pattern_params failed: current_pattern is None")
return False
log.info(
"UCDController.send_current_pattern_params pattern=%s params=%s",
getattr(self.current_pattern, "name", self.current_pattern),
pattern_params,
)
if pattern_params is not None and not self.set_pattern(
self.current_pattern,
pattern_params,
):
log.error("UCDController.send_current_pattern_params failed: set_pattern returned False")
return False
log.info("UCDController.send_current_pattern_params calling run()")
self.run()
log.info("UCDController.send_current_pattern_params done")
return True
except Exception:
log.exception("UCDController.send_current_pattern_params exception")
return False
def set_color_mode(self, cf, bpc, cm):
@@ -298,8 +343,11 @@ class UCDController:
def apply_video_mode(self):
"""应用当前 color_info 和 timing"""
if self.current_timing:
log.info("UCDController.apply_video_mode start timing=%s", self.current_timing)
self.set_video_mode()
log.info("UCDController.apply_video_mode done")
return True
log.warning("UCDController.apply_video_mode skipped: current_timing is None")
return False
def set_video_mode(self):
@@ -313,28 +361,48 @@ class UCDController:
self.color_info.bpc,
)
self.format_changed = (current_config != getattr(self, "_last_sent_config", None))
log.info(
"UCDController.set_video_mode format_changed=%s color_format=%s colorimetry=%s dynamic_range=%s bpc=%s",
self.format_changed,
self.color_info.color_format,
self.color_info.colorimetry,
self.color_info.dynamic_range,
self.color_info.bpc,
)
video_mode = UniTAP.VideoMode(
timing=self.current_timing, color_info=self.color_info
)
pg, _ = self.get_tx_modules()
log.info("UCDController.set_video_mode calling pg.set_vm()")
pg.set_vm(vm=video_mode)
log.info("UCDController.set_video_mode done")
self._last_sent_config = current_config
return True
def set_pattern(self, pattern, pattern_params=None):
"""设置pattern"""
if self.current_timing:
needs_params = {
UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor,
UCDEnum.VideoPatternInfo.VideoPatternParams.WhiteVStrips,
UCDEnum.VideoPatternInfo.VideoPatternParams.GradientRGBStripes,
UCDEnum.VideoPatternInfo.VideoPatternParams.MotionPattern,
UCDEnum.VideoPatternInfo.VideoPatternParams.SquareWindow,
}
if pattern in needs_params and pattern_params:
self.set_pattern_params(pattern, pattern_params)
return True
return False
if self.current_timing is None:
# Pattern-only updates (e.g. Calman patch click) can still be applied on
# an already active output mode. Missing timing should not block pattern staging.
log.warning("UCDController.set_pattern current_timing is None; continue with pattern-only apply")
needs_params = {
UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor,
UCDEnum.VideoPatternInfo.VideoPattern.SolidColor,
UCDEnum.VideoPatternInfo.VideoPatternParams.WhiteVStrips,
UCDEnum.VideoPatternInfo.VideoPatternParams.GradientRGBStripes,
UCDEnum.VideoPatternInfo.VideoPatternParams.MotionPattern,
UCDEnum.VideoPatternInfo.VideoPatternParams.SquareWindow,
}
log.info(
"UCDController.set_pattern pattern=%s pattern_params=%s needs_params=%s",
getattr(pattern, "name", pattern),
pattern_params,
pattern in needs_params,
)
if pattern in needs_params and pattern_params is not None:
self.set_pattern_params(pattern, pattern_params)
return True
def set_next_pattern(self):
"""设置下一个pattern"""
@@ -350,25 +418,40 @@ class UCDController:
def set_pattern_params(self, pattern, pattern_params):
"""设置pattern参数"""
if pattern:
if pattern == UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor:
if pattern is not None:
solid_color_patterns = {
UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor,
UCDEnum.VideoPatternInfo.VideoPattern.SolidColor,
}
if pattern in solid_color_patterns:
log.info("UCDController.set_pattern_params solid_color rgb=%s", pattern_params)
self.current_pattern_param = UniTAP.SolidColorParams(
first=pattern_params[0],
second=pattern_params[1],
third=pattern_params[2],
)
return True
log.warning("UCDController.set_pattern_params unsupported pattern=%s", getattr(pattern, "name", pattern))
return False
def apply_pattern(self):
"""应用当前pattern"""
if self.current_pattern:
if self.current_pattern is not None:
log.info(
"UCDController.apply_pattern start pattern=%s has_params=%s",
getattr(self.current_pattern, "name", self.current_pattern),
self.current_pattern_param is not None,
)
pg, _ = self.get_tx_modules()
log.info("UCDController.apply_pattern calling pg.set_pattern()")
pg.set_pattern(self.current_pattern)
if self.current_pattern_param:
if self.current_pattern_param is not None:
log.info("UCDController.apply_pattern calling pg.set_pattern_params()")
pg.set_pattern_params(self.current_pattern_param)
log.info("UCDController.apply_pattern done")
return True
log.warning("UCDController.apply_pattern skipped: current_pattern is None")
return False
def search_timing(self, width, height, refresh_rate, resolution_type=None):
@@ -383,15 +466,30 @@ class UCDController:
elif resolution_type == "cvt":
standard = UniTAP.common.timing.Timing.Standard.SD_CVT
timing = self.timing_manager.search(
h_active=width,
v_active=height,
f_rate=int(refresh_rate) * 1000,
standard=standard,
)
rr = float(refresh_rate)
# Try both exact and NTSC-compatible rates (e.g. 120000 / 119880).
f_rate_candidates = [
int(round(rr * 1000)),
int(rr * 1000),
int(round((rr * 1000.0) * 1000.0 / 1001.0)),
]
# 去重并保持顺序
f_rate_candidates = list(dict.fromkeys(f_rate_candidates))
if timing:
return timing
standards = [standard]
if standard is not None:
standards.append(None)
for std in standards:
for f_rate in f_rate_candidates:
timing = self.timing_manager.search(
h_active=width,
v_active=height,
f_rate=f_rate,
standard=std,
)
if timing:
return timing
else:
for res_type in ["dmt", "cta", "cvt", "ovt"]:
result = self.search_timing(width, height, refresh_rate, res_type)
@@ -492,18 +590,54 @@ class UCDController:
def set_timing_from_string(self, timing_str):
"""根据格式化timing字符串设置设备timing"""
spec = self.parse_formatted_timing(timing_str)
try:
spec = self.parse_formatted_timing(timing_str)
except Exception:
log.exception("UCDController.set_timing_from_string parse failed timing=%s", timing_str)
return False
rtype = spec["resolution_type"]
rid = spec.get("resolution_id")
width = spec["width"]
height = spec["height"]
fr = spec["refresh_rate"]
if rtype != "ovt":
timing = self.search_timing(width, height, fr, rtype)
if timing:
self.current_timing = timing
return True
if rid is not None and self.set_timing_from_id(rtype, rid):
log.info(
"UCDController.set_timing_from_string success by id timing=%s parsed=(%s id=%s)",
timing_str,
rtype,
rid,
)
return True
# Respect selected timing family first (DMT/CTA/CVT/OVT).
timing = self.search_timing(width, height, fr, rtype)
if timing is None:
# Fallback only for robustness: some SDKs may not classify a timing
# exactly as requested family even though width/height/fps matches.
timing = self.search_timing(width, height, fr, None)
if timing:
self.current_timing = timing
log.info(
"UCDController.set_timing_from_string success timing=%s parsed=(%s %sx%s@%s)",
timing_str,
rtype,
width,
height,
fr,
)
return True
log.error(
"UCDController.set_timing_from_string no timing matched timing=%s parsed=(%s %sx%s@%s)",
timing_str,
rtype,
width,
height,
fr,
)
return False
def set_timing_from_id(self, rtype, rid):
@@ -515,6 +649,12 @@ class UCDController:
timing = self.timing_manager.get_cta(rid)
elif rtype.lower() == "cvt":
timing = self.timing_manager.get_cvt(rid)
elif rtype.lower() == "ovt":
get_ovt = getattr(self.timing_manager, "get_ovt", None)
if callable(get_ovt):
timing = get_ovt(rid)
else:
return False
else:
raise ValueError(f"不支持的分辨率类型: {rtype}")
@@ -561,10 +701,6 @@ class UCDController:
except Exception:
return False
# 向后兼容别名
set_sdr_format = apply_signal_format
set_hdr_format = apply_signal_format
def _get_colorimetry_from_color_space(self, color_space, color_format=None):
"""将色彩空间字符串转换为UniTAP.ColorInfo.Colorimetry。
BT.2020 在 YCbCr 输出时使用 CM_ITUR_BT2020_YCbCrRGB 输出时使用 CM_ITUR_BT2020_RGB。

498
drivers/ucd_driver.py Normal file
View File

@@ -0,0 +1,498 @@
"""UCD 驱动层。
唯一暴露给上层的入口为 :class:`IUcdDevice` 抽象接口,以及实现:
:class:`UCD323Device`(生产)和 :class:`FakeUcdDevice`(单测)。
Phase 1 实现策略
-----------------
为保证零行为变更,:class:`UCD323Device` 当前**内部委托**给已有的
:class:`drivers.UCD323_Function.UCDController`。后续 Phase 2 会将
SDK 调用直接搬入本模块,届时可删除旧 ``UCDController`` 文件。
文件分区:
§1 DeviceInfo / list_devices
§2 IUcdDevice 抽象接口
§3 UCD323Device 真实实现
§4 FakeUcdDevice 单测实现
"""
from __future__ import annotations
from contextlib import contextmanager
import logging
import threading
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
from app.ucd_domain import (
ConnectionChanged,
EventBus,
Interface,
PatternApplied,
PatternKind,
PatternSpec,
SignalApplied,
SignalFormat,
TimingSpec,
UcdApplyFailed,
UcdConfigError,
UcdNotConnected,
UcdSdkError,
UcdState,
UcdStateError,
assert_transition,
)
if TYPE_CHECKING:
from drivers.UCD323_Function import UCDController
log = logging.getLogger(__name__)
_DEVICE_LOCK_TIMEOUT_SECONDS = 8.0
# ─── §1 DeviceInfo / list_devices ────────────────────────────────
@dataclass(frozen=True)
class DeviceInfo:
"""UCD 设备发现条目。
``display`` 是 SDK 给出的完整字符串(``"0: UCD-323 #12345678"``
``index`` / ``serial`` / ``model`` 通过解析得到,解析失败时为 None。
"""
display: str
index: int | None = None
serial: str | None = None
model: str | None = None
@classmethod
def parse(cls, display: str) -> "DeviceInfo":
idx: int | None = None
model: str | None = None
serial: str | None = None
try:
head, rest = display.split(":", 1)
idx = int(head.strip())
rest = rest.strip()
# 形如 "UCD-323 #12345678" 或 "UCD-323 #12345678 (in use)"
tokens = rest.split()
if tokens:
model = tokens[0]
for tok in tokens[1:]:
if tok.startswith("#") and len(tok) >= 2:
serial = tok.lstrip("#")
break
except Exception: # noqa: BLE001 - 解析失败保留原 display 即可
pass
return cls(display=display, index=idx, serial=serial, model=model)
def list_devices(controller: "UCDController") -> list[DeviceInfo]:
"""通过给定的底层 controller 枚举可用 UCD 设备。"""
try:
raw_list = controller.search_device()
except Exception as exc: # noqa: BLE001
raise UcdSdkError("枚举 UCD 设备失败") from exc
return [DeviceInfo.parse(s) for s in (raw_list or [])]
# ─── §2 IUcdDevice 抽象接口 ──────────────────────────────────────
class IUcdDevice(ABC):
"""UCD 信号发生器抽象设备。
上层Service / GUI**只**通过本接口操作硬件,不得穿透到
UniTAP SDK 或具体实现细节。
"""
@property
@abstractmethod
def state(self) -> UcdState: ...
@property
@abstractmethod
def info(self) -> DeviceInfo | None: ...
@abstractmethod
def open(self, info: DeviceInfo, *, interface: Interface = Interface.HDMI) -> None:
"""打开设备并选择接口角色。失败抛 :class:`UcdSdkError` 等。"""
@abstractmethod
def close(self) -> None:
"""关闭设备(幂等)。"""
@abstractmethod
def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool:
"""写入信号格式与 timing未 apply。返回 ``format_changed``。"""
@abstractmethod
def set_pattern(self, pattern: PatternSpec) -> None:
"""设置当前图案(未 apply"""
@abstractmethod
def apply(self) -> None:
"""将已配置的信号格式 + 图案一次性提交给硬件。"""
@abstractmethod
def current_resolution(self) -> tuple[int, int]:
"""读取当前 timing 的 (width, height);未连接时返回默认 (3840, 2160)。"""
# ─── §3 UCD323Device 真实实现 ────────────────────────────────────
class UCD323Device(IUcdDevice):
"""生产环境实现。内部委托给传统 :class:`UCDController`Phase 1"""
def __init__(self, bus: EventBus, controller: "UCDController | None" = None):
from drivers.UCD323_Function import UCDController as _UCDController
self._bus = bus
self._controller: "UCDController" = controller or _UCDController()
self._state: UcdState = UcdState.CLOSED
self._info: DeviceInfo | None = None
self._interface: Interface = Interface.HDMI
self._lock = threading.RLock()
self._lock_owner_tid: int | None = None
self._lock_owner_name: str | None = None
self._curr_signal: SignalFormat | None = None
self._curr_timing: TimingSpec | None = None
self._curr_pattern: PatternSpec | None = None
self._last_applied: tuple[SignalFormat, TimingSpec] | None = None
@contextmanager
def _acquire_device_lock(self, op_name: str):
current = threading.current_thread()
log.info(
"UCD323Device.%s acquiring lock timeout=%.1fs tid=%s thread=%s owner_tid=%s owner_thread=%s",
op_name,
_DEVICE_LOCK_TIMEOUT_SECONDS,
threading.get_ident(),
current.name,
self._lock_owner_tid,
self._lock_owner_name,
)
acquired = self._lock.acquire(timeout=_DEVICE_LOCK_TIMEOUT_SECONDS)
if not acquired:
raise UcdStateError(
"UCD device busy: lock timeout in "
f"UCD323Device.{op_name}, "
f"owner_tid={self._lock_owner_tid}, owner_thread={self._lock_owner_name}"
)
prev_owner_tid = self._lock_owner_tid
prev_owner_name = self._lock_owner_name
self._lock_owner_tid = threading.get_ident()
self._lock_owner_name = current.name
log.info(
"UCD323Device.%s lock acquired tid=%s thread=%s",
op_name,
self._lock_owner_tid,
self._lock_owner_name,
)
try:
yield
finally:
self._lock_owner_tid = prev_owner_tid
self._lock_owner_name = prev_owner_name
self._lock.release()
# -- 读访问 --------------------------------------------------
@property
def state(self) -> UcdState:
return self._state
@property
def info(self) -> DeviceInfo | None:
return self._info
@property
def raw_controller(self) -> "UCDController":
"""Phase 1 过渡期:给暂未迁移的旧调用点的逃生通道。
新代码**不**应使用本属性,迁移完成后即可删除。
"""
return self._controller
# -- 生命周期 ------------------------------------------------
def open(self, info: DeviceInfo, *, interface: Interface = Interface.HDMI) -> None:
with self._acquire_device_lock("open"):
assert_transition(self._state, UcdState.OPENED)
if interface is not Interface.HDMI:
# Phase 1底层 UCDController.open() 写死了 HDMISource。
raise UcdConfigError(
f"暂不支持接口 {interface.value};当前仅实现 HDMI"
)
try:
ok = self._controller.open(info.display)
except Exception as exc: # noqa: BLE001
raise UcdSdkError(f"打开设备失败: {info.display}") from exc
if not ok:
raise UcdSdkError(f"打开设备失败: {info.display}")
self._info = info
self._interface = interface
self._state = UcdState.OPENED
self._bus.publish(ConnectionChanged(True, info.serial))
def close(self) -> None:
with self._acquire_device_lock("close"):
if self._state == UcdState.CLOSED:
return
try:
self._controller.close()
except Exception: # noqa: BLE001
log.exception("关闭 UCD 时发生异常")
self._state = UcdState.CLOSED
self._curr_signal = None
self._curr_timing = None
self._curr_pattern = None
self._last_applied = None
prev_serial = self._info.serial if self._info else None
self._info = None
self._bus.publish(ConnectionChanged(False, prev_serial))
# -- 配置 ----------------------------------------------------
def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool:
with self._acquire_device_lock("configure"):
if self._state == UcdState.CLOSED:
raise UcdNotConnected("UCD 未连接,无法 configure")
try:
# 颜色模式color_format / bpc / colorimetry
if not self._controller.set_color_mode(
signal.color_format.value,
int(signal.bpc),
_colorimetry_to_legacy_key(signal),
):
raise UcdConfigError(
f"set_color_mode 失败: {signal!r}"
)
# dynamic_range 在新接口中是一等公民
self._apply_dynamic_range(signal)
# Timing
if not self._controller.set_timing_from_string(str(timing)):
raise UcdConfigError(f"set_timing_from_string 失败: {timing}")
except UcdConfigError:
raise
except Exception as exc: # noqa: BLE001
raise UcdSdkError("configure 异常") from exc
self._curr_signal = signal
self._curr_timing = timing
self._state = UcdState.CONFIGURED
return (signal, timing) != self._last_applied
def set_pattern(self, pattern: PatternSpec) -> None:
with self._acquire_device_lock("set_pattern"):
# Phase 2 过渡:允许从 OPENED 直接 set_pattern——遗留路径
# test_runner 等)通过旧 controller.apply_signal_format 写入
# 信号格式,未经过本设备的 configure。此时 self._state 仍为
# OPENED但硬件实际已处于可接收 pattern 状态。
if self._state == UcdState.CLOSED:
raise UcdNotConnected("UCD 未连接,无法 set_pattern")
self._curr_pattern = pattern
# 仅本地暂存,真正写硬件在 apply()
def apply(self) -> None:
with self._acquire_device_lock("apply"):
if self._state == UcdState.CLOSED:
raise UcdNotConnected("UCD 未连接,无法 apply")
if self._curr_pattern is None:
raise UcdStateError("apply 前必须先 set_pattern")
try:
ok = self._apply_pattern_via_controller(self._curr_pattern)
except Exception as exc: # noqa: BLE001
raise UcdSdkError(
f"apply 异常: {type(exc).__name__}: {exc}"
) from exc
if not ok:
raise UcdApplyFailed(
f"apply 失败: pattern={self._curr_pattern.kind.value}"
)
# SignalApplied 事件仅在通过新 API configure 过时发出;
# 遗留路径下 self._curr_signal/_curr_timing 可能为 None。
if self._curr_signal is not None and self._curr_timing is not None:
changed = (self._curr_signal, self._curr_timing) != self._last_applied
self._last_applied = (self._curr_signal, self._curr_timing)
self._bus.publish(
SignalApplied(self._curr_signal, self._curr_timing, changed)
)
self._state = UcdState.APPLIED
self._bus.publish(PatternApplied(self._curr_pattern))
# -- 查询 ----------------------------------------------------
def current_resolution(self) -> tuple[int, int]:
try:
return self._controller.get_current_resolution((3840, 2160))
except Exception: # noqa: BLE001
return (3840, 2160)
# -- 内部辅助 ------------------------------------------------
def _apply_dynamic_range(self, signal: SignalFormat) -> None:
import UniTAP # 局部导入,避免本模块在无 SDK 环境下导入即失败
from app.ucd_domain import DynamicRange
ci = self._controller.color_info
if ci is None:
return
if signal.dynamic_range is DynamicRange.FULL:
ci.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA
else:
ci.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_CTA
def _apply_pattern_via_controller(self, pattern: PatternSpec) -> bool:
"""根据 PatternKind 走最合适的旧 controller 路径。"""
if pattern.kind is PatternKind.IMAGE:
if not pattern.image_path:
raise UcdConfigError("IMAGE pattern 必须提供 image_path")
return bool(self._controller.send_image_pattern(pattern.image_path))
# 预定义图案路径:复用 controller.set_pattern + run()
from drivers.UCD323_Enum import UCDEnum # 局部导入避免循环
video_pattern = UCDEnum.VideoPatternInfo.get_video_pattern(pattern.kind.value)
if video_pattern is None:
raise UcdConfigError(f"不支持的 PatternKind: {pattern.kind!r}")
self._controller.current_pattern = video_pattern
params: list[int] | None = None
if pattern.kind is PatternKind.SOLID:
if pattern.solid_rgb is None:
raise UcdConfigError("SOLID pattern 必须提供 solid_rgb")
params = list(pattern.solid_rgb)
elif pattern.extras:
params = list(pattern.extras)
if not self._controller.set_pattern(video_pattern, params):
raise UcdApplyFailed("controller.set_pattern 返回 False")
# Skip apply_video_mode() (i.e. pg.set_vm) the video format is already
# configured by the main signal panel and re-applying it blocks until the
# device re-locks, causing an apparent UI freeze for pattern-only sends.
if not self._controller.apply_pattern():
raise UcdApplyFailed("controller.apply_pattern 返回 False")
if getattr(self._controller, "current_timing", None) is None:
raise UcdConfigError(
"current_timing is None; please apply selected test profile/timing before sending pattern"
)
try:
pg, _ = self._controller.get_tx_modules()
pg.apply()
except Exception as exc:
raise UcdSdkError("pg.apply() 失败") from exc
return True
def _colorimetry_to_legacy_key(signal: SignalFormat) -> str:
"""新 :class:`Colorimetry` → 旧 ``UCDEnum.ColorInfo.get_colorimetry`` 的 key。
BT.2020 在 YCbCr / RGB 输出下走不同 SDK 枚举(参考旧
``_get_colorimetry_from_color_space`` 的逻辑),这里也做同样的分支。
"""
from app.ucd_domain import Colorimetry, is_ycbcr
cm = signal.colorimetry
ycbcr = is_ycbcr(signal.color_format)
if cm is Colorimetry.BT2020:
return "bt2020ycbcr" if ycbcr else "bt2020rgb"
return {
Colorimetry.SRGB: "srgb",
Colorimetry.BT709: "bt709",
Colorimetry.BT601: "bt601",
Colorimetry.DCI_P3: "dcip3",
Colorimetry.ADOBE_RGB: "adobergb",
}.get(cm, "srgb")
# ─── §4 FakeUcdDevice 单测实现 ───────────────────────────────────
class FakeUcdDevice(IUcdDevice):
"""无硬件依赖的 Fake 实现;记录调用序列供单测断言。"""
def __init__(self, bus: EventBus | None = None) -> None:
self._bus = bus or EventBus()
self._state = UcdState.CLOSED
self._info: DeviceInfo | None = None
self._signal: SignalFormat | None = None
self._timing: TimingSpec | None = None
self._pattern: PatternSpec | None = None
self._last_applied: tuple[SignalFormat, TimingSpec] | None = None
self.calls: list[tuple] = [] # ("open", info) / ("configure", ...) ...
@property
def state(self) -> UcdState:
return self._state
@property
def info(self) -> DeviceInfo | None:
return self._info
def open(self, info: DeviceInfo, *, interface: Interface = Interface.HDMI) -> None:
assert_transition(self._state, UcdState.OPENED)
self.calls.append(("open", info, interface))
self._info = info
self._state = UcdState.OPENED
self._bus.publish(ConnectionChanged(True, info.serial))
def close(self) -> None:
if self._state == UcdState.CLOSED:
return
self.calls.append(("close",))
self._state = UcdState.CLOSED
prev = self._info.serial if self._info else None
self._info = None
self._signal = self._timing = self._pattern = None
self._last_applied = None
self._bus.publish(ConnectionChanged(False, prev))
def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool:
if self._state == UcdState.CLOSED:
raise UcdNotConnected()
self.calls.append(("configure", signal, timing))
self._signal, self._timing = signal, timing
self._state = UcdState.CONFIGURED
return (signal, timing) != self._last_applied
def set_pattern(self, pattern: PatternSpec) -> None:
if self._state not in (UcdState.CONFIGURED, UcdState.APPLIED):
raise UcdStateError(f"非法状态 {self._state.name}")
self.calls.append(("set_pattern", pattern))
self._pattern = pattern
def apply(self) -> None:
if self._signal is None or self._timing is None:
raise UcdStateError("apply 前必须 configure")
if self._pattern is None:
raise UcdStateError("apply 前必须 set_pattern")
self.calls.append(("apply",))
changed = (self._signal, self._timing) != self._last_applied
self._last_applied = (self._signal, self._timing)
self._state = UcdState.APPLIED
self._bus.publish(SignalApplied(self._signal, self._timing, changed))
self._bus.publish(PatternApplied(self._pattern))
def current_resolution(self) -> tuple[int, int]:
if self._timing is None:
return (3840, 2160)
return (self._timing.width, self._timing.height)
__all__ = [
"DeviceInfo",
"list_devices",
"IUcdDevice",
"UCD323Device",
"FakeUcdDevice",
]

View File

@@ -1,46 +0,0 @@
"""通用 UCD323/UCDController 辅助函数。
保留为兼容层和薄代理,避免业务模块直接依赖控制器内部实现细节。
"""
def get_tx_modules(ucd):
"""根据当前接口返回 (pg, ag) 模块。"""
return ucd.get_tx_modules()
def get_current_resolution(ucd, default=(3840, 2160)):
"""从 UCD 当前 timing 获取 (width, height),失败时返回 default。"""
return ucd.get_current_resolution(default)
def send_image_pattern(ucd, image_path):
"""通过 UCDController 发送一张本地图片作为显示 Pattern。"""
if not getattr(ucd, "status", False):
return False
send_via_controller = getattr(ucd, "send_image_pattern", None)
if not callable(send_via_controller):
return False
return bool(send_via_controller(image_path))
def send_solid_rgb_pattern(ucd, rgb, *, raise_on_error=False):
"""通过 UCDController 当前状态发送一组纯色 RGB Pattern。"""
if not getattr(ucd, "status", False):
if raise_on_error:
raise RuntimeError("UCD 未连接,无法发送纯色 Pattern")
return False
send_via_controller = getattr(ucd, "send_solid_rgb_pattern", None)
if not callable(send_via_controller):
if raise_on_error:
raise RuntimeError("UCDController 未实现 send_solid_rgb_pattern")
return False
ok = bool(send_via_controller(list(rgb)))
if not ok and raise_on_error:
raise RuntimeError(f"发送纯色 Pattern 失败: {rgb}")
return ok

View File

@@ -6,9 +6,13 @@ import time
import os
import datetime
import traceback
import matplotlib
import matplotlib.pyplot as plt
from app_version import APP_NAME, APP_VERSION, get_app_title
from drivers.UCD323_Function import UCDController
from drivers.ucd_driver import UCD323Device
from app.ucd_domain import EventBus
from app.services.ucd_service import SignalService
from app.pq.pq_config import PQConfig
from app.pq.pq_result import PQResultStore
from app.export import (
@@ -16,14 +20,16 @@ from app.export import (
export_excel_report as _export_excel_report_impl,
EXCEL_EXPORT_CONFIG as _EXCEL_EXPORT_CONFIG,
)
from app.views.panels import custom_template_panel as _ctp
from app.views.panels import side_panels as _sp
from app.views.panels import cct_panel as _ccp
from app.views.panels import main_layout as _main
from app.views.panels import ai_image_panel as _aip
from app.views.panels import single_step_panel as _ssp
from app.views.panels import pantone_baseline_panel as _pbp
from app.views import panel_manager as PM
from app.views.panels.custom_template_panel import CustomTemplatePanelMixin
from app.views.panels.side_panels import SidePanelsMixin
from app.views.panels.cct_panel import CctPanelMixin
from app.views.panels.main_layout import MainLayoutMixin
from app.views.panels.ai_image_panel import AIImagePanelMixin
from app.views.panels.single_step_panel import SingleStepPanelMixin
from app.views.panels.pantone_baseline_panel import PantoneBaselinePanelMixin
from app.views.panels.gamma_pattern_panel import GammaPatternPanelMixin
from app.views.panels.calman_panel import CalmanPanelMixin
from app.views.panel_manager import PanelManagerMixin
from app.logging_setup import setup_logging, attach_gui_handler
# Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持
@@ -41,77 +47,45 @@ from app.tests.color_accuracy import (
from app.tests.eotf import calculate_pq_curve as _calc_pq_curve
from app.tests.gamma import calculate_gamma as _calc_gamma
from app.tests.gamut import calculate_gamut_coverage as _calc_gamut_coverage
from app.plots.plot_accuracy import plot_accuracy as _plot_accuracy
from app.plots.plot_cct import plot_cct as _plot_cct
from app.plots.plot_contrast import plot_contrast as _plot_contrast
from app.plots.plot_eotf import plot_eotf as _plot_eotf
from app.plots.plot_gamma import plot_gamma as _plot_gamma
from app.plots.plot_gamut import plot_gamut as _plot_gamut
from app.views.chart_frame import (
clear_chart as _cf_clear_chart,
create_result_chart_frame as _cf_create_result_chart_frame,
init_accuracy_chart as _cf_init_accuracy_chart,
init_cct_chart as _cf_init_cct_chart,
init_contrast_chart as _cf_init_contrast_chart,
init_eotf_chart as _cf_init_eotf_chart,
init_gamma_chart as _cf_init_gamma_chart,
init_gamut_chart as _cf_init_gamut_chart,
on_chart_tab_changed as _cf_on_chart_tab_changed,
sync_gamut_toolbar as _cf_sync_gamut_toolbar,
_on_gamut_toolbar_changed as _cf_on_gamut_toolbar_changed,
update_chart_tabs_state as _cf_update_chart_tabs_state,
)
from app.config_io import (
clear_config_file as _cfg_clear_config_file,
get_config_path as _cfg_get_config_path,
load_pq_config as _cfg_load_pq_config,
save_pq_config as _cfg_save_pq_config,
)
from app.tests.local_dimming import (
clear_ld_records as _ld_clear_ld_records,
measure_ld_luminance as _ld_measure_ld_luminance,
save_local_dimming_results as _ld_save_local_dimming_results,
send_ld_window as _ld_send_ld_window,
start_local_dimming_test as _ld_start_local_dimming_test,
stop_local_dimming_test as _ld_stop_local_dimming_test,
update_ld_results as _ld_update_ld_results,
)
from app.plots.plot_accuracy import PlotAccuracyMixin
from app.plots.plot_cct import PlotCctMixin
from app.plots.plot_contrast import PlotContrastMixin
from app.plots.plot_eotf import PlotEotfMixin
from app.plots.plot_gamma import PlotGammaMixin
from app.plots.plot_gamut import PlotGamutMixin
from app.views.chart_frame import ChartFrameMixin
from app.config_io import ConfigIOMixin
from app.tests.local_dimming import LocalDimmingMixin
from app.services import PatternService
from app.device.connection import (
check_com_connections as _dev_check_com_connections,
check_port_connection as _dev_check_port_connection,
disconnect_com_connections as _dev_disconnect_com_connections,
enable_com_widgets as _dev_enable_com_widgets,
get_available_com_ports as _dev_get_available_com_ports,
get_available_ucd_ports as _dev_get_available_ucd_ports,
refresh_com_ports as _dev_refresh_com_ports,
update_connection_indicator as _dev_update_connection_indicator,
)
from app.runner.test_runner import (
get_current_test_result as _run_get_current_test_result,
new_pq_results as _run_new_pq_results,
on_custom_template_test_completed as _run_on_custom_template_test_completed,
on_test_completed as _run_on_test_completed,
on_test_error as _run_on_test_error,
run_custom_sdr_test as _run_run_custom_sdr_test,
run_hdr_movie_test as _run_run_hdr_movie_test,
run_screen_module_test as _run_run_screen_module_test,
run_sdr_movie_test as _run_run_sdr_movie_test,
run_test as _run_run_test,
send_fix_pattern as _run_send_fix_pattern,
test_cct as _run_test_cct,
test_color_accuracy as _run_test_color_accuracy,
test_contrast as _run_test_contrast,
test_custom_sdr as _run_test_custom_sdr,
test_eotf as _run_test_eotf,
test_gamma as _run_test_gamma,
test_gamut as _run_test_gamut,
)
from app.device.connection import DeviceConnectionMixin
from app.runner.test_runner import TestRunnerMixin
plt.rcParams["font.family"] = ["sans-serif"]
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"]
class PQAutomationApp:
class PQAutomationApp(
ConfigIOMixin,
ChartFrameMixin,
MainLayoutMixin,
CctPanelMixin,
DeviceConnectionMixin,
CustomTemplatePanelMixin,
SidePanelsMixin,
AIImagePanelMixin,
SingleStepPanelMixin,
PantoneBaselinePanelMixin,
GammaPatternPanelMixin,
CalmanPanelMixin,
LocalDimmingMixin,
PanelManagerMixin,
TestRunnerMixin,
PlotGamutMixin,
PlotGammaMixin,
PlotEotfMixin,
PlotCctMixin,
PlotContrastMixin,
PlotAccuracyMixin,
):
def __init__(self, root):
self.root = root
self.root.title(get_app_title())
@@ -126,7 +100,20 @@ class PQAutomationApp:
# 初始化设备连接状态
self.ca = None # CA410色度计
self.ucd = UCDController() # 信号发生器
self.ucd = UCDController() # 信号发生器(旧接口,过渡期保留)
# 新架构EventBus + 设备抽象 + 服务层。
# UCD323Device 内部委托 self.ucd保证零行为变更
# 新代码统一走 self.signal_service。
self.event_bus = EventBus()
self.ucd_device = UCD323Device(self.event_bus, self.ucd)
self.signal_service = SignalService(self.ucd_device, self.event_bus)
# 连接控制器:统一管理 CA/UCD 生命周期。
# 旧的 check_com_connections / disconnect_com_connections 等模块级
# 函数仍以类属性形式挂在 PQAutomationApp 上,内部全部委托给本对象。
from app.device.connection import ConnectionController
self.connection = ConnectionController(self)
# 初始化测试状态
self.testing = False
@@ -162,12 +149,12 @@ class PQAutomationApp:
self.log_visible = False
# 创建左侧面板
self.left_frame = ttk.Frame(self.main_frame, width=180)
self.left_frame = ttk.Frame(self.main_frame, width=208)
self.left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=0, pady=5)
self.left_frame.pack_propagate(False)
# 创建左侧导航栏
self.sidebar_frame = ttk.Frame(self.left_frame, bootstyle="primary")
self.sidebar_frame = ttk.Frame(self.left_frame, style="Sidebar.TFrame")
self.sidebar_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=5)
# self.sidebar_frame.pack_propagate(False)
@@ -196,8 +183,8 @@ class PQAutomationApp:
# 创建右上角悬浮配置框
self.create_floating_config_panel()
# 创建右侧结果显示区域
self.result_frame = ttk.LabelFrame(self.control_frame_middle, text="测试结果")
# 创建右侧结果显示区域(无边框,纯 Frame让图表占满
self.result_frame = ttk.Frame(self.control_frame_middle)
self.result_frame.pack(
side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=5
)
@@ -212,6 +199,10 @@ class PQAutomationApp:
# self.create_single_step_panel()
# 创建 Pantone 认证摸底测试面板
self.create_pantone_baseline_panel()
# 创建 Gamma 测试图案配置面板
self.create_gamma_pattern_panel()
# 创建 CALMAN 风格灰阶测试面板
self.create_calman_panel()
# 创建测试类型选择区域
self.create_test_type_frame()
# 创建操作按钮区域
@@ -223,12 +214,24 @@ class PQAutomationApp:
# 在所有控件创建完成后,统一初始化测试类型
self.root.after(100, self.initialize_default_test_type)
# 状态栏
self.status_var = tk.StringVar(value="就绪")
# 状态栏(现代化扁平条,跟随 ttkbootstrap 主题)
self.status_var = tk.StringVar(value="\u25cf 就绪")
status_container = ttk.Frame(root, style="StatusBar.TFrame")
status_container.pack(side=tk.BOTTOM, fill=tk.X)
self.status_bar = ttk.Label(
root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W
status_container,
textvariable=self.status_var,
style="StatusBar.TLabel",
anchor=tk.W,
)
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
self.status_bar.pack(side=tk.LEFT, fill=tk.X, expand=True)
# 右侧版本号
ttk.Label(
status_container,
text=f"v{APP_VERSION}",
style="StatusBarAccent.TLabel",
anchor=tk.E,
).pack(side=tk.RIGHT)
def _dispatch_ui(self, fn, *args, **kwargs):
"""把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。
@@ -261,111 +264,6 @@ class PQAutomationApp:
if hasattr(self, "log_gui"):
self.log_gui.log(f"初始化默认测试类型失败: {str(e)}", level="error")
get_config_path = _cfg_get_config_path
load_pq_config = _cfg_load_pq_config
save_pq_config = _cfg_save_pq_config
register_panel = PM.register_panel
show_panel = PM.show_panel
hide_all_panels = PM.hide_all_panels
init_gamut_chart = _cf_init_gamut_chart
init_gamma_chart = _cf_init_gamma_chart
init_eotf_chart = _cf_init_eotf_chart
init_cct_chart = _cf_init_cct_chart
init_contrast_chart = _cf_init_contrast_chart
init_accuracy_chart = _cf_init_accuracy_chart
clear_chart = _cf_clear_chart
create_result_chart_frame = _cf_create_result_chart_frame
on_chart_tab_changed = _cf_on_chart_tab_changed
sync_gamut_toolbar = _cf_sync_gamut_toolbar
_on_gamut_toolbar_changed = _cf_on_gamut_toolbar_changed
create_floating_config_panel = _main.create_floating_config_panel
create_test_items_content = _main.create_test_items_content
create_signal_format_content = _main.create_signal_format_content
create_connection_content = _main.create_connection_content
create_operation_frame = _main.create_operation_frame
create_test_type_frame = _main.create_test_type_frame
update_config_info_display = _main.update_config_info_display
on_screen_module_timing_changed = _main.on_screen_module_timing_changed
on_sdr_timing_changed = _main.on_sdr_timing_changed
update_test_items = _main.update_test_items
on_test_type_change = _main.on_test_type_change
on_sdr_output_format_changed = _main.on_sdr_output_format_changed
on_hdr_output_format_changed = _main.on_hdr_output_format_changed
create_cct_params_frame = _ccp.create_cct_params_frame
on_sdr_cct_param_focus_out = _ccp.on_sdr_cct_param_focus_out
save_sdr_cct_params = _ccp.save_sdr_cct_params
on_hdr_cct_param_focus_out = _ccp.on_hdr_cct_param_focus_out
save_hdr_cct_params = _ccp.save_hdr_cct_params
recalculate_cct = _ccp.recalculate_cct
recalculate_gamut = _ccp.recalculate_gamut
on_cct_param_focus_out = _ccp.on_cct_param_focus_out
save_cct_params = _ccp.save_cct_params
reload_cct_params = _ccp.reload_cct_params
toggle_cct_params_frame = _ccp.toggle_cct_params_frame
on_screen_gamut_ref_changed = _ccp.on_screen_gamut_ref_changed
on_sdr_gamut_ref_changed = _ccp.on_sdr_gamut_ref_changed
on_hdr_gamut_ref_changed = _ccp.on_hdr_gamut_ref_changed
get_available_ucd_ports = _dev_get_available_ucd_ports
get_available_com_ports = _dev_get_available_com_ports
refresh_com_ports = _dev_refresh_com_ports
check_com_connections = _dev_check_com_connections
update_connection_indicator = _dev_update_connection_indicator
check_port_connection = _dev_check_port_connection
enable_com_widgets = _dev_enable_com_widgets
disconnect_com_connections = _dev_disconnect_com_connections
create_custom_template_result_panel = _ctp.create_custom_template_result_panel
show_custom_result_context_menu = _ctp.show_custom_result_context_menu
set_custom_result_table_locked = _ctp.set_custom_result_table_locked
start_custom_row_single_step = _ctp.start_custom_row_single_step
copy_custom_result_table = _ctp.copy_custom_result_table
clear_custom_template_results = _ctp.clear_custom_template_results
auto_expand_custom_result_view = _ctp.auto_expand_custom_result_view
append_custom_template_result = _ctp.append_custom_template_result
start_custom_template_test = _ctp.start_custom_template_test
update_custom_button_visibility = _ctp.update_custom_button_visibility
export_custom_template_excel = _ctp.export_custom_template_excel
export_custom_template_charts = _ctp.export_custom_template_charts
create_log_panel = _sp.create_log_panel
create_local_dimming_panel = _sp.create_local_dimming_panel
toggle_local_dimming_panel = _sp.toggle_local_dimming_panel
toggle_log_panel = _sp.toggle_log_panel
update_sidebar_selection = _sp.update_sidebar_selection
# ---- AI 图片对话面板 ----
create_ai_image_panel = _aip.create_ai_image_panel
toggle_ai_image_panel = _aip.toggle_ai_image_panel
reload_ai_image_list = _aip.reload_ai_image_list
# ---- 单步调试面板 ----
create_single_step_panel = _ssp.create_single_step_panel
toggle_single_step_panel = _ssp.toggle_single_step_panel
# ---- Pantone 认证摸底测试面板 ----
create_pantone_baseline_panel = _pbp.create_pantone_baseline_panel
toggle_pantone_baseline_panel = _pbp.toggle_pantone_baseline_panel
# ---- 单步调试面板(统一实现,委托到 side_panels 模块) ----
_toggle_debug_panel = _sp._toggle_debug_panel
toggle_screen_debug_panel = _sp.toggle_screen_debug_panel
toggle_sdr_debug_panel = _sp.toggle_sdr_debug_panel
toggle_hdr_debug_panel = _sp.toggle_hdr_debug_panel
clear_config_file = _cfg_clear_config_file
start_local_dimming_test = _ld_start_local_dimming_test
update_ld_results = _ld_update_ld_results
stop_local_dimming_test = _ld_stop_local_dimming_test
send_ld_window = _ld_send_ld_window
measure_ld_luminance = _ld_measure_ld_luminance
clear_ld_records = _ld_clear_ld_records
save_local_dimming_results = _ld_save_local_dimming_results
def _save_current_cct_params(self, swallow_errors=True):
"""按当前测试类型分发保存对应的 CCT 参数。"""
try:
@@ -460,10 +358,11 @@ class PQAutomationApp:
"screen_module": 0,
"sdr_movie": 1,
"hdr_movie": 2,
"local_dimming": 3,
}
target_tab = tab_mapping.get(test_type, 0)
for i in range(3):
for i in range(4):
self.signal_tabs.tab(i, state="normal")
self.signal_tabs.select(target_tab)
@@ -476,8 +375,10 @@ class PQAutomationApp:
self.sdr_signal_frame.tkraise()
elif target_tab == 2:
self.hdr_signal_frame.tkraise()
elif target_tab == 3:
self.local_dimming_signal_frame.tkraise()
for i in range(3):
for i in range(4):
if i != target_tab:
self.signal_tabs.tab(i, state="disabled")
@@ -487,43 +388,31 @@ class PQAutomationApp:
if hasattr(self, "log_gui"):
self.log_gui.log(f"切换信号格式失败: {str(e)}", level="error")
def _switch_chart_tabs_by_test_type(self, test_type):
"""按测试类型切换 Gamma/EOTF 与客户模板结果 Tab"""
if not hasattr(self, "chart_notebook"):
def _sync_custom_template_tab_visibility(self, test_type):
"""按测试类型与客户模板结果状态同步客户模板 Tab 可见性"""
if not hasattr(self, "_set_custom_template_tab_visible"):
return
try:
current_tabs = list(self.chart_notebook.tabs())
gamma_tab_id = str(self.gamma_chart_frame)
eotf_tab_id = str(self.eotf_chart_frame)
# 客户模板结果 Tab 只属于 SDR Movie。
if test_type != "sdr_movie":
self._set_custom_template_tab_visible(False)
return
if test_type == "hdr_movie":
if gamma_tab_id in current_tabs:
gamma_index = current_tabs.index(gamma_tab_id)
self.chart_notebook.forget(gamma_index)
if eotf_tab_id not in current_tabs:
self.chart_notebook.insert(1, self.eotf_chart_frame, text="EOTF 曲线")
else:
if eotf_tab_id in current_tabs:
eotf_index = current_tabs.index(eotf_tab_id)
self.chart_notebook.forget(eotf_index)
if gamma_tab_id not in current_tabs:
self.chart_notebook.insert(1, self.gamma_chart_frame, text="Gamma 曲线")
has_custom_rows = False
tree = getattr(self, "custom_result_tree", None)
if tree is not None:
try:
has_custom_rows = len(tree.get_children()) > 0
except Exception:
has_custom_rows = False
custom_tab_id = str(self.custom_template_tab_frame)
current_tabs = list(self.chart_notebook.tabs())
if test_type == "sdr_movie":
if custom_tab_id not in current_tabs:
self.chart_notebook.add(self.custom_template_tab_frame, text="客户模板结果显示")
else:
if custom_tab_id in current_tabs:
self.chart_notebook.forget(self.custom_template_tab_frame)
self.chart_notebook.update_idletasks()
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"切换 Gamma/EOTF Tab 失败: {str(e)}", level="error")
# SDR 下仅在客户模板测试进行中,或已有客户模板结果时显示。
show_tab = has_custom_rows or (
getattr(self, "testing", False)
and getattr(self, "test_type_var", None) is not None
and self.test_type_var.get() == "sdr_movie"
)
self._set_custom_template_tab_visible(show_tab)
def change_test_type(self, test_type):
"""切换测试类型"""
@@ -534,6 +423,7 @@ class PQAutomationApp:
"ai_image",
"single_step",
"pantone_baseline",
"gamma_pattern",
):
self.hide_all_panels()
self._save_cct_params_before_test_type_switch()
@@ -541,10 +431,15 @@ class PQAutomationApp:
# 更新测试项目和侧边栏
self.update_test_items()
if hasattr(self, "refresh_connection_indicators"):
try:
self.refresh_connection_indicators()
except Exception:
pass
self.update_sidebar_selection()
self.on_test_type_change()
self._switch_signal_format_tabs(test_type)
self._switch_chart_tabs_by_test_type(test_type)
self._sync_custom_template_tab_visibility(test_type)
self.sync_gamut_toolbar()
self._restore_charts_for_type(test_type)
@@ -634,6 +529,8 @@ class PQAutomationApp:
return "开始 SDR Movie 测试,请设置正确的图像模式"
if test_type == "hdr_movie":
return "开始 HDR Movie 测试,请设置正确的图像模式"
if test_type == "local_dimming":
return "Local Dimming 为手动模式,请在右侧面板发送图案并采集数据"
return f"开始{self.get_test_type_name(test_type)}测试"
def _launch_test_thread(self, test_type, test_items):
@@ -827,21 +724,8 @@ class PQAutomationApp:
self.log_gui.log(traceback.format_exc(), level="error")
messagebox.showerror("错误", f"保存测试结果失败: {str(e)}")
new_pq_results = _run_new_pq_results
run_test = _run_run_test
run_screen_module_test = _run_run_screen_module_test
run_custom_sdr_test = _run_run_custom_sdr_test
run_sdr_movie_test = _run_run_sdr_movie_test
run_hdr_movie_test = _run_run_hdr_movie_test
send_fix_pattern = _run_send_fix_pattern
test_custom_sdr = _run_test_custom_sdr
test_gamut = _run_test_gamut
test_gamma = _run_test_gamma
test_eotf = _run_test_eotf
test_cct = _run_test_cct
test_contrast = _run_test_contrast
test_color_accuracy = _run_test_color_accuracy
# 纯算法函数:作为 staticmethod 保留在主类(不依赖 self且 calculate_xxx
# 的命名空间由历史代码以 self.calculate_xxx 调用)。
calculate_delta_e_2000 = staticmethod(_calc_delta_e_2000)
get_accuracy_color_standards = staticmethod(_get_accuracy_color_standards)
calculate_gamut_coverage = staticmethod(_calc_gamut_coverage)
@@ -849,20 +733,6 @@ class PQAutomationApp:
calculate_color_accuracy = staticmethod(_calc_color_accuracy)
calculate_pq_curve = staticmethod(_calc_pq_curve)
plot_gamut = _plot_gamut
plot_gamma = _plot_gamma
plot_eotf = _plot_eotf
plot_cct = _plot_cct
plot_contrast = _plot_contrast
plot_accuracy = _plot_accuracy
on_test_completed = _run_on_test_completed
on_custom_template_test_completed = _run_on_custom_template_test_completed
get_current_test_result = _run_get_current_test_result
on_test_error = _run_on_test_error
update_chart_tabs_state = _cf_update_chart_tabs_state
def get_test_type_name(self, test_type):
"""获取测试类型的显示名称"""
if test_type == "screen_module":
@@ -871,6 +741,8 @@ class PQAutomationApp:
return "SDR Movie测试"
elif test_type == "hdr_movie":
return "HDR Movie测试"
elif test_type == "local_dimming":
return "Local Dimming"
return test_type
def get_selected_test_items(self):
@@ -892,8 +764,19 @@ class PQAutomationApp:
# 保存当前选中的测试项到配置
self.config.set_current_test_items(self.get_selected_test_items())
# 待修改为三种测试类型的timing值
self.config.set_current_timing(self.screen_module_timing_var.get())
# 按当前测试类型保存对应 timing避免误覆盖其它测试类型配置。
if self.config.current_test_type == "screen_module":
self.config.set_current_timing(self.screen_module_timing_var.get())
elif (
self.config.current_test_type == "sdr_movie"
and hasattr(self, "sdr_timing_var")
):
self.config.set_current_timing(self.sdr_timing_var.get())
elif (
self.config.current_test_type == "local_dimming"
and hasattr(self, "local_dimming_timing_var")
):
self.config.set_current_timing(self.local_dimming_timing_var.get())
# 自动保存配置到文件
self.save_pq_config()
@@ -912,6 +795,13 @@ class PQAutomationApp:
# 控制参数框的显示
self.toggle_cct_params_frame()
# 同步刷新顶部 header 折叠预览(现代化布局新增)
if hasattr(self, "refresh_config_preview"):
try:
self.refresh_config_preview()
except Exception:
pass
def on_closing(self):
"""窗口关闭时的处理"""
try:
@@ -939,8 +829,10 @@ class PQAutomationApp:
def main():
try:
setup_logging()
# root = tk.Tk()
# 先以浅色主题启动 Window再根据用户偏好含自定义 Calman 深色主题)切换
root = ttk.Window(themename="yeti")
from app.views.theme_manager import apply_initial_theme
apply_initial_theme()
app = PQAutomationApp(root)
# GUI 创建完成后,把 logging 记录同步到日志面板
if hasattr(app, "log_gui"):

View File

@@ -110,7 +110,9 @@ a = Analysis(
'drivers.tvSerail',
'drivers.UCD323_Enum',
'drivers.UCD323_Function',
'drivers.ucd_helpers',
'drivers.ucd_driver',
'app.ucd_domain',
'app.services.ucd_service',
],
hookspath=[],
hooksconfig={},

View File

@@ -1,5 +1,5 @@
{
"current_test_type": "sdr_movie",
"current_test_type": "screen_module",
"test_types": {
"screen_module": {
"name": "屏模组性能测试",
@@ -9,34 +9,52 @@
"cct",
"contrast"
],
"timing": "DMT 1600x 1200 @ 60Hz",
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"patterns": {
"gamut": "rgb",
"gamma": "gray",
"cct": "gray",
"contrast": "rgb"
},
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
},
"gamut_reference": "DCI-P3"
}
},
"sdr_movie": {
"name": "SDR Movie测试",
"test_items": [
"gamut"
"gamut",
"gamma",
"cct",
"contrast",
"accuracy"
],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"patterns": {
"gamut": "rgb",
"gamma": "gray",
"cct": "gray",
"contrast": "rgb",
"accuracy": "accuracy"
},
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
},
"gamut_reference": "BT.2020"
"gamut_reference": "BT.601"
},
"hdr_movie": {
"name": "HDR Movie测试",
@@ -48,15 +66,33 @@
"accuracy"
],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"patterns": {
"gamut": "rgb",
"eotf": "gray",
"cct": "gray",
"contrast": "rgb",
"accuracy": "accuracy"
},
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
}
},
"local_dimming": {
"name": "Local Dimming",
"test_items": [],
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "sRGB",
"patterns": {}
}
},
"device_config": {

188
tools/demo_accuracy_plot.py Normal file
View File

@@ -0,0 +1,188 @@
"""离线色准图 Demo。
运行后会在 tools/demo_outputs/ 下生成一张 PNG
用于在没有 UCD 设备时预览当前色准图表的 Calman 风格布局。
"""
from __future__ import annotations
import argparse
import math
import sys
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
plt.rcParams["font.family"] = ["sans-serif"]
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
plt.rcParams["axes.unicode_minus"] = False
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from app.plots.plot_accuracy import plot_accuracy
from app.tests.color_accuracy import (
calculate_delta_e_2000,
get_accuracy_color_standards,
)
COLOR_NAMES = [
"White",
"Gray 80",
"Gray 65",
"Gray 50",
"Gray 35",
"Dark Skin",
"Light Skin",
"Blue Sky",
"Foliage",
"Blue Flower",
"Bluish Green",
"Orange",
"Purplish Blue",
"Moderate Red",
"Purple",
"Yellow Green",
"Orange Yellow",
"Blue (Legacy)",
"Green (Legacy)",
"Red (Legacy)",
"Yellow (Legacy)",
"Magenta (Legacy)",
"Cyan (Legacy)",
"100% Red",
"100% Green",
"100% Blue",
"100% Cyan",
"100% Magenta",
"100% Yellow",
]
class _DummyNotebook:
def select(self, *_args, **_kwargs):
return None
class _DummyCanvas:
def draw(self):
return None
class _DemoApp:
def __init__(self, fig):
self.accuracy_fig = fig
self.accuracy_canvas = _DummyCanvas()
self.chart_notebook = _DummyNotebook()
self.accuracy_chart_frame = object()
def get_test_type_name(self, test_type):
mapping = {
"sdr_movie": "SDR Movie",
"hdr_movie": "HDR Movie",
"screen_module": "屏模组",
}
return mapping.get(test_type, str(test_type))
def _build_demo_data(test_type: str = "sdr_movie"):
standards = get_accuracy_color_standards(test_type)
rng = np.random.default_rng(20260527)
measured = []
color_patches = []
delta_e_values = []
for idx, name in enumerate(COLOR_NAMES):
sx, sy = standards[name]
# 构造一些“看起来像真实测量”的偏移:
# 大部分点轻微偏移,少数点更明显,便于看出方向和等级差异。
if idx < 5:
offset_scale = 0.0012
elif idx < 23:
offset_scale = 0.0028
else:
offset_scale = 0.0045
angle = rng.uniform(0, 2 * math.pi)
radius = offset_scale * (0.55 + 0.85 * rng.random())
dx = math.cos(angle) * radius
dy = math.sin(angle) * radius
# 为了让图上连线不完全随机,给部分饱和色再加一点定向偏移。
if idx >= 23:
dx += 0.002 * (1 if idx % 2 == 0 else -1)
dy += 0.0015 * (1 if idx % 3 == 0 else -1)
mx = min(max(sx + dx, 0.0), 0.8)
my = min(max(sy + dy, 0.0), 0.9)
# 亮度也做一点微小变化,避免所有点完全同一层。
measured_lv = 70.0 + rng.normal(0, 4.0)
measured_lv = max(measured_lv, 1.0)
delta_e = calculate_delta_e_2000(mx, my, measured_lv, sx, sy)
measured.append((mx, my, measured_lv))
color_patches.append(name)
delta_e_values.append(delta_e)
avg_delta_e = float(np.mean(delta_e_values))
max_delta_e = float(np.max(delta_e_values))
min_delta_e = float(np.min(delta_e_values))
return {
"color_patches": color_patches,
"delta_e_values": delta_e_values,
"color_measurements": measured,
"avg_delta_e": avg_delta_e,
"max_delta_e": max_delta_e,
"min_delta_e": min_delta_e,
"excellent_count": sum(1 for value in delta_e_values if value < 3),
"good_count": sum(1 for value in delta_e_values if 3 <= value < 5),
"poor_count": sum(1 for value in delta_e_values if value >= 5),
"avg_delta_e_gray": float(np.mean(delta_e_values[0:5])),
"avg_delta_e_colorchecker": float(np.mean(delta_e_values[5:23])),
"avg_delta_e_saturated": float(np.mean(delta_e_values[23:29])),
"target_gamma": 2.2,
}
def main():
parser = argparse.ArgumentParser(description="Generate an offline color accuracy demo PNG.")
parser.add_argument(
"--output",
type=Path,
default=Path(__file__).resolve().parent / "demo_outputs" / "accuracy_demo.png",
help="Output PNG path.",
)
parser.add_argument(
"--test-type",
choices=["sdr_movie", "hdr_movie", "screen_module"],
default="sdr_movie",
help="Test type used for the title and standard color set.",
)
args = parser.parse_args()
args.output.parent.mkdir(parents=True, exist_ok=True)
fig = plt.Figure(figsize=(14, 8), dpi=120, tight_layout=False)
app = _DemoApp(fig)
accuracy_data = _build_demo_data(args.test_type)
plot_accuracy(app, accuracy_data, args.test_type)
fig.savefig(args.output, dpi=220)
print(f"Saved demo image to: {args.output}")
if __name__ == "__main__":
main()

Binary file not shown.

After

Width:  |  Height:  |  Size: 545 KiB

224
tools/refactor_to_mixins.py Normal file
View File

@@ -0,0 +1,224 @@
"""一次性脚本:将外置模块中的 `def f(self, ...)` 自由函数转换为 Mixin 方式。
操作:
1. 给所有顶层 `def f(self, ...)` 加 `self: "PQAutomationApp"` 注解(仅注解,不移动)。
2. 在文件顶部(首个 def/class 之前、import 块之后)插入 TYPE_CHECKING 块。
3. 在文件末尾追加 `class XxxMixin:` 把这些函数作为类属性挂上。
不会改变:
- 函数体(包括内部 `_xxx(self, ...)` 直接调用)。
- 已存在的类、模块级常量。
"""
from __future__ import annotations
import ast
import io
import os
import re
import sys
import tokenize
from dataclasses import dataclass
# 文件 -> Mixin 类名
TARGETS: dict[str, str] = {
"app/config_io.py": "ConfigIOMixin",
"app/views/chart_frame.py": "ChartFrameMixin",
"app/views/panels/main_layout.py": "MainLayoutMixin",
"app/views/panels/cct_panel.py": "CctPanelMixin",
"app/device/connection.py": "DeviceConnectionMixin",
"app/views/panels/custom_template_panel.py": "CustomTemplatePanelMixin",
"app/views/panels/side_panels.py": "SidePanelsMixin",
"app/views/panels/ai_image_panel.py": "AIImagePanelMixin",
"app/views/panels/single_step_panel.py": "SingleStepPanelMixin",
"app/views/panels/pantone_baseline_panel.py": "PantoneBaselinePanelMixin",
"app/tests/local_dimming.py": "LocalDimmingMixin",
"app/views/panel_manager.py": "PanelManagerMixin",
"app/runner/test_runner.py": "TestRunnerMixin",
"app/plots/plot_gamut.py": "PlotGamutMixin",
"app/plots/plot_gamma.py": "PlotGammaMixin",
"app/plots/plot_eotf.py": "PlotEotfMixin",
"app/plots/plot_cct.py": "PlotCctMixin",
"app/plots/plot_contrast.py": "PlotContrastMixin",
"app/plots/plot_accuracy.py": "PlotAccuracyMixin",
}
TYPE_CHECKING_BLOCK = (
"from typing import TYPE_CHECKING\n"
"\n"
"if TYPE_CHECKING:\n"
" from pqAutomationApp import PQAutomationApp\n"
"\n"
)
@dataclass
class SelfFunc:
name: str
lineno: int # 1-based, line of `def`
col_offset: int
end_lineno: int
def_line_idx: int # 0-based line index of `def ...` line
self_token_line_idx: int # 0-based line index of `self`
self_token_col: int
def _read(path: str) -> str:
with open(path, "rb") as f:
data = f.read()
# 去 BOM
if data.startswith(b"\xef\xbb\xbf"):
data = data[3:]
return data.decode("utf-8")
def _write(path: str, text: str) -> None:
with open(path, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
def _locate_self_token(src_lines: list[str], def_line_idx: int) -> tuple[int, int] | None:
"""在 def 行(可能多行签名)中定位首个参数 `self` 的位置。"""
# 用 tokenize 解析从 def 行开始的片段
snippet = "".join(src_lines[def_line_idx:])
try:
tokens = list(tokenize.generate_tokens(io.StringIO(snippet).readline))
except tokenize.TokenizeError:
return None
saw_open_paren = False
for tok in tokens:
if tok.type == tokenize.OP and tok.string == "(":
saw_open_paren = True
continue
if saw_open_paren and tok.type == tokenize.NAME and tok.string == "self":
# tok.start 是 (row, col) 相对于 snippet1-based row
row_in_snippet = tok.start[0] - 1
col = tok.start[1]
return (def_line_idx + row_in_snippet, col)
if saw_open_paren and tok.type == tokenize.OP and tok.string in (")", ","):
# 第一个参数不是 self —— 跳过
return None
return None
def collect_self_funcs(src: str) -> tuple[ast.Module, list[SelfFunc]]:
tree = ast.parse(src)
src_lines = src.splitlines(keepends=True)
results: list[SelfFunc] = []
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.args.args and node.args.args[0].arg == "self":
def_idx = node.lineno - 1
pos = _locate_self_token(src_lines, def_idx)
if pos is None:
continue
results.append(SelfFunc(
name=node.name,
lineno=node.lineno,
col_offset=node.col_offset,
end_lineno=node.end_lineno or node.lineno,
def_line_idx=def_idx,
self_token_line_idx=pos[0],
self_token_col=pos[1],
))
return tree, results
def annotate_self(src: str, funcs: list[SelfFunc]) -> str:
"""把每个 def 的首个 `self` 形参替换为 `self: "PQAutomationApp"`。"""
lines = src.splitlines(keepends=True)
# 从后往前替换,避免行号变动
for fn in sorted(funcs, key=lambda f: -f.self_token_line_idx):
line = lines[fn.self_token_line_idx]
col = fn.self_token_col
# 检查后续是否已经有注解
after = line[col + len("self"):]
# 已经注解过则跳过
m = re.match(r"\s*:", after)
if m:
continue
new_line = line[:col] + 'self: "PQAutomationApp"' + line[col + len("self"):]
lines[fn.self_token_line_idx] = new_line
return "".join(lines)
def ensure_type_checking_block(src: str) -> str:
if "from pqAutomationApp import PQAutomationApp" in src:
return src
# 找到首个非 docstring / 非注释 / 非 import 的位置:
# 简单策略:在最后一个 import 行之后插入;若没有 import则在 docstring 之后插入。
lines = src.splitlines(keepends=True)
insert_idx = 0
in_docstring = False
doc_quote: str | None = None
last_import_idx = -1
for i, line in enumerate(lines):
stripped = line.lstrip()
if i == 0 and (stripped.startswith('"""') or stripped.startswith("'''")):
q = stripped[:3]
doc_quote = q
if stripped.count(q) >= 2 and len(stripped) > 3:
# 单行 docstring
last_import_idx = max(last_import_idx, i)
continue
in_docstring = True
continue
if in_docstring:
if doc_quote and doc_quote in line:
in_docstring = False
last_import_idx = max(last_import_idx, i)
continue
if stripped.startswith("import ") or stripped.startswith("from "):
last_import_idx = i
continue
if stripped == "" or stripped.startswith("#"):
continue
# 遇到第一个真实代码
break
insert_idx = last_import_idx + 1
new_lines = lines[:insert_idx] + ["\n", TYPE_CHECKING_BLOCK] + lines[insert_idx:]
return "".join(new_lines)
def append_mixin(src: str, mixin_name: str, func_names: list[str]) -> str:
if f"class {mixin_name}" in src:
return src
body_lines = []
body_lines.append("")
body_lines.append("")
body_lines.append(f"class {mixin_name}:")
body_lines.append(f' """由 tools/refactor_to_mixins.py 自动生成。')
body_lines.append(" 把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。")
body_lines.append(' """')
for name in func_names:
body_lines.append(f" {name} = {name}")
text = "\n".join(body_lines) + "\n"
if not src.endswith("\n"):
src += "\n"
return src + text
def process(path: str, mixin_name: str) -> None:
src = _read(path)
tree, funcs = collect_self_funcs(src)
if not funcs:
print(f" -> skip (no self-funcs)")
return
func_names = [f.name for f in funcs]
new_src = annotate_self(src, funcs)
new_src = ensure_type_checking_block(new_src)
new_src = append_mixin(new_src, mixin_name, func_names)
_write(path, new_src)
print(f" -> {mixin_name} with {len(func_names)} methods")
def main():
root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
os.chdir(root)
for rel, mixin in TARGETS.items():
print(f"Processing {rel}")
process(rel, mixin)
if __name__ == "__main__":
main()