Compare commits

..

28 Commits

Author SHA1 Message Date
xinzhu.yin
46a97d6ae7 优化ucd调用结构 2026-06-11 16:29:36 +08:00
xinzhu.yin
cc7218411c 重构UCD模块 2026-06-11 15:53:41 +08:00
xinzhu.yin
38222ff002 修改UI细节错误 2026-06-10 11:39:08 +08:00
xinzhu.yin
3206079c63 修复色准结果图偏移 2026-06-09 17:15:54 +08:00
xinzhu.yin
25be4b7f4a 修复日志切换错误 2026-06-09 15:40:44 +08:00
xinzhu.yin
f33984affa 修复LocalDimming测试错误 2026-06-09 15:22:20 +08:00
xinzhu.yin
8916f2fff0 修改SDR色准深色异常、修改保存结果深色模式异常 2026-06-09 11:02:55 +08:00
xinzhu.yin
9ad9cf9aa0 添加手动设置窗口亮度、曲线图生成 2026-06-08 11:39:54 +08:00
xinzhu.yin
e4890d9d8d 添加瞬时峰值测试 2026-06-08 11:14:12 +08:00
xinzhu.yin
febbb28a4c 修改部分UI、修改module中心点设定、添加单独连接 2026-06-08 11:03:10 +08:00
xinzhu.yin
e9a591bf6e 修改深色模式下结果图片显示异常 2026-06-05 16:58:46 +08:00
xinzhu.yin
49d82da8b9 修改Calman灰阶中结果图显示、修改UI主题样式应用 2026-06-04 10:36:15 +08:00
xinzhu.yin
3aa975c4d3 修改calman灰阶点击异常、修改色准结果显示异常 2026-06-02 17:34:46 +08:00
xinzhu.yin
85ac47e8de 修改AI生图接口、修改设备连接UI、修改LocalDimming逻辑和UI 2026-05-29 14:40:39 +08:00
xinzhu.yin
21455f3916 修复日志模块深色显示不正确 2026-05-29 08:32:21 +08:00
xinzhu.yin
4498ec501e 继续优化AI图片列表UI显示 2026-05-28 17:34:51 +08:00
xinzhu.yin
64764524aa 添加Local Dimming图像 2026-05-28 17:02:22 +08:00
xinzhu.yin
c173e2338d 继续优化深色模式显示 2026-05-28 16:41:52 +08:00
xinzhu.yin
cf724d60d7 添加深色模式 2026-05-28 10:50:52 +08:00
xinzhu.yin
f8f2d471e5 屏模组添加colorinfo设置、修改配置项UI样式 2026-05-28 10:20:17 +08:00
xinzhu.yin
c63b9ef615 继续优化色准测试结果显示模块 2026-05-27 16:26:19 +08:00
xinzhu.yin
59c9424218 修改色准测试结果显示 2026-05-27 14:58:44 +08:00
xinzhu.yin
dff4e0df4d 修改引用逻辑、新增Pattern更改界面、新增Calman灰阶界面 2026-05-27 11:26:28 +08:00
xinzhu.yin
a903c17cb3 修复切换config后异常显示pattern问题 2026-05-24 11:37:45 +08:00
xinzhu.yin
29f7d39fe9 删除所有外部旧引用 2026-05-24 11:21:30 +08:00
xinzhu.yin
1b66fff35b 迁移 GUI 调用点 2026-05-24 11:02:37 +08:00
xinzhu.yin
a855ba7157 Merge branch 'master' of http://szmoka.tclking.com:8081/Windows.Python/pqAutomationApp 2026-05-24 10:49:40 +08:00
xinzhu.yin
3ce1574320 新建UCD模块DDD重构文件 2026-05-24 10:49:28 +08:00
52 changed files with 11646 additions and 2621 deletions

View File

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

View File

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

View File

@@ -2,6 +2,20 @@
import os
def _save_with_theme_background(fig, path, *, dpi=300, bbox_inches=None):
"""按图表当前主题背景导出,避免深色模式下被强制写成白底。"""
bg = fig.get_facecolor()
kwargs = {
"dpi": dpi,
"facecolor": bg,
"edgecolor": bg,
"transparent": False,
}
if bbox_inches is not None:
kwargs["bbox_inches"] = bbox_inches
fig.savefig(path, **kwargs)
def _gamut_refs_for_type(test_type):
"""按测试类型返回需要导出的参考色域列表。"""
if test_type == "sdr_movie":
@@ -70,7 +84,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue
per_ref_name = f"色域测试结果_{ref}.png"
path = os.path.join(result_dir, per_ref_name)
fig.savefig(path, dpi=300)
_save_with_theme_background(fig, path, dpi=300)
log(f"已保存: {per_ref_name}")
finally:
ref_var.set(original_ref)
@@ -82,7 +96,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue
path = os.path.join(result_dir, filename)
if default_bbox:
fig.savefig(path, dpi=300)
_save_with_theme_background(fig, path, dpi=300)
else:
fig.savefig(path, dpi=300, bbox_inches="tight")
_save_with_theme_background(fig, path, dpi=300, bbox_inches="tight")
log(f"已保存: {filename}")

View File

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

View File

