Compare commits

...

10 Commits

Author SHA1 Message Date
xinzhu.yin
c42287b7d7 重构pq_config、修改切换面板清空旧逻辑 2026-05-19 11:50:53 +08:00
xinzhu.yin
405b6047b9 色域结果多图保存、更换AI生图服务器接口 2026-05-19 10:06:02 +08:00
xinzhu.yin
d7495734a5 修改色域画图及重绘方式 2026-05-18 15:57:11 +08:00
xinzhu.yin
9371defb6e 添加UCD-API文档 2026-05-13 17:18:50 +08:00
xinzhu.yin
0513f810bd 修改摸底测试功能、修改pattern控制逻辑 2026-05-13 17:17:13 +08:00
xinzhu.yin
9a52e34f2b 添加pantone测试模版文件 2026-05-07 14:12:01 +08:00
xinzhu.yin
3c519dde20 添加Pantone 认证摸底测试面板UI 2026-05-07 11:21:17 +08:00
xinzhu.yin
7bc8fd8557 修改AI生图相关日志信息 2026-04-29 19:10:27 +08:00
xinzhu.yin
afd83448ed 修改日志结构 2026-04-29 16:43:31 +08:00
xinzhu.yin
377bba2a0b 添加AI图像生成接口、修改相关界面 2026-04-29 15:25:58 +08:00
155 changed files with 97016 additions and 1669 deletions

6
.gitignore vendored
View File

