Compare commits
15 Commits
23a01be699
...
85ac47e8de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
85ac47e8de | ||
|
|
21455f3916 | ||
|
|
4498ec501e | ||
|
|
64764524aa | ||
|
|
c173e2338d | ||
|
|
cf724d60d7 | ||
|
|
f8f2d471e5 | ||
|
|
c63b9ef615 | ||
|
|
59c9424218 | ||
|
|
dff4e0df4d | ||
|
|
a903c17cb3 | ||
|
|
29f7d39fe9 | ||
|
|
1b66fff35b | ||
|
|
a855ba7157 | ||
|
|
3ce1574320 |
@@ -1,4 +1,4 @@
|
|||||||
"""配置文件 I/O(Step 4 重构)。
|
"""配置文件 I/O(Step 4 重构)。
|
||||||
|
|
||||||
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
|
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
|
||||||
以保留原有 `self.xxx` 属性访问不变。
|
以保留原有 `self.xxx` 属性访问不变。
|
||||||
@@ -8,7 +8,13 @@ import json
|
|||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
def get_config_path(self):
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def get_config_path(self: "PQAutomationApp"):
|
||||||
"""获取配置文件的完整路径(兼容打包后的程序)"""
|
"""获取配置文件的完整路径(兼容打包后的程序)"""
|
||||||
|
|
||||||
# 判断是否是打包后的程序
|
# 判断是否是打包后的程序
|
||||||
@@ -30,7 +36,7 @@ def get_config_path(self):
|
|||||||
return config_file
|
return config_file
|
||||||
|
|
||||||
|
|
||||||
def load_pq_config(self):
|
def load_pq_config(self: "PQAutomationApp"):
|
||||||
"""加载PQ配置(兼容打包后的程序)"""
|
"""加载PQ配置(兼容打包后的程序)"""
|
||||||
try:
|
try:
|
||||||
# 使用 self.config_file(已经是动态路径)
|
# 使用 self.config_file(已经是动态路径)
|
||||||
@@ -48,7 +54,7 @@ def load_pq_config(self):
|
|||||||
self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error")
|
self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error")
|
||||||
|
|
||||||
|
|
||||||
def save_pq_config(self):
|
def save_pq_config(self: "PQAutomationApp"):
|
||||||
"""保存PQ配置(兼容打包后的程序)"""
|
"""保存PQ配置(兼容打包后的程序)"""
|
||||||
try:
|
try:
|
||||||
# 确保目录存在
|
# 确保目录存在
|
||||||
@@ -61,7 +67,7 @@ def save_pq_config(self):
|
|||||||
self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error")
|
self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
def clear_config_file(self):
|
def clear_config_file(self: "PQAutomationApp"):
|
||||||
"""清理配置文件(兼容打包后的程序)"""
|
"""清理配置文件(兼容打包后的程序)"""
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
|
||||||
@@ -82,3 +88,13 @@ def clear_config_file(self):
|
|||||||
self.log_gui.log(f"配置文件清理失败: {str(e)}", level="error")
|
self.log_gui.log(f"配置文件清理失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigIOMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
get_config_path = get_config_path
|
||||||
|
load_pq_config = load_pq_config
|
||||||
|
save_pq_config = save_pq_config
|
||||||
|
clear_config_file = clear_config_file
|
||||||
|
|||||||
@@ -1,198 +1,300 @@
|
|||||||
"""设备连接(UCD323 / CA410)相关逻辑(Step 4 重构)。
|
"""设备连接管理(UCD323 / CA410)。
|
||||||
|
|
||||||
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
|
重构目标
|
||||||
以保留原有 `self.xxx` 属性访问不变。
|
---------
|
||||||
|
- 用 :class:`ConnectionController` 类封装连接生命周期,替代旧的
|
||||||
|
"把模块级函数当类方法装到 PQAutomationApp 上" 的写法。
|
||||||
|
- 拆掉 ``check_port_connection(is_ucd)`` 的布尔旗参数反模式,
|
||||||
|
分离为 ``connect_ucd`` / ``connect_ca`` 两条独立路径。
|
||||||
|
- UCD 这一侧不再直接调用旧 ``UCDController``,而是通过
|
||||||
|
:class:`UCD323Device` + :class:`EventBus`;指示器更新由订阅
|
||||||
|
:class:`ConnectionChanged` 事件触发,与 GUI 解耦。
|
||||||
|
|
||||||
|
模块底部仍保留 7 个旧名字函数作为薄兼容层(``check_com_connections``
|
||||||
|
等),供 ``pqAutomationApp.PQAutomationApp`` 类体继续以同名属性挂接,
|
||||||
|
保证调用点(按钮 command、_dispatch_ui 引用)零修改。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from tkinter import messagebox
|
from tkinter import messagebox
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.ucd_domain import ConnectionChanged, UcdError
|
||||||
from drivers.caSerail import CASerail
|
from drivers.caSerail import CASerail
|
||||||
|
from drivers.ucd_driver import DeviceInfo
|
||||||
|
|
||||||
def get_available_ucd_ports(self):
|
from typing import TYPE_CHECKING
|
||||||
"""获取可用的UCD端口列表"""
|
|
||||||
return self.ucd.search_device()
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
def get_available_com_ports(self):
|
if TYPE_CHECKING:
|
||||||
"""获取可用的COM端口列表"""
|
from app.ucd_domain import EventBus
|
||||||
try:
|
from drivers.ucd_driver import UCD323Device
|
||||||
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 []
|
|
||||||
|
|
||||||
|
|
||||||
def refresh_com_ports(self):
|
# ─── ConnectionController ────────────────────────────────────────
|
||||||
"""刷新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()
|
|
||||||
|
|
||||||
|
|
||||||
def check_com_connections(self):
|
class ConnectionController:
|
||||||
"""检测COM端口连接状态"""
|
"""统一管理 CA410 / UCD323 的连接、断开与可用端口枚举。
|
||||||
# 禁用连接按钮,防止重复点击
|
|
||||||
self.check_button.configure(state="disabled")
|
|
||||||
self.refresh_button.configure(state="disabled")
|
|
||||||
|
|
||||||
# 更新状态栏
|
设计要点:
|
||||||
self.status_var.set("正在检测连接...")
|
- 不持有 :mod:`tkinter` 控件;所有 GUI 更新由订阅事件总线后回调完成。
|
||||||
self.root.update()
|
- UCD 连接经由 :class:`UCD323Device`,自动发布
|
||||||
|
:class:`ConnectionChanged` 事件。
|
||||||
|
- CA 连接同样发布 :class:`ConnectionChanged` 事件(带 ``serial=None``,
|
||||||
|
指示器订阅时只看 ``connected`` 字段)。
|
||||||
|
"""
|
||||||
|
|
||||||
# 使用线程进行连接检测
|
def __init__(self, app):
|
||||||
def check_connections():
|
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:
|
try:
|
||||||
# 检测TV连接
|
return self._device.raw_controller.search_device() or []
|
||||||
ucd_connected = self.check_port_connection(is_ucd=True)
|
except Exception as exc: # noqa: BLE001
|
||||||
self._dispatch_ui(
|
self._log(f"枚举 UCD 设备失败: {exc}", level="error")
|
||||||
self.update_connection_indicator,
|
return []
|
||||||
self.ucd_status_indicator, ucd_connected,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 检测CA连接
|
def list_com_ports(self) -> list[str]:
|
||||||
ca_connected = self.check_port_connection(is_ucd=False)
|
try:
|
||||||
self._dispatch_ui(
|
import serial.tools.list_ports
|
||||||
self.update_connection_indicator,
|
|
||||||
self.ca_status_indicator, ca_connected,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 更新状态栏
|
return [p.device for p in serial.tools.list_ports.comports()]
|
||||||
self._dispatch_ui(self.status_var.set, "连接检测完成")
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log(f"获取COM端口列表出错: {exc}", level="error")
|
||||||
|
return []
|
||||||
|
|
||||||
# 重新启用所有控件
|
# -- UCD 连接 ------------------------------------------------
|
||||||
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)
|
|
||||||
|
|
||||||
# 启动线程
|
def connect_ucd(self, display: str) -> bool:
|
||||||
threading.Thread(target=check_connections, daemon=True).start()
|
"""打开指定 UCD 设备。成功返回 True。"""
|
||||||
|
if self._device.state.name != "CLOSED":
|
||||||
|
|
||||||
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:
|
|
||||||
try:
|
try:
|
||||||
self.ucd.close()
|
self._device.close()
|
||||||
except:
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
finally:
|
try:
|
||||||
self.ucd.status = False
|
self._device.open(DeviceInfo.parse(display))
|
||||||
self.log_gui.log("UCD连接已断开", level="info")
|
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连接
|
def disconnect_ucd(self) -> None:
|
||||||
if self.ca is not None:
|
try:
|
||||||
|
self._device.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
# 旧 controller.status 也要清零,兼容仍读取它的代码
|
||||||
|
try:
|
||||||
|
self._app.ucd.status = False
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._log("UCD连接已断开", level="info")
|
||||||
|
|
||||||
|
# -- CA 连接 -------------------------------------------------
|
||||||
|
|
||||||
|
def connect_ca(self) -> bool:
|
||||||
|
"""打开 CA410。成功返回 True 并设置 ``app.ca``。"""
|
||||||
|
if self._app.ca is not None:
|
||||||
try:
|
try:
|
||||||
self.ca.close()
|
self._app.ca.close()
|
||||||
except:
|
except Exception: # noqa: BLE001
|
||||||
pass
|
pass
|
||||||
finally:
|
self._app.ca = None
|
||||||
self.ca = None
|
|
||||||
self.log_gui.log("CA连接已断开", level="info")
|
|
||||||
|
|
||||||
# 重新启用相关控件
|
try:
|
||||||
self.enable_com_widgets()
|
ca = CASerail()
|
||||||
self.ucd_status_indicator.config(bg="gray")
|
ca.open(self._app.config.device_config["ca_com"], 19200, 7, "E", 2)
|
||||||
self.ca_status_indicator.config(bg="gray")
|
if not ca.set_all_Display():
|
||||||
self.status_var.set("串口连接已断开")
|
self._log(
|
||||||
|
f"端口 {self._app.config.device_config['ca_com']} 异常,色温仪连接失败",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
ca.close()
|
||||||
|
return False
|
||||||
|
ca.setSynchMode(3)
|
||||||
|
ca.setMeasureSpeed(1)
|
||||||
|
time.sleep(0.5)
|
||||||
|
ca.setZeroCalibration()
|
||||||
|
channel_value = self._app.ca_channel_var.get()
|
||||||
|
ca.setChannel(f"{int(channel_value):02d}")
|
||||||
|
self._app.ca = ca
|
||||||
|
self._bus.publish(ConnectionChanged(True, None))
|
||||||
|
return True
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log(f"CA410 连接失败: {exc}", level="error")
|
||||||
|
return False
|
||||||
|
|
||||||
except Exception as e:
|
def disconnect_ca(self) -> None:
|
||||||
self.log_gui.log(f"断开连接时发生错误: {str(e)}", level="info")
|
if self._app.ca is None:
|
||||||
messagebox.showerror("错误", f"断开连接失败: {str(e)}")
|
return
|
||||||
|
try:
|
||||||
|
self._app.ca.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
self._app.ca = None
|
||||||
|
self._bus.publish(ConnectionChanged(False, None))
|
||||||
|
self._log("CA连接已断开", level="info")
|
||||||
|
|
||||||
|
# -- 一次性入口 ----------------------------------------------
|
||||||
|
|
||||||
|
def check_all_async(self) -> None:
|
||||||
|
"""异步并联检测 UCD + CA,通过 ``_dispatch_ui`` 回主线程更新 UI。"""
|
||||||
|
app = self._app
|
||||||
|
app.check_button.configure(state="disabled")
|
||||||
|
app.refresh_button.configure(state="disabled")
|
||||||
|
app.status_var.set("正在检测连接...")
|
||||||
|
app.root.update()
|
||||||
|
|
||||||
|
def worker():
|
||||||
|
try:
|
||||||
|
ucd_ok = self.connect_ucd(app.ucd_list_var.get())
|
||||||
|
app._dispatch_ui(
|
||||||
|
app.update_connection_indicator,
|
||||||
|
app.ucd_status_indicator, ucd_ok,
|
||||||
|
)
|
||||||
|
ca_ok = self.connect_ca()
|
||||||
|
app._dispatch_ui(
|
||||||
|
app.update_connection_indicator,
|
||||||
|
app.ca_status_indicator, ca_ok,
|
||||||
|
)
|
||||||
|
app._dispatch_ui(app.status_var.set, "连接检测完成")
|
||||||
|
app._dispatch_ui(self._enable_widgets)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
app._dispatch_ui(app.log_gui.log, f"连接检测出错: {exc}")
|
||||||
|
app._dispatch_ui(self._enable_widgets)
|
||||||
|
|
||||||
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
def disconnect_all(self) -> None:
|
||||||
|
try:
|
||||||
|
self.disconnect_ucd()
|
||||||
|
self.disconnect_ca()
|
||||||
|
self._enable_widgets()
|
||||||
|
self._app.ucd_status_indicator.config(bg="gray")
|
||||||
|
self._app.ca_status_indicator.config(bg="gray")
|
||||||
|
self._app.status_var.set("串口连接已断开")
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
self._log(f"断开连接时发生错误: {exc}", level="info")
|
||||||
|
messagebox.showerror("错误", f"断开连接失败: {exc}")
|
||||||
|
|
||||||
|
def refresh_ports(self) -> None:
|
||||||
|
"""刷新 UCD + COM 端口下拉框;指示器复位。"""
|
||||||
|
app = self._app
|
||||||
|
com_ports = self.list_com_ports()
|
||||||
|
ucd_list = self.list_ucd_devices()
|
||||||
|
|
||||||
|
if app.ucd_list_var.get() not in ucd_list:
|
||||||
|
app.ucd_list_var.set(ucd_list[0] if ucd_list else "")
|
||||||
|
app.ucd_list_combo.config(values=ucd_list)
|
||||||
|
|
||||||
|
if app.ca_com_var.get() not in com_ports:
|
||||||
|
app.ca_com_var.set(
|
||||||
|
com_ports[1] if len(com_ports) > 1 else (com_ports[0] if com_ports else "")
|
||||||
|
)
|
||||||
|
app.ca_com_combo.config(values=com_ports)
|
||||||
|
|
||||||
|
if hasattr(app, "ucd_status_indicator"):
|
||||||
|
app.ucd_status_indicator.config(bg="gray")
|
||||||
|
if hasattr(app, "ca_status_indicator"):
|
||||||
|
app.ca_status_indicator.config(bg="gray")
|
||||||
|
|
||||||
|
app.update_config()
|
||||||
|
|
||||||
|
# -- 内部 ----------------------------------------------------
|
||||||
|
|
||||||
|
def _enable_widgets(self) -> None:
|
||||||
|
self._app.check_button.configure(state="normal")
|
||||||
|
self._app.refresh_button.configure(state="normal")
|
||||||
|
|
||||||
|
def _log(self, msg: str, *, level: str = "info") -> None:
|
||||||
|
log_gui = getattr(self._app, "log_gui", None)
|
||||||
|
if log_gui is not None:
|
||||||
|
log_gui.log(msg, level=level)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 旧名字兼容层 ────────────────────────────────────────────────
|
||||||
|
# pqAutomationApp 类体仍以同名属性挂接这些函数;它们都委托给
|
||||||
|
# ``self.connection``。Phase 3 完成后这些 shim 可以连同类体的属性
|
||||||
|
# 挂接一并删除,让 GUI 直接调用 ``self.connection.xxx``。
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_ucd_ports(self: "PQAutomationApp"):
|
||||||
|
return self.connection.list_ucd_devices()
|
||||||
|
|
||||||
|
|
||||||
|
def get_available_com_ports(self: "PQAutomationApp"):
|
||||||
|
return self.connection.list_com_ports()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_com_ports(self: "PQAutomationApp"):
|
||||||
|
self.connection.refresh_ports()
|
||||||
|
|
||||||
|
|
||||||
|
def check_com_connections(self: "PQAutomationApp"):
|
||||||
|
self.connection.check_all_async()
|
||||||
|
|
||||||
|
|
||||||
|
def update_connection_indicator(self: "PQAutomationApp", indicator, connected):
|
||||||
|
indicator.config(bg="green" if connected else "red")
|
||||||
|
|
||||||
|
|
||||||
|
def check_port_connection(self: "PQAutomationApp", is_ucd=True):
|
||||||
|
"""[已弃用] 旗参数反模式;保留仅为兼容旧调用点。"""
|
||||||
|
if is_ucd:
|
||||||
|
return self.connection.connect_ucd(self.ucd_list_var.get())
|
||||||
|
return self.connection.connect_ca()
|
||||||
|
|
||||||
|
|
||||||
|
def enable_com_widgets(self: "PQAutomationApp"):
|
||||||
|
self.connection._enable_widgets()
|
||||||
|
|
||||||
|
|
||||||
|
def disconnect_com_connections(self: "PQAutomationApp"):
|
||||||
|
self.connection.disconnect_all()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ConnectionController",
|
||||||
|
# 兼容层
|
||||||
|
"get_available_ucd_ports",
|
||||||
|
"get_available_com_ports",
|
||||||
|
"refresh_com_ports",
|
||||||
|
"check_com_connections",
|
||||||
|
"update_connection_indicator",
|
||||||
|
"check_port_connection",
|
||||||
|
"enable_com_widgets",
|
||||||
|
"disconnect_com_connections",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceConnectionMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
get_available_ucd_ports = get_available_ucd_ports
|
||||||
|
get_available_com_ports = get_available_com_ports
|
||||||
|
refresh_com_ports = refresh_com_ports
|
||||||
|
check_com_connections = check_com_connections
|
||||||
|
update_connection_indicator = update_connection_indicator
|
||||||
|
check_port_connection = check_port_connection
|
||||||
|
enable_com_widgets = enable_com_widgets
|
||||||
|
disconnect_com_connections = disconnect_com_connections
|
||||||
|
|||||||
@@ -2,6 +2,21 @@
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
_EXPORT_BG_COLOR = "#FFFFFF"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_with_light_background(fig, path, *, dpi=300, bbox_inches=None):
|
||||||
|
"""导出统一浅色背景,避免深色主题下图片背景变暗。"""
|
||||||
|
kwargs = {
|
||||||
|
"dpi": dpi,
|
||||||
|
"facecolor": _EXPORT_BG_COLOR,
|
||||||
|
"edgecolor": _EXPORT_BG_COLOR,
|
||||||
|
}
|
||||||
|
if bbox_inches is not None:
|
||||||
|
kwargs["bbox_inches"] = bbox_inches
|
||||||
|
fig.savefig(path, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _gamut_refs_for_type(test_type):
|
def _gamut_refs_for_type(test_type):
|
||||||
"""按测试类型返回需要导出的参考色域列表。"""
|
"""按测试类型返回需要导出的参考色域列表。"""
|
||||||
if test_type == "sdr_movie":
|
if test_type == "sdr_movie":
|
||||||
@@ -70,7 +85,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
|
|||||||
continue
|
continue
|
||||||
per_ref_name = f"色域测试结果_{ref}.png"
|
per_ref_name = f"色域测试结果_{ref}.png"
|
||||||
path = os.path.join(result_dir, per_ref_name)
|
path = os.path.join(result_dir, per_ref_name)
|
||||||
fig.savefig(path, dpi=300)
|
_save_with_light_background(fig, path, dpi=300)
|
||||||
log(f"已保存: {per_ref_name}")
|
log(f"已保存: {per_ref_name}")
|
||||||
finally:
|
finally:
|
||||||
ref_var.set(original_ref)
|
ref_var.set(original_ref)
|
||||||
@@ -82,7 +97,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
|
|||||||
continue
|
continue
|
||||||
path = os.path.join(result_dir, filename)
|
path = os.path.join(result_dir, filename)
|
||||||
if default_bbox:
|
if default_bbox:
|
||||||
fig.savefig(path, dpi=300)
|
_save_with_light_background(fig, path, dpi=300)
|
||||||
else:
|
else:
|
||||||
fig.savefig(path, dpi=300, bbox_inches="tight")
|
_save_with_light_background(fig, path, dpi=300, bbox_inches="tight")
|
||||||
log(f"已保存: {filename}")
|
log(f"已保存: {filename}")
|
||||||
|
|||||||
@@ -1,321 +1,364 @@
|
|||||||
"""色准测试结果绘制。
|
"""色准测试结果绘制。
|
||||||
|
|
||||||
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_accuracy 原样搬迁。
|
布局:
|
||||||
|
- 左侧:大尺寸 ColorChecker 条形图(每个条形使用对应颜色)。
|
||||||
|
- 右侧:CIE 1976 u'v' 色度图(目标点/实测点/偏移连线)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from matplotlib.patches import Rectangle
|
from matplotlib.patches import Rectangle
|
||||||
|
from matplotlib.lines import Line2D
|
||||||
|
|
||||||
|
from app.plots.gamut_background import get_cie1976_background
|
||||||
|
from app.tests.color_accuracy import get_accuracy_color_standards
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
def plot_accuracy(self, accuracy_data, test_type):
|
# ============================================================
|
||||||
"""绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma)"""
|
# 常量
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
self.accuracy_ax.clear()
|
_COLOR_MAP = {
|
||||||
self.accuracy_ax.set_xlim(0, 1)
|
"White": "#FFFFFF",
|
||||||
self.accuracy_ax.set_ylim(0, 1)
|
"Gray 80": "#E6E6E6",
|
||||||
self.accuracy_ax.axis("off")
|
"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,
|
def _grade_color(delta_e: float) -> str:
|
||||||
right=0.95,
|
if delta_e < 3:
|
||||||
top=0.95,
|
return "#1FAE45" # 绿
|
||||||
bottom=0.02,
|
if delta_e < 5:
|
||||||
|
return "#E08A00" # 橙
|
||||||
|
return "#D81B1B" # 红
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
edge_colors = [_grade_color(dE) for dE in delta_e_values]
|
||||||
|
|
||||||
|
ax.barh(
|
||||||
|
y_pos,
|
||||||
|
delta_e_values,
|
||||||
|
height=0.72,
|
||||||
|
color=bar_colors,
|
||||||
|
edgecolor=edge_colors,
|
||||||
|
linewidth=1.0,
|
||||||
|
zorder=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 获取色准数据
|
text_color = "#F3F5F7" if dark_mode else "#111111"
|
||||||
color_patches = accuracy_data.get("color_patches", [])
|
bg_color = "#0F1115" if dark_mode else "#FFFFFF"
|
||||||
delta_e_values = accuracy_data.get("delta_e_values", [])
|
spine_color = "#8C8F94" if dark_mode else "#9A9A9A"
|
||||||
avg_delta_e = accuracy_data.get("avg_delta_e", 0)
|
|
||||||
max_delta_e = accuracy_data.get("max_delta_e", 0)
|
|
||||||
min_delta_e = accuracy_data.get("min_delta_e", 0)
|
|
||||||
excellent_count = accuracy_data.get("excellent_count", 0)
|
|
||||||
good_count = accuracy_data.get("good_count", 0)
|
|
||||||
poor_count = accuracy_data.get("poor_count", 0)
|
|
||||||
|
|
||||||
# 获取 Gamma 值
|
ax.set_yticks(y_pos)
|
||||||
target_gamma = accuracy_data.get("target_gamma", 2.2)
|
ax.set_yticklabels(color_patches, fontsize=max(5, 7 * font_scale), color=text_color)
|
||||||
|
ax.invert_yaxis()
|
||||||
|
|
||||||
|
x_max = max(15.0, max(delta_e_values) * 1.15)
|
||||||
|
ax.set_xlim(0, x_max)
|
||||||
|
ax.tick_params(axis="x", labelsize=max(6, 8 * font_scale), colors=text_color)
|
||||||
|
ax.grid(axis="x", linestyle="-", linewidth=0.6, alpha=0.3, zorder=0)
|
||||||
|
ax.grid(axis="y", linestyle=":", linewidth=0.35, alpha=0.15, zorder=0)
|
||||||
|
|
||||||
|
ax.set_facecolor(bg_color)
|
||||||
|
for spine in ax.spines.values():
|
||||||
|
spine.set_color(spine_color)
|
||||||
|
spine.set_linewidth(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 子图:CIE 1976 u'v' 色度图(目标 vs 实测)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _draw_uv_diagram(ax, color_patches, measurements, standards, font_scale=1.0, dark_mode=False):
|
||||||
|
"""绘制 CIE 1976 u'v' 上的色准对比。"""
|
||||||
|
ax.clear()
|
||||||
|
try:
|
||||||
|
bg, bbox = get_cie1976_background()
|
||||||
|
xmin, xmax, ymin, ymax = bbox
|
||||||
|
ax.imshow(
|
||||||
|
bg, extent=(xmin, xmax, ymin, ymax),
|
||||||
|
origin="upper", interpolation="bicubic",
|
||||||
|
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 "#333333"
|
||||||
|
|
||||||
|
ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
|
||||||
|
ax.set_aspect("equal", adjustable="box")
|
||||||
|
ax.set_title("CIE 1976 u'v'", fontsize=max(8, 11 * font_scale), fontweight="bold",
|
||||||
|
color=text_color, pad=4)
|
||||||
|
ax.set_xlabel("u'", fontsize=max(7, 9 * font_scale), color=sub_text_color, labelpad=1)
|
||||||
|
ax.set_ylabel("v'", fontsize=max(7, 9 * font_scale), color=sub_text_color, labelpad=1)
|
||||||
|
ax.tick_params(axis="both", labelsize=max(6, 8 * font_scale), colors=tick_color)
|
||||||
|
for sp in ax.spines.values():
|
||||||
|
sp.set_color(outer_edge)
|
||||||
|
sp.set_linewidth(0.9)
|
||||||
|
|
||||||
|
for name, meas in zip(color_patches, measurements):
|
||||||
|
if meas is None or len(meas) < 2:
|
||||||
|
continue
|
||||||
|
mx, my = meas[0], meas[1]
|
||||||
|
sxy = standards.get(name)
|
||||||
|
if sxy is None:
|
||||||
|
continue
|
||||||
|
sx, sy = sxy
|
||||||
|
|
||||||
|
m_u, m_v = _xy_to_uv(mx, my)
|
||||||
|
s_u, s_v = _xy_to_uv(sx, sy)
|
||||||
|
|
||||||
|
face = _COLOR_MAP.get(name, "#FFFFFF")
|
||||||
|
|
||||||
|
# 目标点:仅空心方框(不填充标准颜色)
|
||||||
|
ax.scatter(
|
||||||
|
[s_u], [s_v],
|
||||||
|
s=56, marker="s",
|
||||||
|
facecolors="none", edgecolors=outer_edge,
|
||||||
|
linewidths=1.25, zorder=18,
|
||||||
|
)
|
||||||
|
# 实测点:白色外圈 + 内层圆点
|
||||||
|
ax.scatter(
|
||||||
|
[m_u], [m_v],
|
||||||
|
s=52, marker="o",
|
||||||
|
facecolors="none", edgecolors=outer_edge,
|
||||||
|
linewidths=1.0, zorder=19,
|
||||||
|
)
|
||||||
|
ax.scatter(
|
||||||
|
[m_u], [m_v],
|
||||||
|
s=24, marker="o",
|
||||||
|
facecolors=face, edgecolors="#111111",
|
||||||
|
linewidths=0.85, zorder=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
legend_handles = [
|
||||||
|
Line2D([0], [0], marker="s", linestyle="none",
|
||||||
|
markerfacecolor="#CCCCCC", markeredgecolor=outer_edge,
|
||||||
|
markersize=7, label="目标 (Target)"),
|
||||||
|
Line2D([0], [0], marker="o", linestyle="none",
|
||||||
|
markerfacecolor="#CCCCCC", markeredgecolor="#000000",
|
||||||
|
markersize=7, label="实测 (Actual)"),
|
||||||
|
]
|
||||||
|
leg = ax.legend(
|
||||||
|
handles=legend_handles,
|
||||||
|
loc="lower right", fontsize=max(6, 8 * font_scale),
|
||||||
|
framealpha=0.88, labelcolor=legend_label_color,
|
||||||
|
)
|
||||||
|
if leg is not None:
|
||||||
|
leg.get_frame().set_facecolor(legend_bg)
|
||||||
|
leg.get_frame().set_edgecolor(legend_edge)
|
||||||
|
leg.set_zorder(50)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_result_judgement(ax, accuracy_data, font_scale=1.0, dark_mode=False):
|
||||||
|
"""底部结果条"""
|
||||||
|
ax.clear()
|
||||||
|
ax.set_xlim(0, 1)
|
||||||
|
ax.set_ylim(0, 1)
|
||||||
|
ax.axis("off")
|
||||||
|
|
||||||
|
avg = accuracy_data.get("avg_delta_e", 0.0)
|
||||||
|
mx = accuracy_data.get("max_delta_e", 0.0)
|
||||||
|
|
||||||
|
panel_bg = "#1A1E24" if dark_mode else "#FFFFFF"
|
||||||
|
panel_edge = "#4A5058" if dark_mode else "#C6C6C6"
|
||||||
|
text_color = "#F3F5F7" if dark_mode else "#111111"
|
||||||
|
|
||||||
|
ax.add_patch(Rectangle(
|
||||||
|
(0.0, 0.10), 1.0, 0.80,
|
||||||
|
transform=ax.transAxes,
|
||||||
|
facecolor=panel_bg, edgecolor=panel_edge, linewidth=1.0,
|
||||||
|
))
|
||||||
|
|
||||||
|
ax.text(
|
||||||
|
0.03, 0.50,
|
||||||
|
f"Avg dE2000: {avg:.2f}",
|
||||||
|
ha="left", va="center",
|
||||||
|
fontsize=max(11, 20 * font_scale), fontweight="normal", color=text_color,
|
||||||
|
transform=ax.transAxes,
|
||||||
|
)
|
||||||
|
ax.text(
|
||||||
|
0.52, 0.50,
|
||||||
|
f"Max dE2000: {mx:.2f}",
|
||||||
|
ha="left", va="center",
|
||||||
|
fontsize=max(11, 20 * font_scale), fontweight="normal", color=text_color,
|
||||||
|
transform=ax.transAxes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# 主入口
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
|
||||||
|
"""绘制色准测试结果 - Calman 风格(色块 + CIE 1976 u'v' + 统计)。"""
|
||||||
|
|
||||||
|
fig = self.accuracy_fig
|
||||||
|
fig.clear()
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.views.theme_manager import is_dark
|
||||||
|
dark_mode = is_dark()
|
||||||
|
except Exception:
|
||||||
|
dark_mode = False
|
||||||
|
|
||||||
|
fig.patch.set_facecolor("#1B1F24" if dark_mode else "#FFFFFF")
|
||||||
|
|
||||||
|
# 根据当前画布像素尺寸动态缩放字体,避免窗口缩小时文字挤压重叠。
|
||||||
|
font_scale = 1.0
|
||||||
|
try:
|
||||||
|
canvas_widget = self.accuracy_canvas.get_tk_widget()
|
||||||
|
cw = max(1, int(canvas_widget.winfo_width()))
|
||||||
|
ch = max(1, int(canvas_widget.winfo_height()))
|
||||||
|
font_scale = min(cw / 1000.0, ch / 600.0)
|
||||||
|
font_scale = max(0.60, min(1.0, font_scale))
|
||||||
|
except Exception:
|
||||||
|
font_scale = 1.0
|
||||||
|
|
||||||
|
color_patches = accuracy_data.get("color_patches", []) or []
|
||||||
|
delta_e_values = accuracy_data.get("delta_e_values", []) or []
|
||||||
|
measurements = accuracy_data.get("color_measurements", []) or []
|
||||||
|
|
||||||
|
try:
|
||||||
|
target_gamma = float(accuracy_data.get("target_gamma", 2.2))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
target_gamma = 2.2
|
||||||
|
|
||||||
test_type_name = self.get_test_type_name(test_type)
|
test_type_name = self.get_test_type_name(test_type)
|
||||||
|
|
||||||
# ========== 标题(动态显示 Gamma)==========
|
|
||||||
if test_type == "sdr_movie":
|
if test_type == "sdr_movie":
|
||||||
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})"
|
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})"
|
||||||
elif test_type == "hdr_movie":
|
elif test_type == "hdr_movie":
|
||||||
title = f"{test_type_name} - 色准测试(全 29色 | PQ EOTF)"
|
title = f"{test_type_name} - 色准测试(全 29色 | PQ EOTF)"
|
||||||
else: # screen_module
|
else:
|
||||||
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})"
|
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})"
|
||||||
|
|
||||||
self.accuracy_fig.suptitle(
|
title_color = "#F3F5F7" if dark_mode else "#111"
|
||||||
|
fig.suptitle(
|
||||||
title,
|
title,
|
||||||
fontsize=11,
|
fontsize=max(8, 11 * font_scale),
|
||||||
y=0.98,
|
y=0.975,
|
||||||
fontweight="bold",
|
fontweight="bold",
|
||||||
color="#111111",
|
color=title_color,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== 29色:6行5列布局 ==========
|
gs = fig.add_gridspec(
|
||||||
cols = 5
|
2, 2,
|
||||||
rows = 6
|
width_ratios=[1.12, 1.0],
|
||||||
|
height_ratios=[4.8, 0.48],
|
||||||
patch_width = 0.135
|
left=0.08, right=0.985,
|
||||||
patch_height = 0.085
|
top=0.92, bottom=0.05,
|
||||||
x_start = 0.08
|
wspace=0.14, hspace=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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# ========== 统计内容(无内部框)==========
|
ax_left = fig.add_subplot(gs[0, 0])
|
||||||
stats_y = card_y + card_height * 0.55
|
ax_uv = fig.add_subplot(gs[0, 1])
|
||||||
|
ax_judge = fig.add_subplot(gs[1, :])
|
||||||
|
|
||||||
# 左侧:ΔE 统计
|
# 兼容外部对 self.accuracy_ax 的引用
|
||||||
left_x = card_x + 0.02
|
self.accuracy_ax = ax_judge
|
||||||
stats_text = [
|
|
||||||
f"平均 ΔE: {avg_delta_e:.2f}",
|
|
||||||
f"最大 ΔE: {max_delta_e:.2f}",
|
|
||||||
f"最小 ΔE: {min_delta_e:.2f}",
|
|
||||||
]
|
|
||||||
|
|
||||||
for i, text in enumerate(stats_text):
|
_draw_left_panel(
|
||||||
self.accuracy_ax.text(
|
ax_left,
|
||||||
left_x,
|
color_patches,
|
||||||
stats_y - i * 0.030,
|
delta_e_values,
|
||||||
text,
|
font_scale=font_scale,
|
||||||
ha="left",
|
dark_mode=dark_mode,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
self.accuracy_ax.text(
|
try:
|
||||||
middle_x,
|
standards = get_accuracy_color_standards(test_type)
|
||||||
stats_y - 0.030,
|
except Exception:
|
||||||
f"良好 (3≤ΔE<5): {good_count} 个",
|
standards = {}
|
||||||
ha="left",
|
|
||||||
va="center",
|
_draw_uv_diagram(
|
||||||
fontsize=7,
|
ax_uv,
|
||||||
color="orange",
|
color_patches,
|
||||||
fontweight="bold",
|
measurements,
|
||||||
transform=self.accuracy_ax.transAxes,
|
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(
|
try:
|
||||||
middle_x,
|
self.update_accuracy_result_table(accuracy_data, standards)
|
||||||
stats_y - 0.060,
|
except Exception:
|
||||||
f"偏差 (ΔE≥5): {poor_count} 个",
|
pass
|
||||||
ha="left",
|
|
||||||
va="center",
|
|
||||||
fontsize=7,
|
|
||||||
color="red",
|
|
||||||
fontweight="bold",
|
|
||||||
transform=self.accuracy_ax.transAxes,
|
|
||||||
)
|
|
||||||
|
|
||||||
# 右侧:总体评价
|
|
||||||
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()
|
self.accuracy_canvas.draw()
|
||||||
self.chart_notebook.select(self.accuracy_chart_frame)
|
self.chart_notebook.select(self.accuracy_chart_frame)
|
||||||
|
|
||||||
|
|
||||||
|
class PlotAccuracyMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
plot_accuracy = plot_accuracy
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""CCT / 色度一致性绘制。
|
"""CCT / 色度一致性绘制。
|
||||||
|
|
||||||
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。
|
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
def plot_cct(self, test_type):
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def plot_cct(self: "PQAutomationApp", test_type):
|
||||||
"""绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值"""
|
"""绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值"""
|
||||||
|
|
||||||
self.cct_fig.clear()
|
self.cct_fig.clear()
|
||||||
@@ -322,3 +328,10 @@ def plot_cct(self, test_type):
|
|||||||
self.chart_notebook.select(self.cct_chart_frame)
|
self.chart_notebook.select(self.cct_chart_frame)
|
||||||
|
|
||||||
self.log_gui.log("xy 色度坐标图绘制完成", level="success")
|
self.log_gui.log("xy 色度坐标图绘制完成", level="success")
|
||||||
|
|
||||||
|
|
||||||
|
class PlotCctMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
plot_cct = plot_cct
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_contrast 原样搬迁
|
|||||||
|
|
||||||
from matplotlib.patches import Rectangle
|
from matplotlib.patches import Rectangle
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
def plot_contrast(self, contrast_data, test_type):
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
|
||||||
"""绘制对比度测试结果 - 固定布局版本"""
|
"""绘制对比度测试结果 - 固定布局版本"""
|
||||||
|
|
||||||
# 清空并重置
|
# 清空并重置
|
||||||
@@ -165,3 +171,10 @@ def plot_contrast(self, contrast_data, test_type):
|
|||||||
|
|
||||||
self.contrast_canvas.draw()
|
self.contrast_canvas.draw()
|
||||||
self.chart_notebook.select(self.contrast_chart_frame)
|
self.chart_notebook.select(self.contrast_chart_frame)
|
||||||
|
|
||||||
|
|
||||||
|
class PlotContrastMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
plot_contrast = plot_contrast
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""EOTF 曲线绘制(HDR)。
|
"""EOTF 曲线绘制(HDR)。
|
||||||
|
|
||||||
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。
|
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type):
|
||||||
"""绘制 EOTF 曲线 + 数据表格(HDR 专用,包含实测亮度)"""
|
"""绘制 EOTF 曲线 + 数据表格(HDR 专用,包含实测亮度)"""
|
||||||
|
|
||||||
# ========== 1. 清空并重置左侧曲线 ==========
|
# ========== 1. 清空并重置左侧曲线 ==========
|
||||||
@@ -146,3 +152,10 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success")
|
self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success")
|
||||||
|
|
||||||
|
|
||||||
|
class PlotEotfMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
plot_eotf = plot_eotf
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
"""Gamma 曲线绘制。
|
"""Gamma 曲线绘制。
|
||||||
|
|
||||||
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。
|
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def plot_gamma(self: "PQAutomationApp", L_bar, results_with_gamma_list, target_gamma, test_type):
|
||||||
"""绘制Gamma曲线 + 数据表格(包含实测亮度)"""
|
"""绘制Gamma曲线 + 数据表格(包含实测亮度)"""
|
||||||
|
|
||||||
# ========== 1. 清空并重置左侧曲线 ==========
|
# ========== 1. 清空并重置左侧曲线 ==========
|
||||||
@@ -140,3 +146,10 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
|
|||||||
self.chart_notebook.select(self.gamma_chart_frame)
|
self.chart_notebook.select(self.gamma_chart_frame)
|
||||||
|
|
||||||
self.log_gui.log("Gamma曲线 + 数据表格绘制完成", level="success")
|
self.log_gui.log("Gamma曲线 + 数据表格绘制完成", level="success")
|
||||||
|
|
||||||
|
|
||||||
|
class PlotGammaMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
plot_gamma = plot_gamma
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ from app.plots.gamut_background import (
|
|||||||
get_cie1976_background,
|
get_cie1976_background,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
# ============ 参考色域定义(CIE 1931 xy)============
|
# ============ 参考色域定义(CIE 1931 xy)============
|
||||||
_REF_GAMUTS_XY = {
|
_REF_GAMUTS_XY = {
|
||||||
@@ -143,35 +148,41 @@ def _draw_measured_triangle(ax, vertices, *, uv_space=False):
|
|||||||
# )
|
# )
|
||||||
|
|
||||||
|
|
||||||
def _draw_coverage_box(ax, x_pos, y_pos, current_ref, coverage):
|
def _draw_coverage_box(ax, x_pos, y_pos, current_ref, coverage, *, dark_mode):
|
||||||
|
text_color = "#FFF" if dark_mode else "#111"
|
||||||
|
box_face = "#111" if dark_mode else "#FFFFFF"
|
||||||
|
box_edge = "#FFF" if dark_mode else "#333"
|
||||||
ax.text(
|
ax.text(
|
||||||
x_pos, y_pos,
|
x_pos, y_pos,
|
||||||
f"{current_ref}\n覆盖率: {coverage:.1f}%",
|
f"{current_ref}\n覆盖率: {coverage:.1f}%",
|
||||||
ha="right", va="bottom",
|
ha="right", va="bottom",
|
||||||
fontsize=11, fontweight="bold",
|
fontsize=11, fontweight="bold",
|
||||||
color="#FFF",
|
color=text_color,
|
||||||
bbox=dict(
|
bbox=dict(
|
||||||
boxstyle="round,pad=0.38",
|
boxstyle="round,pad=0.38",
|
||||||
facecolor="#111",
|
facecolor=box_face,
|
||||||
edgecolor="#FFF",
|
edgecolor=box_edge,
|
||||||
linewidth=1.7,
|
linewidth=1.7,
|
||||||
alpha=0.98,
|
alpha=0.98,
|
||||||
),
|
),
|
||||||
zorder=30,
|
zorder=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim):
|
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim, dark_mode):
|
||||||
ax.set_facecolor("#000")
|
text = "#F4F6F8" if dark_mode else "#111"
|
||||||
ax.set_title(title, fontsize=12, fontweight="bold", color="#FFF", pad=8)
|
grid = "#444" if dark_mode else "#B8BDC3"
|
||||||
ax.set_xlabel(xlabel, fontsize=10, color="#FFF")
|
spine_color = "#888" if dark_mode else "#666"
|
||||||
ax.set_ylabel(ylabel, fontsize=10, color="#FFF")
|
ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
|
||||||
|
ax.set_title(title, fontsize=12, fontweight="bold", color=text, pad=8)
|
||||||
|
ax.set_xlabel(xlabel, fontsize=10, color=text)
|
||||||
|
ax.set_ylabel(ylabel, fontsize=10, color=text)
|
||||||
ax.set_xlim(*xlim)
|
ax.set_xlim(*xlim)
|
||||||
ax.set_ylim(*ylim)
|
ax.set_ylim(*ylim)
|
||||||
ax.set_aspect("equal", adjustable="datalim")
|
ax.set_aspect("equal", adjustable="datalim")
|
||||||
ax.grid(True, linestyle=":", linewidth=0.7, color="#444", alpha=0.32)
|
ax.grid(True, linestyle=":", linewidth=0.7, color=grid, alpha=0.32)
|
||||||
ax.tick_params(axis="both", labelsize=9, colors="#FFF")
|
ax.tick_params(axis="both", labelsize=9, colors=text)
|
||||||
for spine in ax.spines.values():
|
for spine in ax.spines.values():
|
||||||
spine.set_color("#888")
|
spine.set_color(spine_color)
|
||||||
spine.set_linewidth(0.8)
|
spine.set_linewidth(0.8)
|
||||||
ax.set_clip_on(False)
|
ax.set_clip_on(False)
|
||||||
|
|
||||||
@@ -193,16 +204,22 @@ def _blit_background(ax, background, bbox):
|
|||||||
# 主入口
|
# 主入口
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def plot_gamut(self, results, coverage, test_type):
|
def plot_gamut(self: "PQAutomationApp", results, coverage, test_type):
|
||||||
"""绘制色域图(图像层 + 框架层分离架构)。"""
|
"""绘制色域图(图像层 + 框架层分离架构)。"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
from app.views.theme_manager import is_dark
|
||||||
|
dark_mode = is_dark()
|
||||||
|
except Exception:
|
||||||
|
dark_mode = False
|
||||||
|
|
||||||
ax_xy = self.gamut_ax_xy
|
ax_xy = self.gamut_ax_xy
|
||||||
ax_uv = self.gamut_ax_uv
|
ax_uv = self.gamut_ax_uv
|
||||||
|
|
||||||
ax_xy.clear()
|
ax_xy.clear()
|
||||||
ax_uv.clear()
|
ax_uv.clear()
|
||||||
# 全局黑色背景
|
# 全局背景跟随浅/深色主题
|
||||||
self.gamut_fig.patch.set_facecolor("#000")
|
self.gamut_fig.patch.set_facecolor("#0D1014" if dark_mode else "#FFFFFF")
|
||||||
|
|
||||||
# ========== 读取用户选择的参考标准 ==========
|
# ========== 读取用户选择的参考标准 ==========
|
||||||
if test_type == "screen_module":
|
if test_type == "screen_module":
|
||||||
@@ -260,6 +277,7 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
xlabel="x", ylabel="y",
|
xlabel="x", ylabel="y",
|
||||||
xlim=(bbox_xy[0], bbox_xy[1]),
|
xlim=(bbox_xy[0], bbox_xy[1]),
|
||||||
ylim=(bbox_xy[2], bbox_xy[3]),
|
ylim=(bbox_xy[2], bbox_xy[3]),
|
||||||
|
dark_mode=dark_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
for ref_name in other_refs:
|
for ref_name in other_refs:
|
||||||
@@ -284,7 +302,8 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
|
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
|
||||||
|
|
||||||
_draw_coverage_box(
|
_draw_coverage_box(
|
||||||
ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage
|
ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage,
|
||||||
|
dark_mode=dark_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 暗化三角形外部区域(黑色半透明遮罩)
|
# 暗化三角形外部区域(黑色半透明遮罩)
|
||||||
@@ -298,18 +317,19 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
|
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
|
||||||
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
|
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
|
||||||
path = Path(verts, codes)
|
path = Path(verts, codes)
|
||||||
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
|
mask_face = (0, 0, 0, 0.65) if dark_mode else (1, 1, 1, 0.50)
|
||||||
|
patch = PathPatch(path, facecolor=mask_face, lw=0, zorder=7)
|
||||||
ax_xy.add_patch(patch)
|
ax_xy.add_patch(patch)
|
||||||
|
|
||||||
legend = ax_xy.legend(
|
legend = ax_xy.legend(
|
||||||
loc="upper right", fontsize=8.5,
|
loc="upper right", fontsize=8.5,
|
||||||
framealpha=0.0, edgecolor="#000", fancybox=True,
|
framealpha=0.0, edgecolor="#000", fancybox=True,
|
||||||
labelcolor="#FFF"
|
labelcolor="#FFF" if dark_mode else "#111"
|
||||||
)
|
)
|
||||||
legend.set_zorder(200)
|
legend.set_zorder(200)
|
||||||
legend.get_frame().set_facecolor("#000")
|
legend.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
|
||||||
legend.get_frame().set_alpha(0.5)
|
legend.get_frame().set_alpha(0.5 if dark_mode else 0.78)
|
||||||
legend.get_frame().set_edgecolor("#FFF")
|
legend.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
|
||||||
ax_xy.add_artist(legend)
|
ax_xy.add_artist(legend)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -329,6 +349,7 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
xlabel="u'", ylabel="v'",
|
xlabel="u'", ylabel="v'",
|
||||||
xlim=(bbox_uv[0], bbox_uv[1]),
|
xlim=(bbox_uv[0], bbox_uv[1]),
|
||||||
ylim=(bbox_uv[2], bbox_uv[3]),
|
ylim=(bbox_uv[2], bbox_uv[3]),
|
||||||
|
dark_mode=dark_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
measured_uv = None
|
measured_uv = None
|
||||||
@@ -362,7 +383,8 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
_draw_measured_triangle(ax_uv, measured_uv, uv_space=True)
|
_draw_measured_triangle(ax_uv, measured_uv, uv_space=True)
|
||||||
|
|
||||||
_draw_coverage_box(
|
_draw_coverage_box(
|
||||||
ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage
|
ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage,
|
||||||
|
dark_mode=dark_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
u0, u1 = bbox_uv[0], bbox_uv[1]
|
u0, u1 = bbox_uv[0], bbox_uv[1]
|
||||||
@@ -374,18 +396,19 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
|
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
|
||||||
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
|
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
|
||||||
path = Path(verts, codes)
|
path = Path(verts, codes)
|
||||||
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
|
mask_face = (0, 0, 0, 0.65) if dark_mode else (1, 1, 1, 0.50)
|
||||||
|
patch = PathPatch(path, facecolor=mask_face, lw=0, zorder=7)
|
||||||
ax_uv.add_patch(patch)
|
ax_uv.add_patch(patch)
|
||||||
|
|
||||||
legend_uv = ax_uv.legend(
|
legend_uv = ax_uv.legend(
|
||||||
loc="upper right", fontsize=8.5,
|
loc="upper right", fontsize=8.5,
|
||||||
framealpha=0.0, edgecolor="#000", fancybox=True,
|
framealpha=0.0, edgecolor="#000", fancybox=True,
|
||||||
labelcolor="#FFF"
|
labelcolor="#FFF" if dark_mode else "#111"
|
||||||
)
|
)
|
||||||
legend_uv.set_zorder(200)
|
legend_uv.set_zorder(200)
|
||||||
legend_uv.get_frame().set_facecolor("#000")
|
legend_uv.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
|
||||||
legend_uv.get_frame().set_alpha(0.72)
|
legend_uv.get_frame().set_alpha(0.72 if dark_mode else 0.82)
|
||||||
legend_uv.get_frame().set_edgecolor("#FFF")
|
legend_uv.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
|
||||||
ax_uv.add_artist(legend_uv)
|
ax_uv.add_artist(legend_uv)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -408,3 +431,10 @@ def plot_gamut(self, results, coverage, test_type):
|
|||||||
self.sync_gamut_toolbar()
|
self.sync_gamut_toolbar()
|
||||||
|
|
||||||
self.log_gui.log("色域图绘制完成", level="success")
|
self.log_gui.log("色域图绘制完成", level="success")
|
||||||
|
|
||||||
|
|
||||||
|
class PlotGamutMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
plot_gamut = plot_gamut
|
||||||
|
|||||||
@@ -74,12 +74,19 @@ _DEFAULT_CCT_PARAMS = {
|
|||||||
"y_ideal": 0.3290,
|
"y_ideal": 0.3290,
|
||||||
"y_tolerance": 0.003,
|
"y_tolerance": 0.003,
|
||||||
},
|
},
|
||||||
|
"local_dimming": {
|
||||||
|
"x_ideal": 0.3127,
|
||||||
|
"x_tolerance": 0.003,
|
||||||
|
"y_ideal": 0.3290,
|
||||||
|
"y_tolerance": 0.003,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULT_GAMUT_REFERENCE = {
|
_DEFAULT_GAMUT_REFERENCE = {
|
||||||
"screen_module": "DCI-P3",
|
"screen_module": "DCI-P3",
|
||||||
"sdr_movie": "BT.709",
|
"sdr_movie": "BT.709",
|
||||||
"hdr_movie": "BT.2020",
|
"hdr_movie": "BT.2020",
|
||||||
|
"local_dimming": "DCI-P3",
|
||||||
}
|
}
|
||||||
|
|
||||||
_DEFAULT_TEST_TYPES = {
|
_DEFAULT_TEST_TYPES = {
|
||||||
@@ -87,6 +94,7 @@ _DEFAULT_TEST_TYPES = {
|
|||||||
"name": "屏模组性能测试",
|
"name": "屏模组性能测试",
|
||||||
"test_items": ["gamut", "gamma", "cct", "contrast"],
|
"test_items": ["gamut", "gamma", "cct", "contrast"],
|
||||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||||
|
"data_range": "Full",
|
||||||
"color_format": "RGB",
|
"color_format": "RGB",
|
||||||
"bpc": 8,
|
"bpc": 8,
|
||||||
"colorimetry": "sRGB",
|
"colorimetry": "sRGB",
|
||||||
@@ -96,6 +104,7 @@ _DEFAULT_TEST_TYPES = {
|
|||||||
"name": "SDR Movie测试",
|
"name": "SDR Movie测试",
|
||||||
"test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"],
|
"test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"],
|
||||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||||
|
"data_range": "Full",
|
||||||
"color_format": "RGB",
|
"color_format": "RGB",
|
||||||
"bpc": 8,
|
"bpc": 8,
|
||||||
"colorimetry": "sRGB",
|
"colorimetry": "sRGB",
|
||||||
@@ -105,11 +114,22 @@ _DEFAULT_TEST_TYPES = {
|
|||||||
"name": "HDR Movie测试",
|
"name": "HDR Movie测试",
|
||||||
"test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"],
|
"test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"],
|
||||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||||
|
"data_range": "Full",
|
||||||
"color_format": "RGB",
|
"color_format": "RGB",
|
||||||
"bpc": 8,
|
"bpc": 8,
|
||||||
"colorimetry": "sRGB",
|
"colorimetry": "sRGB",
|
||||||
"patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
|
"patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
|
||||||
},
|
},
|
||||||
|
"local_dimming": {
|
||||||
|
"name": "Local Dimming",
|
||||||
|
"test_items": [],
|
||||||
|
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||||
|
"data_range": "Full",
|
||||||
|
"color_format": "RGB",
|
||||||
|
"bpc": 8,
|
||||||
|
"colorimetry": "sRGB",
|
||||||
|
"patterns": {},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
_PATTERN_RGB = {
|
_PATTERN_RGB = {
|
||||||
@@ -221,6 +241,401 @@ def get_pattern(name: str) -> dict:
|
|||||||
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json")
|
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json")
|
||||||
|
|
||||||
|
|
||||||
|
def reload_gray_pattern() -> dict:
|
||||||
|
"""重新从 ``settings/patterns/gray.json`` 加载灰阶 pattern。
|
||||||
|
|
||||||
|
原地更新 ``_PATTERN_GRAY``,让 ``PQConfig.default_pattern_gray``
|
||||||
|
与 ``_KNOWN_PATTERNS['gray']`` 等所有现有引用同步生效,
|
||||||
|
无需重启程序即可应用新 pattern 列表。
|
||||||
|
"""
|
||||||
|
new_data = _load_pattern_or_empty(
|
||||||
|
_PATTERNS_DIR / "gray.json", default=_PATTERN_GRAY_FALLBACK
|
||||||
|
)
|
||||||
|
_PATTERN_GRAY.clear()
|
||||||
|
_PATTERN_GRAY.update(new_data)
|
||||||
|
return copy.deepcopy(_PATTERN_GRAY)
|
||||||
|
|
||||||
|
|
||||||
|
def get_gray_pattern_fallback() -> dict:
|
||||||
|
"""返回硬编码默认 11 点灰阶 pattern 的深拷贝(用于 UI 的"恢复默认")。"""
|
||||||
|
return copy.deepcopy(_PATTERN_GRAY_FALLBACK)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 灰阶 Pattern 预设管理(settings/patterns/presets/gray/*.json)
|
||||||
|
# =============================================================================
|
||||||
|
#
|
||||||
|
# 设计要点:
|
||||||
|
# - 每个预设独立 JSON 文件,文件名(不含 .json)即预设名。
|
||||||
|
# - 内置预设以 ``_builtin_`` 前缀命名,并在 _meta.locked=True,UI 禁止删除/改名/覆盖。
|
||||||
|
# - 当前激活预设记录在 settings/patterns/presets/_active.json,便于 UI 显示。
|
||||||
|
# - 应用某预设 = 把它复制写入 settings/patterns/gray.json + reload_gray_pattern()。
|
||||||
|
# - gamma/cct/contrast/eotf 共用同一份 gray 预设(与 runner 现有共享灰阶采集对齐)。
|
||||||
|
#
|
||||||
|
|
||||||
|
_PRESETS_DIR = _PATTERNS_DIR / "presets"
|
||||||
|
_ACTIVE_INDEX_FILE = _PRESETS_DIR / "_active.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _gray_presets_dir() -> Path:
|
||||||
|
p = _PRESETS_DIR / "gray"
|
||||||
|
p.mkdir(parents=True, exist_ok=True)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_preset_name(name: str) -> str:
|
||||||
|
"""清洗预设名,去掉文件系统危险字符。"""
|
||||||
|
name = (name or "").strip()
|
||||||
|
bad = '<>:"/\\|?*\n\r\t'
|
||||||
|
for ch in bad:
|
||||||
|
name = name.replace(ch, "_")
|
||||||
|
return name[:80] or "untitled"
|
||||||
|
|
||||||
|
|
||||||
|
def _preset_path(test_kind: str, name: str) -> Path:
|
||||||
|
if test_kind != "gray":
|
||||||
|
raise ValueError(f"暂仅支持 test_kind='gray',收到: {test_kind}")
|
||||||
|
return _gray_presets_dir() / f"{_safe_preset_name(name)}.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_active_index() -> dict:
|
||||||
|
try:
|
||||||
|
with open(_ACTIVE_INDEX_FILE, encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
return data if isinstance(data, dict) else {}
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_active_index(data: dict) -> None:
|
||||||
|
_PRESETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(_ACTIVE_INDEX_FILE, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def list_presets(test_kind: str = "gray") -> list[dict]:
|
||||||
|
"""
|
||||||
|
列出指定类别下的所有预设。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
list of {name, locked, description, point_count, file_path, generator}
|
||||||
|
"""
|
||||||
|
d = _gray_presets_dir() if test_kind == "gray" else None
|
||||||
|
if d is None:
|
||||||
|
return []
|
||||||
|
items: list[dict] = []
|
||||||
|
for fp in sorted(d.glob("*.json")):
|
||||||
|
try:
|
||||||
|
data = load_pattern_file(fp)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
meta = data.get("_meta", {}) or {}
|
||||||
|
items.append({
|
||||||
|
"name": fp.stem,
|
||||||
|
"locked": bool(meta.get("locked", False)),
|
||||||
|
"description": meta.get("description", ""),
|
||||||
|
"generator": meta.get("generator", ""),
|
||||||
|
"created": meta.get("created", ""),
|
||||||
|
"point_count": len(data.get("pattern_params") or []),
|
||||||
|
"file_path": str(fp),
|
||||||
|
})
|
||||||
|
# 内置 _builtin_ 排前,其余按名字排序
|
||||||
|
items.sort(key=lambda x: (not x["name"].startswith("_builtin_"), x["name"]))
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def load_preset(test_kind: str, name: str) -> dict:
|
||||||
|
"""加载预设的完整 pattern 数据(含 _meta)。"""
|
||||||
|
return load_pattern_file(_preset_path(test_kind, name))
|
||||||
|
|
||||||
|
|
||||||
|
def save_preset(
|
||||||
|
test_kind: str,
|
||||||
|
name: str,
|
||||||
|
pattern: dict,
|
||||||
|
*,
|
||||||
|
description: str = "",
|
||||||
|
generator: str = "",
|
||||||
|
locked: bool = False,
|
||||||
|
overwrite: bool = True,
|
||||||
|
) -> Path:
|
||||||
|
"""保存预设。若 overwrite=False 且文件已存在或目标为锁定预设则抛错。"""
|
||||||
|
from datetime import datetime
|
||||||
|
name = _safe_preset_name(name)
|
||||||
|
path = _preset_path(test_kind, name)
|
||||||
|
|
||||||
|
if path.exists():
|
||||||
|
try:
|
||||||
|
existing = load_pattern_file(path)
|
||||||
|
if (existing.get("_meta") or {}).get("locked"):
|
||||||
|
raise PermissionError(f"预设 '{name}' 已锁定,不可覆盖")
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
if not overwrite:
|
||||||
|
raise FileExistsError(f"预设 '{name}' 已存在")
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"pattern_mode": pattern.get("pattern_mode", "SolidColor"),
|
||||||
|
"measurement_bit_depth": pattern.get("measurement_bit_depth", 8),
|
||||||
|
"measurement_max_value": max(0, len(pattern.get("pattern_params") or []) - 1),
|
||||||
|
"pattern_params": [list(map(int, rgb)) for rgb in (pattern.get("pattern_params") or [])],
|
||||||
|
"_meta": {
|
||||||
|
"description": description,
|
||||||
|
"generator": generator,
|
||||||
|
"locked": locked,
|
||||||
|
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
save_pattern_file(path, data)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def delete_preset(test_kind: str, name: str) -> None:
|
||||||
|
"""删除预设;锁定预设不可删除。"""
|
||||||
|
path = _preset_path(test_kind, name)
|
||||||
|
if not path.exists():
|
||||||
|
raise FileNotFoundError(f"预设 '{name}' 不存在")
|
||||||
|
data = load_pattern_file(path)
|
||||||
|
if (data.get("_meta") or {}).get("locked"):
|
||||||
|
raise PermissionError(f"预设 '{name}' 已锁定,不可删除")
|
||||||
|
path.unlink()
|
||||||
|
# 若被删的恰是激活预设,清理记录
|
||||||
|
idx = _load_active_index()
|
||||||
|
if idx.get(test_kind) == name:
|
||||||
|
idx.pop(test_kind, None)
|
||||||
|
_save_active_index(idx)
|
||||||
|
|
||||||
|
|
||||||
|
def rename_preset(test_kind: str, old: str, new: str) -> Path:
|
||||||
|
"""重命名;锁定预设不可改名。"""
|
||||||
|
src = _preset_path(test_kind, old)
|
||||||
|
if not src.exists():
|
||||||
|
raise FileNotFoundError(f"预设 '{old}' 不存在")
|
||||||
|
data = load_pattern_file(src)
|
||||||
|
if (data.get("_meta") or {}).get("locked"):
|
||||||
|
raise PermissionError(f"预设 '{old}' 已锁定,不可重命名")
|
||||||
|
dst = _preset_path(test_kind, new)
|
||||||
|
if dst.exists():
|
||||||
|
raise FileExistsError(f"目标预设 '{new}' 已存在")
|
||||||
|
src.rename(dst)
|
||||||
|
idx = _load_active_index()
|
||||||
|
if idx.get(test_kind) == old:
|
||||||
|
idx[test_kind] = dst.stem
|
||||||
|
_save_active_index(idx)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
|
||||||
|
def duplicate_preset(test_kind: str, src_name: str, new_name: str) -> Path:
|
||||||
|
"""复制一个预设为新副本(解除锁定)。"""
|
||||||
|
data = load_preset(test_kind, src_name)
|
||||||
|
meta = dict(data.get("_meta") or {})
|
||||||
|
meta["locked"] = False
|
||||||
|
meta["description"] = f"复制自 {src_name}" + (
|
||||||
|
f";{meta.get('description', '')}" if meta.get("description") else ""
|
||||||
|
)
|
||||||
|
data["_meta"] = meta
|
||||||
|
return save_preset(
|
||||||
|
test_kind,
|
||||||
|
new_name,
|
||||||
|
data,
|
||||||
|
description=meta["description"],
|
||||||
|
generator=meta.get("generator", ""),
|
||||||
|
locked=False,
|
||||||
|
overwrite=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def activate_preset(test_kind: str, name: str) -> dict:
|
||||||
|
"""
|
||||||
|
将指定预设应用为当前 gray pattern:
|
||||||
|
- 写入 settings/patterns/gray.json(剥离 _meta 以保持原格式)
|
||||||
|
- reload_gray_pattern() 让运行时立即生效
|
||||||
|
- 在 _active.json 记录激活预设名
|
||||||
|
"""
|
||||||
|
data = load_preset(test_kind, name)
|
||||||
|
clean = {
|
||||||
|
"pattern_mode": data.get("pattern_mode", "SolidColor"),
|
||||||
|
"measurement_bit_depth": data.get("measurement_bit_depth", 8),
|
||||||
|
"measurement_max_value": data.get("measurement_max_value", 0),
|
||||||
|
"pattern_params": data.get("pattern_params") or [],
|
||||||
|
}
|
||||||
|
save_pattern_file(_PATTERNS_DIR / "gray.json", clean)
|
||||||
|
reload_gray_pattern()
|
||||||
|
idx = _load_active_index()
|
||||||
|
idx[test_kind] = _safe_preset_name(name)
|
||||||
|
_save_active_index(idx)
|
||||||
|
return clean
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_preset_name(test_kind: str = "gray") -> str | None:
|
||||||
|
"""返回当前激活预设名(来自 _active.json);不存在则返回 None。"""
|
||||||
|
return _load_active_index().get(test_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def import_preset_from_file(test_kind: str, src_file, *, name: str | None = None) -> Path:
|
||||||
|
"""从外部 JSON 文件导入为预设。"""
|
||||||
|
data = load_pattern_file(src_file)
|
||||||
|
if not data.get("pattern_params"):
|
||||||
|
raise ValueError("文件中未找到 pattern_params")
|
||||||
|
preset_name = name or Path(src_file).stem
|
||||||
|
return save_preset(
|
||||||
|
test_kind,
|
||||||
|
preset_name,
|
||||||
|
data,
|
||||||
|
description=(data.get("_meta") or {}).get("description", "导入"),
|
||||||
|
generator=(data.get("_meta") or {}).get("generator", ""),
|
||||||
|
locked=False,
|
||||||
|
overwrite=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def export_preset_to_file(test_kind: str, name: str, dst_file) -> Path:
|
||||||
|
"""将预设导出到指定外部 JSON 文件。"""
|
||||||
|
data = load_preset(test_kind, name)
|
||||||
|
save_pattern_file(dst_file, data)
|
||||||
|
return Path(dst_file)
|
||||||
|
|
||||||
|
|
||||||
|
# ---- 内置预设生成器 ----------------------------------------------------------
|
||||||
|
|
||||||
|
def _gen_even_gray(n: int) -> list[list[int]]:
|
||||||
|
"""N 点等分 (100%→0%)。"""
|
||||||
|
if n < 2:
|
||||||
|
n = 2
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
pct = 100.0 - (100.0 / (n - 1)) * i
|
||||||
|
v = int(round(pct / 100.0 * 255))
|
||||||
|
out.append([v, v, v])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_pq_gray(n: int) -> list[list[int]]:
|
||||||
|
"""在 PQ 编码空间 N 点等分(亮度按 PQ 曲线均匀分布,低端更密集)。
|
||||||
|
|
||||||
|
采样规则:取 PQ 信号值从 1.0 到 0.0 等分,转 8-bit RGB 灰阶。
|
||||||
|
(PQ 信号本身在感知亮度上即为线性,故"等分编码值"≈"等分感知亮度"。)
|
||||||
|
"""
|
||||||
|
if n < 2:
|
||||||
|
n = 2
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
v_pq = 1.0 - i / (n - 1)
|
||||||
|
v = int(round(v_pq * 255))
|
||||||
|
out.append([v, v, v])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_gamma_gray(n: int, gamma: float = 2.2) -> list[list[int]]:
|
||||||
|
"""在线性光强空间 N 点等分,再用 gamma 编码到 8-bit 灰阶(暗端更密集)。"""
|
||||||
|
if n < 2:
|
||||||
|
n = 2
|
||||||
|
out = []
|
||||||
|
for i in range(n):
|
||||||
|
lin = 1.0 - i / (n - 1) # 线性光 1→0
|
||||||
|
code = lin ** (1.0 / gamma) # gamma 编码
|
||||||
|
v = int(round(code * 255))
|
||||||
|
out.append([v, v, v])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
_BUILTIN_GRAY_PRESETS = [
|
||||||
|
("_builtin_even_11pt",
|
||||||
|
"11 点等分 (100%→0%),行业标准灰阶",
|
||||||
|
"even-11",
|
||||||
|
_gen_even_gray(11)),
|
||||||
|
("_builtin_even_21pt",
|
||||||
|
"21 点等分 (5% 步长),更精细的 SDR 灰阶",
|
||||||
|
"even-21",
|
||||||
|
_gen_even_gray(21)),
|
||||||
|
("_builtin_gamma22_17pt",
|
||||||
|
"17 点 Gamma 2.2 分布(暗端更密集),用于 SDR Gamma 拟合",
|
||||||
|
"gamma2.2-17",
|
||||||
|
_gen_gamma_gray(17, 2.2)),
|
||||||
|
("_builtin_pq_17pt",
|
||||||
|
"17 点 PQ 编码等分,用于 HDR EOTF 评估",
|
||||||
|
"pq-17",
|
||||||
|
_gen_pq_gray(17)),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_builtin_presets() -> None:
|
||||||
|
"""启动时调用:确保内置预设存在;若已存在则跳过(不覆盖用户修改)。
|
||||||
|
|
||||||
|
同时迁移:若 presets/ 目录为空但 settings/patterns/gray.json 存在,
|
||||||
|
将其作为 ``user_current`` 预设引入,避免用户原有自定义丢失。
|
||||||
|
"""
|
||||||
|
presets_dir = _gray_presets_dir()
|
||||||
|
for name, desc, gen, params in _BUILTIN_GRAY_PRESETS:
|
||||||
|
path = presets_dir / f"{name}.json"
|
||||||
|
if path.exists():
|
||||||
|
continue
|
||||||
|
save_preset(
|
||||||
|
"gray",
|
||||||
|
name,
|
||||||
|
{
|
||||||
|
"pattern_mode": "SolidColor",
|
||||||
|
"measurement_bit_depth": 8,
|
||||||
|
"measurement_max_value": len(params) - 1,
|
||||||
|
"pattern_params": params,
|
||||||
|
},
|
||||||
|
description=desc,
|
||||||
|
generator=gen,
|
||||||
|
locked=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 迁移:用户原有 gray.json 入库为 user_current(仅在 presets 目录还没有任何用户预设时)
|
||||||
|
user_presets = [p for p in presets_dir.glob("*.json") if not p.stem.startswith("_builtin_")]
|
||||||
|
if not user_presets:
|
||||||
|
gray_file = _PATTERNS_DIR / "gray.json"
|
||||||
|
if gray_file.exists():
|
||||||
|
try:
|
||||||
|
cur = load_pattern_file(gray_file)
|
||||||
|
if cur.get("pattern_params"):
|
||||||
|
save_preset(
|
||||||
|
"gray",
|
||||||
|
"user_current",
|
||||||
|
cur,
|
||||||
|
description="迁移自原 gray.json",
|
||||||
|
generator="migrated",
|
||||||
|
locked=False,
|
||||||
|
overwrite=False,
|
||||||
|
)
|
||||||
|
idx = _load_active_index()
|
||||||
|
idx.setdefault("gray", "user_current")
|
||||||
|
_save_active_index(idx)
|
||||||
|
except (json.JSONDecodeError, OSError, FileExistsError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 若无激活预设记录,按规则推断(优先匹配当前 gray.json 内容)
|
||||||
|
idx = _load_active_index()
|
||||||
|
if "gray" not in idx:
|
||||||
|
try:
|
||||||
|
cur = load_pattern_file(_PATTERNS_DIR / "gray.json")
|
||||||
|
cur_params = cur.get("pattern_params") or []
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
cur_params = []
|
||||||
|
match: str | None = None
|
||||||
|
for fp in presets_dir.glob("*.json"):
|
||||||
|
try:
|
||||||
|
if (load_pattern_file(fp).get("pattern_params") or []) == cur_params:
|
||||||
|
match = fp.stem
|
||||||
|
break
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
if match:
|
||||||
|
idx["gray"] = match
|
||||||
|
_save_active_index(idx)
|
||||||
|
|
||||||
|
|
||||||
|
# 自动确保内置预设存在(首次启动会创建文件)
|
||||||
|
try:
|
||||||
|
ensure_builtin_presets()
|
||||||
|
except OSError:
|
||||||
|
# 启动期目录不可写则跳过;UI 层有兜底
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class PQConfig:
|
class PQConfig:
|
||||||
def __init__(self, current_test_type="screen_module"):
|
def __init__(self, current_test_type="screen_module"):
|
||||||
@@ -300,7 +715,16 @@ class PQConfig:
|
|||||||
def from_dict(self, config_dict):
|
def from_dict(self, config_dict):
|
||||||
"""从字典加载配置"""
|
"""从字典加载配置"""
|
||||||
self.current_test_type = config_dict.get("current_test_type", "screen_module")
|
self.current_test_type = config_dict.get("current_test_type", "screen_module")
|
||||||
self.current_test_types = config_dict.get("test_types", self.current_test_types)
|
|
||||||
|
# 以默认模板为底,叠加历史配置,保证新字段(如 data_range)在旧配置下也有值。
|
||||||
|
loaded_test_types = config_dict.get("test_types", {})
|
||||||
|
merged_test_types = copy.deepcopy(_DEFAULT_TEST_TYPES)
|
||||||
|
if isinstance(loaded_test_types, dict):
|
||||||
|
for test_type, loaded_cfg in loaded_test_types.items():
|
||||||
|
if test_type in merged_test_types and isinstance(loaded_cfg, dict):
|
||||||
|
merged_test_types[test_type].update(loaded_cfg)
|
||||||
|
self.current_test_types = merged_test_types
|
||||||
|
|
||||||
self.device_config = config_dict.get("device_config", self.device_config)
|
self.device_config = config_dict.get("device_config", self.device_config)
|
||||||
self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern)
|
self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern)
|
||||||
|
|
||||||
|
|||||||
@@ -45,11 +45,7 @@ def load_icon(png_path):
|
|||||||
|
|
||||||
|
|
||||||
def backgroud_style_set():
|
def backgroud_style_set():
|
||||||
style = ttk.Style()
|
style = ttk.Style() # noqa: F841 - 保持原副作用:确保 Style 实例化
|
||||||
# 移除背景色设置,使用默认背景色
|
# 现代化样式集中注册(Card / ConfigHeader / Toolbar / StatusBar / Sidebar 等)
|
||||||
style.configure(
|
from app.views.modern_styles import apply_modern_styles
|
||||||
"SidebarSelected.TButton",
|
apply_modern_styles()
|
||||||
# anchor="w",
|
|
||||||
padding=10,
|
|
||||||
background="#005470",
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""测试执行(runner)相关逻辑(Step 5 重构)。
|
"""测试执行(runner)相关逻辑(Step 5 重构)。
|
||||||
|
|
||||||
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
|
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
|
||||||
以保留原有 `self.xxx` 属性访问不变。
|
以保留原有 `self.xxx` 属性访问不变。
|
||||||
@@ -15,7 +15,13 @@ import numpy as np
|
|||||||
import algorithm.pq_algorithm as pq_algorithm
|
import algorithm.pq_algorithm as pq_algorithm
|
||||||
from app.pq.pq_result import PQResult
|
from app.pq.pq_result import PQResult
|
||||||
|
|
||||||
def new_pq_results(self, test_type, test_name):
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def new_pq_results(self: "PQAutomationApp", test_type, test_name):
|
||||||
# 通过 PQResultStore 创建/替换指定 test_type 的结果,并设为当前活跃
|
# 通过 PQResultStore 创建/替换指定 test_type 的结果,并设为当前活跃
|
||||||
self.results.new(test_type, test_name)
|
self.results.new(test_type, test_name)
|
||||||
# 设置配置
|
# 设置配置
|
||||||
@@ -36,7 +42,7 @@ def new_pq_results(self, test_type, test_name):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_test(self, test_type, test_items):
|
def run_test(self: "PQAutomationApp", test_type, test_items):
|
||||||
"""执行测试"""
|
"""执行测试"""
|
||||||
try:
|
try:
|
||||||
self.log_gui.log(f"开始执行{self.get_test_type_name(test_type)}测试", level="info")
|
self.log_gui.log(f"开始执行{self.get_test_type_name(test_type)}测试", level="info")
|
||||||
@@ -51,6 +57,11 @@ def run_test(self, test_type, test_items):
|
|||||||
self.run_sdr_movie_test(test_items)
|
self.run_sdr_movie_test(test_items)
|
||||||
elif test_type == "hdr_movie":
|
elif test_type == "hdr_movie":
|
||||||
self.run_hdr_movie_test(test_items)
|
self.run_hdr_movie_test(test_items)
|
||||||
|
elif test_type == "local_dimming":
|
||||||
|
self.log_gui.log(
|
||||||
|
"Local Dimming 为手动模式,请在 Local Dimming 面板发送图案并采集亮度",
|
||||||
|
level="info",
|
||||||
|
)
|
||||||
|
|
||||||
# 测试完成后更新UI状态
|
# 测试完成后更新UI状态
|
||||||
if self.testing: # 如果没有被中途停止
|
if self.testing: # 如果没有被中途停止
|
||||||
@@ -63,7 +74,7 @@ def run_test(self, test_type, test_items):
|
|||||||
self._dispatch_ui(self.on_test_error)
|
self._dispatch_ui(self.on_test_error)
|
||||||
|
|
||||||
|
|
||||||
def run_screen_module_test(self, test_items):
|
def run_screen_module_test(self: "PQAutomationApp", test_items):
|
||||||
"""执行屏模组性能测试 - 优化版"""
|
"""执行屏模组性能测试 - 优化版"""
|
||||||
self.log_gui.log("执行屏模组性能测试...", level="info")
|
self.log_gui.log("执行屏模组性能测试...", level="info")
|
||||||
|
|
||||||
@@ -138,7 +149,7 @@ def run_screen_module_test(self, test_items):
|
|||||||
self.test_contrast("screen_module", shared_gray_data)
|
self.test_contrast("screen_module", shared_gray_data)
|
||||||
|
|
||||||
|
|
||||||
def run_custom_sdr_test(self, test_items):
|
def run_custom_sdr_test(self: "PQAutomationApp", test_items):
|
||||||
"""执行客户定制 SDR 测试 - 升级版"""
|
"""执行客户定制 SDR 测试 - 升级版"""
|
||||||
self.log_gui.log("执行客户定制 SDR 测试...", level="info")
|
self.log_gui.log("执行客户定制 SDR 测试...", level="info")
|
||||||
# 获取信号格式设置
|
# 获取信号格式设置
|
||||||
@@ -154,7 +165,7 @@ def run_custom_sdr_test(self, test_items):
|
|||||||
self._dispatch_ui(self.on_custom_template_test_completed)
|
self._dispatch_ui(self.on_custom_template_test_completed)
|
||||||
|
|
||||||
|
|
||||||
def run_sdr_movie_test(self, test_items):
|
def run_sdr_movie_test(self: "PQAutomationApp", test_items):
|
||||||
"""执行SDR Movie测试"""
|
"""执行SDR Movie测试"""
|
||||||
self.log_gui.log("执行SDR Movie测试...", level="info")
|
self.log_gui.log("执行SDR Movie测试...", level="info")
|
||||||
|
|
||||||
@@ -225,7 +236,7 @@ def run_sdr_movie_test(self, test_items):
|
|||||||
self.test_color_accuracy("sdr_movie")
|
self.test_color_accuracy("sdr_movie")
|
||||||
|
|
||||||
|
|
||||||
def run_hdr_movie_test(self, test_items):
|
def run_hdr_movie_test(self: "PQAutomationApp", test_items):
|
||||||
"""执行HDR Movie测试"""
|
"""执行HDR Movie测试"""
|
||||||
self.log_gui.log("执行HDR Movie测试...", level="info")
|
self.log_gui.log("执行HDR Movie测试...", level="info")
|
||||||
|
|
||||||
@@ -300,7 +311,7 @@ def run_hdr_movie_test(self, test_items):
|
|||||||
self.test_color_accuracy("hdr_movie")
|
self.test_color_accuracy("hdr_movie")
|
||||||
|
|
||||||
|
|
||||||
def send_fix_pattern(self, mode):
|
def send_fix_pattern(self: "PQAutomationApp", mode):
|
||||||
"""发送固定图案并采集数据 - 支持不同测试类型的信号格式"""
|
"""发送固定图案并采集数据 - 支持不同测试类型的信号格式"""
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
@@ -309,9 +320,26 @@ def send_fix_pattern(self, mode):
|
|||||||
|
|
||||||
self.log_gui.log("=" * 50, level="separator")
|
self.log_gui.log("=" * 50, level="separator")
|
||||||
|
|
||||||
# 信号格式设置后等待电视重新锁定 HDMI 信号
|
# 判定信号是否变化(决定 settle 长度)。
|
||||||
# format_changed=True 表示本次 set_video_mode 的参数与上次不同,TV 需要重新锁定
|
# - SDR/HDR:prepare_session 内部已调用 ``apply_signal_format`` → ``set_video_mode``,
|
||||||
format_changed = getattr(getattr(self, "ucd", None), "format_changed", True)
|
# 此时 ``format_changed`` 反映本次 vs 上次的差异,可直接读取。
|
||||||
|
# - screen_module:prepare_session 只 stage 了 color_info/timing,未调用
|
||||||
|
# ``set_video_mode``,``format_changed`` 仍是上次测试的陈旧值,按"潜在变化"处理。
|
||||||
|
# 注意:必须在 prime 提交之前读取,否则 prime 的 set_video_mode 会拿当次
|
||||||
|
# 参数与刚写入的 ``_last_sent_config`` 比对,得到 False,把这个标志覆盖掉。
|
||||||
|
if mode == "screen_module":
|
||||||
|
format_changed = True
|
||||||
|
else:
|
||||||
|
format_changed = bool(
|
||||||
|
getattr(getattr(self, "ucd", None), "format_changed", True)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 预热提交:prepare_session 仅 stage 了新的 color/timing/pattern,
|
||||||
|
# 真正的 ``pg.apply()`` 要到第一次发图时才发生。提前发送首个 pattern,
|
||||||
|
# 让 TV 在 signal_settle 期间就开始重新锁定信号;
|
||||||
|
# 否则前 1~2 个 pattern 会落在 TV 锁定窗口里导致测量错误。
|
||||||
|
self.pattern_service.send_session_pattern(session, 0)
|
||||||
|
|
||||||
if format_changed:
|
if format_changed:
|
||||||
signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 5.0)))
|
signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 5.0)))
|
||||||
self.log_gui.log(
|
self.log_gui.log(
|
||||||
@@ -355,8 +383,13 @@ def send_fix_pattern(self, mode):
|
|||||||
else:
|
else:
|
||||||
self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info")
|
self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info")
|
||||||
|
|
||||||
self.pattern_service.send_session_pattern(session, i)
|
# 首图已在 prime 阶段发送并经 signal_settle 稳定,无需重发也无需再等
|
||||||
time.sleep(settle_time)
|
# settle_time;后续 pattern 走正常发图 + 等待。
|
||||||
|
if i == 0:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.pattern_service.send_session_pattern(session, i)
|
||||||
|
time.sleep(settle_time)
|
||||||
|
|
||||||
# 测量数据
|
# 测量数据
|
||||||
if mode == "custom":
|
if mode == "custom":
|
||||||
@@ -438,7 +471,7 @@ def send_fix_pattern(self, mode):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def test_custom_sdr(self):
|
def test_custom_sdr(self: "PQAutomationApp"):
|
||||||
"""执行客户定制 SDR 测试 - 升级版"""
|
"""执行客户定制 SDR 测试 - 升级版"""
|
||||||
self.log_gui.log("执行客户定制 SDR 测试...", level="info")
|
self.log_gui.log("执行客户定制 SDR 测试...", level="info")
|
||||||
results = self.send_fix_pattern("custom")
|
results = self.send_fix_pattern("custom")
|
||||||
@@ -449,7 +482,7 @@ def test_custom_sdr(self):
|
|||||||
self.log_gui.log(f"客户模板采集完成,共 {len(results)} 组数据", level="success")
|
self.log_gui.log(f"客户模板采集完成,共 {len(results)} 组数据", level="success")
|
||||||
|
|
||||||
|
|
||||||
def test_gamut(self, test_type):
|
def test_gamut(self: "PQAutomationApp", test_type):
|
||||||
"""测试色域"""
|
"""测试色域"""
|
||||||
self.log_gui.log("开始测试色域...", level="info")
|
self.log_gui.log("开始测试色域...", level="info")
|
||||||
self.results.start_test_item("gamut")
|
self.results.start_test_item("gamut")
|
||||||
@@ -607,7 +640,7 @@ def test_gamut(self, test_type):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def test_gamma(self, test_type, gray_data=None):
|
def test_gamma(self: "PQAutomationApp", test_type, gray_data=None):
|
||||||
"""测试Gamma曲线
|
"""测试Gamma曲线
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -698,7 +731,7 @@ def test_gamma(self, test_type, gray_data=None):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def test_eotf(self, test_type, gray_data=None):
|
def test_eotf(self: "PQAutomationApp", test_type, gray_data=None):
|
||||||
"""测试 EOTF 曲线(HDR 专用)
|
"""测试 EOTF 曲线(HDR 专用)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -781,7 +814,7 @@ def test_eotf(self, test_type, gray_data=None):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def test_cct(self, test_type, gray_data=None):
|
def test_cct(self: "PQAutomationApp", test_type, gray_data=None):
|
||||||
"""测试色度一致性"""
|
"""测试色度一致性"""
|
||||||
self.log_gui.log("开始测试色度一致性...", level="info")
|
self.log_gui.log("开始测试色度一致性...", level="info")
|
||||||
self.results.start_test_item("cct")
|
self.results.start_test_item("cct")
|
||||||
@@ -821,7 +854,7 @@ def test_cct(self, test_type, gray_data=None):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def test_contrast(self, test_type, gray_data=None):
|
def test_contrast(self: "PQAutomationApp", test_type, gray_data=None):
|
||||||
"""测试对比度
|
"""测试对比度
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -885,7 +918,7 @@ def test_contrast(self, test_type, gray_data=None):
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
|
|
||||||
def test_color_accuracy(self, test_type):
|
def test_color_accuracy(self: "PQAutomationApp", test_type):
|
||||||
"""测试色准 - 使用手工实现的 ΔE 2000(应用 Gamma)"""
|
"""测试色准 - 使用手工实现的 ΔE 2000(应用 Gamma)"""
|
||||||
|
|
||||||
# ========== Gamma 参考值 ==========
|
# ========== Gamma 参考值 ==========
|
||||||
@@ -1045,7 +1078,7 @@ def test_color_accuracy(self, test_type):
|
|||||||
self.log_gui.log("色准测试完成", level="success")
|
self.log_gui.log("色准测试完成", level="success")
|
||||||
|
|
||||||
|
|
||||||
def on_test_completed(self):
|
def on_test_completed(self: "PQAutomationApp"):
|
||||||
"""测试完成后的UI更新"""
|
"""测试完成后的UI更新"""
|
||||||
self.testing = False
|
self.testing = False
|
||||||
self.start_btn.config(state=tk.NORMAL)
|
self.start_btn.config(state=tk.NORMAL)
|
||||||
@@ -1188,7 +1221,7 @@ def on_test_completed(self):
|
|||||||
messagebox.showinfo("完成", "测试已完成!")
|
messagebox.showinfo("完成", "测试已完成!")
|
||||||
|
|
||||||
|
|
||||||
def on_custom_template_test_completed(self):
|
def on_custom_template_test_completed(self: "PQAutomationApp"):
|
||||||
"""客户模板测试完成后的UI更新"""
|
"""客户模板测试完成后的UI更新"""
|
||||||
self.testing = False
|
self.testing = False
|
||||||
self.set_custom_result_table_locked(False)
|
self.set_custom_result_table_locked(False)
|
||||||
@@ -1209,7 +1242,7 @@ def on_custom_template_test_completed(self):
|
|||||||
messagebox.showinfo("完成", "客户模板测试已完成!")
|
messagebox.showinfo("完成", "客户模板测试已完成!")
|
||||||
|
|
||||||
|
|
||||||
def get_current_test_result(self):
|
def get_current_test_result(self: "PQAutomationApp"):
|
||||||
"""获取当前测试结果"""
|
"""获取当前测试结果"""
|
||||||
test_type = self.test_type_var.get()
|
test_type = self.test_type_var.get()
|
||||||
test_items = self.get_selected_test_items()
|
test_items = self.get_selected_test_items()
|
||||||
@@ -1241,7 +1274,7 @@ def get_current_test_result(self):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def on_test_error(self):
|
def on_test_error(self: "PQAutomationApp"):
|
||||||
"""测试出错后的UI更新"""
|
"""测试出错后的UI更新"""
|
||||||
self.testing = False
|
self.testing = False
|
||||||
self.set_custom_result_table_locked(False)
|
self.set_custom_result_table_locked(False)
|
||||||
@@ -1262,3 +1295,27 @@ def on_test_error(self):
|
|||||||
messagebox.showerror("错误", "测试过程中发生错误,请查看日志")
|
messagebox.showerror("错误", "测试过程中发生错误,请查看日志")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunnerMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
new_pq_results = new_pq_results
|
||||||
|
run_test = run_test
|
||||||
|
run_screen_module_test = run_screen_module_test
|
||||||
|
run_custom_sdr_test = run_custom_sdr_test
|
||||||
|
run_sdr_movie_test = run_sdr_movie_test
|
||||||
|
run_hdr_movie_test = run_hdr_movie_test
|
||||||
|
send_fix_pattern = send_fix_pattern
|
||||||
|
test_custom_sdr = test_custom_sdr
|
||||||
|
test_gamut = test_gamut
|
||||||
|
test_gamma = test_gamma
|
||||||
|
test_eotf = test_eotf
|
||||||
|
test_cct = test_cct
|
||||||
|
test_contrast = test_contrast
|
||||||
|
test_color_accuracy = test_color_accuracy
|
||||||
|
on_test_completed = on_test_completed
|
||||||
|
on_custom_template_test_completed = on_custom_template_test_completed
|
||||||
|
get_current_test_result = get_current_test_result
|
||||||
|
on_test_error = on_test_error
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
||||||
|
|
||||||
后端接口(测试环境):
|
后端接口(生产/测试环境):
|
||||||
POST {API_BASE_URL}{API_PATH}
|
POST {API_BASE_URL}{API_GENERATE_PATH}
|
||||||
body: {"user_message": str, "session_id": str}
|
body: {"user_message": str, "session_id": str, "upload_image_url"?: str}
|
||||||
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
||||||
|
POST {API_BASE_URL}{API_UPLOAD_PATH} # multipart/form-data, field: file
|
||||||
|
resp: {"code": 200, "message": "", "data": {"upload_image_url": "..."}}
|
||||||
|
|
||||||
|
带 ``upload_image_url`` 启用"图生图"模式;多轮对话需将上一轮返回的 imageUrl
|
||||||
|
作为下一轮请求的 upload_image_url(由 panel 通过会话级缓存自动维护)。
|
||||||
|
|
||||||
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
||||||
"""
|
"""
|
||||||
@@ -41,10 +46,20 @@ _META_SUFFIX = ".json"
|
|||||||
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||||
|
|
||||||
# 测试环境后端
|
# 测试环境后端
|
||||||
# API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
|
# API_BASE_URL = "http://10.201.44.70:9008/ai-agent/"
|
||||||
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
|
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
|
||||||
API_PATH = "api/v1/pqtest/generate"
|
API_GENERATE_PATH = "api/v1/pqtest/generate"
|
||||||
API_TIMEOUT = 300.0 # 后端最长 60s,留余量
|
API_UPLOAD_PATH = "api/v1/pqtest/upload"
|
||||||
|
API_TIMEOUT = 300.0 # 后端最长 120s,留余量
|
||||||
|
API_UPLOAD_TIMEOUT = 60.0
|
||||||
|
|
||||||
|
# 上传接口限制(来自接口文档)
|
||||||
|
UPLOAD_MAX_BYTES = 10 * 1024 * 1024
|
||||||
|
UPLOAD_MAX_PIXELS = 4096
|
||||||
|
UPLOAD_ALLOWED_EXT = (".png", ".jpg", ".jpeg")
|
||||||
|
|
||||||
|
# 兼容旧名(如其他模块仍引用)
|
||||||
|
API_PATH = API_GENERATE_PATH
|
||||||
|
|
||||||
# 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置
|
# 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置
|
||||||
_session_id: str = str(uuid.uuid4())
|
_session_id: str = str(uuid.uuid4())
|
||||||
@@ -133,9 +148,9 @@ class AIImageRecord:
|
|||||||
# ---------- 后端 API ----------
|
# ---------- 后端 API ----------
|
||||||
|
|
||||||
|
|
||||||
def _api_endpoint() -> str:
|
def _api_endpoint(path: str = API_GENERATE_PATH) -> str:
|
||||||
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
|
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
|
||||||
return base + API_PATH.lstrip("/")
|
return base + path.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
def _pretty_json_text(value) -> str:
|
def _pretty_json_text(value) -> str:
|
||||||
@@ -150,22 +165,33 @@ def _pretty_json_text(value) -> str:
|
|||||||
return "" if value is None else str(value)
|
return "" if value is None else str(value)
|
||||||
|
|
||||||
|
|
||||||
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str:
|
def _call_pqtest_generate(
|
||||||
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。"""
|
user_message: str,
|
||||||
payload = json.dumps(
|
session_id: str,
|
||||||
{"user_message": user_message,
|
upload_image_url: Optional[str] = None,
|
||||||
"session_id": session_id},
|
timeout: float = API_TIMEOUT,
|
||||||
ensure_ascii=False,
|
) -> str:
|
||||||
).encode("utf-8")
|
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。
|
||||||
|
|
||||||
|
``upload_image_url`` 传入时启用"图生图"模式。
|
||||||
|
"""
|
||||||
|
body: dict = {"user_message": user_message, "session_id": session_id}
|
||||||
|
if upload_image_url:
|
||||||
|
body["upload_image_url"] = upload_image_url
|
||||||
|
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||||||
request_headers = {
|
request_headers = {
|
||||||
"Content-Type": "application/json; charset=utf-8",
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
"User-Agent": "pqAutomationApp/1.0",
|
"User-Agent": "pqAutomationApp/1.0",
|
||||||
}
|
}
|
||||||
endpoint = _api_endpoint()
|
endpoint = _api_endpoint(API_GENERATE_PATH)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
|
"[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s",
|
||||||
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
|
_mask_sid(session_id),
|
||||||
|
"img2img" if upload_image_url else "txt2img",
|
||||||
|
len(user_message or ""),
|
||||||
|
_truncate(user_message),
|
||||||
|
upload_image_url or "-",
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
|
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
|
||||||
@@ -250,6 +276,137 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A
|
|||||||
return image_url
|
return image_url
|
||||||
|
|
||||||
|
|
||||||
|
def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, auto_resize: bool = True) -> str:
|
||||||
|
"""以 multipart/form-data 上传本地图片,返回 ``upload_image_url``。失败抛异常。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
auto_resize: True 时,若图片超过 4096×4096 或 10MB 则自动缩放/重压
|
||||||
|
"""
|
||||||
|
if not file_path or not os.path.isfile(file_path):
|
||||||
|
raise FileNotFoundError(f"图片文件不存在: {file_path}")
|
||||||
|
ext = os.path.splitext(file_path)[1].lower()
|
||||||
|
if ext not in UPLOAD_ALLOWED_EXT:
|
||||||
|
raise ValueError(f"不支持的图片格式 ({ext}),仅支持 PNG/JPG/JPEG")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
iw, ih = img.size
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(f"无法读取图片: {exc}") from exc
|
||||||
|
|
||||||
|
# 检查大小,如需则缩放
|
||||||
|
size = os.path.getsize(file_path)
|
||||||
|
needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES)
|
||||||
|
|
||||||
|
if needs_resize:
|
||||||
|
if not auto_resize:
|
||||||
|
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
|
||||||
|
raise ValueError(f"分辨率超过 4096×4096(当前 {iw}×{ih})")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB)")
|
||||||
|
|
||||||
|
# 自动缩放:等比例缩放至 4096×4096 以内
|
||||||
|
logger.info("[AIImage][UPLOAD] 自动缩放 %dx%d (%.1fMB) 至 ≤4096×4096",
|
||||||
|
iw, ih, size/1024/1024)
|
||||||
|
scale = min(UPLOAD_MAX_PIXELS / iw, UPLOAD_MAX_PIXELS / ih, 1.0)
|
||||||
|
new_w, new_h = max(1, int(iw * scale)), max(1, int(ih * scale))
|
||||||
|
|
||||||
|
with Image.open(file_path) as img:
|
||||||
|
img_resized = img.resize((new_w, new_h), Image.LANCZOS)
|
||||||
|
# 重压至 10MB 以下
|
||||||
|
# 首先尝试原格式
|
||||||
|
tmp_io = BytesIO()
|
||||||
|
fmt = "PNG" if ext == ".png" else "JPEG"
|
||||||
|
save_kw = {"format": fmt}
|
||||||
|
img_resized.save(tmp_io, **save_kw)
|
||||||
|
tmp_bytes = tmp_io.getvalue()
|
||||||
|
|
||||||
|
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
|
||||||
|
file_bytes = tmp_bytes
|
||||||
|
else:
|
||||||
|
# 原格式太大,转换为 JPEG 并压缩
|
||||||
|
logger.info("[AIImage][UPLOAD] 原格式超过限制,转为 JPEG 并压缩")
|
||||||
|
quality = 95
|
||||||
|
while quality >= 50:
|
||||||
|
tmp_io = BytesIO()
|
||||||
|
img_resized.save(tmp_io, format="JPEG", quality=quality, optimize=True)
|
||||||
|
tmp_bytes = tmp_io.getvalue()
|
||||||
|
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
|
||||||
|
file_bytes = tmp_bytes
|
||||||
|
break
|
||||||
|
quality -= 5
|
||||||
|
else:
|
||||||
|
# 即使 quality=50 还是太大,也用这个版本上传(后端会处理)
|
||||||
|
file_bytes = tmp_bytes
|
||||||
|
|
||||||
|
logger.info("[AIImage][UPLOAD] 缩放完成 %dx%d (%.1fMB)",
|
||||||
|
new_w, new_h, len(file_bytes)/1024/1024)
|
||||||
|
iw, ih = new_w, new_h
|
||||||
|
else:
|
||||||
|
with open(file_path, "rb") as f:
|
||||||
|
file_bytes = f.read()
|
||||||
|
|
||||||
|
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
||||||
|
boundary = "----pqAuto" + uuid.uuid4().hex
|
||||||
|
filename = os.path.basename(file_path)
|
||||||
|
crlf = b"\r\n"
|
||||||
|
body = b"".join([
|
||||||
|
b"--", boundary.encode("ascii"), crlf,
|
||||||
|
b'Content-Disposition: form-data; name="file"; filename="',
|
||||||
|
filename.encode("utf-8"), b'"', crlf,
|
||||||
|
b"Content-Type: ", mime.encode("ascii"), crlf, crlf,
|
||||||
|
file_bytes, crlf,
|
||||||
|
b"--", boundary.encode("ascii"), b"--", crlf,
|
||||||
|
])
|
||||||
|
|
||||||
|
endpoint = _api_endpoint(API_UPLOAD_PATH)
|
||||||
|
headers = {
|
||||||
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "pqAutomationApp/1.0",
|
||||||
|
"Content-Length": str(len(body)),
|
||||||
|
}
|
||||||
|
logger.info(
|
||||||
|
"[AIImage][UPLOAD] file=%s size=%dB mime=%s wh=%dx%d -> %s",
|
||||||
|
filename, len(file_bytes), mime, iw, ih, endpoint,
|
||||||
|
)
|
||||||
|
request = Request(endpoint, data=body, method="POST", headers=headers)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
raw = response.read()
|
||||||
|
http_status = response.status
|
||||||
|
raw_text = raw.decode("utf-8", errors="replace")
|
||||||
|
logger.info("[AIImage][UPLOAD_RESP]\nstatus=%s\nbody=%s",
|
||||||
|
http_status, _pretty_json_text(raw_text))
|
||||||
|
except HTTPError as exc:
|
||||||
|
err_raw = b""
|
||||||
|
try:
|
||||||
|
err_raw = exc.read() or b""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
|
||||||
|
logger.error("[AIImage][UPLOAD_ERR] status=%s reason=%s body=%s",
|
||||||
|
getattr(exc, "code", "?"), str(exc), _pretty_json_text(err_text))
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[AIImage][UPLOAD_ERR] %s: %s", type(exc).__name__, exc)
|
||||||
|
raise
|
||||||
|
elapsed = time.monotonic() - t0
|
||||||
|
try:
|
||||||
|
result = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(f"上传接口返回非 JSON:{raw_text}") from exc
|
||||||
|
code = result.get("code")
|
||||||
|
message = result.get("message") or ""
|
||||||
|
data = result.get("data") or {}
|
||||||
|
url = (data.get("upload_image_url") or "").strip()
|
||||||
|
if code != 200 or not url:
|
||||||
|
raise RuntimeError(f"上传失败 code={code} msg={message or '未知错误'}")
|
||||||
|
logger.info("[AIImage][UPLOAD_OK] elapsed=%.2fs url=%s", elapsed, url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
# ---------- 缓存路径工具 ----------
|
# ---------- 缓存路径工具 ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -504,6 +661,7 @@ def request_image_async(
|
|||||||
base_dir: Optional[str] = None,
|
base_dir: Optional[str] = None,
|
||||||
session_id: Optional[str] = None,
|
session_id: Optional[str] = None,
|
||||||
cancel_event: Optional[threading.Event] = None,
|
cancel_event: Optional[threading.Event] = None,
|
||||||
|
upload_image_url: Optional[str] = None,
|
||||||
) -> threading.Thread:
|
) -> threading.Thread:
|
||||||
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
|
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
|
||||||
|
|
||||||
@@ -511,24 +669,33 @@ def request_image_async(
|
|||||||
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
||||||
|
|
||||||
``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。
|
``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。
|
||||||
|
``upload_image_url`` 传入后启用"图生图"模式。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
sid = session_id or get_session_id()
|
sid = session_id or get_session_id()
|
||||||
cancel = cancel_event
|
cancel = cancel_event
|
||||||
|
ref_url = (upload_image_url or "").strip() or None
|
||||||
|
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
if cancel is not None and cancel.is_set():
|
if cancel is not None and cancel.is_set():
|
||||||
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
|
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
|
||||||
return
|
return
|
||||||
image_url = _call_pqtest_generate(prompt, sid)
|
image_url = _call_pqtest_generate(prompt, sid, upload_image_url=ref_url)
|
||||||
if cancel is not None and cancel.is_set():
|
if cancel is not None and cancel.is_set():
|
||||||
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
|
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
|
||||||
return
|
return
|
||||||
|
extra = {
|
||||||
|
"source": "ai-api",
|
||||||
|
"session_id": sid,
|
||||||
|
"mode": "img2img" if ref_url else "txt2img",
|
||||||
|
}
|
||||||
|
if ref_url:
|
||||||
|
extra["upload_image_url"] = ref_url
|
||||||
record = import_image_from_url(
|
record = import_image_from_url(
|
||||||
image_url=image_url,
|
image_url=image_url,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
extra={"source": "ai-api", "session_id": sid},
|
extra=extra,
|
||||||
base_dir=base_dir,
|
base_dir=base_dir,
|
||||||
)
|
)
|
||||||
if cancel is not None and cancel.is_set():
|
if cancel is not None and cancel.is_set():
|
||||||
@@ -593,6 +760,38 @@ def import_image_from_url_async(
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image_async(
|
||||||
|
file_path: str,
|
||||||
|
on_success: Callable[[str], None],
|
||||||
|
on_error: Callable[[Exception], None],
|
||||||
|
cancel_event: Optional[threading.Event] = None,
|
||||||
|
timeout: float = API_UPLOAD_TIMEOUT,
|
||||||
|
auto_resize: bool = True,
|
||||||
|
) -> threading.Thread:
|
||||||
|
"""后台上传本地图片到后端,成功回调返回 ``upload_image_url``。
|
||||||
|
|
||||||
|
参数:
|
||||||
|
auto_resize: True 时自动缩放超大图片
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _worker():
|
||||||
|
try:
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
return
|
||||||
|
url = _call_pqtest_upload(file_path, timeout=timeout, auto_resize=auto_resize)
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
return
|
||||||
|
on_success(url)
|
||||||
|
except Exception as exc:
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
return
|
||||||
|
on_error(exc)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_worker, daemon=True)
|
||||||
|
t.start()
|
||||||
|
return t
|
||||||
|
|
||||||
|
|
||||||
def is_remote_image_url(value: str) -> bool:
|
def is_remote_image_url(value: str) -> bool:
|
||||||
"""判断输入是否为 http/https 图片地址。"""
|
"""判断输入是否为 http/https 图片地址。"""
|
||||||
url = (value or "").strip()
|
url = (value or "").strip()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
from app.data_range_converter import convert_pattern_params
|
from app.data_range_converter import convert_pattern_params
|
||||||
from app.pq.pq_config import get_pattern
|
from app.pq.pq_config import get_pattern
|
||||||
from drivers.ucd_helpers import send_solid_rgb_pattern
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -31,15 +30,52 @@ class PatternService:
|
|||||||
source_params = self._get_source_pattern_params(mode)
|
source_params = self._get_source_pattern_params(mode)
|
||||||
|
|
||||||
if test_type == "screen_module":
|
if test_type == "screen_module":
|
||||||
|
screen_cfg = self.app.config.current_test_types.get("screen_module", {})
|
||||||
|
color_space = (
|
||||||
|
self.app.screen_module_color_space_var.get()
|
||||||
|
if hasattr(self.app, "screen_module_color_space_var")
|
||||||
|
else screen_cfg.get("colorimetry", "sRGB")
|
||||||
|
)
|
||||||
|
data_range = (
|
||||||
|
self.app.screen_module_data_range_var.get()
|
||||||
|
if hasattr(self.app, "screen_module_data_range_var")
|
||||||
|
else screen_cfg.get("data_range", "Full")
|
||||||
|
)
|
||||||
|
bit_depth = (
|
||||||
|
self.app.screen_module_bit_depth_var.get()
|
||||||
|
if hasattr(self.app, "screen_module_bit_depth_var")
|
||||||
|
else f"{int(screen_cfg.get('bpc', 8))}bit"
|
||||||
|
)
|
||||||
|
output_format = (
|
||||||
|
self.app.screen_module_output_format_var.get()
|
||||||
|
if hasattr(self.app, "screen_module_output_format_var")
|
||||||
|
else screen_cfg.get("color_format", "RGB")
|
||||||
|
)
|
||||||
|
|
||||||
if log_details:
|
if log_details:
|
||||||
self._log("=" * 50, "separator")
|
self._log("=" * 50, "separator")
|
||||||
self._log("设置屏模组信号格式:", "info")
|
self._log("设置屏模组信号格式:", "info")
|
||||||
self._log("=" * 50, "separator")
|
self._log("=" * 50, "separator")
|
||||||
|
for label, value in [
|
||||||
|
("色彩空间", color_space),
|
||||||
|
("色彩格式", output_format),
|
||||||
|
("数据范围", data_range),
|
||||||
|
("编码位深", bit_depth),
|
||||||
|
("Timing", self.app.config.current_test_types[test_type]["timing"]),
|
||||||
|
]:
|
||||||
|
self._log(f" {label}: {value}", "info")
|
||||||
|
self.app.signal_service.apply_config(active_config)
|
||||||
|
success = self.app.signal_service.update_signal_format(
|
||||||
|
color_space=color_space,
|
||||||
|
data_range=data_range,
|
||||||
|
bit_depth=bit_depth,
|
||||||
|
output_format=output_format,
|
||||||
|
)
|
||||||
|
if log_details:
|
||||||
self._log(
|
self._log(
|
||||||
f" Timing: {self.app.config.current_test_types[test_type]['timing']}",
|
f"屏模组信号格式设置{'成功' if success else '失败'}",
|
||||||
"info",
|
"success" if success else "error",
|
||||||
)
|
)
|
||||||
self.app.ucd.set_ucd_params(active_config)
|
|
||||||
|
|
||||||
elif test_type == "sdr_movie":
|
elif test_type == "sdr_movie":
|
||||||
data_range = self.app.sdr_data_range_var.get()
|
data_range = self.app.sdr_data_range_var.get()
|
||||||
@@ -61,12 +97,12 @@ class PatternService:
|
|||||||
active_config = self.app.config.get_temp_config_with_converted_params(
|
active_config = self.app.config.get_temp_config_with_converted_params(
|
||||||
mode=mode, converted_params=converted_params
|
mode=mode, converted_params=converted_params
|
||||||
)
|
)
|
||||||
self.app.ucd.set_ucd_params(active_config)
|
self.app.signal_service.apply_config(active_config)
|
||||||
success = self.app.ucd.apply_signal_format(
|
success = self.app.signal_service.update_signal_format(
|
||||||
color_space=self.app.sdr_color_space_var.get(),
|
color_space=self.app.sdr_color_space_var.get(),
|
||||||
data_range=data_range,
|
data_range=data_range,
|
||||||
bit_depth=self.app.sdr_bit_depth_var.get(),
|
bit_depth=self.app.sdr_bit_depth_var.get(),
|
||||||
color_format=self.app.sdr_output_format_var.get(),
|
output_format=self.app.sdr_output_format_var.get(),
|
||||||
)
|
)
|
||||||
if log_details:
|
if log_details:
|
||||||
self._log(f"SDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error")
|
self._log(f"SDR 信号格式设置{'成功' if success else '失败'}", "success" if success else "error")
|
||||||
@@ -93,12 +129,12 @@ class PatternService:
|
|||||||
active_config = self.app.config.get_temp_config_with_converted_params(
|
active_config = self.app.config.get_temp_config_with_converted_params(
|
||||||
mode=mode, converted_params=converted_params
|
mode=mode, converted_params=converted_params
|
||||||
)
|
)
|
||||||
self.app.ucd.set_ucd_params(active_config)
|
self.app.signal_service.apply_config(active_config)
|
||||||
success = self.app.ucd.apply_signal_format(
|
success = self.app.signal_service.update_signal_format(
|
||||||
color_space=self.app.hdr_color_space_var.get(),
|
color_space=self.app.hdr_color_space_var.get(),
|
||||||
data_range=data_range,
|
data_range=data_range,
|
||||||
bit_depth=self.app.hdr_bit_depth_var.get(),
|
bit_depth=self.app.hdr_bit_depth_var.get(),
|
||||||
color_format=self.app.hdr_output_format_var.get(),
|
output_format=self.app.hdr_output_format_var.get(),
|
||||||
max_cll=self.app.hdr_maxcll_var.get(),
|
max_cll=self.app.hdr_maxcll_var.get(),
|
||||||
max_fall=self.app.hdr_maxfall_var.get(),
|
max_fall=self.app.hdr_maxfall_var.get(),
|
||||||
)
|
)
|
||||||
@@ -124,7 +160,7 @@ class PatternService:
|
|||||||
raise IndexError(f"pattern 索引越界: {index}")
|
raise IndexError(f"pattern 索引越界: {index}")
|
||||||
|
|
||||||
pattern_param = session.pattern_params[index]
|
pattern_param = session.pattern_params[index]
|
||||||
if not self.app.ucd.send_current_pattern_params(pattern_param):
|
if not self.app.signal_service.send_pattern_params(pattern_param):
|
||||||
raise RuntimeError(f"发送 pattern 失败: {index}")
|
raise RuntimeError(f"发送 pattern 失败: {index}")
|
||||||
return pattern_param
|
return pattern_param
|
||||||
|
|
||||||
@@ -135,7 +171,7 @@ class PatternService:
|
|||||||
log_details=False,
|
log_details=False,
|
||||||
)
|
)
|
||||||
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type)
|
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type)
|
||||||
send_solid_rgb_pattern(self.app.ucd, converted_rgb, raise_on_error=True)
|
self.app.signal_service.send_solid_rgb(converted_rgb)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def _get_source_pattern_params(self, mode):
|
def _get_source_pattern_params(self, mode):
|
||||||
|
|||||||
237
app/services/ucd_service.py
Normal file
237
app/services/ucd_service.py
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
"""UCD 信号 / 图案应用服务层。
|
||||||
|
|
||||||
|
服务层是 GUI ↔ Driver 的唯一通道,负责:
|
||||||
|
- 将 UI 字符串("BT.709"、"10bit"、"YCbCr 4:4:4" 等)翻译成 :class:`SignalFormat`;
|
||||||
|
- 将各 panel 的 timing 字符串翻译成 :class:`TimingSpec`;
|
||||||
|
- 协调 :meth:`IUcdDevice.configure` / ``set_pattern`` / ``apply`` 的调用顺序;
|
||||||
|
- 通过 :class:`EventBus` 让 GUI 订阅状态变化,而非主动轮询。
|
||||||
|
|
||||||
|
本层不直接 import UniTAP,也不读取 :mod:`tkinter` 变量;
|
||||||
|
所有输入都是显式参数,便于单测。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from app.ucd_domain import (
|
||||||
|
Colorimetry,
|
||||||
|
DynamicRange,
|
||||||
|
EventBus,
|
||||||
|
PatternKind,
|
||||||
|
PatternSpec,
|
||||||
|
SignalFormat,
|
||||||
|
TimingSpec,
|
||||||
|
UcdError,
|
||||||
|
bit_depth_str_to_bpc,
|
||||||
|
color_space_to_colorimetry,
|
||||||
|
data_range_to_dynamic_range,
|
||||||
|
output_format_to_color_format,
|
||||||
|
parse_timing_str,
|
||||||
|
)
|
||||||
|
from drivers.ucd_driver import IUcdDevice
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 视图字符串 → 值对象 转换工具 ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_signal_format(
|
||||||
|
*,
|
||||||
|
color_space: str,
|
||||||
|
output_format: str,
|
||||||
|
bit_depth: str,
|
||||||
|
data_range: str = "Full",
|
||||||
|
) -> SignalFormat:
|
||||||
|
"""根据下拉框字符串组装 :class:`SignalFormat`。
|
||||||
|
|
||||||
|
各参数解析失败抛 :class:`UcdConfigError`。
|
||||||
|
"""
|
||||||
|
return SignalFormat(
|
||||||
|
color_format=output_format_to_color_format(output_format),
|
||||||
|
colorimetry=color_space_to_colorimetry(color_space),
|
||||||
|
bpc=bit_depth_str_to_bpc(bit_depth),
|
||||||
|
dynamic_range=data_range_to_dynamic_range(data_range),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_timing(timing_str: str) -> TimingSpec:
|
||||||
|
"""``"DMT 3840x2160@60Hz"`` → :class:`TimingSpec`。"""
|
||||||
|
return parse_timing_str(timing_str)
|
||||||
|
|
||||||
|
|
||||||
|
def solid_rgb_pattern(rgb: tuple[int, int, int] | list[int]) -> PatternSpec:
|
||||||
|
r, g, b = rgb[0], rgb[1], rgb[2]
|
||||||
|
return PatternSpec(kind=PatternKind.SOLID, solid_rgb=(int(r), int(g), int(b)))
|
||||||
|
|
||||||
|
|
||||||
|
def image_pattern(path: str) -> PatternSpec:
|
||||||
|
return PatternSpec(kind=PatternKind.IMAGE, image_path=path)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── 服务 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class SignalService:
|
||||||
|
"""协调 SignalFormat / Timing / Pattern 的写入与提交。
|
||||||
|
|
||||||
|
使用线程锁串行化所有对外的 ``apply_*`` 调用,避免多个测试线程
|
||||||
|
同时操作 UCD 造成 SDK 状态错乱。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, device: IUcdDevice, bus: EventBus):
|
||||||
|
self._dev = device
|
||||||
|
self._bus = bus
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
# -- 高层接口 ------------------------------------------------
|
||||||
|
|
||||||
|
def apply(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
signal: SignalFormat,
|
||||||
|
timing: TimingSpec,
|
||||||
|
pattern: PatternSpec,
|
||||||
|
) -> bool:
|
||||||
|
"""一次性提交信号格式 + timing + 图案。
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``format_changed``——本次相对上一次 :meth:`apply` 是否变化。
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
log.info(
|
||||||
|
"SignalService.apply signal=%s timing=%s pattern=%s",
|
||||||
|
signal,
|
||||||
|
timing,
|
||||||
|
pattern.kind.value,
|
||||||
|
)
|
||||||
|
changed = self._dev.configure(signal, timing)
|
||||||
|
self._dev.set_pattern(pattern)
|
||||||
|
self._dev.apply()
|
||||||
|
return changed
|
||||||
|
|
||||||
|
def send_pattern(self, pattern: PatternSpec) -> None:
|
||||||
|
"""在已 configure 的信号上仅更新图案后 apply。"""
|
||||||
|
with self._lock:
|
||||||
|
log.info("SignalService.send_pattern pattern=%s", pattern.kind.value)
|
||||||
|
self._dev.set_pattern(pattern)
|
||||||
|
self._dev.apply()
|
||||||
|
|
||||||
|
def send_solid_rgb(self, rgb: tuple[int, int, int] | list[int]) -> None:
|
||||||
|
self.send_pattern(solid_rgb_pattern(rgb))
|
||||||
|
|
||||||
|
def send_image(self, path: str) -> None:
|
||||||
|
self.send_pattern(image_pattern(path))
|
||||||
|
|
||||||
|
# -- 过渡期 API(Phase 2)-----------------------------------
|
||||||
|
# 现有 GUI 回调以"仅更新信号格式、不切换图案"的方式调用
|
||||||
|
# ``ucd.apply_signal_format(color_space=..., color_format=..., bit_depth=...)``。
|
||||||
|
# 新代码统一通过本方法走 SignalService;内部仍委托给底层
|
||||||
|
# controller 的同名旧接口,迁移完成后将替换为纯净实现。
|
||||||
|
|
||||||
|
def update_signal_format(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
color_space: str,
|
||||||
|
output_format: str,
|
||||||
|
bit_depth: str,
|
||||||
|
data_range: str = "Full",
|
||||||
|
max_cll: int | None = None,
|
||||||
|
max_fall: int | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""仅将信号格式 stage 到 SDK(沿用上一次的 timing),不切换图案。
|
||||||
|
|
||||||
|
UI 字符串先经域层解析做参数校验;解析失败抛 :class:`UcdConfigError`。
|
||||||
|
"""
|
||||||
|
# 解析仅做校验;当前实现走 raw controller 的旧 API
|
||||||
|
_ = build_signal_format(
|
||||||
|
color_space=color_space,
|
||||||
|
output_format=output_format,
|
||||||
|
bit_depth=bit_depth,
|
||||||
|
data_range=data_range,
|
||||||
|
)
|
||||||
|
ctrl = getattr(self._dev, "raw_controller", None)
|
||||||
|
if ctrl is None:
|
||||||
|
raise UcdError("update_signal_format 暂仅支持 UCD323Device")
|
||||||
|
with self._lock:
|
||||||
|
return bool(
|
||||||
|
ctrl.apply_signal_format(
|
||||||
|
color_space=color_space,
|
||||||
|
color_format=output_format,
|
||||||
|
bit_depth=bit_depth,
|
||||||
|
data_range=data_range,
|
||||||
|
max_cll=max_cll,
|
||||||
|
max_fall=max_fall,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- 透传给上层的查询 ---------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def device(self) -> IUcdDevice:
|
||||||
|
return self._dev
|
||||||
|
|
||||||
|
def current_resolution(self) -> tuple[int, int]:
|
||||||
|
return self._dev.current_resolution()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_connected(self) -> bool:
|
||||||
|
"""UCD 设备是否已打开。供 GUI 做前置校验。"""
|
||||||
|
ctrl = getattr(self._dev, "raw_controller", None)
|
||||||
|
return bool(ctrl and getattr(ctrl, "status", False))
|
||||||
|
|
||||||
|
# -- 过渡期 API(Phase 5):config 驱动的写入 -----------------
|
||||||
|
# 现有 GUI / Service 通过 ``PQConfig`` 对象描述当次测试参数,
|
||||||
|
# 由 :class:`UCDController.set_ucd_params` 翻译为色彩/Timing/Pattern。
|
||||||
|
# 在配置层重构落地前,这两个方法作为 SignalService 的统一入口,
|
||||||
|
# 让上层不再直接接触 ``self.app.ucd``。
|
||||||
|
|
||||||
|
def apply_config(self, config) -> bool:
|
||||||
|
"""按 :class:`PQConfig` 写入色彩 / Timing / 当前 Pattern(不 apply 输出)。"""
|
||||||
|
ctrl = getattr(self._dev, "raw_controller", None)
|
||||||
|
if ctrl is None:
|
||||||
|
raise UcdError("apply_config 暂仅支持 UCD323Device")
|
||||||
|
with self._lock:
|
||||||
|
return bool(ctrl.set_ucd_params(config))
|
||||||
|
|
||||||
|
def send_pattern_params(self, params) -> bool:
|
||||||
|
"""以 ``params`` 更新当前 pattern 的参数并 apply。"""
|
||||||
|
ctrl = getattr(self._dev, "raw_controller", None)
|
||||||
|
if ctrl is None:
|
||||||
|
raise UcdError("send_pattern_params 暂仅支持 UCD323Device")
|
||||||
|
with self._lock:
|
||||||
|
return bool(ctrl.send_current_pattern_params(params))
|
||||||
|
|
||||||
|
def apply_and_run(self, config, pattern_params) -> bool:
|
||||||
|
"""``set_ucd_params`` + ``set_pattern`` + ``run`` 的复合操作。
|
||||||
|
|
||||||
|
服务于 custom_template_panel 单步流程。
|
||||||
|
"""
|
||||||
|
ctrl = getattr(self._dev, "raw_controller", None)
|
||||||
|
if ctrl is None:
|
||||||
|
raise UcdError("apply_and_run 暂仅支持 UCD323Device")
|
||||||
|
with self._lock:
|
||||||
|
if not ctrl.set_ucd_params(config):
|
||||||
|
return False
|
||||||
|
if not ctrl.set_pattern(ctrl.current_pattern, pattern_params):
|
||||||
|
return False
|
||||||
|
return bool(ctrl.run())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"SignalService",
|
||||||
|
"build_signal_format",
|
||||||
|
"build_timing",
|
||||||
|
"solid_rgb_pattern",
|
||||||
|
"image_pattern",
|
||||||
|
# 重导出常用域类型方便上层 import 一次到位
|
||||||
|
"SignalFormat",
|
||||||
|
"TimingSpec",
|
||||||
|
"PatternSpec",
|
||||||
|
"PatternKind",
|
||||||
|
"Colorimetry",
|
||||||
|
"DynamicRange",
|
||||||
|
"UcdError",
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Local Dimming 测试逻辑(应用层)。
|
"""Local Dimming 测试逻辑(应用层)。
|
||||||
|
|
||||||
整合自原 drivers/local_dimming_test.py:窗口图片生成与测试主循环
|
整合自原 drivers/local_dimming_test.py:窗口图片生成与测试主循环
|
||||||
直接落在本模块,UCD 通用操作下沉到 drivers.ucd_helpers。
|
直接落在本模块,UCD 通用操作通过 SignalService 完成。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
@@ -18,7 +18,12 @@ from tkinter import filedialog, messagebox
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
@@ -26,6 +31,9 @@ from drivers.ucd_helpers import get_current_resolution, send_image_pattern
|
|||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
DEFAULT_WINDOW_PERCENTAGES = [1, 2, 5, 10, 18, 25, 50, 75, 100]
|
DEFAULT_WINDOW_PERCENTAGES = [1, 2, 5, 10, 18, 25, 50, 75, 100]
|
||||||
|
DEFAULT_CHESSBOARD_GRID = 5
|
||||||
|
INSTANT_PEAK_WINDOW_PERCENTAGE = 10
|
||||||
|
INSTANT_PEAK_CAPTURE_DELAY = 0.5
|
||||||
|
|
||||||
_TEMP_DIR = None
|
_TEMP_DIR = None
|
||||||
_IMAGE_CACHE = {} # {(width, height, percentage): file_path}
|
_IMAGE_CACHE = {} # {(width, height, percentage): file_path}
|
||||||
@@ -84,117 +92,325 @@ def _ensure_window_image(width, height, percentage):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_solid_image(width, height, rgb, name):
|
||||||
|
"""生成或复用纯色 PNG 文件,返回路径。"""
|
||||||
|
rgb = tuple(int(v) for v in rgb)
|
||||||
|
key = ("solid", width, height, rgb)
|
||||||
|
cached = _IMAGE_CACHE.get(key)
|
||||||
|
if cached and os.path.exists(cached):
|
||||||
|
return cached
|
||||||
|
|
||||||
|
arr = np.zeros((height, width, 3), dtype=np.uint8)
|
||||||
|
arr[:, :] = rgb
|
||||||
|
path = os.path.join(_get_temp_dir(), f"{name}_{width}x{height}.png")
|
||||||
|
Image.fromarray(arr, mode="RGB").save(path, format="PNG")
|
||||||
|
_IMAGE_CACHE[key] = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _make_checkerboard_image_array(width, height, grid_size, center_white):
|
||||||
|
"""生成棋盘格图像,保证中心块可切换黑/白。"""
|
||||||
|
image = np.zeros((height, width, 3), dtype=np.uint8)
|
||||||
|
y_edges = np.linspace(0, height, grid_size + 1, dtype=int)
|
||||||
|
x_edges = np.linspace(0, width, grid_size + 1, dtype=int)
|
||||||
|
center_index = grid_size // 2
|
||||||
|
|
||||||
|
for row in range(grid_size):
|
||||||
|
for col in range(grid_size):
|
||||||
|
block_is_white = (row + col) % 2 == 0
|
||||||
|
if not center_white:
|
||||||
|
block_is_white = not block_is_white
|
||||||
|
value = 255 if block_is_white else 0
|
||||||
|
image[
|
||||||
|
y_edges[row]:y_edges[row + 1],
|
||||||
|
x_edges[col]:x_edges[col + 1],
|
||||||
|
] = value
|
||||||
|
|
||||||
|
center_value = 255 if center_white else 0
|
||||||
|
image[
|
||||||
|
y_edges[center_index]:y_edges[center_index + 1],
|
||||||
|
x_edges[center_index]:x_edges[center_index + 1],
|
||||||
|
] = center_value
|
||||||
|
return image
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_checkerboard_image(width, height, grid_size, center_white):
|
||||||
|
"""生成或复用棋盘格 PNG 文件,返回路径。"""
|
||||||
|
key = ("checkerboard", width, height, grid_size, center_white)
|
||||||
|
cached = _IMAGE_CACHE.get(key)
|
||||||
|
if cached and os.path.exists(cached):
|
||||||
|
return cached
|
||||||
|
|
||||||
|
arr = _make_checkerboard_image_array(width, height, grid_size, center_white)
|
||||||
|
center_name = "white_center" if center_white else "black_center"
|
||||||
|
path = os.path.join(
|
||||||
|
_get_temp_dir(),
|
||||||
|
f"checkerboard_{grid_size}x{grid_size}_{center_name}_{width}x{height}.png",
|
||||||
|
)
|
||||||
|
Image.fromarray(arr, mode="RGB").save(path, format="PNG")
|
||||||
|
_IMAGE_CACHE[key] = path
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ld_result_row(test_item, pattern_label, value, x="--", y="--"):
|
||||||
|
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||||||
|
if isinstance(value, (int, float, np.floating)):
|
||||||
|
display_value = f"{float(value):.4f}"
|
||||||
|
else:
|
||||||
|
display_value = str(value)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"test_item": test_item,
|
||||||
|
"pattern": pattern_label,
|
||||||
|
"value": display_value,
|
||||||
|
"x": x if isinstance(x, str) else f"{x:.4f}",
|
||||||
|
"y": y if isinstance(y, str) else f"{y:.4f}",
|
||||||
|
"time": timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _measure_ld_row(self: "PQAutomationApp", test_item, pattern_label):
|
||||||
|
"""读取一次 CA410 数据并包装为表格行。"""
|
||||||
|
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
|
||||||
|
if lv is None:
|
||||||
|
raise RuntimeError(f"{pattern_label} 采集失败")
|
||||||
|
return _build_ld_result_row(test_item, pattern_label, lv, x, y), lv
|
||||||
|
|
||||||
|
|
||||||
|
def _send_ld_image(self: "PQAutomationApp", image_path):
|
||||||
|
self.signal_service.send_image(image_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_ld_ucd_params(self: "PQAutomationApp") -> bool:
|
||||||
|
"""发送 Local Dimming 图案前,按当前测试类型写入 UCD 参数。"""
|
||||||
|
test_type = getattr(self.config, "current_test_type", "screen_module")
|
||||||
|
cfg = self.config.current_test_types.get(test_type, {})
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.signal_service.apply_config(self.config)
|
||||||
|
|
||||||
|
if test_type == "screen_module":
|
||||||
|
ok = self.signal_service.update_signal_format(
|
||||||
|
color_space=(
|
||||||
|
self.screen_module_color_space_var.get()
|
||||||
|
if hasattr(self, "screen_module_color_space_var")
|
||||||
|
else cfg.get("colorimetry", "sRGB")
|
||||||
|
),
|
||||||
|
data_range=(
|
||||||
|
self.screen_module_data_range_var.get()
|
||||||
|
if hasattr(self, "screen_module_data_range_var")
|
||||||
|
else cfg.get("data_range", "Full")
|
||||||
|
),
|
||||||
|
bit_depth=(
|
||||||
|
self.screen_module_bit_depth_var.get()
|
||||||
|
if hasattr(self, "screen_module_bit_depth_var")
|
||||||
|
else f"{int(cfg.get('bpc', 8))}bit"
|
||||||
|
),
|
||||||
|
output_format=(
|
||||||
|
self.screen_module_output_format_var.get()
|
||||||
|
if hasattr(self, "screen_module_output_format_var")
|
||||||
|
else cfg.get("color_format", "RGB")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif test_type == "sdr_movie":
|
||||||
|
ok = self.signal_service.update_signal_format(
|
||||||
|
color_space=(
|
||||||
|
self.sdr_color_space_var.get()
|
||||||
|
if hasattr(self, "sdr_color_space_var")
|
||||||
|
else cfg.get("colorimetry", "sRGB")
|
||||||
|
),
|
||||||
|
data_range=(
|
||||||
|
self.sdr_data_range_var.get()
|
||||||
|
if hasattr(self, "sdr_data_range_var")
|
||||||
|
else cfg.get("data_range", "Full")
|
||||||
|
),
|
||||||
|
bit_depth=(
|
||||||
|
self.sdr_bit_depth_var.get()
|
||||||
|
if hasattr(self, "sdr_bit_depth_var")
|
||||||
|
else f"{int(cfg.get('bpc', 8))}bit"
|
||||||
|
),
|
||||||
|
output_format=(
|
||||||
|
self.sdr_output_format_var.get()
|
||||||
|
if hasattr(self, "sdr_output_format_var")
|
||||||
|
else cfg.get("color_format", "RGB")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif test_type == "hdr_movie":
|
||||||
|
ok = self.signal_service.update_signal_format(
|
||||||
|
color_space=(
|
||||||
|
self.hdr_color_space_var.get()
|
||||||
|
if hasattr(self, "hdr_color_space_var")
|
||||||
|
else cfg.get("colorimetry", "sRGB")
|
||||||
|
),
|
||||||
|
data_range=(
|
||||||
|
self.hdr_data_range_var.get()
|
||||||
|
if hasattr(self, "hdr_data_range_var")
|
||||||
|
else cfg.get("data_range", "Full")
|
||||||
|
),
|
||||||
|
bit_depth=(
|
||||||
|
self.hdr_bit_depth_var.get()
|
||||||
|
if hasattr(self, "hdr_bit_depth_var")
|
||||||
|
else f"{int(cfg.get('bpc', 8))}bit"
|
||||||
|
),
|
||||||
|
output_format=(
|
||||||
|
self.hdr_output_format_var.get()
|
||||||
|
if hasattr(self, "hdr_output_format_var")
|
||||||
|
else cfg.get("color_format", "RGB")
|
||||||
|
),
|
||||||
|
max_cll=(
|
||||||
|
self.hdr_maxcll_var.get()
|
||||||
|
if hasattr(self, "hdr_maxcll_var")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
max_fall=(
|
||||||
|
self.hdr_maxfall_var.get()
|
||||||
|
if hasattr(self, "hdr_maxfall_var")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
elif test_type == "local_dimming":
|
||||||
|
ok = self.signal_service.update_signal_format(
|
||||||
|
color_space=(
|
||||||
|
self.local_dimming_color_space_var.get()
|
||||||
|
if hasattr(self, "local_dimming_color_space_var")
|
||||||
|
else cfg.get("colorimetry", "sRGB")
|
||||||
|
),
|
||||||
|
data_range=(
|
||||||
|
self.local_dimming_data_range_var.get()
|
||||||
|
if hasattr(self, "local_dimming_data_range_var")
|
||||||
|
else cfg.get("data_range", "Full")
|
||||||
|
),
|
||||||
|
bit_depth=(
|
||||||
|
self.local_dimming_bit_depth_var.get()
|
||||||
|
if hasattr(self, "local_dimming_bit_depth_var")
|
||||||
|
else f"{int(cfg.get('bpc', 8))}bit"
|
||||||
|
),
|
||||||
|
output_format=(
|
||||||
|
self.local_dimming_output_format_var.get()
|
||||||
|
if hasattr(self, "local_dimming_output_format_var")
|
||||||
|
else cfg.get("color_format", "RGB")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._dispatch_ui(
|
||||||
|
self.log_gui.log,
|
||||||
|
f"Local Dimming 不支持的测试类型: {test_type}",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not ok:
|
||||||
|
self._dispatch_ui(self.log_gui.log, "Local Dimming UCD 参数设置失败", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
self._dispatch_ui(self.log_gui.log, f"Local Dimming UCD 参数设置异常: {e}", "error")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _run_ld_measurement_step(self: "PQAutomationApp", width, height, wait_time, step, log):
|
||||||
|
label = step["label"]
|
||||||
|
test_item = step["test_item"]
|
||||||
|
kind = step["kind"]
|
||||||
|
|
||||||
|
if kind == "window":
|
||||||
|
percentage = step["percentage"]
|
||||||
|
image_path = _ensure_window_image(width, height, percentage)
|
||||||
|
_send_ld_image(self, image_path)
|
||||||
|
settle_time = wait_time
|
||||||
|
elif kind == "black":
|
||||||
|
image_path = _ensure_solid_image(width, height, (0, 0, 0), "black")
|
||||||
|
_send_ld_image(self, image_path)
|
||||||
|
settle_time = wait_time
|
||||||
|
elif kind == "checkerboard":
|
||||||
|
image_path = _ensure_checkerboard_image(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
DEFAULT_CHESSBOARD_GRID,
|
||||||
|
step["center_white"],
|
||||||
|
)
|
||||||
|
_send_ld_image(self, image_path)
|
||||||
|
settle_time = wait_time
|
||||||
|
elif kind == "instant_peak":
|
||||||
|
black_image = _ensure_solid_image(width, height, (0, 0, 0), "black")
|
||||||
|
peak_image = _ensure_window_image(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
step["percentage"],
|
||||||
|
)
|
||||||
|
_send_ld_image(self, black_image)
|
||||||
|
log(f" 黑场预置 {wait_time:.1f} 秒", level="info")
|
||||||
|
time.sleep(wait_time)
|
||||||
|
_send_ld_image(self, peak_image)
|
||||||
|
settle_time = min(wait_time, INSTANT_PEAK_CAPTURE_DELAY)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"未知 Local Dimming 测试步骤: {kind}")
|
||||||
|
|
||||||
|
log(f" 等待 {settle_time:.1f} 秒后采集...", level="info")
|
||||||
|
time.sleep(settle_time)
|
||||||
|
return _measure_ld_row(self, test_item, label)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_current_ld_pattern(self: "PQAutomationApp", test_item, pattern_label, percentage=None):
|
||||||
|
self.current_ld_test_item = test_item
|
||||||
|
self.current_ld_pattern_label = pattern_label
|
||||||
|
self.current_ld_percentage = percentage
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
# GUI 入口(绑定为 PQAutomationApp 方法)
|
# GUI 入口(绑定为 PQAutomationApp 方法)
|
||||||
# --------------------------------------------------------------------------
|
# --------------------------------------------------------------------------
|
||||||
|
|
||||||
def start_local_dimming_test(self):
|
def start_local_dimming_test(self: "PQAutomationApp"):
|
||||||
"""开始 Local Dimming 测试。"""
|
"""Local Dimming 不再提供自动测试,保留接口仅提示用户使用手动模式。"""
|
||||||
if not self.ca or not self.ucd.status:
|
messagebox.showinfo("提示", "Local Dimming 请使用手动发送图案后再采集亮度")
|
||||||
messagebox.showerror("错误", "请先连接 CA410 和 UCD323")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.ld_start_btn.config(state=tk.DISABLED)
|
|
||||||
self.ld_stop_btn.config(state=tk.NORMAL)
|
|
||||||
self.ld_save_btn.config(state=tk.DISABLED)
|
|
||||||
|
|
||||||
for item in self.ld_tree.get_children():
|
|
||||||
self.ld_tree.delete(item)
|
|
||||||
|
|
||||||
wait_time = float(self.ld_wait_time_var.get())
|
|
||||||
stop_event = threading.Event()
|
|
||||||
self.ld_stop_event = stop_event
|
|
||||||
|
|
||||||
def worker():
|
|
||||||
log = self.log_gui.log
|
|
||||||
log("=" * 60, level="separator")
|
|
||||||
log("开始 Local Dimming 测试", level="info")
|
|
||||||
log("=" * 60, level="separator")
|
|
||||||
|
|
||||||
width, height = get_current_resolution(self.ucd)
|
|
||||||
total = len(DEFAULT_WINDOW_PERCENTAGES)
|
|
||||||
log(f" 分辨率: {width}x{height}", level="info")
|
|
||||||
log(f" 测试窗口: {DEFAULT_WINDOW_PERCENTAGES}", level="info")
|
|
||||||
log(f" 等待时间: {wait_time} 秒", level="info")
|
|
||||||
|
|
||||||
results = []
|
|
||||||
for i, percentage in enumerate(DEFAULT_WINDOW_PERCENTAGES, 1):
|
|
||||||
if stop_event.is_set():
|
|
||||||
log("测试已停止", level="error")
|
|
||||||
break
|
|
||||||
|
|
||||||
log(f"[{i}/{total}] 测试 {percentage}% 窗口...", level="info")
|
|
||||||
try:
|
|
||||||
image_path = _ensure_window_image(width, height, percentage)
|
|
||||||
except Exception as e:
|
|
||||||
log(f" 图像生成失败: {e}", level="error")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not send_image_pattern(self.ucd, image_path):
|
|
||||||
log(f" {percentage}% 窗口发送失败,跳过", level="error")
|
|
||||||
continue
|
|
||||||
|
|
||||||
log(f"等待 {wait_time} 秒...", level="info")
|
|
||||||
time.sleep(wait_time)
|
|
||||||
|
|
||||||
try:
|
|
||||||
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
|
|
||||||
except Exception as e:
|
|
||||||
log(f" 采集亮度异常: {e}", level="error")
|
|
||||||
continue
|
|
||||||
if lv is None:
|
|
||||||
log(f" {percentage}% 窗口采集失败", level="error")
|
|
||||||
continue
|
|
||||||
|
|
||||||
log(f"采集亮度: {lv:.2f} cd/m²", level="info")
|
|
||||||
results.append((percentage, x, y, lv, _X, _Y, _Z))
|
|
||||||
|
|
||||||
log("=" * 60, level="separator")
|
|
||||||
log(f"Local Dimming 测试完成 ({len(results)}/{total})", level="success")
|
|
||||||
log("=" * 60, level="separator")
|
|
||||||
|
|
||||||
self.ld_test_results = results
|
|
||||||
self._dispatch_ui(self.update_ld_results, results)
|
|
||||||
self._dispatch_ui(self.ld_start_btn.config, state=tk.NORMAL)
|
|
||||||
self._dispatch_ui(self.ld_stop_btn.config, state=tk.DISABLED)
|
|
||||||
self._dispatch_ui(self.ld_save_btn.config, state=tk.NORMAL)
|
|
||||||
|
|
||||||
threading.Thread(target=worker, daemon=True).start()
|
|
||||||
|
|
||||||
|
|
||||||
def update_ld_results(self, results):
|
def update_ld_results(self: "PQAutomationApp", results):
|
||||||
"""把批量测试结果填入 Treeview。"""
|
"""把批量测试结果填入 Treeview。"""
|
||||||
for percentage, x, y, lv, _X, _Y, _Z in results:
|
for row in results:
|
||||||
self.ld_tree.insert(
|
self.ld_tree.insert(
|
||||||
"", tk.END,
|
"", tk.END,
|
||||||
values=(f"{percentage}%", f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}"),
|
values=(
|
||||||
|
row["test_item"],
|
||||||
|
row["pattern"],
|
||||||
|
row["value"],
|
||||||
|
row["x"],
|
||||||
|
row["y"],
|
||||||
|
row["time"],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def stop_local_dimming_test(self):
|
def stop_local_dimming_test(self: "PQAutomationApp"):
|
||||||
"""请求停止当前 Local Dimming 测试。"""
|
"""兼容旧接口,无操作。"""
|
||||||
ev = getattr(self, "ld_stop_event", None)
|
return
|
||||||
if ev:
|
|
||||||
ev.set()
|
|
||||||
|
|
||||||
|
|
||||||
def send_ld_window(self, percentage):
|
def send_ld_window(self: "PQAutomationApp", percentage):
|
||||||
"""发送指定百分比的白色窗口(手动模式)。"""
|
"""发送指定百分比的白色窗口(手动模式)。"""
|
||||||
if not self.ucd.status:
|
if not self.signal_service.is_connected:
|
||||||
messagebox.showwarning("警告", "请先连接 UCD323 设备")
|
messagebox.showwarning("警告", "请先连接 UCD323 设备")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info")
|
self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info")
|
||||||
self.current_ld_percentage = percentage
|
_set_current_ld_pattern(self, "峰值亮度", f"{percentage}%窗口", percentage)
|
||||||
|
|
||||||
def send():
|
def send():
|
||||||
width, height = get_current_resolution(self.ucd)
|
if not _apply_ld_ucd_params(self):
|
||||||
|
return
|
||||||
|
width, height = self.signal_service.current_resolution()
|
||||||
try:
|
try:
|
||||||
image_path = _ensure_window_image(width, height, percentage)
|
image_path = _ensure_window_image(width, height, percentage)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
|
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
|
||||||
return
|
return
|
||||||
ok = send_image_pattern(self.ucd, image_path)
|
try:
|
||||||
|
self.signal_service.send_image(image_path)
|
||||||
|
ok = True
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
msg = (
|
msg = (
|
||||||
f"{percentage}% 窗口已发送" if ok
|
f"{percentage}% 窗口已发送" if ok
|
||||||
else f"{percentage}% 窗口发送失败"
|
else f"{percentage}% 窗口发送失败"
|
||||||
@@ -204,12 +420,128 @@ def send_ld_window(self, percentage):
|
|||||||
threading.Thread(target=send, daemon=True).start()
|
threading.Thread(target=send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def measure_ld_luminance(self):
|
def send_ld_checkerboard(self: "PQAutomationApp", center_white):
|
||||||
|
"""发送棋盘格图案(手动模式)。"""
|
||||||
|
if not self.signal_service.is_connected:
|
||||||
|
messagebox.showwarning("警告", "请先连接 UCD323 设备")
|
||||||
|
return
|
||||||
|
|
||||||
|
pattern_label = "棋盘格(中心白)" if center_white else "棋盘格(中心黑)"
|
||||||
|
self.log_gui.log(f"🔲 发送 {pattern_label}...", level="info")
|
||||||
|
_set_current_ld_pattern(self, "棋盘格对比度", pattern_label)
|
||||||
|
|
||||||
|
def send():
|
||||||
|
if not _apply_ld_ucd_params(self):
|
||||||
|
return
|
||||||
|
width, height = self.signal_service.current_resolution()
|
||||||
|
try:
|
||||||
|
image_path = _ensure_checkerboard_image(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
DEFAULT_CHESSBOARD_GRID,
|
||||||
|
center_white,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.signal_service.send_image(image_path)
|
||||||
|
ok = True
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
msg = f"{pattern_label} 已发送" if ok else f"{pattern_label} 发送失败"
|
||||||
|
self._dispatch_ui(self.log_gui.log, msg)
|
||||||
|
|
||||||
|
threading.Thread(target=send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def send_ld_black_pattern(self: "PQAutomationApp"):
|
||||||
|
"""发送全黑图案(手动模式)。"""
|
||||||
|
if not self.signal_service.is_connected:
|
||||||
|
messagebox.showwarning("警告", "请先连接 UCD323 设备")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.log_gui.log("⚫ 发送全黑画面...", level="info")
|
||||||
|
_set_current_ld_pattern(self, "黑电平", "全黑画面")
|
||||||
|
|
||||||
|
def send():
|
||||||
|
if not _apply_ld_ucd_params(self):
|
||||||
|
return
|
||||||
|
width, height = self.signal_service.current_resolution()
|
||||||
|
try:
|
||||||
|
image_path = _ensure_solid_image(width, height, (0, 0, 0), "black")
|
||||||
|
except Exception as e:
|
||||||
|
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.signal_service.send_image(image_path)
|
||||||
|
ok = True
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
msg = "全黑画面已发送" if ok else "全黑画面发送失败"
|
||||||
|
self._dispatch_ui(self.log_gui.log, msg)
|
||||||
|
|
||||||
|
threading.Thread(target=send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def send_ld_instant_peak(self: "PQAutomationApp"):
|
||||||
|
"""发送瞬时峰值亮度图案:先黑场,再切到 10% 窗口并保持。"""
|
||||||
|
if not self.signal_service.is_connected:
|
||||||
|
messagebox.showwarning("警告", "请先连接 UCD323 设备")
|
||||||
|
return
|
||||||
|
|
||||||
|
pattern_label = f"黑场后切 {INSTANT_PEAK_WINDOW_PERCENTAGE}%窗口"
|
||||||
|
self.log_gui.log(f"⚡ 发送瞬时峰值图案: {pattern_label}", level="info")
|
||||||
|
_set_current_ld_pattern(
|
||||||
|
self,
|
||||||
|
"瞬时峰值亮度",
|
||||||
|
pattern_label,
|
||||||
|
INSTANT_PEAK_WINDOW_PERCENTAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def send():
|
||||||
|
if not _apply_ld_ucd_params(self):
|
||||||
|
return
|
||||||
|
width, height = self.signal_service.current_resolution()
|
||||||
|
try:
|
||||||
|
black_image = _ensure_solid_image(width, height, (0, 0, 0), "black")
|
||||||
|
peak_image = _ensure_window_image(
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
INSTANT_PEAK_WINDOW_PERCENTAGE,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self.signal_service.send_image(black_image)
|
||||||
|
time.sleep(INSTANT_PEAK_CAPTURE_DELAY)
|
||||||
|
self.signal_service.send_image(peak_image)
|
||||||
|
ok = True
|
||||||
|
except Exception:
|
||||||
|
ok = False
|
||||||
|
|
||||||
|
msg = (
|
||||||
|
f"瞬时峰值图案已发送,当前保持 {INSTANT_PEAK_WINDOW_PERCENTAGE}%窗口"
|
||||||
|
if ok else
|
||||||
|
"瞬时峰值图案发送失败"
|
||||||
|
)
|
||||||
|
self._dispatch_ui(self.log_gui.log, msg)
|
||||||
|
|
||||||
|
threading.Thread(target=send, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
def measure_ld_luminance(self: "PQAutomationApp"):
|
||||||
"""测量当前显示的亮度并追加一行到 Treeview。"""
|
"""测量当前显示的亮度并追加一行到 Treeview。"""
|
||||||
if not self.ca:
|
if not self.ca:
|
||||||
messagebox.showwarning("警告", "请先连接 CA410 色度计")
|
messagebox.showwarning("警告", "请先连接 CA410 色度计")
|
||||||
return
|
return
|
||||||
if self.current_ld_percentage is None:
|
if getattr(self, "current_ld_pattern_label", None) is None:
|
||||||
messagebox.showinfo("提示", "请先发送一个窗口图案")
|
messagebox.showinfo("提示", "请先发送一个窗口图案")
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -232,8 +564,12 @@ def measure_ld_luminance(self):
|
|||||||
self._dispatch_ui(
|
self._dispatch_ui(
|
||||||
self.ld_tree.insert, "", tk.END,
|
self.ld_tree.insert, "", tk.END,
|
||||||
values=(
|
values=(
|
||||||
f"{self.current_ld_percentage}%",
|
getattr(self, "current_ld_test_item", "手动采集"),
|
||||||
f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}", timestamp,
|
self.current_ld_pattern_label,
|
||||||
|
f"{lv:.4f}",
|
||||||
|
f"{x:.4f}",
|
||||||
|
f"{y:.4f}",
|
||||||
|
timestamp,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
self._dispatch_ui(self.log_gui.log, f"采集完成: {lv:.2f} cd/m²")
|
self._dispatch_ui(self.log_gui.log, f"采集完成: {lv:.2f} cd/m²")
|
||||||
@@ -241,16 +577,18 @@ def measure_ld_luminance(self):
|
|||||||
threading.Thread(target=measure, daemon=True).start()
|
threading.Thread(target=measure, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def clear_ld_records(self):
|
def clear_ld_records(self: "PQAutomationApp"):
|
||||||
"""清空 Treeview 中的测试记录。"""
|
"""清空 Treeview 中的测试记录。"""
|
||||||
for item in self.ld_tree.get_children():
|
for item in self.ld_tree.get_children():
|
||||||
self.ld_tree.delete(item)
|
self.ld_tree.delete(item)
|
||||||
self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --")
|
self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --")
|
||||||
self.current_ld_percentage = None
|
self.current_ld_percentage = None
|
||||||
|
self.current_ld_test_item = None
|
||||||
|
self.current_ld_pattern_label = None
|
||||||
self.log_gui.log("测试记录已清空", level="info")
|
self.log_gui.log("测试记录已清空", level="info")
|
||||||
|
|
||||||
|
|
||||||
def save_local_dimming_results(self):
|
def save_local_dimming_results(self: "PQAutomationApp"):
|
||||||
"""把 Treeview 中的全部记录导出为 CSV。"""
|
"""把 Treeview 中的全部记录导出为 CSV。"""
|
||||||
if len(self.ld_tree.get_children()) == 0:
|
if len(self.ld_tree.get_children()) == 0:
|
||||||
messagebox.showinfo("提示", "没有可保存的数据")
|
messagebox.showinfo("提示", "没有可保存的数据")
|
||||||
@@ -271,7 +609,7 @@ def save_local_dimming_results(self):
|
|||||||
try:
|
try:
|
||||||
with open(save_path, "w", newline="", encoding="utf-8-sig") as f:
|
with open(save_path, "w", newline="", encoding="utf-8-sig") as f:
|
||||||
writer = csv.writer(f)
|
writer = csv.writer(f)
|
||||||
writer.writerow(["窗口百分比", "亮度 (cd/m²)", "x", "y", "时间"])
|
writer.writerow(["测试项目", "图案", "亮度/结果", "x", "y", "时间"])
|
||||||
for item in self.ld_tree.get_children():
|
for item in self.ld_tree.get_children():
|
||||||
writer.writerow(self.ld_tree.item(item, "values"))
|
writer.writerow(self.ld_tree.item(item, "values"))
|
||||||
self.log_gui.log(f"测试结果已保存: {save_path}", level="success")
|
self.log_gui.log(f"测试结果已保存: {save_path}", level="success")
|
||||||
@@ -279,3 +617,19 @@ def save_local_dimming_results(self):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log_gui.log(f"保存失败: {str(e)}", level="error")
|
self.log_gui.log(f"保存失败: {str(e)}", level="error")
|
||||||
messagebox.showerror("错误", f"保存失败: {str(e)}")
|
messagebox.showerror("错误", f"保存失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class LocalDimmingMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
start_local_dimming_test = start_local_dimming_test
|
||||||
|
update_ld_results = update_ld_results
|
||||||
|
stop_local_dimming_test = stop_local_dimming_test
|
||||||
|
send_ld_window = send_ld_window
|
||||||
|
send_ld_checkerboard = send_ld_checkerboard
|
||||||
|
send_ld_black_pattern = send_ld_black_pattern
|
||||||
|
send_ld_instant_peak = send_ld_instant_peak
|
||||||
|
measure_ld_luminance = measure_ld_luminance
|
||||||
|
clear_ld_records = clear_ld_records
|
||||||
|
save_local_dimming_results = save_local_dimming_results
|
||||||
|
|||||||
388
app/ucd_domain.py
Normal file
388
app/ucd_domain.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""UCD 控制 Domain 层。
|
||||||
|
|
||||||
|
纯数据 + 纯函数:枚举、值对象、状态机、错误体系、事件总线、
|
||||||
|
业务字符串解析与映射。本模块**不**依赖 UniTAP / 任何硬件;
|
||||||
|
可用纯单测覆盖。
|
||||||
|
|
||||||
|
文件分区:
|
||||||
|
§1 枚举与值对象
|
||||||
|
§2 状态机
|
||||||
|
§3 错误体系
|
||||||
|
§4 事件总线
|
||||||
|
§5 业务字符串解析 / 映射
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §1 枚举与值对象 ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class Interface(str, Enum):
|
||||||
|
"""UCD 物理输出接口。"""
|
||||||
|
|
||||||
|
HDMI = "HDMI"
|
||||||
|
DP = "DP"
|
||||||
|
USBC = "Type-C"
|
||||||
|
|
||||||
|
|
||||||
|
class ColorFormat(str, Enum):
|
||||||
|
"""像素颜色格式(与 UI 显示字符串解耦的内部表示)。"""
|
||||||
|
|
||||||
|
RGB = "rgb"
|
||||||
|
YCBCR_444 = "ycbcr444"
|
||||||
|
YCBCR_422 = "ycbcr422"
|
||||||
|
YCBCR_420 = "ycbcr420"
|
||||||
|
Y_ONLY = "yonly"
|
||||||
|
IDO_DEFINED = "ido_defined"
|
||||||
|
RAW = "raw"
|
||||||
|
DSC = "dsc"
|
||||||
|
|
||||||
|
|
||||||
|
class Colorimetry(str, Enum):
|
||||||
|
"""色度空间。"""
|
||||||
|
|
||||||
|
SRGB = "sRGB"
|
||||||
|
BT709 = "BT.709"
|
||||||
|
BT601 = "BT.601"
|
||||||
|
BT2020 = "BT.2020"
|
||||||
|
DCI_P3 = "DCI-P3"
|
||||||
|
ADOBE_RGB = "AdobeRGB"
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicRange(str, Enum):
|
||||||
|
FULL = "Full"
|
||||||
|
LIMITED = "Limited"
|
||||||
|
|
||||||
|
|
||||||
|
class TimingStandard(str, Enum):
|
||||||
|
DMT = "dmt"
|
||||||
|
CTA = "cta"
|
||||||
|
CVT = "cvt"
|
||||||
|
OVT = "ovt"
|
||||||
|
|
||||||
|
|
||||||
|
class PatternKind(str, Enum):
|
||||||
|
"""高层图案种类。
|
||||||
|
|
||||||
|
与 UCD 内部 VideoPattern 枚举区分:仅暴露业务上真正用到的几种。
|
||||||
|
"""
|
||||||
|
|
||||||
|
DISABLED = "disabled"
|
||||||
|
SOLID = "solidcolor"
|
||||||
|
SOLID_WHITE = "solidwhite"
|
||||||
|
SOLID_RED = "solidred"
|
||||||
|
SOLID_GREEN = "solidgreen"
|
||||||
|
SOLID_BLUE = "solidblue"
|
||||||
|
COLOR_BARS = "colorbars"
|
||||||
|
CHESSBOARD = "chessboard"
|
||||||
|
WHITE_VSTRIPS = "whitevstrips"
|
||||||
|
GRADIENT_RGB_STRIPES = "gradientrgbstripes"
|
||||||
|
COLOR_RAMP = "colorramp"
|
||||||
|
COLOR_SQUARES = "coloursquares"
|
||||||
|
MOTION = "motionpattern"
|
||||||
|
SQUARE_WINDOW = "squarewindow"
|
||||||
|
IMAGE = "image" # 来自文件路径
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SignalFormat:
|
||||||
|
"""信号格式(color_info 部分)。"""
|
||||||
|
|
||||||
|
color_format: ColorFormat
|
||||||
|
colorimetry: Colorimetry
|
||||||
|
bpc: int # 8 / 10 / 12
|
||||||
|
dynamic_range: DynamicRange = DynamicRange.FULL
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TimingSpec:
|
||||||
|
"""显示 Timing 描述。"""
|
||||||
|
|
||||||
|
standard: TimingStandard
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
refresh_hz: float
|
||||||
|
|
||||||
|
def __str__(self) -> str: # 便于日志
|
||||||
|
return f"{self.standard.value.upper()} {self.width}x{self.height}@{self.refresh_hz:g}Hz"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PatternSpec:
|
||||||
|
"""图案描述。
|
||||||
|
|
||||||
|
联合字段含义:
|
||||||
|
SOLID → solid_rgb=(r,g,b)
|
||||||
|
IMAGE → image_path
|
||||||
|
其它预定义图案 → extras 视具体类型
|
||||||
|
"""
|
||||||
|
|
||||||
|
kind: PatternKind
|
||||||
|
solid_rgb: tuple[int, int, int] | None = None
|
||||||
|
image_path: str | None = None
|
||||||
|
extras: tuple = field(default_factory=tuple)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §2 状态机 ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class UcdState(Enum):
|
||||||
|
CLOSED = 0
|
||||||
|
OPENED = 1 # 设备已打开,未配置信号
|
||||||
|
CONFIGURED = 2 # SignalFormat + Timing 已写入,未 apply
|
||||||
|
APPLIED = 3 # 已 apply,硬件正在输出
|
||||||
|
|
||||||
|
|
||||||
|
_ALLOWED: dict[UcdState, set[UcdState]] = {
|
||||||
|
UcdState.CLOSED: {UcdState.OPENED, UcdState.CLOSED},
|
||||||
|
UcdState.OPENED: {UcdState.CONFIGURED, UcdState.CLOSED, UcdState.OPENED},
|
||||||
|
UcdState.CONFIGURED: {UcdState.CONFIGURED, UcdState.APPLIED, UcdState.OPENED, UcdState.CLOSED},
|
||||||
|
UcdState.APPLIED: {UcdState.CONFIGURED, UcdState.APPLIED, UcdState.OPENED, UcdState.CLOSED},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def assert_transition(curr: UcdState, nxt: UcdState) -> None:
|
||||||
|
"""校验状态转移。非法转移抛 :class:`UcdStateError`。"""
|
||||||
|
if nxt not in _ALLOWED[curr]:
|
||||||
|
raise UcdStateError(f"非法状态转移: {curr.name} -> {nxt.name}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §3 错误体系 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class UcdError(Exception):
|
||||||
|
"""UCD 控制相关错误的基类。"""
|
||||||
|
|
||||||
|
|
||||||
|
class UcdNotConnected(UcdError):
|
||||||
|
"""设备未打开/未连接。"""
|
||||||
|
|
||||||
|
|
||||||
|
class UcdStateError(UcdError):
|
||||||
|
"""状态机非法转移或前置条件不满足。"""
|
||||||
|
|
||||||
|
|
||||||
|
class UcdConfigError(UcdError):
|
||||||
|
"""业务配置参数不合法(如不支持的 color_format、解析失败)。"""
|
||||||
|
|
||||||
|
|
||||||
|
class UcdApplyFailed(UcdError):
|
||||||
|
"""SDK ``apply`` 返回失败或超时。"""
|
||||||
|
|
||||||
|
|
||||||
|
class UcdSdkError(UcdError):
|
||||||
|
"""UniTAP SDK 内部异常的包装;原始异常保存在 ``__cause__``。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §4 事件总线 ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class UcdEvent:
|
||||||
|
"""事件基类。"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConnectionChanged(UcdEvent):
|
||||||
|
connected: bool
|
||||||
|
serial: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SignalApplied(UcdEvent):
|
||||||
|
signal: SignalFormat
|
||||||
|
timing: TimingSpec
|
||||||
|
format_changed: bool # 与上一次 apply 相比 (signal,timing) 是否改变
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PatternApplied(UcdEvent):
|
||||||
|
pattern: PatternSpec
|
||||||
|
|
||||||
|
|
||||||
|
class EventBus:
|
||||||
|
"""极简同步事件总线(按事件类型分发)。
|
||||||
|
|
||||||
|
单线程使用;如需跨线程派发,由订阅者自行 marshal 到 UI 线程。
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._subs: dict[type, list[Callable[[Any], None]]] = {}
|
||||||
|
|
||||||
|
def subscribe(self, evt_type: type, cb: Callable[[Any], None]) -> None:
|
||||||
|
self._subs.setdefault(evt_type, []).append(cb)
|
||||||
|
|
||||||
|
def publish(self, evt: UcdEvent) -> None:
|
||||||
|
for cb in self._subs.get(type(evt), []):
|
||||||
|
try:
|
||||||
|
cb(evt)
|
||||||
|
except Exception: # noqa: BLE001 - 订阅者错误不应影响发布者
|
||||||
|
log.exception("UCD 事件处理器抛出异常: %r", evt)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §5 业务字符串解析 / 映射 ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
_OUTPUT_FORMAT_TO_COLOR_FORMAT: dict[str, ColorFormat] = {
|
||||||
|
"RGB": ColorFormat.RGB,
|
||||||
|
"YCbCr 4:4:4": ColorFormat.YCBCR_444,
|
||||||
|
"YCbCr 4:2:2": ColorFormat.YCBCR_422,
|
||||||
|
"YCbCr 4:2:0": ColorFormat.YCBCR_420,
|
||||||
|
"Y Only": ColorFormat.Y_ONLY,
|
||||||
|
"IDO Defined": ColorFormat.IDO_DEFINED,
|
||||||
|
"RAW": ColorFormat.RAW,
|
||||||
|
"DSC": ColorFormat.DSC,
|
||||||
|
}
|
||||||
|
|
||||||
|
_COLOR_SPACE_TO_COLORIMETRY: dict[str, Colorimetry] = {
|
||||||
|
"sRGB": Colorimetry.SRGB,
|
||||||
|
"BT.709": Colorimetry.BT709,
|
||||||
|
"BT.601": Colorimetry.BT601,
|
||||||
|
"BT.2020": Colorimetry.BT2020,
|
||||||
|
"DCI-P3": Colorimetry.DCI_P3,
|
||||||
|
"AdobeRGB": Colorimetry.ADOBE_RGB,
|
||||||
|
}
|
||||||
|
|
||||||
|
_BIT_DEPTH_STR_TO_BPC: dict[str, int] = {
|
||||||
|
"8bit": 8,
|
||||||
|
"10bit": 10,
|
||||||
|
"12bit": 12,
|
||||||
|
}
|
||||||
|
|
||||||
|
_DATA_RANGE_TO_DYNAMIC_RANGE: dict[str, DynamicRange] = {
|
||||||
|
"Full": DynamicRange.FULL,
|
||||||
|
"Limited": DynamicRange.LIMITED,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def output_format_to_color_format(s: str) -> ColorFormat:
|
||||||
|
"""显示用 ``"YCbCr 4:4:4"`` → :class:`ColorFormat`。未知值视为 RGB。"""
|
||||||
|
return _OUTPUT_FORMAT_TO_COLOR_FORMAT.get(s, ColorFormat.RGB)
|
||||||
|
|
||||||
|
|
||||||
|
def color_space_to_colorimetry(s: str) -> Colorimetry:
|
||||||
|
"""显示用 ``"BT.709"`` → :class:`Colorimetry`。未知抛 :class:`UcdConfigError`。"""
|
||||||
|
if s in _COLOR_SPACE_TO_COLORIMETRY:
|
||||||
|
return _COLOR_SPACE_TO_COLORIMETRY[s]
|
||||||
|
raise UcdConfigError(f"未知色彩空间: {s!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def bit_depth_str_to_bpc(s: str) -> int:
|
||||||
|
"""``"10bit"`` → 10。未知抛 :class:`UcdConfigError`。"""
|
||||||
|
if s in _BIT_DEPTH_STR_TO_BPC:
|
||||||
|
return _BIT_DEPTH_STR_TO_BPC[s]
|
||||||
|
raise UcdConfigError(f"未知位深: {s!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def data_range_to_dynamic_range(s: str) -> DynamicRange:
|
||||||
|
if s in _DATA_RANGE_TO_DYNAMIC_RANGE:
|
||||||
|
return _DATA_RANGE_TO_DYNAMIC_RANGE[s]
|
||||||
|
raise UcdConfigError(f"未知数据范围: {s!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def is_ycbcr(color_format: ColorFormat | str | None) -> bool:
|
||||||
|
"""判断输出是否为 YCbCr 系列。接受 :class:`ColorFormat` 或 UI 字符串。"""
|
||||||
|
if color_format is None:
|
||||||
|
return False
|
||||||
|
if isinstance(color_format, ColorFormat):
|
||||||
|
return color_format in {
|
||||||
|
ColorFormat.YCBCR_444,
|
||||||
|
ColorFormat.YCBCR_422,
|
||||||
|
ColorFormat.YCBCR_420,
|
||||||
|
}
|
||||||
|
return "YCbCr" in str(color_format)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_timing_str(timing_str: str) -> TimingSpec:
|
||||||
|
"""解析 ``"DMT 3840x2160@60Hz"`` 风格的字符串为 :class:`TimingSpec`。
|
||||||
|
|
||||||
|
宽容处理空格 / 大小写 / ``Hz`` 后缀大小写。
|
||||||
|
解析失败抛 :class:`UcdConfigError`。
|
||||||
|
"""
|
||||||
|
if not isinstance(timing_str, str):
|
||||||
|
raise UcdConfigError(f"timing_str 必须是字符串: {timing_str!r}")
|
||||||
|
|
||||||
|
s = " ".join(timing_str.strip().split())
|
||||||
|
s = s.replace(" x", "x").replace("x ", "x")
|
||||||
|
|
||||||
|
parts = s.split(" ", 1)
|
||||||
|
if len(parts) < 2:
|
||||||
|
raise UcdConfigError(f"无法解析 timing: {timing_str!r}")
|
||||||
|
type_str, rest = parts[0].strip().upper(), parts[1].strip()
|
||||||
|
|
||||||
|
if "@" not in rest:
|
||||||
|
raise UcdConfigError(f"无法解析 timing (缺少 '@'): {timing_str!r}")
|
||||||
|
left, right = (p.strip() for p in rest.split("@", 1))
|
||||||
|
|
||||||
|
if "x" not in left:
|
||||||
|
raise UcdConfigError(f"无法解析分辨率 (缺少 'x'): {timing_str!r}")
|
||||||
|
wh = left.split("x")
|
||||||
|
if len(wh) != 2:
|
||||||
|
raise UcdConfigError(f"无法解析分辨率: {timing_str!r}")
|
||||||
|
try:
|
||||||
|
width, height = int(wh[0]), int(wh[1])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UcdConfigError(f"分辨率数字解析失败: {timing_str!r}") from exc
|
||||||
|
|
||||||
|
hz_str = right.replace("Hz", "").replace("HZ", "").strip()
|
||||||
|
try:
|
||||||
|
refresh_hz = float(hz_str)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UcdConfigError(f"刷新率解析失败: {timing_str!r}") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
standard = TimingStandard(type_str.lower())
|
||||||
|
except ValueError as exc:
|
||||||
|
raise UcdConfigError(f"未知的分辨率类型: {type_str!r}") from exc
|
||||||
|
|
||||||
|
return TimingSpec(
|
||||||
|
standard=standard,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
refresh_hz=refresh_hz,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
# §1
|
||||||
|
"Interface",
|
||||||
|
"ColorFormat",
|
||||||
|
"Colorimetry",
|
||||||
|
"DynamicRange",
|
||||||
|
"TimingStandard",
|
||||||
|
"PatternKind",
|
||||||
|
"SignalFormat",
|
||||||
|
"TimingSpec",
|
||||||
|
"PatternSpec",
|
||||||
|
# §2
|
||||||
|
"UcdState",
|
||||||
|
"assert_transition",
|
||||||
|
# §3
|
||||||
|
"UcdError",
|
||||||
|
"UcdNotConnected",
|
||||||
|
"UcdStateError",
|
||||||
|
"UcdConfigError",
|
||||||
|
"UcdApplyFailed",
|
||||||
|
"UcdSdkError",
|
||||||
|
# §4
|
||||||
|
"UcdEvent",
|
||||||
|
"ConnectionChanged",
|
||||||
|
"SignalApplied",
|
||||||
|
"PatternApplied",
|
||||||
|
"EventBus",
|
||||||
|
# §5
|
||||||
|
"output_format_to_color_format",
|
||||||
|
"color_space_to_colorimetry",
|
||||||
|
"bit_depth_str_to_bpc",
|
||||||
|
"data_range_to_dynamic_range",
|
||||||
|
"is_ycbcr",
|
||||||
|
"parse_timing_str",
|
||||||
|
]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""图表框架相关逻辑(Step 3 重构)。
|
"""图表框架相关逻辑(Step 3 重构)。
|
||||||
|
|
||||||
从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app`
|
从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app`
|
||||||
以保留原有 `self.xxx` 属性访问不变。
|
以保留原有 `self.xxx` 属性访问不变。
|
||||||
@@ -10,7 +10,52 @@ import matplotlib.pyplot as plt
|
|||||||
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
|
||||||
from app.views.pq_debug_panel import PQDebugPanel
|
from app.views.pq_debug_panel import PQDebugPanel
|
||||||
|
|
||||||
def init_gamut_chart(self):
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def _result_bg_color() -> str:
|
||||||
|
"""根据当前主题返回结果图背景色。"""
|
||||||
|
try:
|
||||||
|
from app.views.theme_manager import is_dark
|
||||||
|
return "#1B1F24" if is_dark() else "#FFFFFF"
|
||||||
|
except Exception:
|
||||||
|
return "#FFFFFF"
|
||||||
|
|
||||||
|
|
||||||
|
def apply_result_chart_theme(self: "PQAutomationApp"):
|
||||||
|
"""统一刷新结果图画布背景,使其跟随浅/深色主题。"""
|
||||||
|
bg = _result_bg_color()
|
||||||
|
|
||||||
|
chart_pairs = [
|
||||||
|
("gamut_fig", "gamut_canvas"),
|
||||||
|
("gamma_fig", "gamma_canvas"),
|
||||||
|
("eotf_fig", "eotf_canvas"),
|
||||||
|
("cct_fig", "cct_canvas"),
|
||||||
|
("contrast_fig", "contrast_canvas"),
|
||||||
|
("accuracy_fig", "accuracy_canvas"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for fig_attr, canvas_attr in chart_pairs:
|
||||||
|
fig = getattr(self, fig_attr, None)
|
||||||
|
canvas = getattr(self, canvas_attr, None)
|
||||||
|
if fig is not None:
|
||||||
|
fig.patch.set_facecolor(bg)
|
||||||
|
if canvas is not None:
|
||||||
|
try:
|
||||||
|
widget = canvas.get_tk_widget()
|
||||||
|
widget.configure(bg=bg, highlightthickness=0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
canvas.draw_idle()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def init_gamut_chart(self: "PQAutomationApp"):
|
||||||
"""初始化色域图表 - 手动设置subplot位置,完全避免重叠"""
|
"""初始化色域图表 - 手动设置subplot位置,完全避免重叠"""
|
||||||
container = ttk.Frame(self.gamut_chart_frame)
|
container = ttk.Frame(self.gamut_chart_frame)
|
||||||
container.pack(expand=True, fill=tk.BOTH)
|
container.pack(expand=True, fill=tk.BOTH)
|
||||||
@@ -65,7 +110,7 @@ def init_gamut_chart(self):
|
|||||||
self.gamut_canvas.draw()
|
self.gamut_canvas.draw()
|
||||||
|
|
||||||
|
|
||||||
def sync_gamut_toolbar(self):
|
def sync_gamut_toolbar(self: "PQAutomationApp"):
|
||||||
"""将工具栏参考标准按钮同步为当前测试类型的 ref var 值。"""
|
"""将工具栏参考标准按钮同步为当前测试类型的 ref var 值。"""
|
||||||
if not hasattr(self, "_gamut_ref_toolbar_var"):
|
if not hasattr(self, "_gamut_ref_toolbar_var"):
|
||||||
return
|
return
|
||||||
@@ -80,7 +125,7 @@ def sync_gamut_toolbar(self):
|
|||||||
self._gamut_ref_toolbar_var.set(getattr(self, attr).get())
|
self._gamut_ref_toolbar_var.set(getattr(self, attr).get())
|
||||||
|
|
||||||
|
|
||||||
def _on_gamut_toolbar_changed(self, std):
|
def _on_gamut_toolbar_changed(self: "PQAutomationApp", std):
|
||||||
"""用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。"""
|
"""用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。"""
|
||||||
test_type = self.config.current_test_type
|
test_type = self.config.current_test_type
|
||||||
var_map = {
|
var_map = {
|
||||||
@@ -105,7 +150,7 @@ def _on_gamut_toolbar_changed(self, std):
|
|||||||
self.recalculate_gamut()
|
self.recalculate_gamut()
|
||||||
|
|
||||||
|
|
||||||
def init_gamma_chart(self):
|
def init_gamma_chart(self: "PQAutomationApp"):
|
||||||
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格(4列 + 通用说明)"""
|
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格(4列 + 通用说明)"""
|
||||||
container = ttk.Frame(self.gamma_chart_frame)
|
container = ttk.Frame(self.gamma_chart_frame)
|
||||||
container.pack(expand=True, fill=tk.BOTH)
|
container.pack(expand=True, fill=tk.BOTH)
|
||||||
@@ -214,7 +259,7 @@ def init_gamma_chart(self):
|
|||||||
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
|
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
|
||||||
self.gamma_canvas.draw()
|
self.gamma_canvas.draw()
|
||||||
|
|
||||||
def init_eotf_chart(self):
|
def init_eotf_chart(self: "PQAutomationApp"):
|
||||||
"""初始化 EOTF 曲线图表(HDR 专用)- 左侧曲线 + 右侧表格(4列)"""
|
"""初始化 EOTF 曲线图表(HDR 专用)- 左侧曲线 + 右侧表格(4列)"""
|
||||||
container = ttk.Frame(self.eotf_chart_frame)
|
container = ttk.Frame(self.eotf_chart_frame)
|
||||||
container.pack(expand=True, fill=tk.BOTH)
|
container.pack(expand=True, fill=tk.BOTH)
|
||||||
@@ -319,7 +364,7 @@ def init_eotf_chart(self):
|
|||||||
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
|
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
|
||||||
self.eotf_canvas.draw()
|
self.eotf_canvas.draw()
|
||||||
|
|
||||||
def init_cct_chart(self):
|
def init_cct_chart(self: "PQAutomationApp"):
|
||||||
"""初始化色度坐标图表 - 正向横坐标,标题居中最上方"""
|
"""初始化色度坐标图表 - 正向横坐标,标题居中最上方"""
|
||||||
container = ttk.Frame(self.cct_chart_frame)
|
container = ttk.Frame(self.cct_chart_frame)
|
||||||
container.pack(expand=True)
|
container.pack(expand=True)
|
||||||
@@ -364,7 +409,7 @@ def init_cct_chart(self):
|
|||||||
|
|
||||||
self.cct_canvas.draw()
|
self.cct_canvas.draw()
|
||||||
|
|
||||||
def init_contrast_chart(self):
|
def init_contrast_chart(self: "PQAutomationApp"):
|
||||||
"""初始化对比度图表 - 固定大小,居中显示"""
|
"""初始化对比度图表 - 固定大小,居中显示"""
|
||||||
container = ttk.Frame(self.contrast_chart_frame)
|
container = ttk.Frame(self.contrast_chart_frame)
|
||||||
container.pack(expand=True)
|
container.pack(expand=True)
|
||||||
@@ -399,23 +444,30 @@ def init_contrast_chart(self):
|
|||||||
|
|
||||||
self.contrast_canvas.draw()
|
self.contrast_canvas.draw()
|
||||||
|
|
||||||
def init_accuracy_chart(self):
|
def init_accuracy_chart(self: "PQAutomationApp"):
|
||||||
"""初始化色准图表 - 固定大小,居中显示"""
|
"""初始化色准图表 - 固定大小,居中显示"""
|
||||||
container = ttk.Frame(self.accuracy_chart_frame)
|
container = ttk.Frame(self.accuracy_chart_frame)
|
||||||
container.pack(expand=True)
|
container.pack(expand=True, fill=tk.BOTH)
|
||||||
|
container.grid_rowconfigure(0, weight=1)
|
||||||
|
container.grid_rowconfigure(1, weight=0, minsize=220)
|
||||||
|
container.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
# 上方图表优先显示;下方表格固定高度,避免挤占图表区域。
|
||||||
|
plot_container = ttk.Frame(container)
|
||||||
|
plot_container.grid(row=0, column=0, sticky="nsew")
|
||||||
|
|
||||||
|
table_container = ttk.LabelFrame(container, text="色准明细")
|
||||||
|
table_container.grid(row=1, column=0, sticky="ew", padx=4, pady=(2, 4))
|
||||||
|
|
||||||
self.accuracy_fig = plt.Figure(
|
self.accuracy_fig = plt.Figure(
|
||||||
figsize=(10, 6),
|
figsize=(10, 6),
|
||||||
dpi=100,
|
dpi=100,
|
||||||
tight_layout=False,
|
tight_layout=False,
|
||||||
)
|
)
|
||||||
self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=container)
|
self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=plot_container)
|
||||||
|
|
||||||
canvas_widget = self.accuracy_canvas.get_tk_widget()
|
canvas_widget = self.accuracy_canvas.get_tk_widget()
|
||||||
canvas_widget.pack()
|
canvas_widget.pack(fill=tk.BOTH, expand=True)
|
||||||
|
|
||||||
canvas_widget.config(width=1000, height=600)
|
|
||||||
canvas_widget.pack_propagate(False)
|
|
||||||
|
|
||||||
self.accuracy_ax = self.accuracy_fig.add_subplot(111)
|
self.accuracy_ax = self.accuracy_fig.add_subplot(111)
|
||||||
self.accuracy_ax.set_xlim(0, 1)
|
self.accuracy_ax.set_xlim(0, 1)
|
||||||
@@ -433,8 +485,136 @@ def init_accuracy_chart(self):
|
|||||||
)
|
)
|
||||||
|
|
||||||
self.accuracy_canvas.draw()
|
self.accuracy_canvas.draw()
|
||||||
|
self._init_accuracy_result_table(table_container)
|
||||||
|
|
||||||
def clear_chart(self):
|
|
||||||
|
def _init_accuracy_result_table(self: "PQAutomationApp", parent):
|
||||||
|
"""创建色准结果表格(支持横向/纵向滚动)。"""
|
||||||
|
table_wrap = ttk.Frame(parent)
|
||||||
|
table_wrap.pack(fill=tk.BOTH, expand=True, padx=2, pady=2)
|
||||||
|
|
||||||
|
self.accuracy_result_table = ttk.Treeview(
|
||||||
|
table_wrap,
|
||||||
|
show="headings",
|
||||||
|
height=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
x_scroll = ttk.Scrollbar(
|
||||||
|
table_wrap,
|
||||||
|
orient=tk.HORIZONTAL,
|
||||||
|
command=self.accuracy_result_table.xview,
|
||||||
|
)
|
||||||
|
y_scroll = ttk.Scrollbar(
|
||||||
|
table_wrap,
|
||||||
|
orient=tk.VERTICAL,
|
||||||
|
command=self.accuracy_result_table.yview,
|
||||||
|
)
|
||||||
|
self.accuracy_result_table.configure(
|
||||||
|
xscrollcommand=x_scroll.set,
|
||||||
|
yscrollcommand=y_scroll.set,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.accuracy_result_table.grid(row=0, column=0, sticky="nsew")
|
||||||
|
y_scroll.grid(row=0, column=1, sticky="ns")
|
||||||
|
x_scroll.grid(row=1, column=0, sticky="ew")
|
||||||
|
table_wrap.grid_rowconfigure(0, weight=1)
|
||||||
|
table_wrap.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
self.clear_accuracy_result_table()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_accuracy_result_table(self: "PQAutomationApp"):
|
||||||
|
"""清空色准表格并恢复占位内容。"""
|
||||||
|
if not hasattr(self, "accuracy_result_table"):
|
||||||
|
return
|
||||||
|
|
||||||
|
tree = self.accuracy_result_table
|
||||||
|
tree.delete(*tree.get_children())
|
||||||
|
|
||||||
|
columns = ("metric", "value")
|
||||||
|
tree.configure(columns=columns)
|
||||||
|
tree.heading("metric", text="项目")
|
||||||
|
tree.heading("value", text="值")
|
||||||
|
tree.column("metric", width=150, anchor="w", stretch=False)
|
||||||
|
tree.column("value", width=300, anchor="w", stretch=True)
|
||||||
|
|
||||||
|
tree.insert("", tk.END, values=("状态", "等待色准测试数据..."))
|
||||||
|
|
||||||
|
|
||||||
|
def update_accuracy_result_table(self: "PQAutomationApp", accuracy_data, standards):
|
||||||
|
"""更新色准表格:按指标行 + 色块列展示,可横向滚动浏览。"""
|
||||||
|
if not hasattr(self, "accuracy_result_table"):
|
||||||
|
return
|
||||||
|
|
||||||
|
tree = self.accuracy_result_table
|
||||||
|
tree.delete(*tree.get_children())
|
||||||
|
|
||||||
|
color_patches = accuracy_data.get("color_patches", []) or []
|
||||||
|
measurements = accuracy_data.get("color_measurements", []) or []
|
||||||
|
delta_e_values = accuracy_data.get("delta_e_values", []) or []
|
||||||
|
delta_e_itp_values = accuracy_data.get("delta_e_itp_values", []) or []
|
||||||
|
|
||||||
|
if not color_patches:
|
||||||
|
self.clear_accuracy_result_table()
|
||||||
|
return
|
||||||
|
|
||||||
|
columns = ["metric"] + [f"c{i}" for i in range(len(color_patches))]
|
||||||
|
tree.configure(columns=columns)
|
||||||
|
|
||||||
|
tree.heading("metric", text="项目")
|
||||||
|
tree.column("metric", width=140, anchor="w", stretch=False)
|
||||||
|
|
||||||
|
for i, name in enumerate(color_patches):
|
||||||
|
col = f"c{i}"
|
||||||
|
tree.heading(col, text=name)
|
||||||
|
tree.column(col, width=96, anchor="center", stretch=False)
|
||||||
|
|
||||||
|
def fmt(v, digits=4):
|
||||||
|
if isinstance(v, (int, float)):
|
||||||
|
return f"{v:.{digits}f}"
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
row_x = ["x: CIE31"]
|
||||||
|
row_y = ["y: CIE31"]
|
||||||
|
row_Y = ["Y"]
|
||||||
|
row_tx = ["Target x:CIE31"]
|
||||||
|
row_ty = ["Target y:CIE31"]
|
||||||
|
row_de2000 = ["ΔE 2000"]
|
||||||
|
|
||||||
|
include_itp = bool(delta_e_itp_values)
|
||||||
|
row_deitp = ["ΔE ITP"] if include_itp else None
|
||||||
|
|
||||||
|
for i, name in enumerate(color_patches):
|
||||||
|
m = measurements[i] if i < len(measurements) else None
|
||||||
|
sx, sy = standards.get(name, (None, None))
|
||||||
|
|
||||||
|
if m is not None and len(m) >= 3:
|
||||||
|
row_x.append(fmt(m[0], 4))
|
||||||
|
row_y.append(fmt(m[1], 4))
|
||||||
|
row_Y.append(fmt(m[2], 4))
|
||||||
|
else:
|
||||||
|
row_x.append("N/A")
|
||||||
|
row_y.append("N/A")
|
||||||
|
row_Y.append("N/A")
|
||||||
|
|
||||||
|
row_tx.append(fmt(sx, 4))
|
||||||
|
row_ty.append(fmt(sy, 4))
|
||||||
|
|
||||||
|
de = delta_e_values[i] if i < len(delta_e_values) else None
|
||||||
|
row_de2000.append(fmt(de, 4))
|
||||||
|
|
||||||
|
if include_itp and row_deitp is not None:
|
||||||
|
ditp = delta_e_itp_values[i] if i < len(delta_e_itp_values) else None
|
||||||
|
row_deitp.append(fmt(ditp, 4))
|
||||||
|
|
||||||
|
rows = [row_x, row_y, row_Y, row_tx, row_ty, row_de2000]
|
||||||
|
if include_itp and row_deitp is not None:
|
||||||
|
rows.append(row_deitp)
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
tree.insert("", tk.END, values=row)
|
||||||
|
|
||||||
|
def clear_chart(self: "PQAutomationApp"):
|
||||||
"""清空所有图表"""
|
"""清空所有图表"""
|
||||||
|
|
||||||
# ========== 1. 清空色域图表 ==========
|
# ========== 1. 清空色域图表 ==========
|
||||||
@@ -729,7 +909,10 @@ def clear_chart(self):
|
|||||||
|
|
||||||
self.accuracy_canvas.draw()
|
self.accuracy_canvas.draw()
|
||||||
|
|
||||||
def update_chart_tabs_state(self):
|
# 清空色准明细表格
|
||||||
|
self.clear_accuracy_result_table()
|
||||||
|
|
||||||
|
def update_chart_tabs_state(self: "PQAutomationApp"):
|
||||||
"""根据测试项目复选框状态动态增删图表 Tab(保持规范顺序)。
|
"""根据测试项目复选框状态动态增删图表 Tab(保持规范顺序)。
|
||||||
|
|
||||||
- 色域 / Gamma 或 EOTF / 色度一致性 / 对比度 / 色准 全部走动态 add/forget
|
- 色域 / Gamma 或 EOTF / 色度一致性 / 对比度 / 色准 全部走动态 add/forget
|
||||||
@@ -801,7 +984,7 @@ def update_chart_tabs_state(self):
|
|||||||
if hasattr(self, "log_gui"):
|
if hasattr(self, "log_gui"):
|
||||||
self.log_gui.log(f"更新Tab状态失败: {str(e)}", level="error")
|
self.log_gui.log(f"更新Tab状态失败: {str(e)}", level="error")
|
||||||
|
|
||||||
def create_result_chart_frame(self):
|
def create_result_chart_frame(self: "PQAutomationApp"):
|
||||||
"""创建结果图表区域 - 6个独立Tab(Gamma 和 EOTF 分离)"""
|
"""创建结果图表区域 - 6个独立Tab(Gamma 和 EOTF 分离)"""
|
||||||
# 创建Notebook用于图表切换
|
# 创建Notebook用于图表切换
|
||||||
self.chart_notebook = ttk.Notebook(self.result_frame)
|
self.chart_notebook = ttk.Notebook(self.result_frame)
|
||||||
@@ -844,6 +1027,7 @@ def create_result_chart_frame(self):
|
|||||||
self.init_cct_chart()
|
self.init_cct_chart()
|
||||||
self.init_contrast_chart()
|
self.init_contrast_chart()
|
||||||
self.init_accuracy_chart()
|
self.init_accuracy_chart()
|
||||||
|
self.apply_result_chart_theme()
|
||||||
|
|
||||||
# 绑定Tab切换事件
|
# 绑定Tab切换事件
|
||||||
self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed)
|
self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed)
|
||||||
@@ -859,12 +1043,36 @@ def create_result_chart_frame(self):
|
|||||||
# 创建单步调试面板实例
|
# 创建单步调试面板实例
|
||||||
self.debug_panel = PQDebugPanel(self.debug_container, self)
|
self.debug_panel = PQDebugPanel(self.debug_container, self)
|
||||||
|
|
||||||
def on_chart_tab_changed(self, event):
|
def on_chart_tab_changed(self: "PQAutomationApp", event):
|
||||||
"""Tab切换时的事件处理"""
|
"""Tab切换时的事件处理"""
|
||||||
try:
|
try:
|
||||||
self._last_tab_index = self.chart_notebook.index(
|
selected_tab = self.chart_notebook.select()
|
||||||
self.chart_notebook.select()
|
# 在动态 add/forget tab 的过程中,可能短暂出现“无选中页签”。
|
||||||
)
|
if not selected_tab:
|
||||||
|
return
|
||||||
|
self._last_tab_index = self.chart_notebook.index(selected_tab)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error")
|
self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ChartFrameMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
init_gamut_chart = init_gamut_chart
|
||||||
|
sync_gamut_toolbar = sync_gamut_toolbar
|
||||||
|
_on_gamut_toolbar_changed = _on_gamut_toolbar_changed
|
||||||
|
init_gamma_chart = init_gamma_chart
|
||||||
|
init_eotf_chart = init_eotf_chart
|
||||||
|
init_cct_chart = init_cct_chart
|
||||||
|
init_contrast_chart = init_contrast_chart
|
||||||
|
init_accuracy_chart = init_accuracy_chart
|
||||||
|
apply_result_chart_theme = apply_result_chart_theme
|
||||||
|
_init_accuracy_result_table = _init_accuracy_result_table
|
||||||
|
clear_accuracy_result_table = clear_accuracy_result_table
|
||||||
|
update_accuracy_result_table = update_accuracy_result_table
|
||||||
|
clear_chart = clear_chart
|
||||||
|
update_chart_tabs_state = update_chart_tabs_state
|
||||||
|
create_result_chart_frame = create_result_chart_frame
|
||||||
|
on_chart_tab_changed = on_chart_tab_changed
|
||||||
|
|||||||
@@ -1,104 +1,149 @@
|
|||||||
import ttkbootstrap as ttk
|
"""现代化的可折叠面板(取代 v1 的图标按钮版本)。
|
||||||
|
|
||||||
|
升级要点(保留 ``add(child, title=...)`` 旧签名兼容):
|
||||||
|
- header 整条可点击切换展开/收起;
|
||||||
|
- 使用 Unicode chevron (▾/▸),无需 PNG 资源;
|
||||||
|
- 新增 ``preview_textvariable``:折叠时在 header 显示当前配置摘要;
|
||||||
|
- 新增 ``header_actions``:在 header 右侧注入自定义按钮(如顶部工具条)。
|
||||||
|
"""
|
||||||
|
|
||||||
import tkinter
|
import tkinter
|
||||||
from tkinter import ttk
|
from tkinter import ttk
|
||||||
from pathlib import Path
|
|
||||||
from ttkbootstrap import Style
|
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def get_resource_path(relative_path):
|
|
||||||
"""
|
|
||||||
获取资源文件的绝对路径(兼容开发环境和打包后)
|
|
||||||
|
|
||||||
Args:
|
|
||||||
relative_path: 相对路径,如 "assets/icons8_double_up_24px.png"
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: 资源文件的绝对路径
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
# PyInstaller 打包后的临时文件夹路径
|
|
||||||
base_path = sys._MEIPASS
|
|
||||||
except AttributeError:
|
|
||||||
# 开发环境:使用项目根目录
|
|
||||||
# 当前文件: app/views/collapsing_frame.py
|
|
||||||
# 项目根目录: app/views 的祖父目录
|
|
||||||
current_file = os.path.abspath(__file__)
|
|
||||||
views_dir = os.path.dirname(current_file)
|
|
||||||
app_dir = os.path.dirname(views_dir)
|
|
||||||
base_path = os.path.dirname(app_dir)
|
|
||||||
|
|
||||||
return os.path.join(base_path, relative_path)
|
|
||||||
|
|
||||||
|
|
||||||
class CollapsingFrame(ttk.Frame):
|
class CollapsingFrame(ttk.Frame):
|
||||||
"""
|
"""A modern collapsible frame widget."""
|
||||||
A collapsible frame widget that opens and closes with a button click.
|
|
||||||
"""
|
CHEVRON_OPEN = "\u25be" # ▾
|
||||||
|
CHEVRON_CLOSED = "\u25b8" # ▸
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self.columnconfigure(0, weight=1)
|
self.columnconfigure(0, weight=1)
|
||||||
self.cumulative_rows = 0
|
self.cumulative_rows = 0
|
||||||
p = Path(__file__).parent
|
# 兼容旧代码可能引用 self.images
|
||||||
self.images = [
|
self.images: list = []
|
||||||
tkinter.PhotoImage(
|
|
||||||
name="open", file=get_resource_path("assets/icons8_double_up_24px.png")
|
|
||||||
),
|
|
||||||
tkinter.PhotoImage(
|
|
||||||
name="closed",
|
|
||||||
file=get_resource_path("assets/icons8_double_right_24px.png"),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
def add(self, child, title="", style="primary.TButton", **kwargs):
|
# ------------------------------------------------------------------
|
||||||
"""Add a child to the collapsible frame
|
# 公共 API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def add(
|
||||||
|
self,
|
||||||
|
child,
|
||||||
|
title: str = "",
|
||||||
|
style: str = "primary.TButton", # 兼容旧签名(不再使用)
|
||||||
|
preview_textvariable=None,
|
||||||
|
header_actions=None,
|
||||||
|
**kwargs,
|
||||||
|
):
|
||||||
|
"""添加一个子区段到折叠面板。
|
||||||
|
|
||||||
:param ttk.Frame child: the child frame to add to the widget
|
:param child: 必须是一个 ttk.Frame;
|
||||||
:param str title: the title appearing on the collapsible section header
|
:param title: 标题文本;
|
||||||
:param str style: the ttk style to apply to the collapsible section header
|
:param preview_textvariable: 折叠时显示在 header 上的状态摘要 StringVar;
|
||||||
|
:param header_actions: 回调 ``fn(actions_frame)``,可在 header 右侧添加按钮。
|
||||||
"""
|
"""
|
||||||
if child.winfo_class() != "TFrame": # must be a frame
|
if child.winfo_class() != "TFrame":
|
||||||
return
|
return
|
||||||
style_color = style.split(".")[0]
|
|
||||||
frm = ttk.Frame(self, style=f"{style_color}.TFrame")
|
|
||||||
frm.grid(row=self.cumulative_rows, column=0, sticky="ew")
|
|
||||||
|
|
||||||
# header title
|
header = ttk.Frame(self, style="ConfigHeader.TFrame", padding=(12, 6))
|
||||||
lbl = ttk.Label(frm, text=title, style=f"{style_color}.Inverse.TLabel")
|
header.grid(row=self.cumulative_rows, column=0, sticky="ew")
|
||||||
if kwargs.get("textvariable"):
|
header.columnconfigure(1, weight=1)
|
||||||
lbl.configure(textvariable=kwargs.get("textvariable"))
|
|
||||||
lbl.pack(side="left", fill="both", padx=10)
|
|
||||||
|
|
||||||
# header toggle button
|
# 左:chevron + 标题
|
||||||
btn = ttk.Button(
|
title_box = ttk.Frame(header, style="ConfigHeader.TFrame")
|
||||||
frm,
|
title_box.grid(row=0, column=0, sticky="w")
|
||||||
image="open",
|
|
||||||
style=style,
|
chevron = ttk.Label(
|
||||||
command=lambda c=child: self._toggle_open_close(child),
|
title_box, text=self.CHEVRON_OPEN, style="ConfigChevron.TLabel"
|
||||||
)
|
)
|
||||||
btn.pack(side="right")
|
chevron.pack(side="left", padx=(0, 8))
|
||||||
|
|
||||||
|
title_lbl = ttk.Label(title_box, text=title, style="ConfigHeader.TLabel")
|
||||||
|
if kwargs.get("textvariable"):
|
||||||
|
title_lbl.configure(textvariable=kwargs.get("textvariable"))
|
||||||
|
title_lbl.pack(side="left")
|
||||||
|
|
||||||
|
# 中:折叠状态预览
|
||||||
|
preview_lbl = None
|
||||||
|
if preview_textvariable is not None:
|
||||||
|
preview_lbl = ttk.Label(
|
||||||
|
header,
|
||||||
|
textvariable=preview_textvariable,
|
||||||
|
style="ConfigPreview.TLabel",
|
||||||
|
)
|
||||||
|
preview_lbl.grid(row=0, column=1, sticky="w", padx=(16, 8))
|
||||||
|
|
||||||
|
# 右:actions(如顶部工具条按钮)
|
||||||
|
actions_frame = ttk.Frame(header, style="ConfigHeader.TFrame")
|
||||||
|
actions_frame.grid(row=0, column=2, sticky="e")
|
||||||
|
if callable(header_actions):
|
||||||
|
try:
|
||||||
|
header_actions(actions_frame)
|
||||||
|
except Exception:
|
||||||
|
# 注入失败不应影响整体折叠面板渲染
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 整条 header 点击切换
|
||||||
|
clickable = [header, title_box, chevron, title_lbl]
|
||||||
|
if preview_lbl is not None:
|
||||||
|
clickable.append(preview_lbl)
|
||||||
|
for w in clickable:
|
||||||
|
w.bind(
|
||||||
|
"<Button-1>",
|
||||||
|
lambda _e, c=child: self._toggle_open_close(c),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
w.configure(cursor="hand2")
|
||||||
|
except tkinter.TclError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
child._chevron = chevron
|
||||||
|
child._header = header
|
||||||
|
child._preview_lbl = preview_lbl
|
||||||
|
# 兼容旧代码 child.btn.invoke() / child.btn.configure(image=...)
|
||||||
|
child.btn = _HeaderToggleProxy(self, child, chevron)
|
||||||
|
|
||||||
# assign toggle button to child so that it's accesible when toggling (need to change image)
|
|
||||||
child.btn = btn
|
|
||||||
child.grid(row=self.cumulative_rows + 1, column=0, sticky="news")
|
child.grid(row=self.cumulative_rows + 1, column=0, sticky="news")
|
||||||
|
|
||||||
# increment the row assignment
|
|
||||||
self.cumulative_rows += 2
|
self.cumulative_rows += 2
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# 内部实现
|
||||||
|
# ------------------------------------------------------------------
|
||||||
def _toggle_open_close(self, child):
|
def _toggle_open_close(self, child):
|
||||||
"""
|
|
||||||
Open or close the section and change the toggle button image accordingly
|
|
||||||
|
|
||||||
:param ttk.Frame child: the child element to add or remove from grid manager
|
|
||||||
"""
|
|
||||||
if child.winfo_viewable():
|
if child.winfo_viewable():
|
||||||
child.grid_remove()
|
child.grid_remove()
|
||||||
child.btn.configure(image="closed")
|
try:
|
||||||
|
child._chevron.configure(text=self.CHEVRON_CLOSED)
|
||||||
|
except (AttributeError, tkinter.TclError):
|
||||||
|
pass
|
||||||
else:
|
else:
|
||||||
child.grid()
|
child.grid()
|
||||||
child.btn.configure(image="open")
|
try:
|
||||||
|
child._chevron.configure(text=self.CHEVRON_OPEN)
|
||||||
|
except (AttributeError, tkinter.TclError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class _HeaderToggleProxy:
|
||||||
|
"""兼容旧代码:``child.btn.invoke()`` / ``child.btn.configure(image=...)``。"""
|
||||||
|
|
||||||
|
def __init__(self, owner: "CollapsingFrame", child, chevron):
|
||||||
|
self._owner = owner
|
||||||
|
self._child = child
|
||||||
|
self._chevron = chevron
|
||||||
|
|
||||||
|
def invoke(self):
|
||||||
|
self._owner._toggle_open_close(self._child)
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
image = kwargs.get("image")
|
||||||
|
if image == "closed":
|
||||||
|
self._chevron.configure(text=CollapsingFrame.CHEVRON_CLOSED)
|
||||||
|
elif image == "open":
|
||||||
|
self._chevron.configure(text=CollapsingFrame.CHEVRON_OPEN)
|
||||||
|
|
||||||
|
config = configure
|
||||||
|
|
||||||
|
|
||||||
# class Application(tkinter.Tk):
|
# class Application(tkinter.Tk):
|
||||||
|
|||||||
240
app/views/modern_styles.py
Normal file
240
app/views/modern_styles.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
"""现代化 UI 样式注册(跟随 ttkbootstrap 当前主题)。
|
||||||
|
|
||||||
|
由 backgroud_style_set() 调用一次。这里集中定义"配置项卡片化"、
|
||||||
|
"现代化标题栏"、"工具条"、"状态栏" 等所需的所有 ttk Style,
|
||||||
|
保持主题切换时颜色自动跟随。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ttkbootstrap as ttk
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb(h: str) -> tuple[int, int, int]:
|
||||||
|
h = h.lstrip("#")
|
||||||
|
return int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _rgb_to_hex(r: int, g: int, b: int) -> str:
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def _mix(c1: str, c2: str, ratio: float) -> str:
|
||||||
|
"""按 ratio (0~1) 将 c1 与 c2 线性混合。"""
|
||||||
|
r1, g1, b1 = _hex_to_rgb(c1)
|
||||||
|
r2, g2, b2 = _hex_to_rgb(c2)
|
||||||
|
return _rgb_to_hex(
|
||||||
|
int(r1 * (1 - ratio) + r2 * ratio),
|
||||||
|
int(g1 * (1 - ratio) + g2 * ratio),
|
||||||
|
int(b1 * (1 - ratio) + b2 * ratio),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dark(color: str) -> bool:
|
||||||
|
r, g, b = _hex_to_rgb(color)
|
||||||
|
# ITU-R BT.601 亮度
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 < 128
|
||||||
|
|
||||||
|
|
||||||
|
def apply_modern_styles() -> None:
|
||||||
|
"""注册或刷新现代化样式集。可在主题切换后再次调用。"""
|
||||||
|
style = ttk.Style()
|
||||||
|
theme = style.colors # ttkbootstrap.style.Colors
|
||||||
|
|
||||||
|
bg = theme.bg # 主背景
|
||||||
|
fg = theme.fg # 主前景
|
||||||
|
primary = theme.primary
|
||||||
|
secondary = theme.secondary
|
||||||
|
info = theme.info
|
||||||
|
dark = theme.dark
|
||||||
|
border = theme.border
|
||||||
|
inputbg = theme.inputbg
|
||||||
|
|
||||||
|
dark_theme = _is_dark(bg)
|
||||||
|
|
||||||
|
# 卡片背景:在主背景上轻微偏移,营造层级感
|
||||||
|
card_bg = _mix(bg, "#ffffff", 0.04) if dark_theme else _mix(bg, "#000000", 0.025)
|
||||||
|
card_border = _mix(bg, fg, 0.18) if dark_theme else _mix(bg, "#000000", 0.10)
|
||||||
|
# 配置项 header 用 secondary 主题色
|
||||||
|
header_bg = secondary
|
||||||
|
header_fg = "#ffffff" if _is_dark(secondary) else "#1a1a1a"
|
||||||
|
header_hover_bg = _mix(secondary, "#ffffff", 0.08) if _is_dark(secondary) else _mix(secondary, "#000000", 0.08)
|
||||||
|
|
||||||
|
preview_fg = _mix(header_fg, header_bg, 0.35)
|
||||||
|
sidebar_bg = _mix(dark, bg, 0.18) if dark_theme else _mix(primary, "#000000", 0.10)
|
||||||
|
sidebar_hover = _mix(sidebar_bg, "#ffffff", 0.07) if dark_theme else _mix(sidebar_bg, "#000000", 0.06)
|
||||||
|
sidebar_selected = _mix(sidebar_bg, "#ffffff", 0.14) if dark_theme else _mix(sidebar_bg, "#000000", 0.10)
|
||||||
|
# 侧栏背景在浅色主题下也偏深,文字颜色需按侧栏亮度自适应,避免“黑字不明显”。
|
||||||
|
sidebar_fg = "#F4F8FD" if _is_dark(sidebar_bg) else _mix(fg, bg, 0.05)
|
||||||
|
sidebar_muted = _mix(sidebar_fg, sidebar_bg, 0.45)
|
||||||
|
|
||||||
|
# ---------------- 卡片 ----------------
|
||||||
|
style.configure(
|
||||||
|
"Card.TFrame",
|
||||||
|
background=card_bg,
|
||||||
|
bordercolor=card_border,
|
||||||
|
relief="solid",
|
||||||
|
borderwidth=1,
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"CardTitle.TLabel",
|
||||||
|
background=card_bg,
|
||||||
|
foreground=fg,
|
||||||
|
font=("Segoe UI", 10, "bold"),
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"CardBody.TLabel",
|
||||||
|
background=card_bg,
|
||||||
|
foreground=fg,
|
||||||
|
font=("Segoe UI", 9),
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"CardIcon.TLabel",
|
||||||
|
background=card_bg,
|
||||||
|
foreground=info if dark_theme else primary,
|
||||||
|
font=("Segoe UI", 9, "bold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# 内嵌于 Card 的容器(与 Card.TFrame 同背景,无边框)
|
||||||
|
style.configure("CardInner.TFrame", background=card_bg, borderwidth=0)
|
||||||
|
|
||||||
|
# ---------------- 配置项 Header ----------------
|
||||||
|
style.configure(
|
||||||
|
"ConfigHeader.TFrame",
|
||||||
|
background=header_bg,
|
||||||
|
borderwidth=0,
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"ConfigHeaderHover.TFrame",
|
||||||
|
background=header_hover_bg,
|
||||||
|
borderwidth=0,
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"ConfigHeader.TLabel",
|
||||||
|
background=header_bg,
|
||||||
|
foreground=header_fg,
|
||||||
|
font=("Segoe UI", 10, "bold"),
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"ConfigHeaderHover.TLabel",
|
||||||
|
background=header_hover_bg,
|
||||||
|
foreground=header_fg,
|
||||||
|
font=("Segoe UI", 10, "bold"),
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"ConfigChevron.TLabel",
|
||||||
|
background=header_bg,
|
||||||
|
foreground=header_fg,
|
||||||
|
font=("Segoe UI Symbol", 12, "bold"),
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"ConfigPreview.TLabel",
|
||||||
|
background=header_bg,
|
||||||
|
foreground=preview_fg,
|
||||||
|
font=("Segoe UI", 9),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- 顶部工具条 ----------------
|
||||||
|
style.configure("Toolbar.TFrame", background=bg, borderwidth=0)
|
||||||
|
# 工具条上的次要按钮(清理配置等)
|
||||||
|
style.configure(
|
||||||
|
"ToolbarMuted.TButton",
|
||||||
|
font=("Segoe UI", 9),
|
||||||
|
padding=(10, 5),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- 区段标题(侧栏 / 卡片外) ----------------
|
||||||
|
style.configure(
|
||||||
|
"SectionTitle.TLabel",
|
||||||
|
background=bg,
|
||||||
|
foreground=_mix(fg, bg, 0.45),
|
||||||
|
font=("Segoe UI", 8, "bold"),
|
||||||
|
)
|
||||||
|
style.configure("Sidebar.TFrame", background=sidebar_bg, borderwidth=0)
|
||||||
|
# 侧栏内的小区段标题(侧栏背景是 primary)
|
||||||
|
style.configure(
|
||||||
|
"SidebarSection.TLabel",
|
||||||
|
background=sidebar_bg,
|
||||||
|
foreground=sidebar_muted,
|
||||||
|
font=("Segoe UI", 8, "bold"),
|
||||||
|
)
|
||||||
|
# 侧栏顶部品牌区
|
||||||
|
brand_bg = _mix(sidebar_bg, "#ffffff", 0.05) if dark_theme else _mix(sidebar_bg, "#000000", 0.05)
|
||||||
|
style.configure(
|
||||||
|
"SidebarBrand.TFrame",
|
||||||
|
background=brand_bg,
|
||||||
|
borderwidth=0,
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"SidebarBrand.TLabel",
|
||||||
|
background=brand_bg,
|
||||||
|
foreground="#ffffff",
|
||||||
|
font=("Segoe UI Semibold", 12),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- 结果区无边框标题行 ----------------
|
||||||
|
style.configure("ResultHeader.TFrame", background=bg, borderwidth=0)
|
||||||
|
style.configure(
|
||||||
|
"ResultHeader.TLabel",
|
||||||
|
background=bg,
|
||||||
|
foreground=fg,
|
||||||
|
font=("Segoe UI", 11, "bold"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- 状态栏 ----------------
|
||||||
|
statusbar_bg = _mix(bg, "#000000", 0.06) if not dark_theme else _mix(bg, "#ffffff", 0.06)
|
||||||
|
statusbar_fg = _mix(fg, bg, 0.15)
|
||||||
|
style.configure(
|
||||||
|
"StatusBar.TFrame",
|
||||||
|
background=statusbar_bg,
|
||||||
|
borderwidth=0,
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"StatusBar.TLabel",
|
||||||
|
background=statusbar_bg,
|
||||||
|
foreground=statusbar_fg,
|
||||||
|
font=("Segoe UI", 9),
|
||||||
|
padding=(10, 4),
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"StatusBarAccent.TLabel",
|
||||||
|
background=statusbar_bg,
|
||||||
|
foreground=info if dark_theme else primary,
|
||||||
|
font=("Segoe UI", 9, "bold"),
|
||||||
|
padding=(10, 4),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------- Sidebar 按钮(保留兼容名) ----------------
|
||||||
|
style.configure(
|
||||||
|
"Sidebar.TButton",
|
||||||
|
background=sidebar_bg,
|
||||||
|
foreground=sidebar_fg,
|
||||||
|
font=("Segoe UI", 10),
|
||||||
|
padding=(18, 9),
|
||||||
|
borderwidth=0,
|
||||||
|
anchor="w",
|
||||||
|
)
|
||||||
|
style.map(
|
||||||
|
"Sidebar.TButton",
|
||||||
|
background=[
|
||||||
|
("active", sidebar_hover),
|
||||||
|
("pressed", sidebar_selected),
|
||||||
|
],
|
||||||
|
foreground=[("active", "#ffffff" if _is_dark(sidebar_hover) else sidebar_fg)],
|
||||||
|
)
|
||||||
|
style.configure(
|
||||||
|
"SidebarSelected.TButton",
|
||||||
|
background=sidebar_selected,
|
||||||
|
foreground="#ffffff",
|
||||||
|
font=("Segoe UI Semibold", 10),
|
||||||
|
padding=(18, 9),
|
||||||
|
borderwidth=0,
|
||||||
|
anchor="w",
|
||||||
|
)
|
||||||
|
style.map(
|
||||||
|
"SidebarSelected.TButton",
|
||||||
|
background=[
|
||||||
|
("active", _mix(sidebar_selected, "#ffffff", 0.06) if dark_theme else _mix(sidebar_selected, "#000000", 0.06)),
|
||||||
|
("pressed", _mix(sidebar_selected, "#000000", 0.08)),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -5,7 +5,13 @@ register_panel / show_panel / hide_all_panels —— 在右侧栏不同浮动面
|
|||||||
|
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
|
|
||||||
def register_panel(self, panel_name, frame, button, visible_attr):
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def register_panel(self: "PQAutomationApp", panel_name, frame, button, visible_attr):
|
||||||
"""注册一个面板到管理系统"""
|
"""注册一个面板到管理系统"""
|
||||||
self.panels[panel_name] = {
|
self.panels[panel_name] = {
|
||||||
"frame": frame,
|
"frame": frame,
|
||||||
@@ -14,7 +20,7 @@ def register_panel(self, panel_name, frame, button, visible_attr):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def show_panel(self, panel_name):
|
def show_panel(self: "PQAutomationApp", panel_name):
|
||||||
"""显示指定面板,隐藏其他所有面板"""
|
"""显示指定面板,隐藏其他所有面板"""
|
||||||
if panel_name not in self.panels:
|
if panel_name not in self.panels:
|
||||||
return
|
return
|
||||||
@@ -30,10 +36,22 @@ def show_panel(self, panel_name):
|
|||||||
# 显示指定面板
|
# 显示指定面板
|
||||||
panel_info = self.panels[panel_name]
|
panel_info = self.panels[panel_name]
|
||||||
|
|
||||||
# 隐藏主内容区域
|
# 隐藏主内容区域。
|
||||||
self.control_frame_top.pack_forget()
|
# Local Dimming 作为并列测试类型时,需要保留顶部配置区,
|
||||||
self.control_frame_middle.pack_forget()
|
# 让用户在面板上方直接看到并修改配置项。
|
||||||
self.control_frame_bottom.pack_forget()
|
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)
|
panel_info["frame"].pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5)
|
||||||
@@ -47,7 +65,7 @@ def show_panel(self, panel_name):
|
|||||||
self.current_panel = panel_name
|
self.current_panel = panel_name
|
||||||
|
|
||||||
|
|
||||||
def hide_all_panels(self):
|
def hide_all_panels(self: "PQAutomationApp"):
|
||||||
"""隐藏所有面板,显示主内容区域"""
|
"""隐藏所有面板,显示主内容区域"""
|
||||||
# 隐藏所有注册的面板
|
# 隐藏所有注册的面板
|
||||||
for panel_name, panel_info in self.panels.items():
|
for panel_name, panel_info in self.panels.items():
|
||||||
@@ -70,3 +88,12 @@ def hide_all_panels(self):
|
|||||||
self.current_panel = None
|
self.current_panel = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class PanelManagerMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
register_panel = register_panel
|
||||||
|
show_panel = show_panel
|
||||||
|
hide_all_panels = hide_all_panels
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1162
app/views/panels/calman_panel.py
Normal file
1162
app/views/panels/calman_panel.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
"""CCT 参数面板及其处理函数(与主文件重构版本保持同步)。"""
|
"""CCT 参数面板及其处理函数(与主文件重构版本保持同步)。"""
|
||||||
|
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -8,8 +8,22 @@ import ttkbootstrap as ttk
|
|||||||
|
|
||||||
import algorithm.pq_algorithm as pq_algorithm
|
import algorithm.pq_algorithm as pq_algorithm
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
def create_cct_params_frame(self):
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def _theme_colors():
|
||||||
|
style = ttk.Style()
|
||||||
|
colors = style.colors
|
||||||
|
return {
|
||||||
|
"muted": colors.secondary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def create_cct_params_frame(self: "PQAutomationApp"):
|
||||||
"""创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)"""
|
"""创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)"""
|
||||||
|
|
||||||
# ==================== 屏模组色度参数 Frame ====================
|
# ==================== 屏模组色度参数 Frame ====================
|
||||||
@@ -116,7 +130,7 @@ def create_cct_params_frame(self):
|
|||||||
self.cct_params_frame,
|
self.cct_params_frame,
|
||||||
text="提示: 清空输入框将恢复默认值",
|
text="提示: 清空输入框将恢复默认值",
|
||||||
font=("SimHei", 8),
|
font=("SimHei", 8),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
|
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
|
||||||
|
|
||||||
# ==================== SDR 色度参数 Frame ====================
|
# ==================== SDR 色度参数 Frame ====================
|
||||||
@@ -221,7 +235,7 @@ def create_cct_params_frame(self):
|
|||||||
self.sdr_cct_params_frame,
|
self.sdr_cct_params_frame,
|
||||||
text="提示: 清空输入框将恢复默认值",
|
text="提示: 清空输入框将恢复默认值",
|
||||||
font=("SimHei", 8),
|
font=("SimHei", 8),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
|
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
|
||||||
|
|
||||||
# ==================== HDR 色度参数 Frame ====================
|
# ==================== HDR 色度参数 Frame ====================
|
||||||
@@ -326,11 +340,11 @@ def create_cct_params_frame(self):
|
|||||||
self.hdr_cct_params_frame,
|
self.hdr_cct_params_frame,
|
||||||
text="提示: 清空输入框将恢复默认值",
|
text="提示: 清空输入框将恢复默认值",
|
||||||
font=("SimHei", 8),
|
font=("SimHei", 8),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
|
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
|
||||||
|
|
||||||
|
|
||||||
def _get_cct_var_dict(self, test_type):
|
def _get_cct_var_dict(self: "PQAutomationApp", test_type):
|
||||||
"""按测试类型返回 CCT 变量映射。"""
|
"""按测试类型返回 CCT 变量映射。"""
|
||||||
if test_type == "sdr_movie":
|
if test_type == "sdr_movie":
|
||||||
return {
|
return {
|
||||||
@@ -354,7 +368,7 @@ def _get_cct_var_dict(self, test_type):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _parse_cct_float(self, var, default):
|
def _parse_cct_float(self: "PQAutomationApp", var, default):
|
||||||
"""读取并解析 CCT 输入值,失败时回落默认值。"""
|
"""读取并解析 CCT 输入值,失败时回落默认值。"""
|
||||||
try:
|
try:
|
||||||
value = var.get().strip()
|
value = var.get().strip()
|
||||||
@@ -365,7 +379,7 @@ def _parse_cct_float(self, var, default):
|
|||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
def _save_cct_params_for(self, test_type):
|
def _save_cct_params_for(self: "PQAutomationApp", test_type):
|
||||||
"""保存指定测试类型的 CCT 参数。"""
|
"""保存指定测试类型的 CCT 参数。"""
|
||||||
try:
|
try:
|
||||||
default_params = self.config.get_default_cct_params(test_type)
|
default_params = self.config.get_default_cct_params(test_type)
|
||||||
@@ -384,7 +398,7 @@ def _save_cct_params_for(self, test_type):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _handle_cct_focus_out(self, var, default_value, save_func, label):
|
def _handle_cct_focus_out(self: "PQAutomationApp", var, default_value, save_func, label):
|
||||||
"""统一处理 CCT 参数失焦校验并保存。"""
|
"""统一处理 CCT 参数失焦校验并保存。"""
|
||||||
try:
|
try:
|
||||||
value = var.get().strip()
|
value = var.get().strip()
|
||||||
@@ -414,27 +428,27 @@ def _handle_cct_focus_out(self, var, default_value, save_func, label):
|
|||||||
self.log_gui.log(f"处理 {label} 参数失败: {str(e)}", level="error")
|
self.log_gui.log(f"处理 {label} 参数失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
def on_sdr_cct_param_focus_out(self, var, default_value):
|
def on_sdr_cct_param_focus_out(self: "PQAutomationApp", var, default_value):
|
||||||
"""SDR 色度参数失去焦点时的处理。"""
|
"""SDR 色度参数失去焦点时的处理。"""
|
||||||
_handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "SDR")
|
_handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "SDR")
|
||||||
|
|
||||||
|
|
||||||
def save_sdr_cct_params(self):
|
def save_sdr_cct_params(self: "PQAutomationApp"):
|
||||||
"""保存 SDR 色度参数。"""
|
"""保存 SDR 色度参数。"""
|
||||||
_save_cct_params_for(self, "sdr_movie")
|
_save_cct_params_for(self, "sdr_movie")
|
||||||
|
|
||||||
|
|
||||||
def on_hdr_cct_param_focus_out(self, var, default_value):
|
def on_hdr_cct_param_focus_out(self: "PQAutomationApp", var, default_value):
|
||||||
"""HDR 色度参数失去焦点时的处理。"""
|
"""HDR 色度参数失去焦点时的处理。"""
|
||||||
_handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "HDR")
|
_handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "HDR")
|
||||||
|
|
||||||
|
|
||||||
def save_hdr_cct_params(self):
|
def save_hdr_cct_params(self: "PQAutomationApp"):
|
||||||
"""保存 HDR 色度参数。"""
|
"""保存 HDR 色度参数。"""
|
||||||
_save_cct_params_for(self, "hdr_movie")
|
_save_cct_params_for(self, "hdr_movie")
|
||||||
|
|
||||||
|
|
||||||
def recalculate_cct(self):
|
def recalculate_cct(self: "PQAutomationApp"):
|
||||||
"""重新计算并绘制色度图"""
|
"""重新计算并绘制色度图"""
|
||||||
try:
|
try:
|
||||||
# 1. 保存新参数
|
# 1. 保存新参数
|
||||||
@@ -496,7 +510,7 @@ def recalculate_cct(self):
|
|||||||
messagebox.showerror("错误", f"重新计算失败: {str(e)}")
|
messagebox.showerror("错误", f"重新计算失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def recalculate_gamut(self):
|
def recalculate_gamut(self: "PQAutomationApp"):
|
||||||
"""重新计算并绘制色域图(使用新的参考标准)"""
|
"""重新计算并绘制色域图(使用新的参考标准)"""
|
||||||
try:
|
try:
|
||||||
# 1. 收起配置项
|
# 1. 收起配置项
|
||||||
@@ -628,33 +642,23 @@ def recalculate_gamut(self):
|
|||||||
# 10. 重新绘制色域图
|
# 10. 重新绘制色域图
|
||||||
self.plot_gamut(rgb_data, coverage_xy, test_type)
|
self.plot_gamut(rgb_data, coverage_xy, test_type)
|
||||||
|
|
||||||
self.log_gui.log("色域图已重新绘制", level="success")
|
|
||||||
self.log_gui.log("=" * 50, level="separator")
|
|
||||||
|
|
||||||
messagebox.showinfo(
|
|
||||||
"成功",
|
|
||||||
f"色域图已根据新参考标准 {reference_standard} 重新绘制!\n\n"
|
|
||||||
f"XY 覆盖率: {coverage_xy:.1f}%\n"
|
|
||||||
f"UV 覆盖率: {coverage_uv:.1f}%",
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.log_gui.log(f"重新计算失败: {str(e)}", level="error")
|
self.log_gui.log(f"重新计算失败: {str(e)}", level="error")
|
||||||
self.log_gui.log(traceback.format_exc(), level="error")
|
self.log_gui.log(traceback.format_exc(), level="error")
|
||||||
messagebox.showerror("错误", f"重新计算失败: {str(e)}")
|
messagebox.showerror("错误", f"重新计算失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def on_cct_param_focus_out(self, var, default_value):
|
def on_cct_param_focus_out(self: "PQAutomationApp", var, default_value):
|
||||||
"""色度参数失去焦点时的处理 - 空值恢复默认"""
|
"""色度参数失去焦点时的处理 - 空值恢复默认"""
|
||||||
_handle_cct_focus_out(self, var, default_value, self.save_cct_params, "屏模组")
|
_handle_cct_focus_out(self, var, default_value, self.save_cct_params, "屏模组")
|
||||||
|
|
||||||
|
|
||||||
def save_cct_params(self):
|
def save_cct_params(self: "PQAutomationApp"):
|
||||||
"""保存色度参数 - 简化版"""
|
"""保存色度参数 - 简化版"""
|
||||||
_save_cct_params_for(self, self.config.current_test_type)
|
_save_cct_params_for(self, self.config.current_test_type)
|
||||||
|
|
||||||
|
|
||||||
def reload_cct_params(self):
|
def reload_cct_params(self: "PQAutomationApp"):
|
||||||
"""切换测试类型时重新加载色度参数"""
|
"""切换测试类型时重新加载色度参数"""
|
||||||
try:
|
try:
|
||||||
current_type = self.config.current_test_type
|
current_type = self.config.current_test_type
|
||||||
@@ -676,7 +680,7 @@ def reload_cct_params(self):
|
|||||||
self.log_gui.log(f"重新加载色度参数失败: {str(e)}", level="error")
|
self.log_gui.log(f"重新加载色度参数失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
def toggle_cct_params_frame(self):
|
def toggle_cct_params_frame(self: "PQAutomationApp"):
|
||||||
"""根据测试类型和测试项的选中状态显示对应参数框"""
|
"""根据测试类型和测试项的选中状态显示对应参数框"""
|
||||||
selected_items = self.get_selected_test_items()
|
selected_items = self.get_selected_test_items()
|
||||||
current_test_type = self.config.current_test_type
|
current_test_type = self.config.current_test_type
|
||||||
@@ -718,7 +722,7 @@ _GAMUT_REF_CONFIGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _on_gamut_ref_changed(self, test_type, event=None):
|
def _on_gamut_ref_changed(self: "PQAutomationApp", test_type, event=None):
|
||||||
cfg = _GAMUT_REF_CONFIGS[test_type]
|
cfg = _GAMUT_REF_CONFIGS[test_type]
|
||||||
try:
|
try:
|
||||||
new_ref = getattr(self, cfg["var_attr"]).get()
|
new_ref = getattr(self, cfg["var_attr"]).get()
|
||||||
@@ -732,13 +736,38 @@ def _on_gamut_ref_changed(self, test_type, event=None):
|
|||||||
self.log_gui.log(f"保存 {cfg['label']} 色域参考标准失败: {str(e)}", level="error")
|
self.log_gui.log(f"保存 {cfg['label']} 色域参考标准失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
def on_screen_gamut_ref_changed(self, event=None):
|
def on_screen_gamut_ref_changed(self: "PQAutomationApp", event=None):
|
||||||
_on_gamut_ref_changed(self, "screen_module", event)
|
_on_gamut_ref_changed(self, "screen_module", event)
|
||||||
|
|
||||||
|
|
||||||
def on_sdr_gamut_ref_changed(self, event=None):
|
def on_sdr_gamut_ref_changed(self: "PQAutomationApp", event=None):
|
||||||
_on_gamut_ref_changed(self, "sdr_movie", event)
|
_on_gamut_ref_changed(self, "sdr_movie", event)
|
||||||
|
|
||||||
|
|
||||||
def on_hdr_gamut_ref_changed(self, event=None):
|
def on_hdr_gamut_ref_changed(self: "PQAutomationApp", event=None):
|
||||||
_on_gamut_ref_changed(self, "hdr_movie", event)
|
_on_gamut_ref_changed(self, "hdr_movie", event)
|
||||||
|
|
||||||
|
|
||||||
|
class CctPanelMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
create_cct_params_frame = create_cct_params_frame
|
||||||
|
_get_cct_var_dict = _get_cct_var_dict
|
||||||
|
_parse_cct_float = _parse_cct_float
|
||||||
|
_save_cct_params_for = _save_cct_params_for
|
||||||
|
_handle_cct_focus_out = _handle_cct_focus_out
|
||||||
|
on_sdr_cct_param_focus_out = on_sdr_cct_param_focus_out
|
||||||
|
save_sdr_cct_params = save_sdr_cct_params
|
||||||
|
on_hdr_cct_param_focus_out = on_hdr_cct_param_focus_out
|
||||||
|
save_hdr_cct_params = save_hdr_cct_params
|
||||||
|
recalculate_cct = recalculate_cct
|
||||||
|
recalculate_gamut = recalculate_gamut
|
||||||
|
on_cct_param_focus_out = on_cct_param_focus_out
|
||||||
|
save_cct_params = save_cct_params
|
||||||
|
reload_cct_params = reload_cct_params
|
||||||
|
toggle_cct_params_frame = toggle_cct_params_frame
|
||||||
|
_on_gamut_ref_changed = _on_gamut_ref_changed
|
||||||
|
on_screen_gamut_ref_changed = on_screen_gamut_ref_changed
|
||||||
|
on_sdr_gamut_ref_changed = on_sdr_gamut_ref_changed
|
||||||
|
on_hdr_gamut_ref_changed = on_hdr_gamut_ref_changed
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""自定义模板结果面板(Step 6 重构)。"""
|
"""自定义模板结果面板(Step 6 重构)。"""
|
||||||
|
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -11,7 +11,13 @@ import numpy as np
|
|||||||
|
|
||||||
from app.data_range_converter import convert_pattern_params
|
from app.data_range_converter import convert_pattern_params
|
||||||
|
|
||||||
def create_custom_template_result_panel(self):
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def create_custom_template_result_panel(self: "PQAutomationApp"):
|
||||||
"""创建客户模板结果显示区域(黑底表格)"""
|
"""创建客户模板结果显示区域(黑底表格)"""
|
||||||
self.custom_result_frame = ttk.LabelFrame(
|
self.custom_result_frame = ttk.LabelFrame(
|
||||||
self.custom_template_tab_frame, text="客户模板结果显示"
|
self.custom_template_tab_frame, text="客户模板结果显示"
|
||||||
@@ -151,7 +157,7 @@ def create_custom_template_result_panel(self):
|
|||||||
table_container.grid_columnconfigure(0, weight=1)
|
table_container.grid_columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
|
||||||
def show_custom_result_context_menu(self, event):
|
def show_custom_result_context_menu(self: "PQAutomationApp", event):
|
||||||
"""显示客户模板结果右键菜单"""
|
"""显示客户模板结果右键菜单"""
|
||||||
if not hasattr(self, "custom_result_tree") or not hasattr(
|
if not hasattr(self, "custom_result_tree") or not hasattr(
|
||||||
self, "custom_result_menu"
|
self, "custom_result_menu"
|
||||||
@@ -197,7 +203,7 @@ def show_custom_result_context_menu(self, event):
|
|||||||
self.custom_result_menu.grab_release()
|
self.custom_result_menu.grab_release()
|
||||||
|
|
||||||
|
|
||||||
def set_custom_result_table_locked(self, locked):
|
def set_custom_result_table_locked(self: "PQAutomationApp", locked):
|
||||||
"""锁定/解锁客户模板结果表(测试期间禁选择、禁右键)"""
|
"""锁定/解锁客户模板结果表(测试期间禁选择、禁右键)"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -208,7 +214,7 @@ def set_custom_result_table_locked(self, locked):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def start_custom_row_single_step(self):
|
def start_custom_row_single_step(self: "PQAutomationApp"):
|
||||||
"""单步测试当前选中行:发送该行 pattern 并覆盖该行测量结果"""
|
"""单步测试当前选中行:发送该行 pattern 并覆盖该行测量结果"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -252,7 +258,7 @@ def start_custom_row_single_step(self):
|
|||||||
).start()
|
).start()
|
||||||
|
|
||||||
|
|
||||||
def _clear_custom_result_row(self, item_id, row_no):
|
def _clear_custom_result_row(self: "PQAutomationApp", item_id, row_no):
|
||||||
"""单步测试开始前清空指定行的测量数据"""
|
"""单步测试开始前清空指定行的测量数据"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -281,7 +287,7 @@ def _clear_custom_result_row(self, item_id, row_no):
|
|||||||
self.custom_result_tree.see(item_id)
|
self.custom_result_tree.see(item_id)
|
||||||
|
|
||||||
|
|
||||||
def _run_custom_row_single_step(self, item_id, row_no):
|
def _run_custom_row_single_step(self: "PQAutomationApp", item_id, row_no):
|
||||||
"""后台执行客户模板单步测试"""
|
"""后台执行客户模板单步测试"""
|
||||||
try:
|
try:
|
||||||
self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...")
|
self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...")
|
||||||
@@ -310,10 +316,8 @@ def _run_custom_row_single_step(self, item_id, row_no):
|
|||||||
self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围")
|
self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围")
|
||||||
return
|
return
|
||||||
|
|
||||||
self.ucd.set_ucd_params(temp_config)
|
|
||||||
pattern_param = converted_params[row_no - 1]
|
pattern_param = converted_params[row_no - 1]
|
||||||
self.ucd.set_pattern(self.ucd.current_pattern, pattern_param)
|
self.signal_service.apply_and_run(temp_config, pattern_param)
|
||||||
self.ucd.run()
|
|
||||||
|
|
||||||
time.sleep(self.pattern_settle_time)
|
time.sleep(self.pattern_settle_time)
|
||||||
|
|
||||||
@@ -354,7 +358,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
|
|||||||
self._dispatch_ui(self.status_var.set, "单步测试失败")
|
self._dispatch_ui(self.status_var.set, "单步测试失败")
|
||||||
|
|
||||||
|
|
||||||
def _update_custom_result_row(self, item_id, row_no, result_data):
|
def _update_custom_result_row(self: "PQAutomationApp", item_id, row_no, result_data):
|
||||||
"""覆盖更新客户模板结果表中指定行"""
|
"""覆盖更新客户模板结果表中指定行"""
|
||||||
|
|
||||||
def fmt(value, digits=4):
|
def fmt(value, digits=4):
|
||||||
@@ -396,7 +400,7 @@ def _update_custom_result_row(self, item_id, row_no, result_data):
|
|||||||
self.custom_result_tree.item(item_id, values=new_values)
|
self.custom_result_tree.item(item_id, values=new_values)
|
||||||
|
|
||||||
|
|
||||||
def copy_custom_result_table(self):
|
def copy_custom_result_table(self: "PQAutomationApp"):
|
||||||
"""复制客户模板结果表格到剪贴板(不含标题行/No./Pattern)"""
|
"""复制客户模板结果表格到剪贴板(不含标题行/No./Pattern)"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -436,7 +440,7 @@ def copy_custom_result_table(self):
|
|||||||
if hasattr(self, "log_gui"):
|
if hasattr(self, "log_gui"):
|
||||||
self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)", level="success")
|
self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)", level="success")
|
||||||
|
|
||||||
def clear_custom_template_results(self):
|
def clear_custom_template_results(self: "PQAutomationApp"):
|
||||||
"""清空客户模板结果表格"""
|
"""清空客户模板结果表格"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -444,7 +448,7 @@ def clear_custom_template_results(self):
|
|||||||
self.custom_result_tree.delete(item)
|
self.custom_result_tree.delete(item)
|
||||||
|
|
||||||
|
|
||||||
def auto_expand_custom_result_view(self):
|
def auto_expand_custom_result_view(self: "PQAutomationApp"):
|
||||||
"""当客户模板表格有数据时,自动扩展窗口以尽量完整显示所有列"""
|
"""当客户模板表格有数据时,自动扩展窗口以尽量完整显示所有列"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -482,7 +486,7 @@ def auto_expand_custom_result_view(self):
|
|||||||
self.log_gui.log(f"自动扩展客户模板窗口失败: {str(e)}", level="error")
|
self.log_gui.log(f"自动扩展客户模板窗口失败: {str(e)}", level="error")
|
||||||
|
|
||||||
|
|
||||||
def append_custom_template_result(self, row_no, result_data):
|
def append_custom_template_result(self: "PQAutomationApp", row_no, result_data):
|
||||||
"""追加一条客户模板结果到表格"""
|
"""追加一条客户模板结果到表格"""
|
||||||
|
|
||||||
def fmt(value, digits=4):
|
def fmt(value, digits=4):
|
||||||
@@ -525,7 +529,7 @@ def append_custom_template_result(self, row_no, result_data):
|
|||||||
self.auto_expand_custom_result_view()
|
self.auto_expand_custom_result_view()
|
||||||
|
|
||||||
|
|
||||||
def start_custom_template_test(self):
|
def start_custom_template_test(self: "PQAutomationApp"):
|
||||||
"""开始客户模板测试(SDR)"""
|
"""开始客户模板测试(SDR)"""
|
||||||
|
|
||||||
if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
|
if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
|
||||||
@@ -573,7 +577,7 @@ def start_custom_template_test(self):
|
|||||||
self.test_thread.daemon = True
|
self.test_thread.daemon = True
|
||||||
self.test_thread.start()
|
self.test_thread.start()
|
||||||
|
|
||||||
def update_custom_button_visibility(self):
|
def update_custom_button_visibility(self: "PQAutomationApp"):
|
||||||
"""只在 SDR 测试时显示客户模版按钮"""
|
"""只在 SDR 测试时显示客户模版按钮"""
|
||||||
if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"):
|
if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"):
|
||||||
return
|
return
|
||||||
@@ -627,7 +631,7 @@ def update_custom_button_visibility(self):
|
|||||||
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success")
|
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success")
|
||||||
|
|
||||||
|
|
||||||
def export_custom_template_excel(self):
|
def export_custom_template_excel(self: "PQAutomationApp"):
|
||||||
"""将客户模板结果表导出为 Excel 文件(14 列完整数据)"""
|
"""将客户模板结果表导出为 Excel 文件(14 列完整数据)"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -775,7 +779,7 @@ def export_custom_template_excel(self):
|
|||||||
messagebox.showerror("错误", f"导出失败:{str(e)}")
|
messagebox.showerror("错误", f"导出失败:{str(e)}")
|
||||||
|
|
||||||
|
|
||||||
def export_custom_template_charts(self):
|
def export_custom_template_charts(self: "PQAutomationApp"):
|
||||||
"""生成客户模板图表:xy 色度散点图 + Lv 亮度曲线图,保存为 PNG"""
|
"""生成客户模板图表:xy 色度散点图 + Lv 亮度曲线图,保存为 PNG"""
|
||||||
if not hasattr(self, "custom_result_tree"):
|
if not hasattr(self, "custom_result_tree"):
|
||||||
return
|
return
|
||||||
@@ -912,3 +916,24 @@ def export_custom_template_charts(self):
|
|||||||
if hasattr(self, "log_gui"):
|
if hasattr(self, "log_gui"):
|
||||||
self.log_gui.log(f"生成图表失败: {str(e)}", level="error")
|
self.log_gui.log(f"生成图表失败: {str(e)}", level="error")
|
||||||
messagebox.showerror("错误", f"生成图表失败:{str(e)}")
|
messagebox.showerror("错误", f"生成图表失败:{str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class CustomTemplatePanelMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
create_custom_template_result_panel = create_custom_template_result_panel
|
||||||
|
show_custom_result_context_menu = show_custom_result_context_menu
|
||||||
|
set_custom_result_table_locked = set_custom_result_table_locked
|
||||||
|
start_custom_row_single_step = start_custom_row_single_step
|
||||||
|
_clear_custom_result_row = _clear_custom_result_row
|
||||||
|
_run_custom_row_single_step = _run_custom_row_single_step
|
||||||
|
_update_custom_result_row = _update_custom_result_row
|
||||||
|
copy_custom_result_table = copy_custom_result_table
|
||||||
|
clear_custom_template_results = clear_custom_template_results
|
||||||
|
auto_expand_custom_result_view = auto_expand_custom_result_view
|
||||||
|
append_custom_template_result = append_custom_template_result
|
||||||
|
start_custom_template_test = start_custom_template_test
|
||||||
|
update_custom_button_visibility = update_custom_button_visibility
|
||||||
|
export_custom_template_excel = export_custom_template_excel
|
||||||
|
export_custom_template_charts = export_custom_template_charts
|
||||||
|
|||||||
1075
app/views/panels/gamma_pattern_panel.py
Normal file
1075
app/views/panels/gamma_pattern_panel.py
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -11,11 +11,17 @@ from tkinter import filedialog, messagebox
|
|||||||
|
|
||||||
import ttkbootstrap as ttk
|
import ttkbootstrap as ttk
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_TEMPLATE_FILE = "pantone_2670_colors.xlsx"
|
_TEMPLATE_FILE = "pantone_2670_colors.xlsx"
|
||||||
|
|
||||||
|
|
||||||
def create_pantone_baseline_panel(self):
|
def create_pantone_baseline_panel(self: "PQAutomationApp"):
|
||||||
"""创建 Pantone 认证摸底测试面板。"""
|
"""创建 Pantone 认证摸底测试面板。"""
|
||||||
frame = ttk.Frame(self.content_frame)
|
frame = ttk.Frame(self.content_frame)
|
||||||
self.pantone_baseline_frame = frame
|
self.pantone_baseline_frame = frame
|
||||||
@@ -149,12 +155,12 @@ def create_pantone_baseline_panel(self):
|
|||||||
_set_button_states(self)
|
_set_button_states(self)
|
||||||
|
|
||||||
|
|
||||||
def toggle_pantone_baseline_panel(self):
|
def toggle_pantone_baseline_panel(self: "PQAutomationApp"):
|
||||||
"""切换 Pantone 认证摸底测试面板。"""
|
"""切换 Pantone 认证摸底测试面板。"""
|
||||||
self.show_panel("pantone_baseline")
|
self.show_panel("pantone_baseline")
|
||||||
|
|
||||||
|
|
||||||
def _get_settings_dir(self):
|
def _get_settings_dir(self: "PQAutomationApp"):
|
||||||
"""返回 settings 绝对目录,避免依赖当前工作目录。"""
|
"""返回 settings 绝对目录,避免依赖当前工作目录。"""
|
||||||
if getattr(self, "config_file", None):
|
if getattr(self, "config_file", None):
|
||||||
return os.path.dirname(self.config_file)
|
return os.path.dirname(self.config_file)
|
||||||
@@ -168,7 +174,7 @@ def _get_settings_dir(self):
|
|||||||
return os.path.join(base_dir, "settings")
|
return os.path.join(base_dir, "settings")
|
||||||
|
|
||||||
|
|
||||||
def _load_patterns(self):
|
def _load_patterns(self: "PQAutomationApp"):
|
||||||
path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
|
path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
|
||||||
if not os.path.isfile(path):
|
if not os.path.isfile(path):
|
||||||
raise FileNotFoundError(f"未找到模板文件: {path}")
|
raise FileNotFoundError(f"未找到模板文件: {path}")
|
||||||
@@ -201,11 +207,11 @@ def _load_patterns(self):
|
|||||||
return patterns
|
return patterns
|
||||||
|
|
||||||
|
|
||||||
def _start_pantone_baseline(self):
|
def _start_pantone_baseline(self: "PQAutomationApp"):
|
||||||
if self._pantone_running:
|
if self._pantone_running:
|
||||||
messagebox.showinfo("提示", "Pantone 任务正在执行")
|
messagebox.showinfo("提示", "Pantone 任务正在执行")
|
||||||
return
|
return
|
||||||
if not getattr(self, "ucd", None) or not self.ucd.status:
|
if not self.signal_service.is_connected:
|
||||||
messagebox.showwarning("警告", "请先连接 UCD323")
|
messagebox.showwarning("警告", "请先连接 UCD323")
|
||||||
return
|
return
|
||||||
if not getattr(self, "ca", None):
|
if not getattr(self, "ca", None):
|
||||||
@@ -247,14 +253,14 @@ def _start_pantone_baseline(self):
|
|||||||
_launch_worker(self, start_index=0, settle=settle)
|
_launch_worker(self, start_index=0, settle=settle)
|
||||||
|
|
||||||
|
|
||||||
def _resume_pantone_baseline(self):
|
def _resume_pantone_baseline(self: "PQAutomationApp"):
|
||||||
if self._pantone_running:
|
if self._pantone_running:
|
||||||
messagebox.showinfo("提示", "Pantone 任务正在执行")
|
messagebox.showinfo("提示", "Pantone 任务正在执行")
|
||||||
return
|
return
|
||||||
if not self._pantone_paused:
|
if not self._pantone_paused:
|
||||||
messagebox.showinfo("提示", "当前没有可继续的暂停任务")
|
messagebox.showinfo("提示", "当前没有可继续的暂停任务")
|
||||||
return
|
return
|
||||||
if not getattr(self, "ucd", None) or not self.ucd.status:
|
if not self.signal_service.is_connected:
|
||||||
messagebox.showwarning("警告", "请先连接 UCD323")
|
messagebox.showwarning("警告", "请先连接 UCD323")
|
||||||
return
|
return
|
||||||
if not getattr(self, "ca", None):
|
if not getattr(self, "ca", None):
|
||||||
@@ -291,7 +297,7 @@ def _resume_pantone_baseline(self):
|
|||||||
_launch_worker(self, start_index=self._pantone_next_index, settle=settle)
|
_launch_worker(self, start_index=self._pantone_next_index, settle=settle)
|
||||||
|
|
||||||
|
|
||||||
def _launch_worker(self, start_index, settle):
|
def _launch_worker(self: "PQAutomationApp", start_index, settle):
|
||||||
total = self._pantone_target_count or len(self.pantone_patterns)
|
total = self._pantone_target_count or len(self.pantone_patterns)
|
||||||
|
|
||||||
def worker():
|
def worker():
|
||||||
@@ -401,7 +407,7 @@ def _launch_worker(self, start_index, settle):
|
|||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _append_result_row(self, record, total):
|
def _append_result_row(self: "PQAutomationApp", record, total):
|
||||||
self.pantone_tree.insert(
|
self.pantone_tree.insert(
|
||||||
"",
|
"",
|
||||||
tk.END,
|
tk.END,
|
||||||
@@ -423,7 +429,7 @@ def _append_result_row(self, record, total):
|
|||||||
self.pantone_tree.see(children[-1])
|
self.pantone_tree.see(children[-1])
|
||||||
|
|
||||||
|
|
||||||
def _pause_pantone_baseline(self):
|
def _pause_pantone_baseline(self: "PQAutomationApp"):
|
||||||
if not self._pantone_running:
|
if not self._pantone_running:
|
||||||
messagebox.showinfo("提示", "当前没有运行中的任务")
|
messagebox.showinfo("提示", "当前没有运行中的任务")
|
||||||
return
|
return
|
||||||
@@ -433,7 +439,7 @@ def _pause_pantone_baseline(self):
|
|||||||
self._pantone_control_event.set()
|
self._pantone_control_event.set()
|
||||||
|
|
||||||
|
|
||||||
def _end_pantone_baseline(self):
|
def _end_pantone_baseline(self: "PQAutomationApp"):
|
||||||
if self._pantone_running:
|
if self._pantone_running:
|
||||||
self._pantone_stop_requested = True
|
self._pantone_stop_requested = True
|
||||||
self.pantone_status_var.set("结束中...")
|
self.pantone_status_var.set("结束中...")
|
||||||
@@ -448,7 +454,7 @@ def _end_pantone_baseline(self):
|
|||||||
_set_button_states(self)
|
_set_button_states(self)
|
||||||
|
|
||||||
|
|
||||||
def _clear_results(self):
|
def _clear_results(self: "PQAutomationApp"):
|
||||||
if self._pantone_running:
|
if self._pantone_running:
|
||||||
messagebox.showinfo("提示", "任务执行中,无法清空")
|
messagebox.showinfo("提示", "任务执行中,无法清空")
|
||||||
return
|
return
|
||||||
@@ -463,7 +469,7 @@ def _clear_results(self):
|
|||||||
_set_button_states(self)
|
_set_button_states(self)
|
||||||
|
|
||||||
|
|
||||||
def _set_button_states(self):
|
def _set_button_states(self: "PQAutomationApp"):
|
||||||
if self._pantone_running:
|
if self._pantone_running:
|
||||||
self.pantone_start_btn.configure(state=tk.DISABLED)
|
self.pantone_start_btn.configure(state=tk.DISABLED)
|
||||||
self.pantone_pause_btn.configure(state=tk.NORMAL)
|
self.pantone_pause_btn.configure(state=tk.NORMAL)
|
||||||
@@ -479,7 +485,7 @@ def _set_button_states(self):
|
|||||||
self.pantone_resume_btn.configure(state=tk.NORMAL if can_resume else tk.DISABLED)
|
self.pantone_resume_btn.configure(state=tk.NORMAL if can_resume else tk.DISABLED)
|
||||||
|
|
||||||
|
|
||||||
def _save_as_template(self):
|
def _save_as_template(self: "PQAutomationApp"):
|
||||||
if not self.pantone_results:
|
if not self.pantone_results:
|
||||||
messagebox.showinfo("提示", "暂无可导出的结果")
|
messagebox.showinfo("提示", "暂无可导出的结果")
|
||||||
return
|
return
|
||||||
@@ -502,7 +508,7 @@ def _save_as_template(self):
|
|||||||
messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}")
|
messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
def _resolve_results_dir(self):
|
def _resolve_results_dir(self: "PQAutomationApp"):
|
||||||
if getattr(self, "config_file", None):
|
if getattr(self, "config_file", None):
|
||||||
root_dir = os.path.dirname(os.path.dirname(self.config_file))
|
root_dir = os.path.dirname(os.path.dirname(self.config_file))
|
||||||
else:
|
else:
|
||||||
@@ -514,7 +520,7 @@ def _resolve_results_dir(self):
|
|||||||
return results_dir
|
return results_dir
|
||||||
|
|
||||||
|
|
||||||
def _auto_save_template(self):
|
def _auto_save_template(self: "PQAutomationApp"):
|
||||||
results_dir = _resolve_results_dir(self)
|
results_dir = _resolve_results_dir(self)
|
||||||
target_count = len(self.pantone_results)
|
target_count = len(self.pantone_results)
|
||||||
filename = (
|
filename = (
|
||||||
@@ -526,7 +532,7 @@ def _auto_save_template(self):
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
def _write_template_xlsx(self, path):
|
def _write_template_xlsx(self: "PQAutomationApp", path):
|
||||||
# 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。
|
# 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。
|
||||||
template_path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
|
template_path = os.path.join(_get_settings_dir(self), _TEMPLATE_FILE)
|
||||||
from openpyxl import load_workbook, Workbook
|
from openpyxl import load_workbook, Workbook
|
||||||
@@ -560,3 +566,25 @@ def _write_template_xlsx(self, path):
|
|||||||
ws.cell(row=idx, column=6, value=float(item["y"]))
|
ws.cell(row=idx, column=6, value=float(item["y"]))
|
||||||
|
|
||||||
wb.save(path)
|
wb.save(path)
|
||||||
|
|
||||||
|
|
||||||
|
class PantoneBaselinePanelMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
create_pantone_baseline_panel = create_pantone_baseline_panel
|
||||||
|
toggle_pantone_baseline_panel = toggle_pantone_baseline_panel
|
||||||
|
_get_settings_dir = _get_settings_dir
|
||||||
|
_load_patterns = _load_patterns
|
||||||
|
_start_pantone_baseline = _start_pantone_baseline
|
||||||
|
_resume_pantone_baseline = _resume_pantone_baseline
|
||||||
|
_launch_worker = _launch_worker
|
||||||
|
_append_result_row = _append_result_row
|
||||||
|
_pause_pantone_baseline = _pause_pantone_baseline
|
||||||
|
_end_pantone_baseline = _end_pantone_baseline
|
||||||
|
_clear_results = _clear_results
|
||||||
|
_set_button_states = _set_button_states
|
||||||
|
_save_as_template = _save_as_template
|
||||||
|
_resolve_results_dir = _resolve_results_dir
|
||||||
|
_auto_save_template = _auto_save_template
|
||||||
|
_write_template_xlsx = _write_template_xlsx
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""侧边面板(日志 / Local Dimming / 调试)"""
|
"""侧边面板(日志 / Local Dimming / 调试)"""
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
@@ -7,7 +7,13 @@ import ttkbootstrap as ttk
|
|||||||
from app.views.pq_log_gui import PQLogGUI
|
from app.views.pq_log_gui import PQLogGUI
|
||||||
from app.views.pq_debug_panel import PQDebugPanel
|
from app.views.pq_debug_panel import PQDebugPanel
|
||||||
|
|
||||||
def create_log_panel(self):
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
def create_log_panel(self: "PQAutomationApp"):
|
||||||
"""创建日志面板"""
|
"""创建日志面板"""
|
||||||
self.log_frame = ttk.Frame(self.content_frame)
|
self.log_frame = ttk.Frame(self.content_frame)
|
||||||
self.log_gui = PQLogGUI(self.log_frame)
|
self.log_gui = PQLogGUI(self.log_frame)
|
||||||
@@ -22,8 +28,8 @@ def create_log_panel(self):
|
|||||||
) # button会在后面设置
|
) # button会在后面设置
|
||||||
|
|
||||||
|
|
||||||
def create_local_dimming_panel(self):
|
def create_local_dimming_panel(self: "PQAutomationApp"):
|
||||||
"""创建 Local Dimming 测试面板 - 手动控制版"""
|
"""创建 Local Dimming 测试面板。"""
|
||||||
self.local_dimming_frame = ttk.Frame(self.content_frame)
|
self.local_dimming_frame = ttk.Frame(self.content_frame)
|
||||||
|
|
||||||
# 主容器
|
# 主容器
|
||||||
@@ -82,6 +88,52 @@ def create_local_dimming_panel(self):
|
|||||||
width=12,
|
width=12,
|
||||||
).pack(side=tk.LEFT, padx=3)
|
).pack(side=tk.LEFT, padx=3)
|
||||||
|
|
||||||
|
# ==================== 3. 其他手动图案 ====================
|
||||||
|
pattern_frame = ttk.LabelFrame(main_container, text="🧩 其他测试图案", padding=10)
|
||||||
|
pattern_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
|
|
||||||
|
ttk.Label(
|
||||||
|
pattern_frame,
|
||||||
|
text="手动发送棋盘格、瞬时峰值、黑场图案,再点击采集当前亮度",
|
||||||
|
font=("", 9),
|
||||||
|
foreground="#28a745",
|
||||||
|
).pack(pady=(0, 8))
|
||||||
|
|
||||||
|
pattern_row = ttk.Frame(pattern_frame)
|
||||||
|
pattern_row.pack(fill=tk.X)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
pattern_row,
|
||||||
|
text="棋盘格(中心白)",
|
||||||
|
command=lambda: self.send_ld_checkerboard(True),
|
||||||
|
bootstyle="secondary",
|
||||||
|
width=14,
|
||||||
|
).pack(side=tk.LEFT, padx=3)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
pattern_row,
|
||||||
|
text="棋盘格(中心黑)",
|
||||||
|
command=lambda: self.send_ld_checkerboard(False),
|
||||||
|
bootstyle="secondary",
|
||||||
|
width=14,
|
||||||
|
).pack(side=tk.LEFT, padx=3)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
pattern_row,
|
||||||
|
text="瞬时峰值",
|
||||||
|
command=self.send_ld_instant_peak,
|
||||||
|
bootstyle="warning",
|
||||||
|
width=12,
|
||||||
|
).pack(side=tk.LEFT, padx=3)
|
||||||
|
|
||||||
|
ttk.Button(
|
||||||
|
pattern_row,
|
||||||
|
text="全黑画面",
|
||||||
|
command=self.send_ld_black_pattern,
|
||||||
|
bootstyle="dark",
|
||||||
|
width=12,
|
||||||
|
).pack(side=tk.LEFT, padx=3)
|
||||||
|
|
||||||
# ==================== 4. CA410 采集按钮 ====================
|
# ==================== 4. CA410 采集按钮 ====================
|
||||||
measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10)
|
measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10)
|
||||||
measure_frame.pack(fill=tk.X, pady=(0, 10))
|
measure_frame.pack(fill=tk.X, pady=(0, 10))
|
||||||
@@ -112,15 +164,19 @@ def create_local_dimming_panel(self):
|
|||||||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
|
||||||
|
|
||||||
# Treeview
|
# Treeview
|
||||||
columns = ("窗口百分比", "亮度 (cd/m²)", "x", "y", "时间")
|
columns = ("测试项目", "图案", "亮度/结果", "x", "y", "时间")
|
||||||
self.ld_tree = ttk.Treeview(
|
self.ld_tree = ttk.Treeview(
|
||||||
result_frame, columns=columns, show="headings", height=10
|
result_frame, columns=columns, show="headings", height=10
|
||||||
)
|
)
|
||||||
|
|
||||||
for col in columns:
|
for col in columns:
|
||||||
self.ld_tree.heading(col, text=col)
|
self.ld_tree.heading(col, text=col)
|
||||||
if col == "窗口百分比":
|
if col == "测试项目":
|
||||||
self.ld_tree.column(col, width=100, anchor=tk.CENTER)
|
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
|
||||||
|
elif col == "图案":
|
||||||
|
self.ld_tree.column(col, width=140, anchor=tk.CENTER)
|
||||||
|
elif col == "亮度/结果":
|
||||||
|
self.ld_tree.column(col, width=110, anchor=tk.CENTER)
|
||||||
elif col == "时间":
|
elif col == "时间":
|
||||||
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
|
self.ld_tree.column(col, width=120, anchor=tk.CENTER)
|
||||||
else:
|
else:
|
||||||
@@ -170,14 +226,16 @@ def create_local_dimming_panel(self):
|
|||||||
|
|
||||||
# 初始化当前窗口百分比(用于记录)
|
# 初始化当前窗口百分比(用于记录)
|
||||||
self.current_ld_percentage = None
|
self.current_ld_percentage = None
|
||||||
|
self.current_ld_test_item = None
|
||||||
|
self.current_ld_pattern_label = None
|
||||||
|
|
||||||
|
|
||||||
def toggle_local_dimming_panel(self):
|
def toggle_local_dimming_panel(self: "PQAutomationApp"):
|
||||||
"""切换 Local Dimming 面板显示"""
|
"""切换 Local Dimming 面板显示"""
|
||||||
self.show_panel("local_dimming")
|
self.show_panel("local_dimming")
|
||||||
|
|
||||||
|
|
||||||
def toggle_log_panel(self):
|
def toggle_log_panel(self: "PQAutomationApp"):
|
||||||
"""切换日志面板的显示状态"""
|
"""切换日志面板的显示状态"""
|
||||||
self.show_panel("log")
|
self.show_panel("log")
|
||||||
|
|
||||||
@@ -226,7 +284,7 @@ DEBUG_PANEL_CONFIGS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _toggle_debug_panel(self, test_type):
|
def _toggle_debug_panel(self: "PQAutomationApp", test_type):
|
||||||
"""打开/关闭对应测试类型的单步调试面板(独立窗口)。"""
|
"""打开/关闭对应测试类型的单步调试面板(独立窗口)。"""
|
||||||
cfg = DEBUG_PANEL_CONFIGS[test_type]
|
cfg = DEBUG_PANEL_CONFIGS[test_type]
|
||||||
win_attr = cfg["window_attr"]
|
win_attr = cfg["window_attr"]
|
||||||
@@ -288,25 +346,26 @@ def _toggle_debug_panel(self, test_type):
|
|||||||
win.update_idletasks()
|
win.update_idletasks()
|
||||||
|
|
||||||
|
|
||||||
def toggle_screen_debug_panel(self):
|
def toggle_screen_debug_panel(self: "PQAutomationApp"):
|
||||||
_toggle_debug_panel(self, "screen_module")
|
_toggle_debug_panel(self, "screen_module")
|
||||||
|
|
||||||
|
|
||||||
def toggle_sdr_debug_panel(self):
|
def toggle_sdr_debug_panel(self: "PQAutomationApp"):
|
||||||
_toggle_debug_panel(self, "sdr_movie")
|
_toggle_debug_panel(self, "sdr_movie")
|
||||||
|
|
||||||
|
|
||||||
def toggle_hdr_debug_panel(self):
|
def toggle_hdr_debug_panel(self: "PQAutomationApp"):
|
||||||
_toggle_debug_panel(self, "hdr_movie")
|
_toggle_debug_panel(self, "hdr_movie")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def update_sidebar_selection(self):
|
def update_sidebar_selection(self: "PQAutomationApp"):
|
||||||
"""更新侧边栏按钮的选中状态"""
|
"""更新侧边栏按钮的选中状态"""
|
||||||
# 重置所有按钮样式为默认
|
# 重置所有按钮样式为默认
|
||||||
self.screen_module_btn.configure(style="Sidebar.TButton")
|
self.screen_module_btn.configure(style="Sidebar.TButton")
|
||||||
self.sdr_movie_btn.configure(style="Sidebar.TButton")
|
self.sdr_movie_btn.configure(style="Sidebar.TButton")
|
||||||
self.hdr_movie_btn.configure(style="Sidebar.TButton")
|
self.hdr_movie_btn.configure(style="Sidebar.TButton")
|
||||||
|
self.local_dimming_btn.configure(style="Sidebar.TButton")
|
||||||
|
|
||||||
# 设置当前选中按钮的样式
|
# 设置当前选中按钮的样式
|
||||||
current_type = self.test_type_var.get()
|
current_type = self.test_type_var.get()
|
||||||
@@ -316,3 +375,20 @@ def update_sidebar_selection(self):
|
|||||||
self.sdr_movie_btn.configure(style="SidebarSelected.TButton")
|
self.sdr_movie_btn.configure(style="SidebarSelected.TButton")
|
||||||
elif current_type == "hdr_movie":
|
elif current_type == "hdr_movie":
|
||||||
self.hdr_movie_btn.configure(style="SidebarSelected.TButton")
|
self.hdr_movie_btn.configure(style="SidebarSelected.TButton")
|
||||||
|
elif current_type == "local_dimming":
|
||||||
|
self.local_dimming_btn.configure(style="SidebarSelected.TButton")
|
||||||
|
|
||||||
|
|
||||||
|
class SidePanelsMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
create_log_panel = create_log_panel
|
||||||
|
create_local_dimming_panel = create_local_dimming_panel
|
||||||
|
toggle_local_dimming_panel = toggle_local_dimming_panel
|
||||||
|
toggle_log_panel = toggle_log_panel
|
||||||
|
_toggle_debug_panel = _toggle_debug_panel
|
||||||
|
toggle_screen_debug_panel = toggle_screen_debug_panel
|
||||||
|
toggle_sdr_debug_panel = toggle_sdr_debug_panel
|
||||||
|
toggle_hdr_debug_panel = toggle_hdr_debug_panel
|
||||||
|
update_sidebar_selection = update_sidebar_selection
|
||||||
|
|||||||
@@ -13,7 +13,14 @@ from tkinter import filedialog, messagebox
|
|||||||
import ttkbootstrap as ttk
|
import ttkbootstrap as ttk
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pqAutomationApp import PQAutomationApp
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_DEFAULT_SAMPLES = [
|
_DEFAULT_SAMPLES = [
|
||||||
@@ -26,7 +33,7 @@ _DEFAULT_SAMPLES = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def create_single_step_panel(self):
|
def create_single_step_panel(self: "PQAutomationApp"):
|
||||||
"""创建单步调试面板。"""
|
"""创建单步调试面板。"""
|
||||||
frame = ttk.Frame(self.content_frame)
|
frame = ttk.Frame(self.content_frame)
|
||||||
self.single_step_frame = frame
|
self.single_step_frame = frame
|
||||||
@@ -245,12 +252,12 @@ def create_single_step_panel(self):
|
|||||||
_load_default_samples(self)
|
_load_default_samples(self)
|
||||||
|
|
||||||
|
|
||||||
def toggle_single_step_panel(self):
|
def toggle_single_step_panel(self: "PQAutomationApp"):
|
||||||
"""切换单步调试面板。"""
|
"""切换单步调试面板。"""
|
||||||
self.show_panel("single_step")
|
self.show_panel("single_step")
|
||||||
|
|
||||||
|
|
||||||
def _load_default_samples(self):
|
def _load_default_samples(self: "PQAutomationApp"):
|
||||||
self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES]
|
self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES]
|
||||||
_refresh_sample_list(self, select_index=0 if self.single_step_samples else None)
|
_refresh_sample_list(self, select_index=0 if self.single_step_samples else None)
|
||||||
self.single_step_status_var.set(
|
self.single_step_status_var.set(
|
||||||
@@ -258,7 +265,7 @@ def _load_default_samples(self):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _refresh_sample_list(self, select_index=None):
|
def _refresh_sample_list(self: "PQAutomationApp", select_index=None):
|
||||||
self.single_step_listbox.delete(0, tk.END)
|
self.single_step_listbox.delete(0, tk.END)
|
||||||
for sample in self.single_step_samples:
|
for sample in self.single_step_samples:
|
||||||
self.single_step_listbox.insert(
|
self.single_step_listbox.insert(
|
||||||
@@ -279,14 +286,14 @@ def _refresh_sample_list(self, select_index=None):
|
|||||||
self.single_step_status_var.set("样本列表为空")
|
self.single_step_status_var.set("样本列表为空")
|
||||||
|
|
||||||
|
|
||||||
def _on_sample_select(self):
|
def _on_sample_select(self: "PQAutomationApp"):
|
||||||
selection = self.single_step_listbox.curselection()
|
selection = self.single_step_listbox.curselection()
|
||||||
if not selection:
|
if not selection:
|
||||||
return
|
return
|
||||||
_select_sample(self, selection[0])
|
_select_sample(self, selection[0])
|
||||||
|
|
||||||
|
|
||||||
def _select_sample(self, index):
|
def _select_sample(self: "PQAutomationApp", index):
|
||||||
sample = self.single_step_samples[index]
|
sample = self.single_step_samples[index]
|
||||||
self.single_step_current_index = index
|
self.single_step_current_index = index
|
||||||
self.single_step_name_var.set(sample["name"])
|
self.single_step_name_var.set(sample["name"])
|
||||||
@@ -296,7 +303,7 @@ def _select_sample(self, index):
|
|||||||
self.single_step_status_var.set(f"当前样本: {sample['name']}")
|
self.single_step_status_var.set(f"当前样本: {sample['name']}")
|
||||||
|
|
||||||
|
|
||||||
def _import_samples_csv(self):
|
def _import_samples_csv(self: "PQAutomationApp"):
|
||||||
path = filedialog.askopenfilename(
|
path = filedialog.askopenfilename(
|
||||||
title="选择单步调试样本 CSV",
|
title="选择单步调试样本 CSV",
|
||||||
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")],
|
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")],
|
||||||
@@ -333,7 +340,7 @@ def _import_samples_csv(self):
|
|||||||
self.log_gui.log(f"单步调试样本已导入: {len(samples)} 条", level="success")
|
self.log_gui.log(f"单步调试样本已导入: {len(samples)} 条", level="success")
|
||||||
|
|
||||||
|
|
||||||
def _delete_current_sample(self):
|
def _delete_current_sample(self: "PQAutomationApp"):
|
||||||
if self.single_step_current_index is None:
|
if self.single_step_current_index is None:
|
||||||
return
|
return
|
||||||
removed = self.single_step_samples.pop(self.single_step_current_index)
|
removed = self.single_step_samples.pop(self.single_step_current_index)
|
||||||
@@ -342,7 +349,7 @@ def _delete_current_sample(self):
|
|||||||
self.single_step_status_var.set(f"已删除样本: {removed['name']}")
|
self.single_step_status_var.set(f"已删除样本: {removed['name']}")
|
||||||
|
|
||||||
|
|
||||||
def _upsert_sample(self):
|
def _upsert_sample(self: "PQAutomationApp"):
|
||||||
try:
|
try:
|
||||||
sample = {
|
sample = {
|
||||||
"name": self.single_step_name_var.get().strip(),
|
"name": self.single_step_name_var.get().strip(),
|
||||||
@@ -386,10 +393,10 @@ def _format_float(value):
|
|||||||
return f"{number:.4f}"
|
return f"{number:.4f}"
|
||||||
|
|
||||||
|
|
||||||
def _build_color_patch(self, hex_value):
|
def _build_color_patch(self: "PQAutomationApp", hex_value):
|
||||||
if not getattr(self, "ucd", None) or not self.ucd.status:
|
if not self.signal_service.is_connected:
|
||||||
raise RuntimeError("请先连接 UCD323 设备")
|
raise RuntimeError("请先连接 UCD323 设备")
|
||||||
width, height = get_current_resolution(self.ucd)
|
width, height = self.signal_service.current_resolution()
|
||||||
rgb = tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5))
|
rgb = tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5))
|
||||||
temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches")
|
temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
@@ -400,7 +407,7 @@ def _build_color_patch(self, hex_value):
|
|||||||
return file_path
|
return file_path
|
||||||
|
|
||||||
|
|
||||||
def _send_current_patch(self):
|
def _send_current_patch(self: "PQAutomationApp"):
|
||||||
if self.single_step_current_index is None:
|
if self.single_step_current_index is None:
|
||||||
messagebox.showinfo("提示", "请先选择一个样本")
|
messagebox.showinfo("提示", "请先选择一个样本")
|
||||||
return
|
return
|
||||||
@@ -409,9 +416,7 @@ def _send_current_patch(self):
|
|||||||
def worker():
|
def worker():
|
||||||
try:
|
try:
|
||||||
image_path = _build_color_patch(self, sample["hex"])
|
image_path = _build_color_patch(self, sample["hex"])
|
||||||
ok = send_image_pattern(self.ucd, image_path)
|
self.signal_service.send_image(image_path)
|
||||||
if not ok:
|
|
||||||
raise RuntimeError("UCD323 发送失败")
|
|
||||||
self.single_step_current_image_path = image_path
|
self.single_step_current_image_path = image_path
|
||||||
self._dispatch_ui(
|
self._dispatch_ui(
|
||||||
self.single_step_status_var.set,
|
self.single_step_status_var.set,
|
||||||
@@ -429,7 +434,7 @@ def _send_current_patch(self):
|
|||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _measure_current_sample(self):
|
def _measure_current_sample(self: "PQAutomationApp"):
|
||||||
if self.single_step_current_index is None:
|
if self.single_step_current_index is None:
|
||||||
messagebox.showinfo("提示", "请先选择一个样本")
|
messagebox.showinfo("提示", "请先选择一个样本")
|
||||||
return
|
return
|
||||||
@@ -458,7 +463,7 @@ def _measure_current_sample(self):
|
|||||||
threading.Thread(target=worker, daemon=True).start()
|
threading.Thread(target=worker, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
def _commit_result(self):
|
def _commit_result(self: "PQAutomationApp"):
|
||||||
if self.single_step_current_index is None:
|
if self.single_step_current_index is None:
|
||||||
messagebox.showinfo("提示", "请先选择一个样本")
|
messagebox.showinfo("提示", "请先选择一个样本")
|
||||||
return
|
return
|
||||||
@@ -510,14 +515,14 @@ def _commit_result(self):
|
|||||||
self.single_step_status_var.set(f"已记录结果,ΔE2000={record['delta_e']}")
|
self.single_step_status_var.set(f"已记录结果,ΔE2000={record['delta_e']}")
|
||||||
|
|
||||||
|
|
||||||
def _clear_results(self):
|
def _clear_results(self: "PQAutomationApp"):
|
||||||
self.single_step_results = []
|
self.single_step_results = []
|
||||||
for item in self.single_step_result_tree.get_children():
|
for item in self.single_step_result_tree.get_children():
|
||||||
self.single_step_result_tree.delete(item)
|
self.single_step_result_tree.delete(item)
|
||||||
self.single_step_status_var.set("结果已清空")
|
self.single_step_status_var.set("结果已清空")
|
||||||
|
|
||||||
|
|
||||||
def _export_results_csv(self):
|
def _export_results_csv(self: "PQAutomationApp"):
|
||||||
if not self.single_step_results:
|
if not self.single_step_results:
|
||||||
messagebox.showinfo("提示", "暂无可导出的调试结果")
|
messagebox.showinfo("提示", "暂无可导出的调试结果")
|
||||||
return
|
return
|
||||||
@@ -549,3 +554,24 @@ def _export_results_csv(self):
|
|||||||
self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}")
|
self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
|
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
class SingleStepPanelMixin:
|
||||||
|
"""由 tools/refactor_to_mixins.py 自动生成。
|
||||||
|
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
|
||||||
|
"""
|
||||||
|
create_single_step_panel = create_single_step_panel
|
||||||
|
toggle_single_step_panel = toggle_single_step_panel
|
||||||
|
_load_default_samples = _load_default_samples
|
||||||
|
_refresh_sample_list = _refresh_sample_list
|
||||||
|
_on_sample_select = _on_sample_select
|
||||||
|
_select_sample = _select_sample
|
||||||
|
_import_samples_csv = _import_samples_csv
|
||||||
|
_delete_current_sample = _delete_current_sample
|
||||||
|
_upsert_sample = _upsert_sample
|
||||||
|
_build_color_patch = _build_color_patch
|
||||||
|
_send_current_patch = _send_current_patch
|
||||||
|
_measure_current_sample = _measure_current_sample
|
||||||
|
_commit_result = _commit_result
|
||||||
|
_clear_results = _clear_results
|
||||||
|
_export_results_csv = _export_results_csv
|
||||||
|
|||||||
@@ -10,6 +10,18 @@ import threading
|
|||||||
import time
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def _theme_colors():
|
||||||
|
style = ttk.Style()
|
||||||
|
colors = style.colors
|
||||||
|
return {
|
||||||
|
"fg": colors.fg,
|
||||||
|
"muted": colors.secondary,
|
||||||
|
"info": colors.info,
|
||||||
|
"warning": colors.warning,
|
||||||
|
"error": colors.danger,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class PQDebugPanel:
|
class PQDebugPanel:
|
||||||
"""PQ 单步调试面板 - 支持 Gamma/EOTF/色准单步测试"""
|
"""PQ 单步调试面板 - 支持 Gamma/EOTF/色准单步测试"""
|
||||||
|
|
||||||
@@ -72,7 +84,7 @@ class PQDebugPanel:
|
|||||||
self.screen_gamma_frame,
|
self.screen_gamma_frame,
|
||||||
text="测试完成后可用,选择灰阶进行单步调试",
|
text="测试完成后可用,选择灰阶进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
# 灰阶选择
|
# 灰阶选择
|
||||||
@@ -137,7 +149,7 @@ class PQDebugPanel:
|
|||||||
self.screen_rgb_frame,
|
self.screen_rgb_frame,
|
||||||
text="测试完成后可用,选择颜色进行单步调试",
|
text="测试完成后可用,选择颜色进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
# RGB 颜色选择
|
# RGB 颜色选择
|
||||||
@@ -210,7 +222,7 @@ class PQDebugPanel:
|
|||||||
self.sdr_gamma_frame,
|
self.sdr_gamma_frame,
|
||||||
text="测试完成后可用,选择灰阶进行单步调试",
|
text="测试完成后可用,选择灰阶进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
ttk.Label(self.sdr_gamma_frame, text="选择灰阶:").grid(
|
ttk.Label(self.sdr_gamma_frame, text="选择灰阶:").grid(
|
||||||
@@ -272,7 +284,7 @@ class PQDebugPanel:
|
|||||||
self.sdr_accuracy_frame,
|
self.sdr_accuracy_frame,
|
||||||
text="测试完成后可用,选择色块进行单步调试",
|
text="测试完成后可用,选择色块进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
ttk.Label(self.sdr_accuracy_frame, text="选择色块:").grid(
|
ttk.Label(self.sdr_accuracy_frame, text="选择色块:").grid(
|
||||||
@@ -334,7 +346,7 @@ class PQDebugPanel:
|
|||||||
self.sdr_rgb_frame,
|
self.sdr_rgb_frame,
|
||||||
text="测试完成后可用,选择颜色进行单步调试",
|
text="测试完成后可用,选择颜色进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
# RGB 颜色选择
|
# RGB 颜色选择
|
||||||
@@ -407,7 +419,7 @@ class PQDebugPanel:
|
|||||||
self.hdr_eotf_frame,
|
self.hdr_eotf_frame,
|
||||||
text="测试完成后可用,选择灰阶进行单步调试",
|
text="测试完成后可用,选择灰阶进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
ttk.Label(self.hdr_eotf_frame, text="选择灰阶:").grid(
|
ttk.Label(self.hdr_eotf_frame, text="选择灰阶:").grid(
|
||||||
@@ -469,7 +481,7 @@ class PQDebugPanel:
|
|||||||
self.hdr_accuracy_frame,
|
self.hdr_accuracy_frame,
|
||||||
text="测试完成后可用,选择色块进行单步调试",
|
text="测试完成后可用,选择色块进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
ttk.Label(self.hdr_accuracy_frame, text="选择色块:").grid(
|
ttk.Label(self.hdr_accuracy_frame, text="选择色块:").grid(
|
||||||
@@ -531,7 +543,7 @@ class PQDebugPanel:
|
|||||||
self.hdr_rgb_frame,
|
self.hdr_rgb_frame,
|
||||||
text="测试完成后可用,选择颜色进行单步调试",
|
text="测试完成后可用,选择颜色进行单步调试",
|
||||||
font=("SimHei", 9),
|
font=("SimHei", 9),
|
||||||
foreground="gray",
|
foreground=_theme_colors()["muted"],
|
||||||
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
).grid(row=0, column=0, columnspan=3, pady=(0, 10))
|
||||||
|
|
||||||
# RGB 颜色选择
|
# RGB 颜色选择
|
||||||
@@ -1007,12 +1019,13 @@ class PQDebugPanel:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# 设置标签样式
|
# 设置标签样式
|
||||||
tree.tag_configure("header", background="#E3F2FD", font=("SimHei", 9, "bold"))
|
palette = _theme_colors()
|
||||||
tree.tag_configure("normal", foreground="black")
|
tree.tag_configure("header", background=palette["info"], font=("SimHei", 9, "bold"))
|
||||||
tree.tag_configure("warning", foreground="red")
|
tree.tag_configure("normal", foreground=palette["fg"])
|
||||||
tree.tag_configure("highlight", foreground="blue", font=("SimHei", 9, "bold"))
|
tree.tag_configure("warning", foreground=palette["warning"])
|
||||||
|
tree.tag_configure("highlight", foreground=palette["info"], font=("SimHei", 9, "bold"))
|
||||||
tree.tag_configure(
|
tree.tag_configure(
|
||||||
"highlight_warning", foreground="red", font=("SimHei", 9, "bold")
|
"highlight_warning", foreground=palette["warning"], font=("SimHei", 9, "bold")
|
||||||
)
|
)
|
||||||
|
|
||||||
def _disable_test_button(self, test_type, test_item):
|
def _disable_test_button(self, test_type, test_item):
|
||||||
|
|||||||
@@ -17,6 +17,71 @@ _GUI_LEVEL_TO_LOG = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _theme_colors():
|
||||||
|
style = ttk.Style()
|
||||||
|
colors = style.colors
|
||||||
|
return {
|
||||||
|
"bg": colors.bg,
|
||||||
|
"fg": colors.fg,
|
||||||
|
"muted": colors.secondary,
|
||||||
|
"accent": colors.info,
|
||||||
|
"warning": colors.warning,
|
||||||
|
"error": colors.danger,
|
||||||
|
"success": colors.success,
|
||||||
|
"text_bg": colors.inputbg,
|
||||||
|
"text_fg": colors.inputfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_hex(hex_color: str, fallback: str = "#808080") -> str:
|
||||||
|
"""把颜色字符串规范化为 #RRGGBB;非法输入回退到 fallback。"""
|
||||||
|
if not isinstance(hex_color, str):
|
||||||
|
return fallback
|
||||||
|
c = hex_color.strip()
|
||||||
|
if not c:
|
||||||
|
return fallback
|
||||||
|
if c.startswith("#"):
|
||||||
|
c = c[1:]
|
||||||
|
if len(c) == 3:
|
||||||
|
c = "".join(ch * 2 for ch in c)
|
||||||
|
if len(c) != 6:
|
||||||
|
return fallback
|
||||||
|
try:
|
||||||
|
int(c, 16)
|
||||||
|
except ValueError:
|
||||||
|
return fallback
|
||||||
|
return f"#{c}"
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_to_rgb(hex_color: str):
|
||||||
|
c = _normalize_hex(hex_color, fallback="#808080").lstrip("#")
|
||||||
|
return int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
|
||||||
|
|
||||||
|
|
||||||
|
def _mix(hex_a: str, hex_b: str, ratio: float) -> str:
|
||||||
|
ratio = max(0.0, min(1.0, ratio))
|
||||||
|
r1, g1, b1 = _hex_to_rgb(hex_a)
|
||||||
|
r2, g2, b2 = _hex_to_rgb(hex_b)
|
||||||
|
r = int(r1 * (1 - ratio) + r2 * ratio)
|
||||||
|
g = int(g1 * (1 - ratio) + g2 * ratio)
|
||||||
|
b = int(b1 * (1 - ratio) + b2 * ratio)
|
||||||
|
return f"#{r:02x}{g:02x}{b:02x}"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dark(hex_color: str) -> bool:
|
||||||
|
r, g, b = _hex_to_rgb(hex_color)
|
||||||
|
return (r * 299 + g * 587 + b * 114) / 1000 < 128
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_text_color(bg_hex: str, fg_hint: str) -> str:
|
||||||
|
"""根据背景亮度给出稳定可读的文本颜色。"""
|
||||||
|
bg_hex = _normalize_hex(bg_hex, fallback="#f5f5f5")
|
||||||
|
fg_hint = _normalize_hex(fg_hint, fallback="#202020")
|
||||||
|
if _is_dark(bg_hex):
|
||||||
|
return _mix("#ffffff", fg_hint, 0.25)
|
||||||
|
return _mix("#000000", fg_hint, 0.25)
|
||||||
|
|
||||||
|
|
||||||
class PQLogGUI(ttk.Frame):
|
class PQLogGUI(ttk.Frame):
|
||||||
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
|
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
|
||||||
|
|
||||||
@@ -53,21 +118,22 @@ class PQLogGUI(ttk.Frame):
|
|||||||
text_container = ttk.Frame(log_frame)
|
text_container = ttk.Frame(log_frame)
|
||||||
text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
|
text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
|
||||||
|
|
||||||
|
palette = _theme_colors()
|
||||||
self.log_text = tk.Text(
|
self.log_text = tk.Text(
|
||||||
text_container,
|
text_container,
|
||||||
height=10,
|
height=10,
|
||||||
width=50,
|
width=50,
|
||||||
wrap=tk.WORD,
|
wrap=tk.WORD,
|
||||||
font=("Consolas", 10),
|
font=("Consolas", 10),
|
||||||
bg="#fbfcfe",
|
bg=palette["text_bg"],
|
||||||
fg="#1f2937",
|
fg=palette["text_fg"],
|
||||||
relief=tk.FLAT,
|
relief=tk.FLAT,
|
||||||
bd=0,
|
bd=0,
|
||||||
padx=10,
|
padx=10,
|
||||||
pady=8,
|
pady=8,
|
||||||
spacing1=2,
|
spacing1=2,
|
||||||
spacing3=2,
|
spacing3=2,
|
||||||
insertbackground="#1f2937",
|
insertbackground=palette["text_fg"],
|
||||||
)
|
)
|
||||||
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
|
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
|
||||||
|
|
||||||
@@ -114,21 +180,45 @@ class PQLogGUI(ttk.Frame):
|
|||||||
self._update_summary()
|
self._update_summary()
|
||||||
|
|
||||||
def _configure_tags(self):
|
def _configure_tags(self):
|
||||||
self.log_text.tag_configure("timestamp", foreground="#6b7280")
|
palette = _theme_colors()
|
||||||
self.log_text.tag_configure("level_info", foreground="#2563eb")
|
bg = self.log_text.cget("bg") or palette["text_bg"] or palette["bg"]
|
||||||
self.log_text.tag_configure("level_success", foreground="#0f766e")
|
base_fg = _auto_text_color(bg, palette["fg"])
|
||||||
self.log_text.tag_configure("level_warning", foreground="#b45309")
|
muted_fg = _mix(base_fg, bg, 0.45)
|
||||||
self.log_text.tag_configure("level_error", foreground="#b91c1c")
|
debug_level_color = _mix(palette["accent"], base_fg, 0.35)
|
||||||
self.log_text.tag_configure("level_debug", foreground="#7c3aed")
|
debug_msg_color = _mix(palette["accent"], base_fg, 0.50)
|
||||||
self.log_text.tag_configure("message", foreground="#1f2937")
|
self.log_text.tag_configure("timestamp", foreground=palette["muted"])
|
||||||
self.log_text.tag_configure("message_success", foreground="#0f766e")
|
self.log_text.tag_configure("level_info", foreground=palette["accent"])
|
||||||
self.log_text.tag_configure("message_warning", foreground="#b45309")
|
self.log_text.tag_configure("level_success", foreground=palette["success"])
|
||||||
self.log_text.tag_configure("message_error", foreground="#991b1b")
|
self.log_text.tag_configure("level_warning", foreground=palette["warning"])
|
||||||
self.log_text.tag_configure("message_debug", foreground="#6d28d9")
|
self.log_text.tag_configure("level_error", foreground=palette["error"])
|
||||||
self.log_text.tag_configure("separator", foreground="#94a3b8")
|
self.log_text.tag_configure("level_debug", foreground=debug_level_color)
|
||||||
self.log_text.tag_configure("traceback", foreground="#7f1d1d")
|
self.log_text.tag_configure("message", foreground=base_fg)
|
||||||
|
self.log_text.tag_configure("message_success", foreground=palette["success"])
|
||||||
|
self.log_text.tag_configure("message_warning", foreground=palette["warning"])
|
||||||
|
self.log_text.tag_configure("message_error", foreground=palette["error"])
|
||||||
|
self.log_text.tag_configure("message_debug", foreground=debug_msg_color)
|
||||||
|
self.log_text.tag_configure("separator", foreground=muted_fg)
|
||||||
|
self.log_text.tag_configure("traceback", foreground=palette["error"])
|
||||||
self.log_text.tag_configure("blank", spacing1=4, spacing3=4)
|
self.log_text.tag_configure("blank", spacing1=4, spacing3=4)
|
||||||
|
|
||||||
|
def refresh_log_theme(self):
|
||||||
|
"""主题切换后刷新日志控件的背景和字体颜色。"""
|
||||||
|
if threading.current_thread() is not threading.main_thread():
|
||||||
|
self.after(0, self.refresh_log_theme)
|
||||||
|
return
|
||||||
|
|
||||||
|
palette = _theme_colors()
|
||||||
|
bg = palette["text_bg"]
|
||||||
|
fg_hint = palette["text_fg"] if palette["text_fg"] else palette["fg"]
|
||||||
|
fg = _auto_text_color(bg, fg_hint)
|
||||||
|
|
||||||
|
self.log_text.configure(
|
||||||
|
bg=bg,
|
||||||
|
fg=fg,
|
||||||
|
insertbackground=fg,
|
||||||
|
)
|
||||||
|
self._configure_tags()
|
||||||
|
|
||||||
def _append_message(self, message, level):
|
def _append_message(self, message, level):
|
||||||
lines = message.splitlines() or [""]
|
lines = message.splitlines() or [""]
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
|||||||
134
app/views/theme_manager.py
Normal file
134
app/views/theme_manager.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""主题管理:注册 Calman 风格深色主题 + 提供运行时切换。
|
||||||
|
|
||||||
|
主题在启动时通过 ``apply_initial_theme(root_style)`` 注入到 ttkbootstrap,
|
||||||
|
当前选择持久化到 ``settings/ui_preferences.json``。运行时调用
|
||||||
|
``toggle_theme(root_style)`` / ``set_theme(root_style, name)`` 可即时切换,
|
||||||
|
并自动重新调用 ``apply_modern_styles()`` 让自定义样式跟上新色板。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ttkbootstrap.style import Style, ThemeDefinition
|
||||||
|
|
||||||
|
from app.views.modern_styles import apply_modern_styles
|
||||||
|
|
||||||
|
|
||||||
|
_PREFS_PATH = Path("settings/ui_preferences.json")
|
||||||
|
|
||||||
|
# 浅色主题:沿用旧的 yeti(首发布兼容)
|
||||||
|
LIGHT_THEME = "yeti"
|
||||||
|
# 深色主题:自定义 Calman 风格
|
||||||
|
DARK_THEME = "calman_dark"
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# Calman 风格深色主题色板(参考实测截图取色)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
_CALMAN_DARK_COLORS = {
|
||||||
|
"primary": "#343A41", # 主色改为炭灰,避免大面积亮蓝
|
||||||
|
"secondary": "#444A51", # 中性深灰(用于 header / 分组背景)
|
||||||
|
"success": "#4FB960",
|
||||||
|
"info": "#6FAFCC", # 降低饱和度,只做少量点缀
|
||||||
|
"warning": "#F2A93B",
|
||||||
|
"danger": "#E0524A",
|
||||||
|
"light": "#BFC6CE", # 高亮文本
|
||||||
|
"dark": "#0D1014", # 最深背景(侧栏底色)
|
||||||
|
"bg": "#1B1F24", # 主窗口背景
|
||||||
|
"fg": "#E4E8EE", # 主文本颜色
|
||||||
|
"selectbg": "#5A6169",
|
||||||
|
"selectfg": "#E4E8EE",
|
||||||
|
"border": "#2A2F36",
|
||||||
|
"inputfg": "#E4E8EE",
|
||||||
|
"inputbg": "#24292F",
|
||||||
|
"active": "#2A2F36",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def register_themes() -> None:
|
||||||
|
"""把自定义深色主题注册到 ttkbootstrap(可重复调用,幂等)。"""
|
||||||
|
style = Style()
|
||||||
|
if DARK_THEME in style.theme_names():
|
||||||
|
return
|
||||||
|
theme_def = ThemeDefinition(
|
||||||
|
name=DARK_THEME,
|
||||||
|
themetype="dark",
|
||||||
|
colors=_CALMAN_DARK_COLORS,
|
||||||
|
)
|
||||||
|
style.register_theme(theme_def)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 偏好持久化
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def _read_prefs() -> dict:
|
||||||
|
try:
|
||||||
|
return json.loads(_PREFS_PATH.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_prefs(data: dict) -> None:
|
||||||
|
try:
|
||||||
|
_PREFS_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_PREFS_PATH.write_text(
|
||||||
|
json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except OSError:
|
||||||
|
# 写入失败不应影响 UI
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_saved_theme() -> Optional[str]:
|
||||||
|
return _read_prefs().get("theme")
|
||||||
|
|
||||||
|
|
||||||
|
def save_theme(name: str) -> None:
|
||||||
|
prefs = _read_prefs()
|
||||||
|
prefs["theme"] = name
|
||||||
|
_write_prefs(prefs)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 主题应用 / 切换
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
def apply_initial_theme() -> str:
|
||||||
|
"""启动时调用:注册主题 + 加载偏好 + 切到对应主题。
|
||||||
|
|
||||||
|
返回最终生效的主题名。
|
||||||
|
"""
|
||||||
|
register_themes()
|
||||||
|
name = get_saved_theme() or LIGHT_THEME
|
||||||
|
style = Style()
|
||||||
|
if name not in style.theme_names():
|
||||||
|
name = LIGHT_THEME
|
||||||
|
style.theme_use(name)
|
||||||
|
apply_modern_styles()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def set_theme(name: str) -> str:
|
||||||
|
"""切换到指定主题,持久化偏好,并刷新自定义样式。"""
|
||||||
|
register_themes()
|
||||||
|
style = Style()
|
||||||
|
if name not in style.theme_names():
|
||||||
|
name = LIGHT_THEME
|
||||||
|
style.theme_use(name)
|
||||||
|
apply_modern_styles()
|
||||||
|
save_theme(name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_theme() -> str:
|
||||||
|
"""在浅 / 深之间切换。返回新主题名。"""
|
||||||
|
style = Style()
|
||||||
|
current = style.theme.name
|
||||||
|
target = DARK_THEME if current != DARK_THEME else LIGHT_THEME
|
||||||
|
return set_theme(target)
|
||||||
|
|
||||||
|
|
||||||
|
def is_dark() -> bool:
|
||||||
|
return Style().theme.name == DARK_THEME
|
||||||
BIN
docs/PQ生图后端接口文档v2.pdf
Normal file
BIN
docs/PQ生图后端接口文档v2.pdf
Normal file
Binary file not shown.
Binary file not shown.
@@ -561,10 +561,6 @@ class UCDController:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# 向后兼容别名
|
|
||||||
set_sdr_format = apply_signal_format
|
|
||||||
set_hdr_format = apply_signal_format
|
|
||||||
|
|
||||||
def _get_colorimetry_from_color_space(self, color_space, color_format=None):
|
def _get_colorimetry_from_color_space(self, color_space, color_format=None):
|
||||||
"""将色彩空间字符串转换为UniTAP.ColorInfo.Colorimetry。
|
"""将色彩空间字符串转换为UniTAP.ColorInfo.Colorimetry。
|
||||||
BT.2020 在 YCbCr 输出时使用 CM_ITUR_BT2020_YCbCr,RGB 输出时使用 CM_ITUR_BT2020_RGB。
|
BT.2020 在 YCbCr 输出时使用 CM_ITUR_BT2020_YCbCr,RGB 输出时使用 CM_ITUR_BT2020_RGB。
|
||||||
|
|||||||
441
drivers/ucd_driver.py
Normal file
441
drivers/ucd_driver.py
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
"""UCD 驱动层。
|
||||||
|
|
||||||
|
唯一暴露给上层的入口为 :class:`IUcdDevice` 抽象接口,以及实现:
|
||||||
|
:class:`UCD323Device`(生产)和 :class:`FakeUcdDevice`(单测)。
|
||||||
|
|
||||||
|
Phase 1 实现策略
|
||||||
|
-----------------
|
||||||
|
为保证零行为变更,:class:`UCD323Device` 当前**内部委托**给已有的
|
||||||
|
:class:`drivers.UCD323_Function.UCDController`。后续 Phase 2 会将
|
||||||
|
SDK 调用直接搬入本模块,届时可删除旧 ``UCDController`` 文件。
|
||||||
|
|
||||||
|
文件分区:
|
||||||
|
§1 DeviceInfo / list_devices
|
||||||
|
§2 IUcdDevice 抽象接口
|
||||||
|
§3 UCD323Device 真实实现
|
||||||
|
§4 FakeUcdDevice 单测实现
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.ucd_domain import (
|
||||||
|
ConnectionChanged,
|
||||||
|
EventBus,
|
||||||
|
Interface,
|
||||||
|
PatternApplied,
|
||||||
|
PatternKind,
|
||||||
|
PatternSpec,
|
||||||
|
SignalApplied,
|
||||||
|
SignalFormat,
|
||||||
|
TimingSpec,
|
||||||
|
UcdApplyFailed,
|
||||||
|
UcdConfigError,
|
||||||
|
UcdNotConnected,
|
||||||
|
UcdSdkError,
|
||||||
|
UcdState,
|
||||||
|
UcdStateError,
|
||||||
|
assert_transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from drivers.UCD323_Function import UCDController
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §1 DeviceInfo / list_devices ────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeviceInfo:
|
||||||
|
"""UCD 设备发现条目。
|
||||||
|
|
||||||
|
``display`` 是 SDK 给出的完整字符串(``"0: UCD-323 #12345678"``);
|
||||||
|
``index`` / ``serial`` / ``model`` 通过解析得到,解析失败时为 None。
|
||||||
|
"""
|
||||||
|
|
||||||
|
display: str
|
||||||
|
index: int | None = None
|
||||||
|
serial: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, display: str) -> "DeviceInfo":
|
||||||
|
idx: int | None = None
|
||||||
|
model: str | None = None
|
||||||
|
serial: str | None = None
|
||||||
|
try:
|
||||||
|
head, rest = display.split(":", 1)
|
||||||
|
idx = int(head.strip())
|
||||||
|
rest = rest.strip()
|
||||||
|
# 形如 "UCD-323 #12345678" 或 "UCD-323 #12345678 (in use)"
|
||||||
|
tokens = rest.split()
|
||||||
|
if tokens:
|
||||||
|
model = tokens[0]
|
||||||
|
for tok in tokens[1:]:
|
||||||
|
if tok.startswith("#") and len(tok) >= 2:
|
||||||
|
serial = tok.lstrip("#")
|
||||||
|
break
|
||||||
|
except Exception: # noqa: BLE001 - 解析失败保留原 display 即可
|
||||||
|
pass
|
||||||
|
return cls(display=display, index=idx, serial=serial, model=model)
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices(controller: "UCDController") -> list[DeviceInfo]:
|
||||||
|
"""通过给定的底层 controller 枚举可用 UCD 设备。"""
|
||||||
|
try:
|
||||||
|
raw_list = controller.search_device()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise UcdSdkError("枚举 UCD 设备失败") from exc
|
||||||
|
return [DeviceInfo.parse(s) for s in (raw_list or [])]
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §2 IUcdDevice 抽象接口 ──────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class IUcdDevice(ABC):
|
||||||
|
"""UCD 信号发生器抽象设备。
|
||||||
|
|
||||||
|
上层(Service / GUI)**只**通过本接口操作硬件,不得穿透到
|
||||||
|
UniTAP SDK 或具体实现细节。
|
||||||
|
"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def state(self) -> UcdState: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def info(self) -> DeviceInfo | None: ...
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def open(self, info: DeviceInfo, *, interface: Interface = Interface.HDMI) -> None:
|
||||||
|
"""打开设备并选择接口角色。失败抛 :class:`UcdSdkError` 等。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def close(self) -> None:
|
||||||
|
"""关闭设备(幂等)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool:
|
||||||
|
"""写入信号格式与 timing(未 apply)。返回 ``format_changed``。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_pattern(self, pattern: PatternSpec) -> None:
|
||||||
|
"""设置当前图案(未 apply)。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def apply(self) -> None:
|
||||||
|
"""将已配置的信号格式 + 图案一次性提交给硬件。"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def current_resolution(self) -> tuple[int, int]:
|
||||||
|
"""读取当前 timing 的 (width, height);未连接时返回默认 (3840, 2160)。"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §3 UCD323Device 真实实现 ────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class UCD323Device(IUcdDevice):
|
||||||
|
"""生产环境实现。内部委托给传统 :class:`UCDController`(Phase 1)。"""
|
||||||
|
|
||||||
|
def __init__(self, bus: EventBus, controller: "UCDController | None" = None):
|
||||||
|
from drivers.UCD323_Function import UCDController as _UCDController
|
||||||
|
|
||||||
|
self._bus = bus
|
||||||
|
self._controller: "UCDController" = controller or _UCDController()
|
||||||
|
self._state: UcdState = UcdState.CLOSED
|
||||||
|
self._info: DeviceInfo | None = None
|
||||||
|
self._interface: Interface = Interface.HDMI
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
|
||||||
|
self._curr_signal: SignalFormat | None = None
|
||||||
|
self._curr_timing: TimingSpec | None = None
|
||||||
|
self._curr_pattern: PatternSpec | None = None
|
||||||
|
self._last_applied: tuple[SignalFormat, TimingSpec] | None = None
|
||||||
|
|
||||||
|
# -- 读访问 --------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> UcdState:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info(self) -> DeviceInfo | None:
|
||||||
|
return self._info
|
||||||
|
|
||||||
|
@property
|
||||||
|
def raw_controller(self) -> "UCDController":
|
||||||
|
"""Phase 1 过渡期:给暂未迁移的旧调用点的逃生通道。
|
||||||
|
|
||||||
|
新代码**不**应使用本属性,迁移完成后即可删除。
|
||||||
|
"""
|
||||||
|
return self._controller
|
||||||
|
|
||||||
|
# -- 生命周期 ------------------------------------------------
|
||||||
|
|
||||||
|
def open(self, info: DeviceInfo, *, interface: Interface = Interface.HDMI) -> None:
|
||||||
|
with self._lock:
|
||||||
|
assert_transition(self._state, UcdState.OPENED)
|
||||||
|
if interface is not Interface.HDMI:
|
||||||
|
# Phase 1:底层 UCDController.open() 写死了 HDMISource。
|
||||||
|
raise UcdConfigError(
|
||||||
|
f"暂不支持接口 {interface.value};当前仅实现 HDMI"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ok = self._controller.open(info.display)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise UcdSdkError(f"打开设备失败: {info.display}") from exc
|
||||||
|
if not ok:
|
||||||
|
raise UcdSdkError(f"打开设备失败: {info.display}")
|
||||||
|
self._info = info
|
||||||
|
self._interface = interface
|
||||||
|
self._state = UcdState.OPENED
|
||||||
|
self._bus.publish(ConnectionChanged(True, info.serial))
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._state == UcdState.CLOSED:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._controller.close()
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
log.exception("关闭 UCD 时发生异常")
|
||||||
|
self._state = UcdState.CLOSED
|
||||||
|
self._curr_signal = None
|
||||||
|
self._curr_timing = None
|
||||||
|
self._curr_pattern = None
|
||||||
|
self._last_applied = None
|
||||||
|
prev_serial = self._info.serial if self._info else None
|
||||||
|
self._info = None
|
||||||
|
self._bus.publish(ConnectionChanged(False, prev_serial))
|
||||||
|
|
||||||
|
# -- 配置 ----------------------------------------------------
|
||||||
|
|
||||||
|
def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if self._state == UcdState.CLOSED:
|
||||||
|
raise UcdNotConnected("UCD 未连接,无法 configure")
|
||||||
|
try:
|
||||||
|
# 颜色模式(color_format / bpc / colorimetry)
|
||||||
|
if not self._controller.set_color_mode(
|
||||||
|
signal.color_format.value,
|
||||||
|
int(signal.bpc),
|
||||||
|
_colorimetry_to_legacy_key(signal),
|
||||||
|
):
|
||||||
|
raise UcdConfigError(
|
||||||
|
f"set_color_mode 失败: {signal!r}"
|
||||||
|
)
|
||||||
|
# dynamic_range 在新接口中是一等公民
|
||||||
|
self._apply_dynamic_range(signal)
|
||||||
|
|
||||||
|
# Timing
|
||||||
|
if not self._controller.set_timing_from_string(str(timing)):
|
||||||
|
raise UcdConfigError(f"set_timing_from_string 失败: {timing}")
|
||||||
|
except UcdConfigError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise UcdSdkError("configure 异常") from exc
|
||||||
|
|
||||||
|
self._curr_signal = signal
|
||||||
|
self._curr_timing = timing
|
||||||
|
self._state = UcdState.CONFIGURED
|
||||||
|
return (signal, timing) != self._last_applied
|
||||||
|
|
||||||
|
def set_pattern(self, pattern: PatternSpec) -> None:
|
||||||
|
with self._lock:
|
||||||
|
# Phase 2 过渡:允许从 OPENED 直接 set_pattern——遗留路径
|
||||||
|
# (test_runner 等)通过旧 controller.apply_signal_format 写入
|
||||||
|
# 信号格式,未经过本设备的 configure。此时 self._state 仍为
|
||||||
|
# OPENED,但硬件实际已处于可接收 pattern 状态。
|
||||||
|
if self._state == UcdState.CLOSED:
|
||||||
|
raise UcdNotConnected("UCD 未连接,无法 set_pattern")
|
||||||
|
self._curr_pattern = pattern
|
||||||
|
# 仅本地暂存,真正写硬件在 apply()
|
||||||
|
|
||||||
|
def apply(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if self._state == UcdState.CLOSED:
|
||||||
|
raise UcdNotConnected("UCD 未连接,无法 apply")
|
||||||
|
if self._curr_pattern is None:
|
||||||
|
raise UcdStateError("apply 前必须先 set_pattern")
|
||||||
|
try:
|
||||||
|
ok = self._apply_pattern_via_controller(self._curr_pattern)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
raise UcdSdkError("apply 异常") from exc
|
||||||
|
if not ok:
|
||||||
|
raise UcdApplyFailed(
|
||||||
|
f"apply 失败: pattern={self._curr_pattern.kind.value}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# SignalApplied 事件仅在通过新 API configure 过时发出;
|
||||||
|
# 遗留路径下 self._curr_signal/_curr_timing 可能为 None。
|
||||||
|
if self._curr_signal is not None and self._curr_timing is not None:
|
||||||
|
changed = (self._curr_signal, self._curr_timing) != self._last_applied
|
||||||
|
self._last_applied = (self._curr_signal, self._curr_timing)
|
||||||
|
self._bus.publish(
|
||||||
|
SignalApplied(self._curr_signal, self._curr_timing, changed)
|
||||||
|
)
|
||||||
|
self._state = UcdState.APPLIED
|
||||||
|
self._bus.publish(PatternApplied(self._curr_pattern))
|
||||||
|
|
||||||
|
# -- 查询 ----------------------------------------------------
|
||||||
|
|
||||||
|
def current_resolution(self) -> tuple[int, int]:
|
||||||
|
try:
|
||||||
|
return self._controller.get_current_resolution((3840, 2160))
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return (3840, 2160)
|
||||||
|
|
||||||
|
# -- 内部辅助 ------------------------------------------------
|
||||||
|
|
||||||
|
def _apply_dynamic_range(self, signal: SignalFormat) -> None:
|
||||||
|
import UniTAP # 局部导入,避免本模块在无 SDK 环境下导入即失败
|
||||||
|
|
||||||
|
from app.ucd_domain import DynamicRange
|
||||||
|
|
||||||
|
ci = self._controller.color_info
|
||||||
|
if ci is None:
|
||||||
|
return
|
||||||
|
if signal.dynamic_range is DynamicRange.FULL:
|
||||||
|
ci.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA
|
||||||
|
else:
|
||||||
|
ci.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_CTA
|
||||||
|
|
||||||
|
def _apply_pattern_via_controller(self, pattern: PatternSpec) -> bool:
|
||||||
|
"""根据 PatternKind 走最合适的旧 controller 路径。"""
|
||||||
|
if pattern.kind is PatternKind.IMAGE:
|
||||||
|
if not pattern.image_path:
|
||||||
|
raise UcdConfigError("IMAGE pattern 必须提供 image_path")
|
||||||
|
return bool(self._controller.send_image_pattern(pattern.image_path))
|
||||||
|
|
||||||
|
# 预定义图案路径:复用 controller.set_pattern + run()
|
||||||
|
from drivers.UCD323_Enum import UCDEnum # 局部导入避免循环
|
||||||
|
|
||||||
|
video_pattern = UCDEnum.VideoPatternInfo.get_video_pattern(pattern.kind.value)
|
||||||
|
if video_pattern is None:
|
||||||
|
raise UcdConfigError(f"不支持的 PatternKind: {pattern.kind!r}")
|
||||||
|
self._controller.current_pattern = video_pattern
|
||||||
|
|
||||||
|
params: list[int] | None = None
|
||||||
|
if pattern.kind is PatternKind.SOLID:
|
||||||
|
if pattern.solid_rgb is None:
|
||||||
|
raise UcdConfigError("SOLID pattern 必须提供 solid_rgb")
|
||||||
|
params = list(pattern.solid_rgb)
|
||||||
|
elif pattern.extras:
|
||||||
|
params = list(pattern.extras)
|
||||||
|
|
||||||
|
if not self._controller.set_pattern(video_pattern, params):
|
||||||
|
raise UcdApplyFailed("controller.set_pattern 返回 False")
|
||||||
|
return bool(self._controller.run())
|
||||||
|
|
||||||
|
|
||||||
|
def _colorimetry_to_legacy_key(signal: SignalFormat) -> str:
|
||||||
|
"""新 :class:`Colorimetry` → 旧 ``UCDEnum.ColorInfo.get_colorimetry`` 的 key。
|
||||||
|
|
||||||
|
BT.2020 在 YCbCr / RGB 输出下走不同 SDK 枚举(参考旧
|
||||||
|
``_get_colorimetry_from_color_space`` 的逻辑),这里也做同样的分支。
|
||||||
|
"""
|
||||||
|
from app.ucd_domain import Colorimetry, is_ycbcr
|
||||||
|
|
||||||
|
cm = signal.colorimetry
|
||||||
|
ycbcr = is_ycbcr(signal.color_format)
|
||||||
|
|
||||||
|
if cm is Colorimetry.BT2020:
|
||||||
|
return "bt2020ycbcr" if ycbcr else "bt2020rgb"
|
||||||
|
return {
|
||||||
|
Colorimetry.SRGB: "srgb",
|
||||||
|
Colorimetry.BT709: "bt709",
|
||||||
|
Colorimetry.BT601: "bt601",
|
||||||
|
Colorimetry.DCI_P3: "dcip3",
|
||||||
|
Colorimetry.ADOBE_RGB: "adobergb",
|
||||||
|
}.get(cm, "srgb")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── §4 FakeUcdDevice 单测实现 ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class FakeUcdDevice(IUcdDevice):
|
||||||
|
"""无硬件依赖的 Fake 实现;记录调用序列供单测断言。"""
|
||||||
|
|
||||||
|
def __init__(self, bus: EventBus | None = None) -> None:
|
||||||
|
self._bus = bus or EventBus()
|
||||||
|
self._state = UcdState.CLOSED
|
||||||
|
self._info: DeviceInfo | None = None
|
||||||
|
self._signal: SignalFormat | None = None
|
||||||
|
self._timing: TimingSpec | None = None
|
||||||
|
self._pattern: PatternSpec | None = None
|
||||||
|
self._last_applied: tuple[SignalFormat, TimingSpec] | None = None
|
||||||
|
self.calls: list[tuple] = [] # ("open", info) / ("configure", ...) ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def state(self) -> UcdState:
|
||||||
|
return self._state
|
||||||
|
|
||||||
|
@property
|
||||||
|
def info(self) -> DeviceInfo | None:
|
||||||
|
return self._info
|
||||||
|
|
||||||
|
def open(self, info: DeviceInfo, *, interface: Interface = Interface.HDMI) -> None:
|
||||||
|
assert_transition(self._state, UcdState.OPENED)
|
||||||
|
self.calls.append(("open", info, interface))
|
||||||
|
self._info = info
|
||||||
|
self._state = UcdState.OPENED
|
||||||
|
self._bus.publish(ConnectionChanged(True, info.serial))
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
if self._state == UcdState.CLOSED:
|
||||||
|
return
|
||||||
|
self.calls.append(("close",))
|
||||||
|
self._state = UcdState.CLOSED
|
||||||
|
prev = self._info.serial if self._info else None
|
||||||
|
self._info = None
|
||||||
|
self._signal = self._timing = self._pattern = None
|
||||||
|
self._last_applied = None
|
||||||
|
self._bus.publish(ConnectionChanged(False, prev))
|
||||||
|
|
||||||
|
def configure(self, signal: SignalFormat, timing: TimingSpec) -> bool:
|
||||||
|
if self._state == UcdState.CLOSED:
|
||||||
|
raise UcdNotConnected()
|
||||||
|
self.calls.append(("configure", signal, timing))
|
||||||
|
self._signal, self._timing = signal, timing
|
||||||
|
self._state = UcdState.CONFIGURED
|
||||||
|
return (signal, timing) != self._last_applied
|
||||||
|
|
||||||
|
def set_pattern(self, pattern: PatternSpec) -> None:
|
||||||
|
if self._state not in (UcdState.CONFIGURED, UcdState.APPLIED):
|
||||||
|
raise UcdStateError(f"非法状态 {self._state.name}")
|
||||||
|
self.calls.append(("set_pattern", pattern))
|
||||||
|
self._pattern = pattern
|
||||||
|
|
||||||
|
def apply(self) -> None:
|
||||||
|
if self._signal is None or self._timing is None:
|
||||||
|
raise UcdStateError("apply 前必须 configure")
|
||||||
|
if self._pattern is None:
|
||||||
|
raise UcdStateError("apply 前必须 set_pattern")
|
||||||
|
self.calls.append(("apply",))
|
||||||
|
changed = (self._signal, self._timing) != self._last_applied
|
||||||
|
self._last_applied = (self._signal, self._timing)
|
||||||
|
self._state = UcdState.APPLIED
|
||||||
|
self._bus.publish(SignalApplied(self._signal, self._timing, changed))
|
||||||
|
self._bus.publish(PatternApplied(self._pattern))
|
||||||
|
|
||||||
|
def current_resolution(self) -> tuple[int, int]:
|
||||||
|
if self._timing is None:
|
||||||
|
return (3840, 2160)
|
||||||
|
return (self._timing.width, self._timing.height)
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DeviceInfo",
|
||||||
|
"list_devices",
|
||||||
|
"IUcdDevice",
|
||||||
|
"UCD323Device",
|
||||||
|
"FakeUcdDevice",
|
||||||
|
]
|
||||||
@@ -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
|
|
||||||
|
|
||||||
|
|
||||||
@@ -9,6 +9,9 @@ import traceback
|
|||||||
import matplotlib.pyplot as plt
|
import matplotlib.pyplot as plt
|
||||||
from app_version import APP_NAME, APP_VERSION, get_app_title
|
from app_version import APP_NAME, APP_VERSION, get_app_title
|
||||||
from drivers.UCD323_Function import UCDController
|
from drivers.UCD323_Function import UCDController
|
||||||
|
from drivers.ucd_driver import UCD323Device
|
||||||
|
from app.ucd_domain import EventBus
|
||||||
|
from app.services.ucd_service import SignalService
|
||||||
from app.pq.pq_config import PQConfig
|
from app.pq.pq_config import PQConfig
|
||||||
from app.pq.pq_result import PQResultStore
|
from app.pq.pq_result import PQResultStore
|
||||||
from app.export import (
|
from app.export import (
|
||||||
@@ -16,14 +19,16 @@ from app.export import (
|
|||||||
export_excel_report as _export_excel_report_impl,
|
export_excel_report as _export_excel_report_impl,
|
||||||
EXCEL_EXPORT_CONFIG as _EXCEL_EXPORT_CONFIG,
|
EXCEL_EXPORT_CONFIG as _EXCEL_EXPORT_CONFIG,
|
||||||
)
|
)
|
||||||
from app.views.panels import custom_template_panel as _ctp
|
from app.views.panels.custom_template_panel import CustomTemplatePanelMixin
|
||||||
from app.views.panels import side_panels as _sp
|
from app.views.panels.side_panels import SidePanelsMixin
|
||||||
from app.views.panels import cct_panel as _ccp
|
from app.views.panels.cct_panel import CctPanelMixin
|
||||||
from app.views.panels import main_layout as _main
|
from app.views.panels.main_layout import MainLayoutMixin
|
||||||
from app.views.panels import ai_image_panel as _aip
|
from app.views.panels.ai_image_panel import AIImagePanelMixin
|
||||||
from app.views.panels import single_step_panel as _ssp
|
from app.views.panels.single_step_panel import SingleStepPanelMixin
|
||||||
from app.views.panels import pantone_baseline_panel as _pbp
|
from app.views.panels.pantone_baseline_panel import PantoneBaselinePanelMixin
|
||||||
from app.views import panel_manager as PM
|
from app.views.panels.gamma_pattern_panel import GammaPatternPanelMixin
|
||||||
|
from app.views.panels.calman_panel import CalmanPanelMixin
|
||||||
|
from app.views.panel_manager import PanelManagerMixin
|
||||||
from app.logging_setup import setup_logging, attach_gui_handler
|
from app.logging_setup import setup_logging, attach_gui_handler
|
||||||
|
|
||||||
# Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持
|
# Step 0/1 重构:资源工具和纯算法已迁移到 app/ 包,这里重新导入以保持
|
||||||
@@ -41,77 +46,46 @@ from app.tests.color_accuracy import (
|
|||||||
from app.tests.eotf import calculate_pq_curve as _calc_pq_curve
|
from app.tests.eotf import calculate_pq_curve as _calc_pq_curve
|
||||||
from app.tests.gamma import calculate_gamma as _calc_gamma
|
from app.tests.gamma import calculate_gamma as _calc_gamma
|
||||||
from app.tests.gamut import calculate_gamut_coverage as _calc_gamut_coverage
|
from app.tests.gamut import calculate_gamut_coverage as _calc_gamut_coverage
|
||||||
from app.plots.plot_accuracy import plot_accuracy as _plot_accuracy
|
from app.plots.plot_accuracy import PlotAccuracyMixin
|
||||||
from app.plots.plot_cct import plot_cct as _plot_cct
|
from app.plots.plot_cct import PlotCctMixin
|
||||||
from app.plots.plot_contrast import plot_contrast as _plot_contrast
|
from app.plots.plot_contrast import PlotContrastMixin
|
||||||
from app.plots.plot_eotf import plot_eotf as _plot_eotf
|
from app.plots.plot_eotf import PlotEotfMixin
|
||||||
from app.plots.plot_gamma import plot_gamma as _plot_gamma
|
from app.plots.plot_gamma import PlotGammaMixin
|
||||||
from app.plots.plot_gamut import plot_gamut as _plot_gamut
|
from app.plots.plot_gamut import PlotGamutMixin
|
||||||
from app.views.chart_frame import (
|
from app.views.chart_frame import ChartFrameMixin
|
||||||
clear_chart as _cf_clear_chart,
|
from app.config_io import ConfigIOMixin
|
||||||
create_result_chart_frame as _cf_create_result_chart_frame,
|
from app.tests.local_dimming import LocalDimmingMixin
|
||||||
init_accuracy_chart as _cf_init_accuracy_chart,
|
|
||||||
init_cct_chart as _cf_init_cct_chart,
|
|
||||||
init_contrast_chart as _cf_init_contrast_chart,
|
|
||||||
init_eotf_chart as _cf_init_eotf_chart,
|
|
||||||
init_gamma_chart as _cf_init_gamma_chart,
|
|
||||||
init_gamut_chart as _cf_init_gamut_chart,
|
|
||||||
on_chart_tab_changed as _cf_on_chart_tab_changed,
|
|
||||||
sync_gamut_toolbar as _cf_sync_gamut_toolbar,
|
|
||||||
_on_gamut_toolbar_changed as _cf_on_gamut_toolbar_changed,
|
|
||||||
update_chart_tabs_state as _cf_update_chart_tabs_state,
|
|
||||||
)
|
|
||||||
from app.config_io import (
|
|
||||||
clear_config_file as _cfg_clear_config_file,
|
|
||||||
get_config_path as _cfg_get_config_path,
|
|
||||||
load_pq_config as _cfg_load_pq_config,
|
|
||||||
save_pq_config as _cfg_save_pq_config,
|
|
||||||
)
|
|
||||||
from app.tests.local_dimming import (
|
|
||||||
clear_ld_records as _ld_clear_ld_records,
|
|
||||||
measure_ld_luminance as _ld_measure_ld_luminance,
|
|
||||||
save_local_dimming_results as _ld_save_local_dimming_results,
|
|
||||||
send_ld_window as _ld_send_ld_window,
|
|
||||||
start_local_dimming_test as _ld_start_local_dimming_test,
|
|
||||||
stop_local_dimming_test as _ld_stop_local_dimming_test,
|
|
||||||
update_ld_results as _ld_update_ld_results,
|
|
||||||
)
|
|
||||||
from app.services import PatternService
|
from app.services import PatternService
|
||||||
from app.device.connection import (
|
from app.device.connection import DeviceConnectionMixin
|
||||||
check_com_connections as _dev_check_com_connections,
|
from app.runner.test_runner import TestRunnerMixin
|
||||||
check_port_connection as _dev_check_port_connection,
|
|
||||||
disconnect_com_connections as _dev_disconnect_com_connections,
|
|
||||||
enable_com_widgets as _dev_enable_com_widgets,
|
|
||||||
get_available_com_ports as _dev_get_available_com_ports,
|
|
||||||
get_available_ucd_ports as _dev_get_available_ucd_ports,
|
|
||||||
refresh_com_ports as _dev_refresh_com_ports,
|
|
||||||
update_connection_indicator as _dev_update_connection_indicator,
|
|
||||||
)
|
|
||||||
from app.runner.test_runner import (
|
|
||||||
get_current_test_result as _run_get_current_test_result,
|
|
||||||
new_pq_results as _run_new_pq_results,
|
|
||||||
on_custom_template_test_completed as _run_on_custom_template_test_completed,
|
|
||||||
on_test_completed as _run_on_test_completed,
|
|
||||||
on_test_error as _run_on_test_error,
|
|
||||||
run_custom_sdr_test as _run_run_custom_sdr_test,
|
|
||||||
run_hdr_movie_test as _run_run_hdr_movie_test,
|
|
||||||
run_screen_module_test as _run_run_screen_module_test,
|
|
||||||
run_sdr_movie_test as _run_run_sdr_movie_test,
|
|
||||||
run_test as _run_run_test,
|
|
||||||
send_fix_pattern as _run_send_fix_pattern,
|
|
||||||
test_cct as _run_test_cct,
|
|
||||||
test_color_accuracy as _run_test_color_accuracy,
|
|
||||||
test_contrast as _run_test_contrast,
|
|
||||||
test_custom_sdr as _run_test_custom_sdr,
|
|
||||||
test_eotf as _run_test_eotf,
|
|
||||||
test_gamma as _run_test_gamma,
|
|
||||||
test_gamut as _run_test_gamut,
|
|
||||||
)
|
|
||||||
|
|
||||||
plt.rcParams["font.family"] = ["sans-serif"]
|
plt.rcParams["font.family"] = ["sans-serif"]
|
||||||
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"]
|
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"]
|
||||||
|
|
||||||
class PQAutomationApp:
|
|
||||||
|
class PQAutomationApp(
|
||||||
|
ConfigIOMixin,
|
||||||
|
ChartFrameMixin,
|
||||||
|
MainLayoutMixin,
|
||||||
|
CctPanelMixin,
|
||||||
|
DeviceConnectionMixin,
|
||||||
|
CustomTemplatePanelMixin,
|
||||||
|
SidePanelsMixin,
|
||||||
|
AIImagePanelMixin,
|
||||||
|
SingleStepPanelMixin,
|
||||||
|
PantoneBaselinePanelMixin,
|
||||||
|
GammaPatternPanelMixin,
|
||||||
|
CalmanPanelMixin,
|
||||||
|
LocalDimmingMixin,
|
||||||
|
PanelManagerMixin,
|
||||||
|
TestRunnerMixin,
|
||||||
|
PlotGamutMixin,
|
||||||
|
PlotGammaMixin,
|
||||||
|
PlotEotfMixin,
|
||||||
|
PlotCctMixin,
|
||||||
|
PlotContrastMixin,
|
||||||
|
PlotAccuracyMixin,
|
||||||
|
):
|
||||||
def __init__(self, root):
|
def __init__(self, root):
|
||||||
self.root = root
|
self.root = root
|
||||||
self.root.title(get_app_title())
|
self.root.title(get_app_title())
|
||||||
@@ -126,7 +100,20 @@ class PQAutomationApp:
|
|||||||
|
|
||||||
# 初始化设备连接状态
|
# 初始化设备连接状态
|
||||||
self.ca = None # CA410色度计
|
self.ca = None # CA410色度计
|
||||||
self.ucd = UCDController() # 信号发生器
|
self.ucd = UCDController() # 信号发生器(旧接口,过渡期保留)
|
||||||
|
|
||||||
|
# 新架构:EventBus + 设备抽象 + 服务层。
|
||||||
|
# UCD323Device 内部委托 self.ucd,保证零行为变更;
|
||||||
|
# 新代码统一走 self.signal_service。
|
||||||
|
self.event_bus = EventBus()
|
||||||
|
self.ucd_device = UCD323Device(self.event_bus, self.ucd)
|
||||||
|
self.signal_service = SignalService(self.ucd_device, self.event_bus)
|
||||||
|
|
||||||
|
# 连接控制器:统一管理 CA/UCD 生命周期。
|
||||||
|
# 旧的 check_com_connections / disconnect_com_connections 等模块级
|
||||||
|
# 函数仍以类属性形式挂在 PQAutomationApp 上,内部全部委托给本对象。
|
||||||
|
from app.device.connection import ConnectionController
|
||||||
|
self.connection = ConnectionController(self)
|
||||||
|
|
||||||
# 初始化测试状态
|
# 初始化测试状态
|
||||||
self.testing = False
|
self.testing = False
|
||||||
@@ -162,12 +149,12 @@ class PQAutomationApp:
|
|||||||
self.log_visible = False
|
self.log_visible = False
|
||||||
|
|
||||||
# 创建左侧面板
|
# 创建左侧面板
|
||||||
self.left_frame = ttk.Frame(self.main_frame, width=180)
|
self.left_frame = ttk.Frame(self.main_frame, width=208)
|
||||||
self.left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=0, pady=5)
|
self.left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=0, pady=5)
|
||||||
self.left_frame.pack_propagate(False)
|
self.left_frame.pack_propagate(False)
|
||||||
|
|
||||||
# 创建左侧导航栏
|
# 创建左侧导航栏
|
||||||
self.sidebar_frame = ttk.Frame(self.left_frame, bootstyle="primary")
|
self.sidebar_frame = ttk.Frame(self.left_frame, style="Sidebar.TFrame")
|
||||||
self.sidebar_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=5)
|
self.sidebar_frame.pack(fill=tk.BOTH, expand=True, padx=0, pady=5)
|
||||||
# self.sidebar_frame.pack_propagate(False)
|
# self.sidebar_frame.pack_propagate(False)
|
||||||
|
|
||||||
@@ -196,8 +183,8 @@ class PQAutomationApp:
|
|||||||
# 创建右上角悬浮配置框
|
# 创建右上角悬浮配置框
|
||||||
self.create_floating_config_panel()
|
self.create_floating_config_panel()
|
||||||
|
|
||||||
# 创建右侧结果显示区域
|
# 创建右侧结果显示区域(无边框,纯 Frame,让图表占满)
|
||||||
self.result_frame = ttk.LabelFrame(self.control_frame_middle, text="测试结果")
|
self.result_frame = ttk.Frame(self.control_frame_middle)
|
||||||
self.result_frame.pack(
|
self.result_frame.pack(
|
||||||
side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=5
|
side=tk.BOTTOM, fill=tk.BOTH, expand=True, padx=0, pady=5
|
||||||
)
|
)
|
||||||
@@ -212,6 +199,10 @@ class PQAutomationApp:
|
|||||||
# self.create_single_step_panel()
|
# self.create_single_step_panel()
|
||||||
# 创建 Pantone 认证摸底测试面板
|
# 创建 Pantone 认证摸底测试面板
|
||||||
self.create_pantone_baseline_panel()
|
self.create_pantone_baseline_panel()
|
||||||
|
# 创建 Gamma 测试图案配置面板
|
||||||
|
self.create_gamma_pattern_panel()
|
||||||
|
# 创建 CALMAN 风格灰阶测试面板
|
||||||
|
self.create_calman_panel()
|
||||||
# 创建测试类型选择区域
|
# 创建测试类型选择区域
|
||||||
self.create_test_type_frame()
|
self.create_test_type_frame()
|
||||||
# 创建操作按钮区域
|
# 创建操作按钮区域
|
||||||
@@ -223,12 +214,24 @@ class PQAutomationApp:
|
|||||||
# 在所有控件创建完成后,统一初始化测试类型
|
# 在所有控件创建完成后,统一初始化测试类型
|
||||||
self.root.after(100, self.initialize_default_test_type)
|
self.root.after(100, self.initialize_default_test_type)
|
||||||
|
|
||||||
# 状态栏
|
# 状态栏(现代化扁平条,跟随 ttkbootstrap 主题)
|
||||||
self.status_var = tk.StringVar(value="就绪")
|
self.status_var = tk.StringVar(value="\u25cf 就绪")
|
||||||
|
status_container = ttk.Frame(root, style="StatusBar.TFrame")
|
||||||
|
status_container.pack(side=tk.BOTTOM, fill=tk.X)
|
||||||
self.status_bar = ttk.Label(
|
self.status_bar = ttk.Label(
|
||||||
root, textvariable=self.status_var, relief=tk.SUNKEN, anchor=tk.W
|
status_container,
|
||||||
|
textvariable=self.status_var,
|
||||||
|
style="StatusBar.TLabel",
|
||||||
|
anchor=tk.W,
|
||||||
)
|
)
|
||||||
self.status_bar.pack(side=tk.BOTTOM, fill=tk.X)
|
self.status_bar.pack(side=tk.LEFT, fill=tk.X, expand=True)
|
||||||
|
# 右侧版本号
|
||||||
|
ttk.Label(
|
||||||
|
status_container,
|
||||||
|
text=f"v{APP_VERSION}",
|
||||||
|
style="StatusBarAccent.TLabel",
|
||||||
|
anchor=tk.E,
|
||||||
|
).pack(side=tk.RIGHT)
|
||||||
|
|
||||||
def _dispatch_ui(self, fn, *args, **kwargs):
|
def _dispatch_ui(self, fn, *args, **kwargs):
|
||||||
"""把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。
|
"""把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。
|
||||||
@@ -261,111 +264,6 @@ class PQAutomationApp:
|
|||||||
if hasattr(self, "log_gui"):
|
if hasattr(self, "log_gui"):
|
||||||
self.log_gui.log(f"初始化默认测试类型失败: {str(e)}", level="error")
|
self.log_gui.log(f"初始化默认测试类型失败: {str(e)}", level="error")
|
||||||
|
|
||||||
get_config_path = _cfg_get_config_path
|
|
||||||
load_pq_config = _cfg_load_pq_config
|
|
||||||
save_pq_config = _cfg_save_pq_config
|
|
||||||
|
|
||||||
register_panel = PM.register_panel
|
|
||||||
show_panel = PM.show_panel
|
|
||||||
hide_all_panels = PM.hide_all_panels
|
|
||||||
|
|
||||||
init_gamut_chart = _cf_init_gamut_chart
|
|
||||||
init_gamma_chart = _cf_init_gamma_chart
|
|
||||||
init_eotf_chart = _cf_init_eotf_chart
|
|
||||||
init_cct_chart = _cf_init_cct_chart
|
|
||||||
init_contrast_chart = _cf_init_contrast_chart
|
|
||||||
init_accuracy_chart = _cf_init_accuracy_chart
|
|
||||||
clear_chart = _cf_clear_chart
|
|
||||||
create_result_chart_frame = _cf_create_result_chart_frame
|
|
||||||
on_chart_tab_changed = _cf_on_chart_tab_changed
|
|
||||||
sync_gamut_toolbar = _cf_sync_gamut_toolbar
|
|
||||||
_on_gamut_toolbar_changed = _cf_on_gamut_toolbar_changed
|
|
||||||
|
|
||||||
create_floating_config_panel = _main.create_floating_config_panel
|
|
||||||
create_test_items_content = _main.create_test_items_content
|
|
||||||
create_signal_format_content = _main.create_signal_format_content
|
|
||||||
create_connection_content = _main.create_connection_content
|
|
||||||
create_operation_frame = _main.create_operation_frame
|
|
||||||
create_test_type_frame = _main.create_test_type_frame
|
|
||||||
update_config_info_display = _main.update_config_info_display
|
|
||||||
on_screen_module_timing_changed = _main.on_screen_module_timing_changed
|
|
||||||
on_sdr_timing_changed = _main.on_sdr_timing_changed
|
|
||||||
update_test_items = _main.update_test_items
|
|
||||||
on_test_type_change = _main.on_test_type_change
|
|
||||||
on_sdr_output_format_changed = _main.on_sdr_output_format_changed
|
|
||||||
on_hdr_output_format_changed = _main.on_hdr_output_format_changed
|
|
||||||
|
|
||||||
create_cct_params_frame = _ccp.create_cct_params_frame
|
|
||||||
on_sdr_cct_param_focus_out = _ccp.on_sdr_cct_param_focus_out
|
|
||||||
save_sdr_cct_params = _ccp.save_sdr_cct_params
|
|
||||||
on_hdr_cct_param_focus_out = _ccp.on_hdr_cct_param_focus_out
|
|
||||||
save_hdr_cct_params = _ccp.save_hdr_cct_params
|
|
||||||
recalculate_cct = _ccp.recalculate_cct
|
|
||||||
recalculate_gamut = _ccp.recalculate_gamut
|
|
||||||
on_cct_param_focus_out = _ccp.on_cct_param_focus_out
|
|
||||||
save_cct_params = _ccp.save_cct_params
|
|
||||||
reload_cct_params = _ccp.reload_cct_params
|
|
||||||
toggle_cct_params_frame = _ccp.toggle_cct_params_frame
|
|
||||||
on_screen_gamut_ref_changed = _ccp.on_screen_gamut_ref_changed
|
|
||||||
on_sdr_gamut_ref_changed = _ccp.on_sdr_gamut_ref_changed
|
|
||||||
on_hdr_gamut_ref_changed = _ccp.on_hdr_gamut_ref_changed
|
|
||||||
|
|
||||||
get_available_ucd_ports = _dev_get_available_ucd_ports
|
|
||||||
get_available_com_ports = _dev_get_available_com_ports
|
|
||||||
refresh_com_ports = _dev_refresh_com_ports
|
|
||||||
check_com_connections = _dev_check_com_connections
|
|
||||||
update_connection_indicator = _dev_update_connection_indicator
|
|
||||||
check_port_connection = _dev_check_port_connection
|
|
||||||
enable_com_widgets = _dev_enable_com_widgets
|
|
||||||
disconnect_com_connections = _dev_disconnect_com_connections
|
|
||||||
|
|
||||||
create_custom_template_result_panel = _ctp.create_custom_template_result_panel
|
|
||||||
show_custom_result_context_menu = _ctp.show_custom_result_context_menu
|
|
||||||
set_custom_result_table_locked = _ctp.set_custom_result_table_locked
|
|
||||||
start_custom_row_single_step = _ctp.start_custom_row_single_step
|
|
||||||
copy_custom_result_table = _ctp.copy_custom_result_table
|
|
||||||
clear_custom_template_results = _ctp.clear_custom_template_results
|
|
||||||
auto_expand_custom_result_view = _ctp.auto_expand_custom_result_view
|
|
||||||
append_custom_template_result = _ctp.append_custom_template_result
|
|
||||||
start_custom_template_test = _ctp.start_custom_template_test
|
|
||||||
update_custom_button_visibility = _ctp.update_custom_button_visibility
|
|
||||||
export_custom_template_excel = _ctp.export_custom_template_excel
|
|
||||||
export_custom_template_charts = _ctp.export_custom_template_charts
|
|
||||||
|
|
||||||
create_log_panel = _sp.create_log_panel
|
|
||||||
create_local_dimming_panel = _sp.create_local_dimming_panel
|
|
||||||
toggle_local_dimming_panel = _sp.toggle_local_dimming_panel
|
|
||||||
toggle_log_panel = _sp.toggle_log_panel
|
|
||||||
update_sidebar_selection = _sp.update_sidebar_selection
|
|
||||||
|
|
||||||
# ---- AI 图片对话面板 ----
|
|
||||||
create_ai_image_panel = _aip.create_ai_image_panel
|
|
||||||
toggle_ai_image_panel = _aip.toggle_ai_image_panel
|
|
||||||
reload_ai_image_list = _aip.reload_ai_image_list
|
|
||||||
|
|
||||||
# ---- 单步调试面板 ----
|
|
||||||
create_single_step_panel = _ssp.create_single_step_panel
|
|
||||||
toggle_single_step_panel = _ssp.toggle_single_step_panel
|
|
||||||
|
|
||||||
# ---- Pantone 认证摸底测试面板 ----
|
|
||||||
create_pantone_baseline_panel = _pbp.create_pantone_baseline_panel
|
|
||||||
toggle_pantone_baseline_panel = _pbp.toggle_pantone_baseline_panel
|
|
||||||
|
|
||||||
# ---- 单步调试面板(统一实现,委托到 side_panels 模块) ----
|
|
||||||
_toggle_debug_panel = _sp._toggle_debug_panel
|
|
||||||
toggle_screen_debug_panel = _sp.toggle_screen_debug_panel
|
|
||||||
toggle_sdr_debug_panel = _sp.toggle_sdr_debug_panel
|
|
||||||
toggle_hdr_debug_panel = _sp.toggle_hdr_debug_panel
|
|
||||||
|
|
||||||
clear_config_file = _cfg_clear_config_file
|
|
||||||
start_local_dimming_test = _ld_start_local_dimming_test
|
|
||||||
update_ld_results = _ld_update_ld_results
|
|
||||||
stop_local_dimming_test = _ld_stop_local_dimming_test
|
|
||||||
send_ld_window = _ld_send_ld_window
|
|
||||||
measure_ld_luminance = _ld_measure_ld_luminance
|
|
||||||
clear_ld_records = _ld_clear_ld_records
|
|
||||||
save_local_dimming_results = _ld_save_local_dimming_results
|
|
||||||
|
|
||||||
def _save_current_cct_params(self, swallow_errors=True):
|
def _save_current_cct_params(self, swallow_errors=True):
|
||||||
"""按当前测试类型分发保存对应的 CCT 参数。"""
|
"""按当前测试类型分发保存对应的 CCT 参数。"""
|
||||||
try:
|
try:
|
||||||
@@ -460,10 +358,11 @@ class PQAutomationApp:
|
|||||||
"screen_module": 0,
|
"screen_module": 0,
|
||||||
"sdr_movie": 1,
|
"sdr_movie": 1,
|
||||||
"hdr_movie": 2,
|
"hdr_movie": 2,
|
||||||
|
"local_dimming": 3,
|
||||||
}
|
}
|
||||||
target_tab = tab_mapping.get(test_type, 0)
|
target_tab = tab_mapping.get(test_type, 0)
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(4):
|
||||||
self.signal_tabs.tab(i, state="normal")
|
self.signal_tabs.tab(i, state="normal")
|
||||||
|
|
||||||
self.signal_tabs.select(target_tab)
|
self.signal_tabs.select(target_tab)
|
||||||
@@ -476,8 +375,10 @@ class PQAutomationApp:
|
|||||||
self.sdr_signal_frame.tkraise()
|
self.sdr_signal_frame.tkraise()
|
||||||
elif target_tab == 2:
|
elif target_tab == 2:
|
||||||
self.hdr_signal_frame.tkraise()
|
self.hdr_signal_frame.tkraise()
|
||||||
|
elif target_tab == 3:
|
||||||
|
self.local_dimming_signal_frame.tkraise()
|
||||||
|
|
||||||
for i in range(3):
|
for i in range(4):
|
||||||
if i != target_tab:
|
if i != target_tab:
|
||||||
self.signal_tabs.tab(i, state="disabled")
|
self.signal_tabs.tab(i, state="disabled")
|
||||||
|
|
||||||
@@ -499,16 +400,16 @@ class PQAutomationApp:
|
|||||||
|
|
||||||
if test_type == "hdr_movie":
|
if test_type == "hdr_movie":
|
||||||
if gamma_tab_id in current_tabs:
|
if gamma_tab_id in current_tabs:
|
||||||
gamma_index = current_tabs.index(gamma_tab_id)
|
self.chart_notebook.forget(self.gamma_chart_frame)
|
||||||
self.chart_notebook.forget(gamma_index)
|
|
||||||
if eotf_tab_id not in current_tabs:
|
if eotf_tab_id not in current_tabs:
|
||||||
self.chart_notebook.insert(1, self.eotf_chart_frame, text="EOTF 曲线")
|
insert_pos = min(1, len(self.chart_notebook.tabs()))
|
||||||
|
self.chart_notebook.insert(insert_pos, self.eotf_chart_frame, text="EOTF 曲线")
|
||||||
else:
|
else:
|
||||||
if eotf_tab_id in current_tabs:
|
if eotf_tab_id in current_tabs:
|
||||||
eotf_index = current_tabs.index(eotf_tab_id)
|
self.chart_notebook.forget(self.eotf_chart_frame)
|
||||||
self.chart_notebook.forget(eotf_index)
|
|
||||||
if gamma_tab_id not in current_tabs:
|
if gamma_tab_id not in current_tabs:
|
||||||
self.chart_notebook.insert(1, self.gamma_chart_frame, text="Gamma 曲线")
|
insert_pos = min(1, len(self.chart_notebook.tabs()))
|
||||||
|
self.chart_notebook.insert(insert_pos, self.gamma_chart_frame, text="Gamma 曲线")
|
||||||
|
|
||||||
custom_tab_id = str(self.custom_template_tab_frame)
|
custom_tab_id = str(self.custom_template_tab_frame)
|
||||||
current_tabs = list(self.chart_notebook.tabs())
|
current_tabs = list(self.chart_notebook.tabs())
|
||||||
@@ -534,6 +435,7 @@ class PQAutomationApp:
|
|||||||
"ai_image",
|
"ai_image",
|
||||||
"single_step",
|
"single_step",
|
||||||
"pantone_baseline",
|
"pantone_baseline",
|
||||||
|
"gamma_pattern",
|
||||||
):
|
):
|
||||||
self.hide_all_panels()
|
self.hide_all_panels()
|
||||||
self._save_cct_params_before_test_type_switch()
|
self._save_cct_params_before_test_type_switch()
|
||||||
@@ -634,6 +536,8 @@ class PQAutomationApp:
|
|||||||
return "开始 SDR Movie 测试,请设置正确的图像模式"
|
return "开始 SDR Movie 测试,请设置正确的图像模式"
|
||||||
if test_type == "hdr_movie":
|
if test_type == "hdr_movie":
|
||||||
return "开始 HDR Movie 测试,请设置正确的图像模式"
|
return "开始 HDR Movie 测试,请设置正确的图像模式"
|
||||||
|
if test_type == "local_dimming":
|
||||||
|
return "Local Dimming 为手动模式,请在右侧面板发送图案并采集数据"
|
||||||
return f"开始{self.get_test_type_name(test_type)}测试"
|
return f"开始{self.get_test_type_name(test_type)}测试"
|
||||||
|
|
||||||
def _launch_test_thread(self, test_type, test_items):
|
def _launch_test_thread(self, test_type, test_items):
|
||||||
@@ -827,21 +731,8 @@ class PQAutomationApp:
|
|||||||
self.log_gui.log(traceback.format_exc(), level="error")
|
self.log_gui.log(traceback.format_exc(), level="error")
|
||||||
messagebox.showerror("错误", f"保存测试结果失败: {str(e)}")
|
messagebox.showerror("错误", f"保存测试结果失败: {str(e)}")
|
||||||
|
|
||||||
new_pq_results = _run_new_pq_results
|
# 纯算法函数:作为 staticmethod 保留在主类(不依赖 self,且 calculate_xxx
|
||||||
run_test = _run_run_test
|
# 的命名空间由历史代码以 self.calculate_xxx 调用)。
|
||||||
run_screen_module_test = _run_run_screen_module_test
|
|
||||||
run_custom_sdr_test = _run_run_custom_sdr_test
|
|
||||||
run_sdr_movie_test = _run_run_sdr_movie_test
|
|
||||||
run_hdr_movie_test = _run_run_hdr_movie_test
|
|
||||||
send_fix_pattern = _run_send_fix_pattern
|
|
||||||
test_custom_sdr = _run_test_custom_sdr
|
|
||||||
test_gamut = _run_test_gamut
|
|
||||||
test_gamma = _run_test_gamma
|
|
||||||
test_eotf = _run_test_eotf
|
|
||||||
test_cct = _run_test_cct
|
|
||||||
test_contrast = _run_test_contrast
|
|
||||||
test_color_accuracy = _run_test_color_accuracy
|
|
||||||
|
|
||||||
calculate_delta_e_2000 = staticmethod(_calc_delta_e_2000)
|
calculate_delta_e_2000 = staticmethod(_calc_delta_e_2000)
|
||||||
get_accuracy_color_standards = staticmethod(_get_accuracy_color_standards)
|
get_accuracy_color_standards = staticmethod(_get_accuracy_color_standards)
|
||||||
calculate_gamut_coverage = staticmethod(_calc_gamut_coverage)
|
calculate_gamut_coverage = staticmethod(_calc_gamut_coverage)
|
||||||
@@ -849,20 +740,6 @@ class PQAutomationApp:
|
|||||||
calculate_color_accuracy = staticmethod(_calc_color_accuracy)
|
calculate_color_accuracy = staticmethod(_calc_color_accuracy)
|
||||||
calculate_pq_curve = staticmethod(_calc_pq_curve)
|
calculate_pq_curve = staticmethod(_calc_pq_curve)
|
||||||
|
|
||||||
plot_gamut = _plot_gamut
|
|
||||||
plot_gamma = _plot_gamma
|
|
||||||
plot_eotf = _plot_eotf
|
|
||||||
plot_cct = _plot_cct
|
|
||||||
plot_contrast = _plot_contrast
|
|
||||||
plot_accuracy = _plot_accuracy
|
|
||||||
|
|
||||||
on_test_completed = _run_on_test_completed
|
|
||||||
on_custom_template_test_completed = _run_on_custom_template_test_completed
|
|
||||||
get_current_test_result = _run_get_current_test_result
|
|
||||||
on_test_error = _run_on_test_error
|
|
||||||
|
|
||||||
update_chart_tabs_state = _cf_update_chart_tabs_state
|
|
||||||
|
|
||||||
def get_test_type_name(self, test_type):
|
def get_test_type_name(self, test_type):
|
||||||
"""获取测试类型的显示名称"""
|
"""获取测试类型的显示名称"""
|
||||||
if test_type == "screen_module":
|
if test_type == "screen_module":
|
||||||
@@ -871,6 +748,8 @@ class PQAutomationApp:
|
|||||||
return "SDR Movie测试"
|
return "SDR Movie测试"
|
||||||
elif test_type == "hdr_movie":
|
elif test_type == "hdr_movie":
|
||||||
return "HDR Movie测试"
|
return "HDR Movie测试"
|
||||||
|
elif test_type == "local_dimming":
|
||||||
|
return "Local Dimming"
|
||||||
return test_type
|
return test_type
|
||||||
|
|
||||||
def get_selected_test_items(self):
|
def get_selected_test_items(self):
|
||||||
@@ -892,8 +771,19 @@ class PQAutomationApp:
|
|||||||
|
|
||||||
# 保存当前选中的测试项到配置
|
# 保存当前选中的测试项到配置
|
||||||
self.config.set_current_test_items(self.get_selected_test_items())
|
self.config.set_current_test_items(self.get_selected_test_items())
|
||||||
# 待修改为三种测试类型的timing值
|
# 按当前测试类型保存对应 timing,避免误覆盖其它测试类型配置。
|
||||||
self.config.set_current_timing(self.screen_module_timing_var.get())
|
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()
|
self.save_pq_config()
|
||||||
|
|
||||||
@@ -912,6 +802,13 @@ class PQAutomationApp:
|
|||||||
# 控制参数框的显示
|
# 控制参数框的显示
|
||||||
self.toggle_cct_params_frame()
|
self.toggle_cct_params_frame()
|
||||||
|
|
||||||
|
# 同步刷新顶部 header 折叠预览(现代化布局新增)
|
||||||
|
if hasattr(self, "refresh_config_preview"):
|
||||||
|
try:
|
||||||
|
self.refresh_config_preview()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
def on_closing(self):
|
def on_closing(self):
|
||||||
"""窗口关闭时的处理"""
|
"""窗口关闭时的处理"""
|
||||||
try:
|
try:
|
||||||
@@ -939,8 +836,10 @@ class PQAutomationApp:
|
|||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
setup_logging()
|
setup_logging()
|
||||||
# root = tk.Tk()
|
# 先以浅色主题启动 Window,再根据用户偏好(含自定义 Calman 深色主题)切换
|
||||||
root = ttk.Window(themename="yeti")
|
root = ttk.Window(themename="yeti")
|
||||||
|
from app.views.theme_manager import apply_initial_theme
|
||||||
|
apply_initial_theme()
|
||||||
app = PQAutomationApp(root)
|
app = PQAutomationApp(root)
|
||||||
# GUI 创建完成后,把 logging 记录同步到日志面板
|
# GUI 创建完成后,把 logging 记录同步到日志面板
|
||||||
if hasattr(app, "log_gui"):
|
if hasattr(app, "log_gui"):
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ a = Analysis(
|
|||||||
'drivers.tvSerail',
|
'drivers.tvSerail',
|
||||||
'drivers.UCD323_Enum',
|
'drivers.UCD323_Enum',
|
||||||
'drivers.UCD323_Function',
|
'drivers.UCD323_Function',
|
||||||
'drivers.ucd_helpers',
|
'drivers.ucd_driver',
|
||||||
|
'app.ucd_domain',
|
||||||
|
'app.services.ucd_service',
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"current_test_type": "sdr_movie",
|
"current_test_type": "screen_module",
|
||||||
"test_types": {
|
"test_types": {
|
||||||
"screen_module": {
|
"screen_module": {
|
||||||
"name": "屏模组性能测试",
|
"name": "屏模组性能测试",
|
||||||
@@ -9,10 +9,17 @@
|
|||||||
"cct",
|
"cct",
|
||||||
"contrast"
|
"contrast"
|
||||||
],
|
],
|
||||||
"timing": "DMT 1600x 1200 @ 60Hz",
|
"timing": "OVT 1280x 720 @ 120Hz",
|
||||||
|
"data_range": "Full",
|
||||||
"color_format": "RGB",
|
"color_format": "RGB",
|
||||||
"bpc": 8,
|
"bpc": 8,
|
||||||
"colorimetry": "sRGB",
|
"colorimetry": "DCI-P3",
|
||||||
|
"patterns": {
|
||||||
|
"gamut": "rgb",
|
||||||
|
"gamma": "gray",
|
||||||
|
"cct": "gray",
|
||||||
|
"contrast": "rgb"
|
||||||
|
},
|
||||||
"cct_params": {
|
"cct_params": {
|
||||||
"x_ideal": 0.3127,
|
"x_ideal": 0.3127,
|
||||||
"x_tolerance": 0.003,
|
"x_tolerance": 0.003,
|
||||||
@@ -24,19 +31,28 @@
|
|||||||
"sdr_movie": {
|
"sdr_movie": {
|
||||||
"name": "SDR Movie测试",
|
"name": "SDR Movie测试",
|
||||||
"test_items": [
|
"test_items": [
|
||||||
"gamut"
|
"gamut",
|
||||||
|
"accuracy"
|
||||||
],
|
],
|
||||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
"timing": "OVT 1280x 720 @ 120Hz",
|
||||||
|
"data_range": "Full",
|
||||||
"color_format": "RGB",
|
"color_format": "RGB",
|
||||||
"bpc": 8,
|
"bpc": 8,
|
||||||
"colorimetry": "sRGB",
|
"colorimetry": "sRGB",
|
||||||
|
"patterns": {
|
||||||
|
"gamut": "rgb",
|
||||||
|
"gamma": "gray",
|
||||||
|
"cct": "gray",
|
||||||
|
"contrast": "rgb",
|
||||||
|
"accuracy": "accuracy"
|
||||||
|
},
|
||||||
"cct_params": {
|
"cct_params": {
|
||||||
"x_ideal": 0.3127,
|
"x_ideal": 0.3127,
|
||||||
"x_tolerance": 0.003,
|
"x_tolerance": 0.003,
|
||||||
"y_ideal": 0.329,
|
"y_ideal": 0.329,
|
||||||
"y_tolerance": 0.003
|
"y_tolerance": 0.003
|
||||||
},
|
},
|
||||||
"gamut_reference": "BT.2020"
|
"gamut_reference": "DCI-P3"
|
||||||
},
|
},
|
||||||
"hdr_movie": {
|
"hdr_movie": {
|
||||||
"name": "HDR Movie测试",
|
"name": "HDR Movie测试",
|
||||||
@@ -48,15 +64,33 @@
|
|||||||
"accuracy"
|
"accuracy"
|
||||||
],
|
],
|
||||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||||
|
"data_range": "Full",
|
||||||
"color_format": "RGB",
|
"color_format": "RGB",
|
||||||
"bpc": 8,
|
"bpc": 8,
|
||||||
"colorimetry": "sRGB",
|
"colorimetry": "sRGB",
|
||||||
|
"patterns": {
|
||||||
|
"gamut": "rgb",
|
||||||
|
"eotf": "gray",
|
||||||
|
"cct": "gray",
|
||||||
|
"contrast": "rgb",
|
||||||
|
"accuracy": "accuracy"
|
||||||
|
},
|
||||||
"cct_params": {
|
"cct_params": {
|
||||||
"x_ideal": 0.3127,
|
"x_ideal": 0.3127,
|
||||||
"x_tolerance": 0.003,
|
"x_tolerance": 0.003,
|
||||||
"y_ideal": 0.329,
|
"y_ideal": 0.329,
|
||||||
"y_tolerance": 0.003
|
"y_tolerance": 0.003
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"local_dimming": {
|
||||||
|
"name": "Local Dimming",
|
||||||
|
"test_items": [],
|
||||||
|
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||||
|
"data_range": "Full",
|
||||||
|
"color_format": "RGB",
|
||||||
|
"bpc": 8,
|
||||||
|
"colorimetry": "sRGB",
|
||||||
|
"patterns": {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"device_config": {
|
"device_config": {
|
||||||
|
|||||||
188
tools/demo_accuracy_plot.py
Normal file
188
tools/demo_accuracy_plot.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
"""离线色准图 Demo。
|
||||||
|
|
||||||
|
运行后会在 tools/demo_outputs/ 下生成一张 PNG,
|
||||||
|
用于在没有 UCD 设备时预览当前色准图表的 Calman 风格布局。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import math
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
plt.rcParams["font.family"] = ["sans-serif"]
|
||||||
|
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"]
|
||||||
|
plt.rcParams["axes.unicode_minus"] = False
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(REPO_ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(REPO_ROOT))
|
||||||
|
|
||||||
|
from app.plots.plot_accuracy import plot_accuracy
|
||||||
|
from app.tests.color_accuracy import (
|
||||||
|
calculate_delta_e_2000,
|
||||||
|
get_accuracy_color_standards,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
COLOR_NAMES = [
|
||||||
|
"White",
|
||||||
|
"Gray 80",
|
||||||
|
"Gray 65",
|
||||||
|
"Gray 50",
|
||||||
|
"Gray 35",
|
||||||
|
"Dark Skin",
|
||||||
|
"Light Skin",
|
||||||
|
"Blue Sky",
|
||||||
|
"Foliage",
|
||||||
|
"Blue Flower",
|
||||||
|
"Bluish Green",
|
||||||
|
"Orange",
|
||||||
|
"Purplish Blue",
|
||||||
|
"Moderate Red",
|
||||||
|
"Purple",
|
||||||
|
"Yellow Green",
|
||||||
|
"Orange Yellow",
|
||||||
|
"Blue (Legacy)",
|
||||||
|
"Green (Legacy)",
|
||||||
|
"Red (Legacy)",
|
||||||
|
"Yellow (Legacy)",
|
||||||
|
"Magenta (Legacy)",
|
||||||
|
"Cyan (Legacy)",
|
||||||
|
"100% Red",
|
||||||
|
"100% Green",
|
||||||
|
"100% Blue",
|
||||||
|
"100% Cyan",
|
||||||
|
"100% Magenta",
|
||||||
|
"100% Yellow",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyNotebook:
|
||||||
|
def select(self, *_args, **_kwargs):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _DummyCanvas:
|
||||||
|
def draw(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class _DemoApp:
|
||||||
|
def __init__(self, fig):
|
||||||
|
self.accuracy_fig = fig
|
||||||
|
self.accuracy_canvas = _DummyCanvas()
|
||||||
|
self.chart_notebook = _DummyNotebook()
|
||||||
|
self.accuracy_chart_frame = object()
|
||||||
|
|
||||||
|
def get_test_type_name(self, test_type):
|
||||||
|
mapping = {
|
||||||
|
"sdr_movie": "SDR Movie",
|
||||||
|
"hdr_movie": "HDR Movie",
|
||||||
|
"screen_module": "屏模组",
|
||||||
|
}
|
||||||
|
return mapping.get(test_type, str(test_type))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_demo_data(test_type: str = "sdr_movie"):
|
||||||
|
standards = get_accuracy_color_standards(test_type)
|
||||||
|
rng = np.random.default_rng(20260527)
|
||||||
|
|
||||||
|
measured = []
|
||||||
|
color_patches = []
|
||||||
|
delta_e_values = []
|
||||||
|
|
||||||
|
for idx, name in enumerate(COLOR_NAMES):
|
||||||
|
sx, sy = standards[name]
|
||||||
|
|
||||||
|
# 构造一些“看起来像真实测量”的偏移:
|
||||||
|
# 大部分点轻微偏移,少数点更明显,便于看出方向和等级差异。
|
||||||
|
if idx < 5:
|
||||||
|
offset_scale = 0.0012
|
||||||
|
elif idx < 23:
|
||||||
|
offset_scale = 0.0028
|
||||||
|
else:
|
||||||
|
offset_scale = 0.0045
|
||||||
|
|
||||||
|
angle = rng.uniform(0, 2 * math.pi)
|
||||||
|
radius = offset_scale * (0.55 + 0.85 * rng.random())
|
||||||
|
dx = math.cos(angle) * radius
|
||||||
|
dy = math.sin(angle) * radius
|
||||||
|
|
||||||
|
# 为了让图上连线不完全随机,给部分饱和色再加一点定向偏移。
|
||||||
|
if idx >= 23:
|
||||||
|
dx += 0.002 * (1 if idx % 2 == 0 else -1)
|
||||||
|
dy += 0.0015 * (1 if idx % 3 == 0 else -1)
|
||||||
|
|
||||||
|
mx = min(max(sx + dx, 0.0), 0.8)
|
||||||
|
my = min(max(sy + dy, 0.0), 0.9)
|
||||||
|
|
||||||
|
# 亮度也做一点微小变化,避免所有点完全同一层。
|
||||||
|
measured_lv = 70.0 + rng.normal(0, 4.0)
|
||||||
|
measured_lv = max(measured_lv, 1.0)
|
||||||
|
|
||||||
|
delta_e = calculate_delta_e_2000(mx, my, measured_lv, sx, sy)
|
||||||
|
|
||||||
|
measured.append((mx, my, measured_lv))
|
||||||
|
color_patches.append(name)
|
||||||
|
delta_e_values.append(delta_e)
|
||||||
|
|
||||||
|
avg_delta_e = float(np.mean(delta_e_values))
|
||||||
|
max_delta_e = float(np.max(delta_e_values))
|
||||||
|
min_delta_e = float(np.min(delta_e_values))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"color_patches": color_patches,
|
||||||
|
"delta_e_values": delta_e_values,
|
||||||
|
"color_measurements": measured,
|
||||||
|
"avg_delta_e": avg_delta_e,
|
||||||
|
"max_delta_e": max_delta_e,
|
||||||
|
"min_delta_e": min_delta_e,
|
||||||
|
"excellent_count": sum(1 for value in delta_e_values if value < 3),
|
||||||
|
"good_count": sum(1 for value in delta_e_values if 3 <= value < 5),
|
||||||
|
"poor_count": sum(1 for value in delta_e_values if value >= 5),
|
||||||
|
"avg_delta_e_gray": float(np.mean(delta_e_values[0:5])),
|
||||||
|
"avg_delta_e_colorchecker": float(np.mean(delta_e_values[5:23])),
|
||||||
|
"avg_delta_e_saturated": float(np.mean(delta_e_values[23:29])),
|
||||||
|
"target_gamma": 2.2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Generate an offline color accuracy demo PNG.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
type=Path,
|
||||||
|
default=Path(__file__).resolve().parent / "demo_outputs" / "accuracy_demo.png",
|
||||||
|
help="Output PNG path.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--test-type",
|
||||||
|
choices=["sdr_movie", "hdr_movie", "screen_module"],
|
||||||
|
default="sdr_movie",
|
||||||
|
help="Test type used for the title and standard color set.",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
fig = plt.Figure(figsize=(14, 8), dpi=120, tight_layout=False)
|
||||||
|
app = _DemoApp(fig)
|
||||||
|
accuracy_data = _build_demo_data(args.test_type)
|
||||||
|
|
||||||
|
plot_accuracy(app, accuracy_data, args.test_type)
|
||||||
|
fig.savefig(args.output, dpi=220)
|
||||||
|
|
||||||
|
print(f"Saved demo image to: {args.output}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
BIN
tools/demo_outputs/accuracy_demo.png
Normal file
BIN
tools/demo_outputs/accuracy_demo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 545 KiB |
224
tools/refactor_to_mixins.py
Normal file
224
tools/refactor_to_mixins.py
Normal 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) 相对于 snippet(1-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()
|
||||||
Reference in New Issue
Block a user