@@ -1,14 +1,19 @@
"""CIE 色度图底图渲染与缓存。
"""
CIE 色度图底图渲染与缓存(工业版)
"重型图像渲染"colour-science 的谱迹颜色填充)与"轻量框架数据层"
(参考/实测三角形、标签、覆盖率)解耦。
特点:
- colour-science 谱迹渲染
- numpy RGBA 缓存
- 内存 + 磁盘缓存
- 支持 light / dark UI
- 启动预热
- 线程安全
底图
- 仅在首次调用或缓存失效时通过 colour-science 渲染一次;
- 渲染结果保存为 numpy RGBA 数组,同时落盘到 settings/cache/
下次启动直接 imread 加载,避免重新跑色彩科学计算。
调用方式
bg, bbox = get_cie1931_background(mode="dark")
ax.imshow(bg, extent=bbox)
调用方在每次绘图时只需 `ax.imshow(bg, extent=bbox)`,再叠加自己的矢量层。
"""
from __future__ import annotations
@@ -20,90 +25,140 @@ from typing import Tuple
import numpy as np
# 谱迹底图分辨率边长单位像素。1024 对于 14 inch 画布足够细腻,
# 文件大小 ~1-2MB单次渲染 ~0.5-1 s缓存后毫秒级加载。
# ----------------------------
# 配置
# ----------------------------
# 渲染分辨率
_DIAGRAM_RES = 1024
# 缓存版本号:当渲染参数或风格调整时递增,强制重新生成。
_CACHE_VERSION = "v1"
# 缓存版本(风格变化时递增)
_CACHE_VERSION = "v2"
_BBox = Tuple[float, float, float, float] # (xmin, xmax, ymin, ymax)
# UI 颜色
_DARK_BG = "#0f1115"
_LIGHT_BG = "#ffffff"
_BBox = Tuple[float, float, float, float]
_CIE1931_BBOX: _BBox = (0.0, 0.8, 0.0, 0.9)
_CIE1976_BBOX: _BBox = (0.0, 0.65, 0.0, 0.6)
_memory_cache: dict[str, np.ndarray] = {}
_lock = threading.Lock()
# ----------------------------
# cache path
# ----------------------------
def _cache_dir() -> str:
# 项目根目录通过本文件位置反推app/plots/ -> 项目根
here = os.path.dirname(os.path.abspath(__file__))
root = os.path.abspath(os.path.join(here, "..", ".."))
d = os.path.join(root, "settings", "cache")
os.makedirs(d, exist_ok=True)
return d
def _cache_key(kind: str, bbox: _BBox) -> str:
sig = f"{kind}|{bbox}|{_DIAGRAM_RES}|{_CACHE_VERSION}"
h = hashlib.md5(sig.encode("utf-8")).hexdigest()[:10]
return f"chromaticity_{kind}_{h}.npy"
def _cache_key(kind: str, bbox: _BBox, mode: str) -> str:
sig = f"{kind}|{bbox}|{mode}|{_DIAGRAM_RES}|{_CACHE_VERSION}"
h = hashlib.md5(sig.encode()).hexdigest()[:10]
return f"chromaticity_{kind}_{mode}_{h}.npy"
def _cache_path(kind: str, bbox: _BBox) -> str:
return os.path.join(_cache_dir(), _cache_key(kind, bbox))
def _cache_path(kind: str, bbox: _BBox, mode: str) -> str:
return os.path.join(_cache_dir(), _cache_key(kind, bbox, mode))
def _render_chromaticity(kind: str, bbox: _BBox) -> np.ndarray:
"""通过 colour-science 离屏渲染谱迹底图,返回 RGBA float 数组。"""
# 延迟导入:仅在缓存未命中时支付 colour.plotting 的加载开销。
# ----------------------------
# 渲染
# ----------------------------
def _render_chromaticity(kind: str, bbox: _BBox, mode: str) -> np.ndarray:
"""
通过 colour-science 渲染 chromaticity 图。
"""
import matplotlib
prev_backend = matplotlib.get_backend()
try:
matplotlib.use("Agg", force=True)
except Exception:
pass
import matplotlib.pyplot as plt
import colour
from colour.plotting import (
plot_chromaticity_diagram_CIE1931,
plot_chromaticity_diagram_CIE1976UCS,
)
if mode == "dark":
colour.plotting.colour_style("dark")
bg_color = _DARK_BG
else:
colour.plotting.colour_style("light")
bg_color = _LIGHT_BG
xmin, xmax, ymin, ymax = bbox
aspect = (xmax - xmin) / (ymax - ymin)
height = _DIAGRAM_RES
width = int(round(height * aspect))
fig = plt.figure(figsize=(width / 100.0, height / 100.0), dpi=100)
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
fig = plt.figure(
figsize=(width / 100.0, height / 100.0),
dpi=100
)
fig.patch.set_facecolor(bg_color)
ax = fig.add_axes([0, 0, 1, 1])
ax.set_facecolor(bg_color)
if kind == "cie1931":
plot_chromaticity_diagram_CIE1931(
axes=ax, show=False, title=False,
tight_layout=False, transparent_background=True,
axes=ax,
show=False,
title=False,
tight_layout=False,
bounding_box=bbox,
transparent_background=False,
)
elif kind == "cie1976":
plot_chromaticity_diagram_CIE1976UCS(
axes=ax, show=False, title=False,
tight_layout=False, transparent_background=True,
axes=ax,
show=False,
title=False,
tight_layout=False,
bounding_box=bbox,
transparent_background=False,
)
else:
plt.close(fig)
raise ValueError(f"unknown diagram kind: {kind!r}")
raise ValueError(f"unknown diagram kind: {kind}")
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
ax.set_axis_off()
ax.set_position([0.0, 0.0, 1.0, 1.0])
ax.set_position([0, 0, 1, 1])
fig.canvas.draw()
# 从 canvas 抓取 RGBA 数组
buf = np.asarray(fig.canvas.buffer_rgba()).copy()
buf = np.flipud(buf)
plt.close(fig)
try:
@@ -114,52 +169,107 @@ def _render_chromaticity(kind: str, bbox: _BBox) -> np.ndarray:
return buf
def _load_or_render(kind: str, bbox: _BBox) -> np.ndarray:
key = _cache_key(kind, bbox)
# ----------------------------
# load / render
# ----------------------------
def _load_or_render(kind: str, bbox: _BBox, mode: str) -> np.ndarray:
key = _cache_key(kind, bbox, mode)
with _lock:
if key in _memory_cache:
return _memory_cache[key]
disk = _cache_path(kind, bbox)
disk = _cache_path(kind, bbox, mode)
if os.path.isfile(disk):
try:
arr = np.load(disk)
_memory_cache[key] = arr
return arr
except Exception:
# 缓存损坏则重新渲染
try:
os.remove(disk)
except OSError:
pass
arr = _render_chromaticity(kind, bbox)
arr = _render_chromaticity(kind, bbox, mode)
_memory_cache[key] = arr
try:
np.save(disk, arr)
except Exception:
pass
return arr
def get_cie1931_background() -> Tuple[np.ndarray, _BBox]:
"""返回 (RGBA 数组, bbox),可直接 ax.imshow(arr, extent=[*bbox])。"""
return _load_or_render("cie1931", _CIE1931_BBOX), _CIE1931_BBOX
# ----------------------------
# public API
# ----------------------------
def get_cie1931_background(mode: str = "dark") -> Tuple[np.ndarray, _BBox]:
"""
获取 CIE1931 背景图
mode:
"dark"
"light"
"""
return _load_or_render("cie1931", _CIE1931_BBOX, mode), _CIE1931_BBOX
def get_cie1976_background() -> Tuple[np.ndarray, _BBox]:
return _load_or_render("cie1976", _CIE1976_BBOX), _CIE1976_BBOX
def get_cie1976_background(mode: str = "dark") -> Tuple[np.ndarray, _BBox]:
return _load_or_render("cie1976", _CIE1976_BBOX, mode), _CIE1976_BBOX
# ----------------------------
# cache control
# ----------------------------
def clear_cache(*, disk: bool = False) -> None:
"""清空内存缓存(可选连同磁盘)。供调试/样式调整时使用。"""
"""
清空缓存
"""
with _lock:
_memory_cache.clear()
if disk:
d = _cache_dir()
for name in os.listdir(d):
if name.startswith("chromaticity_") and name.endswith(".npy"):
if name.startswith("chromaticity_"):
try:
os.remove(os.path.join(d, name))
except OSError:
pass
# ----------------------------
# warmup
# ----------------------------
def warmup_cache(mode: str = "dark") -> None:
"""
启动预热缓存
可在软件启动时调用,避免首次绘图卡顿。
"""
get_cie1931_background(mode)
get_cie1976_background(mode)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

@@ -1 +1,3 @@
from app.services.pattern_service import PatternService, PatternSession
from app.ucd import PatternService, PatternSession
__all__ = ["PatternService", "PatternSession"]

View File

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

View File

@@ -1,163 +0,0 @@
from __future__ import annotations
import copy
from dataclasses import dataclass
from app.data_range_converter import convert_pattern_params
from app.pq.pq_config import get_pattern
from drivers.ucd_helpers import send_solid_rgb_pattern
@dataclass
class PatternSession:
mode: str
test_type: str
active_config: object
pattern_params: list[list[int]]
total_patterns: int
display_names: list[str]
class PatternService:
def __init__(self, app):
self.app = app
def prepare_session(self, mode, *, test_type=None, log_details=False):
test_type = test_type or self.app.config.current_test_type
if not self.app.config.set_current_pattern(mode):
raise ValueError(f"未知的图案模式: {mode}")
active_config = self.app.config
source_params = self._get_source_pattern_params(mode)
if test_type == "screen_module":
if log_details:
self._log("=" * 50, "separator")
self._log("设置屏模组信号格式:", "info")
self._log("=" * 50, "separator")
self._log(
f" Timing: {self.app.config.current_test_types[test_type]['timing']}",
"info",
)
self.app.ucd.set_ucd_params(active_config)
elif test_type == "sdr_movie":
data_range = self.app.sdr_data_range_var.get()
if log_details:
self._log("=" * 50, "separator")
self._log("设置 SDR 信号格式:", "info")
self._log("=" * 50, "separator")
for label, value in [
("色彩空间", self.app.sdr_color_space_var.get()),
("色彩格式", self.app.sdr_output_format_var.get()),
("Gamma", self.app.sdr_gamma_type_var.get()),
("数据范围", data_range),
("编码位深", self.app.sdr_bit_depth_var.get()),
]:
self._log(f" {label}: {value}", "info")
converted_params = convert_pattern_params(
source_params, data_range=data_range, verbose=False
)
active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
self.app.ucd.set_ucd_params(active_config)
success = self.app.ucd.apply_signal_format(
color_space=self.app.sdr_color_space_var.get(),
data_range=data_range,
bit_depth=self.app.sdr_bit_depth_var.get(),
color_format=self.app.sdr_output_format_var.get(),
)
if log_details:
self._log(f"SDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error")
self._log(f"图案参数已设置,共 {len(converted_params)} 个图案", "success")
elif test_type == "hdr_movie":
data_range = self.app.hdr_data_range_var.get()
if log_details:
self._log("=" * 50, "separator")
self._log("设置 HDR 信号格式:", "info")
self._log("=" * 50, "separator")
for label, value in [
("色彩空间", self.app.hdr_color_space_var.get()),
("色彩格式", self.app.hdr_output_format_var.get()),
("数据范围", data_range),
("编码位深", self.app.hdr_bit_depth_var.get()),
("MaxCLL", self.app.hdr_maxcll_var.get()),
("MaxFALL", self.app.hdr_maxfall_var.get()),
]:
self._log(f" {label}: {value}", "info")
converted_params = convert_pattern_params(
source_params, data_range=data_range, verbose=False
)
active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
self.app.ucd.set_ucd_params(active_config)
success = self.app.ucd.apply_signal_format(
color_space=self.app.hdr_color_space_var.get(),
data_range=data_range,
bit_depth=self.app.hdr_bit_depth_var.get(),
color_format=self.app.hdr_output_format_var.get(),
max_cll=self.app.hdr_maxcll_var.get(),
max_fall=self.app.hdr_maxfall_var.get(),
)
if log_details:
self._log(f"HDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error")
self._log(f"图案参数已设置,共 {len(converted_params)} 个图案", "success")
else:
raise ValueError(f"不支持的测试类型: {test_type}")
pattern_params = copy.deepcopy(active_config.current_pattern["pattern_params"])
return PatternSession(
mode=mode,
test_type=test_type,
active_config=active_config,
pattern_params=pattern_params,
total_patterns=len(pattern_params),
display_names=self._get_display_names(mode, len(pattern_params)),
)
def send_session_pattern(self, session, index):
if index < 0 or index >= session.total_patterns:
raise IndexError(f"pattern 索引越界: {index}")
pattern_param = session.pattern_params[index]
if not self.app.ucd.send_current_pattern_params(pattern_param):
raise RuntimeError(f"发送 pattern 失败: {index}")
return pattern_param
def send_rgb(self, rgb, *, session=None, test_type=None):
active_session = session or self.prepare_session(
"rgb",
test_type=test_type,
log_details=False,
)
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type)
send_solid_rgb_pattern(self.app.ucd, converted_rgb, raise_on_error=True)
return True
def _get_source_pattern_params(self, mode):
return copy.deepcopy(get_pattern(mode)["pattern_params"])
def _get_display_names(self, mode, total_patterns):
if mode == "accuracy":
return self.app.config.get_accuracy_color_names()
if mode == "custom" and hasattr(self.app.config, "get_temp_pattern_names"):
return self.app.config.get_temp_pattern_names()
return [f"P {index + 1}" for index in range(total_patterns)]
def _convert_rgb_for_test_type(self, rgb, test_type):
if test_type == "sdr_movie":
data_range = self.app.sdr_data_range_var.get()
elif test_type == "hdr_movie":
data_range = self.app.hdr_data_range_var.get()
else:
data_range = "Full"
return convert_pattern_params([list(rgb)], data_range=data_range, verbose=False)[0]
def _log(self, message, level):
if hasattr(self.app, "log_gui"):
self.app.log_gui.log(message, level=level)

File diff suppressed because it is too large Load Diff

28
app/ucd/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""UCD 信号发生器 — domain / enum / device / service。
GUI 与测试代码通常只需::
from app.ucd import SignalService, UCD323Device, EventBus
"""
from app.ucd.domain import * # noqa: F403
from app.ucd.enum import UCDEnum
from app.ucd.device import (
DeviceInfo,
IUcdDevice,
UCD323Device,
list_devices,
)
from app.ucd.service import PatternService, PatternSession, SignalService
__all__ = [
"SignalService",
"PatternService",
"PatternSession",
"UCD323Device",
"IUcdDevice",
"DeviceInfo",
"UCDEnum",
"EventBus",
"ConnectionChanged",
"DeviceKind",
]