@@ -18,8 +18,8 @@ dist/
*.log *.log
*.tmp *.tmp
*.bak *.bak
*.csv /*.csv
*.xlsx /*.xlsx
# IDE files # IDE files
.vscode/ .vscode/
@@ -32,3 +32,5 @@ Desktop.ini
# Local configuration overrides # Local configuration overrides
settings/*.local.json settings/*.local.json
settings/ settings/
log/
results/

View File

@@ -2,6 +2,23 @@
import os import os
def _gamut_refs_for_type(test_type):
"""按测试类型返回需要导出的参考色域列表。"""
if test_type == "sdr_movie":
return ["BT.709", "DCI-P3", "BT.2020", "BT.601"]
return ["BT.709", "DCI-P3", "BT.2020"]
def _gamut_ref_var_attr(test_type):
if test_type == "screen_module":
return "screen_gamut_ref_var"
if test_type == "sdr_movie":
return "sdr_gamut_ref_var"
if test_type == "hdr_movie":
return "hdr_gamut_ref_var"
return None
# (item_key, fig_attr, filename, allowed_test_types_or_None, default_bbox_inches_auto) # (item_key, fig_attr, filename, allowed_test_types_or_None, default_bbox_inches_auto)
IMAGE_SPECS = [ IMAGE_SPECS = [
("gamut", "gamut_fig", "色域测试结果.png", None, True), ("gamut", "gamut_fig", "色域测试结果.png", None, True),
@@ -16,7 +33,7 @@ IMAGE_SPECS = [
def save_result_images(result_dir, current_test_type, selected_items, def save_result_images(result_dir, current_test_type, selected_items,
figure_provider, log): figure_provider, log, app=None):
"""根据测试类型和已选项将各测试图表保存为 PNG。 """根据测试类型和已选项将各测试图表保存为 PNG。
figure_provider: callable(attr_name) -> matplotlib Figure 或 None figure_provider: callable(attr_name) -> matplotlib Figure 或 None
@@ -27,6 +44,39 @@ def save_result_images(result_dir, current_test_type, selected_items,
continue continue
if allowed_types is not None and current_test_type not in allowed_types: if allowed_types is not None and current_test_type not in allowed_types:
continue continue
# 色域图:按所有可切换参考状态逐个重绘并保存。
if item_key == "gamut" and app is not None:
var_attr = _gamut_ref_var_attr(current_test_type)
ref_var = getattr(app, var_attr, None) if var_attr else None
rgb_data = None
coverage = 0.0
if hasattr(app, "results") and app.results:
rgb_data = app.results.get_intermediate_data("gamut", "rgb")
try:
coverage = app.results.test_items["gamut"].final_result.get("coverage", 0.0)
except Exception:
coverage = 0.0
if ref_var is not None and rgb_data and len(rgb_data) >= 3 and hasattr(app, "plot_gamut"):
original_ref = ref_var.get()
try:
for ref in _gamut_refs_for_type(current_test_type):
ref_var.set(ref)
app.plot_gamut(rgb_data, coverage, current_test_type)
fig = figure_provider(fig_attr)
if fig is None:
continue
per_ref_name = f"色域测试结果_{ref}.png"
path = os.path.join(result_dir, per_ref_name)
fig.savefig(path, dpi=300)
log(f"已保存: {per_ref_name}")
finally:
ref_var.set(original_ref)
app.plot_gamut(rgb_data, coverage, current_test_type)
continue
fig = figure_provider(fig_attr) fig = figure_provider(fig_attr)
if fig is None: if fig is None:
continue continue

122
app/logging_setup.py Normal file
View File

@@ -0,0 +1,122 @@
from __future__ import annotations
import logging
import os
from datetime import datetime
from typing import Optional
# stdlib 级别 → PQLogGUI 标签
_LEVEL_TO_GUI = {
logging.DEBUG: "debug",
logging.INFO: "info",
logging.WARNING: "warning",
logging.ERROR: "error",
logging.CRITICAL: "error",
}
# PQLogGUI 标签 → stdlib 级别
GUI_LEVEL_TO_LOG = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"separator": logging.INFO,
"blank": logging.DEBUG,
}
# 用于在 LogRecord 上做 GUI 来源标记,避免回环
_FROM_GUI_FLAG = "_from_gui"
# 文件日志:头一行元信息,第二行正文,记录之间空行隔开,方便阅读
_FILE_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s\n%(message)s\n"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
_initialized = False
def _resolve_log_dir(log_dir: Optional[str]) -> str:
if log_dir:
return log_dir
# 默认放在工作目录下的 log/,方便用户直接打开查看
return os.path.join(os.getcwd(), "log")
def setup_logging(
level: int = logging.INFO,
log_dir: Optional[str] = None,
) -> str:
"""配置全局 logging返回日志目录。可重复调用第二次起为空操作。
- 不再向终端输出,避免污染控制台。
- 日志文件按日期命名:``log/YYYY-MM-DD.log``。
"""
global _initialized
root = logging.getLogger()
if _initialized:
return _resolve_log_dir(log_dir)
resolved = _resolve_log_dir(log_dir)
os.makedirs(resolved, exist_ok=True)
file_formatter = logging.Formatter(_FILE_LOG_FORMAT, datefmt=_DATE_FORMAT)
root.setLevel(level)
# 移除既有 handler避免重复 / basicConfig 默认控制台输出
for handler in list(root.handlers):
root.removeHandler(handler)
today = datetime.now().strftime("%Y-%m-%d")
log_file = os.path.join(resolved, f"{today}.log")
file_handler = logging.FileHandler(log_file, encoding="utf-8", delay=True)
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(file_formatter)
root.addHandler(file_handler)
_initialized = True
logging.getLogger(__name__).info("日志系统初始化完成 dir=%s", resolved)
return resolved
class TkLogHandler(logging.Handler):
"""把 stdlib logging 的记录转发到 ``PQLogGUI`` 面板。
带 ``_from_gui`` 标记的记录会被忽略,避免 GUI → file → GUI 回环。
"""
def __init__(self, log_gui):
super().__init__()
self._log_gui = log_gui
def emit(self, record: logging.LogRecord) -> None: # noqa: D401
if getattr(record, _FROM_GUI_FLAG, False):
return
try:
message = self.format(record)
except Exception:
try:
message = record.getMessage()
except Exception:
return
gui_level = _LEVEL_TO_GUI.get(record.levelno, "info")
try:
# PQLogGUI.log 内部已处理跨线程
self._log_gui.log(message, level=gui_level, _from_logging=True)
except Exception:
self.handleError(record)
def attach_gui_handler(log_gui) -> TkLogHandler:
"""把 ``PQLogGUI`` 注册为 root logger 的 handler。已存在则替换。"""
root = logging.getLogger()
# 移除旧的 TkLogHandler保证只挂一个
for h in list(root.handlers):
if isinstance(h, TkLogHandler):
root.removeHandler(h)
handler = TkLogHandler(log_gui)
handler.setLevel(logging.INFO)
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
root.addHandler(handler)
return handler

View File

@@ -0,0 +1,165 @@
"""CIE 色度图底图渲染与缓存。
"重型图像渲染"colour-science 的谱迹颜色填充)与"轻量框架数据层"
(参考/实测三角形、标签、覆盖率)解耦。
底图:
- 仅在首次调用或缓存失效时通过 colour-science 渲染一次;
- 渲染结果保存为 numpy RGBA 数组,同时落盘到 settings/cache/
下次启动直接 imread 加载,避免重新跑色彩科学计算。
调用方在每次绘图时只需 `ax.imshow(bg, extent=bbox)`,再叠加自己的矢量层。
"""
from __future__ import annotations
import hashlib
import os
import threading
from typing import Tuple
import numpy as np
# 谱迹底图分辨率边长单位像素。1024 对于 14 inch 画布足够细腻,
# 文件大小 ~1-2MB单次渲染 ~0.5-1 s缓存后毫秒级加载。
_DIAGRAM_RES = 1024
# 缓存版本号:当渲染参数或风格调整时递增,强制重新生成。
_CACHE_VERSION = "v1"
_BBox = Tuple[float, float, float, float] # (xmin, xmax, ymin, ymax)
_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()
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_path(kind: str, bbox: _BBox) -> str:
return os.path.join(_cache_dir(), _cache_key(kind, bbox))
def _render_chromaticity(kind: str, bbox: _BBox) -> np.ndarray:
"""通过 colour-science 离屏渲染谱迹底图,返回 RGBA float 数组。"""
# 延迟导入:仅在缓存未命中时支付 colour.plotting 的加载开销。
import matplotlib
prev_backend = matplotlib.get_backend()
try:
matplotlib.use("Agg", force=True)
except Exception:
pass
import matplotlib.pyplot as plt
from colour.plotting import (
plot_chromaticity_diagram_CIE1931,
plot_chromaticity_diagram_CIE1976UCS,
)
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])
if kind == "cie1931":
plot_chromaticity_diagram_CIE1931(
axes=ax, show=False, title=False,
tight_layout=False, transparent_background=True,
bounding_box=bbox,
)
elif kind == "cie1976":
plot_chromaticity_diagram_CIE1976UCS(
axes=ax, show=False, title=False,
tight_layout=False, transparent_background=True,
bounding_box=bbox,
)
else:
plt.close(fig)
raise ValueError(f"unknown diagram kind: {kind!r}")
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])
fig.canvas.draw()
# 从 canvas 抓取 RGBA 数组
buf = np.asarray(fig.canvas.buffer_rgba()).copy()
plt.close(fig)
try:
matplotlib.use(prev_backend, force=True)
except Exception:
pass
return buf
def _load_or_render(kind: str, bbox: _BBox) -> np.ndarray:
key = _cache_key(kind, bbox)
with _lock:
if key in _memory_cache:
return _memory_cache[key]
disk = _cache_path(kind, bbox)
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)
_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
def get_cie1976_background() -> Tuple[np.ndarray, _BBox]:
return _load_or_render("cie1976", _CIE1976_BBOX), _CIE1976_BBOX
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"):
try:
os.remove(os.path.join(d, name))
except OSError:
pass

View File

@@ -1,34 +1,208 @@
"""色域图Gamut绘制。 """色域图Gamut绘制 - Calman 风格
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamut 整体搬迁, 架构:**图像渲染层** 与 **基础数据/框架层** 分离
实现与原方法完全一致;原方法仅保留为一行转发。 ------------------------------------------------
- 图像渲染层CIE 1931 / 1976 谱迹色域底图。
由 `app.plots.gamut_background` 通过 colour-science 离屏渲染一次,
结果以 numpy RGBA 数组缓存在内存与磁盘settings/cache/),后续直接
`ax.imshow(bg, extent=bbox)` 复用 → 主线程绘制开销可忽略。
- 基础数据/框架层(轻):参考色域三角形、实测色域三角形、顶点标签、
覆盖率信息框等矢量元素,每次绘制都在真实色度坐标系上重画。
视觉风格:参照 Calman colorspace 显示:
- 当前选中的参考标准:亮色实线 + 顶点空心方框;
- 其他参考标准:半透明虚线(便于对比,不喧宾夺主);
- 实测色域:红色粗边 + 淡红填充 + 顶点圆点 + 浮动坐标标签;
- 右下角白底红字覆盖率信息框。
""" """
import matplotlib.image as mpimg from matplotlib.patches import Polygon
import numpy as np
from matplotlib.patches import PathPatch
from matplotlib.path import Path
import algorithm.pq_algorithm as pq_algorithm import algorithm.pq_algorithm as pq_algorithm
from app.resources import get_resource_path from app.plots.gamut_background import (
get_cie1931_background,
get_cie1976_background,
)
# ============ 参考色域定义CIE 1931 xy============
_REF_GAMUTS_XY = {
"BT.601": [(0.6300, 0.3400), (0.3100, 0.5950), (0.1550, 0.0700)],
"BT.709": [(0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)],
"DCI-P3": [(0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)],
"BT.2020": [(0.7080, 0.2920), (0.1700, 0.7970), (0.1310, 0.0460)],
}
_REF_COLORS = {
"BT.601": "#FBD985",
"BT.709": "#FFFFFF",
"DCI-P3": "#6AA2F7",
"BT.2020": "#73FC9C",
}
# ============================================================
# 坐标转换
# ============================================================
def _xy_to_uv(x, y):
"""CIE 1931 xy → CIE 1976 u'v'"""
denom = -2.0 * x + 12.0 * y + 3.0
if abs(denom) < 1e-10:
return 0.0, 0.0
return (4.0 * x) / denom, (9.0 * y) / denom
def _ref_gamut_uv(name):
return [_xy_to_uv(x, y) for x, y in _REF_GAMUTS_XY[name]]
# ============================================================
# 数据/框架层绘制原语
# ============================================================
def _draw_reference_triangle(ax, vertices, color, *, is_current, label):
"""参考色域三角形"""
xs = [p[0] for p in vertices] + [vertices[0][0]]
ys = [p[1] for p in vertices] + [vertices[0][1]]
if is_current:
poly = Polygon(
vertices, closed=True,
facecolor=(1, 1, 1, 0.18), # 半透明白
edgecolor=color, linewidth=2.2, zorder=8,
)
ax.add_patch(poly)
ax.plot(
xs, ys,
color=color, linewidth=2.2, linestyle="-",
label=label, zorder=9,
)
ax.scatter(
xs[:-1], ys[:-1],
s=60, facecolors="none", edgecolors=color,
linewidths=2, marker="s", zorder=10,
)
else:
ax.plot(
xs, ys,
color=color, linewidth=1.2, linestyle="--",
alpha=0.55, label=label, zorder=5,
)
def _draw_measured_triangle(ax, vertices, *, uv_space=False):
xs = [p[0] for p in vertices] + [vertices[0][0]]
ys = [p[1] for p in vertices] + [vertices[0][1]]
# 半透明红色填充
poly = Polygon(
vertices, closed=True,
facecolor=(1.0, 0.1, 0.1, 0),
edgecolor="#FF2A2A",
linewidth=2.8, zorder=12,
joinstyle="round"
)
ax.add_patch(poly)
# 顶点(白边红心)
ax.scatter(
xs[:-1], ys[:-1],
s=60, facecolors="#FF2A2A",
edgecolors="white", linewidths=1.5,
marker="o", zorder=13,
)
# for (x, y), name in zip(vertices, label_prefix):
# dx, dy = x - cx, y - cy
# norm = max(1e-6, (dx * dx + dy * dy) ** 0.5)
# ox = dx / norm * offset_pix
# oy = dy / norm * offset_pix
# ax.annotate(
# f"{name} ({x:.3f}, {y:.3f})",
# xy=(x, y),
# xytext=(ox, oy),
# textcoords="offset points",
# fontsize=8.5, color="white", fontweight="bold",
# ha="center", va="center",
# bbox=dict(
# boxstyle="round,pad=0.35",
# facecolor="#D81B1B",
# edgecolor="white",
# linewidth=1.2,
# alpha=0.95,
# ),
# arrowprops=dict(
# arrowstyle="-", color="#FF1F1F", lw=1.2, alpha=0.9,
# ),
# zorder=12,
# clip_on=False,
# )
def _draw_coverage_box(ax, x_pos, y_pos, current_ref, coverage):
ax.text(
x_pos, y_pos,
f"{current_ref}\n覆盖率: {coverage:.1f}%",
ha="right", va="bottom",
fontsize=11, fontweight="bold",
color="#FFF",
bbox=dict(
boxstyle="round,pad=0.38",
facecolor="#111",
edgecolor="#FFF",
linewidth=1.7,
alpha=0.98,
),
zorder=30,
)
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim):
ax.set_facecolor("#000")
ax.set_title(title, fontsize=12, fontweight="bold", color="#FFF", pad=8)
ax.set_xlabel(xlabel, fontsize=10, color="#FFF")
ax.set_ylabel(ylabel, fontsize=10, color="#FFF")
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_aspect("equal", adjustable="datalim")
ax.grid(True, linestyle=":", linewidth=0.7, color="#444", alpha=0.32)
ax.tick_params(axis="both", labelsize=9, colors="#FFF")
for spine in ax.spines.values():
spine.set_color("#888")
spine.set_linewidth(0.8)
ax.set_clip_on(False)
def _blit_background(ax, background, bbox):
"""渲染层贴底:将预渲染的谱迹底图贴到真实色度坐标。"""
xmin, xmax, ymin, ymax = bbox
ax.imshow(
background,
extent=(xmin, xmax, ymin, ymax),
origin="upper", # canvas.buffer_rgba 行 0 为顶部
interpolation="bicubic",
zorder=0,
aspect="auto", # 由 _style_axes 的 set_aspect("equal") 控制
)
# ============================================================
# 主入口
# ============================================================
def plot_gamut(self, results, coverage, test_type): def plot_gamut(self, results, coverage, test_type):
"""绘制色域图 - 根据用户选择的参考标准动态计算覆盖率""" """绘制色域图(图像层 + 框架层分离架构)。"""
# 实现从原 PQAutomationApp 方法体原样搬迁,为减少修改面
# 范围、保持行为一致,给 self 赋值为传入的 app 实例。
self.gamut_ax_xy.clear() ax_xy = self.gamut_ax_xy
self.gamut_ax_uv.clear() ax_uv = self.gamut_ax_uv
# ==================== XY 图校准参数 ==================== ax_xy.clear()
XY_ORIGIN_X = 20.55 ax_uv.clear()
XY_ORIGIN_Y = 378.00 # 全局黑色背景
XY_PIXELS_PER_X = 510.6818 self.gamut_fig.patch.set_facecolor("#000")
XY_PIXELS_PER_Y = 429.8844
# ==================== UV 图校准参数 ====================
UV_ORIGIN_U = 26.91
UV_ORIGIN_V = 377.16
UV_PIXELS_PER_U = 615.7260
UV_PIXELS_PER_V = 599.8432
# ========== 读取用户选择的参考标准 ========== # ========== 读取用户选择的参考标准 ==========
if test_type == "screen_module": if test_type == "screen_module":
@@ -40,499 +214,197 @@ def plot_gamut(self, results, coverage, test_type):
else: else:
current_ref = "DCI-P3" current_ref = "DCI-P3"
# ========== ✅✅根据参考标准重新计算覆盖率XY 空间)========== if current_ref not in _REF_GAMUTS_XY:
xy_coverage = coverage # 默认使用传入的值
uv_coverage = 0.0
try:
# 提取前 3 个 RGB 点的 xy 坐标
if len(results) >= 3:
xy_points = [[result[0], result[1]] for result in results[:3]]
# 根据参考标准计算 XY 覆盖率
if current_ref == "BT.2020":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020(
xy_points
)
elif current_ref == "BT.709":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709(
xy_points
)
elif current_ref == "DCI-P3":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(
xy_points
)
elif current_ref == "BT.601":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601(
xy_points
)
else:
self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error") self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error")
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(
xy_points
)
current_ref = "DCI-P3" current_ref = "DCI-P3"
self.log_gui.log( # ========== 重新计算 xy 覆盖率 ==========
f"XY 空间覆盖率({current_ref}: {xy_coverage:.1f}%" xy_coverage = coverage
, level="success") uv_coverage = 0.0
measured_xy = None
except Exception as e:
self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error")
xy_coverage = coverage # 回退到传入值
# =================================================
# ========== 左图CIE 1931 xy ==========
try:
img_xy = mpimg.imread(get_resource_path("assets/cie.png"))
h_xy, w_xy = img_xy.shape[:2]
self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}", level="info")
self.gamut_ax_xy.imshow(img_xy, extent=[0, w_xy, h_xy, 0], aspect="equal")
self.gamut_ax_xy.set_xlim(0, w_xy)
self.gamut_ax_xy.set_ylim(h_xy, 0)
self.gamut_ax_xy.axis("off")
self.gamut_ax_xy.set_clip_on(False)
def cie_xy_to_pixel(x, y):
"""CIE xy → 像素坐标"""
px = XY_ORIGIN_X + x * XY_PIXELS_PER_X
py = XY_ORIGIN_Y - y * XY_PIXELS_PER_Y
return px, py
if len(results) >= 3: if len(results) >= 3:
red_x, red_y = results[0][0], results[0][1] measured_xy = [(float(r[0]), float(r[1])) for r in results[:3]]
green_x, green_y = results[1][0], results[1][1] try:
blue_x, blue_y = results[2][0], results[2][1] if current_ref == "BT.2020":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020(measured_xy)
elif current_ref == "BT.709":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709(measured_xy)
elif current_ref == "DCI-P3":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(measured_xy)
elif current_ref == "BT.601":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601(measured_xy)
self.log_gui.log( self.log_gui.log(
f"测量色域: R({red_x:.4f},{red_y:.4f}) " f"XY 空间覆盖率({current_ref}: {xy_coverage:.1f}%", level="success"
f"G({green_x:.4f},{green_y:.4f}) B({blue_x:.4f},{blue_y:.4f})" )
, level="info") except Exception as e:
self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error")
xy_coverage = coverage
# ========== 绘制测量三角形 ========== # 需要叠加的次要参考色域
points = [ other_refs = [
cie_xy_to_pixel(red_x, red_y), r for r in _REF_GAMUTS_XY.keys()
cie_xy_to_pixel(green_x, green_y), if r != current_ref
cie_xy_to_pixel(blue_x, blue_y), and (r != "BT.601" or test_type == "sdr_movie")
cie_xy_to_pixel(red_x, red_y),
] ]
xs = [p[0] for p in points] # ============================================================
ys = [p[1] for p in points] # 左图CIE 1931 xy
# ============================================================
self.gamut_ax_xy.plot( try:
xs, bg_xy, bbox_xy = get_cie1931_background()
ys, _blit_background(ax_xy, bg_xy, bbox_xy)
color="red", _style_axes(
linewidth=2.5, ax_xy,
marker="o", title="CIE 1931 xy",
markersize=10, xlabel="x", ylabel="y",
markerfacecolor="red", xlim=(bbox_xy[0], bbox_xy[1]),
markeredgecolor="white", ylim=(bbox_xy[2], bbox_xy[3]),
markeredgewidth=2,
label="测量色域",
zorder=10,
) )
# ========== 标注 RGB 点 ========== for ref_name in other_refs:
labels = ["R", "G", "B"] _draw_reference_triangle(
coords = [(red_x, red_y), (green_x, green_y), (blue_x, blue_y)] ax_xy, _REF_GAMUTS_XY[ref_name],
_REF_COLORS[ref_name],
for (x_cie, y_cie), label in zip(coords, labels): is_current=False, label=ref_name,
px, py = cie_xy_to_pixel(x_cie, y_cie) )
_draw_reference_triangle(
# 自适应偏移 ax_xy, _REF_GAMUTS_XY[current_ref],
if label == "R": _REF_COLORS[current_ref],
offset = (-60, -40) if x_cie > 0.6 else (0, -60) is_current=True, label=f"{current_ref} (参考)",
elif label == "G":
offset = (0, -60)
else: # B
offset = (60, 40)
self.gamut_ax_xy.annotate(
f"{label}\n({x_cie:.3f},{y_cie:.3f})",
xy=(px, py),
xytext=offset,
textcoords="offset points",
fontsize=9,
color="white",
fontweight="bold",
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="red",
alpha=0.9,
edgecolor="white",
linewidth=2,
),
arrowprops=dict(arrowstyle="->", color="red", lw=2),
zorder=11,
clip_on=False,
) )
# ========== 绘制所有参考标准 ========== if measured_xy is not None:
# DCI-P3 r_xy, g_xy, b_xy = measured_xy
dcip3 = [ self.log_gui.log(
(0.6800, 0.3200), f"测量色域: R({r_xy[0]:.4f},{r_xy[1]:.4f}) "
(0.2650, 0.6900), f"G({g_xy[0]:.4f},{g_xy[1]:.4f}) B({b_xy[0]:.4f},{b_xy[1]:.4f})",
(0.1500, 0.0600), level="info",
(0.6800, 0.3200), )
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
_draw_coverage_box(
ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage
)
# 暗化三角形外部区域(黑色半透明遮罩)
x0, x1 = bbox_xy[0], bbox_xy[1]
y0, y1 = bbox_xy[2], bbox_xy[3]
# 多边形路径:外框+三角形
verts = [
(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0),
*_REF_GAMUTS_XY[current_ref], _REF_GAMUTS_XY[current_ref][0]
] ]
dcip3_px = [cie_xy_to_pixel(x, y) for x, y in dcip3] codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
self.gamut_ax_xy.plot( codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
[p[0] for p in dcip3_px], path = Path(verts, codes)
[p[1] for p in dcip3_px], patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
color="blue", ax_xy.add_patch(patch)
linewidth=1.5,
linestyle="--",
marker="s",
markersize=6,
alpha=0.7,
label="DCI-P3",
zorder=5,
)
# BT.2020 legend = ax_xy.legend(
bt2020 = [ loc="upper right", fontsize=8.5,
(0.7080, 0.2920), framealpha=0.0, edgecolor="#000", fancybox=True,
(0.1700, 0.7970), labelcolor="#FFF"
(0.1310, 0.0460),
(0.7080, 0.2920),
]
bt2020_px = [cie_xy_to_pixel(x, y) for x, y in bt2020]
self.gamut_ax_xy.plot(
[p[0] for p in bt2020_px],
[p[1] for p in bt2020_px],
color="green",
linewidth=1.5,
linestyle="-.",
marker="D",
markersize=5,
alpha=0.7,
label="BT.2020",
zorder=4,
)
# BT.709
bt709 = [
(0.6400, 0.3300),
(0.3000, 0.6000),
(0.1500, 0.0600),
(0.6400, 0.3300),
]
bt709_px = [cie_xy_to_pixel(x, y) for x, y in bt709]
self.gamut_ax_xy.plot(
[p[0] for p in bt709_px],
[p[1] for p in bt709_px],
color="gray",
linewidth=1.2,
linestyle=":",
marker="^",
markersize=5,
alpha=0.6,
label="BT.709",
zorder=3,
)
# BT.601(仅 SDR 测试)
if test_type == "sdr_movie":
bt601 = [
(0.6300, 0.3400),
(0.3100, 0.5950),
(0.1550, 0.0700),
(0.6300, 0.3400),
]
bt601_px = [cie_xy_to_pixel(x, y) for x, y in bt601]
self.gamut_ax_xy.plot(
[p[0] for p in bt601_px],
[p[1] for p in bt601_px],
color="purple",
linewidth=1.2,
linestyle="-",
marker="o",
markersize=5,
alpha=0.6,
label="BT.601",
zorder=3,
)
# ========== XY 覆盖率标注(使用重新计算的值)==========
self.gamut_ax_xy.text(
w_xy * 0.85,
h_xy * 0.92,
f"参考: {current_ref}\n覆盖率: {xy_coverage:.1f}%",
ha="right",
va="bottom",
fontsize=11,
fontweight="bold",
color="red",
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="white",
alpha=0.95,
edgecolor="red",
linewidth=2,
),
zorder=12,
)
# 图例
self.gamut_ax_xy.legend(
loc="upper right",
fontsize=7,
framealpha=0.95,
edgecolor="black",
fancybox=True,
) )
legend.set_zorder(200)
legend.get_frame().set_facecolor("#000")
legend.get_frame().set_alpha(0.5)
legend.get_frame().set_edgecolor("#FFF")
ax_xy.add_artist(legend)
except Exception as e: except Exception as e:
self.log_gui.log(f"XY 图绘制失败: {str(e)}", level="error") self.log_gui.log(f"XY 图绘制失败: {str(e)}", level="error")
import traceback import traceback
self.log_gui.log(traceback.format_exc(), level="error") self.log_gui.log(traceback.format_exc(), level="error")
# ========== 右图CIE 1976 u'v' ========== # ============================================================
# 右图CIE 1976 u'v'
# ============================================================
try: try:
img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png")) bg_uv, bbox_uv = get_cie1976_background()
h_uv, w_uv = img_uv.shape[:2] _blit_background(ax_uv, bg_uv, bbox_uv)
_style_axes(
ax_uv,
title="CIE 1976 u'v'",
xlabel="u'", ylabel="v'",
xlim=(bbox_uv[0], bbox_uv[1]),
ylim=(bbox_uv[2], bbox_uv[3]),
)
self.log_gui.log(f"加载 UV 色域图: {w_uv}x{h_uv}", level="info") measured_uv = None
if measured_xy is not None:
self.gamut_ax_uv.imshow(img_uv, extent=[0, w_uv, h_uv, 0], aspect="equal") measured_uv = [_xy_to_uv(x, y) for x, y in measured_xy]
self.gamut_ax_uv.set_xlim(0, w_uv)
self.gamut_ax_uv.set_ylim(h_uv, 0)
self.gamut_ax_uv.axis("off")
self.gamut_ax_uv.set_clip_on(False)
def cie_uv_to_pixel(u, v):
"""CIE u'v' → 像素坐标"""
px = UV_ORIGIN_U + u * UV_PIXELS_PER_U
py = UV_ORIGIN_V - v * UV_PIXELS_PER_V
return px, py
if len(results) >= 3:
# 只取前 3 个 RGB 点
rgb_results = results[:3]
# 转换为 u'v' 坐标
def xy_to_uv(x, y):
"""xy → u'v' 转换"""
denom = -2 * x + 12 * y + 3
if abs(denom) < 1e-10:
return 0, 0
u = (4 * x) / denom
v = (9 * y) / denom
return u, v
uv_coords = [
[u, v] for u, v in [xy_to_uv(r[0], r[1]) for r in rgb_results]
]
self.log_gui.log(f"UV 坐标: {uv_coords}", level="info")
# ========== ✅✅计算 u'v' 覆盖率(使用参考标准)==========
try: try:
uv_coverage = pq_algorithm.calculate_uv_gamut_coverage( uv_coverage = pq_algorithm.calculate_uv_gamut_coverage(
uv_coords, reference=current_ref [list(uv) for uv in measured_uv], reference=current_ref
) )
self.log_gui.log( self.log_gui.log(
f"UV 空间覆盖率({current_ref}: {uv_coverage:.1f}%" f"UV 空间覆盖率({current_ref}: {uv_coverage:.1f}%",
, level="success") level="success",
)
except Exception as e: except Exception as e:
self.log_gui.log(f"计算 UV 覆盖率失败: {str(e)}", level="error") self.log_gui.log(f"计算 UV 覆盖率失败: {str(e)}", level="error")
uv_coverage = 0.0 uv_coverage = 0.0
# =================================================
# ========== 绘制测量三角形 ========== for ref_name in other_refs:
uv_coords_plot = uv_coords + [uv_coords[0]] _draw_reference_triangle(
points_uv = [cie_uv_to_pixel(u, v) for u, v in uv_coords_plot] ax_uv, _ref_gamut_uv(ref_name),
xs_uv = [p[0] for p in points_uv] _REF_COLORS[ref_name],
ys_uv = [p[1] for p in points_uv] is_current=False, label=ref_name,
)
self.gamut_ax_uv.plot( _draw_reference_triangle(
xs_uv, ax_uv, _ref_gamut_uv(current_ref),
ys_uv, _REF_COLORS[current_ref],
color="red", is_current=True, label=f"{current_ref} (参考)",
linewidth=2.5,
marker="o",
markersize=10,
markerfacecolor="red",
markeredgecolor="white",
markeredgewidth=2,
label="测量色域",
zorder=10,
) )
# ========== 标注 RGB 点 ========== if measured_uv is not None:
labels = ["R", "G", "B"] _draw_measured_triangle(ax_uv, measured_uv, uv_space=True)
for (u, v), label in zip(uv_coords, labels):
px, py = cie_uv_to_pixel(u, v)
# 自适应偏移 _draw_coverage_box(
if label == "R": ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage
if u > 0.42 and v > 0.50:
offset = (-70, 20)
elif u > 0.45:
offset = (30, 50)
else:
offset = (50, 45)
elif label == "G":
offset = (0, -60)
else: # B
offset = (60, 40)
self.gamut_ax_uv.annotate(
f"{label}\n({u:.3f},{v:.3f})",
xy=(px, py),
xytext=offset,
textcoords="offset points",
fontsize=9,
color="white",
fontweight="bold",
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="red",
alpha=0.9,
edgecolor="white",
linewidth=2,
),
arrowprops=dict(arrowstyle="->", color="red", lw=2),
zorder=11,
clip_on=False,
) )
# ========== DCI-P3 参考(蓝色)========== u0, u1 = bbox_uv[0], bbox_uv[1]
dcip3_uv = [ v0, v1 = bbox_uv[2], bbox_uv[3]
[0.4970, 0.5260], verts = [
[0.0999, 0.5780], (u0, v0), (u0, v1), (u1, v1), (u1, v0), (u0, v0),
[0.1754, 0.1576], *_ref_gamut_uv(current_ref), _ref_gamut_uv(current_ref)[0]
[0.4970, 0.5260],
] ]
dcip3_uv_px = [cie_uv_to_pixel(u, v) for u, v in dcip3_uv] codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
path = Path(verts, codes)
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
ax_uv.add_patch(patch)
self.gamut_ax_uv.plot( legend_uv = ax_uv.legend(
[p[0] for p in dcip3_uv_px], loc="upper right", fontsize=8.5,
[p[1] for p in dcip3_uv_px], framealpha=0.0, edgecolor="#000", fancybox=True,
color="blue", labelcolor="#FFF"
linewidth=1.5,
linestyle="--",
marker="s",
markersize=6,
alpha=0.7,
label="DCI-P3",
zorder=5,
)
# ========== BT.2020 参考(绿色)==========
bt2020_uv = [
[0.5566, 0.5165],
[0.0556, 0.5868],
[0.1593, 0.1258],
[0.5566, 0.5165],
]
bt2020_uv_px = [cie_uv_to_pixel(u, v) for u, v in bt2020_uv]
self.gamut_ax_uv.plot(
[p[0] for p in bt2020_uv_px],
[p[1] for p in bt2020_uv_px],
color="green",
linewidth=1.5,
linestyle="-.",
marker="D",
markersize=5,
alpha=0.7,
label="BT.2020",
zorder=4,
)
# ========== BT.709 参考(灰色)==========
bt709_uv = [
[0.4507, 0.5229],
[0.1250, 0.5625],
[0.1754, 0.1576],
[0.4507, 0.5229],
]
bt709_uv_px = [cie_uv_to_pixel(u, v) for u, v in bt709_uv]
self.gamut_ax_uv.plot(
[p[0] for p in bt709_uv_px],
[p[1] for p in bt709_uv_px],
color="gray",
linewidth=1.2,
linestyle=":",
marker="^",
markersize=5,
alpha=0.6,
label="BT.709",
zorder=3,
)
# ========== BT.601 参考(紫色)- 仅 SDR 测试显示 ==========
if test_type == "sdr_movie":
bt601_uv = [
[0.4510, 0.5236],
[0.1291, 0.5606],
[0.1787, 0.1610],
[0.4510, 0.5236],
]
bt601_uv_px = [cie_uv_to_pixel(u, v) for u, v in bt601_uv]
self.gamut_ax_uv.plot(
[p[0] for p in bt601_uv_px],
[p[1] for p in bt601_uv_px],
color="purple",
linewidth=1.2,
linestyle="-",
marker="o",
markersize=5,
alpha=0.6,
label="BT.601",
zorder=3,
)
# ========== UV 覆盖率标注(使用动态计算的值)==========
self.gamut_ax_uv.text(
w_uv * 0.85,
h_uv * 0.92,
f"参考: {current_ref}\n覆盖率: {uv_coverage:.1f}%",
ha="right",
va="bottom",
fontsize=11,
fontweight="bold",
color="red",
bbox=dict(
boxstyle="round,pad=0.5",
facecolor="white",
alpha=0.95,
edgecolor="red",
linewidth=2,
),
zorder=12,
)
# 图例
self.gamut_ax_uv.legend(
loc="upper right",
fontsize=7,
framealpha=0.95,
edgecolor="black",
fancybox=True,
) )
legend_uv.set_zorder(200)
legend_uv.get_frame().set_facecolor("#000")
legend_uv.get_frame().set_alpha(0.72)
legend_uv.get_frame().set_edgecolor("#FFF")
ax_uv.add_artist(legend_uv)
except Exception as e: except Exception as e:
self.log_gui.log(f"UV 图绘制失败: {str(e)}", level="error") self.log_gui.log(f"UV 图绘制失败: {str(e)}", level="error")
import traceback import traceback
self.log_gui.log(traceback.format_exc(), level="error") self.log_gui.log(traceback.format_exc(), level="error")
# ========== 总标题 ========== # ========== 总标题 ==========
test_type_name = self.get_test_type_name(test_type) test_type_name = self.get_test_type_name(test_type)
self.gamut_fig.suptitle( self.gamut_fig.suptitle(
f"{test_type_name} - 色域测试", fontsize=12, y=0.98, fontweight="bold" f"{test_type_name} - 色域测试",
fontsize=12, y=0.98, fontweight="bold",
) )
self.gamut_canvas.draw() self.gamut_canvas.draw()
self.chart_notebook.select(self.gamut_chart_frame) self.chart_notebook.select(self.gamut_chart_frame)
# 同步工具栏按钮选中状态
if hasattr(self, "sync_gamut_toolbar"):
self.sync_gamut_toolbar()
self.log_gui.log("色域图绘制完成", level="success") self.log_gui.log("色域图绘制完成", level="success")

View File

@@ -1,11 +1,61 @@
# PQ自动化测试配置模块 # PQ自动化测试配置模块
import json import json
import copy import copy
from pathlib import Path
class PQConfig: # =============================================================================
def __init__(self, current_test_type="screen_module", device_config={}, pattern={}): # Pattern 文件读写工具统一存储格式settings/patterns/{name}.json
self.default_cct_params_by_type = { # =============================================================================
_PATTERNS_DIR = Path("settings/patterns")
def load_pattern_file(filepath) -> dict:
"""
从 JSON 文件加载 pattern 配置。
文件格式::
{
"pattern_mode": "SolidColor",
"measurement_bit_depth": 8,
"measurement_max_value": N,
"pattern_params": [[R, G, B], ...]
}
"""
with open(filepath, encoding="utf-8") as f:
return json.load(f)
def save_pattern_file(filepath, pattern: dict) -> None:
"""将 pattern 配置保存到 JSON 文件(目录不存在时自动创建)。"""
path = Path(filepath)
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(pattern, f, indent=4, ensure_ascii=False)
def _load_pattern_or_empty(filepath, default=None) -> dict:
"""加载 pattern 文件;文件不存在或格式错误时返回 default或空 pattern 结构)。"""
try:
return load_pattern_file(filepath)
except (FileNotFoundError, json.JSONDecodeError, KeyError):
if default is not None:
return copy.deepcopy(default)
return {
"pattern_mode": "SolidColor",
"measurement_bit_depth": 8,
"measurement_max_value": 0,
"pattern_params": [],
}
# =============================================================================
# 静态默认值 —— 纯常量,永不在运行时修改
# =============================================================================
_DEFAULT_CCT_PARAMS = {
"screen_module": { "screen_module": {
"x_ideal": 0.3127, "x_ideal": 0.3127,
"x_tolerance": 0.003, "x_tolerance": 0.003,
@@ -24,14 +74,15 @@ class PQConfig:
"y_ideal": 0.3290, "y_ideal": 0.3290,
"y_tolerance": 0.003, "y_tolerance": 0.003,
}, },
} }
self.default_gamut_reference_by_type = {
_DEFAULT_GAMUT_REFERENCE = {
"screen_module": "DCI-P3", "screen_module": "DCI-P3",
"sdr_movie": "BT.709", "sdr_movie": "BT.709",
"hdr_movie": "BT.2020", "hdr_movie": "BT.2020",
} }
self.default_test_types = { _DEFAULT_TEST_TYPES = {
"screen_module": { "screen_module": {
"name": "屏模组性能测试", "name": "屏模组性能测试",
"test_items": ["gamut", "gamma", "cct", "contrast"], "test_items": ["gamut", "gamma", "cct", "contrast"],
@@ -39,6 +90,7 @@ class PQConfig:
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
"patterns": {"gamut": "rgb", "gamma": "gray", "cct": "gray", "contrast": "rgb"},
}, },
"sdr_movie": { "sdr_movie": {
"name": "SDR Movie测试", "name": "SDR Movie测试",
@@ -47,6 +99,7 @@ class PQConfig:
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
"patterns": {"gamut": "rgb", "gamma": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
}, },
"hdr_movie": { "hdr_movie": {
"name": "HDR Movie测试", "name": "HDR Movie测试",
@@ -55,18 +108,11 @@ class PQConfig:
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB", "colorimetry": "sRGB",
"patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
}, },
} }
# 设备连接配置 _PATTERN_RGB = {
self.device_config = {
"ca_com": "COM1",
"ucd_list": "0: UCD-323 [2128C209]",
"ca_channel": "0",
}
# ========== RGB Pattern 配置 ==========
self.default_pattern_rgb = {
"pattern_mode": "SolidColor", "pattern_mode": "SolidColor",
"measurement_bit_depth": 8, "measurement_bit_depth": 8,
"measurement_max_value": 2, "measurement_max_value": 2,
@@ -75,10 +121,10 @@ class PQConfig:
[0, 255, 0], # 绿色 [0, 255, 0], # 绿色
[0, 0, 255], # 蓝色 [0, 0, 255], # 蓝色
], ],
} }
# ========== 灰阶 Pattern 配置 ========== # 11点灰阶硬编码兜底文件缺失时使用主要数据来自 settings/patterns/gray.json
self.default_pattern_gray = { _PATTERN_GRAY_FALLBACK = {
"pattern_mode": "SolidColor", "pattern_mode": "SolidColor",
"measurement_bit_depth": 8, "measurement_bit_depth": 8,
"measurement_max_value": 10, "measurement_max_value": 10,
@@ -95,10 +141,11 @@ class PQConfig:
[26, 26, 26], # 10% [26, 26, 26], # 10%
[0, 0, 0], # 0% 黑色 [0, 0, 0], # 0% 黑色
], ],
} }
# 灰阶 pattern 从文件加载,支持用户编辑;文件缺失时回退到硬编码兜底
_PATTERN_GRAY = _load_pattern_or_empty(_PATTERNS_DIR / "gray.json", default=_PATTERN_GRAY_FALLBACK)
# ========== 色准 Pattern 配置29色 - SDR 和 HDR 通用)========== _PATTERN_ACCURACY = {
self.default_pattern_accuracy = {
"pattern_mode": "SolidColor", "pattern_mode": "SolidColor",
"measurement_bit_depth": 8, "measurement_bit_depth": 8,
"measurement_max_value": 28, # 29个颜色最大索引是28 "measurement_max_value": 28, # 29个颜色最大索引是28
@@ -136,170 +183,64 @@ class PQConfig:
[255, 0, 255], # 27: 100% Magenta [255, 0, 255], # 27: 100% Magenta
[255, 255, 0], # 28: 100% Yellow [255, 255, 0], # 28: 100% Yellow
], ],
}
# _PATTERN_TEMP 从文件读取,可通过编辑 settings/patterns/temp.json 自定义
_PATTERN_TEMP = _load_pattern_or_empty(_PATTERNS_DIR / "temp.json")
# =============================================================================
# Pattern 注册表 —— 核心具名 pattern启动时解析
# =============================================================================
# 内置常量 pattern行业标准不依赖文件
_BUILTIN_PATTERNS = {
"rgb": _PATTERN_RGB,
"accuracy": _PATTERN_ACCURACY,
}
# 全部已知 pattern启动时加载用于 get_pattern 快速查找)
_KNOWN_PATTERNS = {
**_BUILTIN_PATTERNS,
"gray": _PATTERN_GRAY, # 从 gray.json 加载,有硬编码兜底
"custom": _PATTERN_TEMP, # 从 temp.json 加载(客户模板测试)
}
def get_pattern(name: str) -> dict:
"""
按名称返回 pattern 的深拷贝。
查找顺序:
1. ``_KNOWN_PATTERNS``(核心四类,启动时解析)
2. ``settings/patterns/{name}.json``(动态文件,如 pantone、客户定制等
文件不存在时返回空 pattern``pattern_params`` 为空列表)。
"""
if name in _KNOWN_PATTERNS:
return copy.deepcopy(_KNOWN_PATTERNS[name])
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json")
class PQConfig:
def __init__(self, current_test_type="screen_module"):
# ---- 向后兼容:只读属性,指向模块级常量 ----
self.default_cct_params_by_type = _DEFAULT_CCT_PARAMS
self.default_gamut_reference_by_type = _DEFAULT_GAMUT_REFERENCE
self.default_test_types = _DEFAULT_TEST_TYPES
self.default_pattern_rgb = _PATTERN_RGB
self.default_pattern_gray = _PATTERN_GRAY
self.default_pattern_accuracy = _PATTERN_ACCURACY
self.default_pattern_temp = _PATTERN_TEMP
# ---- 设备连接配置 ----
self.device_config = {
"ca_com": "COM1",
"ucd_list": "0: UCD-323 [2128C209]",
"ca_channel": "0",
} }
self.default_pattern_temp = { # ---- 自定义图案(用户可变)----
"pattern_mode": "SolidColor",
"measurement_bit_depth": 8,
"measurement_max_value": 146,
"pattern_params": [
[255, 255, 255],
[242, 242, 242],
[230, 230, 230],
[217, 217, 217],
[204, 204, 204],
[191, 191, 191],
[179, 179, 179],
[166, 166, 166],
[153, 153, 153],
[140, 140, 140],
[128, 128, 128],
[115, 115, 115],
[102, 102, 102],
[89, 89, 89],
[77, 77, 77],
[64, 64, 64],
[51, 51, 51],
[38, 38, 38],
[26, 26, 26],
[13, 13, 13],
[0, 0, 0],
[255, 0, 0],
[242, 0, 0],
[230, 0, 0],
[217, 0, 0],
[204, 0, 0],
[191, 0, 0],
[179, 0, 0],
[166, 0, 0],
[153, 0, 0],
[140, 0, 0],
[128, 0, 0],
[115, 0, 0],
[102, 0, 0],
[89, 0, 0],
[77, 0, 0],
[64, 0, 0],
[51, 0, 0],
[38, 0, 0],
[26, 0, 0],
[13, 0, 0],
[0, 0, 0],
[0, 255, 0],
[0, 242, 0],
[0, 230, 0],
[0, 217, 0],
[0, 204, 0],
[0, 191, 0],
[0, 179, 0],
[0, 166, 0],
[0, 153, 0],
[0, 140, 0],
[0, 128, 0],
[0, 115, 0],
[0, 102, 0],
[0, 89, 0],
[0, 77, 0],
[0, 64, 0],
[0, 51, 0],
[0, 38, 0],
[0, 26, 0],
[0, 13, 0],
[0, 0, 0],
[0, 0, 255],
[0, 0, 242],
[0, 0, 230],
[0, 0, 217],
[0, 0, 204],
[0, 0, 191],
[0, 0, 179],
[0, 0, 166],
[0, 0, 153],
[0, 0, 140],
[0, 0, 128],
[0, 0, 115],
[0, 0, 102],
[0, 0, 89],
[0, 0, 77],
[0, 0, 64],
[0, 0, 51],
[0, 0, 38],
[0, 0, 26],
[0, 0, 13],
[0, 0, 0],
[255, 255, 0],
[242, 242, 0],
[230, 230, 0],
[217, 217, 0],
[204, 204, 0],
[191, 191, 0],
[179, 179, 0],
[166, 166, 0],
[153, 153, 0],
[140, 140, 0],
[128, 128, 0],
[115, 115, 0],
[102, 102, 0],
[89, 89, 0],
[77, 77, 0],
[64, 64, 0],
[51, 51, 0],
[38, 38, 0],
[26, 26, 0],
[13, 13, 0],
[0, 0, 0],
[0, 255, 255],
[0, 242, 242],
[0, 230, 230],
[0, 217, 217],
[0, 204, 204],
[0, 191, 191],
[0, 179, 179],
[0, 166, 166],
[0, 153, 153],
[0, 140, 140],
[0, 128, 128],
[0, 115, 115],
[0, 102, 102],
[0, 89, 89],
[0, 77, 77],
[0, 64, 64],
[0, 51, 51],
[0, 38, 38],
[0, 26, 26],
[0, 13, 13],
[0, 0, 0],
[255, 0, 255],
[242, 0, 242],
[230, 0, 230],
[217, 0, 217],
[204, 0, 204],
[191, 0, 191],
[179, 0, 179],
[166, 0, 166],
[153, 0, 153],
[140, 0, 140],
[128, 0, 128],
[115, 0, 115],
[102, 0, 102],
[89, 0, 89],
[77, 0, 77],
[64, 0, 64],
[51, 0, 51],
[38, 0, 38],
[26, 0, 26],
[13, 0, 13],
[0, 0, 0],
]
}
# 自定义图案
self.custom_pattern = { self.custom_pattern = {
"pattern_mode": "SolidColor", "pattern_mode": "SolidColor",
"measurement_bit_depth": 8, "measurement_bit_depth": 8,
@@ -307,9 +248,10 @@ class PQConfig:
"pattern_params": [], "pattern_params": [],
} }
self.current_test_types = self.default_test_types # ---- 运行态 ----
self.current_test_types = copy.deepcopy(_DEFAULT_TEST_TYPES)
self.current_test_type = current_test_type self.current_test_type = current_test_type
self.current_pattern = self.default_pattern_rgb self.current_pattern = copy.deepcopy(_PATTERN_RGB) # 深拷贝,避免引用污染
def get_default_cct_params(self, test_type): def get_default_cct_params(self, test_type):
"""获取指定测试类型的默认 CCT 参数副本。""" """获取指定测试类型的默认 CCT 参数副本。"""
@@ -338,12 +280,8 @@ class PQConfig:
temp_config = copy.deepcopy(self) temp_config = copy.deepcopy(self)
# 2. 设置正确的 pattern 模式 # 2. 设置正确的 pattern 模式
if mode == "rgb": _resolved = get_pattern(mode)
temp_config.current_pattern = copy.deepcopy(self.default_pattern_rgb) temp_config.current_pattern = _resolved if _resolved["pattern_params"] else copy.deepcopy(_PATTERN_RGB)
elif mode == "gray":
temp_config.current_pattern = copy.deepcopy(self.default_pattern_gray)
elif mode == "accuracy":
temp_config.current_pattern = copy.deepcopy(self.default_pattern_accuracy)
# 3. 替换为转换后的参数 # 3. 替换为转换后的参数
temp_config.current_pattern["pattern_params"] = converted_params temp_config.current_pattern["pattern_params"] = converted_params
@@ -356,9 +294,6 @@ class PQConfig:
"current_test_type": self.current_test_type, "current_test_type": self.current_test_type,
"test_types": self.current_test_types, "test_types": self.current_test_types,
"device_config": self.device_config, "device_config": self.device_config,
"default_pattern_rgb": self.default_pattern_rgb,
"default_pattern_gray": self.default_pattern_gray,
"default_pattern_accuracy": self.default_pattern_accuracy,
"custom_pattern": self.custom_pattern, "custom_pattern": self.custom_pattern,
} }
@@ -367,30 +302,6 @@ class PQConfig:
self.current_test_type = config_dict.get("current_test_type", "screen_module") self.current_test_type = config_dict.get("current_test_type", "screen_module")
self.current_test_types = config_dict.get("test_types", self.current_test_types) self.current_test_types = config_dict.get("test_types", self.current_test_types)
self.device_config = config_dict.get("device_config", self.device_config) self.device_config = config_dict.get("device_config", self.device_config)
self.default_pattern_rgb = config_dict.get(
"default_pattern_rgb", self.default_pattern_rgb
)
self.default_pattern_gray = config_dict.get(
"default_pattern_gray", self.default_pattern_gray
)
# ========== 强制使用新的 29色配置 ==========
loaded_accuracy = config_dict.get("default_pattern_accuracy", None)
# 检查加载的配置是否是旧的 10色
if loaded_accuracy and len(loaded_accuracy.get("pattern_params", [])) != 29:
print(
f"检测到旧的配置({len(loaded_accuracy.get('pattern_params', []))}色),强制使用新的 29色配置"
)
# 使用 __init__ 中定义的新配置
self.default_pattern_accuracy = self.default_pattern_accuracy
else:
self.default_pattern_accuracy = config_dict.get(
"default_pattern_accuracy", self.default_pattern_accuracy
)
# ==========================================
self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern) self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern)
def save_to_file(self, filename): def save_to_file(self, filename):
@@ -426,32 +337,32 @@ class PQConfig:
return True return True
def set_current_pattern(self, mode): def set_current_pattern(self, mode):
"""设置当前模式的测试图案""" """设置当前模式的测试图案(支持所有已注册名称及动态文件 pattern"""
if mode == "rgb": pattern = get_pattern(mode)
self.current_pattern = self.default_pattern_rgb if not pattern["pattern_params"]:
elif mode == "gray":
self.current_pattern = self.default_pattern_gray
elif mode == "accuracy": # 色准模式SDR 和 HDR 通用 29色
self.current_pattern = self.default_pattern_accuracy
elif mode == "custom":
# self.current_pattern = self.custom_pattern
self.current_pattern = self.default_pattern_temp
else:
return False return False
self.current_pattern = pattern # get_pattern 已深拷贝
# 确保 measurement_max_value 是整数
if "measurement_max_value" in self.current_pattern:
value = self.current_pattern["measurement_max_value"]
if isinstance(value, str):
try:
self.current_pattern["measurement_max_value"] = int(value)
except ValueError:
self.current_pattern["measurement_max_value"] = (
len(self.current_pattern["pattern_params"]) - 1
)
return True return True
def get_test_item_pattern(self, test_item, test_type=None) -> str:
"""
返回指定测试项目所使用的 pattern 名称。
从当前测试类型的 ``patterns`` 字段查找;未配置时按通用规则回退。
"""
tt = test_type or self.current_test_type
patterns = self.current_test_types.get(tt, {}).get("patterns", {})
_fallback = {
"gamut": "rgb",
"gamma": "gray",
"eotf": "gray",
"cct": "gray",
"contrast": "rgb",
"accuracy": "accuracy",
"custom": "custom",
}
return patterns.get(test_item) or _fallback.get(test_item, "gray")
def set_custom_pattern(self, pattern_mode, pattern_params): def set_custom_pattern(self, pattern_mode, pattern_params):
"""设置自定义模式的测试项""" """设置自定义模式的测试项"""
self.custom_pattern["pattern_mode"] = pattern_mode self.custom_pattern["pattern_mode"] = pattern_mode
@@ -511,7 +422,7 @@ class PQConfig:
list: [(name, r, g, b), ...] list: [(name, r, g, b), ...]
""" """
names = self.get_accuracy_color_names() names = self.get_accuracy_color_names()
rgb_values = self.default_pattern_accuracy["pattern_params"] rgb_values = _PATTERN_ACCURACY["pattern_params"]
return [(name, r, g, b) for name, (r, g, b) in zip(names, rgb_values)] return [(name, r, g, b) for name, (r, g, b) in zip(names, rgb_values)]
@@ -525,7 +436,7 @@ class PQConfig:
for value in percentages: for value in percentages:
names.append(f"{prefix} {value}%") names.append(f"{prefix} {value}%")
pattern_count = len(self.default_pattern_temp.get("pattern_params", [])) pattern_count = len(_PATTERN_TEMP.get("pattern_params", []))
if pattern_count <= len(names): if pattern_count <= len(names):
return names[:pattern_count] return names[:pattern_count]
@@ -538,23 +449,15 @@ class PQConfig:
def get_test_item_chinese_names(self, test_items): def get_test_item_chinese_names(self, test_items):
"""获取测试项目的显示名称""" """获取测试项目的显示名称"""
item_names = [] _name_map = {
for item in test_items: "gamut": "色域",
if item == "gamut": "gamma": "Gamma",
item_names.append("色域") "eotf": "EOTF",
elif item == "gamma": "cct": "色度一致性",
item_names.append("Gamma") "contrast": "对比度",
elif item == "eotf": "accuracy": "色准",
item_names.append("EOTF") }
elif item == "cct": return [_name_map.get(item, item) for item in test_items]
item_names.append("色度一致性")
elif item == "contrast":
item_names.append("对比度")
elif item == "accuracy":
item_names.append("色准")
else:
item_names.append(item)
return item_names
def get_current_config(self): def get_current_config(self):
"""返回当前测试类型相关的所有配置信息""" """返回当前测试类型相关的所有配置信息"""
@@ -577,62 +480,3 @@ class PQConfig:
} }
return config_info return config_info
# ========== 验证代码(测试完成后可删除)==========
if __name__ == "__main__":
print("=" * 60)
print("验证 pq_config.py 配置")
print("=" * 60)
config = PQConfig()
# 检查 default_pattern_accuracy
pattern_count = len(config.default_pattern_accuracy["pattern_params"])
max_value = config.default_pattern_accuracy["measurement_max_value"]
print(f"\ndefault_pattern_accuracy:")
print(f" 图案数量: {pattern_count}")
print(f" measurement_max_value: {max_value}")
if pattern_count == 29 and max_value == 28:
print("\n配置正确29色")
# 显示前 5 个图案
print("\n前5个图案:")
for i in range(5):
rgb = config.default_pattern_accuracy["pattern_params"][i]
names = config.get_accuracy_color_names()
print(f" [{i}] {names[i]:15s} RGB{rgb}")
# 显示后 5 个图案
print("\n后5个图案:")
for i in range(24, 29):
rgb = config.default_pattern_accuracy["pattern_params"][i]
names = config.get_accuracy_color_names()
print(f" [{i}] {names[i]:15s} RGB{rgb}")
# 测试 set_current_pattern
print("\n测试 set_current_pattern('accuracy'):")
config.set_current_pattern("accuracy")
current_count = len(config.current_pattern["pattern_params"])
print(f" current_pattern 图案数量: {current_count}")
if current_count == 29:
print(" set_current_pattern 工作正常")
else:
print(f" set_current_pattern 失败!只有 {current_count} 个图案")
else:
print(f"\n配置错误!")
print(f" 期望: 29 个图案, measurement_max_value=28")
print(f" 实际: {pattern_count} 个图案, measurement_max_value={max_value}")
print("\n请检查 default_pattern_accuracy 定义!")
print(" 应该包含:")
print(" - 5个灰阶")
print(" - 18个 ColorChecker 色块")
print(" - 6个 100% 饱和色")
print(" - 总计 29 个 RGB 数组")
print("=" * 60)

View File

@@ -13,7 +13,6 @@ import colour
import numpy as np import numpy as np
import algorithm.pq_algorithm as pq_algorithm import algorithm.pq_algorithm as pq_algorithm
from app.data_range_converter import convert_pattern_params
from app.pq.pq_result import PQResult from app.pq.pq_result import PQResult
def new_pq_results(self, test_type, test_name): def new_pq_results(self, test_type, test_name):
@@ -310,205 +309,12 @@ def send_fix_pattern(self, mode):
results = [] results = []
try: try:
# 1. 设置图案模式 session = self.pattern_service.prepare_session(mode, log_details=True)
if mode == "rgb":
self.config.set_current_pattern("rgb")
elif mode == "gray":
self.config.set_current_pattern("gray")
elif mode == "accuracy": # 色准模式SDR 和 HDR 通用 29色
self.config.set_current_pattern("accuracy")
elif mode == "custom":
self.config.set_current_pattern("custom")
else:
self.log_gui.log(f"未知的图案模式: {mode}", level="error")
return None
# 2. 获取当前测试类型
test_type = self.config.current_test_type
# 3. 根据测试类型设置信号格式和图案
if test_type == "screen_module":
# 屏模组测试:使用 Timing
self.log_gui.log("=" * 50, level="separator")
self.log_gui.log("设置屏模组信号格式:", level="info")
self.log_gui.log("=" * 50, level="separator")
timing_str = self.config.current_test_types[test_type]["timing"]
self.log_gui.log(f" Timing: {timing_str}", level="info")
# 屏模组测试:直接使用原始配置
self.ucd.set_ucd_params(self.config)
elif test_type == "sdr_movie":
# SDR 测试设置色彩空间、Gamma 等
self.log_gui.log("=" * 50, level="separator")
self.log_gui.log("设置 SDR 信号格式:", level="info")
self.log_gui.log("=" * 50, level="separator")
color_space = self.sdr_color_space_var.get()
gamma = self.sdr_gamma_type_var.get()
data_range = self.sdr_data_range_var.get()
bit_depth = self.sdr_bit_depth_var.get()
self.log_gui.log(f" 色彩空间: {color_space}", level="info")
self.log_gui.log(f" Gamma: {gamma}", level="info")
self.log_gui.log(f" 数据范围: {data_range}", level="info")
self.log_gui.log(f" 编码位深: {bit_depth}", level="info")
success = self.ucd.set_sdr_format(
color_space=color_space,
gamma=gamma,
data_range=data_range,
bit_depth=bit_depth,
)
if success:
self.log_gui.log("SDR 信号格式设置成功", level="success")
else:
self.log_gui.log("SDR 信号格式设置失败", level="error")
# 设置图案参数
if mode == "accuracy":
self.log_gui.log(f"设置 SDR 29色色准测试图案...", level="info")
else:
self.log_gui.log(f"设置 SDR 测试图案({mode} 模式)...", level="info")
# ========== ✅✅修改:使用临时配置对象 ==========
import copy
# 从原始配置获取参数(每次都是干净的)
if mode == "rgb":
original_params = copy.deepcopy(
self.config.default_pattern_rgb["pattern_params"]
)
elif mode == "gray":
original_params = copy.deepcopy(
self.config.default_pattern_gray["pattern_params"]
)
elif mode == "accuracy":
original_params = copy.deepcopy(
self.config.default_pattern_accuracy["pattern_params"]
)
elif mode == "custom":
original_params = copy.deepcopy(
self.config.default_pattern_temp["pattern_params"]
)
self.log_gui.log(f" 使用原始 RGB 参数(前 3 个):", level="info")
for i in range(min(3, len(original_params))):
self.log_gui.log(f" [{i+1}] {original_params[i]}", level="info")
# 根据 data_range 转换
converted_params = convert_pattern_params(
pattern_params=original_params, data_range=data_range, verbose=False
)
if data_range == "Limited":
self.log_gui.log(" 转换为 Limited Range (16-235):", level="info")
for i in range(min(3, len(converted_params))):
self.log_gui.log(
f" {original_params[i]}{converted_params[i]}"
, level="info")
else:
self.log_gui.log("Full RangeRGB 保持不变", level="success")
# 创建临时配置对象(不修改 self.config
temp_config = self.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
# 使用临时配置设置参数
self.ucd.set_ucd_params(temp_config)
self.log_gui.log(f"图案参数已设置,共 {len(converted_params)} 个图案", level="success")
# ========== 修改结束 ==========
elif test_type == "hdr_movie":
# HDR 测试设置色彩空间、Metadata 等
self.log_gui.log("=" * 50, level="separator")
self.log_gui.log("设置 HDR 信号格式:", level="info")
self.log_gui.log("=" * 50, level="separator")
color_space = self.hdr_color_space_var.get()
data_range = self.hdr_data_range_var.get()
bit_depth = self.hdr_bit_depth_var.get()
max_cll = self.hdr_maxcll_var.get()
max_fall = self.hdr_maxfall_var.get()
self.log_gui.log(f" 色彩空间: {color_space}", level="info")
self.log_gui.log(f" 数据范围: {data_range}", level="info")
self.log_gui.log(f" 编码位深: {bit_depth}", level="info")
self.log_gui.log(f" MaxCLL: {max_cll}", level="info")
self.log_gui.log(f" MaxFALL: {max_fall}", level="info")
success = self.ucd.set_hdr_format(
color_space=color_space,
data_range=data_range,
bit_depth=bit_depth,
max_cll=max_cll,
max_fall=max_fall,
)
if success:
self.log_gui.log("HDR 信号格式设置成功", level="success")
else:
self.log_gui.log("HDR 信号格式设置失败", level="error")
# 设置图案参数
if mode == "accuracy":
self.log_gui.log(f"设置 HDR 29色色准测试图案...", level="info")
else:
self.log_gui.log(f"设置 HDR 测试图案({mode} 模式)...", level="info")
# ========== ✅✅修改:使用临时配置对象 ==========
import copy
# 从原始配置获取参数
if mode == "rgb":
original_params = copy.deepcopy(
self.config.default_pattern_rgb["pattern_params"]
)
elif mode == "gray":
original_params = copy.deepcopy(
self.config.default_pattern_gray["pattern_params"]
)
elif mode == "accuracy":
original_params = copy.deepcopy(
self.config.default_pattern_accuracy["pattern_params"]
)
self.log_gui.log(f" 使用原始 RGB 参数(前 3 个):", level="info")
for i in range(min(3, len(original_params))):
self.log_gui.log(f" [{i+1}] {original_params[i]}", level="info")
# 根据 data_range 转换
converted_params = convert_pattern_params(
pattern_params=original_params, data_range=data_range, verbose=False
)
if data_range == "Limited":
self.log_gui.log(" 转换为 Limited Range (16-235):", level="info")
for i in range(min(3, len(converted_params))):
self.log_gui.log(
f" {original_params[i]}{converted_params[i]}"
, level="info")
else:
self.log_gui.log("Full RangeRGB 保持不变", level="success")
# 创建临时配置对象
temp_config = self.config.get_temp_config_with_converted_params(
mode=mode, converted_params=converted_params
)
self.ucd.set_ucd_params(temp_config)
self.log_gui.log(f"图案参数已设置,共 {len(converted_params)} 个图案", level="success")
# ========== 修改结束 ==========
self.log_gui.log("=" * 50, level="separator") self.log_gui.log("=" * 50, level="separator")
# 4. 循环发送图案并采集数据(使用原始配置的数量) # 4. 循环发送图案并采集数据
total_patterns = len(self.config.current_pattern["pattern_params"]) total_patterns = session.total_patterns
self.log_gui.log(f"开始采集数据,共 {total_patterns} 个图案", level="info") self.log_gui.log(f"开始采集数据,共 {total_patterns} 个图案", level="info")
settle_time = max(0.2, float(getattr(self, "pattern_settle_time", 1.0))) settle_time = max(0.2, float(getattr(self, "pattern_settle_time", 1.0)))
progress_step = max( progress_step = max(
@@ -518,14 +324,7 @@ def send_fix_pattern(self, mode):
f"采集等待时间: {settle_time:.2f}s可通过 pattern_settle_time 调整)" f"采集等待时间: {settle_time:.2f}s可通过 pattern_settle_time 调整)"
, level="info") , level="info")
# 获取颜色名称列表(用于日志显示) display_names = session.display_names
color_names = None
if mode == "accuracy":
color_names = self.config.get_accuracy_color_names()
custom_pattern_names = []
if mode == "custom" and hasattr(self.config, "get_temp_pattern_names"):
custom_pattern_names = self.config.get_temp_pattern_names()
for i in range(total_patterns): for i in range(total_patterns):
if not self.testing: if not self.testing:
@@ -538,17 +337,15 @@ def send_fix_pattern(self, mode):
or ((i + 1) % progress_step == 0) or ((i + 1) % progress_step == 0)
) )
# 设置下一个图案(显示颜色名称)
if should_log_detail: if should_log_detail:
if color_names and i < len(color_names): if display_names and i < len(display_names):
self.log_gui.log( self.log_gui.log(
f"发送第 {i+1}/{total_patterns} 个图案: {color_names[i]}..." f"发送第 {i+1}/{total_patterns} 个图案: {display_names[i]}..."
, level="info") , level="info")
else: else:
self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info") self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info")
self.ucd.set_next_pattern() self.pattern_service.send_session_pattern(session, i)
self.ucd.run()
time.sleep(settle_time) time.sleep(settle_time)
# 测量数据 # 测量数据
@@ -582,8 +379,8 @@ def send_fix_pattern(self, mode):
) )
row_data = { row_data = {
"pattern_name": ( "pattern_name": (
custom_pattern_names[i] display_names[i]
if i < len(custom_pattern_names) if i < len(display_names)
else f"P {i + 1}" else f"P {i + 1}"
), ),
"X": X, "X": X,
@@ -786,6 +583,7 @@ def test_gamut(self, test_type):
# 传递完整的 results 用于绘图 # 传递完整的 results 用于绘图
self.plot_gamut(results, coverage, test_type) self.plot_gamut(results, coverage, test_type)
self._save_chart_snapshot(test_type, "gamut", (results, coverage, test_type))
self.log_gui.log("色域测试完成", level="success") self.log_gui.log("色域测试完成", level="success")
@@ -877,6 +675,7 @@ def test_gamma(self, test_type, gray_data=None):
target_gamma = 2.2 target_gamma = 2.2
self.plot_gamma(L_bar, results_with_gamma_list, target_gamma, test_type) self.plot_gamma(L_bar, results_with_gamma_list, target_gamma, test_type)
self._save_chart_snapshot(test_type, "gamma", (L_bar, results_with_gamma_list, target_gamma, test_type))
self.log_gui.log("Gamma测试完成", level="success") self.log_gui.log("Gamma测试完成", level="success")
@@ -959,6 +758,7 @@ def test_eotf(self, test_type, gray_data=None):
# ========== 绘制 EOTF 曲线 ========== # ========== 绘制 EOTF 曲线 ==========
# HDR 使用 PQ 曲线,目标 gamma 设为 None不使用传统 gamma # HDR 使用 PQ 曲线,目标 gamma 设为 None不使用传统 gamma
self.plot_eotf(L_bar, results_with_eotf_list, test_type) self.plot_eotf(L_bar, results_with_eotf_list, test_type)
self._save_chart_snapshot(test_type, "eotf", (L_bar, results_with_eotf_list, test_type))
self.log_gui.log("EOTF 测试完成", level="success") self.log_gui.log("EOTF 测试完成", level="success")
@@ -999,6 +799,7 @@ def test_cct(self, test_type, gray_data=None):
# 绘制图表 # 绘制图表
self.plot_cct(test_type) self.plot_cct(test_type)
self._save_chart_snapshot(test_type, "cct", (test_type,))
self.log_gui.log("色度一致性测试完成", level="success") self.log_gui.log("色度一致性测试完成", level="success")
except Exception as e: except Exception as e:
@@ -1062,6 +863,7 @@ def test_contrast(self, test_type, gray_data=None):
# 绘制对比度图表 # 绘制对比度图表
self.plot_contrast(contrast_data, test_type) self.plot_contrast(contrast_data, test_type)
self._save_chart_snapshot(test_type, "contrast", (contrast_data, test_type))
self.log_gui.log("对比度测试完成", level="success") self.log_gui.log("对比度测试完成", level="success")
except Exception as e: except Exception as e:
@@ -1227,6 +1029,7 @@ def test_color_accuracy(self, test_type):
# ========== 绘制图表 ========== # ========== 绘制图表 ==========
self.plot_accuracy(accuracy_data, test_type) self.plot_accuracy(accuracy_data, test_type)
self._save_chart_snapshot(test_type, "accuracy", (accuracy_data, test_type))
self.log_gui.log("色准测试完成", level="success") self.log_gui.log("色准测试完成", level="success")
@@ -1371,20 +1174,6 @@ def on_test_completed(self):
except Exception as e: except Exception as e:
self.log_gui.log(f"显示色度重新计算按钮失败: {str(e)}", level="error") self.log_gui.log(f"显示色度重新计算按钮失败: {str(e)}", level="error")
if "gamut" in selected_items:
try:
if test_type == "screen_module" and hasattr(self, "recalc_gamut_btn"):
self.recalc_gamut_btn.grid()
self.log_gui.log("屏模组色域参考调整按钮已启用", level="success")
elif test_type == "sdr_movie" and hasattr(self, "sdr_recalc_gamut_btn"):
self.sdr_recalc_gamut_btn.grid()
self.log_gui.log("SDR 色域参考调整按钮已启用", level="success")
elif test_type == "hdr_movie" and hasattr(self, "hdr_recalc_gamut_btn"):
self.hdr_recalc_gamut_btn.grid()
self.log_gui.log("HDR 色域参考调整按钮已启用", level="success")
except Exception as e:
self.log_gui.log(f"显示色域重新计算按钮失败: {str(e)}", level="error")
messagebox.showinfo("完成", "测试已完成!") messagebox.showinfo("完成", "测试已完成!")

View File

@@ -0,0 +1 @@
from app.services.pattern_service import PatternService, PatternSession

View File

@@ -1,6 +1,10 @@
"""AI 图片生成服务:后端请求 + 本地缓存管理。 """AI 图片生成服务:后端请求 + 本地缓存管理。
API 端点待接入,当前通过 ``set_api_caller`` 注入具体实现。 后端接口(测试环境):
POST {API_BASE_URL}{API_PATH}
body: {"user_message": str, "session_id": str}
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。 缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
""" """
@@ -9,15 +13,25 @@ from __future__ import annotations
import datetime as _dt import datetime as _dt
import hashlib import hashlib
import json import json
import logging
import mimetypes import mimetypes
import os import os
import shutil import shutil
import threading import threading
import time
import uuid
from io import BytesIO
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Callable, List, Optional from typing import Callable, List, Optional
from urllib.error import HTTPError
from urllib.parse import urlparse from urllib.parse import urlparse
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
from PIL import Image
logger = logging.getLogger(__name__)
# ---------- 常量 ---------- # ---------- 常量 ----------
@@ -25,41 +39,214 @@ _CACHE_DIRNAME = os.path.join("settings", "ai_image_cache")
_META_SUFFIX = ".json" _META_SUFFIX = ".json"
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp") _SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
# 测试环境后端
# API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
API_PATH = "api/v1/pqtest/generate"
API_TIMEOUT = 300.0 # 后端最长 60s留余量
# 进程级会话 id多轮对话需保持一致可通过 ``reset_session`` 重置
_session_id: str = str(uuid.uuid4())
_session_lock = threading.Lock()
def get_session_id() -> str:
with _session_lock:
return _session_id
def set_session_id(session_id: str) -> str:
"""切换到指定会话。空值会抛错"""
global _session_id
sid = (session_id or "").strip()
if not sid:
raise ValueError("session_id 不能为空")
with _session_lock:
old = _session_id
_session_id = sid
return _session_id
def reset_session() -> str:
"""开启新一轮会话,返回新的 session_id。"""
global _session_id
with _session_lock:
old = _session_id
_session_id = str(uuid.uuid4())
return _session_id
def _mask_sid(sid: str) -> str:
"""日志安全展示:仅保留前 8 位。"""
if not sid:
return "(none)"
return f"{sid[:8]}"
def _truncate(text: str, n: int = 80) -> str:
s = (text or "").replace("\n", " ").strip()
return s if len(s) <= n else s[:n] + ""
# ---------- 数据结构 ---------- # ---------- 数据结构 ----------
@dataclass @dataclass
class AIImageRecord: class AIImageRecord:
"""一条缓存记录。""" """一条缓存记录。
字段说明:
- ``id``: 唯一 id等同于磁盘文件名不含扩展名格式 ``{时间戳}_{md5前8}``。
- ``prompt``: 用户原始输入(完整保留,用于回溯/调试,不应被改写)。
- ``title``: 用户自定义展示标题重命名时写入UI 优先使用,留空则回退 prompt 第一行截断。
- ``image_path``: 图片在缓存目录中的绝对路径。
- ``created_at``: ISO8601 时间字符串。
- ``extra``: 其它元数据,至少包含 ``source`` 与 ``session_id``(标识属于哪一轮对话)。
"""
id: str id: str
prompt: str prompt: str
image_path: str image_path: str
created_at: str # ISO8601 created_at: str # ISO8601
extra: Optional[dict] = None extra: Optional[dict] = None
title: Optional[str] = None
def to_json(self) -> str: def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False, indent=2) return json.dumps(asdict(self), ensure_ascii=False, indent=2)
@property
def display_name(self) -> str:
"""UI 展示名title 优先,否则回退 prompt 第一行。"""
if self.title:
return self.title.strip()
first = (self.prompt or "").strip().splitlines()[0] if self.prompt else ""
return first or "(未命名)"
# ---------- API 注入 ---------- @property
def session_id(self) -> str:
# 调用签名: ``fn(prompt: str) -> (image_bytes: bytes, image_ext: str, extra: dict|None)`` if isinstance(self.extra, dict):
# ``image_ext`` 例如 ``".png"````extra`` 可为 None。 return str(self.extra.get("session_id") or "")
_ApiCaller = Callable[[str], tuple] return ""
_api_caller: Optional[_ApiCaller] = None
def set_api_caller(fn: Optional[_ApiCaller]) -> None: # ---------- 后端 API ----------
"""注入真实的后端 API 调用函数。在 API 就绪前可保持为 None。"""
global _api_caller
_api_caller = fn
def has_api() -> bool: def _api_endpoint() -> str:
return _api_caller is not None base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
return base + API_PATH.lstrip("/")
def _pretty_json_text(value) -> str:
"""把对象或 JSON 字符串格式化为易读文本;失败则回退原始字符串。"""
try:
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, indent=2)
text = "" if value is None else str(value)
parsed = json.loads(text)
return json.dumps(parsed, ensure_ascii=False, indent=2)
except Exception:
return "" if value is None else str(value)
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str:
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。"""
payload = json.dumps(
{"user_message": user_message,
"session_id": session_id},
ensure_ascii=False,
).encode("utf-8")
request_headers = {
"Content-Type": "application/json; charset=utf-8",
"Accept": "application/json",
"User-Agent": "pqAutomationApp/1.0",
}
endpoint = _api_endpoint()
logger.info(
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
)
logger.info(
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
endpoint,
timeout,
_pretty_json_text(request_headers),
_pretty_json_text(payload.decode("utf-8", errors="replace")),
)
request = Request(
endpoint,
data=payload,
method="POST",
headers=request_headers,
)
t0 = time.monotonic()
try:
with urlopen(request, timeout=timeout) as response:
raw = response.read()
http_status = response.status
response_headers = dict(response.headers.items())
raw_text = raw.decode("utf-8", errors="replace")
logger.info(
"[AIImage][RESPONSE]\nstatus=%s\nheaders=%s\nbody=%s",
http_status,
_pretty_json_text(response_headers),
_pretty_json_text(raw_text),
)
except HTTPError as exc:
elapsed = time.monotonic() - t0
err_raw = b""
try:
err_raw = exc.read() or b""
except Exception:
err_raw = b""
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
err_headers = {}
try:
if exc.headers is not None:
err_headers = dict(exc.headers.items())
except Exception:
err_headers = {}
logger.error(
"[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\nheaders=%s\nbody=%s",
_mask_sid(session_id),
elapsed,
getattr(exc, "code", "?"),
str(exc),
_pretty_json_text(err_headers),
_pretty_json_text(err_text),
)
raise
except Exception as exc:
elapsed = time.monotonic() - t0
logger.error(
"[AIImage] 请求异常 sid=%s elapsed=%.2fs %s: %s",
_mask_sid(session_id), elapsed, type(exc).__name__, exc,
)
raise
elapsed = time.monotonic() - t0
logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed)
try:
result = json.loads(raw.decode("utf-8"))
except Exception as exc:
raw_text = raw.decode("utf-8", errors="replace")
logger.error("[AIImage] 响应解析失败 sid=%s raw=%s", _mask_sid(session_id), raw_text)
raise RuntimeError(f"AI 接口返回非 JSON{raw_text}") from exc
code = result.get("code")
message = result.get("message") or ""
data = result.get("data") or {}
image_url = (data.get("imageUrl") or "").strip()
if code != 200 or not image_url:
logger.warning(
"[AIImage] 接口失败 sid=%s code=%s msg=%r",
_mask_sid(session_id), code, message,
)
raise RuntimeError(f"AI 接口失败 code={code} msg={message or '生成失败'}")
logger.info(
"[AIImage] 生成成功 sid=%s elapsed=%.2fs url=%s",
_mask_sid(session_id), elapsed, image_url,
)
return image_url
# ---------- 缓存路径工具 ---------- # ---------- 缓存路径工具 ----------
@@ -83,6 +270,28 @@ def _meta_path_for(image_path: str) -> str:
return os.path.splitext(image_path)[0] + _META_SUFFIX return os.path.splitext(image_path)[0] + _META_SUFFIX
def _sanitize_image_bytes(image_bytes: bytes, image_ext: str) -> bytes:
"""规范化图片字节,尽量去掉已知有问题的 PNG ICC profile。"""
ext = (image_ext or "").lower()
if ext not in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}:
return image_bytes
try:
with Image.open(BytesIO(image_bytes)) as img:
img.load()
normalized = img.copy()
output = BytesIO()
save_kwargs = {}
if ext == ".png":
save_kwargs["icc_profile"] = None
normalized.save(output, format=normalized.format or ext.lstrip(".").upper(), **save_kwargs)
result = output.getvalue()
if result:
return result
except Exception as exc:
logger.warning("[AIImage] 图片规范化失败 ext=%s %s: %s", ext, type(exc).__name__, exc)
return image_bytes
# ---------- 读写 ---------- # ---------- 读写 ----------
@@ -98,6 +307,7 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
prompt = "" prompt = ""
created_at = "" created_at = ""
extra = None extra = None
title = None
rec_id = os.path.splitext(name)[0] rec_id = os.path.splitext(name)[0]
if os.path.isfile(meta_path): if os.path.isfile(meta_path):
try: try:
@@ -106,6 +316,7 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
prompt = data.get("prompt", "") prompt = data.get("prompt", "")
created_at = data.get("created_at", "") created_at = data.get("created_at", "")
extra = data.get("extra") extra = data.get("extra")
title = data.get("title")
rec_id = data.get("id", rec_id) rec_id = data.get("id", rec_id)
except Exception: except Exception:
pass pass
@@ -123,54 +334,27 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
image_path=full, image_path=full,
created_at=created_at, created_at=created_at,
extra=extra, extra=extra,
title=title,
) )
) )
if not records:
seeded = _seed_placeholder_record(cache_dir)
if seeded is not None:
records.append(seeded)
records.sort(key=lambda r: r.created_at, reverse=True) records.sort(key=lambda r: r.created_at, reverse=True)
return records return records
def _seed_placeholder_record(cache_dir: str) -> Optional[AIImageRecord]:
"""当缓存为空时,写入一张本地占位图,便于前端联调。"""
try:
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
src = os.path.join(repo_root, "assets", "entry_1.png")
if not os.path.isfile(src):
return None
rec_id = f"{_dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_placeholder"
image_path = os.path.join(cache_dir, f"{rec_id}.png")
shutil.copyfile(src, image_path)
record = AIImageRecord(
id=rec_id,
prompt="本地测试占位图(后端未接入)",
image_path=image_path,
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
extra={"source": "local-placeholder"},
)
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
f.write(record.to_json())
return record
except Exception:
return None
def save_image_to_cache( def save_image_to_cache(
prompt: str, prompt: str,
image_bytes: bytes, image_bytes: bytes,
image_ext: str = ".png", image_ext: str = ".png",
extra: Optional[dict] = None, extra: Optional[dict] = None,
base_dir: Optional[str] = None, base_dir: Optional[str] = None,
title: Optional[str] = None,
) -> AIImageRecord: ) -> AIImageRecord:
"""把生成的图片字节写入缓存,返回记录。""" """把生成的图片字节写入缓存,返回记录。"""
if not image_ext.startswith("."): if not image_ext.startswith("."):
image_ext = "." + image_ext image_ext = "." + image_ext
if image_ext.lower() not in _SUPPORTED_IMG_EXT: if image_ext.lower() not in _SUPPORTED_IMG_EXT:
image_ext = ".png" image_ext = ".png"
image_bytes = _sanitize_image_bytes(image_bytes, image_ext)
cache_dir = get_cache_dir(base_dir) cache_dir = get_cache_dir(base_dir)
rec_id = _make_id(prompt) rec_id = _make_id(prompt)
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}") image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
@@ -183,6 +367,7 @@ def save_image_to_cache(
image_path=image_path, image_path=image_path,
created_at=_dt.datetime.now().isoformat(timespec="seconds"), created_at=_dt.datetime.now().isoformat(timespec="seconds"),
extra=extra, extra=extra,
title=title,
) )
try: try:
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f: with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
@@ -256,6 +441,53 @@ def export_record(record: AIImageRecord, dest_path: str) -> None:
shutil.copyfile(record.image_path, dest_path) shutil.copyfile(record.image_path, dest_path)
def update_record_title(record: AIImageRecord, new_title: Optional[str]) -> bool:
"""更新记录的展示标题并写回侧车 JSON。空串/None 视为清除标题。"""
title = (new_title or "").strip() or None
meta_path = _meta_path_for(record.image_path)
try:
data: dict = {}
if os.path.isfile(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
data = json.load(f) or {}
if title is None:
data.pop("title", None)
else:
data["title"] = title
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
except Exception:
return False
record.title = title
return True
def group_records_by_session(records: List[AIImageRecord]) -> List[dict]:
"""按 ``session_id`` 分组。
返回元素:``{"session_id", "records", "started_at", "latest_at"}``。
会话按"最近使用时间"倒序,组内记录按时间倒序。
没有 session_id 的记录归到空串 ``""`` 组。
"""
buckets: dict = {}
for rec in records:
buckets.setdefault(rec.session_id, []).append(rec)
sessions = []
for sid, recs in buckets.items():
recs.sort(key=lambda r: r.created_at, reverse=True)
started_at = min((r.created_at for r in recs if r.created_at), default="")
sessions.append(
{
"session_id": sid,
"records": recs,
"started_at": started_at,
"latest_at": recs[0].created_at if recs else "",
}
)
sessions.sort(key=lambda s: s["latest_at"], reverse=True)
return sessions
# ---------- 异步请求 ---------- # ---------- 异步请求 ----------
@@ -264,27 +496,51 @@ def request_image_async(
on_success: Callable[[AIImageRecord], None], on_success: Callable[[AIImageRecord], None],
on_error: Callable[[Exception], None], on_error: Callable[[Exception], None],
base_dir: Optional[str] = None, base_dir: Optional[str] = None,
session_id: Optional[str] = None,
cancel_event: Optional[threading.Event] = None,
) -> threading.Thread: ) -> threading.Thread:
"""在后台线程请求 API → 写入缓存 → 回调。 """在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用UI 侧若需 ``on_success`` / ``on_error`` 会在 **工作线程** 中被调用UI 侧若需
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。 切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
``session_id`` 留空则使用进程级会话 id保证多轮对话上下文
""" """
sid = session_id or get_session_id()
cancel = cancel_event
def _worker(): def _worker():
try: try:
if _api_caller is None: if cancel is not None and cancel.is_set():
raise RuntimeError("AI 图片 API 尚未接入,请调用 set_api_caller 注入") logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
image_bytes, image_ext, extra = _normalize_api_result(_api_caller(prompt)) return
record = save_image_to_cache( image_url = _call_pqtest_generate(prompt, sid)
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
return
record = import_image_from_url(
image_url=image_url,
prompt=prompt, prompt=prompt,
image_bytes=image_bytes, extra={"source": "ai-api", "session_id": sid},
image_ext=image_ext,
extra=extra,
base_dir=base_dir, base_dir=base_dir,
) )
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(下载后) sid=%s", _mask_sid(sid))
return
logger.info(
"[AIImage] 已写入缓存 sid=%s id=%s path=%s",
_mask_sid(sid), record.id, record.image_path,
)
on_success(record) on_success(record)
except Exception as exc: except Exception as exc:
if cancel is not None and cancel.is_set():
logger.info("[AIImage] 任务已取消(异常忽略) sid=%s", _mask_sid(sid))
return
logger.error(
"[AIImage] 生成流程失败 sid=%s %s: %s",
_mask_sid(sid), type(exc).__name__, exc,
)
on_error(exc) on_error(exc)
t = threading.Thread(target=_worker, daemon=True) t = threading.Thread(target=_worker, daemon=True)
@@ -300,11 +556,15 @@ def import_image_from_url_async(
extra: Optional[dict] = None, extra: Optional[dict] = None,
base_dir: Optional[str] = None, base_dir: Optional[str] = None,
timeout: float = 20.0, timeout: float = 20.0,
cancel_event: Optional[threading.Event] = None,
) -> threading.Thread: ) -> threading.Thread:
"""在后台线程下载远程图片并写入缓存""" """在后台线程下载远程图片并写入缓存"""
def _worker(): def _worker():
try: try:
if cancel_event is not None and cancel_event.is_set():
logger.info("[AIImage] URL 导入任务已取消(请求前)")
return
record = import_image_from_url( record = import_image_from_url(
image_url=image_url, image_url=image_url,
prompt=prompt, prompt=prompt,
@@ -312,8 +572,14 @@ def import_image_from_url_async(
base_dir=base_dir, base_dir=base_dir,
timeout=timeout, timeout=timeout,
) )
if cancel_event is not None and cancel_event.is_set():
logger.info("[AIImage] URL 导入任务已取消(下载后)")
return
on_success(record) on_success(record)
except Exception as exc: except Exception as exc:
if cancel_event is not None and cancel_event.is_set():
logger.info("[AIImage] URL 导入任务已取消(异常忽略)")
return
on_error(exc) on_error(exc)
t = threading.Thread(target=_worker, daemon=True) t = threading.Thread(target=_worker, daemon=True)
@@ -321,18 +587,6 @@ def import_image_from_url_async(
return t return t
def _normalize_api_result(result):
"""允许 API 返回 ``bytes`` 或 ``(bytes, ext)`` 或 ``(bytes, ext, extra)``。"""
if isinstance(result, (bytes, bytearray)):
return bytes(result), ".png", None
if isinstance(result, tuple):
if len(result) == 2:
return bytes(result[0]), str(result[1]), None
if len(result) == 3:
return bytes(result[0]), str(result[1]), result[2]
raise ValueError("API 返回格式不支持,需为 bytes 或 (bytes, ext[, extra])")
def is_remote_image_url(value: str) -> bool: def is_remote_image_url(value: str) -> bool:
"""判断输入是否为 http/https 图片地址。""" """判断输入是否为 http/https 图片地址。"""
url = (value or "").strip() url = (value or "").strip()

View File

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

@@ -15,6 +15,24 @@ def init_gamut_chart(self):
container = ttk.Frame(self.gamut_chart_frame) container = ttk.Frame(self.gamut_chart_frame)
container.pack(expand=True, fill=tk.BOTH) container.pack(expand=True, fill=tk.BOTH)
# ---- 参考色域切换工具栏 ----
toolbar = ttk.Frame(container)
toolbar.pack(fill=tk.X, padx=8, pady=(4, 2))
ttk.Label(toolbar, text="参考色域标准:").pack(side=tk.LEFT, padx=(0, 8))
self._gamut_ref_toolbar_var = tk.StringVar(value="DCI-P3")
for std in ["BT.709", "DCI-P3", "BT.2020", "BT.601"]:
rb = ttk.Radiobutton(
toolbar, text=std,
variable=self._gamut_ref_toolbar_var,
value=std,
bootstyle="toolbutton",
command=lambda s=std: self._on_gamut_toolbar_changed(s),
)
rb.pack(side=tk.LEFT, padx=2)
# ---- matplotlib 图表 ----
self.gamut_fig = plt.Figure(figsize=(14, 6), dpi=100) self.gamut_fig = plt.Figure(figsize=(14, 6), dpi=100)
self.gamut_canvas = FigureCanvasTkAgg(self.gamut_fig, master=container) self.gamut_canvas = FigureCanvasTkAgg(self.gamut_fig, master=container)
@@ -29,16 +47,16 @@ def init_gamut_chart(self):
[0.52, 0.08, 0.46, 0.84] [0.52, 0.08, 0.46, 0.84]
) # ← 改回 0.84 ) # ← 改回 0.84
# 初始化XY # 初始化 XY 图(占位坐标系,真实绘制时由 plot_gamut 设置 CIE 1931 范围)
self.gamut_ax_xy.set_xlim(0, 600) self.gamut_ax_xy.set_xlim(0.0, 0.8)
self.gamut_ax_xy.set_ylim(600, 0) self.gamut_ax_xy.set_ylim(0.0, 0.9)
self.gamut_ax_xy.axis("off") self.gamut_ax_xy.set_aspect("equal", adjustable="datalim")
self.gamut_ax_xy.set_clip_on(False) self.gamut_ax_xy.set_clip_on(False)
# 初始化UV # 初始化 UV 图(占位坐标系,真实绘制时由 plot_gamut 设置 CIE 1976 范围)
self.gamut_ax_uv.set_xlim(0, 600) self.gamut_ax_uv.set_xlim(0.0, 0.65)
self.gamut_ax_uv.set_ylim(600, 0) self.gamut_ax_uv.set_ylim(0.0, 0.6)
self.gamut_ax_uv.axis("off") self.gamut_ax_uv.set_aspect("equal", adjustable="datalim")
self.gamut_ax_uv.set_clip_on(False) self.gamut_ax_uv.set_clip_on(False)
# 调整标题位置y=0.98 # 调整标题位置y=0.98
@@ -46,6 +64,47 @@ def init_gamut_chart(self):
self.gamut_canvas.draw() self.gamut_canvas.draw()
def sync_gamut_toolbar(self):
"""将工具栏参考标准按钮同步为当前测试类型的 ref var 值。"""
if not hasattr(self, "_gamut_ref_toolbar_var"):
return
test_type = getattr(self.config, "current_test_type", "screen_module")
var_map = {
"screen_module": "screen_gamut_ref_var",
"sdr_movie": "sdr_gamut_ref_var",
"hdr_movie": "hdr_gamut_ref_var",
}
attr = var_map.get(test_type)
if attr and hasattr(self, attr):
self._gamut_ref_toolbar_var.set(getattr(self, attr).get())
def _on_gamut_toolbar_changed(self, std):
"""用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。"""
test_type = self.config.current_test_type
var_map = {
"screen_module": "screen_gamut_ref_var",
"sdr_movie": "sdr_gamut_ref_var",
"hdr_movie": "hdr_gamut_ref_var",
}
attr = var_map.get(test_type)
if attr and hasattr(self, attr):
getattr(self, attr).set(std)
# 保存到配置
if test_type not in self.config.current_test_types:
self.config.current_test_types[test_type] = {}
self.config.current_test_types[test_type]["gamut_reference"] = std
self.save_pq_config()
# 仅在有色域数据时才重新绘制,避免无数据时弹出警告框
if hasattr(self, "results") and self.results:
rgb_data = self.results.get_intermediate_data("gamut", "rgb")
if rgb_data and len(rgb_data) >= 3:
self.recalculate_gamut()
def init_gamma_chart(self): def init_gamma_chart(self):
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)""" """初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)"""
container = ttk.Frame(self.gamma_chart_frame) container = ttk.Frame(self.gamma_chart_frame)
@@ -600,9 +659,12 @@ def clear_chart(self):
self.eotf_canvas.draw() self.eotf_canvas.draw()
# ========== 4. 清空色度图表 ========== # ========== 4. 清空色度图表 ==========
if hasattr(self, "cct_ax1") and hasattr(self, "cct_ax2"): # 注意plot_cct 会调用 cct_fig.clear() 并重建 subplots导致 self.cct_ax1/ax2 变成
# 上图x coordinates # 过期引用。因此清空时必须同样重建,并更新引用,否则清不干净。
self.cct_ax1.clear() if hasattr(self, "cct_fig") and hasattr(self, "cct_canvas"):
self.cct_fig.clear()
self.cct_ax1 = self.cct_fig.add_subplot(211)
self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9) self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9)
self.cct_ax1.set_ylabel("CIE x", fontsize=9) self.cct_ax1.set_ylabel("CIE x", fontsize=9)
self.cct_ax1.set_xlim(0, 105) self.cct_ax1.set_xlim(0, 105)
@@ -610,8 +672,7 @@ def clear_chart(self):
self.cct_ax1.grid(True, linestyle="--", alpha=0.3) self.cct_ax1.grid(True, linestyle="--", alpha=0.3)
self.cct_ax1.tick_params(labelsize=8) self.cct_ax1.tick_params(labelsize=8)
# 下图y coordinates self.cct_ax2 = self.cct_fig.add_subplot(212)
self.cct_ax2.clear()
self.cct_ax2.set_xlabel("灰阶 (%)", fontsize=9) self.cct_ax2.set_xlabel("灰阶 (%)", fontsize=9)
self.cct_ax2.set_ylabel("CIE y", fontsize=9) self.cct_ax2.set_ylabel("CIE y", fontsize=9)
self.cct_ax2.set_xlim(0, 105) self.cct_ax2.set_xlim(0, 105)
@@ -620,8 +681,6 @@ def clear_chart(self):
self.cct_ax2.tick_params(labelsize=8) 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)
# 重置布局
self.cct_fig.subplots_adjust( self.cct_fig.subplots_adjust(
left=0.12, left=0.12,
right=0.88, right=0.88,
@@ -629,7 +688,6 @@ def clear_chart(self):
bottom=0.08, bottom=0.08,
hspace=0.25, hspace=0.25,
) )
self.cct_canvas.draw() self.cct_canvas.draw()
# ========== 5. 清空对比度图表 ========== # ========== 5. 清空对比度图表 ==========

View File

@@ -28,6 +28,11 @@ def create_ai_image_panel(self):
self.ai_image_current = None # AIImageRecord | None self.ai_image_current = None # AIImageRecord | None
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
self._ai_image_requesting = False self._ai_image_requesting = False
self._ai_image_progress_job = None
self._ai_image_progress_phase = 0
self._ai_image_cancel_event = None
self._ai_image_request_seq = 0
self._ai_image_active_seq = 0
container = ttk.Frame(frame, padding=10) container = ttk.Frame(frame, padding=10)
container.pack(fill=tk.BOTH, expand=True) container.pack(fill=tk.BOTH, expand=True)
@@ -150,11 +155,30 @@ def create_ai_image_panel(self):
send_row, textvariable=self.ai_image_status_var, send_row, textvariable=self.ai_image_status_var,
foreground="#888", font=("微软雅黑", 9), foreground="#888", font=("微软雅黑", 9),
).pack(side=tk.LEFT) ).pack(side=tk.LEFT)
self.ai_image_progress = ttk.Progressbar(
send_row,
mode="indeterminate",
length=120,
bootstyle="info-striped",
)
self.ai_image_progress.pack(side=tk.LEFT, padx=(8, 0))
self.ai_image_progress.pack_forget()
self.ai_image_send_btn = ttk.Button( self.ai_image_send_btn = ttk.Button(
send_row, text="发送", bootstyle="primary", width=10, send_row, text="发送", bootstyle="primary", width=10,
command=lambda: _send_prompt(self), command=lambda: _send_prompt(self),
) )
self.ai_image_send_btn.pack(side=tk.RIGHT) self.ai_image_send_btn.pack(side=tk.RIGHT)
self.ai_image_new_session_btn = ttk.Button(
send_row, text="新对话", bootstyle="secondary-outline", width=10,
command=lambda: _start_new_session(self),
)
self.ai_image_new_session_btn.pack(side=tk.RIGHT, padx=(0, 6))
self.ai_image_stop_btn = ttk.Button(
send_row, text="停止", bootstyle="danger-outline", width=10,
command=lambda: _stop_request(self),
)
self.ai_image_stop_btn.pack(side=tk.RIGHT, padx=(0, 6))
self.ai_image_stop_btn.configure(state=tk.DISABLED)
# 注册面板 # 注册面板
self.register_panel("ai_image", frame, None, "ai_image_visible") self.register_panel("ai_image", frame, None, "ai_image_visible")
@@ -172,18 +196,51 @@ def toggle_ai_image_panel(self):
# ---------------- 列表 / 选中 ---------------- # ---------------- 列表 / 选中 ----------------
def reload_ai_image_list(self): def reload_ai_image_list(self, auto_select_first=True):
"""重新扫描缓存并刷新列表。""" """重新扫描缓存并刷新列表。
按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``
其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。
auto_select_first: 是否自动选中第一张图片(默认 True
"""
self.ai_image_records = _svc.list_records() self.ai_image_records = _svc.list_records()
self.ai_image_listbox.delete(0, tk.END) self.ai_image_listbox.delete(0, tk.END)
for rec in self.ai_image_records: # 维护行号 → 记录索引的映射;分隔头处为 None
label = _format_list_label(rec) self._ai_image_row_map = []
self._ai_image_row_session_map = []
sessions = _svc.group_records_by_session(self.ai_image_records)
flat = []
current_sid = _svc.get_session_id()
for idx, sess in enumerate(sessions, start=1):
sid = sess["session_id"]
is_current = sid and sid == current_sid
header = _format_session_header(idx, sess, is_current=is_current)
self.ai_image_listbox.insert(tk.END, header)
# 头部行:禁用选中(视觉上变灰)
last = self.ai_image_listbox.size() - 1
self.ai_image_listbox.itemconfig(
last, foreground="#888", selectforeground="#888",
background="#f5f5f5", selectbackground="#f5f5f5",
)
self._ai_image_row_map.append(None)
self._ai_image_row_session_map.append(sid)
for rec in sess["records"]:
label = " " + _format_list_label(rec)
self.ai_image_listbox.insert(tk.END, label) self.ai_image_listbox.insert(tk.END, label)
if self.ai_image_records: self._ai_image_row_map.append(len(flat))
self._ai_image_row_session_map.append(rec.session_id)
flat.append(rec)
# 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等)
self.ai_image_records = flat
if self.ai_image_records and auto_select_first:
# 选中第一张实际记录
for row, ridx in enumerate(self._ai_image_row_map):
if ridx is not None:
self.ai_image_listbox.selection_clear(0, tk.END) self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(0) self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(0) self.ai_image_listbox.activate(row)
_select_record(self, self.ai_image_records[0]) _select_record(self, self.ai_image_records[ridx])
break
else: else:
self.ai_image_current = None self.ai_image_current = None
self.ai_image_photo = None self.ai_image_photo = None
@@ -191,6 +248,14 @@ def reload_ai_image_list(self):
self.ai_image_meta_var.set("暂无缓存图片") self.ai_image_meta_var.set("暂无缓存图片")
def _format_session_header(index: int, sess: dict, is_current: bool) -> str:
started = (sess.get("started_at") or "").replace("T", " ")[:16]
tag = "(当前)" if is_current else ""
if sess.get("session_id"):
return f"── 会话 #{index} · {started} {tag}──"
return f"── 未归类 · {started} ──"
def _format_list_label(rec: _svc.AIImageRecord) -> str: def _format_list_label(rec: _svc.AIImageRecord) -> str:
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜 # 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
size_tag = "" size_tag = ""
@@ -205,21 +270,34 @@ def _format_list_label(rec: _svc.AIImageRecord) -> str:
except Exception: except Exception:
pass pass
prompt_line = (rec.prompt or "(无提示)").strip().splitlines()[0] name_line = rec.display_name.splitlines()[0] if rec.display_name else "(未命名)"
# 剩余可用宽度width=34)去掉 size_tag # 列表宽度 width=34,需要扣除两格缩进 + size_tag
max_prompt = 34 - len(size_tag) - 2 max_name = 34 - 2 - len(size_tag) - 2
if max_prompt > 4 and len(prompt_line) > max_prompt: if max_name > 4 and len(name_line) > max_name:
prompt_line = prompt_line[:max_prompt] + "" name_line = name_line[:max_name] + ""
return f"{size_tag}{prompt_line}" return f"{size_tag}{name_line}"
def _on_list_select(self): def _on_list_select(self):
sel = self.ai_image_listbox.curselection() sel = self.ai_image_listbox.curselection()
if not sel: if not sel:
return return
idx = sel[0] row = sel[0]
if 0 <= idx < len(self.ai_image_records): row_map = getattr(self, "_ai_image_row_map", None) or []
_select_record(self, self.ai_image_records[idx]) if row >= len(row_map):
return
ridx = row_map[row]
if ridx is None:
session_id = _session_id_for_row(self, row)
if session_id:
_switch_to_session(self, session_id, show_message=False)
self.ai_image_listbox.selection_clear(row)
return
if 0 <= ridx < len(self.ai_image_records):
rec = self.ai_image_records[ridx]
if rec.session_id:
_switch_to_session(self, rec.session_id, show_message=False, target_record_id=rec.id)
_select_record(self, rec)
def _select_record(self, rec: _svc.AIImageRecord): def _select_record(self, rec: _svc.AIImageRecord):
@@ -243,6 +321,7 @@ def _redraw_preview(self):
ch = canvas.winfo_height() or 1 ch = canvas.winfo_height() or 1
try: try:
img = Image.open(rec.image_path) img = Image.open(rec.image_path)
img.load()
except Exception as exc: except Exception as exc:
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66") canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
return return
@@ -257,6 +336,57 @@ def _redraw_preview(self):
# ---------------- 发送 / 保存 / 删除 ---------------- # ---------------- 发送 / 保存 / 删除 ----------------
def _start_new_session(self):
"""开启新的对话会话,后续生成将使用新的 session_id。"""
if getattr(self, "_ai_image_requesting", False):
messagebox.showinfo("提示", "请等待当前请求完成")
return
_svc.reset_session()
self.ai_image_status_var.set("已开启新对话")
reload_ai_image_list(self, auto_select_first=False)
def _session_id_for_row(self, row: int) -> str:
session_map = getattr(self, "_ai_image_row_session_map", None) or []
if row < 0 or row >= len(session_map):
return ""
return session_map[row] or ""
def _switch_to_session(self, session_id: str, show_message: bool = True, target_record_id: str = ""):
sid = (session_id or "").strip()
if not sid:
return
if sid == _svc.get_session_id():
return
_svc.set_session_id(sid)
reload_ai_image_list(self)
if target_record_id:
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
if ridx is None:
continue
rec = self.ai_image_records[ridx]
if rec.id == target_record_id:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(row)
self.ai_image_listbox.see(row)
_select_record(self, rec)
break
self.ai_image_status_var.set("已切换到历史对话")
if show_message:
messagebox.showinfo("提示", "已切换到所选历史对话")
def _update_request_progress(self):
if not getattr(self, "_ai_image_requesting", False):
self._ai_image_progress_job = None
return
self.ai_image_status_var.set("正在生成图片…")
self._ai_image_progress_phase += 1
self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self))
def _send_prompt(self): def _send_prompt(self):
if getattr(self, "_ai_image_requesting", False): if getattr(self, "_ai_image_requesting", False):
return return
@@ -265,47 +395,69 @@ def _send_prompt(self):
messagebox.showinfo("提示", "请输入内容") messagebox.showinfo("提示", "请输入内容")
return return
self._ai_image_request_seq += 1
req_seq = self._ai_image_request_seq
self._ai_image_active_seq = req_seq
self._ai_image_cancel_event = threading.Event()
_set_requesting(self, True) _set_requesting(self, True)
is_remote_url = _svc.is_remote_image_url(prompt) is_remote_url = _svc.is_remote_image_url(prompt)
self.ai_image_status_var.set("下载中…" if is_remote_url else "请求中…") self.ai_image_status_var.set("下载中…" if is_remote_url else "后端处理中…")
def _success(record): def _success(record):
self.root.after(0, lambda: _on_request_done(self, record, None)) self.root.after(0, lambda: _on_request_done(self, record, None, req_seq))
def _error(exc): def _error(exc):
self.root.after(0, lambda: _on_request_done(self, None, exc)) self.root.after(0, lambda: _on_request_done(self, None, exc, req_seq))
if is_remote_url: if is_remote_url:
_svc.import_image_from_url_async( _svc.import_image_from_url_async(
prompt, prompt,
on_success=_success, on_success=_success,
on_error=_error, on_error=_error,
cancel_event=self._ai_image_cancel_event,
) )
return return
if not _svc.has_api(): _svc.request_image_async(
_set_requesting(self, False) prompt,
self.ai_image_status_var.set("就绪") on_success=_success,
messagebox.showerror( on_error=_error,
"API 未配置", cancel_event=self._ai_image_cancel_event,
"AI 图片 API 尚未接入。\n"
"可直接输入图片 URL 导入,或在启动时通过 "
"app.services.ai_image.set_api_caller(...) 注入真实实现。",
) )
return
_svc.request_image_async(prompt, on_success=_success, on_error=_error)
def _set_requesting(self, flag: bool): def _set_requesting(self, flag: bool):
self._ai_image_requesting = flag self._ai_image_requesting = flag
try: try:
self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL) self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
self.ai_image_new_session_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
self.ai_image_stop_btn.configure(state=tk.NORMAL if flag else tk.DISABLED)
except Exception:
pass
if flag:
self._ai_image_progress_phase = 0
self.ai_image_progress.pack(side=tk.LEFT, padx=(8, 0))
self.ai_image_progress.start(10)
if self._ai_image_progress_job is not None:
self.root.after_cancel(self._ai_image_progress_job)
self._ai_image_progress_job = self.root.after(0, lambda: _update_request_progress(self))
else:
if self._ai_image_progress_job is not None:
self.root.after_cancel(self._ai_image_progress_job)
self._ai_image_progress_job = None
try:
self.ai_image_progress.stop()
self.ai_image_progress.pack_forget()
except Exception: except Exception:
pass pass
def _on_request_done(self, record, exc): def _on_request_done(self, record, exc, req_seq):
# 旧请求回调(例如用户已点击停止后)直接忽略
if req_seq != getattr(self, "_ai_image_active_seq", 0):
return
self._ai_image_active_seq = 0
self._ai_image_cancel_event = None
_set_requesting(self, False) _set_requesting(self, False)
if exc is not None: if exc is not None:
self.ai_image_status_var.set(f"失败: {exc}") self.ai_image_status_var.set(f"失败: {exc}")
@@ -314,17 +466,32 @@ def _on_request_done(self, record, exc):
self.ai_image_status_var.set("完成") self.ai_image_status_var.set("完成")
self.ai_image_input.delete("1.0", tk.END) self.ai_image_input.delete("1.0", tk.END)
reload_ai_image_list(self) reload_ai_image_list(self)
# 定位到新生成项(最新在前)
if record is not None and self.ai_image_records: if record is not None and self.ai_image_records:
for i, r in enumerate(self.ai_image_records): for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
if ridx is None:
continue
r = self.ai_image_records[ridx]
if r.id == record.id: if r.id == record.id:
self.ai_image_listbox.selection_clear(0, tk.END) self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(i) self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(i) self.ai_image_listbox.activate(row)
self.ai_image_listbox.see(row)
_select_record(self, r) _select_record(self, r)
break break
def _stop_request(self):
"""停止当前生成任务(协作取消:屏蔽后续回调并恢复 UI"""
if not getattr(self, "_ai_image_requesting", False):
return
event = getattr(self, "_ai_image_cancel_event", None)
if event is not None:
event.set()
self._ai_image_active_seq = 0
_set_requesting(self, False)
self.ai_image_status_var.set("已停止生成")
def _save_current(self): def _save_current(self):
rec = getattr(self, "ai_image_current", None) rec = getattr(self, "ai_image_current", None)
if rec is None: if rec is None:
@@ -358,53 +525,41 @@ def _delete_current(self):
def _rename_current(self): def _rename_current(self):
"""弹窗让用户修改当前记录的备注名称(即列表显示中 size_tag 之后的部分)。""" """弹窗让用户修改当前记录的展示标题(保存到侧车 ``title`` 字段,原始 prompt 不变)。"""
rec = getattr(self, "ai_image_current", None) rec = getattr(self, "ai_image_current", None)
if rec is None: if rec is None:
messagebox.showinfo("提示", "请先选择一张图片") messagebox.showinfo("提示", "请先选择一张图片")
return return
current_name = rec.prompt or "" current = rec.title or rec.display_name
new_name = simpledialog.askstring( new_name = simpledialog.askstring(
"重命名", "重命名",
"修改备注名称(显示在分辨率标签后面", "修改显示标题(留空可恢复使用原始提示词",
initialvalue=current_name, initialvalue=current,
parent=self.root, parent=self.root,
) )
if new_name is None: # 用户点了取消 if new_name is None: # 取消
return return
new_name = new_name.strip() new_name = new_name.strip()
if not new_name: if new_name == (rec.title or ""):
messagebox.showwarning("提示", "备注名称不能为空")
return
if new_name == current_name:
return return
# 写回 JSON 元数据 if not _svc.update_record_title(rec, new_name):
try: messagebox.showerror("保存失败", "无法更新元数据,请检查文件权限。")
import json
meta_path = os.path.splitext(rec.image_path)[0] + ".json"
meta = {}
if os.path.isfile(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
meta["prompt"] = new_name
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
except Exception as exc:
messagebox.showerror("保存失败", f"无法更新元数据:\n{exc}")
return return
# 同步内存中的记录并刷新列表 target_id = rec.id
rec.prompt = new_name
reload_ai_image_list(self) reload_ai_image_list(self)
# 重新定位到刚才被重命名的图片 # 重新定位
for i, r in enumerate(self.ai_image_records): for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
if r.id == rec.id: if ridx is None:
continue
r = self.ai_image_records[ridx]
if r.id == target_id:
self.ai_image_listbox.selection_clear(0, tk.END) self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(i) self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(i) self.ai_image_listbox.activate(row)
self.ai_image_listbox.see(i) self.ai_image_listbox.see(row)
_select_record(self, r) _select_record(self, r)
break break
@@ -415,14 +570,16 @@ def _rename_current(self):
def _show_list_context_menu(self, event): def _show_list_context_menu(self, event):
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。""" """在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
try: try:
idx = self.ai_image_listbox.nearest(event.y) row = self.ai_image_listbox.nearest(event.y)
except Exception: except Exception:
idx = -1 row = -1
if 0 <= idx < len(self.ai_image_records): row_map = getattr(self, "_ai_image_row_map", None) or []
ridx = row_map[row] if 0 <= row < len(row_map) else None
if ridx is not None and 0 <= ridx < len(self.ai_image_records):
self.ai_image_listbox.selection_clear(0, tk.END) self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(idx) self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(idx) self.ai_image_listbox.activate(row)
_select_record(self, self.ai_image_records[idx]) _select_record(self, self.ai_image_records[ridx])
has_selection = self.ai_image_current is not None has_selection = self.ai_image_current is not None
ucd = getattr(self, "ucd", None) ucd = getattr(self, "ucd", None)

View File

@@ -111,18 +111,6 @@ def create_cct_params_frame(self):
) )
self.recalc_cct_btn.grid_remove() self.recalc_cct_btn.grid_remove()
# 色域重新计算按钮
self.recalc_gamut_btn = ttk.Button(
self.cct_params_frame,
text="应用色域参考并重绘",
command=self.recalculate_gamut,
bootstyle="warning",
)
self.recalc_gamut_btn.grid(
row=4, column=2, columnspan=2, pady=10, padx=5, sticky="ew"
)
self.recalc_gamut_btn.grid_remove()
# 提示文字 # 提示文字
ttk.Label( ttk.Label(
self.cct_params_frame, self.cct_params_frame,
@@ -228,18 +216,6 @@ def create_cct_params_frame(self):
) )
self.sdr_recalc_cct_btn.grid_remove() self.sdr_recalc_cct_btn.grid_remove()
# 色域重新计算按钮SDR
self.sdr_recalc_gamut_btn = ttk.Button(
self.sdr_cct_params_frame,
text="应用色域参考并重绘",
command=self.recalculate_gamut,
bootstyle="warning",
)
self.sdr_recalc_gamut_btn.grid(
row=4, column=2, columnspan=2, pady=10, padx=5, sticky="ew"
)
self.sdr_recalc_gamut_btn.grid_remove()
# 提示文字 # 提示文字
ttk.Label( ttk.Label(
self.sdr_cct_params_frame, self.sdr_cct_params_frame,
@@ -345,18 +321,6 @@ def create_cct_params_frame(self):
) )
self.hdr_recalc_cct_btn.grid_remove() self.hdr_recalc_cct_btn.grid_remove()
# 色域重新计算按钮HDR
self.hdr_recalc_gamut_btn = ttk.Button(
self.hdr_cct_params_frame,
text="应用色域参考并重绘",
command=self.recalculate_gamut,
bootstyle="warning",
)
self.hdr_recalc_gamut_btn.grid(
row=4, column=2, columnspan=2, pady=10, padx=5, sticky="ew"
)
self.hdr_recalc_gamut_btn.grid_remove()
# 提示文字 # 提示文字
ttk.Label( ttk.Label(
self.hdr_cct_params_frame, self.hdr_cct_params_frame,

View File

@@ -136,12 +136,15 @@ def create_custom_template_result_panel(self):
label="单步测试", label="单步测试",
command=self.start_custom_row_single_step, command=self.start_custom_row_single_step,
) )
self.custom_result_menu.add_separator()
# self.custom_result_menu.add_separator() self.custom_result_menu.add_command(
# self.custom_result_menu.add_command( label="生成模板",
# label="单步测试", command=self.export_custom_template_excel,
# command=self.fill_custom_result_test_data, )
# ) self.custom_result_menu.add_command(
label="生成图表",
command=self.export_custom_template_charts,
)
self.custom_result_tree.bind("<Button-3>", self.show_custom_result_context_menu) self.custom_result_tree.bind("<Button-3>", self.show_custom_result_context_menu)
table_container.grid_rowconfigure(0, weight=1) table_container.grid_rowconfigure(0, weight=1)
@@ -181,6 +184,14 @@ def show_custom_result_context_menu(self, event):
1, 1,
state=("normal" if can_single_step else "disabled"), state=("normal" if can_single_step else "disabled"),
) )
self.custom_result_menu.entryconfigure(
3,
state=("normal" if has_rows else "disabled"),
)
self.custom_result_menu.entryconfigure(
4,
state=("normal" if has_rows else "disabled"),
)
self.custom_result_menu.tk_popup(event.x_root, event.y_root) self.custom_result_menu.tk_popup(event.x_root, event.y_root)
finally: finally:
self.custom_result_menu.grab_release() self.custom_result_menu.grab_release()
@@ -614,3 +625,290 @@ def update_custom_button_visibility(self):
# self.status_var.set("已填充 147 行客户模板测试数据") # self.status_var.set("已填充 147 行客户模板测试数据")
# if hasattr(self, "log_gui"): # if hasattr(self, "log_gui"):
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success") # self.log_gui.log("已填充 147 行客户模板测试数据", level="success")
def export_custom_template_excel(self):
"""将客户模板结果表导出为 Excel 文件14 列完整数据)"""
if not hasattr(self, "custom_result_tree"):
return
items = self.custom_result_tree.get_children()
if not items:
messagebox.showinfo("提示", "当前没有可导出的数据")
return
import datetime
from tkinter import filedialog
default_name = (
f"客户模板结果_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
)
save_path = filedialog.asksaveasfilename(
title="保存客户模板 Excel 报告",
defaultextension=".xlsx",
filetypes=[("Excel 文件", "*.xlsx")],
initialfile=default_name,
)
if not save_path:
return
try:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
wb = Workbook()
ws = wb.active
ws.title = "客户模板测试结果"
columns = tuple(self.custom_result_tree["columns"])
num_cols = len(columns)
# 列字母辅助A-N共 14 列,全在单字母范围内)
def col_letter(idx_1based):
return chr(64 + idx_1based)
last_col = col_letter(num_cols)
# ---- 标题行 ----
ws.merge_cells(f"A1:{last_col}1")
ws["A1"] = "客户模板测试结果"
ws["A1"].font = Font(name="微软雅黑", size=16, bold=True, color="FFFFFF")
ws["A1"].fill = PatternFill(
start_color="4472C4", end_color="4472C4", fill_type="solid"
)
ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 35
# 写入测试时间
ws.merge_cells(f"A2:{last_col}2")
ws["A2"] = (
f"测试时间:{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
)
ws["A2"].font = Font(name="微软雅黑", size=10, color="CCCCCC")
ws["A2"].fill = PatternFill(
start_color="2F2F2F", end_color="2F2F2F", fill_type="solid"
)
ws["A2"].alignment = Alignment(horizontal="left", vertical="center")
ws.row_dimensions[2].height = 20
# ---- 表头行 ----
thin = Side(style="thin")
border = Border(left=thin, right=thin, top=thin, bottom=thin)
header_font = Font(name="微软雅黑", size=10, bold=True, color="FFFFFF")
header_fill = PatternFill(
start_color="70AD47", end_color="70AD47", fill_type="solid"
)
header_align = Alignment(
horizontal="center", vertical="center", wrap_text=True
)
for col_idx, col_name in enumerate(columns, start=1):
cell = ws.cell(row=3, column=col_idx, value=col_name)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
cell.border = border
ws.row_dimensions[3].height = 22
# ---- 数据行 ----
data_font = Font(name="微软雅黑", size=10)
data_align = Alignment(horizontal="center", vertical="center")
# 数值列(跳过 Pattern 和 No.)以 4 位小数格式输出
numeric_col_indices = set(range(2, num_cols))
for row_offset, item in enumerate(items):
row_num = 4 + row_offset
values = self.custom_result_tree.item(item, "values")
for col_idx, value in enumerate(values, start=1):
cell = ws.cell(row=row_num, column=col_idx)
cell.font = data_font
cell.alignment = data_align
cell.border = border
# 占位符保持文本,非占位符数值列尝试转为浮点数
if (
col_idx - 1 in numeric_col_indices
and str(value) not in ("---", "--", "")
):
try:
cell.value = float(value)
cell.number_format = "0.0000"
except (ValueError, TypeError):
cell.value = value
else:
cell.value = value
ws.row_dimensions[row_num].height = 20
# ---- 列宽 ----
col_widths = {
"Pattern": 14,
"No.": 8,
"X": 11,
"Y": 11,
"Z": 11,
"x": 10,
"y": 10,
"Lv": 10,
"u'": 10,
"v'": 10,
"Tcp": 12,
"duv": 10,
"\u03bbd/\u03bbc": 12,
"Pe": 10,
}
for col_idx, col_name in enumerate(columns, start=1):
ws.column_dimensions[col_letter(col_idx)].width = col_widths.get(
col_name, 11
)
wb.save(save_path)
if hasattr(self, "status_var"):
self.status_var.set("已导出客户模板 Excel 报告")
if hasattr(self, "log_gui"):
self.log_gui.log(
f"已导出客户模板 Excel 报告: {save_path}", level="success"
)
messagebox.showinfo("成功", f"Excel 报告已保存到:\n{save_path}")
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"导出 Excel 失败: {str(e)}", level="error")
messagebox.showerror("错误", f"导出失败:{str(e)}")
def export_custom_template_charts(self):
"""生成客户模板图表xy 色度散点图 + Lv 亮度曲线图,保存为 PNG"""
if not hasattr(self, "custom_result_tree"):
return
items = self.custom_result_tree.get_children()
if not items:
messagebox.showinfo("提示", "当前没有可绘制的数据")
return
import datetime
from tkinter import filedialog
default_name = (
f"客户模板图表_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
)
save_path = filedialog.asksaveasfilename(
title="保存客户模板图表",
defaultextension=".png",
filetypes=[("PNG 图片", "*.png")],
initialfile=default_name,
)
if not save_path:
return
try:
import matplotlib.pyplot as plt
columns = tuple(self.custom_result_tree["columns"])
col_idx_map = {col: idx for idx, col in enumerate(columns)}
pattern_names, x_vals, y_vals, lv_vals = [], [], [], []
for item in items:
vals = self.custom_result_tree.item(item, "values")
pattern_names.append(str(vals[col_idx_map.get("Pattern", 0)]))
for container, key, fallback in (
(x_vals, "x", 5),
(y_vals, "y", 6),
(lv_vals, "Lv", 7),
):
raw = vals[col_idx_map.get(key, fallback)]
try:
v = float(raw)
container.append(v if np.isfinite(v) and v > -99999998 else None)
except (ValueError, TypeError):
container.append(None)
# ---- 绘图 ----
fig, (ax_xy, ax_lv) = plt.subplots(
1, 2, figsize=(16, 7), facecolor="#1a1a2e"
)
# ── 左图xy 色度散点图 ──
ax_xy.set_facecolor("#0f0f23")
ax_xy.set_xlim(0, 0.8)
ax_xy.set_ylim(0, 0.9)
ax_xy.set_xlabel("x", color="#cccccc", fontsize=11)
ax_xy.set_ylabel("y", color="#cccccc", fontsize=11)
ax_xy.set_title("xy 色度图", color="#ffffff", fontsize=13, fontweight="bold")
ax_xy.tick_params(colors="#aaaaaa", which="both")
for spine in ax_xy.spines.values():
spine.set_color("#444444")
ax_xy.grid(color="#333333", linestyle="--", linewidth=0.5, alpha=0.7)
# D65 白点标注
ax_xy.scatter(
[0.3127], [0.3290],
c="#ffffff", s=100, zorder=5,
marker="+", linewidths=2, label="D65",
)
valid_pairs = [
(x, y, i)
for i, (x, y) in enumerate(zip(x_vals, y_vals))
if x is not None and y is not None
]
if valid_pairs:
xs, ys, idxs = zip(*valid_pairs)
sc = ax_xy.scatter(
xs, ys,
c=idxs,
cmap="plasma",
s=60, zorder=4,
edgecolors="#cccccc", linewidths=0.5, alpha=0.9,
)
cbar = fig.colorbar(sc, ax=ax_xy, pad=0.01)
cbar.set_label("测量序号", color="#cccccc", fontsize=10)
cbar.ax.yaxis.set_tick_params(color="#aaaaaa")
plt.setp(cbar.ax.yaxis.get_ticklabels(), color="#aaaaaa")
ax_xy.legend(
fontsize=9,
facecolor="#2a2a3e",
edgecolor="#555555",
labelcolor="#cccccc",
)
# ── 右图Lv 亮度曲线 ──
ax_lv.set_facecolor("#0f0f23")
ax_lv.set_title(
"Lv 亮度曲线", color="#ffffff", fontsize=13, fontweight="bold"
)
ax_lv.set_xlabel("测量序号", color="#cccccc", fontsize=11)
ax_lv.set_ylabel("Lv (cd/m²)", color="#cccccc", fontsize=11)
ax_lv.tick_params(colors="#aaaaaa", which="both")
for spine in ax_lv.spines.values():
spine.set_color("#444444")
ax_lv.grid(color="#333333", linestyle="--", linewidth=0.5, alpha=0.7)
valid_lv = [(i + 1, lv) for i, lv in enumerate(lv_vals) if lv is not None]
if valid_lv:
seq, lvs = zip(*valid_lv)
ax_lv.plot(
seq, lvs,
color="#4fc3f7", linewidth=1.5,
marker="o", markersize=4,
markerfacecolor="#ff8c00", markeredgecolor="#ff8c00",
)
ax_lv.fill_between(seq, lvs, alpha=0.15, color="#4fc3f7")
plt.tight_layout(pad=2.0)
fig.savefig(save_path, dpi=200, bbox_inches="tight",
facecolor=fig.get_facecolor())
plt.close(fig)
if hasattr(self, "status_var"):
self.status_var.set("已生成客户模板图表")
if hasattr(self, "log_gui"):
self.log_gui.log(
f"已生成客户模板图表: {save_path}", level="success"
)
messagebox.showinfo("成功", f"图表已保存到:\n{save_path}")
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"生成图表失败: {str(e)}", level="error")
messagebox.showerror("错误", f"生成图表失败:{str(e)}")

View File

@@ -435,6 +435,24 @@ def create_test_type_frame(self):
) )
self.ai_image_btn.pack(fill=tk.X, padx=0, pady=1) self.ai_image_btn.pack(fill=tk.X, padx=0, pady=1)
# self.single_step_btn = ttk.Button(
# self.sidebar_frame,
# text="单步调试",
# style="Sidebar.TButton",
# command=self.toggle_single_step_panel,
# takefocus=False,
# )
# self.single_step_btn.pack(fill=tk.X, padx=0, pady=1)
self.pantone_baseline_btn = ttk.Button(
self.sidebar_frame,
text="Pantone认证摸底测试",
style="Sidebar.TButton",
command=self.toggle_pantone_baseline_panel,
takefocus=False,
)
self.pantone_baseline_btn.pack(fill=tk.X, padx=0, pady=1)
# 注册面板按钮 # 注册面板按钮
if hasattr(self, "panels"): if hasattr(self, "panels"):
if "log" in self.panels: if "log" in self.panels:
@@ -443,6 +461,10 @@ def create_test_type_frame(self):
self.panels["local_dimming"]["button"] = self.local_dimming_btn self.panels["local_dimming"]["button"] = self.local_dimming_btn
if "ai_image" in self.panels: if "ai_image" in self.panels:
self.panels["ai_image"]["button"] = self.ai_image_btn self.panels["ai_image"]["button"] = self.ai_image_btn
if "single_step" in self.panels:
self.panels["single_step"]["button"] = self.single_step_btn
if "pantone_baseline" in self.panels:
self.panels["pantone_baseline"]["button"] = self.pantone_baseline_btn
def update_config_info_display(self): def update_config_info_display(self):

View File

@@ -0,0 +1,547 @@
"""Pantone 认证摸底测试面板。"""
from __future__ import annotations
import datetime
import os
import threading
import tkinter as tk
from tkinter import filedialog, messagebox
import ttkbootstrap as ttk
_TEMPLATE_FILE = "pantone\xa02670\xa0colors.xlsx"
def create_pantone_baseline_panel(self):
"""创建 Pantone 认证摸底测试面板。"""
frame = ttk.Frame(self.content_frame)
self.pantone_baseline_frame = frame
self.pantone_baseline_visible = False
self.pantone_patterns = []
self.pantone_results = []
self._pantone_control_event = None
self._pantone_running = False
self._pantone_paused = False
self._pantone_pause_requested = False
self._pantone_stop_requested = False
self._pantone_next_index = 0
self._pantone_target_count = 0
root = ttk.Frame(frame, padding=10)
root.pack(fill=tk.BOTH, expand=True)
title_row = ttk.Frame(root)
title_row.pack(fill=tk.X, pady=(0, 8))
ttk.Label(
title_row,
text="Pantone认证摸底测试",
font=("微软雅黑", 14, "bold"),
).pack(side=tk.LEFT)
self.pantone_status_var = tk.StringVar(value="未开始")
self.pantone_progress_var = tk.StringVar(value="0 / 0")
self.pantone_settle_var = tk.StringVar(value="0.3")
config_row = ttk.LabelFrame(root, text="任务配置", padding=8)
config_row.pack(fill=tk.X)
ttk.Label(config_row, text=f"Pattern来源: settings/{_TEMPLATE_FILE}").pack(
side=tk.LEFT
)
ttk.Label(config_row, text="稳定等待(s):").pack(side=tk.LEFT, padx=(14, 4))
ttk.Entry(config_row, textvariable=self.pantone_settle_var, width=8).pack(
side=tk.LEFT
)
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(
side=tk.RIGHT
)
btn_row = ttk.Frame(root)
btn_row.pack(fill=tk.X, pady=(8, 8))
self.pantone_start_btn = ttk.Button(
btn_row,
text="开始",
bootstyle="primary",
command=lambda: _start_pantone_baseline(self),
)
self.pantone_start_btn.pack(side=tk.LEFT, padx=(0, 6))
self.pantone_pause_btn = ttk.Button(
btn_row,
text="暂停",
bootstyle="warning-outline",
command=lambda: _pause_pantone_baseline(self),
state=tk.DISABLED,
)
self.pantone_pause_btn.pack(side=tk.LEFT, padx=(0, 6))
self.pantone_resume_btn = ttk.Button(
btn_row,
text="继续",
bootstyle="info-outline",
command=lambda: _resume_pantone_baseline(self),
state=tk.DISABLED,
)
self.pantone_resume_btn.pack(side=tk.LEFT, padx=(0, 6))
self.pantone_end_btn = ttk.Button(
btn_row,
text="结束",
bootstyle="danger-outline",
command=lambda: _end_pantone_baseline(self),
state=tk.DISABLED,
)
self.pantone_end_btn.pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(
btn_row,
text="清空结果",
bootstyle="secondary-outline",
command=lambda: _clear_results(self),
).pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(
btn_row,
text="另存为模板",
bootstyle="success",
command=lambda: _save_as_template(self),
).pack(side=tk.LEFT)
table_frame = ttk.LabelFrame(root, text="测试结果R,G,B,L,x,y", padding=8)
table_frame.pack(fill=tk.BOTH, expand=True)
columns = ("idx", "r", "g", "b", "l", "x", "y", "time")
self.pantone_tree = ttk.Treeview(
table_frame,
columns=columns,
show="headings",
height=18,
)
headings = {
"idx": "序号",
"r": "R",
"g": "G",
"b": "B",
"l": "L",
"x": "x",
"y": "y",
"time": "时间",
}
widths = {
"idx": 70,
"r": 70,
"g": 70,
"b": 70,
"l": 90,
"x": 90,
"y": 90,
"time": 110,
}
for col in columns:
self.pantone_tree.heading(col, text=headings[col])
self.pantone_tree.column(col, width=widths[col], anchor=tk.CENTER)
self.pantone_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
scrollbar = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.pantone_tree.yview)
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
self.pantone_tree.configure(yscrollcommand=scrollbar.set)
self.register_panel("pantone_baseline", frame, None, "pantone_baseline_visible")
_set_button_states(self)
def toggle_pantone_baseline_panel(self):
"""切换 Pantone 认证摸底测试面板。"""
self.show_panel("pantone_baseline")
def _load_patterns(self):
path = os.path.join("settings", _TEMPLATE_FILE)
if not os.path.isfile(path):
raise FileNotFoundError(f"未找到模板文件: {path}")
from openpyxl import load_workbook
patterns = []
wb = load_workbook(path, read_only=True, data_only=True)
ws = wb.active
try:
for row in ws.iter_rows(min_row=2, values_only=True):
if not row:
continue
try:
r = int(row[0]) if row[0] is not None else None
g = int(row[1]) if len(row) > 1 and row[1] is not None else None
b = int(row[2]) if len(row) > 2 and row[2] is not None else None
except Exception:
continue
if r is None or g is None or b is None:
continue
if min(r, g, b) < 0 or max(r, g, b) > 255:
continue
patterns.append((r, g, b))
finally:
wb.close()
if not patterns:
raise RuntimeError("模板中未找到有效 RGB 列表(需包含 R/G/B 三列)")
return patterns
def _start_pantone_baseline(self):
if self._pantone_running:
messagebox.showinfo("提示", "Pantone 任务正在执行")
return
if not getattr(self, "ucd", None) or not self.ucd.status:
messagebox.showwarning("警告", "请先连接 UCD323")
return
if not getattr(self, "ca", None):
messagebox.showwarning("警告", "请先连接 CA410")
return
try:
settle = float(self.pantone_settle_var.get().strip())
if settle < 0:
raise ValueError()
except Exception:
messagebox.showerror("参数错误", "稳定等待时间必须是非负数字")
return
try:
self.pantone_patterns = _load_patterns(self)
self._pantone_target_count = len(self.pantone_patterns)
except Exception as exc:
messagebox.showerror("读取失败", str(exc))
return
if self.pantone_results:
if not messagebox.askyesno("确认开始", "开始将清空当前内存中的测试结果,是否继续?"):
return
self._pantone_running = True
self._pantone_paused = False
self._pantone_pause_requested = False
self._pantone_stop_requested = False
self._pantone_control_event = threading.Event()
self._pantone_next_index = 0
self.pantone_status_var.set("执行中")
self.pantone_progress_var.set(f"0 / {self._pantone_target_count}")
self.pantone_results = []
for item in self.pantone_tree.get_children():
self.pantone_tree.delete(item)
_set_button_states(self)
_launch_worker(self, start_index=0, settle=settle)
def _resume_pantone_baseline(self):
if self._pantone_running:
messagebox.showinfo("提示", "Pantone 任务正在执行")
return
if not self._pantone_paused:
messagebox.showinfo("提示", "当前没有可继续的暂停任务")
return
if not getattr(self, "ucd", None) or not self.ucd.status:
messagebox.showwarning("警告", "请先连接 UCD323")
return
if not getattr(self, "ca", None):
messagebox.showwarning("警告", "请先连接 CA410")
return
try:
settle = float(self.pantone_settle_var.get().strip())
if settle < 0:
raise ValueError()
except Exception:
messagebox.showerror("参数错误", "稳定等待时间必须是非负数字")
return
try:
self.pantone_patterns = _load_patterns(self)
self._pantone_target_count = len(self.pantone_patterns)
except Exception as exc:
messagebox.showerror("读取失败", str(exc))
return
if self._pantone_next_index >= self._pantone_target_count:
messagebox.showinfo("提示", "任务已完成,无需继续")
return
self._pantone_running = True
self._pantone_paused = False
self._pantone_pause_requested = False
self._pantone_stop_requested = False
self._pantone_control_event = threading.Event()
self.pantone_status_var.set("执行中")
_set_button_states(self)
_launch_worker(self, start_index=self._pantone_next_index, settle=settle)
def _launch_worker(self, start_index, settle):
total = self._pantone_target_count or len(self.pantone_patterns)
def worker():
end_state = "completed"
try:
src = self.pantone_patterns
src_count = len(src)
rgb_session = self.pattern_service.prepare_session("rgb", log_details=False)
self._dispatch_ui(
self.log_gui.log,
f"Pantone 认证摸底启动: source={src_count}, target={total}, start={start_index + 1}",
"info",
)
for i in range(start_index, total):
if self._pantone_stop_requested:
end_state = "stopped"
break
if self._pantone_pause_requested:
end_state = "paused"
break
r, g, b = src[i % src_count]
try:
self.pattern_service.send_rgb((r, g, b), session=rgb_session)
except Exception as exc:
raise RuntimeError(f"{i + 1} 组发送失败: {exc}") from exc
if settle > 0 and self._pantone_control_event is not None:
self._pantone_control_event.clear()
self._pantone_control_event.wait(timeout=settle)
if self._pantone_stop_requested:
end_state = "stopped"
break
if self._pantone_pause_requested:
end_state = "paused"
break
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
if lv is None:
raise RuntimeError(f"{i + 1} 组 CA410 采集失败")
record = {
"idx": i + 1,
"r": r,
"g": g,
"b": b,
"l": float(lv),
"x": float(x),
"y": float(y),
"time": datetime.datetime.now().strftime("%H:%M:%S"),
}
self.pantone_results.append(record)
self._pantone_next_index = i + 1
self._dispatch_ui(_append_result_row, self, record, total)
if end_state == "paused":
self._pantone_paused = True
self._dispatch_ui(self.pantone_status_var.set, "已暂停")
self._dispatch_ui(
self.log_gui.log,
f"Pantone 任务已暂停,断点 {self._pantone_next_index} / {total}",
"warning",
)
elif end_state == "stopped":
self._pantone_paused = False
self._pantone_next_index = 0
self._dispatch_ui(self.pantone_status_var.set, "已结束")
self._dispatch_ui(
self.log_gui.log,
f"Pantone 任务已结束,保留 {len(self.pantone_results)} 条内存结果",
"warning",
)
else:
self._pantone_paused = False
self._pantone_next_index = total
self._dispatch_ui(self.pantone_status_var.set, "已完成")
self._dispatch_ui(
self.log_gui.log,
f"Pantone 任务完成,共 {len(self.pantone_results)} 条数据",
"success",
)
try:
auto_path = _auto_save_template(self)
self._dispatch_ui(
self.log_gui.log,
f"Pantone 模板已自动保存: {auto_path}",
"success",
)
except Exception as exc:
self._dispatch_ui(
self.log_gui.log,
f"Pantone 自动保存模板失败: {exc}",
"error",
)
except Exception as exc:
self._pantone_paused = False
self._dispatch_ui(self.pantone_status_var.set, "执行失败")
self._dispatch_ui(self.log_gui.log, f"Pantone 任务失败: {exc}", "error")
self._dispatch_ui(messagebox.showerror, "执行失败", str(exc))
finally:
self._pantone_running = False
self._pantone_pause_requested = False
self._pantone_stop_requested = False
self._dispatch_ui(_set_button_states, self)
threading.Thread(target=worker, daemon=True).start()
def _append_result_row(self, record, total):
self.pantone_tree.insert(
"",
tk.END,
values=(
record["idx"],
record["r"],
record["g"],
record["b"],
f"{record['l']:.2f}",
f"{record['x']:.4f}",
f"{record['y']:.4f}",
record["time"],
),
)
self.pantone_progress_var.set(f"{record['idx']} / {total}")
# 每次插入都滚到末尾,便于观察采集进度。
children = self.pantone_tree.get_children()
if children:
self.pantone_tree.see(children[-1])
def _pause_pantone_baseline(self):
if not self._pantone_running:
messagebox.showinfo("提示", "当前没有运行中的任务")
return
self._pantone_pause_requested = True
self.pantone_status_var.set("暂停中...")
if self._pantone_control_event is not None:
self._pantone_control_event.set()
def _end_pantone_baseline(self):
if self._pantone_running:
self._pantone_stop_requested = True
self.pantone_status_var.set("结束中...")
if self._pantone_control_event is not None:
self._pantone_control_event.set()
return
# 非运行态下点击“结束”:清除断点,保留当前内存结果。
self._pantone_paused = False
self._pantone_next_index = 0
self.pantone_status_var.set("已结束")
_set_button_states(self)
def _clear_results(self):
if self._pantone_running:
messagebox.showinfo("提示", "任务执行中,无法清空")
return
self.pantone_results = []
self._pantone_paused = False
self._pantone_next_index = 0
for item in self.pantone_tree.get_children():
self.pantone_tree.delete(item)
self._pantone_target_count = 0
self.pantone_progress_var.set("0 / 0")
self.pantone_status_var.set("结果已清空")
_set_button_states(self)
def _set_button_states(self):
if self._pantone_running:
self.pantone_start_btn.configure(state=tk.DISABLED)
self.pantone_pause_btn.configure(state=tk.NORMAL)
self.pantone_resume_btn.configure(state=tk.DISABLED)
self.pantone_end_btn.configure(state=tk.NORMAL)
return
self.pantone_start_btn.configure(state=tk.NORMAL)
self.pantone_pause_btn.configure(state=tk.DISABLED)
self.pantone_end_btn.configure(state=tk.NORMAL if (self._pantone_paused or self.pantone_results) else tk.DISABLED)
can_resume = self._pantone_paused and self._pantone_next_index < self._pantone_target_count
self.pantone_resume_btn.configure(state=tk.NORMAL if can_resume else tk.DISABLED)
def _save_as_template(self):
if not self.pantone_results:
messagebox.showinfo("提示", "暂无可导出的结果")
return
default_name = _TEMPLATE_FILE.replace("\xa0", " ")
path = filedialog.asksaveasfilename(
title="另存为 Pantone 模板",
defaultextension=".xlsx",
initialfile=default_name,
filetypes=[("Excel 文件", "*.xlsx")],
)
if not path:
return
try:
_write_template_xlsx(self, path)
self.log_gui.log(f"Pantone 模板已保存: {path}", level="success")
self.pantone_status_var.set(f"已保存: {os.path.basename(path)}")
except Exception as exc:
messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}")
def _resolve_results_dir(self):
if getattr(self, "config_file", None):
root_dir = os.path.dirname(os.path.dirname(self.config_file))
else:
root_dir = os.path.dirname(
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
)
results_dir = os.path.join(root_dir, "results")
os.makedirs(results_dir, exist_ok=True)
return results_dir
def _auto_save_template(self):
results_dir = _resolve_results_dir(self)
target_count = len(self.pantone_results)
filename = (
f"pantone_{target_count}_baseline_"
f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
)
path = os.path.join(results_dir, filename)
_write_template_xlsx(self, path)
return path
def _write_template_xlsx(self, path):
# 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。
template_path = os.path.join("settings", _TEMPLATE_FILE)
from openpyxl import load_workbook, Workbook
if os.path.isfile(template_path):
wb = load_workbook(template_path)
ws = wb.active
else:
wb = Workbook()
ws = wb.active
ws.title = "Sheet1"
ws.cell(row=1, column=1, value="R")
ws.cell(row=1, column=2, value="G")
ws.cell(row=1, column=3, value="B")
ws.cell(row=1, column=4, value="L")
ws.cell(row=1, column=5, value="x")
ws.cell(row=1, column=6, value="y")
# 清空旧数据
max_row = max(ws.max_row, 2)
for row in range(2, max_row + 1):
for col in range(1, 7):
ws.cell(row=row, column=col, value=None)
for idx, item in enumerate(self.pantone_results, start=2):
ws.cell(row=idx, column=1, value=int(item["r"]))
ws.cell(row=idx, column=2, value=int(item["g"]))
ws.cell(row=idx, column=3, value=int(item["b"]))
ws.cell(row=idx, column=4, value=float(item["l"]))
ws.cell(row=idx, column=5, value=float(item["x"]))
ws.cell(row=idx, column=6, value=float(item["y"]))
wb.save(path)

View File

@@ -0,0 +1,551 @@
"""单步调试面板。"""
from __future__ import annotations
import csv
import datetime
import os
import tempfile
import threading
import tkinter as tk
from tkinter import filedialog, messagebox
import ttkbootstrap as ttk
from PIL import Image
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
_DEFAULT_SAMPLES = [
{"name": "Red Sample", "hex": "#D22630", "x": "0.6400", "y": "0.3300"},
{"name": "Green Sample", "hex": "#00A651", "x": "0.3000", "y": "0.6000"},
{"name": "Blue Sample", "hex": "#21409A", "x": "0.1500", "y": "0.0600"},
{"name": "Orange Sample", "hex": "#FF6A13", "x": "0.5500", "y": "0.4000"},
{"name": "Violet Sample", "hex": "#6A0DAD", "x": "0.2700", "y": "0.1400"},
{"name": "Gray Sample", "hex": "#8A8D8F", "x": "0.3127", "y": "0.3290"},
]
def create_single_step_panel(self):
"""创建单步调试面板。"""
frame = ttk.Frame(self.content_frame)
self.single_step_frame = frame
self.single_step_visible = False
self.single_step_samples = []
self.single_step_results = []
self.single_step_current_index = None
self.single_step_current_image_path = None
root = ttk.Frame(frame, padding=10)
root.pack(fill=tk.BOTH, expand=True)
root.columnconfigure(0, weight=0)
root.columnconfigure(1, weight=1)
root.rowconfigure(1, weight=1)
title_row = ttk.Frame(root)
title_row.grid(row=0, column=0, columnspan=2, sticky=tk.EW, pady=(0, 10))
ttk.Label(
title_row,
text="单步调试",
font=("微软雅黑", 14, "bold"),
).pack(side=tk.LEFT)
ttk.Label(
title_row,
text="录入目标色块,发送纯色色块并采集 xyY / ΔE2000。",
foreground="#666",
).pack(side=tk.LEFT, padx=(12, 0))
left = ttk.LabelFrame(root, text="样本列表", padding=8)
left.grid(row=1, column=0, sticky=tk.NS, padx=(0, 10))
left.grid_propagate(False)
left.configure(width=340)
self.single_step_listbox = tk.Listbox(
left,
width=34,
activestyle="none",
font=("微软雅黑", 9),
highlightthickness=1,
highlightbackground="#d8d8d8",
highlightcolor="#4a90e2",
selectbackground="#2b6cb0",
selectforeground="#ffffff",
)
self.single_step_listbox.pack(fill=tk.BOTH, expand=True)
self.single_step_listbox.bind(
"<<ListboxSelect>>", lambda e: _on_sample_select(self)
)
list_btn_row = ttk.Frame(left)
list_btn_row.pack(fill=tk.X, pady=(8, 0))
ttk.Button(
list_btn_row,
text="导入 CSV",
bootstyle="secondary-outline",
command=lambda: _import_samples_csv(self),
).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
list_btn_row,
text="载入示例",
bootstyle="secondary-outline",
command=lambda: _load_default_samples(self),
).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
list_btn_row,
text="删除",
bootstyle="danger-outline",
command=lambda: _delete_current_sample(self),
).pack(side=tk.LEFT)
right = ttk.Frame(root)
right.grid(row=1, column=1, sticky=tk.NSEW)
form_frame = ttk.LabelFrame(right, text="样本配置", padding=8)
form_frame.pack(fill=tk.X)
for column in range(6):
form_frame.columnconfigure(column, weight=1 if column in (1, 3, 5) else 0)
self.single_step_name_var = tk.StringVar()
self.single_step_hex_var = tk.StringVar(value="#FFFFFF")
self.single_step_target_x_var = tk.StringVar()
self.single_step_target_y_var = tk.StringVar()
self.single_step_measured_x_var = tk.StringVar()
self.single_step_measured_y_var = tk.StringVar()
self.single_step_measured_lv_var = tk.StringVar()
self.single_step_status_var = tk.StringVar(value="未选择样本")
ttk.Label(form_frame, text="名称").grid(row=0, column=0, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_name_var).grid(
row=0, column=1, sticky=tk.EW, padx=(0, 8), pady=4
)
ttk.Label(form_frame, text="HEX").grid(row=0, column=2, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_hex_var, width=12).grid(
row=0, column=3, sticky=tk.EW, padx=(0, 8), pady=4
)
ttk.Label(form_frame, text="目标 x").grid(row=0, column=4, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_target_x_var, width=10).grid(
row=0, column=5, sticky=tk.EW, pady=4
)
ttk.Label(form_frame, text="目标 y").grid(row=1, column=0, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_target_y_var, width=10).grid(
row=1, column=1, sticky=tk.EW, padx=(0, 8), pady=4
)
ttk.Label(form_frame, text="实测 x").grid(row=1, column=2, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_measured_x_var, width=10).grid(
row=1, column=3, sticky=tk.EW, padx=(0, 8), pady=4
)
ttk.Label(form_frame, text="实测 y").grid(row=1, column=4, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_measured_y_var, width=10).grid(
row=1, column=5, sticky=tk.EW, pady=4
)
ttk.Label(form_frame, text="实测 Lv").grid(row=2, column=0, sticky=tk.W, pady=4)
ttk.Entry(form_frame, textvariable=self.single_step_measured_lv_var, width=10).grid(
row=2, column=1, sticky=tk.EW, padx=(0, 8), pady=4
)
ttk.Label(
form_frame,
textvariable=self.single_step_status_var,
foreground="#666",
).grid(row=2, column=2, columnspan=4, sticky=tk.W, pady=4)
action_row = ttk.Frame(form_frame)
action_row.grid(row=3, column=0, columnspan=6, sticky=tk.EW, pady=(6, 0))
ttk.Button(
action_row,
text="新增 / 更新样本",
bootstyle="primary",
command=lambda: _upsert_sample(self),
).pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(
action_row,
text="发送当前色块",
bootstyle="info-outline",
command=lambda: _send_current_patch(self),
).pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(
action_row,
text="CA410 采集",
bootstyle="success-outline",
command=lambda: _measure_current_sample(self),
).pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(
action_row,
text="记录结果",
bootstyle="warning-outline",
command=lambda: _commit_result(self),
).pack(side=tk.LEFT)
result_frame = ttk.LabelFrame(right, text="调试结果", padding=8)
result_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
columns = (
"name",
"hex",
"target_x",
"target_y",
"measured_x",
"measured_y",
"lv",
"delta_e",
"time",
)
self.single_step_result_tree = ttk.Treeview(
result_frame,
columns=columns,
show="headings",
height=12,
)
headings = {
"name": "名称",
"hex": "HEX",
"target_x": "目标 x",
"target_y": "目标 y",
"measured_x": "实测 x",
"measured_y": "实测 y",
"lv": "Lv",
"delta_e": "ΔE2000",
"time": "时间",
}
widths = {
"name": 130,
"hex": 90,
"target_x": 80,
"target_y": 80,
"measured_x": 80,
"measured_y": 80,
"lv": 80,
"delta_e": 90,
"time": 120,
}
for column in columns:
self.single_step_result_tree.heading(column, text=headings[column])
self.single_step_result_tree.column(
column, width=widths[column], anchor=tk.CENTER
)
self.single_step_result_tree.pack(fill=tk.BOTH, expand=True)
bottom_row = ttk.Frame(right)
bottom_row.pack(fill=tk.X, pady=(8, 0))
ttk.Button(
bottom_row,
text="导出结果 CSV",
bootstyle="success",
command=lambda: _export_results_csv(self),
).pack(side=tk.LEFT, padx=(0, 6))
ttk.Button(
bottom_row,
text="清空结果",
bootstyle="danger-outline",
command=lambda: _clear_results(self),
).pack(side=tk.LEFT)
self.register_panel("single_step", frame, None, "single_step_visible")
_load_default_samples(self)
def toggle_single_step_panel(self):
"""切换单步调试面板。"""
self.show_panel("single_step")
def _load_default_samples(self):
self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES]
_refresh_sample_list(self, select_index=0 if self.single_step_samples else None)
self.single_step_status_var.set(
f"已载入 {len(self.single_step_samples)} 个示例样本"
)
def _refresh_sample_list(self, select_index=None):
self.single_step_listbox.delete(0, tk.END)
for sample in self.single_step_samples:
self.single_step_listbox.insert(
tk.END,
f"{sample['name']} {sample['hex']} ({sample['x']}, {sample['y']})",
)
if select_index is not None and 0 <= select_index < len(self.single_step_samples):
self.single_step_listbox.selection_clear(0, tk.END)
self.single_step_listbox.selection_set(select_index)
self.single_step_listbox.activate(select_index)
_select_sample(self, select_index)
elif not self.single_step_samples:
self.single_step_current_index = None
self.single_step_name_var.set("")
self.single_step_hex_var.set("#FFFFFF")
self.single_step_target_x_var.set("")
self.single_step_target_y_var.set("")
self.single_step_status_var.set("样本列表为空")
def _on_sample_select(self):
selection = self.single_step_listbox.curselection()
if not selection:
return
_select_sample(self, selection[0])
def _select_sample(self, index):
sample = self.single_step_samples[index]
self.single_step_current_index = index
self.single_step_name_var.set(sample["name"])
self.single_step_hex_var.set(sample["hex"])
self.single_step_target_x_var.set(sample["x"])
self.single_step_target_y_var.set(sample["y"])
self.single_step_status_var.set(f"当前样本: {sample['name']}")
def _import_samples_csv(self):
path = filedialog.askopenfilename(
title="选择单步调试样本 CSV",
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")],
)
if not path:
return
samples = []
try:
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
reader = csv.DictReader(fp)
for row in reader:
name = (row.get("name") or row.get("sample") or "").strip()
hex_value = (row.get("hex") or row.get("rgb") or "").strip()
target_x = (row.get("x") or "").strip()
target_y = (row.get("y") or "").strip()
if not name or not hex_value or not target_x or not target_y:
continue
samples.append(
{
"name": name,
"hex": _normalize_hex(hex_value),
"x": target_x,
"y": target_y,
}
)
except Exception as exc:
messagebox.showerror("导入失败", f"读取 CSV 失败: {exc}")
return
if not samples:
messagebox.showwarning("导入失败", "CSV 中没有有效样本,要求列包含 name/hex/x/y")
return
self.single_step_samples = samples
_refresh_sample_list(self, select_index=0)
self.log_gui.log(f"单步调试样本已导入: {len(samples)}", level="success")
def _delete_current_sample(self):
if self.single_step_current_index is None:
return
removed = self.single_step_samples.pop(self.single_step_current_index)
next_index = min(self.single_step_current_index, len(self.single_step_samples) - 1)
_refresh_sample_list(self, select_index=next_index if next_index >= 0 else None)
self.single_step_status_var.set(f"已删除样本: {removed['name']}")
def _upsert_sample(self):
try:
sample = {
"name": self.single_step_name_var.get().strip(),
"hex": _normalize_hex(self.single_step_hex_var.get()),
"x": _format_float(self.single_step_target_x_var.get()),
"y": _format_float(self.single_step_target_y_var.get()),
}
except ValueError as exc:
messagebox.showerror("参数错误", str(exc))
return
if not sample["name"]:
messagebox.showwarning("提示", "请输入样本名称")
return
if self.single_step_current_index is None:
self.single_step_samples.append(sample)
select_index = len(self.single_step_samples) - 1
self.single_step_status_var.set(f"已新增样本: {sample['name']}")
else:
self.single_step_samples[self.single_step_current_index] = sample
select_index = self.single_step_current_index
self.single_step_status_var.set(f"已更新样本: {sample['name']}")
_refresh_sample_list(self, select_index=select_index)
def _normalize_hex(value):
text = (value or "").strip().upper()
if not text:
raise ValueError("HEX 不能为空")
if not text.startswith("#"):
text = "#" + text
if len(text) != 7 or any(ch not in "#0123456789ABCDEF" for ch in text):
raise ValueError("HEX 格式应为 #RRGGBB")
return text
def _format_float(value):
try:
number = float(str(value).strip())
except Exception as exc:
raise ValueError("xy 值必须是数字") from exc
return f"{number:.4f}"
def _build_color_patch(self, hex_value):
if not getattr(self, "ucd", None) or not self.ucd.status:
raise RuntimeError("请先连接 UCD323 设备")
width, height = get_current_resolution(self.ucd)
rgb = tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5))
temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches")
os.makedirs(temp_dir, exist_ok=True)
file_path = os.path.join(
temp_dir, f"single_step_{hex_value[1:]}_{width}x{height}.png"
)
Image.new("RGB", (width, height), rgb).save(file_path, format="PNG")
return file_path
def _send_current_patch(self):
if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本")
return
sample = self.single_step_samples[self.single_step_current_index]
def worker():
try:
image_path = _build_color_patch(self, sample["hex"])
ok = send_image_pattern(self.ucd, image_path)
if not ok:
raise RuntimeError("UCD323 发送失败")
self.single_step_current_image_path = image_path
self._dispatch_ui(
self.single_step_status_var.set,
f"已发送色块: {sample['name']} {sample['hex']}",
)
self._dispatch_ui(
self.log_gui.log,
f"单步调试色块已发送: {sample['name']} {sample['hex']}",
"success",
)
except Exception as exc:
self._dispatch_ui(self.single_step_status_var.set, f"发送失败: {exc}")
self._dispatch_ui(self.log_gui.log, f"单步调试色块发送失败: {exc}", "error")
threading.Thread(target=worker, daemon=True).start()
def _measure_current_sample(self):
if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本")
return
if not getattr(self, "ca", None):
messagebox.showwarning("警告", "请先连接 CA410 色度计")
return
def worker():
try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
if lv is None:
raise RuntimeError("CA410 未返回有效亮度")
self._dispatch_ui(self.single_step_measured_x_var.set, f"{x:.4f}")
self._dispatch_ui(self.single_step_measured_y_var.set, f"{y:.4f}")
self._dispatch_ui(self.single_step_measured_lv_var.set, f"{lv:.2f}")
self._dispatch_ui(self.single_step_status_var.set, "采集完成,可记录结果")
self._dispatch_ui(
self.log_gui.log,
f"单步调试采集完成: x={x:.4f}, y={y:.4f}, Lv={lv:.2f}",
"success",
)
except Exception as exc:
self._dispatch_ui(self.single_step_status_var.set, f"采集失败: {exc}")
self._dispatch_ui(self.log_gui.log, f"单步调试采集失败: {exc}", "error")
threading.Thread(target=worker, daemon=True).start()
def _commit_result(self):
if self.single_step_current_index is None:
messagebox.showinfo("提示", "请先选择一个样本")
return
sample = self.single_step_samples[self.single_step_current_index]
try:
measured_x = float(self.single_step_measured_x_var.get().strip())
measured_y = float(self.single_step_measured_y_var.get().strip())
measured_lv = float(self.single_step_measured_lv_var.get().strip())
target_x = float(sample["x"])
target_y = float(sample["y"])
delta_e = self.calculate_delta_e_2000(
measured_x, measured_y, measured_lv, target_x, target_y
)
except Exception as exc:
messagebox.showerror("记录失败", f"请先准备完整的目标值与实测值: {exc}")
return
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
record = {
"name": sample["name"],
"hex": sample["hex"],
"target_x": f"{target_x:.4f}",
"target_y": f"{target_y:.4f}",
"measured_x": f"{measured_x:.4f}",
"measured_y": f"{measured_y:.4f}",
"lv": f"{measured_lv:.2f}",
"delta_e": f"{delta_e:.3f}",
"time": timestamp,
}
self.single_step_results.append(record)
self.single_step_result_tree.insert(
"",
tk.END,
values=tuple(
record[key]
for key in (
"name",
"hex",
"target_x",
"target_y",
"measured_x",
"measured_y",
"lv",
"delta_e",
"time",
)
),
)
self.single_step_status_var.set(f"已记录结果ΔE2000={record['delta_e']}")
def _clear_results(self):
self.single_step_results = []
for item in self.single_step_result_tree.get_children():
self.single_step_result_tree.delete(item)
self.single_step_status_var.set("结果已清空")
def _export_results_csv(self):
if not self.single_step_results:
messagebox.showinfo("提示", "暂无可导出的调试结果")
return
path = filedialog.asksaveasfilename(
title="导出单步调试结果",
defaultextension=".csv",
initialfile=f"single_step_debug_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
filetypes=[("CSV 文件", "*.csv")],
)
if not path:
return
fieldnames = [
"name",
"hex",
"target_x",
"target_y",
"measured_x",
"measured_y",
"lv",
"delta_e",
"time",
]
try:
with open(path, "w", encoding="utf-8-sig", newline="") as fp:
writer = csv.DictWriter(fp, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(self.single_step_results)
self.log_gui.log(f"单步调试结果已导出: {path}", level="success")
self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}")
except Exception as exc:
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")

View File

@@ -768,28 +768,25 @@ class PQDebugPanel:
# 禁用按钮 # 禁用按钮
self._disable_test_button(test_type, test_item) self._disable_test_button(test_type, test_item)
# 根据测试类型设置信号格式
self._setup_signal_format(test_type)
# 获取图案索引并发送 # 获取图案索引并发送
if test_item in ["gamma", "eotf"]: if test_item in ["gamma", "eotf"]:
pattern_index = self.get_gray_index(selected) pattern_index = self.get_gray_index(selected)
self.app.config.set_current_pattern("gray") pattern_mode = "gray"
elif test_item == "accuracy": elif test_item == "accuracy":
pattern_index = self.get_color_index(selected) pattern_index = self.get_color_index(selected)
self.app.config.set_current_pattern("accuracy") pattern_mode = "accuracy"
elif test_item == "rgb": elif test_item == "rgb":
pattern_index = self.get_rgb_index(selected) pattern_index = self.get_rgb_index(selected)
self.app.config.set_current_pattern("rgb") pattern_mode = "rgb"
else:
raise ValueError(f"不支持的测试项目: {test_item}")
# 设置图案 session = self.app.pattern_service.prepare_session(
self.app.ucd.set_ucd_params(self.app.config) pattern_mode,
test_type=test_type,
# 跳转到目标图案 log_details=False,
for i in range(pattern_index + 1): )
self.app.ucd.set_next_pattern() self.app.pattern_service.send_session_pattern(session, pattern_index)
self.app.ucd.run()
time.sleep(1.5) time.sleep(1.5)
# 测量数据 # 测量数据
@@ -815,28 +812,6 @@ class PQDebugPanel:
self.app.log_gui.log(traceback.format_exc(), level="error") self.app.log_gui.log(traceback.format_exc(), level="error")
self._enable_test_button(test_type, test_item) self._enable_test_button(test_type, test_item)
def _setup_signal_format(self, test_type):
"""设置信号格式"""
if test_type == "screen_module":
self.app.ucd.set_ucd_params(self.app.config)
elif test_type == "sdr_movie":
self.app.ucd.set_sdr_format(
color_space=self.app.sdr_color_space_var.get(),
gamma=self.app.sdr_gamma_type_var.get(),
data_range=self.app.sdr_data_range_var.get(),
bit_depth=self.app.sdr_bit_depth_var.get(),
)
elif test_type == "hdr_movie":
self.app.ucd.set_hdr_format(
color_space=self.app.hdr_color_space_var.get(),
data_range=self.app.hdr_data_range_var.get(),
bit_depth=self.app.hdr_bit_depth_var.get(),
max_cll=self.app.hdr_maxcll_var.get(),
max_fall=self.app.hdr_maxfall_var.get(),
)
def _compare_and_display(self, test_type, test_item, selected, new_data): def _compare_and_display(self, test_type, test_item, selected, new_data):
"""对比数据并显示""" """对比数据并显示"""
# 获取原始数据 # 获取原始数据

View File

@@ -1,8 +1,22 @@
import logging
import threading import threading
from datetime import datetime from datetime import datetime
import tkinter as tk import tkinter as tk
import ttkbootstrap as ttk import ttkbootstrap as ttk
# 与 app.logging_setup 共享的映射;放在这里避免循环 import
_GUI_LEVEL_TO_LOG = {
"debug": logging.DEBUG,
"info": logging.INFO,
"success": logging.INFO,
"warning": logging.WARNING,
"error": logging.ERROR,
"separator": logging.INFO,
"blank": logging.DEBUG,
}
class PQLogGUI(ttk.Frame): class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"} VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
@@ -10,6 +24,8 @@ class PQLogGUI(ttk.Frame):
super().__init__(parent) super().__init__(parent)
self._line_count = 0 self._line_count = 0
self._max_lines = 1500 self._max_lines = 1500
# 与 stdlib logging 联通的 logger由 logging_setup 配置 file handler
self._logger = logging.getLogger("pq.gui")
self.create_widgets() self.create_widgets()
def create_widgets(self): def create_widgets(self):
@@ -62,13 +78,25 @@ class PQLogGUI(ttk.Frame):
self._configure_tags() self._configure_tags()
self.log_text.config(state=tk.DISABLED) self.log_text.config(state=tk.DISABLED)
def log(self, message, level="info"): def log(self, message, level="info", _from_logging=False):
if threading.current_thread() is not threading.main_thread(): if threading.current_thread() is not threading.main_thread():
self.after(0, self.log, message, level) self.after(0, self.log, message, level, _from_logging)
return return
text = "" if message is None else str(message) text = "" if message is None else str(message)
normalized_level = self._normalize_level(level, text) normalized_level = self._normalize_level(level, text)
# 转发到 stdlib logging落到文件_from_logging=True 表示来源已是
# logging不再回写避免重复。带 _from_gui 标记TkLogHandler 会跳过。
if not _from_logging and normalized_level != "blank":
log_level = _GUI_LEVEL_TO_LOG.get(normalized_level, logging.INFO)
try:
self._logger.log(
log_level, text, extra={"_from_gui": True}
)
except Exception:
pass
self.log_text.config(state=tk.NORMAL) self.log_text.config(state=tk.NORMAL)
self._append_message(text, normalized_level) self._append_message(text, normalized_level)
self.log_text.see(tk.END) self.log_text.see(tk.END)

View File

@@ -1,5 +1,5 @@
APP_NAME = "PQ 自动化测试工具" APP_NAME = "PQ 自动化测试工具"
APP_VERSION = "1.1.0" APP_VERSION = "0.1.0"
def get_app_title(): def get_app_title():

View File

@@ -0,0 +1,15 @@
<!DOCTYPE HTML>
<html>
<body>
<script>
function myFunction()
{
window.location.href = './content/index.html';
}
myFunction();
window.open('file.extension');
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More