修改色域画图及重绘方式

This commit is contained in:
xinzhu.yin
2026-05-18 15:57:11 +08:00
parent 9371defb6e
commit d7495734a5
9 changed files with 900 additions and 540 deletions

2
.gitignore vendored
View File

@@ -32,3 +32,5 @@ Desktop.ini
# Local configuration overrides # Local configuration overrides
settings/*.local.json settings/*.local.json
settings/ settings/
log/
results/

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 # 默认使用传入的值 self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error")
current_ref = "DCI-P3"
# ========== 重新计算 xy 覆盖率 ==========
xy_coverage = coverage
uv_coverage = 0.0 uv_coverage = 0.0
measured_xy = None
try: if len(results) >= 3:
# 提取前 3 个 RGB 点的 xy 坐标 measured_xy = [(float(r[0]), float(r[1])) for r in results[:3]]
if len(results) >= 3: try:
xy_points = [[result[0], result[1]] for result in results[:3]]
# 根据参考标准计算 XY 覆盖率
if current_ref == "BT.2020": if current_ref == "BT.2020":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020( _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020(measured_xy)
xy_points
)
elif current_ref == "BT.709": elif current_ref == "BT.709":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709( _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709(measured_xy)
xy_points
)
elif current_ref == "DCI-P3": elif current_ref == "DCI-P3":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(measured_xy)
xy_points
)
elif current_ref == "BT.601": elif current_ref == "BT.601":
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601( _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601(measured_xy)
xy_points
)
else:
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"
self.log_gui.log( self.log_gui.log(
f"XY 空间覆盖率({current_ref}: {xy_coverage:.1f}%" f"XY 空间覆盖率({current_ref}: {xy_coverage:.1f}%", level="success"
, level="success") )
except Exception as e:
self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error")
xy_coverage = coverage
except Exception as e: # 需要叠加的次要参考色域
self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error") other_refs = [
xy_coverage = coverage # 回退到传入值 r for r in _REF_GAMUTS_XY.keys()
# ================================================= if r != current_ref
and (r != "BT.601" or test_type == "sdr_movie")
]
# ========== 左图CIE 1931 xy ========== # ============================================================
# 左图CIE 1931 xy
# ============================================================
try: try:
img_xy = mpimg.imread(get_resource_path("assets/cie.png")) bg_xy, bbox_xy = get_cie1931_background()
h_xy, w_xy = img_xy.shape[:2] _blit_background(ax_xy, bg_xy, bbox_xy)
_style_axes(
ax_xy,
title="CIE 1931 xy",
xlabel="x", ylabel="y",
xlim=(bbox_xy[0], bbox_xy[1]),
ylim=(bbox_xy[2], bbox_xy[3]),
)
self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}", level="info") for ref_name in other_refs:
_draw_reference_triangle(
self.gamut_ax_xy.imshow(img_xy, extent=[0, w_xy, h_xy, 0], aspect="equal") ax_xy, _REF_GAMUTS_XY[ref_name],
self.gamut_ax_xy.set_xlim(0, w_xy) _REF_COLORS[ref_name],
self.gamut_ax_xy.set_ylim(h_xy, 0) is_current=False, label=ref_name,
self.gamut_ax_xy.axis("off") )
self.gamut_ax_xy.set_clip_on(False) _draw_reference_triangle(
ax_xy, _REF_GAMUTS_XY[current_ref],
def cie_xy_to_pixel(x, y): _REF_COLORS[current_ref],
"""CIE xy → 像素坐标""" is_current=True, label=f"{current_ref} (参考)",
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:
red_x, red_y = results[0][0], results[0][1]
green_x, green_y = results[1][0], results[1][1]
blue_x, blue_y = results[2][0], results[2][1]
if measured_xy is not None:
r_xy, g_xy, b_xy = measured_xy
self.log_gui.log( self.log_gui.log(
f"测量色域: R({red_x:.4f},{red_y:.4f}) " f"测量色域: R({r_xy[0]:.4f},{r_xy[1]:.4f}) "
f"G({green_x:.4f},{green_y:.4f}) B({blue_x:.4f},{blue_y:.4f})" f"G({g_xy[0]:.4f},{g_xy[1]:.4f}) B({b_xy[0]:.4f},{b_xy[1]:.4f})",
, level="info") level="info",
# ========== 绘制测量三角形 ==========
points = [
cie_xy_to_pixel(red_x, red_y),
cie_xy_to_pixel(green_x, green_y),
cie_xy_to_pixel(blue_x, blue_y),
cie_xy_to_pixel(red_x, red_y),
]
xs = [p[0] for p in points]
ys = [p[1] for p in points]
self.gamut_ax_xy.plot(
xs,
ys,
color="red",
linewidth=2.5,
marker="o",
markersize=10,
markerfacecolor="red",
markeredgecolor="white",
markeredgewidth=2,
label="测量色域",
zorder=10,
) )
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
# ========== 标注 RGB 点 ========== _draw_coverage_box(
labels = ["R", "G", "B"] ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage
coords = [(red_x, red_y), (green_x, green_y), (blue_x, blue_y)] )
for (x_cie, y_cie), label in zip(coords, labels): # 暗化三角形外部区域(黑色半透明遮罩)
px, py = cie_xy_to_pixel(x_cie, y_cie) 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]
]
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_xy.add_patch(patch)
# 自适应偏移 legend = ax_xy.legend(
if label == "R": loc="upper right", fontsize=8.5,
offset = (-60, -40) if x_cie > 0.6 else (0, -60) framealpha=0.0, edgecolor="#000", fancybox=True,
elif label == "G": labelcolor="#FFF"
offset = (0, -60) )
else: # B legend.set_zorder(200)
offset = (60, 40) legend.get_frame().set_facecolor("#000")
legend.get_frame().set_alpha(0.5)
self.gamut_ax_xy.annotate( legend.get_frame().set_edgecolor("#FFF")
f"{label}\n({x_cie:.3f},{y_cie:.3f})", ax_xy.add_artist(legend)
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
dcip3 = [
(0.6800, 0.3200),
(0.2650, 0.6900),
(0.1500, 0.0600),
(0.6800, 0.3200),
]
dcip3_px = [cie_xy_to_pixel(x, y) for x, y in dcip3]
self.gamut_ax_xy.plot(
[p[0] for p in dcip3_px],
[p[1] for p in dcip3_px],
color="blue",
linewidth=1.5,
linestyle="--",
marker="s",
markersize=6,
alpha=0.7,
label="DCI-P3",
zorder=5,
)
# BT.2020
bt2020 = [
(0.7080, 0.2920),
(0.1700, 0.7970),
(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,
)
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(
xs_uv,
ys_uv,
color="red",
linewidth=2.5,
marker="o",
markersize=10,
markerfacecolor="red",
markeredgecolor="white",
markeredgewidth=2,
label="测量色域",
zorder=10,
) )
_draw_reference_triangle(
ax_uv, _ref_gamut_uv(current_ref),
_REF_COLORS[current_ref],
is_current=True, label=f"{current_ref} (参考)",
)
# ========== 标注 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( u0, u1 = bbox_uv[0], bbox_uv[1]
f"{label}\n({u:.3f},{v:.3f})", v0, v1 = bbox_uv[2], bbox_uv[3]
xy=(px, py), verts = [
xytext=offset, (u0, v0), (u0, v1), (u1, v1), (u1, v0), (u0, v0),
textcoords="offset points", *_ref_gamut_uv(current_ref), _ref_gamut_uv(current_ref)[0]
fontsize=9, ]
color="white", codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
fontweight="bold", codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
bbox=dict( path = Path(verts, codes)
boxstyle="round,pad=0.5", patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
facecolor="red", ax_uv.add_patch(patch)
alpha=0.9,
edgecolor="white",
linewidth=2,
),
arrowprops=dict(arrowstyle="->", color="red", lw=2),
zorder=11,
clip_on=False,
)
# ========== DCI-P3 参考(蓝色)========== legend_uv = ax_uv.legend(
dcip3_uv = [ loc="upper right", fontsize=8.5,
[0.4970, 0.5260], framealpha=0.0, edgecolor="#000", fancybox=True,
[0.0999, 0.5780], labelcolor="#FFF"
[0.1754, 0.1576], )
[0.4970, 0.5260], legend_uv.set_zorder(200)
] legend_uv.get_frame().set_facecolor("#000")
dcip3_uv_px = [cie_uv_to_pixel(u, v) for u, v in dcip3_uv] legend_uv.get_frame().set_alpha(0.72)
legend_uv.get_frame().set_edgecolor("#FFF")
self.gamut_ax_uv.plot( ax_uv.add_artist(legend_uv)
[p[0] for p in dcip3_uv_px],
[p[1] for p in dcip3_uv_px],
color="blue",
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,
)
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

@@ -1168,20 +1168,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

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

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

@@ -57,6 +57,8 @@ from app.views.chart_frame import (
init_gamma_chart as _cf_init_gamma_chart, init_gamma_chart as _cf_init_gamma_chart,
init_gamut_chart as _cf_init_gamut_chart, init_gamut_chart as _cf_init_gamut_chart,
on_chart_tab_changed as _cf_on_chart_tab_changed, on_chart_tab_changed as _cf_on_chart_tab_changed,
sync_gamut_toolbar as _cf_sync_gamut_toolbar,
_on_gamut_toolbar_changed as _cf_on_gamut_toolbar_changed,
update_chart_tabs_state as _cf_update_chart_tabs_state, update_chart_tabs_state as _cf_update_chart_tabs_state,
) )
from app.config_io import ( from app.config_io import (
@@ -274,6 +276,8 @@ class PQAutomationApp:
clear_chart = _cf_clear_chart clear_chart = _cf_clear_chart
create_result_chart_frame = _cf_create_result_chart_frame create_result_chart_frame = _cf_create_result_chart_frame
on_chart_tab_changed = _cf_on_chart_tab_changed on_chart_tab_changed = _cf_on_chart_tab_changed
sync_gamut_toolbar = _cf_sync_gamut_toolbar
_on_gamut_toolbar_changed = _cf_on_gamut_toolbar_changed
create_floating_config_panel = _main.create_floating_config_panel create_floating_config_panel = _main.create_floating_config_panel
create_test_items_content = _main.create_test_items_content create_test_items_content = _main.create_test_items_content
@@ -320,6 +324,8 @@ class PQAutomationApp:
append_custom_template_result = _ctp.append_custom_template_result append_custom_template_result = _ctp.append_custom_template_result
start_custom_template_test = _ctp.start_custom_template_test start_custom_template_test = _ctp.start_custom_template_test
update_custom_button_visibility = _ctp.update_custom_button_visibility update_custom_button_visibility = _ctp.update_custom_button_visibility
export_custom_template_excel = _ctp.export_custom_template_excel
export_custom_template_charts = _ctp.export_custom_template_charts
create_log_panel = _sp.create_log_panel create_log_panel = _sp.create_log_panel
create_local_dimming_panel = _sp.create_local_dimming_panel create_local_dimming_panel = _sp.create_local_dimming_panel
@@ -393,8 +399,6 @@ class PQAutomationApp:
def _hide_recalc_buttons(self, include_gamut=False): def _hide_recalc_buttons(self, include_gamut=False):
"""隐藏重新计算按钮。include_gamut=True 时同时隐藏色域重算按钮。""" """隐藏重新计算按钮。include_gamut=True 时同时隐藏色域重算按钮。"""
attrs = ["recalc_cct_btn", "sdr_recalc_cct_btn", "hdr_recalc_cct_btn"] attrs = ["recalc_cct_btn", "sdr_recalc_cct_btn", "hdr_recalc_cct_btn"]
if include_gamut:
attrs += ["recalc_gamut_btn", "sdr_recalc_gamut_btn", "hdr_recalc_gamut_btn"]
hidden = 0 hidden = 0
for attr in attrs: for attr in attrs:
btn = getattr(self, attr, None) btn = getattr(self, attr, None)
@@ -536,6 +540,7 @@ class PQAutomationApp:
self.on_test_type_change() self.on_test_type_change()
self._switch_signal_format_tabs(test_type) self._switch_signal_format_tabs(test_type)
self._switch_chart_tabs_by_test_type(test_type) self._switch_chart_tabs_by_test_type(test_type)
self.sync_gamut_toolbar()
def _check_start_preconditions(self): def _check_start_preconditions(self):
"""检查开始测试前置条件:设备连接 & 未在测试中。""" """检查开始测试前置条件:设备连接 & 未在测试中。"""

View File

@@ -1,5 +1,5 @@
{ {
"current_test_type": "screen_module", "current_test_type": "sdr_movie",
"test_types": { "test_types": {
"screen_module": { "screen_module": {
"name": "屏模组性能测试", "name": "屏模组性能测试",
@@ -23,16 +23,19 @@
"sdr_movie": { "sdr_movie": {
"name": "SDR Movie测试", "name": "SDR Movie测试",
"test_items": [ "test_items": [
"gamut", "gamut"
"gamma",
"cct",
"contrast",
"accuracy"
], ],
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB" "colorimetry": "sRGB",
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
},
"gamut_reference": "BT.2020"
}, },
"hdr_movie": { "hdr_movie": {
"name": "HDR Movie测试", "name": "HDR Movie测试",
@@ -46,7 +49,13 @@
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB" "colorimetry": "sRGB",
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
}
} }
}, },
"device_config": { "device_config": {