Compare commits

..

15 Commits

Author SHA1 Message Date
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
44 changed files with 8346 additions and 1495 deletions

View File

@@ -1,4 +1,4 @@
"""配置文件 I/OStep 4 重构)。 """配置文件 I/OStep 4 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app` 从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。 以保留原有 `self.xxx` 属性访问不变。
@@ -8,7 +8,13 @@ import json
import os import os
import sys 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 return config_file
def load_pq_config(self): def load_pq_config(self: "PQAutomationApp"):
"""加载PQ配置兼容打包后的程序""" """加载PQ配置兼容打包后的程序"""
try: try:
# 使用 self.config_file已经是动态路径 # 使用 self.config_file已经是动态路径
@@ -48,7 +54,7 @@ def load_pq_config(self):
self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error") self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error")
def save_pq_config(self): def save_pq_config(self: "PQAutomationApp"):
"""保存PQ配置兼容打包后的程序""" """保存PQ配置兼容打包后的程序"""
try: try:
# 确保目录存在 # 确保目录存在
@@ -61,7 +67,7 @@ def save_pq_config(self):
self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error") self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error")
def clear_config_file(self): def clear_config_file(self: "PQAutomationApp"):
"""清理配置文件(兼容打包后的程序)""" """清理配置文件(兼容打包后的程序)"""
from tkinter import messagebox from tkinter import messagebox
@@ -82,3 +88,13 @@ def clear_config_file(self):
self.log_gui.log(f"配置文件清理失败: {str(e)}", level="error") 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,300 @@
"""设备连接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 threading
import time import time
from tkinter import messagebox from tkinter import messagebox
from typing import TYPE_CHECKING
from app.ucd_domain import ConnectionChanged, UcdError
from drivers.caSerail import CASerail from drivers.caSerail import CASerail
from drivers.ucd_driver import DeviceInfo
def get_available_ucd_ports(self): from typing import TYPE_CHECKING
"""获取可用的UCD端口列表"""
return self.ucd.search_device() if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def get_available_com_ports(self): if TYPE_CHECKING:
"""获取可用的COM端口列表""" from app.ucd_domain import EventBus
from drivers.ucd_driver import UCD323Device
# ─── ConnectionController ────────────────────────────────────────
class ConnectionController:
"""统一管理 CA410 / UCD323 的连接、断开与可用端口枚举。
设计要点:
- 不持有 :mod:`tkinter` 控件;所有 GUI 更新由订阅事件总线后回调完成。
- UCD 连接经由 :class:`UCD323Device`,自动发布
:class:`ConnectionChanged` 事件。
- CA 连接同样发布 :class:`ConnectionChanged` 事件(带 ``serial=None``
指示器订阅时只看 ``connected`` 字段)。
"""
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:
return self._device.raw_controller.search_device() or []
except Exception as exc: # noqa: BLE001
self._log(f"枚举 UCD 设备失败: {exc}", level="error")
return []
def list_com_ports(self) -> list[str]:
try: try:
import serial.tools.list_ports import serial.tools.list_ports
ports = serial.tools.list_ports.comports() return [p.device for p in serial.tools.list_ports.comports()]
return [port.device for port in ports] except Exception as exc: # noqa: BLE001
except Exception as e: self._log(f"获取COM端口列表出错: {exc}", level="error")
self.log_gui.log(f"获取COM端口列表出错: {e}", level="error")
return [] return []
# -- UCD 连接 ------------------------------------------------
def refresh_com_ports(self): def connect_ucd(self, display: str) -> bool:
"""刷新COM端口列表""" """打开指定 UCD 设备。成功返回 True。"""
available_ports = self.get_available_com_ports() if self._device.state.name != "CLOSED":
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()
def check_com_connections(self):
"""检测COM端口连接状态"""
# 禁用连接按钮,防止重复点击
self.check_button.configure(state="disabled")
self.refresh_button.configure(state="disabled")
# 更新状态栏
self.status_var.set("正在检测连接...")
self.root.update()
# 使用线程进行连接检测
def check_connections():
try: try:
# 检测TV连接 self._device.close()
ucd_connected = self.check_port_connection(is_ucd=True) except Exception: # noqa: BLE001
self._dispatch_ui(
self.update_connection_indicator,
self.ucd_status_indicator, ucd_connected,
)
# 检测CA连接
ca_connected = self.check_port_connection(is_ucd=False)
self._dispatch_ui(
self.update_connection_indicator,
self.ca_status_indicator, ca_connected,
)
# 更新状态栏
self._dispatch_ui(self.status_var.set, "连接检测完成")
# 重新启用所有控件
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)
# 启动线程
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 pass
if not self.ucd.open(self.ucd_list_var.get()): try:
self.log_gui.log( self._device.open(DeviceInfo.parse(display))
f"设备 {self.ucd_list_var.get()} 异常UCD323连接失败"
, level="error")
return False
else:
return True return True
else: except UcdError as exc:
# 如果CA对象已存在先关闭 self._log(f"设备 {display} 异常UCD323 连接失败: {exc}", level="error")
if self.ca is not None: return False
except Exception as exc: # noqa: BLE001
self._log(f"UCD323 连接异常: {exc}", level="error")
return False
def disconnect_ucd(self) -> None:
try: try:
self.ca.close() self._device.close()
except: except Exception: # noqa: BLE001
pass pass
channel_value = self.ca_channel_var.get() # 旧 controller.status 也要清零,兼容仍读取它的代码
str_channel = f"{int(channel_value):02d}" try:
self.ca = CASerail() self._app.ucd.status = False
self.ca.open(self.config.device_config["ca_com"], 19200, 7, "E", 2) except Exception: # noqa: BLE001
# data = self.ca.set_xyLv_Display() pass
data = self.ca.set_all_Display() self._log("UCD连接已断开", level="info")
if data:
data = self.ca.setSynchMode(3) # -- CA 连接 -------------------------------------------------
data = self.ca.setMeasureSpeed(1)
if True: def connect_ca(self) -> bool:
"""打开 CA410。成功返回 True 并设置 ``app.ca``。"""
if self._app.ca is not None:
try:
self._app.ca.close()
except Exception: # noqa: BLE001
pass
self._app.ca = None
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) time.sleep(0.5)
data = self.ca.setZeroCalibration() ca.setZeroCalibration()
channel_value = self.ca_channel_var.get() channel_value = self._app.ca_channel_var.get()
str_channel = f"{int(channel_value):02d}" ca.setChannel(f"{int(channel_value):02d}")
data = self.ca.setChannel(str_channel) self._app.ca = ca
self._bus.publish(ConnectionChanged(True, None))
return True return True
else: except Exception as exc: # noqa: BLE001
self.log_gui.log( self._log(f"CA410 连接失败: {exc}", level="error")
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 return False
def disconnect_ca(self) -> None:
def enable_com_widgets(self): if self._app.ca is None:
"""重新启用所有控件""" return
self.check_button.configure(state="normal")
self.refresh_button.configure(state="normal")
def disconnect_com_connections(self):
"""断开所有串口连接"""
try: try:
# 断开TV连接 self._app.ca.close()
if self.ucd.status: except Exception: # noqa: BLE001
try:
self.ucd.close()
except:
pass pass
finally: self._app.ca = None
self.ucd.status = False self._bus.publish(ConnectionChanged(False, None))
self.log_gui.log("UCD连接已断开", level="info") self._log("CA连接已断开", level="info")
# 断开CA连接 # -- 一次性入口 ----------------------------------------------
if self.ca is not None:
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: try:
self.ca.close() ucd_ok = self.connect_ucd(app.ucd_list_var.get())
except: app._dispatch_ui(
pass app.update_connection_indicator,
finally: app.ucd_status_indicator, ucd_ok,
self.ca = None )
self.log_gui.log("CA连接已断开", level="info") 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()
self.enable_com_widgets()
self.ucd_status_indicator.config(bg="gray")
self.ca_status_indicator.config(bg="gray")
self.status_var.set("串口连接已断开")
except Exception as e: def disconnect_all(self) -> None:
self.log_gui.log(f"断开连接时发生错误: {str(e)}", level="info") try:
messagebox.showerror("错误", f"断开连接失败: {str(e)}") self.disconnect_ucd()
self.disconnect_ca()
self._enable_widgets()
self._app.ucd_status_indicator.config(bg="gray")
self._app.ca_status_indicator.config(bg="gray")
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)
if hasattr(app, "ucd_status_indicator"):
app.ucd_status_indicator.config(bg="gray")
if hasattr(app, "ca_status_indicator"):
app.ca_status_indicator.config(bg="gray")
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):
indicator.config(bg="green" if connected else "red")
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()
__all__ = [
"ConnectionController",
# 兼容层
"get_available_ucd_ports",
"get_available_com_ports",
"refresh_com_ports",
"check_com_connections",
"update_connection_indicator",
"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
check_port_connection = check_port_connection
enable_com_widgets = enable_com_widgets
disconnect_com_connections = disconnect_com_connections

View File

@@ -2,6 +2,21 @@
import os 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): def _gamut_refs_for_type(test_type):
"""按测试类型返回需要导出的参考色域列表。""" """按测试类型返回需要导出的参考色域列表。"""
if test_type == "sdr_movie": if test_type == "sdr_movie":
@@ -70,7 +85,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue continue
per_ref_name = f"色域测试结果_{ref}.png" per_ref_name = f"色域测试结果_{ref}.png"
path = os.path.join(result_dir, per_ref_name) 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}") log(f"已保存: {per_ref_name}")
finally: finally:
ref_var.set(original_ref) ref_var.set(original_ref)
@@ -82,7 +97,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue continue
path = os.path.join(result_dir, filename) path = os.path.join(result_dir, filename)
if default_bbox: if default_bbox:
fig.savefig(path, dpi=300) _save_with_light_background(fig, path, dpi=300)
else: else:
fig.savefig(path, dpi=300, bbox_inches="tight") _save_with_light_background(fig, path, dpi=300, bbox_inches="tight")
log(f"已保存: {filename}") log(f"已保存: {filename}")

View File

@@ -1,92 +1,33 @@
"""色准测试结果绘制。 """色准测试结果绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_accuracy 原样搬迁。 布局:
- 左侧:大尺寸 ColorChecker 条形图(每个条形使用对应颜色)。
- 右侧CIE 1976 u'v' 色度图(目标点/实测点/偏移连线)。
""" """
from typing import TYPE_CHECKING
from matplotlib.patches import Rectangle from matplotlib.patches import Rectangle
from matplotlib.lines import Line2D
from app.plots.gamut_background import get_cie1976_background
from app.tests.color_accuracy import get_accuracy_color_standards
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_accuracy(self, accuracy_data, test_type): # ============================================================
"""绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma""" # 常量
# ============================================================
self.accuracy_ax.clear() _COLOR_MAP = {
self.accuracy_ax.set_xlim(0, 1)
self.accuracy_ax.set_ylim(0, 1)
self.accuracy_ax.axis("off")
self.accuracy_fig.subplots_adjust(
left=0.05,
right=0.95,
top=0.95,
bottom=0.02,
)
# 获取色准数据
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)
# 获取 Gamma 值
target_gamma = accuracy_data.get("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
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma}"
self.accuracy_fig.suptitle(
title,
fontsize=11,
y=0.98,
fontweight="bold",
color="#111111",
)
# ========== 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", "White": "#FFFFFF",
"Gray 80": "#E6E6E6", "Gray 80": "#E6E6E6",
"Gray 65": "#D1D1D1", "Gray 65": "#D1D1D1",
"Gray 50": "#BABABA", "Gray 50": "#BABABA",
"Gray 35": "#9E9E9E", "Gray 35": "#9E9E9E",
# 饱和色 "Black": "#000000",
"100% Red": "#FF0000",
"100% Green": "#00FF00",
"100% Blue": "#0000FF",
"100% Cyan": "#00FFFF",
"100% Magenta": "#FF00FF",
"100% Yellow": "#FFFF00",
# ColorChecker 颜色
"Dark Skin": "#735242", "Dark Skin": "#735242",
"Light Skin": "#C29682", "Light Skin": "#C29682",
"Blue Sky": "#5E7A9C", "Blue Sky": "#5E7A9C",
@@ -105,217 +46,319 @@ def plot_accuracy(self, accuracy_data, test_type):
"Yellow (Legacy)": "#EDC721", "Yellow (Legacy)": "#EDC721",
"Magenta (Legacy)": "#BA5491", "Magenta (Legacy)": "#BA5491",
"Cyan (Legacy)": "#0085A3", "Cyan (Legacy)": "#0085A3",
"100% Red": "#FF0000",
"100% Green": "#00FF00",
"100% Blue": "#0000FF",
"100% Cyan": "#00FFFF",
"100% Magenta": "#FF00FF",
"100% Yellow": "#FFFF00",
} }
patch_color = color_map.get(color_name, "#808080")
# ΔE 等级颜色 def _grade_color(delta_e: float) -> str:
if delta_e < 3: if delta_e < 3:
edge_color = "green" return "#1FAE45" # 绿
elif delta_e < 5: if delta_e < 5:
edge_color = "orange" return "#E08A00" # 橙
else: return "#D81B1B" # 红
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)
# ========== 标注色块名称(上方)========== def _xy_to_uv(x: float, y: float):
self.accuracy_ax.text( """CIE 1931 xy → CIE 1976 u'v'"""
x + patch_width / 2, denom = -2.0 * x + 12.0 * y + 3.0
y + patch_height + 0.015, if abs(denom) < 1e-10:
color_name, return 0.0, 0.0
ha="center", return (4.0 * x) / denom, (9.0 * y) / denom
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" # ============================================================
# 子图:左侧 Calman 风格面板
# ============================================================
self.accuracy_ax.text( def _draw_left_panel(ax, color_patches, delta_e_values, font_scale=1.0, dark_mode=False):
x + patch_width / 2, """左侧仅保留大条形图。"""
y + patch_height / 2, ax.clear()
f"ΔE\n{delta_e:.2f}",
ha="center", n = len(color_patches)
va="center", if n == 0:
fontsize=5.2, ax.set_axis_off()
fontweight="bold", return
color=text_color,
transform=self.accuracy_ax.transAxes, y_pos = list(range(n))
bbox=dict( bar_colors = [_COLOR_MAP.get(name, "#888888") for name in color_patches]
boxstyle="round,pad=0.22", edge_colors = [_grade_color(dE) for dE in delta_e_values]
facecolor="white" if text_color == "black" else "black",
alpha=0.75, ax.barh(
edgecolor=edge_color, y_pos,
delta_e_values,
height=0.72,
color=bar_colors,
edgecolor=edge_colors,
linewidth=1.0, linewidth=1.0,
), zorder=3,
) )
# ========== 统计信息卡片(只保留外框)========== text_color = "#F3F5F7" if dark_mode else "#111111"
card_width = 0.84 bg_color = "#0F1115" if dark_mode else "#FFFFFF"
card_height = 0.15 spine_color = "#8C8F94" if dark_mode else "#9A9A9A"
card_x = 0.08
card_y = 0.01
info_card = Rectangle( ax.set_yticks(y_pos)
(card_x, card_y), ax.set_yticklabels(color_patches, fontsize=max(5, 7 * font_scale), color=text_color)
card_width, ax.invert_yaxis()
card_height,
transform=self.accuracy_ax.transAxes, x_max = max(15.0, max(delta_e_values) * 1.15)
facecolor="#F0F0F0", ax.set_xlim(0, x_max)
edgecolor="black", ax.tick_params(axis="x", labelsize=max(6, 8 * font_scale), colors=text_color)
linewidth=1.5, 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()
xmin, xmax, ymin, ymax = bbox
ax.imshow(
bg, extent=(xmin, xmax, ymin, ymax),
origin="upper", interpolation="bicubic",
zorder=0, aspect="auto",
) )
self.accuracy_ax.add_patch(info_card) 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"
self.accuracy_ax.text( sub_text_color = "#D3D7DD" if dark_mode else "#222222"
card_x + card_width / 2, tick_color = "#D3D7DD" if dark_mode else "#222222"
card_y + card_height - 0.008, legend_label_color = "#FFF" if dark_mode else "#111"
"色准统计5灰阶 + 18 ColorChecker + 6饱和色 | ΔE 2000 标准)", legend_bg = "#111" if dark_mode else "#FFFFFF"
ha="center", legend_edge = "#FFF" if dark_mode else "#333"
va="top", outer_edge = "#FFFFFF" if dark_mode else "#333333"
fontsize=7.5,
fontweight="bold", ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
color="#111111", ax.set_aspect("equal", adjustable="box")
transform=self.accuracy_ax.transAxes, 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 = _COLOR_MAP.get(name, "#FFFFFF")
# 目标点:仅空心方框(不填充标准颜色)
ax.scatter(
[s_u], [s_v],
s=56, marker="s",
facecolors="none", edgecolors=outer_edge,
linewidths=1.25, zorder=18,
)
# 实测点:白色外圈 + 内层圆点
ax.scatter(
[m_u], [m_v],
s=52, marker="o",
facecolors="none", edgecolors=outer_edge,
linewidths=1.0, zorder=19,
)
ax.scatter(
[m_u], [m_v],
s=24, marker="o",
facecolors=face, edgecolors="#111111",
linewidths=0.85, zorder=20,
) )
# ========== 统计内容(无内部框)========== legend_handles = [
stats_y = card_y + card_height * 0.55 Line2D([0], [0], marker="s", linestyle="none",
markerfacecolor="#CCCCCC", markeredgecolor=outer_edge,
# 左侧ΔE 统计 markersize=7, label="目标 (Target)"),
left_x = card_x + 0.02 Line2D([0], [0], marker="o", linestyle="none",
stats_text = [ markerfacecolor="#CCCCCC", markeredgecolor="#000000",
f"平均 ΔE: {avg_delta_e:.2f}", markersize=7, label="实测 (Actual)"),
f"最大 ΔE: {max_delta_e:.2f}",
f"最小 ΔE: {min_delta_e:.2f}",
] ]
leg = ax.legend(
handles=legend_handles,
loc="lower right", fontsize=max(6, 8 * font_scale),
framealpha=0.88, labelcolor=legend_label_color,
)
if leg is not None:
leg.get_frame().set_facecolor(legend_bg)
leg.get_frame().set_edgecolor(legend_edge)
leg.set_zorder(50)
for i, text in enumerate(stats_text):
self.accuracy_ax.text( def _draw_result_judgement(ax, accuracy_data, font_scale=1.0, dark_mode=False):
left_x, """底部结果条"""
stats_y - i * 0.030, ax.clear()
text, ax.set_xlim(0, 1)
ha="left", ax.set_ylim(0, 1)
va="center", ax.axis("off")
fontsize=7,
fontweight="bold", avg = accuracy_data.get("avg_delta_e", 0.0)
color="#111111", mx = accuracy_data.get("max_delta_e", 0.0)
transform=self.accuracy_ax.transAxes,
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,
) )
# 中间:色块统计
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,
)
self.accuracy_ax.text( def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
middle_x, """绘制色准测试结果 - Calman 风格(色块 + CIE 1976 u'v' + 统计)。"""
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,
)
self.accuracy_ax.text( fig = self.accuracy_fig
middle_x, fig.clear()
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:
right_x = card_x + card_width - 0.02 from app.views.theme_manager import is_dark
dark_mode = is_dark()
except Exception:
dark_mode = False
if avg_delta_e < 2: fig.patch.set_facecolor("#1B1F24" if dark_mode else "#FFFFFF")
grade = "专业级"
grade_icon = "★★★" # 根据当前画布像素尺寸动态缩放字体,避免窗口缩小时文字挤压重叠。
grade_color = "darkgreen" font_scale = 1.0
elif avg_delta_e < 3: try:
grade = "优秀" canvas_widget = self.accuracy_canvas.get_tk_widget()
grade_icon = "OK" cw = max(1, int(canvas_widget.winfo_width()))
grade_color = "green" ch = max(1, int(canvas_widget.winfo_height()))
elif avg_delta_e < 5: font_scale = min(cw / 1000.0, ch / 600.0)
grade = "良好" font_scale = max(0.60, min(1.0, font_scale))
grade_icon = "PASS" except Exception:
grade_color = "orange" 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)
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: else:
grade = "需要校准" title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma}"
grade_icon = "[Error]"
grade_color = "red"
self.accuracy_ax.text( title_color = "#F3F5F7" if dark_mode else "#111"
right_x, fig.suptitle(
stats_y + 0.020, title,
"总体评价:", fontsize=max(8, 11 * font_scale),
ha="right", y=0.975,
va="bottom",
fontsize=7,
fontweight="bold", fontweight="bold",
color="#111111", color=title_color,
transform=self.accuracy_ax.transAxes,
) )
self.accuracy_ax.text( gs = fig.add_gridspec(
right_x, 2, 2,
stats_y - 0.025, width_ratios=[1.12, 1.0],
f"{grade} {grade_icon}", height_ratios=[4.8, 0.48],
ha="right", left=0.08, right=0.985,
va="top", top=0.92, bottom=0.05,
fontsize=11, wspace=0.14, hspace=0.08,
fontweight="bold",
color=grade_color,
transform=self.accuracy_ax.transAxes,
) )
ax_left = fig.add_subplot(gs[0, 0])
ax_uv = fig.add_subplot(gs[0, 1])
ax_judge = fig.add_subplot(gs[1, :])
# 兼容外部对 self.accuracy_ax 的引用
self.accuracy_ax = ax_judge
_draw_left_panel(
ax_left,
color_patches,
delta_e_values,
font_scale=font_scale,
dark_mode=dark_mode,
)
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,
)
try:
self.update_accuracy_result_table(accuracy_data, standards)
except Exception:
pass
self.accuracy_canvas.draw() self.accuracy_canvas.draw()
self.chart_notebook.select(self.accuracy_chart_frame) self.chart_notebook.select(self.accuracy_chart_frame)
class PlotAccuracyMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_accuracy = plot_accuracy

View File

@@ -1,12 +1,18 @@
"""CCT / 色度一致性绘制。 """CCT / 色度一致性绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。
""" """
import numpy as np import numpy as np
from typing import TYPE_CHECKING
def plot_cct(self, test_type): if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_cct(self: "PQAutomationApp", test_type):
"""绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值""" """绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值"""
self.cct_fig.clear() self.cct_fig.clear()
@@ -322,3 +328,10 @@ def plot_cct(self, test_type):
self.chart_notebook.select(self.cct_chart_frame) self.chart_notebook.select(self.cct_chart_frame)
self.log_gui.log("xy 色度坐标图绘制完成", level="success") self.log_gui.log("xy 色度坐标图绘制完成", level="success")
class PlotCctMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_cct = plot_cct

View File

@@ -5,8 +5,14 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_contrast 原样搬迁
from matplotlib.patches import Rectangle from matplotlib.patches import Rectangle
from typing import TYPE_CHECKING
def plot_contrast(self, contrast_data, test_type): if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
"""绘制对比度测试结果 - 固定布局版本""" """绘制对比度测试结果 - 固定布局版本"""
# 清空并重置 # 清空并重置
@@ -165,3 +171,10 @@ def plot_contrast(self, contrast_data, test_type):
self.contrast_canvas.draw() self.contrast_canvas.draw()
self.chart_notebook.select(self.contrast_chart_frame) self.chart_notebook.select(self.contrast_chart_frame)
class PlotContrastMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_contrast = plot_contrast

View File

@@ -1,12 +1,18 @@
"""EOTF 曲线绘制HDR """EOTF 曲线绘制HDR
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。
""" """
import numpy as np import numpy as np
from typing import TYPE_CHECKING
def plot_eotf(self, L_bar, results_with_eotf_list, test_type): if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type):
"""绘制 EOTF 曲线 + 数据表格HDR 专用,包含实测亮度)""" """绘制 EOTF 曲线 + 数据表格HDR 专用,包含实测亮度)"""
# ========== 1. 清空并重置左侧曲线 ========== # ========== 1. 清空并重置左侧曲线 ==========
@@ -146,3 +152,10 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
pass pass
self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success") self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success")
class PlotEotfMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_eotf = plot_eotf

View File

@@ -1,12 +1,18 @@
"""Gamma 曲线绘制。 """Gamma 曲线绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。
""" """
import numpy as np import numpy as np
from typing import TYPE_CHECKING
def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type): if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def plot_gamma(self: "PQAutomationApp", L_bar, results_with_gamma_list, target_gamma, test_type):
"""绘制Gamma曲线 + 数据表格(包含实测亮度)""" """绘制Gamma曲线 + 数据表格(包含实测亮度)"""
# ========== 1. 清空并重置左侧曲线 ========== # ========== 1. 清空并重置左侧曲线 ==========
@@ -140,3 +146,10 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
self.chart_notebook.select(self.gamma_chart_frame) self.chart_notebook.select(self.gamma_chart_frame)
self.log_gui.log("Gamma曲线 + 数据表格绘制完成", level="success") 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, get_cie1976_background,
) )
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
# ============ 参考色域定义CIE 1931 xy============ # ============ 参考色域定义CIE 1931 xy============
_REF_GAMUTS_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( ax.text(
x_pos, y_pos, x_pos, y_pos,
f"{current_ref}\n覆盖率: {coverage:.1f}%", f"{current_ref}\n覆盖率: {coverage:.1f}%",
ha="right", va="bottom", ha="right", va="bottom",
fontsize=11, fontweight="bold", fontsize=11, fontweight="bold",
color="#FFF", color=text_color,
bbox=dict( bbox=dict(
boxstyle="round,pad=0.38", boxstyle="round,pad=0.38",
facecolor="#111", facecolor=box_face,
edgecolor="#FFF", edgecolor=box_edge,
linewidth=1.7, linewidth=1.7,
alpha=0.98, alpha=0.98,
), ),
zorder=30, zorder=30,
) )
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim): def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim, dark_mode):
ax.set_facecolor("#000") text = "#F4F6F8" if dark_mode else "#111"
ax.set_title(title, fontsize=12, fontweight="bold", color="#FFF", pad=8) grid = "#444" if dark_mode else "#B8BDC3"
ax.set_xlabel(xlabel, fontsize=10, color="#FFF") spine_color = "#888" if dark_mode else "#666"
ax.set_ylabel(ylabel, fontsize=10, color="#FFF") 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_xlim(*xlim)
ax.set_ylim(*ylim) ax.set_ylim(*ylim)
ax.set_aspect("equal", adjustable="datalim") ax.set_aspect("equal", adjustable="datalim")
ax.grid(True, linestyle=":", linewidth=0.7, color="#444", alpha=0.32) ax.grid(True, linestyle=":", linewidth=0.7, color=grid, alpha=0.32)
ax.tick_params(axis="both", labelsize=9, colors="#FFF") ax.tick_params(axis="both", labelsize=9, colors=text)
for spine in ax.spines.values(): for spine in ax.spines.values():
spine.set_color("#888") spine.set_color(spine_color)
spine.set_linewidth(0.8) spine.set_linewidth(0.8)
ax.set_clip_on(False) ax.set_clip_on(False)
@@ -193,16 +204,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_xy = self.gamut_ax_xy
ax_uv = self.gamut_ax_uv ax_uv = self.gamut_ax_uv
ax_xy.clear() ax_xy.clear()
ax_uv.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": if test_type == "screen_module":
@@ -260,6 +277,7 @@ def plot_gamut(self, results, coverage, test_type):
xlabel="x", ylabel="y", xlabel="x", ylabel="y",
xlim=(bbox_xy[0], bbox_xy[1]), xlim=(bbox_xy[0], bbox_xy[1]),
ylim=(bbox_xy[2], bbox_xy[3]), ylim=(bbox_xy[2], bbox_xy[3]),
dark_mode=dark_mode,
) )
for ref_name in other_refs: for ref_name in other_refs:
@@ -284,7 +302,8 @@ def plot_gamut(self, results, coverage, test_type):
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False) _draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
_draw_coverage_box( _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 +317,19 @@ def plot_gamut(self, results, coverage, test_type):
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY] codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY] codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
path = Path(verts, codes) 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) ax_xy.add_patch(patch)
legend = ax_xy.legend( legend = ax_xy.legend(
loc="upper right", fontsize=8.5, loc="upper right", fontsize=8.5,
framealpha=0.0, edgecolor="#000", fancybox=True, framealpha=0.0, edgecolor="#000", fancybox=True,
labelcolor="#FFF" labelcolor="#FFF" if dark_mode else "#111"
) )
legend.set_zorder(200) legend.set_zorder(200)
legend.get_frame().set_facecolor("#000") legend.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
legend.get_frame().set_alpha(0.5) legend.get_frame().set_alpha(0.5 if dark_mode else 0.78)
legend.get_frame().set_edgecolor("#FFF") legend.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
ax_xy.add_artist(legend) ax_xy.add_artist(legend)
except Exception as e: except Exception as e:
@@ -329,6 +349,7 @@ def plot_gamut(self, results, coverage, test_type):
xlabel="u'", ylabel="v'", xlabel="u'", ylabel="v'",
xlim=(bbox_uv[0], bbox_uv[1]), xlim=(bbox_uv[0], bbox_uv[1]),
ylim=(bbox_uv[2], bbox_uv[3]), ylim=(bbox_uv[2], bbox_uv[3]),
dark_mode=dark_mode,
) )
measured_uv = None measured_uv = None
@@ -362,7 +383,8 @@ def plot_gamut(self, results, coverage, test_type):
_draw_measured_triangle(ax_uv, measured_uv, uv_space=True) _draw_measured_triangle(ax_uv, measured_uv, uv_space=True)
_draw_coverage_box( _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] u0, u1 = bbox_uv[0], bbox_uv[1]
@@ -374,18 +396,19 @@ def plot_gamut(self, results, coverage, test_type):
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY] codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY] codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
path = Path(verts, codes) 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) ax_uv.add_patch(patch)
legend_uv = ax_uv.legend( legend_uv = ax_uv.legend(
loc="upper right", fontsize=8.5, loc="upper right", fontsize=8.5,
framealpha=0.0, edgecolor="#000", fancybox=True, framealpha=0.0, edgecolor="#000", fancybox=True,
labelcolor="#FFF" labelcolor="#FFF" if dark_mode else "#111"
) )
legend_uv.set_zorder(200) legend_uv.set_zorder(200)
legend_uv.get_frame().set_facecolor("#000") legend_uv.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
legend_uv.get_frame().set_alpha(0.72) legend_uv.get_frame().set_alpha(0.72 if dark_mode else 0.82)
legend_uv.get_frame().set_edgecolor("#FFF") legend_uv.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
ax_uv.add_artist(legend_uv) ax_uv.add_artist(legend_uv)
except Exception as e: except Exception as e:
@@ -408,3 +431,10 @@ def plot_gamut(self, results, coverage, test_type):
self.sync_gamut_toolbar() self.sync_gamut_toolbar()
self.log_gui.log("色域图绘制完成", level="success") self.log_gui.log("色域图绘制完成", level="success")
class PlotGamutMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_gamut = plot_gamut

View File

@@ -74,12 +74,19 @@ _DEFAULT_CCT_PARAMS = {
"y_ideal": 0.3290, "y_ideal": 0.3290,
"y_tolerance": 0.003, "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 = { _DEFAULT_GAMUT_REFERENCE = {
"screen_module": "DCI-P3", "screen_module": "DCI-P3",
"sdr_movie": "BT.709", "sdr_movie": "BT.709",
"hdr_movie": "BT.2020", "hdr_movie": "BT.2020",
"local_dimming": "DCI-P3",
} }
_DEFAULT_TEST_TYPES = { _DEFAULT_TEST_TYPES = {
@@ -87,6 +94,7 @@ _DEFAULT_TEST_TYPES = {
"name": "屏模组性能测试", "name": "屏模组性能测试",
"test_items": ["gamut", "gamma", "cct", "contrast"], "test_items": ["gamut", "gamma", "cct", "contrast"],
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
@@ -96,6 +104,7 @@ _DEFAULT_TEST_TYPES = {
"name": "SDR Movie测试", "name": "SDR Movie测试",
"test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"], "test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"],
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
@@ -105,11 +114,22 @@ _DEFAULT_TEST_TYPES = {
"name": "HDR Movie测试", "name": "HDR Movie测试",
"test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"], "test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"],
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
"patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"}, "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 = { _PATTERN_RGB = {
@@ -221,6 +241,401 @@ def get_pattern(name: str) -> dict:
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json") 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: class PQConfig:
def __init__(self, current_test_type="screen_module"): def __init__(self, current_test_type="screen_module"):
@@ -300,7 +715,16 @@ class PQConfig:
def from_dict(self, config_dict): def from_dict(self, config_dict):
"""从字典加载配置""" """从字典加载配置"""
self.current_test_type = config_dict.get("current_test_type", "screen_module") 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.device_config = config_dict.get("device_config", self.device_config)
self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern) 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(): def backgroud_style_set():
style = ttk.Style() style = ttk.Style() # noqa: F841 - 保持原副作用:确保 Style 实例化
# 移除背景色设置,使用默认背景色 # 现代化样式集中注册Card / ConfigHeader / Toolbar / StatusBar / Sidebar 等)
style.configure( from app.views.modern_styles import apply_modern_styles
"SidebarSelected.TButton", apply_modern_styles()
# anchor="w",
padding=10,
background="#005470",
)

View File

@@ -1,4 +1,4 @@
"""测试执行runner相关逻辑Step 5 重构)。 """测试执行runner相关逻辑Step 5 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app` 从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。 以保留原有 `self.xxx` 属性访问不变。
@@ -15,7 +15,13 @@ import numpy as np
import algorithm.pq_algorithm as pq_algorithm import algorithm.pq_algorithm as pq_algorithm
from app.pq.pq_result import PQResult 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 的结果,并设为当前活跃 # 通过 PQResultStore 创建/替换指定 test_type 的结果,并设为当前活跃
self.results.new(test_type, test_name) 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: try:
self.log_gui.log(f"开始执行{self.get_test_type_name(test_type)}测试", level="info") 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) self.run_sdr_movie_test(test_items)
elif test_type == "hdr_movie": elif test_type == "hdr_movie":
self.run_hdr_movie_test(test_items) self.run_hdr_movie_test(test_items)
elif test_type == "local_dimming":
self.log_gui.log(
"Local Dimming 为手动模式,请在 Local Dimming 面板发送图案并采集亮度",
level="info",
)
# 测试完成后更新UI状态 # 测试完成后更新UI状态
if self.testing: # 如果没有被中途停止 if self.testing: # 如果没有被中途停止
@@ -63,7 +74,7 @@ def run_test(self, test_type, test_items):
self._dispatch_ui(self.on_test_error) 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") 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) 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 测试 - 升级版""" """执行客户定制 SDR 测试 - 升级版"""
self.log_gui.log("执行客户定制 SDR 测试...", level="info") 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) 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测试""" """执行SDR Movie测试"""
self.log_gui.log("执行SDR Movie测试...", level="info") 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") 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测试""" """执行HDR Movie测试"""
self.log_gui.log("执行HDR Movie测试...", level="info") 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") self.test_color_accuracy("hdr_movie")
def send_fix_pattern(self, mode): def send_fix_pattern(self: "PQAutomationApp", mode):
"""发送固定图案并采集数据 - 支持不同测试类型的信号格式""" """发送固定图案并采集数据 - 支持不同测试类型的信号格式"""
results = [] results = []
@@ -309,9 +320,26 @@ def send_fix_pattern(self, mode):
self.log_gui.log("=" * 50, level="separator") self.log_gui.log("=" * 50, level="separator")
# 信号格式设置后等待电视重新锁定 HDMI 信号 # 判定信号是否变化(决定 settle 长度)。
# format_changed=True 表示本次 set_video_mode 的参数与上次不同TV 需要重新锁定 # - SDR/HDRprepare_session 内部已调用 ``apply_signal_format`` → ``set_video_mode``
format_changed = getattr(getattr(self, "ucd", None), "format_changed", True) # 此时 ``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: if format_changed:
signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 5.0))) signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 5.0)))
self.log_gui.log( self.log_gui.log(
@@ -355,6 +383,11 @@ def send_fix_pattern(self, mode):
else: else:
self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info") self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info")
# 首图已在 prime 阶段发送并经 signal_settle 稳定,无需重发也无需再等
# settle_time后续 pattern 走正常发图 + 等待。
if i == 0:
pass
else:
self.pattern_service.send_session_pattern(session, i) self.pattern_service.send_session_pattern(session, i)
time.sleep(settle_time) time.sleep(settle_time)
@@ -438,7 +471,7 @@ def send_fix_pattern(self, mode):
return None return None
def test_custom_sdr(self): def test_custom_sdr(self: "PQAutomationApp"):
"""执行客户定制 SDR 测试 - 升级版""" """执行客户定制 SDR 测试 - 升级版"""
self.log_gui.log("执行客户定制 SDR 测试...", level="info") self.log_gui.log("执行客户定制 SDR 测试...", level="info")
results = self.send_fix_pattern("custom") results = self.send_fix_pattern("custom")
@@ -449,7 +482,7 @@ def test_custom_sdr(self):
self.log_gui.log(f"客户模板采集完成,共 {len(results)} 组数据", level="success") 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.log_gui.log("开始测试色域...", level="info")
self.results.start_test_item("gamut") self.results.start_test_item("gamut")
@@ -607,7 +640,7 @@ def test_gamut(self, test_type):
raise raise
def test_gamma(self, test_type, gray_data=None): def test_gamma(self: "PQAutomationApp", test_type, gray_data=None):
"""测试Gamma曲线 """测试Gamma曲线
Args: Args:
@@ -698,7 +731,7 @@ def test_gamma(self, test_type, gray_data=None):
raise raise
def test_eotf(self, test_type, gray_data=None): def test_eotf(self: "PQAutomationApp", test_type, gray_data=None):
"""测试 EOTF 曲线HDR 专用) """测试 EOTF 曲线HDR 专用)
Args: Args:
@@ -781,7 +814,7 @@ def test_eotf(self, test_type, gray_data=None):
raise 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.log_gui.log("开始测试色度一致性...", level="info")
self.results.start_test_item("cct") self.results.start_test_item("cct")
@@ -821,7 +854,7 @@ def test_cct(self, test_type, gray_data=None):
raise raise
def test_contrast(self, test_type, gray_data=None): def test_contrast(self: "PQAutomationApp", test_type, gray_data=None):
"""测试对比度 """测试对比度
Args: Args:
@@ -885,7 +918,7 @@ def test_contrast(self, test_type, gray_data=None):
raise raise
def test_color_accuracy(self, test_type): def test_color_accuracy(self: "PQAutomationApp", test_type):
"""测试色准 - 使用手工实现的 ΔE 2000应用 Gamma""" """测试色准 - 使用手工实现的 ΔE 2000应用 Gamma"""
# ========== Gamma 参考值 ========== # ========== Gamma 参考值 ==========
@@ -1045,7 +1078,7 @@ def test_color_accuracy(self, test_type):
self.log_gui.log("色准测试完成", level="success") self.log_gui.log("色准测试完成", level="success")
def on_test_completed(self): def on_test_completed(self: "PQAutomationApp"):
"""测试完成后的UI更新""" """测试完成后的UI更新"""
self.testing = False self.testing = False
self.start_btn.config(state=tk.NORMAL) self.start_btn.config(state=tk.NORMAL)
@@ -1188,7 +1221,7 @@ def on_test_completed(self):
messagebox.showinfo("完成", "测试已完成!") messagebox.showinfo("完成", "测试已完成!")
def on_custom_template_test_completed(self): def on_custom_template_test_completed(self: "PQAutomationApp"):
"""客户模板测试完成后的UI更新""" """客户模板测试完成后的UI更新"""
self.testing = False self.testing = False
self.set_custom_result_table_locked(False) self.set_custom_result_table_locked(False)
@@ -1209,7 +1242,7 @@ def on_custom_template_test_completed(self):
messagebox.showinfo("完成", "客户模板测试已完成!") messagebox.showinfo("完成", "客户模板测试已完成!")
def get_current_test_result(self): def get_current_test_result(self: "PQAutomationApp"):
"""获取当前测试结果""" """获取当前测试结果"""
test_type = self.test_type_var.get() test_type = self.test_type_var.get()
test_items = self.get_selected_test_items() test_items = self.get_selected_test_items()
@@ -1241,7 +1274,7 @@ def get_current_test_result(self):
return result return result
def on_test_error(self): def on_test_error(self: "PQAutomationApp"):
"""测试出错后的UI更新""" """测试出错后的UI更新"""
self.testing = False self.testing = False
self.set_custom_result_table_locked(False) self.set_custom_result_table_locked(False)
@@ -1262,3 +1295,27 @@ def on_test_error(self):
messagebox.showerror("错误", "测试过程中发生错误,请查看日志") 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 图片生成服务:后端请求 + 本地缓存管理。 """AI 图片生成服务:后端请求 + 本地缓存管理。
后端接口(测试环境): 后端接口(生产/测试环境):
POST {API_BASE_URL}{API_PATH} POST {API_BASE_URL}{API_GENERATE_PATH}
body: {"user_message": str, "session_id": str} body: {"user_message": str, "session_id": str, "upload_image_url"?: str}
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}} 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`` 侧车记录。 缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
""" """
@@ -41,10 +46,20 @@ _META_SUFFIX = ".json"
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp") _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_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
API_PATH = "api/v1/pqtest/generate" API_GENERATE_PATH = "api/v1/pqtest/generate"
API_TIMEOUT = 300.0 # 后端最长 60s留余量 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`` 重置 # 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置
_session_id: str = str(uuid.uuid4()) _session_id: str = str(uuid.uuid4())
@@ -133,9 +148,9 @@ class AIImageRecord:
# ---------- 后端 API ---------- # ---------- 后端 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 + "/" 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: def _pretty_json_text(value) -> str:
@@ -150,22 +165,33 @@ def _pretty_json_text(value) -> str:
return "" if value is None else str(value) return "" if value is None else str(value)
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str: def _call_pqtest_generate(
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。""" user_message: str,
payload = json.dumps( session_id: str,
{"user_message": user_message, upload_image_url: Optional[str] = None,
"session_id": session_id}, timeout: float = API_TIMEOUT,
ensure_ascii=False, ) -> str:
).encode("utf-8") """调用后端 ``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 = { request_headers = {
"Content-Type": "application/json; charset=utf-8", "Content-Type": "application/json; charset=utf-8",
"Accept": "application/json", "Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0", "User-Agent": "pqAutomationApp/1.0",
} }
endpoint = _api_endpoint() endpoint = _api_endpoint(API_GENERATE_PATH)
logger.info( logger.info(
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r", "[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s",
_mask_sid(session_id), len(user_message or ""), _truncate(user_message), _mask_sid(session_id),
"img2img" if upload_image_url else "txt2img",
len(user_message or ""),
_truncate(user_message),
upload_image_url or "-",
) )
logger.info( logger.info(
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s", "[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
@@ -250,6 +276,137 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
return image_url 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)
if needs_resize:
if not auto_resize:
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
raise ValueError(f"分辨率超过 4096×4096当前 {iw}×{ih}")
else:
raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB")
# 自动缩放:等比例缩放至 4096×4096 以内
logger.info("[AIImage][UPLOAD] 自动缩放 %dx%d (%.1fMB) 至 ≤4096×4096",
iw, ih, size/1024/1024)
scale = min(UPLOAD_MAX_PIXELS / iw, UPLOAD_MAX_PIXELS / ih, 1.0)
new_w, new_h = max(1, int(iw * scale)), max(1, int(ih * scale))
with Image.open(file_path) as img:
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
# 重压至 10MB 以下
# 首先尝试原格式
tmp_io = BytesIO()
fmt = "PNG" if ext == ".png" else "JPEG"
save_kw = {"format": fmt}
img_resized.save(tmp_io, **save_kw)
tmp_bytes = tmp_io.getvalue()
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
file_bytes = tmp_bytes
else:
# 原格式太大,转换为 JPEG 并压缩
logger.info("[AIImage][UPLOAD] 原格式超过限制,转为 JPEG 并压缩")
quality = 95
while quality >= 50:
tmp_io = BytesIO()
img_resized.save(tmp_io, format="JPEG", quality=quality, optimize=True)
tmp_bytes = tmp_io.getvalue()
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
file_bytes = tmp_bytes
break
quality -= 5
else:
# 即使 quality=50 还是太大,也用这个版本上传(后端会处理)
file_bytes = tmp_bytes
logger.info("[AIImage][UPLOAD] 缩放完成 %dx%d (%.1fMB)",
new_w, new_h, len(file_bytes)/1024/1024)
iw, ih = new_w, new_h
else:
with open(file_path, "rb") as f:
file_bytes = f.read()
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
boundary = "----pqAuto" + uuid.uuid4().hex
filename = os.path.basename(file_path)
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 +661,7 @@ def request_image_async(
base_dir: Optional[str] = None, base_dir: Optional[str] = None,
session_id: Optional[str] = None, session_id: Optional[str] = None,
cancel_event: Optional[threading.Event] = None, cancel_event: Optional[threading.Event] = None,
upload_image_url: Optional[str] = None,
) -> threading.Thread: ) -> threading.Thread:
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。 """在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
@@ -511,24 +669,33 @@ def request_image_async(
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。 切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
``session_id`` 留空则使用进程级会话 id保证多轮对话上下文 ``session_id`` 留空则使用进程级会话 id保证多轮对话上下文
``upload_image_url`` 传入后启用"图生图"模式。
""" """
sid = session_id or get_session_id() sid = session_id or get_session_id()
cancel = cancel_event cancel = cancel_event
ref_url = (upload_image_url or "").strip() or None
def _worker(): def _worker():
try: try:
if cancel is not None and cancel.is_set(): if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid)) logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
return 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(): if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid)) logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
return 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( record = import_image_from_url(
image_url=image_url, image_url=image_url,
prompt=prompt, prompt=prompt,
extra={"source": "ai-api", "session_id": sid}, extra=extra,
base_dir=base_dir, base_dir=base_dir,
) )
if cancel is not None and cancel.is_set(): if cancel is not None and cancel.is_set():
@@ -593,6 +760,38 @@ def import_image_from_url_async(
return t 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: def is_remote_image_url(value: str) -> bool:
"""判断输入是否为 http/https 图片地址。""" """判断输入是否为 http/https 图片地址。"""
url = (value or "").strip() 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.data_range_converter import convert_pattern_params
from app.pq.pq_config import get_pattern from app.pq.pq_config import get_pattern
from drivers.ucd_helpers import send_solid_rgb_pattern
@dataclass @dataclass
@@ -31,15 +30,52 @@ class PatternService:
source_params = self._get_source_pattern_params(mode) source_params = self._get_source_pattern_params(mode)
if test_type == "screen_module": 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: if log_details:
self._log("=" * 50, "separator") self._log("=" * 50, "separator")
self._log("设置屏模组信号格式:", "info") self._log("设置屏模组信号格式:", "info")
self._log("=" * 50, "separator") self._log("=" * 50, "separator")
self._log( for label, value in [
f" Timing: {self.app.config.current_test_types[test_type]['timing']}", ("色彩空间", color_space),
"info", ("色彩格式", output_format),
("数据范围", data_range),
("编码位深", bit_depth),
("Timing", self.app.config.current_test_types[test_type]["timing"]),
]:
self._log(f" {label}: {value}", "info")
self.app.signal_service.apply_config(active_config)
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"屏模组信号格式设置{'成功' if success else '失败'}",
"success" if success else "error",
) )
self.app.ucd.set_ucd_params(active_config)
elif test_type == "sdr_movie": elif test_type == "sdr_movie":
data_range = self.app.sdr_data_range_var.get() data_range = self.app.sdr_data_range_var.get()
@@ -61,12 +97,12 @@ class PatternService:
active_config = self.app.config.get_temp_config_with_converted_params( active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params mode=mode, converted_params=converted_params
) )
self.app.ucd.set_ucd_params(active_config) self.app.signal_service.apply_config(active_config)
success = self.app.ucd.apply_signal_format( success = self.app.signal_service.update_signal_format(
color_space=self.app.sdr_color_space_var.get(), color_space=self.app.sdr_color_space_var.get(),
data_range=data_range, data_range=data_range,
bit_depth=self.app.sdr_bit_depth_var.get(), 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: if log_details:
self._log(f"SDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error") self._log(f"SDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error")
@@ -93,12 +129,12 @@ class PatternService:
active_config = self.app.config.get_temp_config_with_converted_params( active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params mode=mode, converted_params=converted_params
) )
self.app.ucd.set_ucd_params(active_config) self.app.signal_service.apply_config(active_config)
success = self.app.ucd.apply_signal_format( success = self.app.signal_service.update_signal_format(
color_space=self.app.hdr_color_space_var.get(), color_space=self.app.hdr_color_space_var.get(),
data_range=data_range, data_range=data_range,
bit_depth=self.app.hdr_bit_depth_var.get(), 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_cll=self.app.hdr_maxcll_var.get(),
max_fall=self.app.hdr_maxfall_var.get(), max_fall=self.app.hdr_maxfall_var.get(),
) )
@@ -124,7 +160,7 @@ class PatternService:
raise IndexError(f"pattern 索引越界: {index}") raise IndexError(f"pattern 索引越界: {index}")
pattern_param = session.pattern_params[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}") raise RuntimeError(f"发送 pattern 失败: {index}")
return pattern_param return pattern_param
@@ -135,7 +171,7 @@ class PatternService:
log_details=False, log_details=False,
) )
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type) 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 return True
def _get_source_pattern_params(self, mode): def _get_source_pattern_params(self, mode):

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

