diff --git a/.gitignore b/.gitignore index bb5bb9b..0342825 100644 --- a/.gitignore +++ b/.gitignore @@ -31,4 +31,6 @@ Desktop.ini # Local configuration overrides settings/*.local.json -settings/ \ No newline at end of file +settings/ +log/ +results/ \ No newline at end of file diff --git a/app/plots/gamut_background.py b/app/plots/gamut_background.py new file mode 100644 index 0000000..e53c871 --- /dev/null +++ b/app/plots/gamut_background.py @@ -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 diff --git a/app/plots/plot_gamut.py b/app/plots/plot_gamut.py index a7c50ad..acbf167 100644 --- a/app/plots/plot_gamut.py +++ b/app/plots/plot_gamut.py @@ -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 -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): - """绘制色域图 - 根据用户选择的参考标准动态计算覆盖率""" - # 实现从原 PQAutomationApp 方法体原样搬迁,为减少修改面 - # 范围、保持行为一致,给 self 赋值为传入的 app 实例。 + """绘制色域图(图像层 + 框架层分离架构)。""" - self.gamut_ax_xy.clear() - self.gamut_ax_uv.clear() + ax_xy = self.gamut_ax_xy + ax_uv = self.gamut_ax_uv - # ==================== XY 图校准参数 ==================== - XY_ORIGIN_X = 20.55 - XY_ORIGIN_Y = 378.00 - XY_PIXELS_PER_X = 510.6818 - 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 + ax_xy.clear() + ax_uv.clear() + # 全局黑色背景 + self.gamut_fig.patch.set_facecolor("#000") # ========== 读取用户选择的参考标准 ========== if test_type == "screen_module": @@ -40,499 +214,197 @@ def plot_gamut(self, results, coverage, test_type): else: current_ref = "DCI-P3" - # ========== ✅✅根据参考标准重新计算覆盖率(XY 空间)========== - xy_coverage = coverage # 默认使用传入的值 + if current_ref not in _REF_GAMUTS_XY: + self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error") + current_ref = "DCI-P3" + + # ========== 重新计算 xy 覆盖率 ========== + xy_coverage = coverage uv_coverage = 0.0 + measured_xy = None - try: - # 提取前 3 个 RGB 点的 xy 坐标 - if len(results) >= 3: - xy_points = [[result[0], result[1]] for result in results[:3]] - - # 根据参考标准计算 XY 覆盖率 + if len(results) >= 3: + measured_xy = [(float(r[0]), float(r[1])) for r in results[:3]] + try: if current_ref == "BT.2020": - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020( - xy_points - ) + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020(measured_xy) elif current_ref == "BT.709": - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709( - xy_points - ) + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709(measured_xy) elif current_ref == "DCI-P3": - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( - xy_points - ) + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(measured_xy) 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") - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( - xy_points - ) - current_ref = "DCI-P3" - + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601(measured_xy) self.log_gui.log( - f"XY 空间覆盖率({current_ref}): {xy_coverage:.1f}%" - , level="success") + f"XY 空间覆盖率({current_ref}): {xy_coverage:.1f}%", 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") - xy_coverage = coverage # 回退到传入值 - # ================================================= + # 需要叠加的次要参考色域 + other_refs = [ + 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: - img_xy = mpimg.imread(get_resource_path("assets/cie.png")) - h_xy, w_xy = img_xy.shape[:2] + bg_xy, bbox_xy = get_cie1931_background() + _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") - - 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: - 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] + for ref_name in other_refs: + _draw_reference_triangle( + ax_xy, _REF_GAMUTS_XY[ref_name], + _REF_COLORS[ref_name], + is_current=False, label=ref_name, + ) + _draw_reference_triangle( + ax_xy, _REF_GAMUTS_XY[current_ref], + _REF_COLORS[current_ref], + is_current=True, label=f"{current_ref} (参考)", + ) + if measured_xy is not None: + r_xy, g_xy, b_xy = measured_xy self.log_gui.log( - f"测量色域: R({red_x:.4f},{red_y:.4f}) " - f"G({green_x:.4f},{green_y:.4f}) B({blue_x:.4f},{blue_y:.4f})" - , 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, + f"测量色域: R({r_xy[0]:.4f},{r_xy[1]:.4f}) " + f"G({g_xy[0]:.4f},{g_xy[1]:.4f}) B({b_xy[0]:.4f},{b_xy[1]:.4f})", + level="info", ) + _draw_measured_triangle(ax_xy, measured_xy, uv_space=False) - # ========== 标注 RGB 点 ========== - labels = ["R", "G", "B"] - coords = [(red_x, red_y), (green_x, green_y), (blue_x, blue_y)] + _draw_coverage_box( + ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage + ) - 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) - # 自适应偏移 - if label == "R": - offset = (-60, -40) if x_cie > 0.6 else (0, -60) - 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, - ) - - # ========== 绘制所有参考标准 ========== - # 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, - ) + legend = ax_xy.legend( + loc="upper right", fontsize=8.5, + framealpha=0.0, edgecolor="#000", fancybox=True, + labelcolor="#FFF" + ) + 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: self.log_gui.log(f"XY 图绘制失败: {str(e)}", level="error") import traceback - self.log_gui.log(traceback.format_exc(), level="error") - # ========== 右图:CIE 1976 u'v' ========== + # ============================================================ + # 右图:CIE 1976 u'v' + # ============================================================ try: - img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png")) - h_uv, w_uv = img_uv.shape[:2] + bg_uv, bbox_uv = get_cie1976_background() + _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") - - self.gamut_ax_uv.imshow(img_uv, extent=[0, w_uv, h_uv, 0], aspect="equal") - 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' 覆盖率(使用参考标准)========== + measured_uv = None + if measured_xy is not None: + measured_uv = [_xy_to_uv(x, y) for x, y in measured_xy] try: 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( - f"UV 空间覆盖率({current_ref}): {uv_coverage:.1f}%" - , level="success") + f"UV 空间覆盖率({current_ref}): {uv_coverage:.1f}%", + level="success", + ) except Exception as e: self.log_gui.log(f"计算 UV 覆盖率失败: {str(e)}", level="error") uv_coverage = 0.0 - # ================================================= - # ========== 绘制测量三角形 ========== - uv_coords_plot = uv_coords + [uv_coords[0]] - points_uv = [cie_uv_to_pixel(u, v) for u, v in uv_coords_plot] - xs_uv = [p[0] for p in points_uv] - ys_uv = [p[1] for p in points_uv] - - 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, + for ref_name in other_refs: + _draw_reference_triangle( + ax_uv, _ref_gamut_uv(ref_name), + _REF_COLORS[ref_name], + is_current=False, label=ref_name, ) + _draw_reference_triangle( + ax_uv, _ref_gamut_uv(current_ref), + _REF_COLORS[current_ref], + is_current=True, label=f"{current_ref} (参考)", + ) - # ========== 标注 RGB 点 ========== - labels = ["R", "G", "B"] - for (u, v), label in zip(uv_coords, labels): - px, py = cie_uv_to_pixel(u, v) + if measured_uv is not None: + _draw_measured_triangle(ax_uv, measured_uv, uv_space=True) - # 自适应偏移 - if label == "R": - 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) + _draw_coverage_box( + ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage + ) - 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 参考(蓝色)========== - dcip3_uv = [ - [0.4970, 0.5260], - [0.0999, 0.5780], - [0.1754, 0.1576], - [0.4970, 0.5260], - ] - dcip3_uv_px = [cie_uv_to_pixel(u, v) for u, v in dcip3_uv] - - self.gamut_ax_uv.plot( - [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, - ) + u0, u1 = bbox_uv[0], bbox_uv[1] + v0, v1 = bbox_uv[2], bbox_uv[3] + verts = [ + (u0, v0), (u0, v1), (u1, v1), (u1, v0), (u0, v0), + *_ref_gamut_uv(current_ref), _ref_gamut_uv(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_uv.add_patch(patch) + + legend_uv = ax_uv.legend( + loc="upper right", fontsize=8.5, + framealpha=0.0, edgecolor="#000", fancybox=True, + labelcolor="#FFF" + ) + 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: self.log_gui.log(f"UV 图绘制失败: {str(e)}", level="error") import traceback - self.log_gui.log(traceback.format_exc(), level="error") # ========== 总标题 ========== test_type_name = self.get_test_type_name(test_type) 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.chart_notebook.select(self.gamut_chart_frame) + # 同步工具栏按钮选中状态 + if hasattr(self, "sync_gamut_toolbar"): + self.sync_gamut_toolbar() + self.log_gui.log("色域图绘制完成", level="success") diff --git a/app/runner/test_runner.py b/app/runner/test_runner.py index 111c97f..cf110b8 100644 --- a/app/runner/test_runner.py +++ b/app/runner/test_runner.py @@ -1168,20 +1168,6 @@ def on_test_completed(self): except Exception as e: 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("完成", "测试已完成!") diff --git a/app/views/chart_frame.py b/app/views/chart_frame.py index e181990..1faaf40 100644 --- a/app/views/chart_frame.py +++ b/app/views/chart_frame.py @@ -15,6 +15,24 @@ def init_gamut_chart(self): container = ttk.Frame(self.gamut_chart_frame) 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_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.84 - # 初始化XY图 - self.gamut_ax_xy.set_xlim(0, 600) - self.gamut_ax_xy.set_ylim(600, 0) - self.gamut_ax_xy.axis("off") + # 初始化 XY 图(占位坐标系,真实绘制时由 plot_gamut 设置 CIE 1931 范围) + self.gamut_ax_xy.set_xlim(0.0, 0.8) + self.gamut_ax_xy.set_ylim(0.0, 0.9) + self.gamut_ax_xy.set_aspect("equal", adjustable="datalim") self.gamut_ax_xy.set_clip_on(False) - # 初始化UV图 - self.gamut_ax_uv.set_xlim(0, 600) - self.gamut_ax_uv.set_ylim(600, 0) - self.gamut_ax_uv.axis("off") + # 初始化 UV 图(占位坐标系,真实绘制时由 plot_gamut 设置 CIE 1976 范围) + self.gamut_ax_uv.set_xlim(0.0, 0.65) + self.gamut_ax_uv.set_ylim(0.0, 0.6) + self.gamut_ax_uv.set_aspect("equal", adjustable="datalim") self.gamut_ax_uv.set_clip_on(False) # 调整标题位置:y=0.98 @@ -46,6 +64,47 @@ def init_gamut_chart(self): 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): """初始化Gamma曲线图表 - 左侧曲线 + 右侧表格(4列 + 通用说明)""" container = ttk.Frame(self.gamma_chart_frame) diff --git a/app/views/panels/cct_panel.py b/app/views/panels/cct_panel.py index d901a84..7105b3b 100644 --- a/app/views/panels/cct_panel.py +++ b/app/views/panels/cct_panel.py @@ -111,18 +111,6 @@ def create_cct_params_frame(self): ) 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( self.cct_params_frame, @@ -228,18 +216,6 @@ def create_cct_params_frame(self): ) 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( self.sdr_cct_params_frame, @@ -345,18 +321,6 @@ def create_cct_params_frame(self): ) 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( self.hdr_cct_params_frame, diff --git a/app/views/panels/custom_template_panel.py b/app/views/panels/custom_template_panel.py index ce32bc4..d271275 100644 --- a/app/views/panels/custom_template_panel.py +++ b/app/views/panels/custom_template_panel.py @@ -136,12 +136,15 @@ def create_custom_template_result_panel(self): label="单步测试", command=self.start_custom_row_single_step, ) - - # self.custom_result_menu.add_separator() - # self.custom_result_menu.add_command( - # label="单步测试", - # command=self.fill_custom_result_test_data, - # ) + self.custom_result_menu.add_separator() + self.custom_result_menu.add_command( + label="生成模板", + command=self.export_custom_template_excel, + ) + self.custom_result_menu.add_command( + label="生成图表", + command=self.export_custom_template_charts, + ) self.custom_result_tree.bind("", self.show_custom_result_context_menu) table_container.grid_rowconfigure(0, weight=1) @@ -181,6 +184,14 @@ def show_custom_result_context_menu(self, event): 1, 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) finally: self.custom_result_menu.grab_release() @@ -614,3 +625,290 @@ def update_custom_button_visibility(self): # self.status_var.set("已填充 147 行客户模板测试数据") # if hasattr(self, "log_gui"): # 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)}") diff --git a/pqAutomationApp.py b/pqAutomationApp.py index 1c4fb0a..d1edc32 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -57,6 +57,8 @@ from app.views.chart_frame import ( init_gamma_chart as _cf_init_gamma_chart, init_gamut_chart as _cf_init_gamut_chart, on_chart_tab_changed as _cf_on_chart_tab_changed, + sync_gamut_toolbar as _cf_sync_gamut_toolbar, + _on_gamut_toolbar_changed as _cf_on_gamut_toolbar_changed, update_chart_tabs_state as _cf_update_chart_tabs_state, ) from app.config_io import ( @@ -274,6 +276,8 @@ class PQAutomationApp: clear_chart = _cf_clear_chart create_result_chart_frame = _cf_create_result_chart_frame on_chart_tab_changed = _cf_on_chart_tab_changed + sync_gamut_toolbar = _cf_sync_gamut_toolbar + _on_gamut_toolbar_changed = _cf_on_gamut_toolbar_changed create_floating_config_panel = _main.create_floating_config_panel create_test_items_content = _main.create_test_items_content @@ -320,6 +324,8 @@ class PQAutomationApp: append_custom_template_result = _ctp.append_custom_template_result start_custom_template_test = _ctp.start_custom_template_test update_custom_button_visibility = _ctp.update_custom_button_visibility + export_custom_template_excel = _ctp.export_custom_template_excel + export_custom_template_charts = _ctp.export_custom_template_charts create_log_panel = _sp.create_log_panel create_local_dimming_panel = _sp.create_local_dimming_panel @@ -393,8 +399,6 @@ class PQAutomationApp: def _hide_recalc_buttons(self, include_gamut=False): """隐藏重新计算按钮。include_gamut=True 时同时隐藏色域重算按钮。""" 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 for attr in attrs: btn = getattr(self, attr, None) @@ -536,6 +540,7 @@ class PQAutomationApp: self.on_test_type_change() self._switch_signal_format_tabs(test_type) self._switch_chart_tabs_by_test_type(test_type) + self.sync_gamut_toolbar() def _check_start_preconditions(self): """检查开始测试前置条件:设备连接 & 未在测试中。""" diff --git a/settings/pq_config.json b/settings/pq_config.json index eb8bafe..24ca29a 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -1,5 +1,5 @@ { - "current_test_type": "screen_module", + "current_test_type": "sdr_movie", "test_types": { "screen_module": { "name": "屏模组性能测试", @@ -23,16 +23,19 @@ "sdr_movie": { "name": "SDR Movie测试", "test_items": [ - "gamut", - "gamma", - "cct", - "contrast", - "accuracy" + "gamut" ], "timing": "DMT 1920x 1080 @ 60Hz", "color_format": "RGB", "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": { "name": "HDR Movie测试", @@ -46,7 +49,13 @@ "timing": "DMT 1920x 1080 @ 60Hz", "color_format": "RGB", "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": {