"""色域图(Gamut)绘制 - Calman 风格。 架构:**图像渲染层** 与 **基础数据/框架层** 分离 ------------------------------------------------ - 图像渲染层(重):CIE 1931 / 1976 谱迹色域底图。 由 `app.plots.gamut_background` 通过 colour-science 离屏渲染一次, 结果以 numpy RGBA 数组缓存在内存与磁盘(settings/cache/),后续直接 `ax.imshow(bg, extent=bbox)` 复用 → 主线程绘制开销可忽略。 - 基础数据/框架层(轻):参考色域三角形、实测色域三角形、顶点标签、 覆盖率信息框等矢量元素,每次绘制都在真实色度坐标系上重画。 视觉风格:参照 Calman colorspace 显示: - 当前选中的参考标准:亮色实线 + 顶点空心方框; - 其他参考标准:半透明虚线(便于对比,不喧宾夺主); - 实测色域:红色粗边 + 淡红填充 + 顶点圆点 + 浮动坐标标签; - 右下角白底红字覆盖率信息框。 """ 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.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): """绘制色域图(图像层 + 框架层分离架构)。""" ax_xy = self.gamut_ax_xy ax_uv = self.gamut_ax_uv ax_xy.clear() ax_uv.clear() # 全局黑色背景 self.gamut_fig.patch.set_facecolor("#000") # ========== 读取用户选择的参考标准 ========== if test_type == "screen_module": current_ref = self.screen_gamut_ref_var.get() elif test_type == "sdr_movie": current_ref = self.sdr_gamut_ref_var.get() elif test_type == "hdr_movie": current_ref = self.hdr_gamut_ref_var.get() else: current_ref = "DCI-P3" 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 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(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( 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 # 需要叠加的次要参考色域 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 # ============================================================ try: 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]), ) 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({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) _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] ] 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( 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' # ============================================================ try: 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]), ) 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( [list(uv) for uv in measured_uv], reference=current_ref ) self.log_gui.log( 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 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} (参考)", ) if measured_uv is not None: _draw_measured_triangle(ax_uv, measured_uv, uv_space=True) _draw_coverage_box( ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage ) 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", ) 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")