@@ -0,0 +1,237 @@
"""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
import logging
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__)
# ─── 视图字符串 → 值对象 转换工具 ────────────────────────────────
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()
# -- 高层接口 ------------------------------------------------
def apply(
self,
*,
signal: SignalFormat,
timing: TimingSpec,
pattern: PatternSpec,
) -> bool:
"""一次性提交信号格式 + timing + 图案。
Returns:
``format_changed``——本次相对上一次 :meth:`apply` 是否变化。
"""
with self._lock:
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._lock:
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._lock:
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._lock:
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._lock:
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._lock:
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窗口图片生成与测试主循环 整合自原 drivers/local_dimming_test.py窗口图片生成与测试主循环
直接落在本模块UCD 通用操作下沉到 drivers.ucd_helpers 直接落在本模块UCD 通用操作通过 SignalService 完成
""" """
import atexit import atexit
@@ -18,7 +18,12 @@ from tkinter import filedialog, messagebox
import numpy as np import numpy as np
from PIL import Image 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_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 _TEMP_DIR = None
_IMAGE_CACHE = {} # {(width, height, percentage): file_path} _IMAGE_CACHE = {} # {(width, height, percentage): file_path}
@@ -84,117 +92,325 @@ def _ensure_window_image(width, height, percentage):
return path 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.ca.readAllDisplay()
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 方法) # GUI 入口(绑定为 PQAutomationApp 方法)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
def start_local_dimming_test(self): def start_local_dimming_test(self: "PQAutomationApp"):
"""开始 Local Dimming 测试""" """Local Dimming 不再提供自动测试,保留接口仅提示用户使用手动模式"""
if not self.ca or not self.ucd.status: messagebox.showinfo("提示", "Local Dimming 请使用手动发送图案后再采集亮度")
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 update_ld_results(self, results): def update_ld_results(self: "PQAutomationApp", results):
"""把批量测试结果填入 Treeview。""" """把批量测试结果填入 Treeview。"""
for percentage, x, y, lv, _X, _Y, _Z in results: for row in results:
self.ld_tree.insert( self.ld_tree.insert(
"", tk.END, "", 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): def stop_local_dimming_test(self: "PQAutomationApp"):
"""请求停止当前 Local Dimming 测试""" """兼容旧接口,无操作"""
ev = getattr(self, "ld_stop_event", None) return
if ev:
ev.set()
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 设备") messagebox.showwarning("警告", "请先连接 UCD323 设备")
return return
self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info") self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info")
self.current_ld_percentage = percentage _set_current_ld_pattern(self, "峰值亮度", f"{percentage}%窗口", percentage)
def send(): 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: try:
image_path = _ensure_window_image(width, height, percentage) image_path = _ensure_window_image(width, height, percentage)
except Exception as e: except Exception as e:
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}") self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
return return
ok = send_image_pattern(self.ucd, image_path) try:
self.signal_service.send_image(image_path)
ok = True
except Exception:
ok = False
msg = ( msg = (
f"{percentage}% 窗口已发送" if ok f"{percentage}% 窗口已发送" if ok
else f"{percentage}% 窗口发送失败" else f"{percentage}% 窗口发送失败"
@@ -204,12 +420,128 @@ def send_ld_window(self, percentage):
threading.Thread(target=send, daemon=True).start() 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。""" """测量当前显示的亮度并追加一行到 Treeview。"""
if not self.ca: if not self.ca:
messagebox.showwarning("警告", "请先连接 CA410 色度计") messagebox.showwarning("警告", "请先连接 CA410 色度计")
return return
if self.current_ld_percentage is None: if getattr(self, "current_ld_pattern_label", None) is None:
messagebox.showinfo("提示", "请先发送一个窗口图案") messagebox.showinfo("提示", "请先发送一个窗口图案")
return return
@@ -232,8 +564,12 @@ def measure_ld_luminance(self):
self._dispatch_ui( self._dispatch_ui(
self.ld_tree.insert, "", tk.END, self.ld_tree.insert, "", tk.END,
values=( values=(
f"{self.current_ld_percentage}%", getattr(self, "current_ld_test_item", "手动采集"),
f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}", timestamp, 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²") 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() threading.Thread(target=measure, daemon=True).start()
def clear_ld_records(self): def clear_ld_records(self: "PQAutomationApp"):
"""清空 Treeview 中的测试记录。""" """清空 Treeview 中的测试记录。"""
for item in self.ld_tree.get_children(): for item in self.ld_tree.get_children():
self.ld_tree.delete(item) self.ld_tree.delete(item)
self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --") self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --")
self.current_ld_percentage = None self.current_ld_percentage = None
self.current_ld_test_item = None
self.current_ld_pattern_label = None
self.log_gui.log("测试记录已清空", level="info") self.log_gui.log("测试记录已清空", level="info")
def save_local_dimming_results(self): def save_local_dimming_results(self: "PQAutomationApp"):
"""把 Treeview 中的全部记录导出为 CSV。""" """把 Treeview 中的全部记录导出为 CSV。"""
if len(self.ld_tree.get_children()) == 0: if len(self.ld_tree.get_children()) == 0:
messagebox.showinfo("提示", "没有可保存的数据") messagebox.showinfo("提示", "没有可保存的数据")
@@ -271,7 +609,7 @@ def save_local_dimming_results(self):
try: try:
with open(save_path, "w", newline="", encoding="utf-8-sig") as f: with open(save_path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f) writer = csv.writer(f)
writer.writerow(["窗口百分比", "亮度 (cd/m²)", "x", "y", "时间"]) writer.writerow(["测试项目", "图案", "亮度/结果", "x", "y", "时间"])
for item in self.ld_tree.get_children(): for item in self.ld_tree.get_children():
writer.writerow(self.ld_tree.item(item, "values")) writer.writerow(self.ld_tree.item(item, "values"))
self.log_gui.log(f"测试结果已保存: {save_path}", level="success") self.log_gui.log(f"测试结果已保存: {save_path}", level="success")
@@ -279,3 +617,19 @@ def save_local_dimming_results(self):
except Exception as e: except Exception as e:
self.log_gui.log(f"保存失败: {str(e)}", level="error") self.log_gui.log(f"保存失败: {str(e)}", level="error")
messagebox.showerror("错误", f"保存失败: {str(e)}") 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` 从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。 以保留原有 `self.xxx` 属性访问不变。
@@ -10,7 +10,52 @@ import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from app.views.pq_debug_panel import PQDebugPanel from app.views.pq_debug_panel import PQDebugPanel
def init_gamut_chart(self): from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def _result_bg_color() -> str:
"""根据当前主题返回结果图背景色。"""
try:
from app.views.theme_manager import is_dark
return "#1B1F24" if is_dark() else "#FFFFFF"
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 init_gamut_chart(self: "PQAutomationApp"):
"""初始化色域图表 - 手动设置subplot位置完全避免重叠""" """初始化色域图表 - 手动设置subplot位置完全避免重叠"""
container = ttk.Frame(self.gamut_chart_frame) container = ttk.Frame(self.gamut_chart_frame)
container.pack(expand=True, fill=tk.BOTH) container.pack(expand=True, fill=tk.BOTH)
@@ -65,7 +110,7 @@ def init_gamut_chart(self):
self.gamut_canvas.draw() self.gamut_canvas.draw()
def sync_gamut_toolbar(self): def sync_gamut_toolbar(self: "PQAutomationApp"):
"""将工具栏参考标准按钮同步为当前测试类型的 ref var 值。""" """将工具栏参考标准按钮同步为当前测试类型的 ref var 值。"""
if not hasattr(self, "_gamut_ref_toolbar_var"): if not hasattr(self, "_gamut_ref_toolbar_var"):
return return
@@ -80,7 +125,7 @@ def sync_gamut_toolbar(self):
self._gamut_ref_toolbar_var.set(getattr(self, attr).get()) 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 → 保存配置 → 重绘(有数据时)。""" """用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。"""
test_type = self.config.current_test_type test_type = self.config.current_test_type
var_map = { var_map = {
@@ -105,7 +150,7 @@ def _on_gamut_toolbar_changed(self, std):
self.recalculate_gamut() self.recalculate_gamut()
def init_gamma_chart(self): def init_gamma_chart(self: "PQAutomationApp"):
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)""" """初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)"""
container = ttk.Frame(self.gamma_chart_frame) container = ttk.Frame(self.gamma_chart_frame)
container.pack(expand=True, fill=tk.BOTH) container.pack(expand=True, fill=tk.BOTH)
@@ -214,7 +259,7 @@ def init_gamma_chart(self):
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98) self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
self.gamma_canvas.draw() self.gamma_canvas.draw()
def init_eotf_chart(self): def init_eotf_chart(self: "PQAutomationApp"):
"""初始化 EOTF 曲线图表HDR 专用)- 左侧曲线 + 右侧表格4列""" """初始化 EOTF 曲线图表HDR 专用)- 左侧曲线 + 右侧表格4列"""
container = ttk.Frame(self.eotf_chart_frame) container = ttk.Frame(self.eotf_chart_frame)
container.pack(expand=True, fill=tk.BOTH) container.pack(expand=True, fill=tk.BOTH)
@@ -319,7 +364,7 @@ def init_eotf_chart(self):
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98) self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
self.eotf_canvas.draw() self.eotf_canvas.draw()
def init_cct_chart(self): def init_cct_chart(self: "PQAutomationApp"):
"""初始化色度坐标图表 - 正向横坐标,标题居中最上方""" """初始化色度坐标图表 - 正向横坐标,标题居中最上方"""
container = ttk.Frame(self.cct_chart_frame) container = ttk.Frame(self.cct_chart_frame)
container.pack(expand=True) container.pack(expand=True)
@@ -364,7 +409,7 @@ def init_cct_chart(self):
self.cct_canvas.draw() self.cct_canvas.draw()
def init_contrast_chart(self): def init_contrast_chart(self: "PQAutomationApp"):
"""初始化对比度图表 - 固定大小,居中显示""" """初始化对比度图表 - 固定大小,居中显示"""
container = ttk.Frame(self.contrast_chart_frame) container = ttk.Frame(self.contrast_chart_frame)
container.pack(expand=True) container.pack(expand=True)
@@ -399,23 +444,30 @@ def init_contrast_chart(self):
self.contrast_canvas.draw() self.contrast_canvas.draw()
def init_accuracy_chart(self): def init_accuracy_chart(self: "PQAutomationApp"):
"""初始化色准图表 - 固定大小,居中显示""" """初始化色准图表 - 固定大小,居中显示"""
container = ttk.Frame(self.accuracy_chart_frame) container = ttk.Frame(self.accuracy_chart_frame)
container.pack(expand=True) container.pack(expand=True, fill=tk.BOTH)
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( self.accuracy_fig = plt.Figure(
figsize=(10, 6), figsize=(10, 6),
dpi=100, dpi=100,
tight_layout=False, tight_layout=False,
) )
self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=container) self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=plot_container)
canvas_widget = self.accuracy_canvas.get_tk_widget() canvas_widget = self.accuracy_canvas.get_tk_widget()
canvas_widget.pack() canvas_widget.pack(fill=tk.BOTH, expand=True)
canvas_widget.config(width=1000, height=600)
canvas_widget.pack_propagate(False)
self.accuracy_ax = self.accuracy_fig.add_subplot(111) self.accuracy_ax = self.accuracy_fig.add_subplot(111)
self.accuracy_ax.set_xlim(0, 1) self.accuracy_ax.set_xlim(0, 1)
@@ -433,8 +485,136 @@ def init_accuracy_chart(self):
) )
self.accuracy_canvas.draw() 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"):
"""清空所有图表""" """清空所有图表"""
# ========== 1. 清空色域图表 ========== # ========== 1. 清空色域图表 ==========
@@ -729,7 +909,10 @@ def clear_chart(self):
self.accuracy_canvas.draw() self.accuracy_canvas.draw()
def update_chart_tabs_state(self): # 清空色准明细表格
self.clear_accuracy_result_table()
def update_chart_tabs_state(self: "PQAutomationApp"):
"""根据测试项目复选框状态动态增删图表 Tab保持规范顺序 """根据测试项目复选框状态动态增删图表 Tab保持规范顺序
- 色域 / Gamma 或 EOTF / 色度一致性 / 对比度 / 色准 全部走动态 add/forget - 色域 / Gamma 或 EOTF / 色度一致性 / 对比度 / 色准 全部走动态 add/forget
@@ -801,7 +984,7 @@ def update_chart_tabs_state(self):
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"更新Tab状态失败: {str(e)}", level="error") 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 分离)""" """创建结果图表区域 - 6个独立TabGamma 和 EOTF 分离)"""
# 创建Notebook用于图表切换 # 创建Notebook用于图表切换
self.chart_notebook = ttk.Notebook(self.result_frame) self.chart_notebook = ttk.Notebook(self.result_frame)
@@ -844,6 +1027,7 @@ def create_result_chart_frame(self):
self.init_cct_chart() self.init_cct_chart()
self.init_contrast_chart() self.init_contrast_chart()
self.init_accuracy_chart() self.init_accuracy_chart()
self.apply_result_chart_theme()
# 绑定Tab切换事件 # 绑定Tab切换事件
self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed) self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed)
@@ -859,12 +1043,36 @@ def create_result_chart_frame(self):
# 创建单步调试面板实例 # 创建单步调试面板实例
self.debug_panel = PQDebugPanel(self.debug_container, 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切换时的事件处理""" """Tab切换时的事件处理"""
try: try:
self._last_tab_index = self.chart_notebook.index( selected_tab = self.chart_notebook.select()
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: except Exception as e:
self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error") 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 import tkinter
from tkinter import ttk 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): class CollapsingFrame(ttk.Frame):
""" """A modern collapsible frame widget."""
A collapsible frame widget that opens and closes with a button click.
""" CHEVRON_OPEN = "\u25be" # ▾
CHEVRON_CLOSED = "\u25b8" # ▸
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.columnconfigure(0, weight=1) self.columnconfigure(0, weight=1)
self.cumulative_rows = 0 self.cumulative_rows = 0
p = Path(__file__).parent # 兼容旧代码可能引用 self.images
self.images = [ self.images: list = []
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"),
),
]
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 child: 必须是一个 ttk.Frame
:param str title: the title appearing on the collapsible section header :param title: 标题文本;
:param str style: the ttk style to apply to the collapsible section header :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 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 header = ttk.Frame(self, style="ConfigHeader.TFrame", padding=(12, 6))
lbl = ttk.Label(frm, text=title, style=f"{style_color}.Inverse.TLabel") header.grid(row=self.cumulative_rows, column=0, sticky="ew")
if kwargs.get("textvariable"): header.columnconfigure(1, weight=1)
lbl.configure(textvariable=kwargs.get("textvariable"))
lbl.pack(side="left", fill="both", padx=10)
# header toggle button # chevron + 标题
btn = ttk.Button( title_box = ttk.Frame(header, style="ConfigHeader.TFrame")
frm, title_box.grid(row=0, column=0, sticky="w")
image="open",
style=style, chevron = ttk.Label(
command=lambda c=child: self._toggle_open_close(child), 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") child.grid(row=self.cumulative_rows + 1, column=0, sticky="news")
# increment the row assignment
self.cumulative_rows += 2 self.cumulative_rows += 2
# ------------------------------------------------------------------
# 内部实现
# ------------------------------------------------------------------
def _toggle_open_close(self, child): 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(): if child.winfo_viewable():
child.grid_remove() child.grid_remove()
child.btn.configure(image="closed") try:
child._chevron.configure(text=self.CHEVRON_CLOSED)
except (AttributeError, tkinter.TclError):
pass
else: else:
child.grid() 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): # class Application(tkinter.Tk):

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

@@ -0,0 +1,240 @@
"""现代化 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 apply_modern_styles() -> None:
"""注册或刷新现代化样式集。可在主题切换后再次调用。"""
style = ttk.Style()
theme = style.colors # ttkbootstrap.style.Colors
bg = theme.bg # 主背景
fg = theme.fg # 主前景
primary = theme.primary
secondary = theme.secondary
info = theme.info
dark = theme.dark
border = theme.border
inputbg = theme.inputbg
dark_theme = _is_dark(bg)
# 卡片背景:在主背景上轻微偏移,营造层级感
card_bg = _mix(bg, "#ffffff", 0.04) if dark_theme else _mix(bg, "#000000", 0.025)
card_border = _mix(bg, fg, 0.18) if dark_theme else _mix(bg, "#000000", 0.10)
# 配置项 header 用 secondary 主题色
header_bg = secondary
header_fg = "#ffffff" if _is_dark(secondary) else "#1a1a1a"
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 = _mix(dark, bg, 0.18) if dark_theme else _mix(primary, "#000000", 0.10)
sidebar_hover = _mix(sidebar_bg, "#ffffff", 0.07) if dark_theme else _mix(sidebar_bg, "#000000", 0.06)
sidebar_selected = _mix(sidebar_bg, "#ffffff", 0.14) if dark_theme else _mix(sidebar_bg, "#000000", 0.10)
# 侧栏背景在浅色主题下也偏深,文字颜色需按侧栏亮度自适应,避免“黑字不明显”。
sidebar_fg = "#F4F8FD" if _is_dark(sidebar_bg) else _mix(fg, bg, 0.05)
sidebar_muted = _mix(sidebar_fg, sidebar_bg, 0.45)
# ---------------- 卡片 ----------------
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("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="#ffffff",
font=("Segoe UI Semibold", 12),
)
# ---------------- 结果区无边框标题行 ----------------
style.configure("ResultHeader.TFrame", background=bg, borderwidth=0)
style.configure(
"ResultHeader.TLabel",
background=bg,
foreground=fg,
font=("Segoe UI", 11, "bold"),
)
# ---------------- 状态栏 ----------------
statusbar_bg = _mix(bg, "#000000", 0.06) if not dark_theme else _mix(bg, "#ffffff", 0.06)
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),
)
# ---------------- 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="#ffffff",
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 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] = { self.panels[panel_name] = {
"frame": frame, "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: if panel_name not in self.panels:
return return
@@ -30,7 +36,19 @@ def show_panel(self, panel_name):
# 显示指定面板 # 显示指定面板
panel_info = self.panels[panel_name] panel_info = self.panels[panel_name]
# 隐藏主内容区域 # 隐藏主内容区域
# 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_top.pack_forget()
self.control_frame_middle.pack_forget() self.control_frame_middle.pack_forget()
self.control_frame_bottom.pack_forget() self.control_frame_bottom.pack_forget()
@@ -47,7 +65,7 @@ def show_panel(self, panel_name):
self.current_panel = 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(): for panel_name, panel_info in self.panels.items():
@@ -70,3 +88,12 @@ def hide_all_panels(self):
self.current_panel = None 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 time
import traceback import traceback
@@ -8,8 +8,22 @@ import ttkbootstrap as ttk
import algorithm.pq_algorithm as pq_algorithm 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 独立(增加色域参考标准选择 + 单步调试按钮)""" """创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)"""
# ==================== 屏模组色度参数 Frame ==================== # ==================== 屏模组色度参数 Frame ====================
@@ -116,7 +130,7 @@ def create_cct_params_frame(self):
self.cct_params_frame, self.cct_params_frame,
text="提示: 清空输入框将恢复默认值", text="提示: 清空输入框将恢复默认值",
font=("SimHei", 8), font=("SimHei", 8),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5) ).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
# ==================== SDR 色度参数 Frame ==================== # ==================== SDR 色度参数 Frame ====================
@@ -221,7 +235,7 @@ def create_cct_params_frame(self):
self.sdr_cct_params_frame, self.sdr_cct_params_frame,
text="提示: 清空输入框将恢复默认值", text="提示: 清空输入框将恢复默认值",
font=("SimHei", 8), font=("SimHei", 8),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5) ).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
# ==================== HDR 色度参数 Frame ==================== # ==================== HDR 色度参数 Frame ====================
@@ -326,11 +340,11 @@ def create_cct_params_frame(self):
self.hdr_cct_params_frame, self.hdr_cct_params_frame,
text="提示: 清空输入框将恢复默认值", text="提示: 清空输入框将恢复默认值",
font=("SimHei", 8), font=("SimHei", 8),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5) ).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 变量映射。""" """按测试类型返回 CCT 变量映射。"""
if test_type == "sdr_movie": if test_type == "sdr_movie":
return { 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 输入值,失败时回落默认值。""" """读取并解析 CCT 输入值,失败时回落默认值。"""
try: try:
value = var.get().strip() value = var.get().strip()
@@ -365,7 +379,7 @@ def _parse_cct_float(self, var, default):
return default return default
def _save_cct_params_for(self, test_type): def _save_cct_params_for(self: "PQAutomationApp", test_type):
"""保存指定测试类型的 CCT 参数。""" """保存指定测试类型的 CCT 参数。"""
try: try:
default_params = self.config.get_default_cct_params(test_type) default_params = self.config.get_default_cct_params(test_type)
@@ -384,7 +398,7 @@ def _save_cct_params_for(self, test_type):
pass 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 参数失焦校验并保存。""" """统一处理 CCT 参数失焦校验并保存。"""
try: try:
value = var.get().strip() 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") 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 色度参数失去焦点时的处理。""" """SDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "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 色度参数。""" """保存 SDR 色度参数。"""
_save_cct_params_for(self, "sdr_movie") _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 色度参数失去焦点时的处理。""" """HDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "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 色度参数。""" """保存 HDR 色度参数。"""
_save_cct_params_for(self, "hdr_movie") _save_cct_params_for(self, "hdr_movie")
def recalculate_cct(self): def recalculate_cct(self: "PQAutomationApp"):
"""重新计算并绘制色度图""" """重新计算并绘制色度图"""
try: try:
# 1. 保存新参数 # 1. 保存新参数
@@ -496,7 +510,7 @@ def recalculate_cct(self):
messagebox.showerror("错误", f"重新计算失败: {str(e)}") messagebox.showerror("错误", f"重新计算失败: {str(e)}")
def recalculate_gamut(self): def recalculate_gamut(self: "PQAutomationApp"):
"""重新计算并绘制色域图(使用新的参考标准)""" """重新计算并绘制色域图(使用新的参考标准)"""
try: try:
# 1. 收起配置项 # 1. 收起配置项
@@ -628,33 +642,23 @@ def recalculate_gamut(self):
# 10. 重新绘制色域图 # 10. 重新绘制色域图
self.plot_gamut(rgb_data, coverage_xy, test_type) 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: except Exception as e:
self.log_gui.log(f"重新计算失败: {str(e)}", level="error") self.log_gui.log(f"重新计算失败: {str(e)}", level="error")
self.log_gui.log(traceback.format_exc(), level="error") self.log_gui.log(traceback.format_exc(), level="error")
messagebox.showerror("错误", f"重新计算失败: {str(e)}") 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, "屏模组") _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) _save_cct_params_for(self, self.config.current_test_type)
def reload_cct_params(self): def reload_cct_params(self: "PQAutomationApp"):
"""切换测试类型时重新加载色度参数""" """切换测试类型时重新加载色度参数"""
try: try:
current_type = self.config.current_test_type 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") 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() selected_items = self.get_selected_test_items()
current_test_type = self.config.current_test_type 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] cfg = _GAMUT_REF_CONFIGS[test_type]
try: try:
new_ref = getattr(self, cfg["var_attr"]).get() 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") 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) _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) _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) _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 threading
import time import time
@@ -11,7 +11,13 @@ import numpy as np
from app.data_range_converter import convert_pattern_params from app.data_range_converter import convert_pattern_params
def create_custom_template_result_panel(self): from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def create_custom_template_result_panel(self: "PQAutomationApp"):
"""创建客户模板结果显示区域(黑底表格)""" """创建客户模板结果显示区域(黑底表格)"""
self.custom_result_frame = ttk.LabelFrame( self.custom_result_frame = ttk.LabelFrame(
self.custom_template_tab_frame, text="客户模板结果显示" self.custom_template_tab_frame, text="客户模板结果显示"
@@ -151,7 +157,7 @@ def create_custom_template_result_panel(self):
table_container.grid_columnconfigure(0, weight=1) table_container.grid_columnconfigure(0, weight=1)
def show_custom_result_context_menu(self, event): def show_custom_result_context_menu(self: "PQAutomationApp", event):
"""显示客户模板结果右键菜单""" """显示客户模板结果右键菜单"""
if not hasattr(self, "custom_result_tree") or not hasattr( if not hasattr(self, "custom_result_tree") or not hasattr(
self, "custom_result_menu" self, "custom_result_menu"
@@ -197,7 +203,7 @@ def show_custom_result_context_menu(self, event):
self.custom_result_menu.grab_release() 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"): if not hasattr(self, "custom_result_tree"):
return return
@@ -208,7 +214,7 @@ def set_custom_result_table_locked(self, locked):
pass pass
def start_custom_row_single_step(self): def start_custom_row_single_step(self: "PQAutomationApp"):
"""单步测试当前选中行:发送该行 pattern 并覆盖该行测量结果""" """单步测试当前选中行:发送该行 pattern 并覆盖该行测量结果"""
if not hasattr(self, "custom_result_tree"): if not hasattr(self, "custom_result_tree"):
return return
@@ -252,7 +258,7 @@ def start_custom_row_single_step(self):
).start() ).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"): if not hasattr(self, "custom_result_tree"):
return return
@@ -281,7 +287,7 @@ def _clear_custom_result_row(self, item_id, row_no):
self.custom_result_tree.see(item_id) 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: try:
self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...") self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...")
@@ -310,10 +316,8 @@ def _run_custom_row_single_step(self, item_id, row_no):
self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围") self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围")
return return
self.ucd.set_ucd_params(temp_config)
pattern_param = converted_params[row_no - 1] pattern_param = converted_params[row_no - 1]
self.ucd.set_pattern(self.ucd.current_pattern, pattern_param) self.signal_service.apply_and_run(temp_config, pattern_param)
self.ucd.run()
time.sleep(self.pattern_settle_time) time.sleep(self.pattern_settle_time)
@@ -354,7 +358,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
self._dispatch_ui(self.status_var.set, "单步测试失败") 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): def fmt(value, digits=4):
@@ -396,7 +400,7 @@ def _update_custom_result_row(self, item_id, row_no, result_data):
self.custom_result_tree.item(item_id, values=new_values) 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""" """复制客户模板结果表格到剪贴板(不含标题行/No./Pattern"""
if not hasattr(self, "custom_result_tree"): if not hasattr(self, "custom_result_tree"):
return return
@@ -436,7 +440,7 @@ def copy_custom_result_table(self):
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)", level="success") 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"): if not hasattr(self, "custom_result_tree"):
return return
@@ -444,7 +448,7 @@ def clear_custom_template_results(self):
self.custom_result_tree.delete(item) 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"): if not hasattr(self, "custom_result_tree"):
return return
@@ -482,7 +486,7 @@ def auto_expand_custom_result_view(self):
self.log_gui.log(f"自动扩展客户模板窗口失败: {str(e)}", level="error") 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): def fmt(value, digits=4):
@@ -525,7 +529,7 @@ def append_custom_template_result(self, row_no, result_data):
self.auto_expand_custom_result_view() self.auto_expand_custom_result_view()
def start_custom_template_test(self): def start_custom_template_test(self: "PQAutomationApp"):
"""开始客户模板测试SDR""" """开始客户模板测试SDR"""
if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"): if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
@@ -573,7 +577,7 @@ def start_custom_template_test(self):
self.test_thread.daemon = True self.test_thread.daemon = True
self.test_thread.start() self.test_thread.start()
def update_custom_button_visibility(self): def update_custom_button_visibility(self: "PQAutomationApp"):
"""只在 SDR 测试时显示客户模版按钮""" """只在 SDR 测试时显示客户模版按钮"""
if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"): if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"):
return return
@@ -627,7 +631,7 @@ def update_custom_button_visibility(self):
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success") # self.log_gui.log("已填充 147 行客户模板测试数据", level="success")
def export_custom_template_excel(self): def export_custom_template_excel(self: "PQAutomationApp"):
"""将客户模板结果表导出为 Excel 文件14 列完整数据)""" """将客户模板结果表导出为 Excel 文件14 列完整数据)"""
if not hasattr(self, "custom_result_tree"): if not hasattr(self, "custom_result_tree"):
return return
@@ -775,7 +779,7 @@ def export_custom_template_excel(self):
messagebox.showerror("错误", f"导出失败:{str(e)}") messagebox.showerror("错误", f"导出失败:{str(e)}")
def export_custom_template_charts(self): def export_custom_template_charts(self: "PQAutomationApp"):
"""生成客户模板图表xy 色度散点图 + Lv 亮度曲线图,保存为 PNG""" """生成客户模板图表xy 色度散点图 + Lv 亮度曲线图,保存为 PNG"""
if not hasattr(self, "custom_result_tree"): if not hasattr(self, "custom_result_tree"):
return return
@@ -912,3 +916,24 @@ def export_custom_template_charts(self):
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"生成图表失败: {str(e)}", level="error") self.log_gui.log(f"生成图表失败: {str(e)}", level="error")
messagebox.showerror("错误", f"生成图表失败:{str(e)}") messagebox.showerror("错误", f"生成图表失败:{str(e)}")
class CustomTemplatePanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_custom_template_result_panel = create_custom_template_result_panel
show_custom_result_context_menu = show_custom_result_context_menu
set_custom_result_table_locked = set_custom_result_table_locked
start_custom_row_single_step = start_custom_row_single_step
_clear_custom_result_row = _clear_custom_result_row
_run_custom_row_single_step = _run_custom_row_single_step
_update_custom_result_row = _update_custom_result_row
copy_custom_result_table = copy_custom_result_table
clear_custom_template_results = clear_custom_template_results
auto_expand_custom_result_view = auto_expand_custom_result_view
append_custom_template_result = append_custom_template_result
start_custom_template_test = start_custom_template_test
update_custom_button_visibility = update_custom_button_visibility
export_custom_template_excel = export_custom_template_excel
export_custom_template_charts = export_custom_template_charts

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 import ttkbootstrap as ttk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
_TEMPLATE_FILE = "pantone_2670_colors.xlsx" _TEMPLATE_FILE = "pantone_2670_colors.xlsx"
def create_pantone_baseline_panel(self): def create_pantone_baseline_panel(self: "PQAutomationApp"):
"""创建 Pantone 认证摸底测试面板。""" """创建 Pantone 认证摸底测试面板。"""
frame = ttk.Frame(self.content_frame) frame = ttk.Frame(self.content_frame)
self.pantone_baseline_frame = frame self.pantone_baseline_frame = frame
@@ -149,12 +155,12 @@ def create_pantone_baseline_panel(self):
_set_button_states(self) _set_button_states(self)
def toggle_pantone_baseline_panel(self): def toggle_pantone_baseline_panel(self: "PQAutomationApp"):
"""切换 Pantone 认证摸底测试面板。""" """切换 Pantone 认证摸底测试面板。"""
self.show_panel("pantone_baseline") self.show_panel("pantone_baseline")
def _get_settings_dir(self): def _get_settings_dir(self: "PQAutomationApp"):
"""返回 settings 绝对目录,避免依赖当前工作目录。""" """返回 settings 绝对目录,避免依赖当前工作目录。"""
if getattr(self, "config_file", None): if getattr(self, "config_file", None):
return os.path.dirname(self.config_file) return os.path.dirname(self.config_file)
@@ -168,7 +174,7 @@ def _get_settings_dir(self):
return os.path.join(base_dir, "settings") 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) path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
if not os.path.isfile(path): if not os.path.isfile(path):
raise FileNotFoundError(f"未找到模板文件: {path}") raise FileNotFoundError(f"未找到模板文件: {path}")
@@ -201,11 +207,11 @@ def _load_patterns(self):
return patterns return patterns
def _start_pantone_baseline(self): def _start_pantone_baseline(self: "PQAutomationApp"):
if self._pantone_running: if self._pantone_running:
messagebox.showinfo("提示", "Pantone 任务正在执行") messagebox.showinfo("提示", "Pantone 任务正在执行")
return return
if not getattr(self, "ucd", None) or not self.ucd.status: if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323") messagebox.showwarning("警告", "请先连接 UCD323")
return return
if not getattr(self, "ca", None): if not getattr(self, "ca", None):
@@ -247,14 +253,14 @@ def _start_pantone_baseline(self):
_launch_worker(self, start_index=0, settle=settle) _launch_worker(self, start_index=0, settle=settle)
def _resume_pantone_baseline(self): def _resume_pantone_baseline(self: "PQAutomationApp"):
if self._pantone_running: if self._pantone_running:
messagebox.showinfo("提示", "Pantone 任务正在执行") messagebox.showinfo("提示", "Pantone 任务正在执行")
return return
if not self._pantone_paused: if not self._pantone_paused:
messagebox.showinfo("提示", "当前没有可继续的暂停任务") messagebox.showinfo("提示", "当前没有可继续的暂停任务")
return return
if not getattr(self, "ucd", None) or not self.ucd.status: if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323") messagebox.showwarning("警告", "请先连接 UCD323")
return return
if not getattr(self, "ca", None): 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) _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) total = self._pantone_target_count or len(self.pantone_patterns)
def worker(): def worker():
@@ -401,7 +407,7 @@ def _launch_worker(self, start_index, settle):
threading.Thread(target=worker, daemon=True).start() 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( self.pantone_tree.insert(
"", "",
tk.END, tk.END,
@@ -423,7 +429,7 @@ def _append_result_row(self, record, total):
self.pantone_tree.see(children[-1]) self.pantone_tree.see(children[-1])
def _pause_pantone_baseline(self): def _pause_pantone_baseline(self: "PQAutomationApp"):
if not self._pantone_running: if not self._pantone_running:
messagebox.showinfo("提示", "当前没有运行中的任务") messagebox.showinfo("提示", "当前没有运行中的任务")
return return
@@ -433,7 +439,7 @@ def _pause_pantone_baseline(self):
self._pantone_control_event.set() self._pantone_control_event.set()
def _end_pantone_baseline(self): def _end_pantone_baseline(self: "PQAutomationApp"):
if self._pantone_running: if self._pantone_running:
self._pantone_stop_requested = True self._pantone_stop_requested = True
self.pantone_status_var.set("结束中...") self.pantone_status_var.set("结束中...")
@@ -448,7 +454,7 @@ def _end_pantone_baseline(self):
_set_button_states(self) _set_button_states(self)
def _clear_results(self): def _clear_results(self: "PQAutomationApp"):
if self._pantone_running: if self._pantone_running:
messagebox.showinfo("提示", "任务执行中,无法清空") messagebox.showinfo("提示", "任务执行中,无法清空")
return return
@@ -463,7 +469,7 @@ def _clear_results(self):
_set_button_states(self) _set_button_states(self)
def _set_button_states(self): def _set_button_states(self: "PQAutomationApp"):
if self._pantone_running: if self._pantone_running:
self.pantone_start_btn.configure(state=tk.DISABLED) self.pantone_start_btn.configure(state=tk.DISABLED)
self.pantone_pause_btn.configure(state=tk.NORMAL) 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) 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: if not self.pantone_results:
messagebox.showinfo("提示", "暂无可导出的结果") messagebox.showinfo("提示", "暂无可导出的结果")
return return
@@ -502,7 +508,7 @@ def _save_as_template(self):
messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}") messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}")
def _resolve_results_dir(self): def _resolve_results_dir(self: "PQAutomationApp"):
if getattr(self, "config_file", None): if getattr(self, "config_file", None):
root_dir = os.path.dirname(os.path.dirname(self.config_file)) root_dir = os.path.dirname(os.path.dirname(self.config_file))
else: else:
@@ -514,7 +520,7 @@ def _resolve_results_dir(self):
return results_dir return results_dir
def _auto_save_template(self): def _auto_save_template(self: "PQAutomationApp"):
results_dir = _resolve_results_dir(self) results_dir = _resolve_results_dir(self)
target_count = len(self.pantone_results) target_count = len(self.pantone_results)
filename = ( filename = (
@@ -526,7 +532,7 @@ def _auto_save_template(self):
return path return path
def _write_template_xlsx(self, path): def _write_template_xlsx(self: "PQAutomationApp", path):
# 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。 # 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。
template_path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE) template_path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
from openpyxl import load_workbook, Workbook 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"])) ws.cell(row=idx, column=6, value=float(item["y"]))
wb.save(path) 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 traceback
import tkinter as tk import tkinter as tk
@@ -7,7 +7,13 @@ import ttkbootstrap as ttk
from app.views.pq_log_gui import PQLogGUI from app.views.pq_log_gui import PQLogGUI
from app.views.pq_debug_panel import PQDebugPanel 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_frame = ttk.Frame(self.content_frame)
self.log_gui = PQLogGUI(self.log_frame) self.log_gui = PQLogGUI(self.log_frame)
@@ -22,8 +28,8 @@ def create_log_panel(self):
) # button会在后面设置 ) # button会在后面设置
def create_local_dimming_panel(self): def create_local_dimming_panel(self: "PQAutomationApp"):
"""创建 Local Dimming 测试面板 - 手动控制版""" """创建 Local Dimming 测试面板"""
self.local_dimming_frame = ttk.Frame(self.content_frame) self.local_dimming_frame = ttk.Frame(self.content_frame)
# 主容器 # 主容器
@@ -82,6 +88,52 @@ def create_local_dimming_panel(self):
width=12, width=12,
).pack(side=tk.LEFT, padx=3) ).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),
foreground="#28a745",
).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 采集按钮 ==================== # ==================== 4. CA410 采集按钮 ====================
measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10) measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10)
measure_frame.pack(fill=tk.X, pady=(0, 10)) measure_frame.pack(fill=tk.X, pady=(0, 10))
@@ -112,15 +164,19 @@ def create_local_dimming_panel(self):
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10)) result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Treeview # Treeview
columns = ("窗口百分比", "亮度 (cd/m²)", "x", "y", "时间") columns = ("测试项目", "图案", "亮度/结果", "x", "y", "时间")
self.ld_tree = ttk.Treeview( self.ld_tree = ttk.Treeview(
result_frame, columns=columns, show="headings", height=10 result_frame, columns=columns, show="headings", height=10
) )
for col in columns: for col in columns:
self.ld_tree.heading(col, text=col) self.ld_tree.heading(col, text=col)
if col == "窗口百分比": if col == "测试项目":
self.ld_tree.column(col, width=100, anchor=tk.CENTER) 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 == "时间": elif col == "时间":
self.ld_tree.column(col, width=120, anchor=tk.CENTER) self.ld_tree.column(col, width=120, anchor=tk.CENTER)
else: else:
@@ -170,14 +226,16 @@ def create_local_dimming_panel(self):
# 初始化当前窗口百分比(用于记录) # 初始化当前窗口百分比(用于记录)
self.current_ld_percentage = None 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 面板显示""" """切换 Local Dimming 面板显示"""
self.show_panel("local_dimming") self.show_panel("local_dimming")
def toggle_log_panel(self): def toggle_log_panel(self: "PQAutomationApp"):
"""切换日志面板的显示状态""" """切换日志面板的显示状态"""
self.show_panel("log") 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] cfg = DEBUG_PANEL_CONFIGS[test_type]
win_attr = cfg["window_attr"] win_attr = cfg["window_attr"]
@@ -288,25 +346,26 @@ def _toggle_debug_panel(self, test_type):
win.update_idletasks() win.update_idletasks()
def toggle_screen_debug_panel(self): def toggle_screen_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "screen_module") _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") _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") _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.screen_module_btn.configure(style="Sidebar.TButton")
self.sdr_movie_btn.configure(style="Sidebar.TButton") self.sdr_movie_btn.configure(style="Sidebar.TButton")
self.hdr_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() current_type = self.test_type_var.get()
@@ -316,3 +375,20 @@ def update_sidebar_selection(self):
self.sdr_movie_btn.configure(style="SidebarSelected.TButton") self.sdr_movie_btn.configure(style="SidebarSelected.TButton")
elif current_type == "hdr_movie": elif current_type == "hdr_movie":
self.hdr_movie_btn.configure(style="SidebarSelected.TButton") 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,14 @@ from tkinter import filedialog, messagebox
import ttkbootstrap as ttk import ttkbootstrap as ttk
from PIL import Image 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
_DEFAULT_SAMPLES = [ _DEFAULT_SAMPLES = [
@@ -26,7 +33,7 @@ _DEFAULT_SAMPLES = [
] ]
def create_single_step_panel(self): def create_single_step_panel(self: "PQAutomationApp"):
"""创建单步调试面板。""" """创建单步调试面板。"""
frame = ttk.Frame(self.content_frame) frame = ttk.Frame(self.content_frame)
self.single_step_frame = frame self.single_step_frame = frame
@@ -245,12 +252,12 @@ def create_single_step_panel(self):
_load_default_samples(self) _load_default_samples(self)
def toggle_single_step_panel(self): def toggle_single_step_panel(self: "PQAutomationApp"):
"""切换单步调试面板。""" """切换单步调试面板。"""
self.show_panel("single_step") 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] 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) _refresh_sample_list(self, select_index=0 if self.single_step_samples else None)
self.single_step_status_var.set( self.single_step_status_var.set(
@@ -258,7 +265,7 @@ def _load_default_samples(self):
) )
def _refresh_sample_list(self, select_index=None): def _refresh_sample_list(self: "PQAutomationApp", select_index=None):
self.single_step_listbox.delete(0, tk.END) self.single_step_listbox.delete(0, tk.END)
for sample in self.single_step_samples: for sample in self.single_step_samples:
self.single_step_listbox.insert( self.single_step_listbox.insert(
@@ -279,14 +286,14 @@ def _refresh_sample_list(self, select_index=None):
self.single_step_status_var.set("样本列表为空") self.single_step_status_var.set("样本列表为空")
def _on_sample_select(self): def _on_sample_select(self: "PQAutomationApp"):
selection = self.single_step_listbox.curselection() selection = self.single_step_listbox.curselection()
if not selection: if not selection:
return return
_select_sample(self, selection[0]) _select_sample(self, selection[0])
def _select_sample(self, index): def _select_sample(self: "PQAutomationApp", index):
sample = self.single_step_samples[index] sample = self.single_step_samples[index]
self.single_step_current_index = index self.single_step_current_index = index
self.single_step_name_var.set(sample["name"]) self.single_step_name_var.set(sample["name"])
@@ -296,7 +303,7 @@ def _select_sample(self, index):
self.single_step_status_var.set(f"当前样本: {sample['name']}") self.single_step_status_var.set(f"当前样本: {sample['name']}")
def _import_samples_csv(self): def _import_samples_csv(self: "PQAutomationApp"):
path = filedialog.askopenfilename( path = filedialog.askopenfilename(
title="选择单步调试样本 CSV", title="选择单步调试样本 CSV",
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")], filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")],
@@ -333,7 +340,7 @@ def _import_samples_csv(self):
self.log_gui.log(f"单步调试样本已导入: {len(samples)}", level="success") 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: if self.single_step_current_index is None:
return return
removed = self.single_step_samples.pop(self.single_step_current_index) removed = self.single_step_samples.pop(self.single_step_current_index)
@@ -342,7 +349,7 @@ def _delete_current_sample(self):
self.single_step_status_var.set(f"已删除样本: {removed['name']}") self.single_step_status_var.set(f"已删除样本: {removed['name']}")
def _upsert_sample(self): def _upsert_sample(self: "PQAutomationApp"):
try: try:
sample = { sample = {
"name": self.single_step_name_var.get().strip(), "name": self.single_step_name_var.get().strip(),
@@ -386,10 +393,10 @@ def _format_float(value):
return f"{number:.4f}" return f"{number:.4f}"
def _build_color_patch(self, hex_value): def _build_color_patch(self: "PQAutomationApp", hex_value):
if not getattr(self, "ucd", None) or not self.ucd.status: if not self.signal_service.is_connected:
raise RuntimeError("请先连接 UCD323 设备") 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)) 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") temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches")
os.makedirs(temp_dir, exist_ok=True) os.makedirs(temp_dir, exist_ok=True)
@@ -400,7 +407,7 @@ def _build_color_patch(self, hex_value):
return file_path return file_path
def _send_current_patch(self): def _send_current_patch(self: "PQAutomationApp"):
if self.single_step_current_index is None: if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本") messagebox.showinfo("提示", "请先选择一个样本")
return return
@@ -409,9 +416,7 @@ def _send_current_patch(self):
def worker(): def worker():
try: try:
image_path = _build_color_patch(self, sample["hex"]) image_path = _build_color_patch(self, sample["hex"])
ok = send_image_pattern(self.ucd, image_path) self.signal_service.send_image(image_path)
if not ok:
raise RuntimeError("UCD323 发送失败")
self.single_step_current_image_path = image_path self.single_step_current_image_path = image_path
self._dispatch_ui( self._dispatch_ui(
self.single_step_status_var.set, self.single_step_status_var.set,
@@ -429,7 +434,7 @@ def _send_current_patch(self):
threading.Thread(target=worker, daemon=True).start() 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: if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本") messagebox.showinfo("提示", "请先选择一个样本")
return return
@@ -458,7 +463,7 @@ def _measure_current_sample(self):
threading.Thread(target=worker, daemon=True).start() threading.Thread(target=worker, daemon=True).start()
def _commit_result(self): def _commit_result(self: "PQAutomationApp"):
if self.single_step_current_index is None: if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本") messagebox.showinfo("提示", "请先选择一个样本")
return return
@@ -510,14 +515,14 @@ def _commit_result(self):
self.single_step_status_var.set(f"已记录结果ΔE2000={record['delta_e']}") self.single_step_status_var.set(f"已记录结果ΔE2000={record['delta_e']}")
def _clear_results(self): def _clear_results(self: "PQAutomationApp"):
self.single_step_results = [] self.single_step_results = []
for item in self.single_step_result_tree.get_children(): for item in self.single_step_result_tree.get_children():
self.single_step_result_tree.delete(item) self.single_step_result_tree.delete(item)
self.single_step_status_var.set("结果已清空") self.single_step_status_var.set("结果已清空")
def _export_results_csv(self): def _export_results_csv(self: "PQAutomationApp"):
if not self.single_step_results: if not self.single_step_results:
messagebox.showinfo("提示", "暂无可导出的调试结果") messagebox.showinfo("提示", "暂无可导出的调试结果")
return return
@@ -549,3 +554,24 @@ def _export_results_csv(self):
self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}") self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}")
except Exception as exc: except Exception as exc:
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}") messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
class SingleStepPanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_single_step_panel = create_single_step_panel
toggle_single_step_panel = toggle_single_step_panel
_load_default_samples = _load_default_samples
_refresh_sample_list = _refresh_sample_list
_on_sample_select = _on_sample_select
_select_sample = _select_sample
_import_samples_csv = _import_samples_csv
_delete_current_sample = _delete_current_sample
_upsert_sample = _upsert_sample
_build_color_patch = _build_color_patch
_send_current_patch = _send_current_patch
_measure_current_sample = _measure_current_sample
_commit_result = _commit_result
_clear_results = _clear_results
_export_results_csv = _export_results_csv

View File

@@ -10,6 +10,18 @@ import threading
import time 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: class PQDebugPanel:
"""PQ 单步调试面板 - 支持 Gamma/EOTF/色准单步测试""" """PQ 单步调试面板 - 支持 Gamma/EOTF/色准单步测试"""
@@ -72,7 +84,7 @@ class PQDebugPanel:
self.screen_gamma_frame, self.screen_gamma_frame,
text="测试完成后可用,选择灰阶进行单步调试", text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# 灰阶选择 # 灰阶选择
@@ -137,7 +149,7 @@ class PQDebugPanel:
self.screen_rgb_frame, self.screen_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试", text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择 # RGB 颜色选择
@@ -210,7 +222,7 @@ class PQDebugPanel:
self.sdr_gamma_frame, self.sdr_gamma_frame,
text="测试完成后可用,选择灰阶进行单步调试", text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.sdr_gamma_frame, text="选择灰阶:").grid( ttk.Label(self.sdr_gamma_frame, text="选择灰阶:").grid(
@@ -272,7 +284,7 @@ class PQDebugPanel:
self.sdr_accuracy_frame, self.sdr_accuracy_frame,
text="测试完成后可用,选择色块进行单步调试", text="测试完成后可用,选择色块进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.sdr_accuracy_frame, text="选择色块:").grid( ttk.Label(self.sdr_accuracy_frame, text="选择色块:").grid(
@@ -334,7 +346,7 @@ class PQDebugPanel:
self.sdr_rgb_frame, self.sdr_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试", text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择 # RGB 颜色选择
@@ -407,7 +419,7 @@ class PQDebugPanel:
self.hdr_eotf_frame, self.hdr_eotf_frame,
text="测试完成后可用,选择灰阶进行单步调试", text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.hdr_eotf_frame, text="选择灰阶:").grid( ttk.Label(self.hdr_eotf_frame, text="选择灰阶:").grid(
@@ -469,7 +481,7 @@ class PQDebugPanel:
self.hdr_accuracy_frame, self.hdr_accuracy_frame,
text="测试完成后可用,选择色块进行单步调试", text="测试完成后可用,选择色块进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.hdr_accuracy_frame, text="选择色块:").grid( ttk.Label(self.hdr_accuracy_frame, text="选择色块:").grid(
@@ -531,7 +543,7 @@ class PQDebugPanel:
self.hdr_rgb_frame, self.hdr_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试", text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9), font=("SimHei", 9),
foreground="gray", foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10)) ).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择 # RGB 颜色选择
@@ -1007,12 +1019,13 @@ class PQDebugPanel:
) )
# 设置标签样式 # 设置标签样式
tree.tag_configure("header", background="#E3F2FD", font=("SimHei", 9, "bold")) palette = _theme_colors()
tree.tag_configure("normal", foreground="black") tree.tag_configure("header", background=palette["info"], font=("SimHei", 9, "bold"))
tree.tag_configure("warning", foreground="red") tree.tag_configure("normal", foreground=palette["fg"])
tree.tag_configure("highlight", foreground="blue", font=("SimHei", 9, "bold")) tree.tag_configure("warning", foreground=palette["warning"])
tree.tag_configure("highlight", foreground=palette["info"], font=("SimHei", 9, "bold"))
tree.tag_configure( 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): 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): class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"} 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 = ttk.Frame(log_frame)
text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6)) text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
palette = _theme_colors()
self.log_text = tk.Text( self.log_text = tk.Text(
text_container, text_container,
height=10, height=10,
width=50, width=50,
wrap=tk.WORD, wrap=tk.WORD,
font=("Consolas", 10), font=("Consolas", 10),
bg="#fbfcfe", bg=palette["text_bg"],
fg="#1f2937", fg=palette["text_fg"],
relief=tk.FLAT, relief=tk.FLAT,
bd=0, bd=0,
padx=10, padx=10,
pady=8, pady=8,
spacing1=2, spacing1=2,
spacing3=2, spacing3=2,
insertbackground="#1f2937", insertbackground=palette["text_fg"],
) )
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT) self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
@@ -114,21 +180,45 @@ class PQLogGUI(ttk.Frame):
self._update_summary() self._update_summary()
def _configure_tags(self): def _configure_tags(self):
self.log_text.tag_configure("timestamp", foreground="#6b7280") palette = _theme_colors()
self.log_text.tag_configure("level_info", foreground="#2563eb") bg = self.log_text.cget("bg") or palette["text_bg"] or palette["bg"]
self.log_text.tag_configure("level_success", foreground="#0f766e") base_fg = _auto_text_color(bg, palette["fg"])
self.log_text.tag_configure("level_warning", foreground="#b45309") muted_fg = _mix(base_fg, bg, 0.45)
self.log_text.tag_configure("level_error", foreground="#b91c1c") debug_level_color = _mix(palette["accent"], base_fg, 0.35)
self.log_text.tag_configure("level_debug", foreground="#7c3aed") debug_msg_color = _mix(palette["accent"], base_fg, 0.50)
self.log_text.tag_configure("message", foreground="#1f2937") self.log_text.tag_configure("timestamp", foreground=palette["muted"])
self.log_text.tag_configure("message_success", foreground="#0f766e") self.log_text.tag_configure("level_info", foreground=palette["accent"])
self.log_text.tag_configure("message_warning", foreground="#b45309") self.log_text.tag_configure("level_success", foreground=palette["success"])
self.log_text.tag_configure("message_error", foreground="#991b1b") self.log_text.tag_configure("level_warning", foreground=palette["warning"])
self.log_text.tag_configure("message_debug", foreground="#6d28d9") self.log_text.tag_configure("level_error", foreground=palette["error"])
self.log_text.tag_configure("separator", foreground="#94a3b8") self.log_text.tag_configure("level_debug", foreground=debug_level_color)
self.log_text.tag_configure("traceback", foreground="#7f1d1d") 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) 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): def _append_message(self, message, level):
lines = message.splitlines() or [""] lines = message.splitlines() or [""]
for line in lines: for line in lines:

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

@@ -0,0 +1,134 @@
"""主题管理:注册 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")
# 浅色主题:沿用旧的 yeti首发布兼容
LIGHT_THEME = "yeti"
# 深色主题:自定义 Calman 风格
DARK_THEME = "calman_dark"
# ----------------------------------------------------------------------
# Calman 风格深色主题色板(参考实测截图取色)
# ----------------------------------------------------------------------
_CALMAN_DARK_COLORS = {
"primary": "#343A41", # 主色改为炭灰,避免大面积亮蓝
"secondary": "#444A51", # 中性深灰(用于 header / 分组背景)
"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 DARK_THEME in style.theme_names():
return
theme_def = ThemeDefinition(
name=DARK_THEME,
themetype="dark",
colors=_CALMAN_DARK_COLORS,
)
style.register_theme(theme_def)
# ----------------------------------------------------------------------
# 偏好持久化
# ----------------------------------------------------------------------
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 = get_saved_theme() or LIGHT_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()
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

Binary file not shown.

Binary file not shown.

View File

@@ -561,10 +561,6 @@ class UCDController:
except Exception: except Exception:
return False 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): def _get_colorimetry_from_color_space(self, color_space, color_format=None):
"""将色彩空间字符串转换为UniTAP.ColorInfo.Colorimetry。 """将色彩空间字符串转换为UniTAP.ColorInfo.Colorimetry。
BT.2020 在 YCbCr 输出时使用 CM_ITUR_BT2020_YCbCrRGB 输出时使用 CM_ITUR_BT2020_RGB。 BT.2020 在 YCbCr 输出时使用 CM_ITUR_BT2020_YCbCrRGB 输出时使用 CM_ITUR_BT2020_RGB。

441
drivers/ucd_driver.py Normal file
View File

@@ -0,0 +1,441 @@
"""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
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__)
# ─── §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._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
# -- 读访问 --------------------------------------------------
@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._lock:
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._lock:
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._lock:
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._lock:
# 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._lock:
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("apply 异常") 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")
return bool(self._controller.run())
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

@@ -9,6 +9,9 @@ import traceback
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from app_version import APP_NAME, APP_VERSION, get_app_title from app_version import APP_NAME, APP_VERSION, get_app_title
from drivers.UCD323_Function import UCDController 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_config import PQConfig
from app.pq.pq_result import PQResultStore from app.pq.pq_result import PQResultStore
from app.export import ( from app.export import (
@@ -16,14 +19,16 @@ from app.export import (
export_excel_report as _export_excel_report_impl, export_excel_report as _export_excel_report_impl,
EXCEL_EXPORT_CONFIG as _EXCEL_EXPORT_CONFIG, EXCEL_EXPORT_CONFIG as _EXCEL_EXPORT_CONFIG,
) )
from app.views.panels import custom_template_panel as _ctp from app.views.panels.custom_template_panel import CustomTemplatePanelMixin
from app.views.panels import side_panels as _sp from app.views.panels.side_panels import SidePanelsMixin
from app.views.panels import cct_panel as _ccp from app.views.panels.cct_panel import CctPanelMixin
from app.views.panels import main_layout as _main from app.views.panels.main_layout import MainLayoutMixin
from app.views.panels import ai_image_panel as _aip from app.views.panels.ai_image_panel import AIImagePanelMixin
from app.views.panels import single_step_panel as _ssp from app.views.panels.single_step_panel import SingleStepPanelMixin
from app.views.panels import pantone_baseline_panel as _pbp from app.views.panels.pantone_baseline_panel import PantoneBaselinePanelMixin
from app.views import panel_manager as PM 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 from app.logging_setup import setup_logging, attach_gui_handler
# Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持 # Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持
@@ -41,77 +46,46 @@ from app.tests.color_accuracy import (
from app.tests.eotf import calculate_pq_curve as _calc_pq_curve 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.gamma import calculate_gamma as _calc_gamma
from app.tests.gamut import calculate_gamut_coverage as _calc_gamut_coverage 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_accuracy import PlotAccuracyMixin
from app.plots.plot_cct import plot_cct as _plot_cct from app.plots.plot_cct import PlotCctMixin
from app.plots.plot_contrast import plot_contrast as _plot_contrast from app.plots.plot_contrast import PlotContrastMixin
from app.plots.plot_eotf import plot_eotf as _plot_eotf from app.plots.plot_eotf import PlotEotfMixin
from app.plots.plot_gamma import plot_gamma as _plot_gamma from app.plots.plot_gamma import PlotGammaMixin
from app.plots.plot_gamut import plot_gamut as _plot_gamut from app.plots.plot_gamut import PlotGamutMixin
from app.views.chart_frame import ( from app.views.chart_frame import ChartFrameMixin
clear_chart as _cf_clear_chart, from app.config_io import ConfigIOMixin
create_result_chart_frame as _cf_create_result_chart_frame, from app.tests.local_dimming import LocalDimmingMixin
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.services import PatternService from app.services import PatternService
from app.device.connection import ( from app.device.connection import DeviceConnectionMixin
check_com_connections as _dev_check_com_connections, from app.runner.test_runner import TestRunnerMixin
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,
)
plt.rcParams["font.family"] = ["sans-serif"] plt.rcParams["font.family"] = ["sans-serif"]
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] 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): def __init__(self, root):
self.root = root self.root = root
self.root.title(get_app_title()) self.root.title(get_app_title())
@@ -126,7 +100,20 @@ class PQAutomationApp:
# 初始化设备连接状态 # 初始化设备连接状态
self.ca = None # CA410色度计 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 self.testing = False
@@ -162,12 +149,12 @@ class PQAutomationApp:
self.log_visible = False 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(side=tk.LEFT, fill=tk.Y, padx=0, pady=5)
self.left_frame.pack_propagate(False) 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(fill=tk.BOTH, expand=True, padx=0, pady=5)
# self.sidebar_frame.pack_propagate(False) # self.sidebar_frame.pack_propagate(False)
@@ -196,8 +183,8 @@ class PQAutomationApp:
# 创建右上角悬浮配置框 # 创建右上角悬浮配置框
self.create_floating_config_panel() self.create_floating_config_panel()
# 创建右侧结果显示区域 # 创建右侧结果显示区域(无边框,纯 Frame让图表占满
self.result_frame = ttk.LabelFrame(self.control_frame_middle, text="测试结果") self.result_frame = ttk.Frame(self.control_frame_middle)
self.result_frame.pack( self.result_frame.pack(
side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=5 side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=5
) )
@@ -212,6 +199,10 @@ class PQAutomationApp:
# self.create_single_step_panel() # self.create_single_step_panel()
# 创建 Pantone 认证摸底测试面板 # 创建 Pantone 认证摸底测试面板
self.create_pantone_baseline_panel() self.create_pantone_baseline_panel()
# 创建 Gamma 测试图案配置面板
self.create_gamma_pattern_panel()
# 创建 CALMAN 风格灰阶测试面板
self.create_calman_panel()
# 创建测试类型选择区域 # 创建测试类型选择区域
self.create_test_type_frame() self.create_test_type_frame()
# 创建操作按钮区域 # 创建操作按钮区域
@@ -223,12 +214,24 @@ class PQAutomationApp:
# 在所有控件创建完成后,统一初始化测试类型 # 在所有控件创建完成后,统一初始化测试类型
self.root.after(100, self.initialize_default_test_type) self.root.after(100, self.initialize_default_test_type)
# 状态栏 # 状态栏(现代化扁平条,跟随 ttkbootstrap 主题)
self.status_var = tk.StringVar(value="就绪") 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( 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): def _dispatch_ui(self, fn, *args, **kwargs):
"""把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。 """把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。
@@ -261,111 +264,6 @@ class PQAutomationApp:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"初始化默认测试类型失败: {str(e)}", level="error") 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): def _save_current_cct_params(self, swallow_errors=True):
"""按当前测试类型分发保存对应的 CCT 参数。""" """按当前测试类型分发保存对应的 CCT 参数。"""
try: try:
@@ -460,10 +358,11 @@ class PQAutomationApp:
"screen_module": 0, "screen_module": 0,
"sdr_movie": 1, "sdr_movie": 1,
"hdr_movie": 2, "hdr_movie": 2,
"local_dimming": 3,
} }
target_tab = tab_mapping.get(test_type, 0) 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.tab(i, state="normal")
self.signal_tabs.select(target_tab) self.signal_tabs.select(target_tab)
@@ -476,8 +375,10 @@ class PQAutomationApp:
self.sdr_signal_frame.tkraise() self.sdr_signal_frame.tkraise()
elif target_tab == 2: elif target_tab == 2:
self.hdr_signal_frame.tkraise() 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: if i != target_tab:
self.signal_tabs.tab(i, state="disabled") self.signal_tabs.tab(i, state="disabled")
@@ -499,16 +400,16 @@ class PQAutomationApp:
if test_type == "hdr_movie": if test_type == "hdr_movie":
if gamma_tab_id in current_tabs: if gamma_tab_id in current_tabs:
gamma_index = current_tabs.index(gamma_tab_id) self.chart_notebook.forget(self.gamma_chart_frame)
self.chart_notebook.forget(gamma_index)
if eotf_tab_id not in current_tabs: if eotf_tab_id not in current_tabs:
self.chart_notebook.insert(1, self.eotf_chart_frame, text="EOTF 曲线") insert_pos = min(1, len(self.chart_notebook.tabs()))
self.chart_notebook.insert(insert_pos, self.eotf_chart_frame, text="EOTF 曲线")
else: else:
if eotf_tab_id in current_tabs: if eotf_tab_id in current_tabs:
eotf_index = current_tabs.index(eotf_tab_id) self.chart_notebook.forget(self.eotf_chart_frame)
self.chart_notebook.forget(eotf_index)
if gamma_tab_id not in current_tabs: if gamma_tab_id not in current_tabs:
self.chart_notebook.insert(1, self.gamma_chart_frame, text="Gamma 曲线") insert_pos = min(1, len(self.chart_notebook.tabs()))
self.chart_notebook.insert(insert_pos, self.gamma_chart_frame, text="Gamma 曲线")
custom_tab_id = str(self.custom_template_tab_frame) custom_tab_id = str(self.custom_template_tab_frame)
current_tabs = list(self.chart_notebook.tabs()) current_tabs = list(self.chart_notebook.tabs())
@@ -534,6 +435,7 @@ class PQAutomationApp:
"ai_image", "ai_image",
"single_step", "single_step",
"pantone_baseline", "pantone_baseline",
"gamma_pattern",
): ):
self.hide_all_panels() self.hide_all_panels()
self._save_cct_params_before_test_type_switch() self._save_cct_params_before_test_type_switch()
@@ -634,6 +536,8 @@ class PQAutomationApp:
return "开始 SDR Movie 测试,请设置正确的图像模式" return "开始 SDR Movie 测试,请设置正确的图像模式"
if test_type == "hdr_movie": if test_type == "hdr_movie":
return "开始 HDR Movie 测试,请设置正确的图像模式" return "开始 HDR Movie 测试,请设置正确的图像模式"
if test_type == "local_dimming":
return "Local Dimming 为手动模式,请在右侧面板发送图案并采集数据"
return f"开始{self.get_test_type_name(test_type)}测试" return f"开始{self.get_test_type_name(test_type)}测试"
def _launch_test_thread(self, test_type, test_items): def _launch_test_thread(self, test_type, test_items):
@@ -827,21 +731,8 @@ class PQAutomationApp:
self.log_gui.log(traceback.format_exc(), level="error") self.log_gui.log(traceback.format_exc(), level="error")
messagebox.showerror("错误", f"保存测试结果失败: {str(e)}") messagebox.showerror("错误", f"保存测试结果失败: {str(e)}")
new_pq_results = _run_new_pq_results # 纯算法函数:作为 staticmethod 保留在主类(不依赖 self且 calculate_xxx
run_test = _run_run_test # 的命名空间由历史代码以 self.calculate_xxx 调用)。
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
calculate_delta_e_2000 = staticmethod(_calc_delta_e_2000) calculate_delta_e_2000 = staticmethod(_calc_delta_e_2000)
get_accuracy_color_standards = staticmethod(_get_accuracy_color_standards) get_accuracy_color_standards = staticmethod(_get_accuracy_color_standards)
calculate_gamut_coverage = staticmethod(_calc_gamut_coverage) calculate_gamut_coverage = staticmethod(_calc_gamut_coverage)
@@ -849,20 +740,6 @@ class PQAutomationApp:
calculate_color_accuracy = staticmethod(_calc_color_accuracy) calculate_color_accuracy = staticmethod(_calc_color_accuracy)
calculate_pq_curve = staticmethod(_calc_pq_curve) 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): def get_test_type_name(self, test_type):
"""获取测试类型的显示名称""" """获取测试类型的显示名称"""
if test_type == "screen_module": if test_type == "screen_module":
@@ -871,6 +748,8 @@ class PQAutomationApp:
return "SDR Movie测试" return "SDR Movie测试"
elif test_type == "hdr_movie": elif test_type == "hdr_movie":
return "HDR Movie测试" return "HDR Movie测试"
elif test_type == "local_dimming":
return "Local Dimming"
return test_type return test_type
def get_selected_test_items(self): def get_selected_test_items(self):
@@ -892,8 +771,19 @@ class PQAutomationApp:
# 保存当前选中的测试项到配置 # 保存当前选中的测试项到配置
self.config.set_current_test_items(self.get_selected_test_items()) self.config.set_current_test_items(self.get_selected_test_items())
# 待修改为三种测试类型的timing值 # 按当前测试类型保存对应 timing避免误覆盖其它测试类型配置。
if self.config.current_test_type == "screen_module":
self.config.set_current_timing(self.screen_module_timing_var.get()) 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() self.save_pq_config()
@@ -912,6 +802,13 @@ class PQAutomationApp:
# 控制参数框的显示 # 控制参数框的显示
self.toggle_cct_params_frame() self.toggle_cct_params_frame()
# 同步刷新顶部 header 折叠预览(现代化布局新增)
if hasattr(self, "refresh_config_preview"):
try:
self.refresh_config_preview()
except Exception:
pass
def on_closing(self): def on_closing(self):
"""窗口关闭时的处理""" """窗口关闭时的处理"""
try: try:
@@ -939,8 +836,10 @@ class PQAutomationApp:
def main(): def main():
try: try:
setup_logging() setup_logging()
# root = tk.Tk() # 先以浅色主题启动 Window再根据用户偏好含自定义 Calman 深色主题)切换
root = ttk.Window(themename="yeti") root = ttk.Window(themename="yeti")
from app.views.theme_manager import apply_initial_theme
apply_initial_theme()
app = PQAutomationApp(root) app = PQAutomationApp(root)
# GUI 创建完成后,把 logging 记录同步到日志面板 # GUI 创建完成后,把 logging 记录同步到日志面板
if hasattr(app, "log_gui"): if hasattr(app, "log_gui"):

View File

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

View File

@@ -1,5 +1,5 @@
{ {
"current_test_type": "sdr_movie", "current_test_type": "screen_module",
"test_types": { "test_types": {
"screen_module": { "screen_module": {
"name": "屏模组性能测试", "name": "屏模组性能测试",
@@ -9,10 +9,17 @@
"cct", "cct",
"contrast" "contrast"
], ],
"timing": "DMT 1600x 1200 @ 60Hz", "timing": "OVT 1280x 720 @ 120Hz",
"data_range": "Full",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "DCI-P3",
"patterns": {
"gamut": "rgb",
"gamma": "gray",
"cct": "gray",
"contrast": "rgb"
},
"cct_params": { "cct_params": {
"x_ideal": 0.3127, "x_ideal": 0.3127,
"x_tolerance": 0.003, "x_tolerance": 0.003,
@@ -24,19 +31,28 @@
"sdr_movie": { "sdr_movie": {
"name": "SDR Movie测试", "name": "SDR Movie测试",
"test_items": [ "test_items": [
"gamut" "gamut",
"accuracy"
], ],
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "OVT 1280x 720 @ 120Hz",
"data_range": "Full",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
"patterns": {
"gamut": "rgb",
"gamma": "gray",
"cct": "gray",
"contrast": "rgb",
"accuracy": "accuracy"
},
"cct_params": { "cct_params": {
"x_ideal": 0.3127, "x_ideal": 0.3127,
"x_tolerance": 0.003, "x_tolerance": 0.003,
"y_ideal": 0.329, "y_ideal": 0.329,
"y_tolerance": 0.003 "y_tolerance": 0.003
}, },
"gamut_reference": "BT.2020" "gamut_reference": "DCI-P3"
}, },
"hdr_movie": { "hdr_movie": {
"name": "HDR Movie测试", "name": "HDR Movie测试",
@@ -48,15 +64,33 @@
"accuracy" "accuracy"
], ],
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
"patterns": {
"gamut": "rgb",
"eotf": "gray",
"cct": "gray",
"contrast": "rgb",
"accuracy": "accuracy"
},
"cct_params": { "cct_params": {
"x_ideal": 0.3127, "x_ideal": 0.3127,
"x_tolerance": 0.003, "x_tolerance": 0.003,
"y_ideal": 0.329, "y_ideal": 0.329,
"y_tolerance": 0.003 "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": { "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()