Compare commits

..

13 Commits

Author SHA1 Message Date
xinzhu.yin
46a97d6ae7 优化ucd调用结构 2026-06-11 16:29:36 +08:00
xinzhu.yin
cc7218411c 重构UCD模块 2026-06-11 15:53:41 +08:00
xinzhu.yin
38222ff002 修改UI细节错误 2026-06-10 11:39:08 +08:00
xinzhu.yin
3206079c63 修复色准结果图偏移 2026-06-09 17:15:54 +08:00
xinzhu.yin
25be4b7f4a 修复日志切换错误 2026-06-09 15:40:44 +08:00
xinzhu.yin
f33984affa 修复LocalDimming测试错误 2026-06-09 15:22:20 +08:00
xinzhu.yin
8916f2fff0 修改SDR色准深色异常、修改保存结果深色模式异常 2026-06-09 11:02:55 +08:00
xinzhu.yin
9ad9cf9aa0 添加手动设置窗口亮度、曲线图生成 2026-06-08 11:39:54 +08:00
xinzhu.yin
e4890d9d8d 添加瞬时峰值测试 2026-06-08 11:14:12 +08:00
xinzhu.yin
febbb28a4c 修改部分UI、修改module中心点设定、添加单独连接 2026-06-08 11:03:10 +08:00
xinzhu.yin
e9a591bf6e 修改深色模式下结果图片显示异常 2026-06-05 16:58:46 +08:00
xinzhu.yin
49d82da8b9 修改Calman灰阶中结果图显示、修改UI主题样式应用 2026-06-04 10:36:15 +08:00
xinzhu.yin
3aa975c4d3 修改calman灰阶点击异常、修改色准结果显示异常 2026-06-02 17:34:46 +08:00
49 changed files with 4740 additions and 2566 deletions

View File

@@ -6,9 +6,8 @@
"把模块级函数当类方法装到 PQAutomationApp 上" 的写法。
- 拆掉 ``check_port_connection(is_ucd)`` 的布尔旗参数反模式,
分离为 ``connect_ucd`` / ``connect_ca`` 两条独立路径。
- UCD 这一侧不再直接调用旧 ``UCDController``,而是通过
:class:`UCD323Device` + :class:`EventBus`;指示器更新由订阅
:class:`ConnectionChanged` 事件触发,与 GUI 解耦。
- UCD 经由 :class:`UCD323Device` + :class:`EventBus` 管理;
指示灯由 GUI 订阅带 :class:`DeviceKind` :class:`ConnectionChanged` 事件更新。
模块底部仍保留 7 个旧名字函数作为薄兼容层(``check_com_connections``
等),供 ``pqAutomationApp.PQAutomationApp`` 类体继续以同名属性挂接,
@@ -22,9 +21,9 @@ import time
from tkinter import messagebox
from typing import TYPE_CHECKING
from app.ucd_domain import ConnectionChanged, UcdError
from app.ucd import ConnectionChanged, DeviceKind, DeviceInfo, UCD323Device, UcdError
from drivers.caSerail import CASerail
from drivers.ucd_driver import DeviceInfo
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
@@ -33,8 +32,7 @@ if TYPE_CHECKING:
if TYPE_CHECKING:
from app.ucd_domain import EventBus
from drivers.ucd_driver import UCD323Device
from app.ucd import EventBus
# ─── ConnectionController ────────────────────────────────────────
@@ -64,7 +62,7 @@ class ConnectionController:
def list_ucd_devices(self) -> list[str]:
"""返回 SDK 给出的设备显示字符串列表。"""
try:
return self._device.raw_controller.search_device() or []
return self._device.search_devices()
except Exception as exc: # noqa: BLE001
self._log(f"枚举 UCD 设备失败: {exc}", level="error")
return []
@@ -102,11 +100,6 @@ class ConnectionController:
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 连接 -------------------------------------------------
@@ -137,7 +130,7 @@ class ConnectionController:
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))
self._bus.publish(ConnectionChanged(DeviceKind.CA, True, None))
return True
except Exception as exc: # noqa: BLE001
self._log(f"CA410 连接失败: {exc}", level="error")
@@ -151,7 +144,7 @@ class ConnectionController:
except Exception: # noqa: BLE001
pass
self._app.ca = None
self._bus.publish(ConnectionChanged(False, None))
self._bus.publish(ConnectionChanged(DeviceKind.CA, False, None))
self._log("CA连接已断开", level="info")
# -- 一次性入口 ----------------------------------------------
@@ -159,8 +152,7 @@ class ConnectionController:
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")
self._set_connect_widgets_state("disabled")
app.status_var.set("正在检测连接...")
app.root.update()
@@ -184,18 +176,87 @@ class ConnectionController:
threading.Thread(target=worker, daemon=True).start()
def check_ucd_async(self) -> None:
"""仅异步连接 UCD323。"""
app = self._app
self._set_connect_widgets_state("disabled")
app.status_var.set("正在连接 UCD323...")
app.root.update()
def worker():
try:
ucd_ok = self.connect_ucd(app.ucd_list_var.get())
app._dispatch_ui(
app.update_connection_indicator,
app.ucd_status_indicator,
ucd_ok,
)
app._dispatch_ui(
app.status_var.set,
"UCD323 连接成功" if ucd_ok else "UCD323 连接失败",
)
app._dispatch_ui(self._enable_widgets)
except Exception as exc: # noqa: BLE001
app._dispatch_ui(app.log_gui.log, f"UCD323 连接出错: {exc}")
app._dispatch_ui(self._enable_widgets)
threading.Thread(target=worker, daemon=True).start()
def check_ca_async(self) -> None:
"""仅异步连接 CA410。"""
app = self._app
self._set_connect_widgets_state("disabled")
app.status_var.set("正在连接 CA410...")
app.root.update()
def worker():
try:
ca_ok = self.connect_ca()
app._dispatch_ui(
app.update_connection_indicator,
app.ca_status_indicator,
ca_ok,
)
app._dispatch_ui(
app.status_var.set,
"CA410 连接成功" if ca_ok else "CA410 连接失败",
)
app._dispatch_ui(self._enable_widgets)
except Exception as exc: # noqa: BLE001
app._dispatch_ui(app.log_gui.log, f"CA410 连接出错: {exc}")
app._dispatch_ui(self._enable_widgets)
threading.Thread(target=worker, daemon=True).start()
def disconnect_all(self) -> None:
try:
self.disconnect_ucd()
self.disconnect_ca()
self._enable_widgets()
self._app.ucd_status_indicator.config(bg="gray")
self._app.ca_status_indicator.config(bg="gray")
self._app.refresh_connection_indicators()
self._app.status_var.set("串口连接已断开")
except Exception as exc: # noqa: BLE001
self._log(f"断开连接时发生错误: {exc}", level="info")
messagebox.showerror("错误", f"断开连接失败: {exc}")
def disconnect_ucd_only(self) -> None:
try:
self.disconnect_ucd()
self._app.refresh_connection_indicators()
self._app.status_var.set("UCD323 已断开")
except Exception as exc: # noqa: BLE001
self._log(f"断开 UCD323 时发生错误: {exc}", level="info")
messagebox.showerror("错误", f"断开 UCD323 失败: {exc}")
def disconnect_ca_only(self) -> None:
try:
self.disconnect_ca()
self._app.refresh_connection_indicators()
self._app.status_var.set("CA410 已断开")
except Exception as exc: # noqa: BLE001
self._log(f"断开 CA410 时发生错误: {exc}", level="info")
messagebox.showerror("错误", f"断开 CA410 失败: {exc}")
def refresh_ports(self) -> None:
"""刷新 UCD + COM 端口下拉框;指示器复位。"""
app = self._app
@@ -212,18 +273,28 @@ class ConnectionController:
)
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.refresh_connection_indicators()
app.update_config()
# -- 内部 ----------------------------------------------------
def _enable_widgets(self) -> None:
self._app.check_button.configure(state="normal")
self._app.refresh_button.configure(state="normal")
self._set_connect_widgets_state("normal")
def _set_connect_widgets_state(self, state: str) -> None:
for attr in (
"check_button",
"ucd_connect_button",
"ca_connect_button",
"refresh_button",
):
widget = getattr(self._app, attr, None)
if widget is not None:
try:
widget.configure(state=state)
except Exception: # noqa: BLE001
pass
def _log(self, msg: str, *, level: str = "info") -> None:
log_gui = getattr(self._app, "log_gui", None)
@@ -253,8 +324,54 @@ def check_com_connections(self: "PQAutomationApp"):
self.connection.check_all_async()
def check_ucd_connection(self: "PQAutomationApp"):
self.connection.check_ucd_async()
def check_ca_connection(self: "PQAutomationApp"):
self.connection.check_ca_async()
def update_connection_indicator(self: "PQAutomationApp", indicator, connected):
indicator.config(bg="green" if connected else "red")
_draw_connection_indicator(indicator, "green" if connected else "red")
def refresh_connection_indicators(self: "PQAutomationApp"):
"""根据当前设备状态重画 UCD / CA 指示灯。"""
if hasattr(self, "ucd_status_indicator"):
ucd_connected = self.signal_service.is_connected
_draw_connection_indicator(
self.ucd_status_indicator,
"green" if ucd_connected else "gray",
)
if hasattr(self, "ca_status_indicator"):
ca_connected = getattr(self, "ca", None) is not None
_draw_connection_indicator(
self.ca_status_indicator,
"green" if ca_connected else "gray",
)
def _draw_connection_indicator(canvas, state: str) -> None:
palette = get_theme_palette()
color_map = {
"green": "#2ECC71",
"red": "#E74C3C",
"gray": "#9AA3AD",
}
fill = color_map.get(state, state)
border = palette["border"]
bg = palette["card_bg"]
try:
canvas.configure(bg=bg, highlightbackground=border, highlightcolor=border)
canvas.delete("all")
# 保持原有视觉:方形状态灯(红/绿/灰)
canvas.create_rectangle(0, 0, 15, 15, fill=fill, outline=border, width=1)
except Exception:
try:
canvas.config(bg=fill)
except Exception:
pass
def check_port_connection(self: "PQAutomationApp", is_ucd=True):
@@ -272,6 +389,57 @@ def disconnect_com_connections(self: "PQAutomationApp"):
self.connection.disconnect_all()
def disconnect_ucd_connection(self: "PQAutomationApp"):
self.connection.disconnect_ucd_only()
def disconnect_ca_connection(self: "PQAutomationApp"):
self.connection.disconnect_ca_only()
def _get_ca_measure_lock(self: "PQAutomationApp"):
lock = getattr(self, "_ca_measure_lock", None)
if lock is None:
lock = threading.RLock()
self._ca_measure_lock = lock
return lock
def _read_ca_display(self: "PQAutomationApp", mode: int):
"""在锁内切换 CA410 Display 模式并立即读取,避免模式串扰。"""
if getattr(self, "ca", None) is None:
raise RuntimeError("请先连接 CA410 色度计")
with _get_ca_measure_lock(self):
self.ca.set_Display(mode)
return self.ca.readAllDisplay()
def read_ca_xyLv(self: "PQAutomationApp"):
"""读取 xy/Lv/XYZDisplay 0"""
return _read_ca_display(self, 0)
def read_ca_tcp_duv(self: "PQAutomationApp"):
"""读取 Tcp/duv/Lv/XYZDisplay 1"""
return _read_ca_display(self, 1)
def read_ca_uvLv(self: "PQAutomationApp"):
"""读取 u'/v'/Lv/XYZDisplay 5"""
return _read_ca_display(self, 5)
def read_ca_xyz(self: "PQAutomationApp"):
"""读取 XYZDisplay 7"""
return _read_ca_display(self, 7)
def read_ca_lambda_pe(self: "PQAutomationApp"):
"""读取 λd/Pe/Lv/XYZDisplay 8"""
return _read_ca_display(self, 8)
__all__ = [
"ConnectionController",
# 兼容层
@@ -279,10 +447,15 @@ __all__ = [
"get_available_com_ports",
"refresh_com_ports",
"check_com_connections",
"check_ucd_connection",
"check_ca_connection",
"update_connection_indicator",
"refresh_connection_indicators",
"check_port_connection",
"enable_com_widgets",
"disconnect_com_connections",
"disconnect_ucd_connection",
"disconnect_ca_connection",
]
@@ -294,7 +467,19 @@ class DeviceConnectionMixin:
get_available_com_ports = get_available_com_ports
refresh_com_ports = refresh_com_ports
check_com_connections = check_com_connections
check_ucd_connection = check_ucd_connection
check_ca_connection = check_ca_connection
update_connection_indicator = update_connection_indicator
refresh_connection_indicators = refresh_connection_indicators
check_port_connection = check_port_connection
enable_com_widgets = enable_com_widgets
disconnect_com_connections = disconnect_com_connections
disconnect_ucd_connection = disconnect_ucd_connection
disconnect_ca_connection = disconnect_ca_connection
_get_ca_measure_lock = _get_ca_measure_lock
_read_ca_display = _read_ca_display
read_ca_xyLv = read_ca_xyLv
read_ca_tcp_duv = read_ca_tcp_duv
read_ca_uvLv = read_ca_uvLv
read_ca_xyz = read_ca_xyz
read_ca_lambda_pe = read_ca_lambda_pe

View File

@@ -2,15 +2,14 @@
import os
_EXPORT_BG_COLOR = "#FFFFFF"
def _save_with_light_background(fig, path, *, dpi=300, bbox_inches=None):
"""导出统一浅色背景,避免深色主题下图片背景变暗。"""
def _save_with_theme_background(fig, path, *, dpi=300, bbox_inches=None):
"""按图表当前主题背景导出,避免深色模式下被强制写成白底。"""
bg = fig.get_facecolor()
kwargs = {
"dpi": dpi,
"facecolor": _EXPORT_BG_COLOR,
"edgecolor": _EXPORT_BG_COLOR,
"facecolor": bg,
"edgecolor": bg,
"transparent": False,
}
if bbox_inches is not None:
kwargs["bbox_inches"] = bbox_inches
@@ -85,7 +84,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue
per_ref_name = f"色域测试结果_{ref}.png"
path = os.path.join(result_dir, per_ref_name)
_save_with_light_background(fig, path, dpi=300)
_save_with_theme_background(fig, path, dpi=300)
log(f"已保存: {per_ref_name}")
finally:
ref_var.set(original_ref)
@@ -97,7 +96,7 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue
path = os.path.join(result_dir, filename)
if default_bbox:
_save_with_light_background(fig, path, dpi=300)
_save_with_theme_background(fig, path, dpi=300)
else:
_save_with_light_background(fig, path, dpi=300, bbox_inches="tight")
_save_with_theme_background(fig, path, dpi=300, bbox_inches="tight")
log(f"已保存: {filename}")

View File

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

View File

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

View File

@@ -9,7 +9,8 @@ from typing import TYPE_CHECKING
from matplotlib.patches import Rectangle
from matplotlib.lines import Line2D
from matplotlib.ticker import MultipleLocator, AutoMinorLocator
from app.views.modern_styles import get_theme_palette
from app.plots.gamut_background import get_cie1976_background
from app.tests.color_accuracy import get_accuracy_color_standards
@@ -55,14 +56,6 @@ _COLOR_MAP = {
}
def _grade_color(delta_e: float) -> str:
if delta_e < 3:
return "#1FAE45" # 绿
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
@@ -70,13 +63,13 @@ def _xy_to_uv(x: float, y: float):
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)
@@ -86,36 +79,44 @@ def _draw_left_panel(ax, color_patches, delta_e_values, font_scale=1.0, dark_mod
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]
edgecolor = "#F3F5F7" if dark_mode else "#202020"
text_color = "#F3F5F7" if dark_mode else "#111111"
bg_color = "#0F1115" if dark_mode else "#FFFFFF"
ax.barh(
y_pos,
delta_e_values,
height=0.72,
color=bar_colors,
edgecolor=edge_colors,
linewidth=1.0,
edgecolor=edgecolor,
linewidth=0.5,
zorder=3,
)
text_color = "#F3F5F7" if dark_mode else "#111111"
bg_color = "#0F1115" if dark_mode else "#FFFFFF"
spine_color = "#8C8F94" if dark_mode else "#9A9A9A"
ax.set_yticks(y_pos)
ax.set_yticklabels(color_patches, fontsize=max(5, 7 * font_scale), color=text_color)
ax.invert_yaxis()
x_max = max(15.0, max(delta_e_values) * 1.15)
ax.set_xlim(0, x_max)
ax.tick_params(axis="x", labelsize=max(6, 8 * font_scale), colors=text_color)
ax.grid(axis="x", linestyle="-", linewidth=0.6, alpha=0.3, zorder=0)
ax.grid(axis="y", linestyle=":", linewidth=0.35, alpha=0.15, zorder=0)
ax.tick_params(
axis="x",
labelsize=max(6, 8 * font_scale),
colors=text_color
)
ax.tick_params(
axis="y",
labelsize=max(5, 7 * font_scale),
colors=text_color
)
ax.set_facecolor(bg_color)
for spine in ax.spines.values():
spine.set_color(spine_color)
spine.set_linewidth(0.9)
# 自动 minor tick
ax.xaxis.set_minor_locator(AutoMinorLocator(2))
# ============================================================
@@ -126,12 +127,17 @@ def _draw_uv_diagram(ax, color_patches, measurements, standards, font_scale=1.0,
"""绘制 CIE 1976 u'v' 上的色准对比。"""
ax.clear()
try:
bg, bbox = get_cie1976_background()
bg, bbox = get_cie1976_background(mode="dark" if dark_mode else "light")
if bg.shape[-1] == 4:
bg = bg[:, :, :3]
xmin, xmax, ymin, ymax = bbox
ax.imshow(
bg, extent=(xmin, xmax, ymin, ymax),
origin="upper", interpolation="bicubic",
zorder=0, aspect="auto",
bg,
extent=(xmin, xmax, ymin, ymax),
origin="lower",
interpolation="bilinear",
zorder=0,
aspect="auto",
)
ax.set_xlim(xmin, xmax)
ax.set_ylim(ymin, ymax)
@@ -145,73 +151,129 @@ def _draw_uv_diagram(ax, color_patches, measurements, standards, font_scale=1.0,
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"
outer_edge = "#FFFFFF" if dark_mode else "#222222"
ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
ax.set_aspect("equal", adjustable="box")
ax.set_title("CIE 1976 u'v'", fontsize=max(8, 11 * font_scale), fontweight="bold",
color=text_color, pad=4)
ax.xaxis.set_major_locator(MultipleLocator(0.1))
ax.yaxis.set_major_locator(MultipleLocator(0.1))
ax.xaxis.set_minor_locator(MultipleLocator(0.02))
ax.yaxis.set_minor_locator(MultipleLocator(0.02))
ax.set_title(
"CIE 1976 u'v'",
fontsize=max(8, 11 * font_scale),
fontweight="bold",
color=text_color,
pad=4,
)
ax.set_xlabel("u'", fontsize=max(7, 9 * font_scale), color=sub_text_color, labelpad=1)
ax.set_ylabel("v'", fontsize=max(7, 9 * font_scale), color=sub_text_color, labelpad=1)
ax.tick_params(axis="both", labelsize=max(6, 8 * font_scale), colors=tick_color)
for sp in ax.spines.values():
sp.set_color(outer_edge)
sp.set_linewidth(0.9)
for name, meas in zip(color_patches, measurements):
if meas is None or len(meas) < 2:
continue
mx, my = meas[0], meas[1]
sxy = standards.get(name)
if sxy is None:
continue
sx, sy = sxy
m_u, m_v = _xy_to_uv(mx, my)
s_u, s_v = _xy_to_uv(sx, sy)
# face = get_patch_color_from_xy(name, (sx, sy)).strip().upper()
face = _COLOR_MAP.get(name, "#FFFFFF")
# face = get_patch_color_from_xy(name, (mx, my))
# face = "#FF0000"
# 目标点:仅空心方框(不填充标准颜色)
# 目标点Target 空心方框
ax.scatter(
[s_u], [s_v],
s=56, marker="s",
facecolors="none", edgecolors=outer_edge,
linewidths=1.25, zorder=18,
s_u,
s_v,
s=90,
marker="s",
facecolors="none",
edgecolors=outer_edge,
linewidths=1.6,
zorder=18,
)
# 实测点:白色外圈 + 内层圆点
# 实测点Actual 彩色实心 + 白色描边
ax.scatter(
[m_u], [m_v],
s=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,
[m_u],
[m_v],
s=80,
marker="o",
color=face,
edgecolors=outer_edge,
linewidths=1.2,
zorder=20,
)
# # Δu'v' 偏差连线
# ax.plot(
# [s_u, m_u],
# [s_v, m_v],
# color=face,
# linewidth=1.0,
# alpha=0.8,
# zorder=15,
# )
legend_handles = [
Line2D([0], [0], marker="s", linestyle="none",
markerfacecolor="#CCCCCC", markeredgecolor=outer_edge,
markersize=7, label="目标 (Target)"),
Line2D([0], [0], marker="o", linestyle="none",
markerfacecolor="#CCCCCC", markeredgecolor="#000000",
markersize=7, label="实测 (Actual)"),
Line2D(
[0],
[0],
marker="s",
linestyle="none",
markerfacecolor="none",
markeredgecolor=outer_edge,
markersize=9,
markeredgewidth=1.4,
label="目标 (Target)",
),
Line2D(
[0],
[0],
marker="o",
linestyle="none",
markerfacecolor="#AAAAAA",
markeredgecolor=outer_edge,
markersize=9,
markeredgewidth=1.2,
label="实测 (Actual)",
),
]
leg = ax.legend(
handles=legend_handles,
loc="lower right", fontsize=max(6, 8 * font_scale),
framealpha=0.88, labelcolor=legend_label_color,
loc="lower right",
fontsize=max(6, 8 * font_scale),
framealpha=0.9,
labelcolor=legend_label_color,
)
if leg is not None:
if leg:
leg.get_frame().set_facecolor(legend_bg)
leg.get_frame().set_edgecolor(legend_edge)
leg.set_zorder(50)
def _draw_result_judgement(ax, accuracy_data, font_scale=1.0, dark_mode=False):
"""底部结果条"""
ax.clear()
@@ -253,23 +315,34 @@ def _draw_result_judgement(ax, accuracy_data, font_scale=1.0, dark_mode=False):
# ============================================================
def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
"""绘制色准测试结果 - Calman 风格(色块 + CIE 1976 u'v' + 统计)。"""
fig = self.accuracy_fig
fig.clear()
"""绘制色准测试结果"""
palette = get_theme_palette()
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")
fig = self.accuracy_fig
fig.clear()
try:
fig.set_layout_engine(None)
except Exception:
try:
fig.set_tight_layout(False)
except Exception:
pass
# 根据当前画布像素尺寸动态缩放字体,避免窗口缩小时文字挤压重叠。
fig.patch.set_facecolor(palette["bg"])
# 先确保色准页签已激活,再读取真实画布尺寸进行动态缩放。
font_scale = 1.0
try:
self.chart_notebook.select(self.accuracy_chart_frame)
self.chart_notebook.update_idletasks()
canvas_widget = self.accuracy_canvas.get_tk_widget()
canvas_widget.update_idletasks()
canvas_widget.update()
cw = max(1, int(canvas_widget.winfo_width()))
ch = max(1, int(canvas_widget.winfo_height()))
font_scale = min(cw / 1000.0, ch / 600.0)
@@ -295,13 +368,12 @@ def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
else:
title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma}"
title_color = "#F3F5F7" if dark_mode else "#111"
fig.suptitle(
title,
fontsize=max(8, 11 * font_scale),
y=0.975,
fontweight="bold",
color=title_color,
color=palette["fg"],
)
gs = fig.add_gridspec(
@@ -316,6 +388,8 @@ def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
ax_left = fig.add_subplot(gs[0, 0])
ax_uv = fig.add_subplot(gs[0, 1])
ax_judge = fig.add_subplot(gs[1, :])
for ax in (ax_left, ax_uv, ax_judge):
ax.set_facecolor(palette["card_bg"])
# 兼容外部对 self.accuracy_ax 的引用
self.accuracy_ax = ax_judge
@@ -353,8 +427,13 @@ def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type):
except Exception:
pass
self.accuracy_canvas.draw()
# 重新刷新布局并绘制,确保画布尺寸与 notebook tab 对齐。
self.chart_notebook.select(self.accuracy_chart_frame)
canvas_widget = self.accuracy_canvas.get_tk_widget()
canvas_widget.update_idletasks()
canvas_widget.update()
self.accuracy_canvas.draw()
canvas_widget.update_idletasks()
class PlotAccuracyMixin:

View File

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

View File

@@ -4,6 +4,7 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_contrast 原样搬迁
"""
from matplotlib.patches import Rectangle
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
@@ -14,9 +15,12 @@ if TYPE_CHECKING:
def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
"""绘制对比度测试结果 - 固定布局版本"""
palette = get_theme_palette()
# 清空并重置
self.contrast_ax.clear()
self.contrast_fig.patch.set_facecolor(palette["bg"])
self.contrast_ax.set_facecolor(palette["card_bg"])
self.contrast_ax.set_xlim(0, 1)
self.contrast_ax.set_ylim(0, 1)
self.contrast_ax.axis("off")
@@ -51,6 +55,7 @@ def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
fontsize=12,
y=0.98,
fontweight="bold",
color=palette["fg"],
)
# ========== 中央大对比度卡片 ==========
@@ -107,16 +112,16 @@ def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
"title": "白场亮度",
"value": f"{max_lum:.2f}",
"unit": "cd/m²",
"color": "#E3F2FD",
"edge_color": "#2196F3",
"color": palette["surface_alt_bg"],
"edge_color": palette["primary"],
},
{
"x": start_x + card_width + gap,
"title": "黑场亮度",
"value": f"{min_lum:.4f}",
"unit": "cd/m²",
"color": "#F3E5F5",
"edge_color": "#9C27B0",
"color": palette["card_bg"],
"edge_color": palette["secondary"],
},
]
@@ -142,6 +147,7 @@ def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
va="top",
fontsize=10,
fontweight="bold",
color=palette["fg"],
transform=self.contrast_ax.transAxes,
)
@@ -154,6 +160,7 @@ def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
va="center",
fontsize=16,
fontweight="bold",
color=palette["fg"],
transform=self.contrast_ax.transAxes,
)
@@ -165,7 +172,7 @@ def plot_contrast(self: "PQAutomationApp", contrast_data, test_type):
ha="center",
va="bottom",
fontsize=9,
color="gray",
color=palette["muted_fg"],
transform=self.contrast_ax.transAxes,
)

View File

@@ -4,6 +4,7 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。
"""
import numpy as np
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
@@ -14,15 +15,21 @@ if TYPE_CHECKING:
def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type):
"""绘制 EOTF 曲线 + 数据表格HDR 专用,包含实测亮度)"""
palette = get_theme_palette()
# ========== 1. 清空并重置左侧曲线 ==========
self.eotf_ax.clear()
self.eotf_fig.patch.set_facecolor(palette["bg"])
self.eotf_ax.set_facecolor(palette["card_bg"])
self.eotf_ax.set_xlim(0, 105)
self.eotf_ax.set_ylim(0, 1.1)
self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.eotf_ax.set_ylabel("L_bar", fontsize=10)
self.eotf_ax.grid(True, linestyle="--", alpha=0.3)
self.eotf_ax.tick_params(labelsize=9)
self.eotf_ax.tick_params(colors=palette["fg"])
for spine in self.eotf_ax.spines.values():
spine.set_color(palette["border"])
# 生成横坐标(灰阶百分比)
x_values = np.linspace(0, 100, len(L_bar))
@@ -120,17 +127,17 @@ def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type)
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white")
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"])
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# ========== 3. 总标题 ==========
test_type_name = self.get_test_type_name(test_type)
@@ -139,6 +146,7 @@ def plot_eotf(self: "PQAutomationApp", L_bar, results_with_eotf_list, test_type)
fontsize=12,
y=0.98,
fontweight="bold",
color=palette["fg"],
)
# 选中 EOTF Tab

