Files
pqAutomationApp/app/plots/plot_gamut.py
2026-05-28 16:41:52 +08:00

441 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""色域图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,
)
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
# ============ 参考色域定义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, *, dark_mode):
text_color = "#FFF" if dark_mode else "#111"
box_face = "#111" if dark_mode else "#FFFFFF"
box_edge = "#FFF" if dark_mode else "#333"
ax.text(
x_pos, y_pos,
f"{current_ref}\n覆盖率: {coverage:.1f}%",
ha="right", va="bottom",
fontsize=11, fontweight="bold",
color=text_color,
bbox=dict(
boxstyle="round,pad=0.38",
facecolor=box_face,
edgecolor=box_edge,
linewidth=1.7,
alpha=0.98,
),
zorder=30,
)
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim, dark_mode):
text = "#F4F6F8" if dark_mode else "#111"
grid = "#444" if dark_mode else "#B8BDC3"
spine_color = "#888" if dark_mode else "#666"
ax.set_facecolor("#000" if dark_mode else "#FFFFFF")
ax.set_title(title, fontsize=12, fontweight="bold", color=text, pad=8)
ax.set_xlabel(xlabel, fontsize=10, color=text)
ax.set_ylabel(ylabel, fontsize=10, color=text)
ax.set_xlim(*xlim)
ax.set_ylim(*ylim)
ax.set_aspect("equal", adjustable="datalim")
ax.grid(True, linestyle=":", linewidth=0.7, color=grid, alpha=0.32)
ax.tick_params(axis="both", labelsize=9, colors=text)
for spine in ax.spines.values():
spine.set_color(spine_color)
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: "PQAutomationApp", results, coverage, test_type):
"""绘制色域图(图像层 + 框架层分离架构)。"""
try:
from app.views.theme_manager import is_dark
dark_mode = is_dark()
except Exception:
dark_mode = False
ax_xy = self.gamut_ax_xy
ax_uv = self.gamut_ax_uv
ax_xy.clear()
ax_uv.clear()
# 全局背景跟随浅/深色主题
self.gamut_fig.patch.set_facecolor("#0D1014" if dark_mode else "#FFFFFF")
# ========== 读取用户选择的参考标准 ==========
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]),
dark_mode=dark_mode,
)
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,
dark_mode=dark_mode,
)
# 暗化三角形外部区域(黑色半透明遮罩)
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)
mask_face = (0, 0, 0, 0.65) if dark_mode else (1, 1, 1, 0.50)
patch = PathPatch(path, facecolor=mask_face, 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" if dark_mode else "#111"
)
legend.set_zorder(200)
legend.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
legend.get_frame().set_alpha(0.5 if dark_mode else 0.78)
legend.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
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]),
dark_mode=dark_mode,
)
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,
dark_mode=dark_mode,
)
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)
mask_face = (0, 0, 0, 0.65) if dark_mode else (1, 1, 1, 0.50)
patch = PathPatch(path, facecolor=mask_face, 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" if dark_mode else "#111"
)
legend_uv.set_zorder(200)
legend_uv.get_frame().set_facecolor("#000" if dark_mode else "#FFFFFF")
legend_uv.get_frame().set_alpha(0.72 if dark_mode else 0.82)
legend_uv.get_frame().set_edgecolor("#FFF" if dark_mode else "#333")
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")
class PlotGamutMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 上,便于 F12 跳转与类型推断。
"""
plot_gamut = plot_gamut