1267
app/ucd/device.py Normal file

File diff suppressed because it is too large Load Diff

486
app/ucd/domain.py Normal file
View File

@@ -0,0 +1,486 @@
"""UCD 域层:类型、状态机、事件、字符串与 PQConfig 映射。"""
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 DeviceKind(str, Enum):
"""连接状态事件所指的设备类型。"""
UCD = "ucd"
CA = "ca"
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):
device: DeviceKind
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
"DeviceKind",
"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",
]
# --- PQConfig / pattern 映射 ---
# PQ pattern_mode 字符串 → PatternKind大小写不敏感
_PQ_PATTERN_MODE_TO_KIND: dict[str, PatternKind] = {
"disabled": PatternKind.DISABLED,
"solidcolor": PatternKind.SOLID,
"solidwhite": PatternKind.SOLID_WHITE,
"solidred": PatternKind.SOLID_RED,
"solidgreen": PatternKind.SOLID_GREEN,
"solidblue": PatternKind.SOLID_BLUE,
"colorbars": PatternKind.COLOR_BARS,
"chessboard": PatternKind.CHESSBOARD,
"whitevstrips": PatternKind.WHITE_VSTRIPS,
"gradientrgbstripes": PatternKind.GRADIENT_RGB_STRIPES,
"colorramp": PatternKind.COLOR_RAMP,
"coloursquares": PatternKind.COLOR_SQUARES,
"motionpattern": PatternKind.MOTION,
"squarewindow": PatternKind.SQUARE_WINDOW,
}
def pattern_mode_to_kind(pattern_mode: str) -> PatternKind:
key = (pattern_mode or "solidcolor").strip().lower()
kind = _PQ_PATTERN_MODE_TO_KIND.get(key)
if kind is None:
raise UcdConfigError(f"不支持的 pattern_mode: {pattern_mode!r}")
return kind
def build_profile_from_config(config, test_type: str | None = None):
"""从 PQConfig 当前 test_type 条目构建 SignalFormat + TimingSpec。"""
test_type = test_type or config.current_test_type
profile = config.current_test_types[test_type]
signal = build_signal_format_from_profile(
color_space=profile["colorimetry"],
color_format=profile["color_format"],
bpc=int(profile["bpc"]),
data_range=profile.get("data_range", "Full"),
)
timing = build_timing(profile["timing"])
return signal, timing
def build_pattern_spec(config, params: list[int] | None = None) -> PatternSpec:
"""将 PQConfig 当前 pattern 与一组参数转为 :class:`PatternSpec`。"""
pattern_mode = config.current_pattern["pattern_mode"]
kind = pattern_mode_to_kind(pattern_mode)
if params is None:
params = config.current_pattern.get("pattern_params", [[]])[0]
if kind is PatternKind.SOLID and params and len(params) >= 3:
return PatternSpec(
kind=kind,
solid_rgb=(int(params[0]), int(params[1]), int(params[2])),
)
if params:
return PatternSpec(kind=kind, extras=tuple(int(v) for v in params))
return PatternSpec(kind=kind)
def build_signal_format(
*,
color_space: str,
output_format: str,
bit_depth: str,
data_range: str = "Full",
) -> SignalFormat:
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_signal_format_from_profile(
*,
color_space: str,
color_format: str,
bpc: int,
data_range: str = "Full",
) -> SignalFormat:
return build_signal_format(
color_space=color_space,
output_format=color_format,
bit_depth=f"{int(bpc)}bit",
data_range=data_range,
)
def build_timing(timing_str: str) -> 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)

View File

@@ -1,3 +1,5 @@
"""UCD SDK 枚举与 UI/SDK 字符串映射。"""
from enum import IntEnum
import UniTAP
@@ -110,7 +112,16 @@ class UCDEnum:
}
if not colorimetry_str:
return None
return colorimetry_map.get(colorimetry_str.lower(), None)
# Normalize: strip hyphens, spaces, dots, underscores so that
# "DCI-P3" → "dcip3", "BT.709" → "bt709", "BT.2020 YCbCr" → "bt2020ycbcr"
normalized = (
colorimetry_str.lower()
.replace("-", "")
.replace(" ", "")
.replace(".", "")
.replace("_", "")
)
return colorimetry_map.get(normalized, colorimetry_map.get(colorimetry_str.lower(), None))
class VideoPatternInfo:
class VideoPattern(IntEnum):
@@ -611,3 +622,4 @@ class UCDEnum:
"DSC": "dsc",
}
return fmt_map.get(format_str, "rgb")

412
app/ucd/service.py Normal file
View File