View File

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

View File

@@ -178,7 +178,7 @@ def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim, dark_mode):
ax.set_ylabel(ylabel, fontsize=10, color=text)
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_aspect("equal", adjustable="datalim")
ax.set_aspect("equal", adjustable="box")
ax.grid(True, linestyle=":", linewidth=0.7, color=grid, alpha=0.32)
ax.tick_params(axis="both", labelsize=9, colors=text)
for spine in ax.spines.values():
@@ -193,8 +193,10 @@ def _blit_background(ax, background, bbox):
ax.imshow(
background,
extent=(xmin, xmax, ymin, ymax),
origin="upper", # canvas.buffer_rgba 行 0 为顶部
interpolation="bicubic",
# gamut_background._render_chromaticity 已做过 np.flipud
# 这里必须使用 lower 才能与真实色度坐标方向一致。
origin="lower",
interpolation="bilinear",
zorder=0,
aspect="auto", # 由 _style_axes 的 set_aspect("equal") 控制
)
@@ -269,7 +271,7 @@ def plot_gamut(self: "PQAutomationApp", results, coverage, test_type):
# 左图CIE 1931 xy
# ============================================================
try:
bg_xy, bbox_xy = get_cie1931_background()
bg_xy, bbox_xy = get_cie1931_background(mode="dark" if dark_mode else "light")
_blit_background(ax_xy, bg_xy, bbox_xy)
_style_axes(
ax_xy,
@@ -341,7 +343,7 @@ def plot_gamut(self: "PQAutomationApp", results, coverage, test_type):
# 右图CIE 1976 u'v'
# ============================================================
try:
bg_uv, bbox_uv = get_cie1976_background()
bg_uv, bbox_uv = get_cie1976_background(mode="dark" if dark_mode else "light")
_blit_background(ax_uv, bg_uv, bbox_uv)
_style_axes(
ax_uv,

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

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

View File

@@ -330,9 +330,7 @@ def send_fix_pattern(self: "PQAutomationApp", mode):
if mode == "screen_module":
format_changed = True
else:
format_changed = bool(
getattr(getattr(self, "ucd", None), "format_changed", True)
)
format_changed = bool(self.signal_service.format_changed)
# 预热提交prepare_session 仅 stage 了新的 color/timing/pattern
# 真正的 ``pg.apply()`` 要到第一次发图时才发生。提前发送首个 pattern
@@ -341,9 +339,9 @@ def send_fix_pattern(self: "PQAutomationApp", mode):
self.pattern_service.send_session_pattern(session, 0)
if format_changed:
signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 5.0)))
signal_settle = max(1.0, float(getattr(self, "signal_settle_time", 4.0)))
self.log_gui.log(
f"信号格式已变化,等待电视重新锁定: {signal_settle:.1f}s(可通过 signal_settle_time 调整)",
f"信号格式已变化,等待电视重新锁定: {signal_settle:.1f}s",
level="info",
)
else:
@@ -394,8 +392,7 @@ def send_fix_pattern(self: "PQAutomationApp", mode):
# 测量数据
if mode == "custom":
result = []
self.ca.set_Display(1)
tcp, duv, lv, X, Y, Z = self.ca.readAllDisplay()
tcp, duv, lv, X, Y, Z = self.read_ca_tcp_duv()
if should_log_detail:
self.log_gui.log(
@@ -403,8 +400,7 @@ def send_fix_pattern(self: "PQAutomationApp", mode):
f"X={X:.4f}, Y={Y:.4f}, Z={Z:.4f}"
, level="success")
self.ca.set_Display(8)
lambda_, Pe, lv, X, Y, Z = self.ca.readAllDisplay()
lambda_, Pe, lv, X, Y, Z = self.read_ca_lambda_pe()
if should_log_detail:
self.log_gui.log(
@@ -449,9 +445,7 @@ def send_fix_pattern(self: "PQAutomationApp", mode):
self.log_gui.log(f"{i+1} 行实时结果写入失败: {str(e)}", level="error")
else:
self.ca.set_xyLv_Display()
x, y, lv, X, Y, Z = self.ca.readAllDisplay()
x, y, lv, X, Y, Z = self.read_ca_xyLv()
results.append([x, y, lv, X, Y, Z])
if should_log_detail:
@@ -835,6 +829,20 @@ def test_cct(self: "PQAutomationApp", test_type, gray_data=None):
self.log_gui.log(f"使用 {len(results)} 个灰阶数据点进行色度计算", level="info")
# 屏模组测试:中心坐标直接使用本次灰阶 100% 实测值(第 1 个点)
if test_type == "screen_module":
try:
if results and len(results[0]) >= 2:
x_100 = float(results[0][0])
y_100 = float(results[0][1])
self.cct_x_ideal_var.set(f"{x_100:.6f}")
self.cct_y_ideal_var.set(f"{y_100:.6f}")
self.log_gui.log(
f"屏模组 CCT 中心采用 100% 实测值: x={x_100:.6f}, y={y_100:.6f}"
, level="success")
except Exception as e:
self.log_gui.log(f"同步屏模组100%中心坐标失败: {str(e)}", level="error")
# 提取色度坐标
cct_values = pq_algorithm.calculate_cct_from_results(results)

View File

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

View File

@@ -294,61 +294,106 @@ def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, aut
except Exception as exc:
raise ValueError(f"无法读取图片: {exc}") from exc
# 检查大小,如需则缩放
size = os.path.getsize(file_path)
needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES)
file_stem = os.path.splitext(os.path.basename(file_path))[0]
upload_ext = ext
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
if needs_resize:
if not auto_resize:
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
raise ValueError(f"分辨率超过 4096×4096当前 {iw}×{ih}")
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))
logger.info(
"[AIImage][UPLOAD] 自动处理超限图片 %dx%d (%.2fMB)",
iw,
ih,
size / 1024 / 1024,
)
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()
working = img.copy()
if len(tmp_bytes) <= UPLOAD_MAX_BYTES:
file_bytes = tmp_bytes
# 先做一次分辨率约束,避免后续压缩开销过大。
scale = min(UPLOAD_MAX_PIXELS / max(1, working.width), UPLOAD_MAX_PIXELS / max(1, working.height), 1.0)
if scale < 1.0:
working = working.resize(
(max(1, int(working.width * scale)), max(1, int(working.height * scale))),
Image.LANCZOS,
)
best_bytes = b""
best_mime = mime
best_ext = upload_ext
# 第一优先:保持原格式。
try:
raw_io = BytesIO()
if ext == ".png":
working.save(raw_io, format="PNG", optimize=True)
raw_mime, raw_ext = "image/png", ".png"
else:
# 原格式太大,转换为 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
rgb = working.convert("RGB") if working.mode not in {"RGB", "L"} else working
rgb.save(raw_io, format="JPEG", quality=95, optimize=True)
raw_mime, raw_ext = "image/jpeg", ".jpg"
best_bytes = raw_io.getvalue()
best_mime = raw_mime
best_ext = raw_ext
except Exception as exc:
logger.warning("[AIImage][UPLOAD] 原格式编码失败,准备转 JPEG: %s", exc)
# 仍超限时,转 JPEG + 渐进压缩;如仍超限则继续降分辨率。
if len(best_bytes) > UPLOAD_MAX_BYTES:
if best_ext != ".jpg":
logger.info("[AIImage][UPLOAD] 原格式仍超限,切换 JPEG 压缩")
working_jpg = working.convert("RGB") if working.mode != "RGB" else working
while True:
compressed = b""
for q in (95, 90, 85, 80, 75, 70, 65, 60, 55, 50):
tmp = BytesIO()
working_jpg.save(tmp, format="JPEG", quality=q, optimize=True)
data = tmp.getvalue()
compressed = data
if len(data) <= UPLOAD_MAX_BYTES:
break
best_bytes = compressed
best_mime = "image/jpeg"
best_ext = ".jpg"
if len(best_bytes) <= UPLOAD_MAX_BYTES:
break
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
next_w = max(256, int(working_jpg.width * 0.9))
next_h = max(256, int(working_jpg.height * 0.9))
if next_w == working_jpg.width and next_h == working_jpg.height:
break
if next_w <= 256 or next_h <= 256:
break
working_jpg = working_jpg.resize((next_w, next_h), Image.LANCZOS)
if len(best_bytes) > UPLOAD_MAX_BYTES:
raise ValueError(
f"自动压缩后仍超过 10MB当前 {len(best_bytes)/1024/1024:.2f}MB请更换图片"
)
file_bytes = best_bytes
mime = best_mime
upload_ext = best_ext
iw, ih = working.width, working.height
logger.info(
"[AIImage][UPLOAD] 自动处理完成 %dx%d %.2fMB (%s)",
iw,
ih,
len(file_bytes) / 1024 / 1024,
mime,
)
else:
with open(file_path, "rb") as f:
file_bytes = f.read()
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
filename = f"{file_stem}{upload_ext}"
boundary = "----pqAuto" + uuid.uuid4().hex
filename = os.path.basename(file_path)
crlf = b"\r\n"
body = b"".join([
b"--", boundary.encode("ascii"), crlf,

View File

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

View File

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

View File

@@ -8,6 +8,7 @@ import atexit
import csv
import datetime
import os
import re
import shutil
import sys
import threading
@@ -15,6 +16,7 @@ import time
import tkinter as tk
from tkinter import filedialog, messagebox
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
@@ -34,6 +36,9 @@ 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
INSTANT_PEAK_DROP_RATIO = 0.97
INSTANT_PEAK_MIN_DROP_NITS = 2.0
INSTANT_PEAK_SAMPLE_INTERVAL = 0.3
_TEMP_DIR = None
_IMAGE_CACHE = {} # {(width, height, percentage): file_path}
@@ -63,8 +68,8 @@ def _get_temp_dir():
return _TEMP_DIR
def _make_window_image_array(width, height, percentage):
"""生成黑底+居中白窗的 numpy 图像,保持屏幕比例。"""
def _make_window_image_array(width, height, percentage, window_level=255):
"""生成黑底+居中窗口图像,保持屏幕比例。"""
image = np.zeros((height, width, 3), dtype=np.uint8)
if percentage >= 100:
ww, wh = width, height
@@ -74,18 +79,19 @@ def _make_window_image_array(width, height, percentage):
wh = int(height * scale)
x1 = (width - ww) // 2
y1 = (height - wh) // 2
image[y1:y1 + wh, x1:x1 + ww] = 255
image[y1:y1 + wh, x1:x1 + ww] = int(window_level)
return image
def _ensure_window_image(width, height, percentage):
def _ensure_window_image(width, height, percentage, window_level=255):
"""生成或复用缓存的窗口 PNG 文件,返回路径。"""
key = (width, height, percentage)
level = max(0, min(255, int(window_level)))
key = (width, height, percentage, level)
cached = _IMAGE_CACHE.get(key)
if cached and os.path.exists(cached):
return cached
arr = _make_window_image_array(width, height, percentage)
fname = f"window_{width}x{height}_{percentage:03d}percent.png"
arr = _make_window_image_array(width, height, percentage, level)
fname = f"window_{width}x{height}_{percentage:03d}percent_{level:03d}lv.png"
path = os.path.join(_get_temp_dir(), fname)
Image.fromarray(arr, mode="RGB").save(path, format="PNG")
_IMAGE_CACHE[key] = path
@@ -152,38 +158,117 @@ def _ensure_checkerboard_image(width, height, grid_size, center_white):
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}"
def _ld_ucd_params_signature(self: "PQAutomationApp") -> tuple:
"""Local Dimming 发图前 UCD 参数签名,用于跳过未变化的重复配置。"""
test_type = getattr(self.config, "current_test_type", "screen_module")
cfg = self.config.current_test_types.get(test_type, {})
timing = cfg.get("timing", "")
if test_type == "screen_module":
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":
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":
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
return (test_type, timing, color_space, data_range, bit_depth, output_format, max_cll, max_fall)
elif test_type == "local_dimming":
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:
display_value = str(value)
return (test_type,)
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,
}
return (test_type, timing, color_space, data_range, bit_depth, output_format)
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 invalidate_ld_ucd_params_cache(self: "PQAutomationApp") -> None:
"""信号格式或分辨率变更后,强制下次发图重新写入 UCD 参数"""
self._last_ld_ucd_signature = None
def _apply_ld_ucd_params(self: "PQAutomationApp") -> bool:
"""发送 Local Dimming 图案前,按当前测试类型写入 UCD 参数。"""
test_type = getattr(self.config, "current_test_type", "screen_module")
signature = _ld_ucd_params_signature(self)
if getattr(self, "_last_ld_ucd_signature", None) == signature:
return True
test_type = signature[0]
cfg = self.config.current_test_types.get(test_type, {})
try:
@@ -303,237 +388,452 @@ def _apply_ld_ucd_params(self: "PQAutomationApp") -> bool:
self._dispatch_ui(self.log_gui.log, "Local Dimming UCD 参数设置失败", "error")
return False
self._last_ld_ucd_signature = signature
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
def _send_ld_pattern_async(self: "PQAutomationApp", image_builder, success_msg, fail_msg):
"""统一的 Local Dimming 图案发送线程"""
# --------------------------------------------------------------------------
# GUI 入口(绑定为 PQAutomationApp 方法)
# --------------------------------------------------------------------------
def worker():
def start_local_dimming_test(self: "PQAutomationApp"):
"""Local Dimming 不再提供自动测试,保留接口仅提示用户使用手动模式。"""
messagebox.showinfo("提示", "Local Dimming 请使用手动发送图案后再采集亮度")
def update_ld_results(self: "PQAutomationApp", results):
"""把批量测试结果填入 Treeview。"""
for row in results:
self.ld_tree.insert(
"", tk.END,
values=(
row["test_item"],
row["pattern"],
row["value"],
row["x"],
row["y"],
row["time"],
),
)
def stop_local_dimming_test(self: "PQAutomationApp"):
"""兼容旧接口,无操作。"""
return
def send_ld_window(self: "PQAutomationApp", percentage):
"""发送指定百分比的白色窗口(手动模式)。"""
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info")
_set_current_ld_pattern(self, "峰值亮度", f"{percentage}%窗口", percentage)
def send():
if not _apply_ld_ucd_params(self):
return
width, height = self.signal_service.current_resolution()
try:
image_path = _ensure_window_image(width, height, percentage)
image_path = image_builder(width, height)
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"{percentage}% 窗口已发送" if ok
else f"{percentage}% 窗口发送失败"
)
msg = success_msg if ok else fail_msg
self._dispatch_ui(self.log_gui.log, msg)
threading.Thread(target=send, daemon=True).start()
threading.Thread(target=worker, daemon=True).start()
# --------------------------------------------------------------------------
# GUI 入口(绑定为 PQAutomationApp 方法)
# --------------------------------------------------------------------------
def send_ld_window(self: "PQAutomationApp", percentage):
FIXED_WINDOW_PERCENTAGE = 40
try:
luminance_percent = float(percentage)
if luminance_percent < 1 or luminance_percent > 100:
raise ValueError
except Exception:
messagebox.showwarning("参数错误", "亮度范围应为 1-100")
return
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
window_level = int(round(luminance_percent / 100 * 255))
self.log_gui.log(
f"发送 {FIXED_WINDOW_PERCENTAGE}%窗口(亮度{luminance_percent:.0f}%...",
level="info",
)
_set_current_ld_pattern(
self,
"峰值亮度",
f"{FIXED_WINDOW_PERCENTAGE}%窗口({luminance_percent:.0f}%亮度)",
FIXED_WINDOW_PERCENTAGE,
)
def builder(width, height):
return _ensure_window_image(
width,
height,
FIXED_WINDOW_PERCENTAGE,
window_level,
)
_send_ld_pattern_async(
self,
builder,
f"{FIXED_WINDOW_PERCENTAGE}%窗口({luminance_percent:.0f}%亮度)已发送",
f"{FIXED_WINDOW_PERCENTAGE}%窗口({luminance_percent:.0f}%亮度)发送失败",
)
def send_ld_manual_window(self: "PQAutomationApp"):
"""按手动输入的窗口大小和亮度发送窗口图案。"""
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
try:
percentage = int(float(self.ld_window_percentage_var.get()))
if percentage < 1 or percentage > 100:
raise ValueError("窗口范围应为 1-100")
except Exception as e:
messagebox.showwarning("参数错误", f"窗口百分比无效: {e}")
return
try:
luminance_percent = float(self.ld_window_luminance_var.get())
if luminance_percent < 1 or luminance_percent > 100:
raise ValueError("亮度范围应为 1-100")
except Exception as e:
messagebox.showwarning("参数错误", f"窗口亮度无效: {e}")
return
window_level = int(round(luminance_percent / 100.0 * 255.0))
self.log_gui.log(
f"发送 {percentage}%窗口(亮度{luminance_percent:.0f}%...",
level="info",
)
_set_current_ld_pattern(
self,
"峰值亮度",
f"{percentage}%窗口({luminance_percent:.0f}%亮度)",
percentage,
)
def builder(width, height):
return _ensure_window_image(
width,
height,
percentage,
window_level,
)
_send_ld_pattern_async(
self,
builder,
f"{percentage}%窗口({luminance_percent:.0f}%亮度)已发送",
f"{percentage}%窗口({luminance_percent:.0f}%亮度)发送失败",
)
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")
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(
def builder(width, height):
return _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()
_send_ld_pattern_async(
self,
builder,
f"{pattern_label} 已发送",
f"{pattern_label} 发送失败",
)
def send_ld_black_pattern(self: "PQAutomationApp"):
"""发送全黑图案(手动模式)。"""
if not self.signal_service.is_connected:
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
self.log_gui.log("发送全黑画面...", level="info")
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
def builder(width, height):
return _ensure_solid_image(width, height, (0, 0, 0), "black")
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()
_send_ld_pattern_async(
self,
builder,
"全黑画面已发送",
"全黑画面发送失败",
)
def send_ld_instant_peak(self: "PQAutomationApp"):
"""发送瞬时峰值亮度图案:先黑场,再切到 10% 窗口并保持"""
def start_ld_instant_peak_tracking(self: "PQAutomationApp"):
"""独立瞬时峰值测试:持续采样直到亮度回落或达到最长测量时长"""
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")
if not self.ca:
messagebox.showwarning("警告", "请先连接 CA410 色度计")
return
if getattr(self, "ld_peak_tracking", False):
messagebox.showinfo("提示", "瞬时峰值测试正在进行中")
return
try:
window_percentage = int(float(self.ld_peak_window_size_var.get()))
if window_percentage < 1 or window_percentage > 100:
raise ValueError("窗口百分比超出范围")
window_luminance_percent = float(self.ld_peak_window_luminance_var.get())
if window_luminance_percent < 1 or window_luminance_percent > 100:
raise ValueError("窗口亮度超出范围")
sample_interval = float(
self.ld_peak_sample_interval_var.get()
if hasattr(self, "ld_peak_sample_interval_var")
else INSTANT_PEAK_SAMPLE_INTERVAL
)
if sample_interval <= 0:
raise ValueError("采样间隔必须大于 0")
# 无限模式
no_limit = bool(
self.ld_peak_no_limit_var.get()
if hasattr(self, "ld_peak_no_limit_var")
else False
)
if not no_limit:
max_duration = float(self.ld_peak_duration_var.get())
if max_duration <= 0:
raise ValueError("测量时长必须大于 0")
else:
max_duration = None
# 回落百分比
drop_percent = float(
self.ld_peak_drop_percent_var.get()
if hasattr(self, "ld_peak_drop_percent_var")
else 3
)
if drop_percent <= 0 or drop_percent >= 50:
raise ValueError("回落百分比建议 1~50")
except Exception as e:
messagebox.showwarning("参数错误", f"请检查瞬时峰值参数: {e}")
return
record_curve = bool(self.ld_peak_record_curve_var.get())
window_level = int(round(window_luminance_percent / 100.0 * 255.0))
pattern_label = f"黑场后切 {window_percentage}%窗口({window_luminance_percent:.0f}%亮度)"
duration_text = "直到亮度回落" if no_limit else f"最长 {max_duration:.1f}s"
self.ld_peak_tracking = True
self.log_gui.log(
f"开始瞬时峰值测试: {pattern_label}{duration_text},回落阈值 {drop_percent:.1f}%",
level="info",
)
_set_current_ld_pattern(
self,
"瞬时峰值亮度",
pattern_label,
INSTANT_PEAK_WINDOW_PERCENTAGE,
window_percentage,
)
def send():
if hasattr(self, "ld_peak_start_btn"):
self.ld_peak_start_btn.configure(state="disabled")
if hasattr(self, "ld_peak_stop_btn"):
self.ld_peak_stop_btn.configure(state="normal")
def run():
peak_lv = None
peak_time = None
drop_time = None
curve_count = 0
try:
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,
window_percentage,
window_level,
)
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
"瞬时峰值图案发送失败"
started = time.time()
while self.ld_peak_tracking:
elapsed = time.time() - started
# 固定时长模式
if max_duration is not None:
if elapsed > max_duration:
break
# 安全保护30分钟
if elapsed > 1800:
self._dispatch_ui(
self.log_gui.log,
"安全超时停止(30分钟)",
"warning",
)
self._dispatch_ui(self.log_gui.log, msg)
break
threading.Thread(target=send, daemon=True).start()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
if lv is None:
time.sleep(sample_interval)
continue
lv = float(lv)
# 更新峰值
if peak_lv is None or lv > peak_lv:
peak_lv = lv
peak_time = elapsed
# 曲线记录
if record_curve:
curve_count += 1
self._dispatch_ui(
self._insert_ld_tree_item,
values=(
"瞬时峰值曲线",
f"{window_percentage}%窗口@{window_luminance_percent:.0f}% t={elapsed:.2f}s",
f"{lv:.4f}",
f"{x:.4f}",
f"{y:.4f}",
datetime.datetime.now().strftime("%H:%M:%S"),
),
)
# 回落检测
if peak_lv is not None:
drop_threshold = peak_lv * (1 - drop_percent / 100.0)
if lv < drop_threshold and elapsed > (peak_time or 0):
drop_time = elapsed
break
self._dispatch_ui(
self.ld_result_label.config,
text=f"亮度:{lv:.2f} cd/m² | 峰值:{(peak_lv or lv):.2f} cd/m² | t:{elapsed:.2f}s",
)
time.sleep(sample_interval)
if peak_lv is None:
self._dispatch_ui(
self.log_gui.log,
"瞬时峰值测试未采到有效亮度",
"warning",
)
return
end_time = drop_time if drop_time is not None else (time.time() - started)
sustain_time = max(0.0, end_time - (peak_time or 0))
result_label = (
f"峰值={peak_lv:.2f} cd/m², 持续={sustain_time:.2f}s"
if drop_time is not None
else f"峰值={peak_lv:.2f} cd/m², 持续>{sustain_time:.2f}s"
)
self._dispatch_ui(
self._insert_ld_tree_item,
values=(
"瞬时峰值亮度",
pattern_label,
result_label,
"--",
"--",
datetime.datetime.now().strftime("%H:%M:%S"),
),
)
self._dispatch_ui(
self.log_gui.log,
f"瞬时峰值测试完成: {result_label},曲线点 {curve_count}",
"success",
)
except Exception as e:
self._dispatch_ui(
self.log_gui.log,
f"瞬时峰值测试异常: {e}",
"error",
)
finally:
self.ld_peak_tracking = False
if hasattr(self, "ld_peak_start_btn"):
self._dispatch_ui(
self.ld_peak_start_btn.configure,
state="normal",
)
if hasattr(self, "ld_peak_stop_btn"):
self._dispatch_ui(
self.ld_peak_stop_btn.configure,
state="disabled",
)
threading.Thread(target=run, daemon=True).start()
def stop_ld_instant_peak_tracking(self: "PQAutomationApp"):
"""停止独立瞬时峰值连续采样"""
if getattr(self, "ld_peak_tracking", False):
self.ld_peak_tracking = False
self.log_gui.log("已请求停止瞬时峰值测试", level="info")
def _insert_ld_tree_item(self, parent="", index=tk.END, **kwargs):
item = self.ld_tree.insert(parent, index, **kwargs)
try:
self.ld_tree.see(item)
except Exception:
pass
return item
def measure_ld_luminance(self: "PQAutomationApp"):
@@ -545,11 +845,9 @@ def measure_ld_luminance(self: "PQAutomationApp"):
messagebox.showinfo("提示", "请先发送一个窗口图案")
return
self.log_gui.log("📏 正在采集亮度...", level="info")
def measure():
try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
except Exception as e:
self._dispatch_ui(self.log_gui.log, f"采集异常: {str(e)}")
return
@@ -562,7 +860,7 @@ def measure_ld_luminance(self: "PQAutomationApp"):
text=f"亮度: {lv:.2f} cd/m² | x: {x:.4f} | y: {y:.4f}",
)
self._dispatch_ui(
self.ld_tree.insert, "", tk.END,
self._insert_ld_tree_item,
values=(
getattr(self, "current_ld_test_item", "手动采集"),
self.current_ld_pattern_label,
@@ -585,6 +883,7 @@ def clear_ld_records(self: "PQAutomationApp"):
self.current_ld_percentage = None
self.current_ld_test_item = None
self.current_ld_pattern_label = None
self.ld_peak_tracking = False
self.log_gui.log("测试记录已清空", level="info")
@@ -619,17 +918,112 @@ def save_local_dimming_results(self: "PQAutomationApp"):
messagebox.showerror("错误", f"保存失败: {str(e)}")
def plot_ld_instant_peak_curve(self: "PQAutomationApp"):
"""绘制最近一次瞬时峰值测试的亮度-时间曲线"""
pattern = re.compile(r"t\s*=\s*([0-9]+(?:\.[0-9]+)?)s")
curve_points = []
# 从表格底部向上找最近一次曲线
items = list(self.ld_tree.get_children())[::-1]
collecting = False
for item in items:
values = self.ld_tree.item(item, "values")
if len(values) < 3:
continue
test_item = str(values[0])
pattern_text = str(values[1])
lv_text = str(values[2])
if test_item == "瞬时峰值曲线":
collecting = True
else:
if collecting:
break
continue
match = pattern.search(pattern_text)
if not match:
continue
try:
t_sec = float(match.group(1))
lv = float(lv_text)
except Exception:
continue
curve_points.append((t_sec, lv))
if not curve_points:
messagebox.showinfo("提示", "没有可绘制的瞬时峰值曲线数据")
return
# 时间排序
curve_points.sort(key=lambda x: x[0])
t_data = [p[0] for p in curve_points]
lv_data = [p[1] for p in curve_points]
fig = plt.figure(figsize=(8.6, 4.6))
ax = fig.add_subplot(111)
ax.plot(
t_data,
lv_data,
"-o",
linewidth=1.8,
markersize=3.5,
color="#2a9d8f",
)
ax.set_title("Instant Peak Luminance Curve")
ax.set_xlabel("Time (s)")
ax.set_ylabel("Luminance (cd/m²)")
ax.grid(True, linestyle="--", alpha=0.35)
# 标记峰值
peak_idx = int(np.argmax(lv_data))
ax.scatter(
[t_data[peak_idx]],
[lv_data[peak_idx]],
color="#e76f51",
zorder=3,
)
ax.annotate(
f"Peak: {lv_data[peak_idx]:.2f} cd/m² @ {t_data[peak_idx]:.2f}s",
(t_data[peak_idx], lv_data[peak_idx]),
xytext=(8, 10),
textcoords="offset points",
fontsize=9,
color="#333333",
)
fig.tight_layout()
plt.show(block=False)
self.log_gui.log("已生成本次瞬时峰值曲线图", level="success")
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_manual_window = send_ld_manual_window
send_ld_checkerboard = send_ld_checkerboard
send_ld_black_pattern = send_ld_black_pattern
send_ld_instant_peak = send_ld_instant_peak
start_ld_instant_peak_tracking = start_ld_instant_peak_tracking
stop_ld_instant_peak_tracking = stop_ld_instant_peak_tracking
measure_ld_luminance = measure_ld_luminance
clear_ld_records = clear_ld_records
save_local_dimming_results = save_local_dimming_results
plot_ld_instant_peak_curve = plot_ld_instant_peak_curve
invalidate_ld_ucd_params_cache = invalidate_ld_ucd_params_cache
_insert_ld_tree_item = _insert_ld_tree_item

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

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

1267
app/ucd/device.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,17 +1,4 @@
"""UCD 控制 Domain 层。
纯数据 + 纯函数枚举值对象状态机错误体系事件总线
业务字符串解析与映射本模块****依赖 UniTAP / 任何硬件
可用纯单测覆盖
文件分区
§1 枚举与值对象
§2 状态机
§3 错误体系
§4 事件总线
§5 业务字符串解析 / 映射
"""
"""UCD 域层:类型、状态机、事件、字符串与 PQConfig 映射。"""
from __future__ import annotations
import logging
@@ -25,6 +12,14 @@ log = logging.getLogger(__name__)
# ─── §1 枚举与值对象 ──────────────────────────────────────────────
class DeviceKind(str, Enum):
"""连接状态事件所指的设备类型。"""
UCD = "ucd"
CA = "ca"
class Interface(str, Enum):
"""UCD 物理输出接口。"""
@@ -192,6 +187,7 @@ class UcdEvent:
@dataclass(frozen=True)
class ConnectionChanged(UcdEvent):
device: DeviceKind
connected: bool
serial: str | None = None
@@ -353,6 +349,7 @@ def parse_timing_str(timing_str: str) -> TimingSpec:
__all__ = [
# §1
"DeviceKind",
"Interface",
"ColorFormat",
"Colorimetry",
@@ -386,3 +383,104 @@ __all__ = [
"is_ycbcr",
"parse_timing_str",
]
# --- PQConfig / pattern 映射 ---
# PQ pattern_mode 字符串 → PatternKind大小写不敏感
_PQ_PATTERN_MODE_TO_KIND: dict[str, PatternKind] = {
"disabled": PatternKind.DISABLED,
"solidcolor": PatternKind.SOLID,
"solidwhite": PatternKind.SOLID_WHITE,
"solidred": PatternKind.SOLID_RED,
"solidgreen": PatternKind.SOLID_GREEN,
"solidblue": PatternKind.SOLID_BLUE,
"colorbars": PatternKind.COLOR_BARS,
"chessboard": PatternKind.CHESSBOARD,
"whitevstrips": PatternKind.WHITE_VSTRIPS,
"gradientrgbstripes": PatternKind.GRADIENT_RGB_STRIPES,
"colorramp": PatternKind.COLOR_RAMP,
"coloursquares": PatternKind.COLOR_SQUARES,
"motionpattern": PatternKind.MOTION,
"squarewindow": PatternKind.SQUARE_WINDOW,
}
def pattern_mode_to_kind(pattern_mode: str) -> PatternKind:
key = (pattern_mode or "solidcolor").strip().lower()
kind = _PQ_PATTERN_MODE_TO_KIND.get(key)
if kind is None:
raise UcdConfigError(f"不支持的 pattern_mode: {pattern_mode!r}")
return kind
def build_profile_from_config(config, test_type: str | None = None):
"""从 PQConfig 当前 test_type 条目构建 SignalFormat + TimingSpec。"""
test_type = test_type or config.current_test_type
profile = config.current_test_types[test_type]
signal = build_signal_format_from_profile(
color_space=profile["colorimetry"],
color_format=profile["color_format"],
bpc=int(profile["bpc"]),
data_range=profile.get("data_range", "Full"),
)
timing = build_timing(profile["timing"])
return signal, timing
def build_pattern_spec(config, params: list[int] | None = None) -> PatternSpec:
"""将 PQConfig 当前 pattern 与一组参数转为 :class:`PatternSpec`。"""
pattern_mode = config.current_pattern["pattern_mode"]
kind = pattern_mode_to_kind(pattern_mode)
if params is None:
params = config.current_pattern.get("pattern_params", [[]])[0]
if kind is PatternKind.SOLID and params and len(params) >= 3:
return PatternSpec(
kind=kind,
solid_rgb=(int(params[0]), int(params[1]), int(params[2])),
)
if params:
return PatternSpec(kind=kind, extras=tuple(int(v) for v in params))
return PatternSpec(kind=kind)
def build_signal_format(
*,
color_space: str,
output_format: str,
bit_depth: str,
data_range: str = "Full",
) -> SignalFormat:
return SignalFormat(
color_format=output_format_to_color_format(output_format),
colorimetry=color_space_to_colorimetry(color_space),
bpc=bit_depth_str_to_bpc(bit_depth),
dynamic_range=data_range_to_dynamic_range(data_range),
)
def build_signal_format_from_profile(
*,
color_space: str,
color_format: str,
bpc: int,
data_range: str = "Full",
) -> SignalFormat:
return build_signal_format(
color_space=color_space,
output_format=color_format,
bit_depth=f"{int(bpc)}bit",
data_range=data_range,
)
def build_timing(timing_str: str) -> TimingSpec:
return parse_timing_str(timing_str)
def solid_rgb_pattern(rgb: tuple[int, int, int] | list[int]) -> PatternSpec:
r, g, b = rgb[0], rgb[1], rgb[2]
return PatternSpec(kind=PatternKind.SOLID, solid_rgb=(int(r), int(g), int(b)))
def image_pattern(path: str) -> PatternSpec:
return PatternSpec(kind=PatternKind.IMAGE, image_path=path)

View File

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

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

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

View File

@@ -9,6 +9,7 @@ import ttkbootstrap as ttk
import matplotlib.pyplot as plt
from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from app.views.pq_debug_panel import PQDebugPanel
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
@@ -19,8 +20,7 @@ if TYPE_CHECKING:
def _result_bg_color() -> str:
"""根据当前主题返回结果图背景色。"""
try:
from app.views.theme_manager import is_dark
return "#1B1F24" if is_dark() else "#FFFFFF"
return get_theme_palette()["bg"]
except Exception:
return "#FFFFFF"
@@ -55,6 +55,19 @@ def apply_result_chart_theme(self: "PQAutomationApp"):
pass
def _apply_axes_theme(ax, palette, *, title=None, xlabel=None, ylabel=None):
ax.set_facecolor(palette["card_bg"])
for spine in ax.spines.values():
spine.set_color(palette["border"])
if title is not None:
ax.set_title(title, color=palette["fg"])
if xlabel is not None:
ax.set_xlabel(xlabel, color=palette["fg"])
if ylabel is not None:
ax.set_ylabel(ylabel, color=palette["fg"])
ax.tick_params(axis="both", colors=palette["fg"])
def init_gamut_chart(self: "PQAutomationApp"):
"""初始化色域图表 - 手动设置subplot位置完全避免重叠"""
container = ttk.Frame(self.gamut_chart_frame)
@@ -154,8 +167,10 @@ def init_gamma_chart(self: "PQAutomationApp"):
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)"""
container = ttk.Frame(self.gamma_chart_frame)
container.pack(expand=True, fill=tk.BOTH)
palette = get_theme_palette()
self.gamma_fig = plt.Figure(figsize=(12, 6), dpi=100, constrained_layout=False)
self.gamma_fig.patch.set_facecolor(palette["bg"])
self.gamma_canvas = FigureCanvasTkAgg(self.gamma_fig, master=container)
canvas_widget = self.gamma_canvas.get_tk_widget()
@@ -163,6 +178,7 @@ def init_gamma_chart(self: "PQAutomationApp"):
# 左侧Gamma 曲线
self.gamma_ax = self.gamma_fig.add_axes([0.08, 0.12, 0.50, 0.78])
_apply_axes_theme(self.gamma_ax, palette, xlabel="灰阶 (%)", ylabel="L_bar")
self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.gamma_ax.set_ylabel("L_bar", fontsize=10)
self.gamma_ax.set_xlim(0, 105)
@@ -182,10 +198,13 @@ def init_gamma_chart(self: "PQAutomationApp"):
ha="center",
va="center",
fontsize=10,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1", facecolor="white", edgecolor="gray", alpha=0.8
boxstyle="round,pad=1",
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -223,17 +242,17 @@ def init_gamma_chart(self: "PQAutomationApp"):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.gamma_table_ax.text(
@@ -246,25 +265,27 @@ def init_gamma_chart(self: "PQAutomationApp"):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.gamma_canvas.draw()
def init_eotf_chart(self: "PQAutomationApp"):
"""初始化 EOTF 曲线图表HDR 专用)- 左侧曲线 + 右侧表格4列"""
container = ttk.Frame(self.eotf_chart_frame)
container.pack(expand=True, fill=tk.BOTH)
palette = get_theme_palette()
self.eotf_fig = plt.Figure(figsize=(12, 6), dpi=100, constrained_layout=False)
self.eotf_fig.patch.set_facecolor(palette["bg"])
self.eotf_canvas = FigureCanvasTkAgg(self.eotf_fig, master=container)
canvas_widget = self.eotf_canvas.get_tk_widget()
@@ -272,6 +293,7 @@ def init_eotf_chart(self: "PQAutomationApp"):
# 左侧EOTF 曲线
self.eotf_ax = self.eotf_fig.add_axes([0.08, 0.12, 0.50, 0.78])
_apply_axes_theme(self.eotf_ax, palette, xlabel="灰阶 (%)", ylabel="L_bar (归一化亮度)")
self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.eotf_ax.set_ylabel("L_bar (归一化亮度)", fontsize=10)
self.eotf_ax.set_xlim(0, 105)
@@ -287,10 +309,13 @@ def init_eotf_chart(self: "PQAutomationApp"):
ha="center",
va="center",
fontsize=11,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1", facecolor="white", edgecolor="gray", alpha=0.8
boxstyle="round,pad=1",
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -328,17 +353,17 @@ def init_eotf_chart(self: "PQAutomationApp"):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.eotf_table_ax.text(
@@ -351,25 +376,27 @@ def init_eotf_chart(self: "PQAutomationApp"):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.eotf_canvas.draw()
def init_cct_chart(self: "PQAutomationApp"):
"""初始化色度坐标图表 - 正向横坐标,标题居中最上方"""
container = ttk.Frame(self.cct_chart_frame)
container.pack(expand=True)
palette = get_theme_palette()
self.cct_fig = plt.Figure(figsize=(8, 6), dpi=100, tight_layout=False)
self.cct_fig.patch.set_facecolor(palette["bg"])
self.cct_canvas = FigureCanvasTkAgg(self.cct_fig, master=container)
canvas_widget = self.cct_canvas.get_tk_widget()
@@ -378,7 +405,9 @@ def init_cct_chart(self: "PQAutomationApp"):
canvas_widget.pack_propagate(False)
self.cct_ax1 = self.cct_fig.add_subplot(211)
self.cct_ax1.set_facecolor(palette["card_bg"])
self.cct_ax2 = self.cct_fig.add_subplot(212)
self.cct_ax2.set_facecolor(palette["card_bg"])
# 上图x coordinates
self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9)
@@ -397,7 +426,7 @@ def init_cct_chart(self: "PQAutomationApp"):
self.cct_ax2.tick_params(labelsize=8)
# 调整标题位置y=0.985(比色域/Gamma略高
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985)
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985, color=palette["fg"])
self.cct_fig.subplots_adjust(
left=0.12,
@@ -413,12 +442,14 @@ def init_contrast_chart(self: "PQAutomationApp"):
"""初始化对比度图表 - 固定大小,居中显示"""
container = ttk.Frame(self.contrast_chart_frame)
container.pack(expand=True)
palette = get_theme_palette()
self.contrast_fig = plt.Figure(
figsize=(6, 6),
dpi=100,
tight_layout=False,
)
self.contrast_fig.patch.set_facecolor(palette["bg"])
self.contrast_canvas = FigureCanvasTkAgg(self.contrast_fig, master=container)
canvas_widget = self.contrast_canvas.get_tk_widget()
@@ -428,12 +459,13 @@ def init_contrast_chart(self: "PQAutomationApp"):
canvas_widget.pack_propagate(False)
self.contrast_ax = self.contrast_fig.add_subplot(111)
self.contrast_ax.set_facecolor(palette["card_bg"])
self.contrast_ax.set_xlim(0, 1)
self.contrast_ax.set_ylim(0, 1)
self.contrast_ax.axis("off")
# 调整标题位置y=0.985
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985)
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985, color=palette["fg"])
self.contrast_fig.subplots_adjust(
left=0.02,
@@ -448,6 +480,7 @@ def init_accuracy_chart(self: "PQAutomationApp"):
"""初始化色准图表 - 固定大小,居中显示"""
container = ttk.Frame(self.accuracy_chart_frame)
container.pack(expand=True, fill=tk.BOTH)
palette = get_theme_palette()
container.grid_rowconfigure(0, weight=1)
container.grid_rowconfigure(1, weight=0, minsize=220)
container.grid_columnconfigure(0, weight=1)
@@ -464,18 +497,27 @@ def init_accuracy_chart(self: "PQAutomationApp"):
dpi=100,
tight_layout=False,
)
self.accuracy_fig.patch.set_facecolor(palette["bg"])
try:
self.accuracy_fig.set_layout_engine(None)
except Exception:
try:
self.accuracy_fig.set_tight_layout(False)
except Exception:
pass
self.accuracy_canvas = FigureCanvasTkAgg(self.accuracy_fig, master=plot_container)
canvas_widget = self.accuracy_canvas.get_tk_widget()
canvas_widget.pack(fill=tk.BOTH, expand=True)
self.accuracy_ax = self.accuracy_fig.add_subplot(111)
self.accuracy_ax.set_facecolor(palette["card_bg"])
self.accuracy_ax.set_xlim(0, 1)
self.accuracy_ax.set_ylim(0, 1)
self.accuracy_ax.axis("off")
# 调整标题位置
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985)
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985, color=palette["fg"])
self.accuracy_fig.subplots_adjust(
left=0.05,
@@ -616,6 +658,7 @@ def update_accuracy_result_table(self: "PQAutomationApp", accuracy_data, standar
def clear_chart(self: "PQAutomationApp"):
"""清空所有图表"""
palette = get_theme_palette()
# ========== 1. 清空色域图表 ==========
if hasattr(self, "gamut_ax_xy") and hasattr(self, "gamut_ax_uv"):
@@ -640,12 +683,17 @@ def clear_chart(self: "PQAutomationApp"):
if hasattr(self, "gamma_ax") and hasattr(self, "gamma_table_ax"):
# 清空左侧曲线
self.gamma_ax.clear()
self.gamma_fig.patch.set_facecolor(palette["bg"])
self.gamma_ax.set_facecolor(palette["card_bg"])
self.gamma_ax.set_xlim(0, 105)
self.gamma_ax.set_ylim(0, 1.1)
self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.gamma_ax.set_ylabel("L_bar", fontsize=10)
self.gamma_ax.grid(True, linestyle="--", alpha=0.3)
self.gamma_ax.tick_params(labelsize=9)
self.gamma_ax.tick_params(colors=palette["fg"])
for spine in self.gamma_ax.spines.values():
spine.set_color(palette["border"])
# 左侧提示
self.gamma_ax.text(
@@ -659,13 +707,13 @@ def clear_chart(self: "PQAutomationApp"):
ha="center",
va="center",
fontsize=10,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1",
facecolor="white",
edgecolor="gray",
alpha=0.8,
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -703,17 +751,17 @@ def clear_chart(self: "PQAutomationApp"):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.gamma_table_ax.text(
@@ -726,29 +774,34 @@ def clear_chart(self: "PQAutomationApp"):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.gamma_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98)
self.gamma_fig.suptitle("Gamma曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.gamma_canvas.draw()
# ========== 3. 清空EOTF图表4列==========
if hasattr(self, "eotf_ax") and hasattr(self, "eotf_table_ax"):
# 清空左侧曲线
self.eotf_ax.clear()
self.eotf_fig.patch.set_facecolor(palette["bg"])
self.eotf_ax.set_facecolor(palette["card_bg"])
self.eotf_ax.set_xlim(0, 105)
self.eotf_ax.set_ylim(0, 1.1)
self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10)
self.eotf_ax.set_ylabel("L_bar (归一化亮度)", fontsize=10)
self.eotf_ax.grid(True, linestyle="--", alpha=0.3)
self.eotf_ax.tick_params(labelsize=9)
self.eotf_ax.tick_params(colors=palette["fg"])
for spine in self.eotf_ax.spines.values():
spine.set_color(palette["border"])
# 左侧提示
self.eotf_ax.text(
@@ -758,13 +811,13 @@ def clear_chart(self: "PQAutomationApp"):
ha="center",
va="center",
fontsize=11,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_ax.transAxes,
bbox=dict(
boxstyle="round,pad=1",
facecolor="white",
edgecolor="gray",
alpha=0.8,
facecolor=palette["card_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
@@ -802,17 +855,17 @@ def clear_chart(self: "PQAutomationApp"):
# 表头样式
for i in range(4):
cell = table[(0, i)]
cell.set_facecolor("#4472C4")
cell.set_text_props(weight="bold", color="white", fontsize=7)
cell.set_facecolor(palette["primary"])
cell.set_text_props(weight="bold", color=palette["select_fg"], fontsize=7)
# 数据行交替颜色
for i in range(1, len(table_data)):
for j in range(4):
cell = table[(i, j)]
if i % 2 == 0:
cell.set_facecolor("#E7E6E6")
cell.set_facecolor(palette["surface_alt_bg"])
else:
cell.set_facecolor("#FFFFFF")
cell.set_facecolor(palette["card_bg"])
# 底部说明
self.eotf_table_ax.text(
@@ -825,17 +878,17 @@ def clear_chart(self: "PQAutomationApp"):
ha="center",
va="bottom",
fontsize=7,
color="gray",
color=palette["muted_fg"],
transform=self.eotf_table_ax.transAxes,
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="lightyellow",
edgecolor="gray",
alpha=0.8,
facecolor=palette["surface_alt_bg"],
edgecolor=palette["border"],
alpha=0.95,
),
)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98)
self.eotf_fig.suptitle("EOTF 曲线 + 数据表格", fontsize=12, y=0.98, color=palette["fg"])
self.eotf_canvas.draw()
# ========== 4. 清空色度图表 ==========
@@ -843,8 +896,10 @@ def clear_chart(self: "PQAutomationApp"):
# 过期引用。因此清空时必须同样重建,并更新引用,否则清不干净。
if hasattr(self, "cct_fig") and hasattr(self, "cct_canvas"):
self.cct_fig.clear()
self.cct_fig.patch.set_facecolor(palette["bg"])
self.cct_ax1 = self.cct_fig.add_subplot(211)
self.cct_ax1.set_facecolor(palette["card_bg"])
self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9)
self.cct_ax1.set_ylabel("CIE x", fontsize=9)
self.cct_ax1.set_xlim(0, 105)
@@ -853,6 +908,7 @@ def clear_chart(self: "PQAutomationApp"):
self.cct_ax1.tick_params(labelsize=8)
self.cct_ax2 = self.cct_fig.add_subplot(212)
self.cct_ax2.set_facecolor(palette["card_bg"])
self.cct_ax2.set_xlabel("灰阶 (%)", fontsize=9)
self.cct_ax2.set_ylabel("CIE y", fontsize=9)
self.cct_ax2.set_xlim(0, 105)
@@ -860,7 +916,7 @@ def clear_chart(self: "PQAutomationApp"):
self.cct_ax2.grid(True, linestyle="--", alpha=0.3)
self.cct_ax2.tick_params(labelsize=8)
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985)
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985, color=palette["fg"])
self.cct_fig.subplots_adjust(
left=0.12,
right=0.88,
@@ -873,11 +929,13 @@ def clear_chart(self: "PQAutomationApp"):
# ========== 5. 清空对比度图表 ==========
if hasattr(self, "contrast_ax"):
self.contrast_ax.clear()
self.contrast_fig.patch.set_facecolor(palette["bg"])
self.contrast_ax.set_facecolor(palette["card_bg"])
self.contrast_ax.set_xlim(0, 1)
self.contrast_ax.set_ylim(0, 1)
self.contrast_ax.axis("off")
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985)
self.contrast_fig.suptitle("对比度测试", fontsize=12, y=0.985, color=palette["fg"])
# 重置布局
self.contrast_fig.subplots_adjust(
@@ -892,12 +950,14 @@ def clear_chart(self: "PQAutomationApp"):
# ========== 6. 清空色准图表 ==========
if hasattr(self, "accuracy_ax"):
self.accuracy_ax.clear()
self.accuracy_fig.patch.set_facecolor(palette["bg"])
self.accuracy_ax.set_facecolor(palette["card_bg"])
self.accuracy_ax.set_xlim(0, 1)
self.accuracy_ax.set_ylim(0, 1)
self.accuracy_ax.axis("off")
# 标题
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985)
self.accuracy_fig.suptitle("色准测试", fontsize=12, y=0.985, color=palette["fg"])
# 重置布局
self.accuracy_fig.subplots_adjust(

View File

@@ -36,37 +36,188 @@ def _is_dark(color: str) -> bool:
return (r * 299 + g * 587 + b * 114) / 1000 < 128
def apply_modern_styles() -> None:
"""注册或刷新现代化样式集。可在主题切换后再次调用。"""
style = ttk.Style()
theme = style.colors # ttkbootstrap.style.Colors
def _contrast_text(color: str, *, dark_text: str, light_text: str) -> str:
return dark_text if _is_dark(color) else light_text
bg = theme.bg # 主背景
fg = theme.fg # 主前景
def get_theme_palette() -> dict[str, str]:
"""返回当前主题的语义色板,供 ttk / tk 自定义控件共用。"""
style = ttk.Style()
theme = style.colors
bg = theme.bg
fg = theme.fg
primary = theme.primary
secondary = theme.secondary
success = theme.success
info = theme.info
warning = theme.warning
danger = theme.danger
dark = theme.dark
border = theme.border
inputbg = theme.inputbg
inputfg = getattr(theme, "inputfg", fg)
dark_theme = _is_dark(bg)
select_bg = getattr(theme, "selectbg", _mix(primary, bg, 0.30 if dark_theme else 0.12))
select_fg = getattr(theme, "selectfg", "#ffffff" if _is_dark(select_bg) else fg)
# 卡片背景:在主背景上轻微偏移,营造层级感
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)
if dark_theme:
card_bg = _mix(bg, "#ffffff", 0.04)
card_border = _mix(bg, fg, 0.18)
header_fg = _contrast_text(
"#444A51",
dark_text="#ffffff",
light_text="#1a1a1a",
)
sidebar_bg = _mix(dark, bg, 0.18)
sidebar_hover = _mix(sidebar_bg, "#ffffff", 0.07)
sidebar_selected = _mix(sidebar_bg, "#ffffff", 0.14)
sidebar_fg = _mix(fg, "#ffffff", 0.04)
sidebar_muted = _mix(sidebar_fg, sidebar_bg, 0.45)
muted_fg = _mix(fg, bg, 0.32)
disabled_fg = _mix(fg, bg, 0.42)
disabled_bg = _mix(inputbg, bg, 0.18)
disabled_border = _mix(border, fg, 0.22)
readonly_bg = _mix(inputbg, "#ffffff", 0.06)
success_fg = _mix(success, "#ffffff", 0.08)
warning_fg = _mix(warning, "#ffffff", 0.06)
info_fg = _mix(info, "#ffffff", 0.06)
statusbar_bg = _mix(bg, "#ffffff", 0.06)
tooltip_bg = _mix(inputbg, bg, 0.08)
tooltip_fg = inputfg
tooltip_border = _mix(border, fg, 0.20)
surface_alt_bg = _mix(card_bg, "#ffffff", 0.05)
surface_hover_bg = _mix(card_bg, "#ffffff", 0.09)
badge_bg = _mix(danger, bg, 0.12)
badge_fg = "#ffffff"
focus = _mix(primary, "#ffffff", 0.18)
config_bg = _mix("#444A51", bg, 0.30)
else:
card_bg = inputbg
card_border = border
header_fg = bg
config_bg = _mix(primary, bg, 0.25)
sidebar_bg = _mix(primary, bg, 0.82)
sidebar_hover = _mix(primary, bg, 0.72)
sidebar_selected = primary
sidebar_fg = fg
sidebar_muted = _mix(fg, sidebar_bg, 0.35)
muted_fg = _mix(fg, bg, 0.38)
disabled_fg = _mix(fg, bg, 0.55)
disabled_bg = _mix(bg, border, 0.18)
disabled_border = _mix(border, bg, 0.18)
readonly_bg = _mix(inputbg, primary, 0.04)
success_fg = success
warning_fg = _mix(warning, fg, 0.18)
info_fg = info
statusbar_bg = _mix(bg, dark, 0.04)
tooltip_bg = inputbg
tooltip_fg = inputfg
tooltip_border = border
surface_alt_bg = _mix(bg, dark, 0.03)
surface_hover_bg = _mix(bg, dark, 0.05)
badge_bg = danger
badge_fg = "#ffffff"
focus = _mix(primary, bg, 0.20)
return {
"bg": bg,
"fg": fg,
"primary": primary,
"secondary": secondary,
"success": success,
"info": info,
"warning": warning,
"danger": danger,
"border": border,
"input_bg": inputbg,
"input_fg": inputfg,
"select_bg": select_bg,
"select_fg": select_fg,
"card_bg": card_bg,
"card_border": card_border,
"header_fg": header_fg,
"sidebar_bg": sidebar_bg,
"sidebar_hover": sidebar_hover,
"sidebar_selected": sidebar_selected,
"sidebar_fg": sidebar_fg,
"sidebar_muted": sidebar_muted,
"muted_fg": muted_fg,
"disabled_fg": disabled_fg,
"disabled_bg": disabled_bg,
"disabled_border": disabled_border,
"readonly_bg": readonly_bg,
"success_fg": success_fg,
"warning_fg": warning_fg,
"info_fg": info_fg,
"statusbar_bg": statusbar_bg,
"tooltip_bg": tooltip_bg,
"tooltip_fg": tooltip_fg,
"tooltip_border": tooltip_border,
"surface_alt_bg": surface_alt_bg,
"surface_hover_bg": surface_hover_bg,
"badge_bg": badge_bg,
"badge_fg": badge_fg,
"focus": focus,
"config_bg": config_bg,
}
def apply_listbox_theme(widget) -> None:
"""将 tk.Listbox 颜色同步到当前主题。"""
palette = get_theme_palette()
widget.configure(
background=palette["input_bg"],
foreground=palette["input_fg"],
highlightbackground=palette["border"],
highlightcolor=palette["focus"],
selectbackground=palette["select_bg"],
selectforeground=palette["select_fg"],
disabledforeground=palette["disabled_fg"],
)
def apply_tooltip_theme(toplevel, label) -> None:
"""将 tooltip 的 tk.Toplevel / Label 同步到当前主题。"""
palette = get_theme_palette()
toplevel.configure(background=palette["tooltip_border"])
label.configure(
bg=palette["tooltip_bg"],
fg=palette["tooltip_fg"],
highlightbackground=palette["tooltip_border"],
)
def apply_modern_styles() -> None:
"""注册或刷新现代化样式集。可在主题切换后再次调用。"""
style = ttk.Style()
palette = get_theme_palette()
bg = palette["bg"]
fg = palette["fg"]
primary = palette["primary"]
secondary = palette["secondary"]
info = palette["info"]
card_bg = palette["card_bg"]
card_border = palette["card_border"]
header_bg = palette["config_bg"]
header_fg = palette["header_fg"]
dark_theme = _is_dark(bg)
header_hover_bg = _mix(secondary, "#ffffff", 0.08) if _is_dark(secondary) else _mix(secondary, "#000000", 0.08)
preview_fg = _mix(header_fg, header_bg, 0.35)
sidebar_bg = palette["sidebar_bg"]
sidebar_hover = palette["sidebar_hover"]
sidebar_selected = palette["sidebar_selected"]
sidebar_fg = palette["sidebar_fg"]
sidebar_muted = palette["sidebar_muted"]
muted_fg = palette["muted_fg"]
disabled_fg = palette["disabled_fg"]
disabled_bg = palette["disabled_bg"]
disabled_border = palette["disabled_border"]
readonly_bg = palette["readonly_bg"]
success_fg = palette["success_fg"]
warning_fg = palette["warning_fg"]
# ---------------- 卡片 ----------------
style.configure(
@@ -134,6 +285,12 @@ def apply_modern_styles() -> None:
font=("Segoe UI", 9),
)
# ---------------- 通用文字语义 ----------------
style.configure("Muted.TLabel", background=bg, foreground=muted_fg)
style.configure("SuccessState.TLabel", background=bg, foreground=success_fg)
style.configure("WarningState.TLabel", background=bg, foreground=warning_fg)
style.configure("InfoState.TLabel", background=bg, foreground=palette["info_fg"])
# ---------------- 顶部工具条 ----------------
style.configure("Toolbar.TFrame", background=bg, borderwidth=0)
# 工具条上的次要按钮(清理配置等)
@@ -168,9 +325,17 @@ def apply_modern_styles() -> None:
style.configure(
"SidebarBrand.TLabel",
background=brand_bg,
foreground="#ffffff",
foreground=palette["badge_fg"],
font=("Segoe UI Semibold", 12),
)
style.configure(
"SidebarBadge.TLabel",
background=palette["badge_bg"],
foreground=palette["badge_fg"],
font=("微软雅黑", 8, "bold"),
anchor="center",
padding=(6, 2),
)
# ---------------- 结果区无边框标题行 ----------------
style.configure("ResultHeader.TFrame", background=bg, borderwidth=0)
@@ -182,7 +347,7 @@ def apply_modern_styles() -> None:
)
# ---------------- 状态栏 ----------------
statusbar_bg = _mix(bg, "#000000", 0.06) if not dark_theme else _mix(bg, "#ffffff", 0.06)
statusbar_bg = palette["statusbar_bg"]
statusbar_fg = _mix(fg, bg, 0.15)
style.configure(
"StatusBar.TFrame",
@@ -204,6 +369,33 @@ def apply_modern_styles() -> None:
padding=(10, 4),
)
# ---------------- 深色禁用态 / 只读态增强 ----------------
style.map(
"TLabel",
foreground=[("disabled", disabled_fg)],
)
style.map(
"TButton",
foreground=[("disabled", disabled_fg)],
background=[("disabled", disabled_bg)],
bordercolor=[("disabled", disabled_border)],
darkcolor=[("disabled", disabled_bg)],
lightcolor=[("disabled", disabled_bg)],
)
style.map(
"TEntry",
foreground=[("disabled", disabled_fg)],
fieldbackground=[("disabled", disabled_bg), ("readonly", readonly_bg)],
bordercolor=[("disabled", disabled_border), ("readonly", disabled_border)],
)
style.map(
"TCombobox",
foreground=[("disabled", disabled_fg), ("readonly", fg)],
fieldbackground=[("disabled", disabled_bg), ("readonly", readonly_bg)],
bordercolor=[("disabled", disabled_border), ("readonly", disabled_border)],
arrowcolor=[("disabled", disabled_fg), ("readonly", muted_fg)],
)
# ---------------- Sidebar 按钮(保留兼容名) ----------------
style.configure(
"Sidebar.TButton",
@@ -225,7 +417,7 @@ def apply_modern_styles() -> None:
style.configure(
"SidebarSelected.TButton",
background=sidebar_selected,
foreground="#ffffff",
foreground=_contrast_text(sidebar_selected, dark_text=palette["badge_fg"], light_text=sidebar_fg),
font=("Segoe UI Semibold", 10),
padding=(18, 9),
borderwidth=0,

View File

@@ -28,6 +28,16 @@ def show_panel(self: "PQAutomationApp", panel_name):
# 如果当前面板就是要显示的面板,则隐藏它
if self.current_panel == panel_name:
self.hide_all_panels()
# 如果当前测试类型是 Local Dimming则在关闭日志等面板后自动恢复 Local Dimming 面板
try:
if (
getattr(self, "config", None)
and getattr(self.config, "current_test_type", None) == "local_dimming"
and panel_name != "local_dimming"
):
self.show_panel("local_dimming")
except Exception:
pass
return
# 隐藏所有面板

View File

@@ -15,6 +15,7 @@ import ttkbootstrap as ttk
from PIL import Image, ImageTk
from app.services import ai_image as _svc
from app.views.modern_styles import apply_tooltip_theme, get_theme_palette
from typing import TYPE_CHECKING
@@ -26,17 +27,19 @@ logger = logging.getLogger(__name__)
def _theme_colors():
style = ttk.Style()
colors = style.colors
palette = get_theme_palette()
return {
"bg": colors.bg,
"fg": colors.fg,
"muted": colors.secondary,
"input_bg": colors.inputbg,
"input_fg": colors.inputfg,
"select_bg": colors.selectbg,
"select_fg": colors.selectfg,
"border": colors.border,
"bg": palette["bg"],
"fg": palette["fg"],
"muted": palette["muted_fg"],
"input_bg": palette["input_bg"],
"input_fg": palette["input_fg"],
"select_bg": palette["select_bg"],
"select_fg": palette["select_fg"],
"border": palette["border"],
"tooltip_bg": palette["tooltip_bg"],
"tooltip_fg": palette["tooltip_fg"],
"tooltip_border": palette["tooltip_border"],
}
@@ -95,8 +98,6 @@ def _show_tree_tooltip(self: "PQAutomationApp", text: str, x_root: int, y_root:
text="",
justify=tk.LEFT,
anchor=tk.W,
bg="#ffffff",
fg="#1f2937",
relief=tk.SOLID,
bd=1,
padx=8,
@@ -104,6 +105,7 @@ def _show_tree_tooltip(self: "PQAutomationApp", text: str, x_root: int, y_root:
font=("微软雅黑", 9),
wraplength=520,
)
apply_tooltip_theme(tip, label)
label.pack(fill=tk.BOTH, expand=True)
self._ai_image_tooltip = tip
self._ai_image_tooltip_label = label
@@ -114,6 +116,7 @@ def _show_tree_tooltip(self: "PQAutomationApp", text: str, x_root: int, y_root:
self._ai_image_tooltip_item = item_id
label.configure(text=text)
apply_tooltip_theme(tip, label)
tip.geometry(f"+{x_root + 14}+{y_root + 18}")
tip.deiconify()
tip.lift()
@@ -412,9 +415,7 @@ def toggle_ai_image_panel(self: "PQAutomationApp"):
self.show_panel("ai_image")
_apply_ai_image_list_style(self)
if not getattr(self, "_ai_image_list_loaded", False):
logger.info("[AIImagePanel] 首次显示面板,开始加载列表")
reload_ai_image_list(self)
self._ai_image_list_loaded = True
_start_new_session(self)
def _get_app_base_dir(self: "PQAutomationApp") -> str:
@@ -615,7 +616,6 @@ def _on_list_select(self: "PQAutomationApp"):
if getattr(self, "_ai_image_reloading", False):
return
if getattr(self, "_ai_image_select_guard", False):
logger.debug("[AIImagePanel] 忽略重入选择事件")
return
sel = self.ai_image_tree.selection()
if not sel:
@@ -628,7 +628,6 @@ def _on_list_select(self: "PQAutomationApp"):
if ridx is None:
session_id = _session_id_for_item(self, item_id)
if session_id:
logger.info("[AIImagePanel] 选中会话头 sid=%s", session_id[:8])
_switch_to_session(self, session_id, show_message=False, refresh_list=False)
return
if 0 <= ridx < len(self.ai_image_records):
@@ -803,17 +802,22 @@ def _on_upload_done(self: "PQAutomationApp", name: str, url: str, exc):
def _clear_reference_image(self: "PQAutomationApp"):
"""清除手动上传的参考图,同时清除当前会话的自动链路参考。"""
"""清除手动上传的参考图
v2.1 规则要求:从第二轮开始应使用最近一次成功生成图作为输入,
因此这里不清除会话级自动链路参考;若需彻底重置,请点“新对话”。
"""
if getattr(self, "_ai_image_requesting", False):
return
self._ai_image_pending_ref_url = ""
self._ai_image_pending_ref_name = ""
sid = _svc.get_session_id()
refs = getattr(self, "_ai_image_session_refs", None)
if isinstance(refs, dict):
refs.pop(sid, None)
_refresh_ref_label(self)
self.ai_image_status_var.set("已清除参考图,切换为文生图模式")
sid = _svc.get_session_id()
refs = getattr(self, "_ai_image_session_refs", None) or {}
if (refs.get(sid) or "").strip():
self.ai_image_status_var.set("已清除手动参考图,当前会话仍沿用上一轮生成图")
else:
self.ai_image_status_var.set("已清除参考图,当前为文生图模式")
def _session_id_for_item(self: "PQAutomationApp", item_id: str) -> str:
@@ -840,7 +844,7 @@ def _switch_to_session(
if sid == _svc.get_session_id():
return
_svc.set_session_id(sid)
logger.info(
logger.debug(
"[AIImagePanel] 切换会话 sid=%s refresh=%s target=%s",
sid[:8],
refresh_list,
@@ -1220,6 +1224,16 @@ def _build_ucd_resized_image(image_path: str, target_w: int, target_h: int) -> s
return out_path
def refresh_ai_image_theme(self: "PQAutomationApp"):
"""刷新 AI 图片面板中的主题相关控件。"""
if hasattr(self, "_apply_ai_image_list_style"):
self._apply_ai_image_list_style()
tip = getattr(self, "_ai_image_tooltip", None)
label = getattr(self, "_ai_image_tooltip_label", None)
if tip is not None and label is not None:
apply_tooltip_theme(tip, label)
class AIImagePanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
@@ -1244,3 +1258,5 @@ class AIImagePanelMixin:
_rename_current = _rename_current
_show_list_context_menu = _show_list_context_menu
_send_to_ucd = _send_to_ucd
_apply_ai_image_list_style = _apply_ai_image_list_style
refresh_ai_image_theme = refresh_ai_image_theme

View File

@@ -22,6 +22,7 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg
from matplotlib.figure import Figure
from app.tests.color_accuracy import calculate_delta_e_2000
from app.views.modern_styles import get_theme_palette
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
@@ -35,10 +36,6 @@ D65_X = 0.3127
D65_Y = 0.3290
TARGET_CCT = 6504
TARGET_GAMMA = 2.2
_DARK_BG = "#2f2f2f"
_AX_BG = "#262626"
_FG = "#d8d8d8"
_GRID = "#5b5b5b"
DE_FORMULAS = ["2000", "94", "76"]
@@ -60,7 +57,7 @@ def _contrast_fg(gray_value: int) -> str:
def _set_canvas_patch(canvas: tk.Canvas, color: str, text: str) -> None:
"""统一设置色块 Canvas 颜色与文字,避免主题对 Label 背景渲染的干扰。"""
gray = int(color[1:3], 16)
canvas.configure(bg=color, highlightbackground="#666666")
canvas.configure(bg=color, highlightbackground=get_theme_palette()["border"])
canvas.itemconfigure("patch_bg", fill=color, outline=color)
canvas.itemconfigure("patch_text", text=text, fill=_contrast_fg(gray))
@@ -115,6 +112,7 @@ def _get_calman_palette() -> dict[str, str]:
"""根据当前主题生成 Calman 调试面板色板。"""
style = ttk.Style()
colors = style.colors
theme_palette = get_theme_palette()
bg = colors.bg
fg = colors.fg
dark_mode = _is_dark_hex(bg)
@@ -131,22 +129,22 @@ def _get_calman_palette() -> dict[str, str]:
reading_fg = _mix(fg, "#ffffff", 0.06)
status_fg = _mix(fg, bg, 0.35)
reading_accent = colors.info
xy_series = "#d7dce4"
d65_mark = "#ffffff"
xy_series = _mix(fg, "#ffffff", 0.10)
d65_mark = _mix(fg, "#ffffff", 0.04)
else:
figure_bg = _mix(bg, "#dfe7ef", 0.45)
axes_bg = _mix(bg, "#eff4f9", 0.72)
grid = _mix("#5f6f82", axes_bg, 0.55)
tree_bg = "#ffffff"
tree_even = "#ffffff"
tree_bg = theme_palette["input_bg"]
tree_even = theme_palette["input_bg"]
tree_odd = "#f3f7fb"
heading_bg = _mix(colors.primary, "#ffffff", 0.82)
reading_bg = _mix(bg, "#e7eef5", 0.58)
reading_fg = fg
status_fg = _mix(fg, bg, 0.25)
reading_accent = _mix(colors.info, "#000000", 0.25)
xy_series = "#1f2a36"
d65_mark = "#253142"
xy_series = _mix(fg, bg, 0.18)
d65_mark = _mix(fg, bg, 0.28)
return {
"figure_bg": figure_bg,
@@ -166,31 +164,59 @@ def _get_calman_palette() -> dict[str, str]:
"tree_heading_bg": heading_bg,
"tree_heading_fg": reading_fg,
"tree_select": _mix(colors.info, figure_bg, 0.35),
"patch_border": theme_palette["border"],
"patch_border_alt": _mix(theme_palette["border"], theme_palette["fg"], 0.12),
"patch_focus": theme_palette["focus"],
"xy_series": xy_series,
"d65_mark": d65_mark,
}
def _xyY_to_rgb_balance(x: float, y: float, big_y: float) -> tuple[float, float, float]:
"""把 xyY 近似映射到 RGB 比例,并归一到平均值 100"""
"""按 D65 同亮度参考计算 RGB BalanceCalman 常见口径)"""
if y <= 0 or big_y <= 0:
return float("nan"), float("nan"), float("nan")
big_x = (x * big_y) / y
big_z = ((1.0 - x - y) * big_y) / y
r = (3.2406 * big_x) + (-1.5372 * big_y) + (-0.4986 * big_z)
g = (-0.9689 * big_x) + (1.8758 * big_y) + (0.0415 * big_z)
b = (0.0557 * big_x) + (-0.2040 * big_y) + (1.0570 * big_z)
r = max(r, 0.0)
g = max(g, 0.0)
b = max(b, 0.0)
avg = (r + g + b) / 3.0
if avg <= 0:
def _xyY_to_xyz(cx: float, cy: float, cy_big: float) -> tuple[float, float, float]:
if cy <= 0:
return float("nan"), float("nan"), float("nan")
return (r / avg) * 100.0, (g / avg) * 100.0, (b / avg) * 100.0
cx_big = (cx * cy_big) / cy
cz_big = ((1.0 - cx - cy) * cy_big) / cy
return cx_big, cy_big, cz_big
def _xyz_to_linear_rgb(cx_big: float, cy_big: float, cz_big: float) -> tuple[float, float, float]:
rr = (3.2406 * cx_big) + (-1.5372 * cy_big) + (-0.4986 * cz_big)
gg = (-0.9689 * cx_big) + (1.8758 * cy_big) + (0.0415 * cz_big)
bb = (0.0557 * cx_big) + (-0.2040 * cy_big) + (1.0570 * cz_big)
return rr, gg, bb
mx, my, mz = _xyY_to_xyz(x, y, big_y)
tx, ty, tz = _xyY_to_xyz(D65_X, D65_Y, big_y)
mr, mg, mb = _xyz_to_linear_rgb(mx, my, mz)
tr, tg, tb = _xyz_to_linear_rgb(tx, ty, tz)
eps = 1e-9
if tr <= eps or tg <= eps or tb <= eps:
return float("nan"), float("nan"), float("nan")
rr = (mr / tr) * 100.0
gg = (mg / tg) * 100.0
bb = (mb / tb) * 100.0
# 明显异常值视为无效,避免图表被离群点拉坏。
if not (math.isfinite(rr) and math.isfinite(gg) and math.isfinite(bb)):
return float("nan"), float("nan"), float("nan")
if rr < 0 or gg < 0 or bb < 0:
return float("nan"), float("nan"), float("nan")
return rr, gg, bb
def _target_gamma_loglog_curve(pct: int) -> float:
"""Calman风格目标曲线低灰阶从 1.8 过渡并逐步逼近 2.2。"""
if pct <= 0:
return 1.8
return TARGET_GAMMA - 0.4 * math.exp(-pct / 6.0)
def _style_axes(self: "PQAutomationApp", ax, title: str) -> None:
@@ -232,6 +258,62 @@ def _apply_calman_tree_style(self: "PQAutomationApp") -> None:
self.calman_data_tree.configure(style="Calman.Treeview")
def _calman_log(self: "PQAutomationApp", message: str, level: str = "info") -> None:
"""统一输出 Calman 面板日志。"""
logger = getattr(self, "log_gui", None)
if logger is None:
return
self._dispatch_ui(self.log_gui.log, f"CALMAN: {message}", level)
def _build_calman_config_summary(self: "PQAutomationApp") -> str:
"""生成顶部配置摘要,跟随当前测试类型展示 UCD 参数。"""
cfg = getattr(self, "config", None)
test_type = getattr(cfg, "current_test_type", "screen_module")
test_cfg = {}
if cfg is not None:
test_cfg = getattr(cfg, "current_test_types", {}).get(test_type, {})
if test_type == "screen_module":
color_space = getattr(getattr(self, "screen_module_color_space_var", None), "get", lambda: test_cfg.get("colorimetry", "-"))()
output_format = getattr(getattr(self, "screen_module_output_format_var", None), "get", lambda: test_cfg.get("color_format", "-"))()
bit_depth = getattr(getattr(self, "screen_module_bit_depth_var", None), "get", lambda: f"{int(test_cfg.get('bpc', 8))}bit")()
data_range = getattr(getattr(self, "screen_module_data_range_var", None), "get", lambda: test_cfg.get("data_range", "Full"))()
timing = test_cfg.get("timing", "-")
profile_name = "Screen"
elif test_type == "sdr_movie":
color_space = getattr(getattr(self, "sdr_color_space_var", None), "get", lambda: test_cfg.get("colorimetry", "-"))()
output_format = getattr(getattr(self, "sdr_output_format_var", None), "get", lambda: test_cfg.get("color_format", "-"))()
bit_depth = getattr(getattr(self, "sdr_bit_depth_var", None), "get", lambda: f"{int(test_cfg.get('bpc', 8))}bit")()
data_range = getattr(getattr(self, "sdr_data_range_var", None), "get", lambda: test_cfg.get("data_range", "Full"))()
timing = test_cfg.get("timing", "-")
profile_name = "SDR"
elif test_type == "hdr_movie":
color_space = getattr(getattr(self, "hdr_color_space_var", None), "get", lambda: test_cfg.get("colorimetry", "-"))()
output_format = getattr(getattr(self, "hdr_output_format_var", None), "get", lambda: test_cfg.get("color_format", "-"))()
bit_depth = getattr(getattr(self, "hdr_bit_depth_var", None), "get", lambda: f"{int(test_cfg.get('bpc', 10))}bit")()
data_range = getattr(getattr(self, "hdr_data_range_var", None), "get", lambda: test_cfg.get("data_range", "Limited"))()
timing = test_cfg.get("timing", "-")
profile_name = "HDR"
else:
color_space = test_cfg.get("colorimetry", "-")
output_format = test_cfg.get("color_format", "-")
bit_depth = test_cfg.get("bpc", "-")
data_range = test_cfg.get("data_range", "-")
timing = test_cfg.get("timing", "-")
profile_name = test_type
return (
f"Profile: {profile_name} | Timing: {timing} | CS: {color_space} | "
f"Fmt: {output_format} | Depth: {bit_depth} | Range: {data_range}"
)
def _refresh_calman_config_summary(self: "PQAutomationApp") -> None:
if hasattr(self, "calman_config_summary_var"):
self.calman_config_summary_var.set(_build_calman_config_summary(self))
def create_calman_panel(self: "PQAutomationApp") -> None:
"""创建 CALMAN 风格灰阶测试面板,注册到 panel_manager。"""
palette = _get_calman_palette()
@@ -242,6 +324,7 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
self.calman_results = {}
self.calman_stop_event = threading.Event()
self.calman_running = False
self.calman_patch_send_busy = False
self.calman_current_level = None
self.calman_last_record = None
self.calman_last_step_seconds = None
@@ -298,6 +381,15 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
)
self.calman_elapsed_label.pack(side=tk.LEFT)
self.calman_config_summary_var = tk.StringVar(value="")
self.calman_config_summary_label = ttk.Label(
control_row,
textvariable=self.calman_config_summary_var,
foreground=palette["status_fg"],
anchor=tk.W,
)
self.calman_config_summary_label.pack(side=tk.LEFT, padx=(12, 0), fill=tk.X, expand=True)
metrics_row = ttk.Frame(chart_frame)
metrics_row.grid(row=2, column=0, sticky=tk.EW, pady=(2, 0))
metrics_row.columnconfigure((0, 1, 2, 3), weight=1)
@@ -414,6 +506,7 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
self.calman_actual_patch_cells = []
self.calman_target_patch_canvases = []
self.calman_target_hexes = []
patch_palette = _get_calman_palette()
for idx, pct in enumerate(self.calman_levels):
rgb = _pct_to_gray_rgb(pct)
color = _rgb_to_hex(rgb)
@@ -427,7 +520,7 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
bd=1,
relief="solid",
highlightthickness=1,
highlightbackground="#808080",
highlightbackground=patch_palette["patch_border_alt"],
cursor="hand2",
)
actual_cell.grid(row=0, column=idx, padx=1, pady=1, sticky=tk.NSEW)
@@ -454,7 +547,7 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
bd=1,
relief="solid",
highlightthickness=1,
highlightbackground="#9c9c9c",
highlightbackground=patch_palette["patch_border"],
cursor="hand2",
)
cell.grid(row=1, column=idx, padx=1, pady=1, sticky=tk.NSEW)
@@ -478,7 +571,13 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
)
def _bind_click(widget, p=pct):
widget.bind("<Button-1>", lambda _e, pp=p: send_patch(self, pp))
def _on_click(_e, pp=p):
send_patch(self, pp)
# Prevent event bubbling from canvas -> parent cell, which would
# otherwise trigger duplicated sends for a single click.
return "break"
widget.bind("<Button-1>", _on_click)
for w in (cell, target_canvas):
_bind_click(w)
@@ -581,6 +680,7 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
right.bind("<Configure>", lambda _e: _adaptive_matrix_columns(self))
_refresh_metric_table(self)
_refresh_calman_config_summary(self)
_update_target_strip(self)
_update_actual_strip(self)
_redraw_calman_charts(self)
@@ -592,6 +692,7 @@ def create_calman_panel(self: "PQAutomationApp") -> None:
def toggle_calman_panel(self: "PQAutomationApp") -> None:
"""切换 CALMAN 灰阶面板显示。"""
self.show_panel("calman")
_refresh_calman_config_summary(self)
# ---------------------------------------------------------------------------
@@ -604,23 +705,43 @@ def send_patch(self: "PQAutomationApp", pct: int) -> None:
if not self.signal_service.is_connected:
messagebox.showwarning("提示", "请先连接 UCD323 设备")
return
if getattr(self, "calman_patch_send_busy", False):
_calman_log(self, f"send busy, ignore click pct={pct}", "warning")
self.calman_status_var.set("发送进行中,请稍候...")
return
rgb_val = int(round(pct * 255 / 100))
self.calman_current_level = pct
self.calman_status_var.set(f"发送 {pct}%RGB={rgb_val}...")
_highlight_patch(self, pct)
_refresh_calman_config_summary(self)
_calman_log(self, f"click patch pct={pct}, rgb=({rgb_val}, {rgb_val}, {rgb_val})")
self.calman_patch_send_busy = True
def worker():
try:
_calman_log(self, f"send_solid_rgb start pct={pct}")
test_type = getattr(self.config, "current_test_type", "screen_module")
if hasattr(self, "pattern_service") and self.pattern_service is not None:
self.pattern_service.send_rgb(
(rgb_val, rgb_val, rgb_val),
test_type=test_type,
)
else:
self.signal_service.send_solid_rgb((rgb_val, rgb_val, rgb_val))
_calman_log(self, f"send_solid_rgb success pct={pct}")
_calman_log(self, f"ucd profile applied test_type={test_type}")
self._dispatch_ui(
self.log_gui.log, f"CALMAN: 已发送 {pct}% 灰阶 (RGB={rgb_val})",
"info",
)
self._dispatch_ui(self.calman_status_var.set, f"{pct}% 已发送")
except Exception as exc:
_calman_log(self, f"send_solid_rgb failed pct={pct}: {exc}", "error")
self._dispatch_ui(self.log_gui.log, f"发送失败: {exc}", "error")
self._dispatch_ui(self.calman_status_var.set, f"发送失败: {exc}")
finally:
self.calman_patch_send_busy = False
threading.Thread(target=worker, daemon=True).start()
@@ -628,7 +749,7 @@ def send_patch(self: "PQAutomationApp", pct: int) -> None:
def _measure_once(self: "PQAutomationApp", pct: int) -> dict | None:
"""采集一次 CA410并组装一条记录含 CCT/Gamma/ΔE2000"""
try:
x, y, lv, X, Y, Z = self.ca.readAllDisplay()
x, y, lv, X, Y, Z = self.read_ca_xyLv()
except Exception as exc:
self._dispatch_ui(self.log_gui.log, f"CA410 采集异常: {exc}", "error")
return None
@@ -693,14 +814,32 @@ def measure_current_patch(self: "PQAutomationApp") -> None:
def worker():
t0 = time.perf_counter()
_calman_log(self, f"measure start pct={pct}")
self._dispatch_ui(self.calman_status_var.set, f"采集 {pct}% 中...")
rec = _measure_once(self, pct)
if rec is None:
_calman_log(self, f"measure failed pct={pct}", "error")
self._dispatch_ui(self.calman_status_var.set, "采集失败")
return
step_s = time.perf_counter() - t0
self.calman_last_step_seconds = step_s
self.calman_results[pct] = rec
_calman_log(
self,
(
"measure success pct={pct}, x={x:.4f}, y={y:.4f}, Y={Y:.3f}, "
"cct={cct:.0f}, gamma={gamma:.3f}, de2000={de:.3f}, step={step:.2f}s"
).format(
pct=pct,
x=rec["x"],
y=rec["y"],
Y=rec["Y"],
cct=rec["cct"] if rec["cct"] == rec["cct"] else float("nan"),
gamma=rec["gamma"] if rec["gamma"] == rec["gamma"] else float("nan"),
de=rec["de2000"] if rec["de2000"] == rec["de2000"] else float("nan"),
step=step_s,
),
)
self._dispatch_ui(_apply_record_to_ui, self, rec)
self._dispatch_ui(
self.calman_status_var.set, f"{pct}% 采集完成 ({step_s:.2f}s)"
@@ -726,15 +865,28 @@ def start_sequence_test(self: "PQAutomationApp") -> None:
settle = float(getattr(self, "pattern_settle_time", 0.4))
self.calman_progress["value"] = 0
self.calman_progress_var.set("0 / 0")
_refresh_calman_config_summary(self)
_calman_log(self, f"sequence start levels={len(self.calman_levels)}, settle={settle:.2f}s")
def worker():
seq_t0 = time.perf_counter()
try:
test_type = getattr(self.config, "current_test_type", "screen_module")
rgb_session = None
if hasattr(self, "pattern_service") and self.pattern_service is not None:
rgb_session = self.pattern_service.prepare_session(
"rgb",
test_type=test_type,
log_details=False,
)
_calman_log(self, f"sequence ucd profile applied test_type={test_type}")
order = sorted(self.calman_levels, reverse=True)
total = len(order)
self._dispatch_ui(self.calman_progress_var.set, f"0 / {total}")
for i, pct in enumerate(order, 1):
if self.calman_stop_event.is_set():
_calman_log(self, f"sequence stop requested at step={i-1}/{total}", "warning")
self._dispatch_ui(self.log_gui.log, "已停止连续测试", "warning")
break
step_t0 = time.perf_counter()
@@ -742,12 +894,20 @@ def start_sequence_test(self: "PQAutomationApp") -> None:
self._dispatch_ui(
self.calman_status_var.set, f"[{i}/{total}] 发送 {pct}%"
)
_calman_log(self, f"sequence send step={i}/{total}, pct={pct}, rgb={rgb_val}")
self._dispatch_ui(_highlight_patch, self, pct)
try:
if rgb_session is not None:
self.pattern_service.send_rgb(
(rgb_val, rgb_val, rgb_val),
session=rgb_session,
)
else:
self.signal_service.send_solid_rgb(
(rgb_val, rgb_val, rgb_val)
)
except Exception as exc:
_calman_log(self, f"sequence send failed step={i}/{total}, pct={pct}: {exc}", "error")
self._dispatch_ui(
self.log_gui.log, f"发送 {pct}% 失败: {exc}", "error"
)
@@ -755,11 +915,30 @@ def start_sequence_test(self: "PQAutomationApp") -> None:
self.calman_current_level = pct
# 等待稳定,停止事件触发时尽快退出
if self.calman_stop_event.wait(settle):
_calman_log(self, f"sequence interrupted during settle step={i}/{total}, pct={pct}", "warning")
break
rec = _measure_once(self, pct)
if rec is None:
_calman_log(self, f"sequence measure failed step={i}/{total}, pct={pct}", "error")
continue
self.calman_results[pct] = rec
_calman_log(
self,
(
"sequence measure step={i}/{total}, pct={pct}, x={x:.4f}, y={y:.4f}, Y={Y:.3f}, "
"cct={cct:.0f}, gamma={gamma:.3f}, de2000={de:.3f}"
).format(
i=i,
total=total,
pct=pct,
x=rec["x"],
y=rec["y"],
Y=rec["Y"],
cct=rec["cct"] if rec["cct"] == rec["cct"] else float("nan"),
gamma=rec["gamma"] if rec["gamma"] == rec["gamma"] else float("nan"),
de=rec["de2000"] if rec["de2000"] == rec["de2000"] else float("nan"),
),
)
self._dispatch_ui(_apply_record_to_ui, self, rec)
step_s = time.perf_counter() - step_t0
total_s = time.perf_counter() - seq_t0
@@ -772,9 +951,11 @@ def start_sequence_test(self: "PQAutomationApp") -> None:
total_s,
)
else:
_calman_log(self, f"sequence complete total={total}")
self._dispatch_ui(self.calman_status_var.set, "连续测试完成")
self._dispatch_ui(self.log_gui.log, "CALMAN: 连续测试完成", "success")
return
_calman_log(self, "sequence stopped", "warning")
self._dispatch_ui(self.calman_status_var.set, "已停止")
finally:
self.calman_running = False
@@ -785,14 +966,17 @@ def start_sequence_test(self: "PQAutomationApp") -> None:
def stop_sequence_test(self: "PQAutomationApp") -> None:
"""请求停止连续测试。"""
if self.calman_running:
_calman_log(self, "stop requested", "warning")
self.calman_stop_event.set()
self.calman_status_var.set("正在停止...")
else:
_calman_log(self, "stop requested but no sequence is running", "warning")
self.calman_status_var.set("当前没有运行中的连续测试")
def clear_results(self: "PQAutomationApp") -> None:
"""清空结果表和图表。"""
_calman_log(self, "clear results")
self.calman_results.clear()
self.calman_last_record = None
self.calman_reading_var.set(
@@ -817,20 +1001,21 @@ def clear_results(self: "PQAutomationApp") -> None:
def _highlight_patch(self: "PQAutomationApp", pct: int) -> None:
"""高亮当前选中色块。"""
palette = _get_calman_palette()
try:
idx = self.calman_levels.index(pct)
except ValueError:
return
for i, cell in enumerate(self.calman_patch_cells):
if i == idx:
cell.configure(highlightbackground="#1f6fb2", highlightthickness=2)
cell.configure(highlightbackground=palette["patch_focus"], highlightthickness=2)
else:
cell.configure(highlightbackground="#9c9c9c", highlightthickness=1)
cell.configure(highlightbackground=palette["patch_border"], highlightthickness=1)
for i, cell in enumerate(self.calman_actual_cells):
if i == idx:
cell.configure(highlightbackground="#1f6fb2", highlightthickness=2)
cell.configure(highlightbackground=palette["patch_focus"], highlightthickness=2)
else:
cell.configure(highlightbackground="#808080", highlightthickness=1)
cell.configure(highlightbackground=palette["patch_border_alt"], highlightthickness=1)
total_cols = len(self.calman_levels) + 1 # 含 metric 列
col_index = idx + 1
@@ -925,10 +1110,18 @@ def _redraw_calman_charts(self: "PQAutomationApp") -> None:
pcts = [r["pct"] for r in recs]
de_vals = [r["de2000"] if r["de2000"] == r["de2000"] else 0 for r in recs]
lum_vals = [r["Y"] if r["Y"] == r["Y"] else 0 for r in recs]
rgb_r = [r["rgb_r"] for r in recs if r["rgb_r"] == r["rgb_r"]]
rgb_g = [r["rgb_g"] for r in recs if r["rgb_g"] == r["rgb_g"]]
rgb_b = [r["rgb_b"] for r in recs if r["rgb_b"] == r["rgb_b"]]
rgb_pcts = [r["pct"] for r in recs if r["rgb_r"] == r["rgb_r"]]
rgb_recs = [
r for r in recs
if (
r.get("rgb_r") == r.get("rgb_r")
and r.get("rgb_g") == r.get("rgb_g")
and r.get("rgb_b") == r.get("rgb_b")
)
]
rgb_pcts = [r["pct"] for r in rgb_recs]
rgb_r = [r["rgb_r"] for r in rgb_recs]
rgb_g = [r["rgb_g"] for r in rgb_recs]
rgb_b = [r["rgb_b"] for r in rgb_recs]
gamma_pcts = [r["pct"] for r in recs if r["gamma"] == r["gamma"]]
gamma_vals = [r["gamma"] for r in recs if r["gamma"] == r["gamma"]]
cct_vals = [r["cct"] for r in recs if r["cct"] == r["cct"]]
@@ -966,6 +1159,16 @@ def _redraw_calman_charts(self: "PQAutomationApp") -> None:
a1.set_ylim(bottom=0)
a1.set_xlabel("", fontsize=8)
rgb_ylim_low = 95.0
rgb_ylim_high = 105.0
if rgb_recs:
rgb_values = rgb_r + rgb_g + rgb_b
rgb_min = min(rgb_values + [100.0])
rgb_max = max(rgb_values + [100.0])
pad = max(0.8, (rgb_max - rgb_min) * 0.15)
rgb_ylim_low = min(95.0, rgb_min - pad)
rgb_ylim_high = max(105.0, rgb_max + pad)
# RGB Balance 线图
a2 = self.calman_ax_rgb_line
a2.clear()
@@ -976,36 +1179,38 @@ def _redraw_calman_charts(self: "PQAutomationApp") -> None:
a2.plot(rgb_pcts, rgb_b, "-", color="#4a73ff", linewidth=1.2)
a2.axhline(100, color="#9e9e9e", lw=0.8, ls="--")
a2.set_xlim(-2, 102)
a2.set_ylim(95, 105)
a2.set_ylim(rgb_ylim_low, rgb_ylim_high)
a2.set_xlabel("", fontsize=8)
# RGB Balance 条图(用最后一个点)
a3 = self.calman_ax_rgb_bar
a3.clear()
_style_axes(self, a3, "RGB Balance")
if recs:
last = recs[-1]
if rgb_recs:
last = rgb_recs[-1]
bars = [
last["rgb_r"] if last["rgb_r"] == last["rgb_r"] else 100,
last["rgb_g"] if last["rgb_g"] == last["rgb_g"] else 100,
last["rgb_b"] if last["rgb_b"] == last["rgb_b"] else 100,
last["rgb_r"],
last["rgb_g"],
last["rgb_b"],
]
a3.bar([0, 1, 2], bars, color=["#ff4d4d", "#4caf50", "#4a73ff"], width=0.7)
a3.set_xticks([0, 1, 2], ["R", "G", "B"])
else:
a3.set_xticks([0, 1, 2], ["R", "G", "B"])
a3.set_ylim(95, 105)
a3.set_ylim(rgb_ylim_low, rgb_ylim_high)
a3.set_xlabel("", fontsize=8)
# Gamma
a4 = self.calman_ax_gamma
a4.clear()
_style_axes(self, a4, "Gamma Log/Log")
target_pcts = list(self.calman_levels)
target_vals = [_target_gamma_loglog_curve(p) for p in target_pcts]
a4.plot(target_pcts, target_vals, "-", color="#f4ff00", linewidth=1.8)
if gamma_pcts:
a4.plot(gamma_pcts, gamma_vals, "-", color="#ffe24d", linewidth=1.3)
a4.axhline(TARGET_GAMMA, color="#9e9e9e", lw=0.8, ls="--")
a4.plot(gamma_pcts, gamma_vals, "-", color="#8f8f8f", linewidth=2.0)
a4.set_xlim(-2, 102)
a4.set_ylim(1.6, 2.8)
a4.set_ylim(1.8, 2.8)
a4.set_xlabel("", fontsize=8)
self.calman_canvas.draw_idle()
@@ -1075,14 +1280,20 @@ def _refresh_metric_table(self: "PQAutomationApp") -> None:
"""重绘下方矩阵表。"""
_apply_calman_tree_style(self)
palette = _get_calman_palette()
ref_white_y = self.calman_results.get(100, {}).get("Y")
def _target_y_abs(pctx):
if pctx is None:
return "-"
if ref_white_y is None or ref_white_y != ref_white_y or ref_white_y <= 0:
return "-"
return _safe_float(ref_white_y * ((pctx / 100.0) ** TARGET_GAMMA), "{:.3f}")
metrics = [
("x CIE31", lambda r: _safe_float(r.get("x")) if r else "-"),
("y CIE31", lambda r: _safe_float(r.get("y")) if r else "-"),
("Y", lambda r: _safe_float(r.get("Y"), "{:.3f}") if r else "-"),
(
"Target Y",
lambda _r, pctx=None: _safe_float((pctx / 100.0) ** TARGET_GAMMA, "{:.4f}") if pctx and pctx > 0 else ("0.0000" if pctx == 0 else "-"),
),
("Target Y", lambda _r, pctx=None: _target_y_abs(pctx)),
("Gamma Log/Log", lambda r: _safe_float(r.get("gamma"), "{:.3f}") if r else "-"),
("CCT", lambda r: _safe_float(r.get("cct"), "{:.0f}") if r else "-"),
("dE 2000", lambda r: _safe_float(r.get("de2000"), "{:.3f}") if r else "-"),
@@ -1135,6 +1346,8 @@ def refresh_calman_theme(self: "PQAutomationApp") -> None:
if hasattr(self, "calman_elapsed_label"):
self.calman_elapsed_label.configure(foreground=palette["status_fg"])
if hasattr(self, "calman_config_summary_label"):
self.calman_config_summary_label.configure(foreground=palette["status_fg"])
if hasattr(self, "calman_status_label"):
self.calman_status_label.configure(foreground=palette["status_fg"])
if hasattr(self, "calman_reading_summary_label"):
@@ -1151,6 +1364,7 @@ def refresh_calman_theme(self: "PQAutomationApp") -> None:
)
_refresh_metric_table(self)
_refresh_calman_config_summary(self)
_redraw_calman_charts(self)

View File

@@ -36,7 +36,7 @@ def create_cct_params_frame(self: "PQAutomationApp"):
# 从配置读取屏模组参数
saved_params = self.config.current_test_types.get("screen_module", {}).get(
"cct_params", screen_default_cct_params.copy()
"cct_params", {}
)
# 色域参考标准
@@ -45,15 +45,11 @@ def create_cct_params_frame(self: "PQAutomationApp"):
)
# 创建屏模组变量
self.cct_x_ideal_var = tk.StringVar(
value=str(saved_params.get("x_ideal", 0.3127))
)
self.cct_x_ideal_var = tk.StringVar(value="")
self.cct_x_tolerance_var = tk.StringVar(
value=str(saved_params.get("x_tolerance", 0.003))
)
self.cct_y_ideal_var = tk.StringVar(
value=str(saved_params.get("y_ideal", 0.3290))
)
self.cct_y_ideal_var = tk.StringVar(value="")
self.cct_y_tolerance_var = tk.StringVar(
value=str(saved_params.get("y_tolerance", 0.003))
)
@@ -74,6 +70,10 @@ def create_cct_params_frame(self: "PQAutomationApp"):
entry = ttk.Entry(self.cct_params_frame, textvariable=var, width=15)
entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3)
# 屏模组中心由实测 100% 点自动决定,避免手动误改。
if key in ("x_ideal", "y_ideal"):
entry.configure(state="readonly")
else:
# 绑定失去焦点事件
default_val = screen_default_cct_params[key]
entry.bind(
@@ -665,15 +665,29 @@ def reload_cct_params(self: "PQAutomationApp"):
saved_params = self.config.current_test_types.get(current_type, {}).get(
"cct_params", None
)
default_params = self.config.get_default_cct_params(current_type)
if saved_params is None:
saved_params = self.config.get_default_cct_params(current_type)
saved_params = {}
# 更新输入框的值
self.cct_x_ideal_var.set(str(saved_params["x_ideal"]))
self.cct_x_tolerance_var.set(str(saved_params["x_tolerance"]))
self.cct_y_ideal_var.set(str(saved_params["y_ideal"]))
self.cct_y_tolerance_var.set(str(saved_params["y_tolerance"]))
if current_type == "screen_module":
self.cct_x_ideal_var.set(
str(saved_params["x_ideal"]) if "x_ideal" in saved_params else ""
)
self.cct_y_ideal_var.set(
str(saved_params["y_ideal"]) if "y_ideal" in saved_params else ""
)
else:
self.cct_x_ideal_var.set(str(saved_params.get("x_ideal", default_params["x_ideal"])) )
self.cct_y_ideal_var.set(str(saved_params.get("y_ideal", default_params["y_ideal"])) )
self.cct_x_tolerance_var.set(
str(saved_params.get("x_tolerance", default_params["x_tolerance"]))
)
self.cct_y_tolerance_var.set(
str(saved_params.get("y_tolerance", default_params["y_tolerance"]))
)
except Exception as e:
if hasattr(self, "log_gui"):

View File

@@ -10,6 +10,7 @@ import colour
import numpy as np
from app.data_range_converter import convert_pattern_params
from app.views.modern_styles import get_theme_palette
from typing import TYPE_CHECKING
@@ -28,37 +29,12 @@ def create_custom_template_result_panel(self: "PQAutomationApp"):
table_container = tk.Frame(
self.custom_result_frame,
bg="#000000",
highlightthickness=1,
highlightbackground="#5a5a5a",
)
table_container.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.custom_result_table_container = table_container
style = ttk.Style()
style.configure(
"CustomResult.Treeview",
background="#000000",
fieldbackground="#000000",
foreground="#ffffff",
rowheight=28,
borderwidth=0,
)
style.configure(
"CustomResult.Treeview.Heading",
background="#2f2f2f",
foreground="#f5f5f5",
font=("Microsoft YaHei", 10, "bold"),
relief="flat",
)
style.map(
"CustomResult.Treeview",
background=[("selected", "#1f4e79")],
foreground=[("selected", "#ffffff")],
)
style.map(
"CustomResult.Treeview.Heading",
background=[("active", "#3b3b3b")],
)
_apply_custom_result_theme(self)
columns = (
"Pattern",
@@ -157,6 +133,70 @@ def create_custom_template_result_panel(self: "PQAutomationApp"):
table_container.grid_columnconfigure(0, weight=1)
def _apply_custom_result_theme(self: "PQAutomationApp"):
palette = get_theme_palette()
container = getattr(self, "custom_result_table_container", None)
if container is not None:
container.configure(
bg=palette["input_bg"],
highlightbackground=palette["border"],
highlightcolor=palette["border"],
)
style = ttk.Style()
style.configure(
"CustomResult.Treeview",
background=palette["input_bg"],
fieldbackground=palette["input_bg"],
foreground=palette["input_fg"],
rowheight=28,
borderwidth=0,
)
style.configure(
"CustomResult.Treeview.Heading",
background=palette["surface_alt_bg"],
foreground=palette["muted_fg"],
font=("Microsoft YaHei", 10, "bold"),
relief="flat",
)
style.map(
"CustomResult.Treeview",
background=[("selected", palette["select_bg"])],
foreground=[("selected", palette["select_fg"])],
)
style.map(
"CustomResult.Treeview.Heading",
background=[("active", palette["surface_hover_bg"])],
)
def refresh_custom_template_theme(self: "PQAutomationApp"):
"""刷新客户模板结果表的主题色。"""
_apply_custom_result_theme(self)
def _set_custom_template_tab_visible(self: "PQAutomationApp", visible: bool):
"""控制客户模板结果 TAB 的显示与隐藏。"""
if not hasattr(self, "chart_notebook") or not hasattr(self, "custom_template_tab_frame"):
return
tab_id = str(self.custom_template_tab_frame)
current_tabs = list(self.chart_notebook.tabs())
if visible:
if tab_id not in current_tabs:
self.chart_notebook.add(self.custom_template_tab_frame, text="客户模板结果显示")
self.chart_notebook.select(self.custom_template_tab_frame)
return
if tab_id in current_tabs:
current_selected = self.chart_notebook.select()
self.chart_notebook.forget(self.custom_template_tab_frame)
remaining_tabs = list(self.chart_notebook.tabs())
if current_selected == tab_id and remaining_tabs:
self.chart_notebook.select(remaining_tabs[0])
def show_custom_result_context_menu(self: "PQAutomationApp", event):
"""显示客户模板结果右键菜单"""
if not hasattr(self, "custom_result_tree") or not hasattr(
@@ -178,7 +218,7 @@ def show_custom_result_context_menu(self: "PQAutomationApp", event):
can_single_step = (
has_selection
and self.ca is not None
and self.ucd is not None
and self.signal_service.is_connected
and not self.testing
)
try:
@@ -219,7 +259,7 @@ def start_custom_row_single_step(self: "PQAutomationApp"):
if not hasattr(self, "custom_result_tree"):
return
if self.ca is None or self.ucd is None:
if self.ca is None or not self.signal_service.is_connected:
messagebox.showerror("错误", "请先连接CA410和信号发生器")
return
@@ -322,11 +362,9 @@ def _run_custom_row_single_step(self: "PQAutomationApp", item_id, row_no):
time.sleep(self.pattern_settle_time)
# 测量显示模式1读取 Tcp/duv/Lv显示模式8读取 λd/Pe/Lv 与 XYZ。
self.ca.set_Display(1)
tcp, duv, lv, _, _, _ = self.ca.readAllDisplay()
tcp, duv, lv, _, _, _ = self.read_ca_tcp_duv()
self.ca.set_Display(8)
lambda_d, pe, lv, X, Y, Z = self.ca.readAllDisplay()
lambda_d, pe, lv, X, Y, Z = self.read_ca_lambda_pe()
xy = colour.XYZ_to_xy(np.array([X, Y, Z]))
u_prime, v_prime, _ = colour.XYZ_to_CIE1976UCS(np.array([X, Y, Z]))
@@ -532,10 +570,7 @@ def append_custom_template_result(self: "PQAutomationApp", row_no, result_data):
def start_custom_template_test(self: "PQAutomationApp"):
"""开始客户模板测试SDR"""
if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
self.chart_notebook.select(self.custom_template_tab_frame)
if self.ca is None or self.ucd is None:
if self.ca is None or not self.signal_service.is_connected:
messagebox.showerror("错误", "请先连接CA410和信号发生器")
return
@@ -569,8 +604,10 @@ def start_custom_template_test(self: "PQAutomationApp"):
self.custom_btn.config(state=tk.NORMAL)
self.status_var.set("测试已取消")
self.set_custom_result_table_locked(False)
_set_custom_template_tab_visible(self, False)
return
_set_custom_template_tab_visible(self, True)
self.set_custom_result_table_locked(True)
self.test_thread = threading.Thread(target=self.run_custom_sdr_test, args=([],))
@@ -923,6 +960,7 @@ class CustomTemplatePanelMixin:
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
create_custom_template_result_panel = create_custom_template_result_panel
_set_custom_template_tab_visible = _set_custom_template_tab_visible
show_custom_result_context_menu = show_custom_result_context_menu
set_custom_result_table_locked = set_custom_result_table_locked
start_custom_row_single_step = start_custom_row_single_step
@@ -937,3 +975,4 @@ class CustomTemplatePanelMixin:
update_custom_button_visibility = update_custom_button_visibility
export_custom_template_excel = export_custom_template_excel
export_custom_template_charts = export_custom_template_charts
refresh_custom_template_theme = refresh_custom_template_theme

View File

@@ -171,7 +171,7 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
ttk.Label(
title_row,
text="Gamma / CCT / 对比度 / EOTF 共用此列表)",
foreground="#888",
style="Muted.TLabel",
).pack(side=tk.LEFT, padx=(8, 0))
# ===== 预设管理行 =====
@@ -194,12 +194,6 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
"<<ComboboxSelected>>", lambda e: _on_preset_selected(self)
)
ttk.Button(
preset_row1, text="加载",
bootstyle="info-outline", width=8,
command=lambda: _load_selected_preset(self),
).pack(side=tk.LEFT, padx=2)
ttk.Button(
preset_row1, text="应用为当前",
bootstyle="success", width=12,
@@ -207,7 +201,7 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
).pack(side=tk.LEFT, padx=2)
self._gamma_active_label = ttk.Label(
preset_row1, text="", foreground="#0a8", font=("微软雅黑", 9, "bold")
preset_row1, text="", style="SuccessState.TLabel", font=("微软雅黑", 9, "bold")
)
self._gamma_active_label.pack(side=tk.LEFT, padx=(10, 0))
@@ -230,7 +224,7 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
# 描述行
self._gamma_meta_label = ttk.Label(
preset_box, text="", foreground="#666", font=("微软雅黑", 9)
preset_box, text="", style="Muted.TLabel", font=("微软雅黑", 9)
)
self._gamma_meta_label.pack(anchor=tk.W, pady=(6, 0))
@@ -274,7 +268,7 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
right = ttk.Frame(mid)
right.grid(row=0, column=1, sticky=tk.NS)
edit_frame = ttk.LabelFrame(right, text="编辑选中点", padding=8)
edit_frame = ttk.LabelFrame(right, text="编辑选中点", padding=8)
edit_frame.pack(fill=tk.X)
self._gamma_edit_r_var = tk.StringVar()
@@ -350,7 +344,7 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
paste_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Label(
paste_frame, text="每行R,G,B 或 R G B\n或:灰度% (如 50%)",
foreground="#888", justify=tk.LEFT,
style="Muted.TLabel", justify=tk.LEFT,
).pack(anchor=tk.W)
ttk.Button(
paste_frame, text="从剪贴板导入",
@@ -358,27 +352,22 @@ def create_gamma_pattern_panel(self: "PQAutomationApp"):
command=lambda: _paste_from_clipboard(self),
).pack(fill=tk.X, pady=(6, 0))
# ===== 底部 =====
bottom = ttk.LabelFrame(root, text="校验与保存", padding=8)
bottom.pack(fill=tk.X, pady=(10, 0))
# ---- 右侧:校验与保存(与编辑区放在一起) ----
save_box = ttk.LabelFrame(right, text="校验与保存", padding=8)
save_box.pack(fill=tk.X, pady=(10, 0))
self._gamma_validate_label = ttk.Label(
bottom, text="", foreground="#666", justify=tk.LEFT
save_box, text="", style="Muted.TLabel", justify=tk.LEFT
)
self._gamma_validate_label.pack(anchor=tk.W)
save_row = ttk.Frame(bottom)
save_row = ttk.Frame(save_box)
save_row.pack(fill=tk.X, pady=(6, 0))
ttk.Button(
save_row, text="保存改动到当前预设",
bootstyle="primary",
command=lambda: _save_to_current_preset(self),
).pack(side=tk.LEFT)
ttk.Button(
save_row, text="应用到运行时 (gray.json)",
bootstyle="success",
command=lambda: _apply_current_to_runtime(self),
).pack(side=tk.LEFT, padx=(6, 0))
ttk.Button(
save_row, text="另存为新预设...",
bootstyle="info-outline",
@@ -435,16 +424,16 @@ def _update_active_label(self: "PQAutomationApp"):
current = self._gamma_current_preset
if active and current == active and not self._gamma_dirty:
self._gamma_active_label.config(
text=f"✔ 当前激活:{active}", foreground="#0a8"
text=f"✔ 当前激活:{active}", style="SuccessState.TLabel"
)
elif active:
extra = "(有未保存改动)" if self._gamma_dirty else ""
self._gamma_active_label.config(
text=f"● 激活:{active} 编辑中:{current or '-'}{extra}",
foreground="#a60" if self._gamma_dirty else "#888",
style="WarningState.TLabel" if self._gamma_dirty else "Muted.TLabel",
)
else:
self._gamma_active_label.config(text="● 未激活任何预设", foreground="#888")
self._gamma_active_label.config(text="● 未激活任何预设", style="Muted.TLabel")
def _on_preset_selected(self: "PQAutomationApp"):
@@ -1023,11 +1012,11 @@ def _run_validation(self: "PQAutomationApp"):
if not msgs:
text = f"✔ 校验通过(共 {len(params)} 点)"
color = "#0a8"
style_name = "SuccessState.TLabel"
else:
text = f"{len(params)} 点 | " + " ".join(msgs)
color = "#a60" if any(m.startswith("") for m in msgs) else "#666"
self._gamma_validate_label.config(text=text, foreground=color)
style_name = "WarningState.TLabel" if any(m.startswith("") for m in msgs) else "Muted.TLabel"
self._gamma_validate_label.config(text=text, style=style_name)
# ============================================================

View File

@@ -4,7 +4,7 @@ import re
import tkinter as tk
import ttkbootstrap as ttk
from drivers.UCD323_Enum import UCDEnum
from app.ucd import UCDEnum
from app.views.collapsing_frame import CollapsingFrame
from app.resources import load_icon
@@ -292,65 +292,9 @@ def create_signal_format_content(self: "PQAutomationApp"):
self.sdr_signal_frame.grid_columnconfigure(1, weight=1)
self.signal_tabs.add(self.sdr_signal_frame, text="SDR测试")
# 色彩空间
ttk.Label(self.sdr_signal_frame, text="色彩空间:").grid(
row=0, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_color_space_var = tk.StringVar(value="BT.709")
sdr_color_space_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_color_space_var,
values=["sRGB", "BT.709", "BT.601", "BT.2020", "DCI-P3"],
width=10,
state="readonly",
)
sdr_color_space_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2)
# Gamma测试参考值用于Gamma曲线绘制和色准计算
ttk.Label(self.sdr_signal_frame, text="Gamma:").grid(
row=1, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_gamma_type_var = tk.StringVar(value=UCDEnum.SignalFormat.GammaType.GAMMA_22)
sdr_gamma_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_gamma_type_var,
values=UCDEnum.SignalFormat.GammaType.get_list(),
width=10,
state="readonly",
)
sdr_gamma_combo.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2)
# 数据范围
ttk.Label(self.sdr_signal_frame, text="数据范围:").grid(
row=2, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_data_range_var = tk.StringVar(value=UCDEnum.SignalFormat.DataRange.FULL)
sdr_range_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_data_range_var,
values=UCDEnum.SignalFormat.DataRange.get_list(),
width=10,
state="readonly",
)
sdr_range_combo.grid(row=2, column=1, sticky=tk.W, padx=5, pady=2)
# 编码位深
ttk.Label(self.sdr_signal_frame, text="编码位深:").grid(
row=3, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_bit_depth_var = tk.StringVar(value=UCDEnum.SignalFormat.BitDepth.BIT_8)
sdr_bit_depth_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_bit_depth_var,
values=UCDEnum.SignalFormat.BitDepth.get_list(),
width=10,
state="readonly",
)
sdr_bit_depth_combo.grid(row=3, column=1, sticky=tk.W, padx=5, pady=2)
# 分辨率
ttk.Label(self.sdr_signal_frame, text="分辨率:").grid(
row=4, column=0, sticky=tk.W, padx=5, pady=2
row=0, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_timing_var = tk.StringVar(
value=self.config.current_test_types.get("sdr_movie", {}).get(
@@ -365,7 +309,63 @@ def create_signal_format_content(self: "PQAutomationApp"):
state="readonly",
)
sdr_timing_combo.bind("<<ComboboxSelected>>", self.on_sdr_timing_changed)
sdr_timing_combo.grid(row=4, column=1, sticky=tk.W, padx=5, pady=2)
sdr_timing_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2)
# 色彩空间
ttk.Label(self.sdr_signal_frame, text="色彩空间:").grid(
row=1, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_color_space_var = tk.StringVar(value="BT.709")
sdr_color_space_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_color_space_var,
values=["sRGB", "BT.709", "BT.601", "BT.2020", "DCI-P3"],
width=10,
state="readonly",
)
sdr_color_space_combo.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2)
# Gamma测试参考值用于Gamma曲线绘制和色准计算
ttk.Label(self.sdr_signal_frame, text="Gamma:").grid(
row=2, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_gamma_type_var = tk.StringVar(value=UCDEnum.SignalFormat.GammaType.GAMMA_22)
sdr_gamma_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_gamma_type_var,
values=UCDEnum.SignalFormat.GammaType.get_list(),
width=10,
state="readonly",
)
sdr_gamma_combo.grid(row=2, column=1, sticky=tk.W, padx=5, pady=2)
# 数据范围
ttk.Label(self.sdr_signal_frame, text="数据范围:").grid(
row=3, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_data_range_var = tk.StringVar(value=UCDEnum.SignalFormat.DataRange.FULL)
sdr_range_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_data_range_var,
values=UCDEnum.SignalFormat.DataRange.get_list(),
width=10,
state="readonly",
)
sdr_range_combo.grid(row=3, column=1, sticky=tk.W, padx=5, pady=2)
# 编码位深
ttk.Label(self.sdr_signal_frame, text="编码位深:").grid(
row=4, column=0, sticky=tk.W, padx=5, pady=2
)
self.sdr_bit_depth_var = tk.StringVar(value=UCDEnum.SignalFormat.BitDepth.BIT_8)
sdr_bit_depth_combo = ttk.Combobox(
self.sdr_signal_frame,
textvariable=self.sdr_bit_depth_var,
values=UCDEnum.SignalFormat.BitDepth.get_list(),
width=10,
state="readonly",
)
sdr_bit_depth_combo.grid(row=4, column=1, sticky=tk.W, padx=5, pady=2)
# 色彩格式
ttk.Label(self.sdr_signal_frame, text="色彩格式:").grid(
@@ -600,7 +600,6 @@ def create_connection_content(self: "PQAutomationApp"):
com_frame, width=15, height=15, bg="gray", highlightthickness=0
)
self.ucd_status_indicator.grid(row=0, column=2, padx=(10, 20))
self.ucd_status_indicator.config(bg="gray")
# 添加按钮框架
button_frame = ttk.Frame(com_frame)
@@ -608,13 +607,14 @@ def create_connection_content(self: "PQAutomationApp"):
button_frame.grid_columnconfigure(0, weight=1)
button_frame.grid_columnconfigure(1, weight=1)
button_frame.grid_columnconfigure(2, weight=1)
button_frame.grid_columnconfigure(3, weight=1)
# connect_icon = load_icon("assets/connect-svgrepo-com.png")
self.check_button = ttk.Button(
button_frame,
# image=connect_icon,
# bootstyle="link",
text="连接",
text="全部连接",
bootstyle="success",
takefocus=False,
command=self.check_com_connections,
@@ -622,6 +622,42 @@ def create_connection_content(self: "PQAutomationApp"):
# self.check_button.image = connect_icon
self.check_button.grid(row=0, column=0, padx=(0, 4), pady=3, sticky="ew")
self.ucd_connect_button = ttk.Button(
button_frame,
text="连接UCD",
bootstyle="success-outline",
takefocus=False,
command=self.check_ucd_connection,
)
self.ucd_connect_button.grid(row=0, column=1, padx=4, pady=3, sticky="ew")
self.ca_connect_button = ttk.Button(
button_frame,
text="连接CA410",
bootstyle="success-outline",
takefocus=False,
command=self.check_ca_connection,
)
self.ca_connect_button.grid(row=0, column=2, padx=4, pady=3, sticky="ew")
self.ucd_disconnect_button = ttk.Button(
button_frame,
text="断开UCD",
bootstyle="danger-outline",
takefocus=False,
command=self.disconnect_ucd_connection,
)
self.ucd_disconnect_button.grid(row=1, column=1, padx=4, pady=3, sticky="ew")
self.ca_disconnect_button = ttk.Button(
button_frame,
text="断开CA410",
bootstyle="danger-outline",
takefocus=False,
command=self.disconnect_ca_connection,
)
self.ca_disconnect_button.grid(row=1, column=2, padx=4, pady=3, sticky="ew")
# disconnect_icon = load_icon("assets/disconnect-svgrepo-com.png")
# 断开连接按钮
self.disconnect_button = ttk.Button(
@@ -634,7 +670,7 @@ def create_connection_content(self: "PQAutomationApp"):
command=self.disconnect_com_connections,
)
# self.disconnect_button.image = disconnect_icon # 防止图标被垃圾回收
self.disconnect_button.grid(row=0, column=1, padx=4, pady=3, sticky="ew")
self.disconnect_button.grid(row=1, column=0, padx=(0, 4), pady=3, sticky="ew")
# refresh_icon = load_icon("assets/refresh-svgrepo-com.png")
self.refresh_button = ttk.Button(
@@ -647,7 +683,7 @@ def create_connection_content(self: "PQAutomationApp"):
command=self.refresh_com_ports,
)
# self.refresh_button.image = refresh_icon # 防止图标被垃圾回收
self.refresh_button.grid(row=0, column=2, padx=(4, 0), pady=3, sticky="ew")
self.refresh_button.grid(row=1, column=3, padx=(4, 0), pady=3, sticky="ew")
# CA端口
ttk.Label(com_frame, text="CA端口:").grid(
@@ -669,7 +705,8 @@ def create_connection_content(self: "PQAutomationApp"):
com_frame, width=15, height=15, bg="gray", highlightthickness=0
)
self.ca_status_indicator.grid(row=1, column=2, padx=(10, 20))
self.ca_status_indicator.config(bg="gray")
self.refresh_connection_indicators()
# 添加CA通道设置
ttk.Label(com_frame, text="CA通道:").grid(
@@ -737,10 +774,9 @@ def create_test_type_frame(self: "PQAutomationApp"):
).pack(fill=tk.X, padx=16, pady=(16, 6), anchor="w")
panel_buttons = [
("log_btn", "测试日志", self.toggle_log_panel),
("ai_image_btn", "AI 图片", self.toggle_ai_image_panel),
("pantone_baseline_btn", "Pantone 摸底", self.toggle_pantone_baseline_panel),
("gamma_pattern_btn", "Gamma 图案", self.toggle_gamma_pattern_panel),
("gamma_pattern_btn", "Gamma Pattern编辑", self.toggle_gamma_pattern_panel),
("calman_btn", "CALMAN 灰阶", self.toggle_calman_panel),
]
for attr, text, cmd in panel_buttons:
@@ -757,16 +793,24 @@ def create_test_type_frame(self: "PQAutomationApp"):
# 测试版水印标签(版本 x.x.0.0 时显示)
from app_version import is_beta_version, APP_VERSION
if is_beta_version():
beta_lbl = tk.Label(
beta_lbl = ttk.Label(
self.sidebar_frame,
text=f"[测试版] v{APP_VERSION}",
foreground="#ffffff",
background="#cc3300",
font=("微软雅黑", 8, "bold"),
anchor="center",
style="SidebarBadge.TLabel",
)
beta_lbl.pack(fill=tk.X, side=tk.BOTTOM, padx=4, pady=(6, 4))
# ---------- 测试日志(底部固定) ----------
self.log_btn = ttk.Button(
self.sidebar_frame,
text="测试日志",
style="Sidebar.TButton",
command=self.toggle_log_panel,
takefocus=False,
)
self.log_btn.pack(fill=tk.X, padx=0, pady=(0, 2), side=tk.BOTTOM)
# ---------- 主题切换(底部固定) ----------
self.theme_toggle_btn = ttk.Button(
self.sidebar_frame,
@@ -780,8 +824,6 @@ def create_test_type_frame(self: "PQAutomationApp"):
# 注册面板按钮
if hasattr(self, "panels"):
if "log" in self.panels:
self.panels["log"]["button"] = self.log_btn
if "ai_image" in self.panels:
self.panels["ai_image"]["button"] = self.ai_image_btn
if "single_step" in self.panels:
@@ -807,9 +849,24 @@ def _refresh_theme_toggle_label(self: "PQAutomationApp") -> None:
def _on_toggle_theme(self: "PQAutomationApp") -> None:
"""切换主题:重新应用 ttk 样式并刷新所有自定义样式相关的标签。"""
# 在测试进行时禁止切换主题,避免影响测量稳定性
if getattr(self, "testing", False):
try:
if hasattr(self, "log_gui"):
self.log_gui.log("警告: 测试进行中,禁止切换主题", level="error")
except Exception:
pass
return
from app.views.theme_manager import toggle_theme
toggle_theme()
# apply_modern_styles()
_refresh_theme_toggle_label(self)
if hasattr(self, "refresh_connection_indicators"):
try:
self.refresh_connection_indicators()
except Exception:
pass
if hasattr(self, "apply_result_chart_theme"):
try:
self.apply_result_chart_theme()
@@ -820,6 +877,21 @@ def _on_toggle_theme(self: "PQAutomationApp") -> None:
self.log_gui.refresh_log_theme()
except Exception:
pass
if hasattr(self, "refresh_ai_image_theme"):
try:
self.refresh_ai_image_theme()
except Exception:
pass
if hasattr(self, "refresh_single_step_theme"):
try:
self.refresh_single_step_theme()
except Exception:
pass
if hasattr(self, "refresh_custom_template_theme"):
try:
self.refresh_custom_template_theme()
except Exception:
pass
if hasattr(self, "refresh_calman_theme"):
try:
self.refresh_calman_theme()
@@ -831,6 +903,18 @@ def _on_toggle_theme(self: "PQAutomationApp") -> None:
self.update_sidebar_selection()
except Exception:
pass
# 以新的 dark_mode 值重绘当前测试类型的所有图表
if hasattr(self, "_chart_snapshots") and hasattr(self, "config"):
test_type = getattr(self.config, "current_test_type", None)
if test_type:
snapshots = self._chart_snapshots.get(test_type, {})
for chart_name, args in snapshots.items():
plot_fn = getattr(self, f"plot_{chart_name}", None)
if plot_fn:
try:
plot_fn(*args)
except Exception:
pass
def update_config_info_display(self: "PQAutomationApp"):
@@ -944,10 +1028,10 @@ def on_screen_module_timing_changed(self: "PQAutomationApp", event=None):
# 根据分辨率给出提示
if width >= 3840: # 4K及以上
self.log_gui.log(" 检测到4K分辨率", level="info")
self.log_gui.log("检测到4K分辨率", level="info")
if refresh_rate >= 120:
self.log_gui.log(" 检测到高刷新率", level="info")
self.log_gui.log("检测到高刷新率", level="info")
# 更新屏模组配置(独立于 current_test_type
self.config.current_test_types.setdefault("screen_module", {})[
@@ -993,7 +1077,7 @@ def on_screen_module_signal_format_changed(self: "PQAutomationApp", event=None):
self.save_pq_config()
return
if getattr(self.ucd, "status", False):
if self.signal_service.is_connected:
ok = self.signal_service.update_signal_format(
color_space=color_space,
data_range=data_range,
@@ -1037,7 +1121,7 @@ def on_sdr_output_format_changed(self: "PQAutomationApp", event=None):
self.log_gui.log("警告: 测试进行中,格式更改将在下次测试时生效", level="error")
return
if getattr(self.ucd, "status", False):
if self.signal_service.is_connected:
ok = self.signal_service.update_signal_format(
color_space=self.sdr_color_space_var.get(),
data_range=self.sdr_data_range_var.get(),
@@ -1061,7 +1145,7 @@ def on_hdr_output_format_changed(self: "PQAutomationApp", event=None):
self.log_gui.log("警告: 测试进行中,格式更改将在下次测试时生效", level="error")
return
if getattr(self.ucd, "status", False):
if self.signal_service.is_connected:
ok = self.signal_service.update_signal_format(
color_space=self.hdr_color_space_var.get(),
data_range=self.hdr_data_range_var.get(),
@@ -1085,6 +1169,9 @@ def on_local_dimming_timing_changed(self: "PQAutomationApp", event=None):
self.config.current_test_types.setdefault("local_dimming", {})["timing"] = selected_timing
if hasattr(self, "invalidate_ld_ucd_params_cache"):
self.invalidate_ld_ucd_params_cache()
if self.testing:
self.log_gui.log("警告: 测试进行中,分辨率更改将在下次测试时生效", level="error")
@@ -1107,6 +1194,9 @@ def on_local_dimming_signal_format_changed(self: "PQAutomationApp", event=None):
ld_cfg["bpc"] = UCDEnum.SignalFormat.BitDepth.get_bit_value(bit_depth)
ld_cfg["data_range"] = data_range
if hasattr(self, "invalidate_ld_ucd_params_cache"):
self.invalidate_ld_ucd_params_cache()
self.log_gui.log(
(
"Local Dimming 信号格式已更新: "
@@ -1121,7 +1211,7 @@ def on_local_dimming_signal_format_changed(self: "PQAutomationApp", event=None):
self.save_pq_config()
return
if getattr(self.ucd, "status", False):
if self.signal_service.is_connected:
ok = self.signal_service.update_signal_format(
color_space=color_space,
data_range=data_range,

View File

@@ -63,7 +63,7 @@ def create_pantone_baseline_panel(self: "PQAutomationApp"):
ttk.Label(config_row, textvariable=self.pantone_progress_var).pack(
side=tk.RIGHT, padx=(8, 0)
)
ttk.Label(config_row, textvariable=self.pantone_status_var, foreground="#666").pack(
ttk.Label(config_row, textvariable=self.pantone_status_var, style="Muted.TLabel").pack(
side=tk.RIGHT
)
@@ -336,7 +336,7 @@ def _launch_worker(self: "PQAutomationApp", start_index, settle):
end_state = "paused"
break
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
if lv is None:
raise RuntimeError(f"{i + 1} 组 CA410 采集失败")

View File

@@ -42,13 +42,13 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
ttk.Label(
title_frame,
text="🔆 Local Dimming 窗口测试",
text="Local Dimming 窗口测试",
font=("微软雅黑", 14, "bold"),
).pack(side=tk.LEFT)
# ==================== 2. 窗口百分比按钮 ====================
window_frame = ttk.LabelFrame(
main_container, text="🔆 窗口百分比(点击发送)", padding=10
main_container, text="窗口百分比", padding=10
)
window_frame.pack(fill=tk.X, pady=(0, 10))
@@ -57,9 +57,53 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
window_frame,
text="点击按钮发送对应百分比的白色窗口(黑色背景 + 居中白色矩形)",
font=("", 9),
foreground="#28a745",
style="SuccessState.TLabel",
).pack(pady=(0, 8))
window_level_row = ttk.Frame(window_frame)
window_level_row.pack(fill=tk.X, pady=(0, 8))
ttk.Label(window_level_row, text="窗口(%):").pack(side=tk.LEFT)
self.ld_window_percentage_var = tk.StringVar(value="10")
ld_window_percentage_entry = ttk.Entry(
window_level_row,
textvariable=self.ld_window_percentage_var,
width=8,
)
ld_window_percentage_entry.pack(side=tk.LEFT, padx=(6, 10))
ttk.Label(window_level_row, text="窗口亮度(%):").pack(side=tk.LEFT)
self.ld_window_luminance_var = tk.StringVar(value="100")
ld_window_luminance_entry = ttk.Entry(
window_level_row,
textvariable=self.ld_window_luminance_var,
width=8,
)
ld_window_luminance_entry.pack(side=tk.LEFT, padx=(6, 10))
ttk.Button(
window_level_row,
text="生成窗口",
command=self.send_ld_manual_window,
bootstyle="success-outline",
width=12,
).pack(side=tk.LEFT)
ld_window_percentage_entry.bind(
"<Return>",
lambda _event: self.send_ld_manual_window(),
)
ld_window_luminance_entry.bind(
"<Return>",
lambda _event: self.send_ld_manual_window(),
)
ttk.Label(
window_level_row,
text="输入后可直接点生成或回车",
style="InfoState.TLabel",
).pack(side=tk.LEFT, padx=(8, 0))
# 第一行1%, 2%, 5%, 10%, 18%
row1 = ttk.Frame(window_frame)
row1.pack(fill=tk.X, pady=(0, 5))
@@ -89,16 +133,9 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
).pack(side=tk.LEFT, padx=3)
# ==================== 3. 其他手动图案 ====================
pattern_frame = ttk.LabelFrame(main_container, text="🧩 其他测试图案", padding=10)
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)
@@ -118,14 +155,6 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
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="全黑画面",
@@ -134,8 +163,92 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
width=12,
).pack(side=tk.LEFT, padx=3)
# ==================== 4. CA410 采集按钮 ====================
measure_frame = ttk.LabelFrame(main_container, text="📊 CA410 测量", padding=10)
# ==================== 4. 独立瞬时峰值连续测试 ====================
peak_frame = ttk.LabelFrame(main_container, text="瞬时峰值独立测试", padding=10)
peak_frame.pack(fill=tk.X, pady=(0, 10))
self.ld_peak_window_size_var = tk.StringVar(value="10")
self.ld_peak_window_luminance_var = tk.StringVar(value="100")
self.ld_peak_duration_var = tk.StringVar(value="20")
self.ld_peak_sample_interval_var = tk.StringVar(value="0.3")
self.ld_peak_record_curve_var = tk.BooleanVar(value=True)
self.ld_peak_no_limit_var = tk.BooleanVar(value=False)
self.ld_peak_drop_percent_var = tk.StringVar(value="3")
ttk.Label(peak_frame, text="窗口(%):").grid(row=1, column=0, sticky=tk.W, padx=(0, 4))
ttk.Entry(peak_frame, textvariable=self.ld_peak_window_size_var, width=8).grid(
row=1, column=1, sticky=tk.W, padx=(0, 10)
)
ttk.Label(peak_frame, text="窗口亮度(%):").grid(
row=1, column=2, sticky=tk.W, padx=(0, 4)
)
ttk.Entry(peak_frame, textvariable=self.ld_peak_window_luminance_var, width=8).grid(
row=1, column=3, sticky=tk.W, padx=(0, 10)
)
ttk.Label(peak_frame, text="连续时长(s):").grid(
row=1, column=4, sticky=tk.W, padx=(0, 4)
)
ttk.Entry(peak_frame, textvariable=self.ld_peak_duration_var, width=8).grid(
row=1, column=5, sticky=tk.W, padx=(0, 10)
)
ttk.Label(peak_frame, text="采样间隔(s):").grid(
row=1, column=6, sticky=tk.W, padx=(0, 4)
)
ttk.Entry(peak_frame, textvariable=self.ld_peak_sample_interval_var, width=8).grid(
row=1, column=7, sticky=tk.W
)
ttk.Checkbutton(
peak_frame,
text="记录曲线点到表格",
variable=self.ld_peak_record_curve_var,
bootstyle="round-toggle",
).grid(row=2, column=0, columnspan=3, sticky=tk.W, pady=(8, 0))
peak_btn_row = ttk.Frame(peak_frame)
peak_btn_row.grid(row=2, column=4, columnspan=4, sticky=tk.EW, pady=(8, 0))
self.ld_peak_start_btn = ttk.Button(
peak_btn_row,
text="开始峰值追踪",
command=self.start_ld_instant_peak_tracking,
bootstyle="warning",
width=14,
)
self.ld_peak_start_btn.pack(side=tk.LEFT, padx=(0, 5))
self.ld_peak_stop_btn = ttk.Button(
peak_btn_row,
text="停止",
command=self.stop_ld_instant_peak_tracking,
bootstyle="danger-outline",
width=10,
state="disabled",
)
self.ld_peak_stop_btn.pack(side=tk.LEFT)
ttk.Label(peak_frame, text="亮度回落(%):").grid(
row=2, column=0, sticky=tk.W, padx=(0, 4), pady=(6, 0)
)
ttk.Entry(
peak_frame,
textvariable=self.ld_peak_drop_percent_var,
width=8
).grid(row=2, column=1, sticky=tk.W, pady=(6, 0))
ttk.Checkbutton(
peak_frame,
text="不固定测试时间",
variable=self.ld_peak_no_limit_var,
bootstyle="round-toggle",
).grid(row=2, column=2, columnspan=3, sticky=tk.W, pady=(6, 0))
# ==================== 5. CA410 采集按钮 ====================
measure_frame = ttk.LabelFrame(main_container, text="CA410 测量", padding=10)
measure_frame.pack(fill=tk.X, pady=(0, 10))
measure_btn_frame = ttk.Frame(measure_frame)
@@ -143,7 +256,7 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
self.ld_measure_btn = ttk.Button(
measure_btn_frame,
text="📏 采集当前亮度",
text="采集当前亮度",
command=self.measure_ld_luminance,
bootstyle="primary",
width=15,
@@ -155,12 +268,12 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
measure_btn_frame,
text="亮度: -- cd/m² | x: -- | y: --",
font=("Consolas", 10),
foreground="#007bff",
style="InfoState.TLabel",
)
self.ld_result_label.pack(side=tk.LEFT, padx=(10, 0))
# ==================== 5. 测试结果表格 ====================
result_frame = ttk.LabelFrame(main_container, text="📋 测试记录", padding=10)
# ==================== 6. 测试结果表格 ====================
result_frame = ttk.LabelFrame(main_container, text="测试记录", padding=10)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# Treeview
@@ -191,7 +304,7 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.ld_tree.configure(yscrollcommand=scrollbar.set)
# ==================== 6. 底部操作按钮 ====================
# ==================== 7. 底部操作按钮 ====================
bottom_frame = ttk.Frame(main_container)
bottom_frame.pack(fill=tk.X)
@@ -206,13 +319,22 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
self.ld_save_btn = ttk.Button(
bottom_frame,
text="💾 保存结果",
text="保存结果",
command=self.save_local_dimming_results,
bootstyle="info",
width=12,
)
self.ld_save_btn.pack(side=tk.LEFT)
self.ld_plot_btn = ttk.Button(
bottom_frame,
text="生成峰值曲线",
command=self.plot_ld_instant_peak_curve,
bootstyle="warning-outline",
width=14,
)
self.ld_plot_btn.pack(side=tk.LEFT, padx=(5, 0))
# 默认隐藏
self.local_dimming_visible = False
@@ -228,6 +350,7 @@ def create_local_dimming_panel(self: "PQAutomationApp"):
self.current_ld_percentage = None
self.current_ld_test_item = None
self.current_ld_pattern_label = None
self.ld_peak_tracking = False
def toggle_local_dimming_panel(self: "PQAutomationApp"):

View File

@@ -13,6 +13,8 @@ from tkinter import filedialog, messagebox
import ttkbootstrap as ttk
from PIL import Image
from app.views.modern_styles import apply_listbox_theme
from typing import TYPE_CHECKING
if TYPE_CHECKING:
@@ -59,7 +61,7 @@ def create_single_step_panel(self: "PQAutomationApp"):
ttk.Label(
title_row,
text="录入目标色块,发送纯色色块并采集 xyY / ΔE2000。",
foreground="#666",
style="Muted.TLabel",
).pack(side=tk.LEFT, padx=(12, 0))
left = ttk.LabelFrame(root, text="样本列表", padding=8)
@@ -73,11 +75,8 @@ def create_single_step_panel(self: "PQAutomationApp"):
activestyle="none",
font=("微软雅黑", 9),
highlightthickness=1,
highlightbackground="#d8d8d8",
highlightcolor="#4a90e2",
selectbackground="#2b6cb0",
selectforeground="#ffffff",
)
apply_listbox_theme(self.single_step_listbox)
self.single_step_listbox.pack(fill=tk.BOTH, expand=True)
self.single_step_listbox.bind(
"<<ListboxSelect>>", lambda e: _on_sample_select(self)
@@ -154,7 +153,7 @@ def create_single_step_panel(self: "PQAutomationApp"):
ttk.Label(
form_frame,
textvariable=self.single_step_status_var,
foreground="#666",
style="Muted.TLabel",
).grid(row=2, column=2, columnspan=4, sticky=tk.W, pady=4)
action_row = ttk.Frame(form_frame)
@@ -444,7 +443,7 @@ def _measure_current_sample(self: "PQAutomationApp"):
def worker():
try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
x, y, lv, _X, _Y, _Z = self.read_ca_xyLv()
if lv is None:
raise RuntimeError("CA410 未返回有效亮度")
self._dispatch_ui(self.single_step_measured_x_var.set, f"{x:.4f}")
@@ -556,6 +555,12 @@ def _export_results_csv(self: "PQAutomationApp"):
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
def refresh_single_step_theme(self: "PQAutomationApp"):
"""刷新单步调试中 tk.Listbox 的主题色。"""
if hasattr(self, "single_step_listbox"):
apply_listbox_theme(self.single_step_listbox)
class SingleStepPanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
@@ -575,3 +580,4 @@ class SingleStepPanelMixin:
_commit_result = _commit_result
_clear_results = _clear_results
_export_results_csv = _export_results_csv
refresh_single_step_theme = refresh_single_step_theme

View File

@@ -612,13 +612,10 @@ class PQDebugPanel:
if test_type == "screen_module":
self.screen_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示屏模组调试面板", level="success")
elif test_type == "sdr_movie":
self.sdr_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示 SDR 调试面板", level="success")
elif test_type == "hdr_movie":
self.hdr_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示 HDR 调试面板", level="success")
# ==================== 启用/禁用控制 ====================
@@ -643,39 +640,31 @@ class PQDebugPanel:
if test_item == "gamma":
self.screen_gray_combo.config(state="readonly")
self.screen_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("屏模组 Gamma 单步调试已启用", level="success")
elif test_item == "rgb":
self.screen_rgb_combo.config(state="readonly")
self.screen_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("屏模组 RGB 单步调试已启用", level="success")
elif test_type == "sdr_movie":
if test_item == "gamma":
self.sdr_gray_combo.config(state="readonly")
self.sdr_gamma_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR Gamma 单步调试已启用", level="success")
elif test_item == "accuracy":
self.sdr_color_combo.config(state="readonly")
self.sdr_accuracy_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR 色准单步调试已启用", level="success")
elif test_item == "rgb":
self.sdr_rgb_combo.config(state="readonly")
self.sdr_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR RGB 单步调试已启用", level="success")
elif test_type == "hdr_movie":
if test_item == "eotf":
self.hdr_gray_combo.config(state="readonly")
self.hdr_eotf_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR EOTF 单步调试已启用", level="success")
elif test_item == "accuracy":
self.hdr_color_combo.config(state="readonly")
self.hdr_accuracy_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR 色准单步调试已启用", level="success")
elif test_item == "rgb":
self.hdr_rgb_combo.config(state="readonly")
self.hdr_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR RGB 单步调试已启用", level="success")
def disable_all_debug(self):
"""禁用所有单步调试(新测试开始时调用)"""
@@ -802,7 +791,7 @@ class PQDebugPanel:
time.sleep(1.5)
# 测量数据
x, y, lv, X, Y, Z = self.app.ca.readAllDisplay()
x, y, lv, X, Y, Z = self.app.read_ca_xyLv()
self.app.log_gui.log(
f"测量完成: x={x:.4f}, y={y:.4f}, lv={lv:.2f}, "

View File

@@ -19,20 +19,44 @@ from app.views.modern_styles import apply_modern_styles
_PREFS_PATH = Path("settings/ui_preferences.json")
# 浅色主题:沿用旧的 yeti首发布兼容
LIGHT_THEME = "yeti"
# 浅色主题:自定义轻量蓝灰色板,恢复旧版浅色观感
LIGHT_THEME = "calman_light"
# 深色主题:自定义 Calman 风格
DARK_THEME = "calman_dark"
_LEGACY_LIGHT_THEMES = {"yeti"}
_CALMAN_LIGHT_COLORS = {
"primary": "#1755a6",
"secondary": "#3572B4",
"success": "#2F9E44",
"info": "#247BA0",
"warning": "#C98700",
"danger": "#CC3300",
"light": "#F7FAFC",
"dark": "#1F2A36",
"bg": "#F5F8FB",
"fg": "#1F2933",
"selectbg": "#2B6CB0",
"selectfg": "#FFFFFF",
"border": "#C8D4E3",
"inputfg": "#243240",
"inputbg": "#FFFFFF",
"active": "#D9E6F2",
}
# ----------------------------------------------------------------------
# Calman 风格深色主题色板(参考实测截图取色)
# Calman 风格深色主题色板
# ----------------------------------------------------------------------
_CALMAN_DARK_COLORS = {
"primary": "#343A41", # 主色改为炭灰,避免大面积亮蓝
"secondary": "#444A51", # 中性深灰(用于 header / 分组背景)
# "primary": "#2A2F36",
# "secondary": "#444A51",
"primary": "#6FAFCC",
"secondary": "#AEAEAE",
"success": "#4FB960",
"info": "#6FAFCC", # 降低饱和度,只做少量点缀
"info": "#6FAFCC",
"warning": "#F2A93B",
"danger": "#E0524A",
"light": "#BFC6CE", # 高亮文本
@@ -51,14 +75,27 @@ _CALMAN_DARK_COLORS = {
def register_themes() -> None:
"""把自定义深色主题注册到 ttkbootstrap可重复调用幂等"""
style = Style()
if LIGHT_THEME not in style.theme_names():
light_def = ThemeDefinition(
name=LIGHT_THEME,
themetype="light",
colors=_CALMAN_LIGHT_COLORS,
)
style.register_theme(light_def)
if DARK_THEME in style.theme_names():
return
theme_def = ThemeDefinition(
dark_def = ThemeDefinition(
name=DARK_THEME,
themetype="dark",
colors=_CALMAN_DARK_COLORS,
)
style.register_theme(theme_def)
style.register_theme(dark_def)
def _normalize_theme_name(name: Optional[str]) -> str:
if not name or name in _LEGACY_LIGHT_THEMES:
return LIGHT_THEME
return name
# ----------------------------------------------------------------------
@@ -101,7 +138,7 @@ def apply_initial_theme() -> str:
返回最终生效的主题名。
"""
register_themes()
name = get_saved_theme() or LIGHT_THEME
name = _normalize_theme_name(get_saved_theme())
style = Style()
if name not in style.theme_names():
name = LIGHT_THEME
@@ -113,6 +150,7 @@ def apply_initial_theme() -> str:
def set_theme(name: str) -> str:
"""切换到指定主题,持久化偏好,并刷新自定义样式。"""
register_themes()
name = _normalize_theme_name(name)
style = Style()
if name not in style.theme_names():
name = LIGHT_THEME

View File

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

131
cache/pq_ai_api_v21_extracted.txt vendored Normal file
View File

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

17
cache/pq_ai_api_v21_summary.txt vendored Normal file
View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@@ -6,12 +6,17 @@ import time
import os
import datetime
import traceback
import matplotlib
import matplotlib.pyplot as plt
from app_version import APP_NAME, APP_VERSION, get_app_title
from drivers.UCD323_Function import UCDController
from drivers.ucd_driver import UCD323Device
from app.ucd_domain import EventBus
from app.services.ucd_service import SignalService
from app.ucd import (
ConnectionChanged,
DeviceKind,
EventBus,
PatternService,
SignalService,
UCD323Device,
)
from app.pq.pq_config import PQConfig
from app.pq.pq_result import PQResultStore
from app.export import (
@@ -55,14 +60,12 @@ from app.plots.plot_gamut import PlotGamutMixin
from app.views.chart_frame import ChartFrameMixin
from app.config_io import ConfigIOMixin
from app.tests.local_dimming import LocalDimmingMixin
from app.services import PatternService
from app.device.connection import DeviceConnectionMixin
from app.runner.test_runner import TestRunnerMixin
plt.rcParams["font.family"] = ["sans-serif"]
plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"]
class PQAutomationApp(
ConfigIOMixin,
ChartFrameMixin,
@@ -100,13 +103,10 @@ class PQAutomationApp(
# 初始化设备连接状态
self.ca = None # CA410色度计
self.ucd = UCDController() # 信号发生器(旧接口,过渡期保留)
# 新架构EventBus + 设备抽象 + 服务层。
# UCD323Device 内部委托 self.ucd保证零行为变更
# 新代码统一走 self.signal_service。
# UCDEventBus + 设备抽象 + 服务层;上层统一走 signal_service / ucd_device
self.event_bus = EventBus()
self.ucd_device = UCD323Device(self.event_bus, self.ucd)
self.ucd_device = UCD323Device(self.event_bus)
self.signal_service = SignalService(self.ucd_device, self.event_bus)
# 连接控制器:统一管理 CA/UCD 生命周期。
@@ -205,6 +205,7 @@ class PQAutomationApp(
self.create_calman_panel()
# 创建测试类型选择区域
self.create_test_type_frame()
self._setup_connection_event_handlers()
# 创建操作按钮区域
self.create_operation_frame()
# 创建结果图表区域
@@ -233,6 +234,23 @@ class PQAutomationApp(
anchor=tk.E,
).pack(side=tk.RIGHT)
def _setup_connection_event_handlers(self) -> None:
"""订阅连接事件,驱动 UCD / CA 指示灯(替代轮询 controller.status"""
def on_connection_changed(evt: ConnectionChanged) -> None:
if evt.device is DeviceKind.UCD:
indicator = getattr(self, "ucd_status_indicator", None)
elif evt.device is DeviceKind.CA:
indicator = getattr(self, "ca_status_indicator", None)
else:
return
if indicator is None:
return
state = "green" if evt.connected else "gray"
self._dispatch_ui(self.update_connection_indicator, indicator, state)
self.event_bus.subscribe(ConnectionChanged, on_connection_changed)
def _dispatch_ui(self, fn, *args, **kwargs):
"""把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。
@@ -388,54 +406,37 @@ class PQAutomationApp(
if hasattr(self, "log_gui"):
self.log_gui.log(f"切换信号格式失败: {str(e)}", level="error")
def _switch_chart_tabs_by_test_type(self, test_type):
"""按测试类型切换 Gamma/EOTF 与客户模板结果 Tab"""
if not hasattr(self, "chart_notebook"):
def _sync_custom_template_tab_visibility(self, test_type):
"""按测试类型与客户模板结果状态同步客户模板 Tab 可见性"""
if not hasattr(self, "_set_custom_template_tab_visible"):
return
# 客户模板结果 Tab 只属于 SDR Movie
if test_type != "sdr_movie":
self._set_custom_template_tab_visible(False)
return
has_custom_rows = False
tree = getattr(self, "custom_result_tree", None)
if tree is not None:
try:
current_tabs = list(self.chart_notebook.tabs())
gamma_tab_id = str(self.gamma_chart_frame)
eotf_tab_id = str(self.eotf_chart_frame)
has_custom_rows = len(tree.get_children()) > 0
except Exception:
has_custom_rows = False
if test_type == "hdr_movie":
if gamma_tab_id in current_tabs:
self.chart_notebook.forget(self.gamma_chart_frame)
if eotf_tab_id not in current_tabs:
insert_pos = min(1, len(self.chart_notebook.tabs()))
self.chart_notebook.insert(insert_pos, self.eotf_chart_frame, text="EOTF 曲线")
else:
if eotf_tab_id in current_tabs:
self.chart_notebook.forget(self.eotf_chart_frame)
if gamma_tab_id not in current_tabs:
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)
current_tabs = list(self.chart_notebook.tabs())
if test_type == "sdr_movie":
if custom_tab_id not in current_tabs:
self.chart_notebook.add(self.custom_template_tab_frame, text="客户模板结果显示")
else:
if custom_tab_id in current_tabs:
self.chart_notebook.forget(self.custom_template_tab_frame)
self.chart_notebook.update_idletasks()
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"切换 Gamma/EOTF Tab 失败: {str(e)}", level="error")
# SDR 下仅在客户模板测试进行中,或已有客户模板结果时显示。
show_tab = has_custom_rows or (
getattr(self, "testing", False)
and getattr(self, "test_type_var", None) is not None
and self.test_type_var.get() == "sdr_movie"
)
self._set_custom_template_tab_visible(show_tab)
def change_test_type(self, test_type):
"""切换测试类型"""
# 切换测试类型时,自动隐藏日志面板和 Local Dimming 面板
# 切换测试类型时,自动隐藏日志面板
if self.current_panel in (
"log",
"local_dimming",
"ai_image",
"single_step",
"pantone_baseline",
"gamma_pattern",
):
self.hide_all_panels()
self._save_cct_params_before_test_type_switch()
@@ -443,10 +444,15 @@ class PQAutomationApp(
# 更新测试项目和侧边栏
self.update_test_items()
if hasattr(self, "refresh_connection_indicators"):
try:
self.refresh_connection_indicators()
except Exception:
pass
self.update_sidebar_selection()
self.on_test_type_change()
self._switch_signal_format_tabs(test_type)
self._switch_chart_tabs_by_test_type(test_type)
self._sync_custom_template_tab_visibility(test_type)
self.sync_gamut_toolbar()
self._restore_charts_for_type(test_type)
@@ -479,7 +485,7 @@ class PQAutomationApp(
def _check_start_preconditions(self):
"""检查开始测试前置条件:设备连接 & 未在测试中。"""
if self.ca is None or self.ucd is None:
if self.ca is None or not self.signal_service.is_connected:
messagebox.showerror("错误", "请先连接CA410和信号发生器")
return False
if self.testing:
@@ -820,8 +826,8 @@ class PQAutomationApp(
print("配置已清理,不再保存")
# 断开设备连接
if self.ucd.status:
self.ucd.close()
if self.signal_service.is_connected:
self.connection.disconnect_ucd()
if self.ca is not None:
self.ca.close()

View File

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

View File

@@ -9,11 +9,11 @@
"cct",
"contrast"
],
"timing": "OVT 1280x 720 @ 120Hz",
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
"colorimetry": "DCI-P3",
"colorimetry": "sRGB",
"patterns": {
"gamut": "rgb",
"gamma": "gray",
@@ -25,16 +25,18 @@
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
},
"gamut_reference": "DCI-P3"
}
},
"sdr_movie": {
"name": "SDR Movie测试",
"test_items": [
"gamut",
"gamma",
"cct",
"contrast",
"accuracy"
],
"timing": "OVT 1280x 720 @ 120Hz",
"timing": "DMT 1920x 1080 @ 60Hz",
"data_range": "Full",
"color_format": "RGB",
"bpc": 8,
@@ -52,7 +54,7 @@
"y_ideal": 0.329,
"y_tolerance": 0.003
},
"gamut_reference": "DCI-P3"
"gamut_reference": "BT.709"
},
"hdr_movie": {
"name": "HDR Movie测试",

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 545 KiB