@@ -0,0 +1,412 @@
"""UCD 服务层SignalService硬件编排+ PatternService测试发图"""
from __future__ import annotations
import logging
from app.ucd.domain import (
EventBus,
PatternSpec,
SignalFormat,
TimingSpec,
UcdState,
image_pattern,
solid_rgb_pattern,
)
from app.ucd.device import IUcdDevice
log = logging.getLogger(__name__)
# ─── SignalService ────────────────────────────────────────────────────────
class SignalService:
"""协调 SignalFormat / Timing / Pattern 的写入与提交。"""
def __init__(self, device: IUcdDevice, bus: EventBus):
self._dev = device
self._bus = bus
# -- 高层接口 ------------------------------------------------
def apply(
self,
*,
signal: SignalFormat,
timing: TimingSpec,
pattern: PatternSpec,
) -> bool:
"""一次性提交信号格式 + timing + 图案。
Returns:
``format_changed``——本次相对上一次 :meth:`apply` 是否变化。
"""
log.info(
"SignalService.apply signal=%s timing=%s pattern=%s",
signal,
timing,
pattern.kind.value,
)
changed = self._dev.configure(signal, timing)
self._dev.set_pattern(pattern)
self._dev.apply()
return changed
def send_pattern(self, pattern: PatternSpec) -> None:
"""在已 configure 的信号上仅更新图案后 apply。"""
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))
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:
"""仅将信号格式提交到 SDK沿用上一次的 timing不切换图案。
UI 字符串先经域层解析做参数校验;解析失败抛 :class:`UcdConfigError`。
"""
_ = build_signal_format(
color_space=color_space,
output_format=output_format,
bit_depth=bit_depth,
data_range=data_range,
)
return self._dev.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 做前置校验。"""
return self._dev.state != UcdState.CLOSED
@property
def format_changed(self) -> bool:
"""最近一次视频模式提交是否相对上次发生变化。"""
return self._dev.format_changed
@property
def last_error(self) -> str | None:
return self._dev.last_error
def apply_config(self, config) -> bool:
"""按 :class:`PQConfig` 写入色彩 / Timing / 当前 Pattern不 apply 输出)。"""
return bool(self._dev.set_ucd_params(config))
def send_pattern_params(self, params) -> bool:
"""以 ``params`` 更新当前 pattern 的参数并 apply。"""
return bool(self._dev.send_current_pattern_params(params))
def apply_and_run(self, config, pattern_params) -> bool:
"""``set_ucd_params`` + ``set_pattern`` + ``run`` 的复合操作。"""
return bool(self._dev.apply_config_and_run(config, pattern_params))
def stage_test_profile(
self,
config,
*,
color_space: str,
output_format: str,
bit_depth: str,
data_range: str = "Full",
max_cll: int | None = None,
max_fall: int | None = None,
) -> bool:
"""按 PQConfig stage 色彩/Timing/Pattern 类型,并提交 UI 覆盖的信号格式。
自动化测试在发图前调用;等价于 ``apply_config`` + ``update_signal_format``。
"""
if not self.apply_config(config):
return False
return self.update_signal_format(
color_space=color_space,
output_format=output_format,
bit_depth=bit_depth,
data_range=data_range,
max_cll=max_cll,
max_fall=max_fall,
)
__all__ = [
"SignalService",
"build_signal_format",
"build_signal_format_from_profile",
"build_timing",
"solid_rgb_pattern",
"image_pattern",
"SignalFormat",
"TimingSpec",
"PatternSpec",
"PatternKind",
"Colorimetry",
"DynamicRange",
"UcdError",
]
# --- PatternService ---
import copy
from dataclasses import dataclass
from app.data_range_converter import convert_pattern_params
from app.pq.pq_config import get_pattern
@dataclass
class PatternSession:
mode: str
test_type: str
active_config: object
pattern_params: list[list[int]]
total_patterns: int
display_names: list[str]
class PatternService:
def __init__(self, app):
self.app = app
def _build_apply_config_error(self, test_type):
timing = self.app.config.current_test_types.get(test_type, {}).get("timing", "-")
detail = ""
err = self.app.signal_service.last_error
if err:
detail = f", detail={err}"
return f"UCD profile apply_config failed for {test_type}, timing={timing}{detail}"
def _stage_profile(
self,
active_config,
*,
color_space: str,
output_format: str,
bit_depth: str,
data_range: str = "Full",
max_cll: int | None = None,
max_fall: int | None = None,
test_type: str,
log_details: bool = False,
log_title: str = "",
log_fields: list[tuple[str, str]] | None = None,
) -> None:
if log_details and log_title:
self._log("=" * 50, "separator")
self._log(log_title, "info")
self._log("=" * 50, "separator")
for label, value in log_fields or []:
self._log(f" {label}: {value}", "info")
if not self.app.signal_service.stage_test_profile(
active_config,
color_space=color_space,
output_format=output_format,
bit_depth=bit_depth,
data_range=data_range,
max_cll=max_cll,
max_fall=max_fall,
):
raise RuntimeError(self._build_apply_config_error(test_type))
if log_details:
self._log("信号格式设置成功", "success")
def prepare_session(self, mode, *, test_type=None, log_details=False):
test_type = test_type or self.app.config.current_test_type
if hasattr(self.app.config, "set_current_test_type"):
self.app.config.set_current_test_type(test_type)
if not self.app.config.set_current_pattern(mode):
raise ValueError(f"未知的图案模式: {mode}")
active_config = self.app.config
source_params = self._get_source_pattern_params(mode)
if test_type == "screen_module":
screen_cfg = self.app.config.current_test_types.get("screen_module", {})
color_space = (
self.app.screen_module_color_space_var.get()
if hasattr(self.app, "screen_module_color_space_var")
else screen_cfg.get("colorimetry", "sRGB")
)
data_range = (
self.app.screen_module_data_range_var.get()
if hasattr(self.app, "screen_module_data_range_var")
else screen_cfg.get("data_range", "Full")
)
bit_depth = (
self.app.screen_module_bit_depth_var.get()
if hasattr(self.app, "screen_module_bit_depth_var")
else f"{int(screen_cfg.get('bpc', 8))}bit"
)
output_format = (
self.app.screen_module_output_format_var.get()
if hasattr(self.app, "screen_module_output_format_var")
else screen_cfg.get("color_format", "RGB")
)
self._stage_profile(
active_config,
color_space=color_space,
data_range=data_range,
bit_depth=bit_depth,
output_format=output_format,
test_type=test_type,
log_details=log_details,
log_title="设置屏模组信号格式:",
log_fields=[
("色彩空间", color_space),
("色彩格式", output_format),
("数据范围", data_range),
("编码位深", bit_depth),
("Timing", self.app.config.current_test_types[test_type]["timing"]),
],
)
elif test_type == "sdr_movie":
data_range = self.app.sdr_data_range_var.get()
converted_params = convert_pattern_params(
source_params, data_range=data_range, verbose=False
)
active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
if hasattr(active_config, "set_current_test_type"):
active_config.set_current_test_type(test_type)
self._stage_profile(
active_config,
color_space=self.app.sdr_color_space_var.get(),
data_range=data_range,
bit_depth=self.app.sdr_bit_depth_var.get(),
output_format=self.app.sdr_output_format_var.get(),
test_type=test_type,
log_details=log_details,
log_title="设置 SDR 信号格式:",
log_fields=[
("色彩空间", self.app.sdr_color_space_var.get()),
("色彩格式", self.app.sdr_output_format_var.get()),
("Gamma", self.app.sdr_gamma_type_var.get()),
("数据范围", data_range),
("编码位深", self.app.sdr_bit_depth_var.get()),
],
)
if log_details:
self._log(f"图案参数已设置,共 {len(converted_params)} 个图案", "success")
elif test_type == "hdr_movie":
data_range = self.app.hdr_data_range_var.get()
converted_params = convert_pattern_params(
source_params, data_range=data_range, verbose=False
)
active_config = self.app.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
if hasattr(active_config, "set_current_test_type"):
active_config.set_current_test_type(test_type)
self._stage_profile(
active_config,
color_space=self.app.hdr_color_space_var.get(),
data_range=data_range,
bit_depth=self.app.hdr_bit_depth_var.get(),
output_format=self.app.hdr_output_format_var.get(),
max_cll=self.app.hdr_maxcll_var.get(),
max_fall=self.app.hdr_maxfall_var.get(),
test_type=test_type,
log_details=log_details,
log_title="设置 HDR 信号格式:",
log_fields=[
("色彩空间", self.app.hdr_color_space_var.get()),
("色彩格式", self.app.hdr_output_format_var.get()),
("数据范围", data_range),
("编码位深", self.app.hdr_bit_depth_var.get()),
("MaxCLL", self.app.hdr_maxcll_var.get()),
("MaxFALL", self.app.hdr_maxfall_var.get()),
],
)
if log_details:
self._log(f"图案参数已设置,共 {len(converted_params)} 个图案", "success")
else:
raise ValueError(f"不支持的测试类型: {test_type}")
pattern_params = copy.deepcopy(active_config.current_pattern["pattern_params"])
return PatternSession(
mode=mode,
test_type=test_type,
active_config=active_config,
pattern_params=pattern_params,
total_patterns=len(pattern_params),
display_names=self._get_display_names(mode, len(pattern_params)),
)
def send_session_pattern(self, session, index):
if index < 0 or index >= session.total_patterns:
raise IndexError(f"pattern 索引越界: {index}")
pattern_param = session.pattern_params[index]
if not self.app.signal_service.send_pattern_params(pattern_param):
raise RuntimeError(f"发送 pattern 失败: {index}")
return pattern_param
def send_rgb(self, rgb, *, session=None, test_type=None):
active_session = session or self.prepare_session(
"rgb",
test_type=test_type,
log_details=False,
)
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type)
self.app.signal_service.send_solid_rgb(converted_rgb)
return True
def _get_source_pattern_params(self, mode):
return copy.deepcopy(get_pattern(mode)["pattern_params"])
def _get_display_names(self, mode, total_patterns):
if mode == "accuracy":
return self.app.config.get_accuracy_color_names()
if mode == "custom" and hasattr(self.app.config, "get_temp_pattern_names"):
return self.app.config.get_temp_pattern_names()
return [f"P {index + 1}" for index in range(total_patterns)]
def _convert_rgb_for_test_type(self, rgb, test_type):
if test_type == "sdr_movie":
data_range = self.app.sdr_data_range_var.get()
elif test_type == "hdr_movie":
data_range = self.app.hdr_data_range_var.get()
else:
data_range = "Full"
return convert_pattern_params([list(rgb)], data_range=data_range, verbose=False)[0]
def _log(self, message, level):
if hasattr(self.app, "log_gui"):
self.app.log_gui.log(message, level=level)

View File

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

View File

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

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

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
"""侧边面板(日志 / Local Dimming / 调试)"""
"""侧边面板(日志 / Local Dimming / 调试)"""
import traceback
import tkinter as tk
@@ -7,7 +7,13 @@ import ttkbootstrap as ttk
from app.views.pq_log_gui import PQLogGUI
from app.views.pq_debug_panel import PQDebugPanel
def create_log_panel(self):
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
def create_log_panel(self: "PQAutomationApp"):
"""创建日志面板"""
self.log_frame = ttk.Frame(self.content_frame)
self.log_gui = PQLogGUI(self.log_frame)
@@ -22,8 +28,8 @@ def create_log_panel(self):
) # button会在后面设置
def create_local_dimming_panel(self):
"""创建 Local Dimming 测试面板 - 手动控制版"""
def create_local_dimming_panel(self: "PQAutomationApp"):
"""创建 Local Dimming 测试面板"""
self.local_dimming_frame = ttk.Frame(self.content_frame)
# 主容器
@@ -36,13 +42,13 @@ def create_local_dimming_panel(self):
ttk.Label(
title_frame,
text="🔆 Local Dimming 窗口测试",
text="Local Dimming 窗口测试",
font=("微软雅黑", 14, "bold"),
).pack(side=tk.LEFT)
# ==================== 2. 窗口百分比按钮 ====================
window_frame = ttk.LabelFrame(
main_container, text="🔆 窗口百分比(点击发送)", padding=10
main_container, text="窗口百分比", padding=10
)
window_frame.pack(fill=tk.X, pady=(0, 10))
@@ -51,9 +57,53 @@ def create_local_dimming_panel(self):
window_frame,
text="点击按钮发送对应百分比的白色窗口(黑色背景 + 居中白色矩形)",
font=("", 9),
foreground="#28a745",
style="SuccessState.TLabel",
).pack(pady=(0, 8))
window_level_row = ttk.Frame(window_frame)
window_level_row.pack(fill=tk.X, pady=(0, 8))
ttk.Label(window_level_row, text="窗口(%):").pack(side=tk.LEFT)
self.ld_window_percentage_var = tk.StringVar(value="10")
ld_window_percentage_entry = ttk.Entry(
window_level_row,
textvariable=self.ld_window_percentage_var,
width=8,
)
ld_window_percentage_entry.pack(side=tk.LEFT, padx=(6, 10))
ttk.Label(window_level_row, text="窗口亮度(%):").pack(side=tk.LEFT)
self.ld_window_luminance_var = tk.StringVar(value="100")
ld_window_luminance_entry = ttk.Entry(
window_level_row,
textvariable=self.ld_window_luminance_var,
width=8,
)
ld_window_luminance_entry.pack(side=tk.LEFT, padx=(6, 10))
ttk.Button(
window_level_row,
text="生成窗口",
command=self.send_ld_manual_window,
bootstyle="success-outline",
width=12,
).pack(side=tk.LEFT)
ld_window_percentage_entry.bind(
"<Return>",
lambda _event: self.send_ld_manual_window(),
)
ld_window_luminance_entry.bind(
"<Return>",
lambda _event: self.send_ld_manual_window(),
)
ttk.Label(
window_level_row,
text="输入后可直接点生成或回车",
style="InfoState.TLabel",
).pack(side=tk.LEFT, padx=(8, 0))
# 第一行1%, 2%, 5%, 10%, 18%
row1 = ttk.Frame(window_frame)
row1.pack(fill=tk.X, pady=(0, 5))
@@ -82,8 +132,123 @@ def create_local_dimming_panel(self):
width=12,
).pack(side=tk.LEFT, padx=3)
# ==================== 4. CA410 采集按钮 ====================
measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10)
# ==================== 3. 其他手动图案 ====================
pattern_frame = ttk.LabelFrame(main_container, text="其他测试图案", padding=10)
pattern_frame.pack(fill=tk.X, pady=(0, 10))
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_black_pattern,
bootstyle="dark",
width=12,
).pack(side=tk.LEFT, padx=3)
# ==================== 4. 独立瞬时峰值连续测试 ====================
peak_frame = ttk.LabelFrame(main_container, text="瞬时峰值独立测试", padding=10)
peak_frame.pack(fill=tk.X, pady=(0, 10))
self.ld_peak_window_size_var = tk.StringVar(value="10")
self.ld_peak_window_luminance_var = tk.StringVar(value="100")
self.ld_peak_duration_var = tk.StringVar(value="20")
self.ld_peak_sample_interval_var = tk.StringVar(value="0.3")
self.ld_peak_record_curve_var = tk.BooleanVar(value=True)
self.ld_peak_no_limit_var = tk.BooleanVar(value=False)
self.ld_peak_drop_percent_var = tk.StringVar(value="3")
ttk.Label(peak_frame, text="窗口(%):").grid(row=1, column=0, sticky=tk.W, padx=(0, 4))
ttk.Entry(peak_frame, textvariable=self.ld_peak_window_size_var, width=8).grid(
row=1, column=1, sticky=tk.W, padx=(0, 10)
)
ttk.Label(peak_frame, text="窗口亮度(%):").grid(
row=1, column=2, sticky=tk.W, padx=(0, 4)
)
ttk.Entry(peak_frame, textvariable=self.ld_peak_window_luminance_var, width=8).grid(
row=1, column=3, sticky=tk.W, padx=(0, 10)
)
ttk.Label(peak_frame, text="连续时长(s):").grid(
row=1, column=4, sticky=tk.W, padx=(0, 4)
)
ttk.Entry(peak_frame, textvariable=self.ld_peak_duration_var, width=8).grid(
row=1, column=5, sticky=tk.W, padx=(0, 10)
)
ttk.Label(peak_frame, text="采样间隔(s):").grid(
row=1, column=6, sticky=tk.W, padx=(0, 4)
)
ttk.Entry(peak_frame, textvariable=self.ld_peak_sample_interval_var, width=8).grid(
row=1, column=7, sticky=tk.W
)
ttk.Checkbutton(
peak_frame,
text="记录曲线点到表格",
variable=self.ld_peak_record_curve_var,
bootstyle="round-toggle",
).grid(row=2, column=0, columnspan=3, sticky=tk.W, pady=(8, 0))
peak_btn_row = ttk.Frame(peak_frame)
peak_btn_row.grid(row=2, column=4, columnspan=4, sticky=tk.EW, pady=(8, 0))
self.ld_peak_start_btn = ttk.Button(
peak_btn_row,
text="开始峰值追踪",
command=self.start_ld_instant_peak_tracking,
bootstyle="warning",
width=14,
)
self.ld_peak_start_btn.pack(side=tk.LEFT, padx=(0, 5))
self.ld_peak_stop_btn = ttk.Button(
peak_btn_row,
text="停止",
command=self.stop_ld_instant_peak_tracking,
bootstyle="danger-outline",
width=10,
state="disabled",
)
self.ld_peak_stop_btn.pack(side=tk.LEFT)
ttk.Label(peak_frame, text="亮度回落(%):").grid(
row=2, column=0, sticky=tk.W, padx=(0, 4), pady=(6, 0)
)
ttk.Entry(
peak_frame,
textvariable=self.ld_peak_drop_percent_var,
width=8
).grid(row=2, column=1, sticky=tk.W, pady=(6, 0))
ttk.Checkbutton(
peak_frame,
text="不固定测试时间",
variable=self.ld_peak_no_limit_var,
bootstyle="round-toggle",
).grid(row=2, column=2, columnspan=3, sticky=tk.W, pady=(6, 0))
# ==================== 5. CA410 采集按钮 ====================
measure_frame = ttk.LabelFrame(main_container, text="CA410 测量", padding=10)
measure_frame.pack(fill=tk.X, pady=(0, 10))
measure_btn_frame = ttk.Frame(measure_frame)
@@ -91,7 +256,7 @@ def create_local_dimming_panel(self):
self.ld_measure_btn = ttk.Button(
measure_btn_frame,
text="📏 采集当前亮度",
text="采集当前亮度",
command=self.measure_ld_luminance,
bootstyle="primary",
width=15,
@@ -103,24 +268,28 @@ def create_local_dimming_panel(self):
measure_btn_frame,
text="亮度: -- cd/m² | x: -- | y: --",
font=("Consolas", 10),
foreground="#007bff",
style="InfoState.TLabel",
)
self.ld_result_label.pack(side=tk.LEFT, padx=(10, 0))
# ==================== 5. 测试结果表格 ====================
result_frame = ttk.LabelFrame(main_container, text="📋 测试记录", padding=10)
# ==================== 6. 测试结果表格 ====================
result_frame = ttk.LabelFrame(main_container, text="测试记录", padding=10)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Treeview
columns = ("窗口百分比", "亮度 (cd/m²)", "x", "y", "时间")
columns = ("测试项目", "图案", "亮度/结果", "x", "y", "时间")
self.ld_tree = ttk.Treeview(
result_frame, columns=columns, show="headings", height=10
)
for col in columns:
self.ld_tree.heading(col, text=col)
if col == "窗口百分比":
self.ld_tree.column(col, width=100, anchor=tk.CENTER)
if col == "测试项目":
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
elif col == "图案":
self.ld_tree.column(col, width=140, anchor=tk.CENTER)
elif col == "亮度/结果":
self.ld_tree.column(col, width=110, anchor=tk.CENTER)
elif col == "时间":
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
else:
@@ -135,7 +304,7 @@ def create_local_dimming_panel(self):
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.ld_tree.configure(yscrollcommand=scrollbar.set)
# ==================== 6. 底部操作按钮 ====================
# ==================== 7. 底部操作按钮 ====================
bottom_frame = ttk.Frame(main_container)
bottom_frame.pack(fill=tk.X)
@@ -150,13 +319,22 @@ def create_local_dimming_panel(self):
self.ld_save_btn = ttk.Button(
bottom_frame,
text="💾 保存结果",
text="保存结果",
command=self.save_local_dimming_results,
bootstyle="info",
width=12,
)
self.ld_save_btn.pack(side=tk.LEFT)
self.ld_plot_btn = ttk.Button(
bottom_frame,
text="生成峰值曲线",
command=self.plot_ld_instant_peak_curve,
bootstyle="warning-outline",
width=14,
)
self.ld_plot_btn.pack(side=tk.LEFT, padx=(5, 0))
# 默认隐藏
self.local_dimming_visible = False
@@ -170,14 +348,17 @@ def create_local_dimming_panel(self):
# 初始化当前窗口百分比(用于记录)
self.current_ld_percentage = None
self.current_ld_test_item = None
self.current_ld_pattern_label = None
self.ld_peak_tracking = False
def toggle_local_dimming_panel(self):
def toggle_local_dimming_panel(self: "PQAutomationApp"):
"""切换 Local Dimming 面板显示"""
self.show_panel("local_dimming")
def toggle_log_panel(self):
def toggle_log_panel(self: "PQAutomationApp"):
"""切换日志面板的显示状态"""
self.show_panel("log")
@@ -226,7 +407,7 @@ DEBUG_PANEL_CONFIGS = {
}
def _toggle_debug_panel(self, test_type):
def _toggle_debug_panel(self: "PQAutomationApp", test_type):
"""打开/关闭对应测试类型的单步调试面板(独立窗口)。"""
cfg = DEBUG_PANEL_CONFIGS[test_type]
win_attr = cfg["window_attr"]
@@ -288,25 +469,26 @@ def _toggle_debug_panel(self, test_type):
win.update_idletasks()
def toggle_screen_debug_panel(self):
def toggle_screen_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "screen_module")
def toggle_sdr_debug_panel(self):
def toggle_sdr_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "sdr_movie")
def toggle_hdr_debug_panel(self):
def toggle_hdr_debug_panel(self: "PQAutomationApp"):
_toggle_debug_panel(self, "hdr_movie")
def update_sidebar_selection(self):
def update_sidebar_selection(self: "PQAutomationApp"):
"""更新侧边栏按钮的选中状态"""
# 重置所有按钮样式为默认
self.screen_module_btn.configure(style="Sidebar.TButton")
self.sdr_movie_btn.configure(style="Sidebar.TButton")
self.hdr_movie_btn.configure(style="Sidebar.TButton")
self.local_dimming_btn.configure(style="Sidebar.TButton")
# 设置当前选中按钮的样式
current_type = self.test_type_var.get()
@@ -316,3 +498,20 @@ def update_sidebar_selection(self):
self.sdr_movie_btn.configure(style="SidebarSelected.TButton")
elif current_type == "hdr_movie":
self.hdr_movie_btn.configure(style="SidebarSelected.TButton")
elif current_type == "local_dimming":
self.local_dimming_btn.configure(style="SidebarSelected.TButton")
class SidePanelsMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_log_panel = create_log_panel
create_local_dimming_panel = create_local_dimming_panel
toggle_local_dimming_panel = toggle_local_dimming_panel
toggle_log_panel = toggle_log_panel
_toggle_debug_panel = _toggle_debug_panel
toggle_screen_debug_panel = toggle_screen_debug_panel
toggle_sdr_debug_panel = toggle_sdr_debug_panel
toggle_hdr_debug_panel = toggle_hdr_debug_panel
update_sidebar_selection = update_sidebar_selection

View File

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

View File

@@ -10,6 +10,18 @@ import threading
import time
def _theme_colors():
style = ttk.Style()
colors = style.colors
return {
"fg": colors.fg,
"muted": colors.secondary,
"info": colors.info,
"warning": colors.warning,
"error": colors.danger,
}
class PQDebugPanel:
"""PQ 单步调试面板 - 支持 Gamma/EOTF/色准单步测试"""
@@ -72,7 +84,7 @@ class PQDebugPanel:
self.screen_gamma_frame,
text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# 灰阶选择
@@ -137,7 +149,7 @@ class PQDebugPanel:
self.screen_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择
@@ -210,7 +222,7 @@ class PQDebugPanel:
self.sdr_gamma_frame,
text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.sdr_gamma_frame, text="选择灰阶:").grid(
@@ -272,7 +284,7 @@ class PQDebugPanel:
self.sdr_accuracy_frame,
text="测试完成后可用,选择色块进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.sdr_accuracy_frame, text="选择色块:").grid(
@@ -334,7 +346,7 @@ class PQDebugPanel:
self.sdr_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择
@@ -407,7 +419,7 @@ class PQDebugPanel:
self.hdr_eotf_frame,
text="测试完成后可用,选择灰阶进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.hdr_eotf_frame, text="选择灰阶:").grid(
@@ -469,7 +481,7 @@ class PQDebugPanel:
self.hdr_accuracy_frame,
text="测试完成后可用,选择色块进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
ttk.Label(self.hdr_accuracy_frame, text="选择色块:").grid(
@@ -531,7 +543,7 @@ class PQDebugPanel:
self.hdr_rgb_frame,
text="测试完成后可用,选择颜色进行单步调试",
font=("SimHei", 9),
foreground="gray",
foreground=_theme_colors()["muted"],
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
# RGB 颜色选择
@@ -600,13 +612,10 @@ class PQDebugPanel:
if test_type == "screen_module":
self.screen_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示屏模组调试面板", level="success")
elif test_type == "sdr_movie":
self.sdr_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示 SDR 调试面板", level="success")
elif test_type == "hdr_movie":
self.hdr_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示 HDR 调试面板", level="success")
# ==================== 启用/禁用控制 ====================
@@ -631,39 +640,31 @@ class PQDebugPanel:
if test_item == "gamma":
self.screen_gray_combo.config(state="readonly")
self.screen_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("屏模组 Gamma 单步调试已启用", level="success")
elif test_item == "rgb":
self.screen_rgb_combo.config(state="readonly")
self.screen_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("屏模组 RGB 单步调试已启用", level="success")
elif test_type == "sdr_movie":
if test_item == "gamma":
self.sdr_gray_combo.config(state="readonly")
self.sdr_gamma_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR Gamma 单步调试已启用", level="success")
elif test_item == "accuracy":
self.sdr_color_combo.config(state="readonly")
self.sdr_accuracy_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR 色准单步调试已启用", level="success")
elif test_item == "rgb":
self.sdr_rgb_combo.config(state="readonly")
self.sdr_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR RGB 单步调试已启用", level="success")
elif test_type == "hdr_movie":
if test_item == "eotf":
self.hdr_gray_combo.config(state="readonly")
self.hdr_eotf_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR EOTF 单步调试已启用", level="success")
elif test_item == "accuracy":
self.hdr_color_combo.config(state="readonly")
self.hdr_accuracy_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR 色准单步调试已启用", level="success")
elif test_item == "rgb":
self.hdr_rgb_combo.config(state="readonly")
self.hdr_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR RGB 单步调试已启用", level="success")
def disable_all_debug(self):
"""禁用所有单步调试(新测试开始时调用)"""
@@ -790,7 +791,7 @@ class PQDebugPanel:
time.sleep(1.5)
# 测量数据
x, y, lv, X, Y, Z = self.app.ca.readAllDisplay()
x, y, lv, X, Y, Z = self.app.read_ca_xyLv()
self.app.log_gui.log(
f"测量完成: x={x:.4f}, y={y:.4f}, lv={lv:.2f}, "
@@ -1007,12 +1008,13 @@ class PQDebugPanel:
)
# 设置标签样式
tree.tag_configure("header", background="#E3F2FD", font=("SimHei", 9, "bold"))
tree.tag_configure("normal", foreground="black")
tree.tag_configure("warning", foreground="red")
tree.tag_configure("highlight", foreground="blue", font=("SimHei", 9, "bold"))
palette = _theme_colors()
tree.tag_configure("header", background=palette["info"], font=("SimHei", 9, "bold"))
tree.tag_configure("normal", foreground=palette["fg"])
tree.tag_configure("warning", foreground=palette["warning"])
tree.tag_configure("highlight", foreground=palette["info"], font=("SimHei", 9, "bold"))
tree.tag_configure(
"highlight_warning", foreground="red", font=("SimHei", 9, "bold")
"highlight_warning", foreground=palette["warning"], font=("SimHei", 9, "bold")
)
def _disable_test_button(self, test_type, test_item):

View File

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

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

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

View File

@@ -3,10 +3,10 @@ APP_VERSION = "106.26.0.0"
def is_beta_version(version: str = APP_VERSION) -> bool:
"""版本号第3、4段均为 '0' 时(格式 x.x.0.0)判定为测试版。"""
"""版本号第3、4段均为 '0' 时(格式 x.x.0.x)判定为测试版。"""
parts = version.split(".")
if len(parts) >= 4:
return parts[2] == "0" and parts[3] == "0"
return parts[2] == "0"
return False

131
cache/pq_ai_api_v21_extracted.txt vendored Normal file
View File

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

17
cache/pq_ai_api_v21_summary.txt vendored Normal file
View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,585 +0,0 @@
# -*- coding: UTF-8 -*-
import UniTAP
import time
import gc
from drivers.UCD323_Enum import UCDEnum
class UCDController:
"""UCD323信号发生器控制类"""
def __init__(self):
self.lUniTAP = UniTAP.TsiLib()
self.dev = None
self.role = None
self.timing_manager = None
self.config = None
self.color_info = None
self.status = False
self.current_interface = "HDMI"
self.current_timing = None
self.current_pattern = None
self.current_pattern_param = None
self.current_pattern_params = None
self.current_pattern_index = 0
def search_device(self):
"""搜索可用设备"""
available_devices = self.lUniTAP.get_list_of_available_devices()
return available_devices if available_devices else []
def open(self, device_name):
"""打开设备"""
temp_dev = None
try:
if self.dev is not None or self.status:
self._force_cleanup()
device_id = int(device_name.split(":")[0])
temp_dev = self.lUniTAP.open(device_id)
try:
self.role = temp_dev.select_role(UniTAP.dev.UCD323.HDMISource)
self.dev = temp_dev
self.current_interface = "HDMI"
except Exception as role_error:
self._close_device_object(temp_dev)
raise role_error
pg, _ = self.get_tx_modules()
self.timing_manager = pg.timing_manager
self.color_info = UniTAP.ColorInfo()
self.status = True
return True
except Exception as e:
self._force_cleanup()
return False
def _reset_state(self):
"""重置所有运行时状态(不关闭设备句柄)"""
self.dev = None
self.role = None
self.status = False
self.timing_manager = None
self.current_timing = None
self.current_pattern = None
self.current_pattern_param = None
self.current_pattern_params = None
self.current_pattern_index = 0
self.current_interface = "HDMI"
def close(self):
"""关闭设备"""
try:
if self.dev:
self._close_device_object(self.dev)
self._reset_state()
self.lUniTAP = None
for i in range(3):
gc.collect()
time.sleep(2.0)
self.lUniTAP = UniTAP.TsiLib()
return True
except Exception as e:
self._reset_state()
try:
self.lUniTAP = None
gc.collect()
time.sleep(2.0)
self.lUniTAP = UniTAP.TsiLib()
except Exception as init_error:
pass
return False
def _close_device_object(self, dev_obj):
"""显式关闭设备对象"""
try:
if dev_obj is None:
return
if self.lUniTAP and hasattr(self.lUniTAP, "close"):
try:
self.lUniTAP.close(dev_obj)
except Exception as e:
pass
dev_obj = None
gc.collect()
time.sleep(1.0)
except Exception as e:
pass
def _force_cleanup(self):
"""强制清理所有状态"""
try:
if self.dev:
self._close_device_object(self.dev)
self._reset_state()
except Exception as e:
pass
def get_tx_modules(self):
"""根据当前接口返回 (pg, ag) 模块。"""
if not self.role:
raise RuntimeError("UCD 未打开,无法获取 TX 模块")
interface = getattr(self, "current_interface", None)
if interface in (None, "HDMI"):
return self.role.hdtx.pg, self.role.hdtx.ag
if interface in ("DP", "Type-C"):
return self.role.dptx.pg, self.role.dptx.ag
raise ValueError(f"不支持的接口类型: {interface}")
def _resolve_timing(self, pg=None):
"""优先从 current_timing 读取 timing必要时回退到 TX 模块。"""
if self.current_timing is not None:
return self.current_timing
if pg is not None:
get_vm = getattr(pg, "get_vm", None)
if callable(get_vm):
try:
vm = get_vm()
return getattr(vm, "timing", None)
except Exception:
pass
return None
def get_current_resolution(self, default=(3840, 2160)):
"""从当前 timing 获取 (width, height),失败时返回 default。"""
try:
pg, _ = self.get_tx_modules()
timing = self._resolve_timing(pg)
if timing and hasattr(timing, "h_active") and hasattr(timing, "v_active"):
return timing.h_active, timing.v_active
except Exception:
pass
return default
def _cleanup(self):
"""清理设备资源"""
try:
if self.dev:
self._close_device_object(self.dev)
self.dev = None
if hasattr(self.lUniTAP, "cleanup"):
self.lUniTAP.cleanup()
except Exception as e:
pass
def set_ucd_params(self, config):
"""设置UCD323参数"""
self.config = config
test_type = self.config.current_test_type
pg, _ = self.get_tx_modules()
self.timing_manager = pg.timing_manager
color_format = self.config.current_test_types[test_type]["color_format"]
bpc = self.config.current_test_types[test_type]["bpc"]
colorimetry = self.config.current_test_types[test_type]["colorimetry"]
if not self.set_color_mode(color_format, bpc, colorimetry):
return False
timing_str = self.config.current_test_types[test_type]["timing"]
self.set_timing_from_string(timing_str)
self.current_pattern_index = 0
pattern_mode = self.config.current_pattern["pattern_mode"]
pattern = UCDEnum.VideoPatternInfo.get_video_pattern(pattern_mode)
if pattern is None:
return False
self.current_pattern = pattern
self.current_pattern_params = self.config.current_pattern["pattern_params"]
return True
def run(self):
"""运行设备"""
self.apply_video_mode()
self.apply_pattern()
pg, _ = self.get_tx_modules()
pg.apply()
return True
def send_image_pattern(self, image_path):
"""发送图片 Pattern依赖当前 timing/color_info 状态)。"""
if not self.status or not self.role:
return False
try:
pg, _ = self.get_tx_modules()
self.apply_video_mode()
pg.set_pattern(pattern=image_path)
pg.apply()
return True
except Exception:
return False
def send_solid_rgb_pattern(self, rgb):
"""发送纯色 RGB Pattern依赖当前 timing/color_info 状态)。"""
if not self.status or not self.role:
return False
try:
self.current_pattern = UCDEnum.VideoPatternInfo.get_video_pattern("solidcolor")
if self.current_pattern is None:
return False
return self.send_current_pattern_params(list(rgb))
except Exception:
return False
def send_current_pattern_params(self, pattern_params):
"""发送当前已配置的 pattern并可附带当前 pattern 参数。"""
if not self.status or not self.role:
return False
try:
if self.current_pattern is None:
return False
if pattern_params is not None and not self.set_pattern(
self.current_pattern,
pattern_params,
):
return False
self.run()
return True
except Exception:
return False
def set_color_mode(self, cf, bpc, cm):
"""设置颜色模式"""
current_dynamic_range = self.color_info.dynamic_range
color_format = UCDEnum.ColorInfo.get_color_format(cf)
if color_format is None:
return False
if not isinstance(bpc, int) or bpc <= 0:
return False
colorimetry = UCDEnum.ColorInfo.get_colorimetry(cm)
if colorimetry is None:
return False
self.color_info.color_format = color_format
self.color_info.bpc = bpc
self.color_info.colorimetry = colorimetry
self.color_info.dynamic_range = current_dynamic_range
return True
def apply_video_mode(self):
"""应用当前 color_info 和 timing"""
if self.current_timing:
self.set_video_mode()
return True
return False
def set_video_mode(self):
"""设置视频模式"""
# 对比上次发出的配置,判断是否会触发电视重新锁定信号
current_config = (
self.current_timing,
self.color_info.color_format,
self.color_info.colorimetry,
self.color_info.dynamic_range,
self.color_info.bpc,
)
self.format_changed = (current_config != getattr(self, "_last_sent_config", None))
video_mode = UniTAP.VideoMode(
timing=self.current_timing, color_info=self.color_info
)
pg, _ = self.get_tx_modules()
pg.set_vm(vm=video_mode)
self._last_sent_config = current_config
return True
def set_pattern(self, pattern, pattern_params=None):
"""设置pattern"""
if self.current_timing:
needs_params = {
UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor,
UCDEnum.VideoPatternInfo.VideoPatternParams.WhiteVStrips,
UCDEnum.VideoPatternInfo.VideoPatternParams.GradientRGBStripes,
UCDEnum.VideoPatternInfo.VideoPatternParams.MotionPattern,
UCDEnum.VideoPatternInfo.VideoPatternParams.SquareWindow,
}
if pattern in needs_params and pattern_params:
self.set_pattern_params(pattern, pattern_params)
return True
return False
def set_next_pattern(self):
"""设置下一个pattern"""
if self.current_pattern_index < len(self.current_pattern_params):
p = self.current_pattern_params[self.current_pattern_index]
self.set_pattern(self.current_pattern, p)
self.current_pattern_index += 1
else:
error_msg = (
f"No more patterns to set. (已设置 {self.current_pattern_index} 个图案)"
)
raise IndexError(error_msg)
def set_pattern_params(self, pattern, pattern_params):
"""设置pattern参数"""
if pattern:
if pattern == UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor:
self.current_pattern_param = UniTAP.SolidColorParams(
first=pattern_params[0],
second=pattern_params[1],
third=pattern_params[2],
)
return True
return False
def apply_pattern(self):
"""应用当前pattern"""
if self.current_pattern:
pg, _ = self.get_tx_modules()
pg.set_pattern(self.current_pattern)
if self.current_pattern_param:
pg.set_pattern_params(self.current_pattern_param)
return True
return False
def search_timing(self, width, height, refresh_rate, resolution_type=None):
"""根据分辨率参数搜索合适的timing"""
if resolution_type:
resolution_type = resolution_type.lower()
standard = None
if resolution_type == "dmt":
standard = UniTAP.common.timing.Timing.Standard.SD_DMT
elif resolution_type == "cta":
standard = UniTAP.common.timing.Timing.Standard.SD_CTA
elif resolution_type == "cvt":
standard = UniTAP.common.timing.Timing.Standard.SD_CVT
timing = self.timing_manager.search(
h_active=width,
v_active=height,
f_rate=int(refresh_rate) * 1000,
standard=standard,
)
if timing:
return timing
else:
for res_type in ["dmt", "cta", "cvt", "ovt"]:
result = self.search_timing(width, height, refresh_rate, res_type)
if result:
return result
return None
def parse_formatted_timing(self, timing_str):
"""解析格式化的timing字符串"""
if not isinstance(timing_str, str):
raise ValueError("timing_str 必须是字符串")
s = " ".join(timing_str.strip().split())
s = s.replace(" x", "x").replace("x ", "x")
parts = s.split(" ", 1)
if len(parts) < 2:
raise ValueError(f"无法解析timing: {timing_str}")
type_str = parts[0].strip().upper()
rest = parts[1].strip()
if "@" not in rest:
raise ValueError(f"无法解析timing(缺少 @): {timing_str}")
left, right = [p.strip() for p in rest.split("@", 1)]
if "x" not in left:
raise ValueError(f"无法解析分辨率(缺少 x): {timing_str}")
wh = left.split("x")
if len(wh) != 2:
raise ValueError(f"无法解析分辨率: {timing_str}")
try:
width = int(wh[0])
height = int(wh[1])
except Exception:
raise ValueError(f"分辨率数字解析失败: {timing_str}")
hz_str = right.replace("Hz", "").replace("HZ", "").strip()
try:
refresh_rate = float(hz_str)
except Exception:
raise ValueError(f"刷新率解析失败: {timing_str}")
rtype_map = {
"DMT": "dmt",
"CTA": "cta",
"CVT": "cvt",
"OVT": "ovt",
}
if type_str not in rtype_map:
raise ValueError(f"未知的分辨率类型: {type_str}")
resolution_type = rtype_map[type_str]
def find_best_id_in_dict(res_map):
best_id, best_diff = None, float("inf")
for rid, info in res_map.items():
if info["width"] == width and info["height"] == height:
diff = abs(float(info["refresh_rate"]) - refresh_rate)
if diff < best_diff:
best_diff = diff
best_id = rid
return best_id if best_diff <= 1.0 else None
def find_best_id_in_list_map(res_map):
best_id, best_diff = None, float("inf")
for rid, infos in res_map.items():
for info in infos:
if info["width"] == width and info["height"] == height:
diff = abs(float(info["refresh_rate"]) - refresh_rate)
if diff < best_diff:
best_diff = diff
best_id = rid
return best_id if best_diff <= 1.0 else None
resolution_id = None
if resolution_type == "dmt":
resolution_id = find_best_id_in_dict(UCDEnum.TimingInfo.dmt_resolution_map)
elif resolution_type == "cta":
resolution_id = find_best_id_in_dict(UCDEnum.TimingInfo.cta_resolution_map)
elif resolution_type == "cvt":
resolution_id = find_best_id_in_list_map(
UCDEnum.TimingInfo.cvt_resolution_map
)
elif resolution_type == "ovt":
resolution_id = find_best_id_in_list_map(
UCDEnum.TimingInfo.ovt_resolution_map
)
result = {
"resolution_type": resolution_type,
"width": width,
"height": height,
"refresh_rate": refresh_rate,
"resolution_id": resolution_id,
}
return result
def set_timing_from_string(self, timing_str):
"""根据格式化timing字符串设置设备timing"""
spec = self.parse_formatted_timing(timing_str)
rtype = spec["resolution_type"]
width = spec["width"]
height = spec["height"]
fr = spec["refresh_rate"]
if rtype != "ovt":
timing = self.search_timing(width, height, fr, rtype)
if timing:
self.current_timing = timing
return True
return False
def set_timing_from_id(self, rtype, rid):
"""根据(type, id)设置设备timing"""
timing = None
if rtype.lower() == "dmt":
timing = self.timing_manager.get_dmt(rid)
elif rtype.lower() == "cta":
timing = self.timing_manager.get_cta(rid)
elif rtype.lower() == "cvt":
timing = self.timing_manager.get_cvt(rid)
else:
raise ValueError(f"不支持的分辨率类型: {rtype}")
if timing:
self.current_timing = timing
return True
else:
return False
def apply_signal_format(
self, color_space=None, data_range=None, bit_depth=None, color_format=None, **_
):
"""统一设置信号格式color_format / colorimetry / dynamic_range / bpc
Gamma/EOTF 传输特性在 ColorInfo API 中不存在;
max_cll / max_fall 暂无对应 SDK 接口,通过 **_ 接收后忽略。
"""
try:
if color_format:
fmt_key = UCDEnum.SignalFormat.OutputFormat.get_format_key(color_format)
cf = UCDEnum.ColorInfo.get_color_format(fmt_key)
if cf is not None:
self.color_info.color_format = cf
if color_space:
colorimetry = self._get_colorimetry_from_color_space(color_space, color_format)
if colorimetry:
self.color_info.colorimetry = colorimetry
if data_range:
if data_range == "Full":
self.color_info.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA
elif data_range == "Limited":
self.color_info.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_CTA
if bit_depth:
bpc = UCDEnum.SignalFormat.BitDepth.get_bit_value(bit_depth)
self.color_info.bpc = bpc
if self.current_timing:
self.set_video_mode()
return True
except Exception:
return False
# 向后兼容别名
set_sdr_format = apply_signal_format
set_hdr_format = apply_signal_format
def _get_colorimetry_from_color_space(self, color_space, color_format=None):
"""将色彩空间字符串转换为UniTAP.ColorInfo.Colorimetry。
BT.2020 在 YCbCr 输出时使用 CM_ITUR_BT2020_YCbCrRGB 输出时使用 CM_ITUR_BT2020_RGB。
"""
is_ycbcr = UCDEnum.SignalFormat.OutputFormat.is_ycbcr(color_format)
bt2020_cm = (
UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_YCbCr
if is_ycbcr
else UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_RGB
)
colorimetry_map = {
"sRGB": UniTAP.ColorInfo.Colorimetry.CM_sRGB,
"BT.709": UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT709,
"BT.601": UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT601,
"BT.2020": bt2020_cm,
"DCI-P3": UniTAP.ColorInfo.Colorimetry.CM_DCI_P3,
}
return colorimetry_map.get(color_space)

View File

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

View File

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

View File

@@ -108,9 +108,11 @@ a = Analysis(
'drivers.baseSerail',
'drivers.caSerail',
'drivers.tvSerail',
'drivers.UCD323_Enum',
'drivers.UCD323_Function',
'drivers.ucd_helpers',
'app.ucd',
'app.ucd.domain',
'app.ucd.enum',
'app.ucd.device',
'app.ucd.service',
],
hookspath=[],
hooksconfig={},

View File

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

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()