diff --git a/app/plots/plot_accuracy.py b/app/plots/plot_accuracy.py index 18e9abe..b25e973 100644 --- a/app/plots/plot_accuracy.py +++ b/app/plots/plot_accuracy.py @@ -1,327 +1,290 @@ """色准测试结果绘制。 -Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_accuracy 原样搬迁。 +布局: +- 左侧:大尺寸 ColorChecker 条形图(每个条形使用对应颜色)。 +- 右侧:CIE 1976 u'v' 色度图(目标点/实测点/偏移连线)。 """ -from matplotlib.patches import Rectangle - from typing import TYPE_CHECKING +from matplotlib.patches import Rectangle +from matplotlib.lines import Line2D + +from app.plots.gamut_background import get_cie1976_background +from app.tests.color_accuracy import get_accuracy_color_standards + if TYPE_CHECKING: from pqAutomationApp import PQAutomationApp +# ============================================================ +# 常量 +# ============================================================ -def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type): - """绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma)""" +_COLOR_MAP = { + "White": "#FFFFFF", + "Gray 80": "#E6E6E6", + "Gray 65": "#D1D1D1", + "Gray 50": "#BABABA", + "Gray 35": "#9E9E9E", + "Black": "#000000", + "Dark Skin": "#735242", + "Light Skin": "#C29682", + "Blue Sky": "#5E7A9C", + "Foliage": "#596B42", + "Blue Flower": "#8280B0", + "Bluish Green": "#63BDA8", + "Orange": "#D97829", + "Purplish Blue": "#4A5CA3", + "Moderate Red": "#C25461", + "Purple": "#5C3D6B", + "Yellow Green": "#9EBA40", + "Orange Yellow": "#E6A12E", + "Blue (Legacy)": "#333D96", + "Green (Legacy)": "#479447", + "Red (Legacy)": "#B0303B", + "Yellow (Legacy)": "#EDC721", + "Magenta (Legacy)": "#BA5491", + "Cyan (Legacy)": "#0085A3", + "100% Red": "#FF0000", + "100% Green": "#00FF00", + "100% Blue": "#0000FF", + "100% Cyan": "#00FFFF", + "100% Magenta": "#FF00FF", + "100% Yellow": "#FFFF00", +} - self.accuracy_ax.clear() - self.accuracy_ax.set_xlim(0, 1) - self.accuracy_ax.set_ylim(0, 1) - self.accuracy_ax.axis("off") - self.accuracy_fig.subplots_adjust( - left=0.05, - right=0.95, - top=0.95, - bottom=0.02, +def _grade_color(delta_e: float) -> str: + if delta_e < 3: + return "#1FAE45" # 绿 + if delta_e < 5: + return "#E08A00" # 橙 + return "#D81B1B" # 红 + + +def _xy_to_uv(x: float, y: float): + """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 + + +# ============================================================ +# 子图:左侧 Calman 风格面板 +# ============================================================ + +def _draw_left_panel(ax, color_patches, delta_e_values): + """左侧仅保留大条形图。""" + ax.clear() + + n = len(color_patches) + if n == 0: + ax.set_axis_off() + return + + y_pos = list(range(n)) + bar_colors = [_COLOR_MAP.get(name, "#888888") for name in color_patches] + edge_colors = [_grade_color(dE) for dE in delta_e_values] + + ax.barh( + y_pos, + delta_e_values, + height=0.72, + color=bar_colors, + edgecolor=edge_colors, + linewidth=1.0, + zorder=3, ) - # 获取色准数据 - color_patches = accuracy_data.get("color_patches", []) - delta_e_values = accuracy_data.get("delta_e_values", []) - avg_delta_e = accuracy_data.get("avg_delta_e", 0) - max_delta_e = accuracy_data.get("max_delta_e", 0) - min_delta_e = accuracy_data.get("min_delta_e", 0) - excellent_count = accuracy_data.get("excellent_count", 0) - good_count = accuracy_data.get("good_count", 0) - poor_count = accuracy_data.get("poor_count", 0) + ax.set_yticks(y_pos) + ax.set_yticklabels(color_patches, fontsize=7) + ax.invert_yaxis() - # 获取 Gamma 值 - target_gamma = accuracy_data.get("target_gamma", 2.2) + x_max = max(15.0, max(delta_e_values) * 1.15) + ax.set_xlim(0, x_max) + ax.grid(axis="x", linestyle="-", linewidth=0.6, alpha=0.3, zorder=0) + ax.grid(axis="y", linestyle=":", linewidth=0.35, alpha=0.15, zorder=0) + + ax.set_facecolor("#FFFFFF") + for spine in ax.spines.values(): + spine.set_color("#9A9A9A") + spine.set_linewidth(0.9) + + +# ============================================================ +# 子图:CIE 1976 u'v' 色度图(目标 vs 实测) +# ============================================================ + +def _draw_uv_diagram(ax, color_patches, measurements, standards): + """绘制 CIE 1976 u'v' 上的色准对比。""" + ax.clear() + try: + bg, bbox = get_cie1976_background() + xmin, xmax, ymin, ymax = bbox + ax.imshow( + bg, extent=(xmin, xmax, ymin, ymax), + origin="upper", interpolation="bicubic", + zorder=0, aspect="auto", + ) + ax.set_xlim(xmin, xmax) + ax.set_ylim(ymin, ymax) + except Exception: + ax.set_xlim(0.0, 0.65) + ax.set_ylim(0.0, 0.60) + + ax.set_facecolor("#000") + ax.set_aspect("equal", adjustable="box") + ax.set_title("CIE 1976 u'v'", fontsize=11, fontweight="bold", + color="#111", pad=4) + ax.set_xlabel("u'", fontsize=9, color="#222", labelpad=1) + ax.set_ylabel("v'", fontsize=9, color="#222", labelpad=1) + ax.tick_params(axis="both", labelsize=8, colors="#222") + for sp in ax.spines.values(): + sp.set_color("#666") + sp.set_linewidth(0.9) + + for name, meas in zip(color_patches, measurements): + if meas is None or len(meas) < 2: + continue + mx, my = meas[0], meas[1] + sxy = standards.get(name) + if sxy is None: + continue + sx, sy = sxy + + m_u, m_v = _xy_to_uv(mx, my) + s_u, s_v = _xy_to_uv(sx, sy) + + face = _COLOR_MAP.get(name, "#FFFFFF") + + # 目标点:仅空心方框(不填充标准颜色) + ax.scatter( + [s_u], [s_v], + s=56, marker="s", + facecolors="none", edgecolors="#FFFFFF", + linewidths=1.25, zorder=18, + ) + # 实测点:白色外圈 + 内层圆点 + ax.scatter( + [m_u], [m_v], + s=52, marker="o", + facecolors="none", edgecolors="#FFFFFF", + linewidths=1.0, zorder=19, + ) + ax.scatter( + [m_u], [m_v], + s=24, marker="o", + facecolors=face, edgecolors="#111111", + linewidths=0.85, zorder=20, + ) + + legend_handles = [ + Line2D([0], [0], marker="s", linestyle="none", + markerfacecolor="#CCCCCC", markeredgecolor="#FFFFFF", + markersize=7, label="目标 (Target)"), + Line2D([0], [0], marker="o", linestyle="none", + markerfacecolor="#CCCCCC", markeredgecolor="#000000", + markersize=7, label="实测 (Actual)"), + ] + leg = ax.legend( + handles=legend_handles, + loc="lower right", fontsize=8, + framealpha=0.88, labelcolor="#FFF", + ) + if leg is not None: + leg.get_frame().set_facecolor("#111") + leg.get_frame().set_edgecolor("#FFF") + leg.set_zorder(50) + + +def _draw_result_judgement(ax, accuracy_data): + """底部结果条""" + ax.clear() + ax.set_xlim(0, 1) + ax.set_ylim(0, 1) + ax.axis("off") + + avg = accuracy_data.get("avg_delta_e", 0.0) + mx = accuracy_data.get("max_delta_e", 0.0) + + ax.add_patch(Rectangle( + (0.0, 0.10), 1.0, 0.80, + transform=ax.transAxes, + facecolor="#FFFFFF", edgecolor="#C6C6C6", linewidth=1.0, + )) + + ax.text( + 0.03, 0.50, + f"Avg dE2000: {avg:.2f}", + ha="left", va="center", + fontsize=20, fontweight="normal", color="#111111", + transform=ax.transAxes, + ) + ax.text( + 0.52, 0.50, + f"Max dE2000: {mx:.2f}", + ha="left", va="center", + fontsize=20, fontweight="normal", color="#111111", + transform=ax.transAxes, + ) + + +# ============================================================ +# 主入口 +# ============================================================ + +def plot_accuracy(self: "PQAutomationApp", accuracy_data, test_type): + """绘制色准测试结果 - Calman 风格(色块 + CIE 1976 u'v' + 统计)。""" + + fig = self.accuracy_fig + fig.clear() + + color_patches = accuracy_data.get("color_patches", []) or [] + delta_e_values = accuracy_data.get("delta_e_values", []) or [] + measurements = accuracy_data.get("color_measurements", []) or [] + + try: + target_gamma = float(accuracy_data.get("target_gamma", 2.2)) + except (TypeError, ValueError): + target_gamma = 2.2 test_type_name = self.get_test_type_name(test_type) - # ========== 标题(动态显示 Gamma)========== if test_type == "sdr_movie": title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})" elif test_type == "hdr_movie": title = f"{test_type_name} - 色准测试(全 29色 | PQ EOTF)" - else: # screen_module + else: title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})" - self.accuracy_fig.suptitle( - title, - fontsize=11, - y=0.98, - fontweight="bold", - color="#111111", + fig.suptitle(title, fontsize=11, y=0.975, fontweight="bold", color="#111") + + gs = fig.add_gridspec( + 2, 2, + width_ratios=[1.12, 1.0], + height_ratios=[4.0, 0.62], + left=0.08, right=0.985, + top=0.91, bottom=0.06, + wspace=0.14, hspace=0.10, ) - # ========== 29色:6行5列布局 ========== - cols = 5 - rows = 6 + ax_left = fig.add_subplot(gs[0, 0]) + ax_uv = fig.add_subplot(gs[0, 1]) + ax_judge = fig.add_subplot(gs[1, :]) - patch_width = 0.135 - patch_height = 0.085 - x_start = 0.08 - y_start = 0.90 - x_gap = 0.035 - y_gap = 0.050 + # 兼容外部对 self.accuracy_ax 的引用 + self.accuracy_ax = ax_judge - # ========== 绘制色块 ========== - for i, (color_name, delta_e) in enumerate(zip(color_patches, delta_e_values)): - row = i // cols - col = i % cols + _draw_left_panel(ax_left, color_patches, delta_e_values) - x = x_start + col * (patch_width + x_gap) - y = y_start - row * (patch_height + y_gap) - - # 颜色映射 - color_map = { - # 灰阶 - "White": "#FFFFFF", - "Gray 80": "#E6E6E6", - "Gray 65": "#D1D1D1", - "Gray 50": "#BABABA", - "Gray 35": "#9E9E9E", - # 饱和色 - "100% Red": "#FF0000", - "100% Green": "#00FF00", - "100% Blue": "#0000FF", - "100% Cyan": "#00FFFF", - "100% Magenta": "#FF00FF", - "100% Yellow": "#FFFF00", - # ColorChecker 颜色 - "Dark Skin": "#735242", - "Light Skin": "#C29682", - "Blue Sky": "#5E7A9C", - "Foliage": "#596B42", - "Blue Flower": "#8280B0", - "Bluish Green": "#63BDA8", - "Orange": "#D97829", - "Purplish Blue": "#4A5CA3", - "Moderate Red": "#C25461", - "Purple": "#5C3D6B", - "Yellow Green": "#9EBA40", - "Orange Yellow": "#E6A12E", - "Blue (Legacy)": "#333D96", - "Green (Legacy)": "#479447", - "Red (Legacy)": "#B0303B", - "Yellow (Legacy)": "#EDC721", - "Magenta (Legacy)": "#BA5491", - "Cyan (Legacy)": "#0085A3", - } - - patch_color = color_map.get(color_name, "#808080") - - # ΔE 等级颜色 - if delta_e < 3: - edge_color = "green" - elif delta_e < 5: - edge_color = "orange" - else: - edge_color = "red" - - # 绘制色块 - rect = Rectangle( - (x, y), - patch_width, - patch_height, - transform=self.accuracy_ax.transAxes, - facecolor=patch_color, - edgecolor=edge_color, - linewidth=1.8, - ) - self.accuracy_ax.add_patch(rect) - - # ========== 标注色块名称(上方)========== - self.accuracy_ax.text( - x + patch_width / 2, - y + patch_height + 0.015, - color_name, - ha="center", - va="bottom", - fontsize=5.5, - fontweight="bold", - transform=self.accuracy_ax.transAxes, - clip_on=False, - ) - - # ========== 标注 ΔE 值(中心)========== - dark_colors = [ - "100% Red", - "100% Green", - "100% Blue", - "Gray 35", - "Dark Skin", - "Foliage", - "Purple", - "Purplish Blue", - "Blue (Legacy)", - "Green (Legacy)", - "Red (Legacy)", - "Magenta (Legacy)", - "Cyan (Legacy)", - ] - - text_color = "white" if color_name in dark_colors else "black" - - self.accuracy_ax.text( - x + patch_width / 2, - y + patch_height / 2, - f"ΔE\n{delta_e:.2f}", - ha="center", - va="center", - fontsize=5.2, - fontweight="bold", - color=text_color, - transform=self.accuracy_ax.transAxes, - bbox=dict( - boxstyle="round,pad=0.22", - facecolor="white" if text_color == "black" else "black", - alpha=0.75, - edgecolor=edge_color, - linewidth=1.0, - ), - ) - - # ========== 统计信息卡片(只保留外框)========== - card_width = 0.84 - card_height = 0.15 - card_x = 0.08 - card_y = 0.01 - - info_card = Rectangle( - (card_x, card_y), - card_width, - card_height, - transform=self.accuracy_ax.transAxes, - facecolor="#F0F0F0", - edgecolor="black", - linewidth=1.5, - ) - self.accuracy_ax.add_patch(info_card) - - # ========== 标题(带说明)========== - self.accuracy_ax.text( - card_x + card_width / 2, - card_y + card_height - 0.008, - "色准统计(5灰阶 + 18 ColorChecker + 6饱和色 | ΔE 2000 标准)", - ha="center", - va="top", - fontsize=7.5, - fontweight="bold", - color="#111111", - transform=self.accuracy_ax.transAxes, - ) - - # ========== 统计内容(无内部框)========== - stats_y = card_y + card_height * 0.55 - - # 左侧:ΔE 统计 - left_x = card_x + 0.02 - stats_text = [ - f"平均 ΔE: {avg_delta_e:.2f}", - f"最大 ΔE: {max_delta_e:.2f}", - f"最小 ΔE: {min_delta_e:.2f}", - ] - - for i, text in enumerate(stats_text): - self.accuracy_ax.text( - left_x, - stats_y - i * 0.030, - text, - ha="left", - va="center", - fontsize=7, - fontweight="bold", - color="#111111", - transform=self.accuracy_ax.transAxes, - ) - - # 中间:色块统计 - middle_x = card_x + card_width * 0.32 - - self.accuracy_ax.text( - middle_x, - stats_y, - f"优秀 (ΔE<3): {excellent_count} 个", - ha="left", - va="center", - fontsize=7, - color="green", - fontweight="bold", - transform=self.accuracy_ax.transAxes, - ) - - self.accuracy_ax.text( - middle_x, - stats_y - 0.030, - f"良好 (3≤ΔE<5): {good_count} 个", - ha="left", - va="center", - fontsize=7, - color="orange", - fontweight="bold", - transform=self.accuracy_ax.transAxes, - ) - - self.accuracy_ax.text( - middle_x, - stats_y - 0.060, - f"偏差 (ΔE≥5): {poor_count} 个", - ha="left", - va="center", - fontsize=7, - color="red", - fontweight="bold", - transform=self.accuracy_ax.transAxes, - ) - - # 右侧:总体评价 - right_x = card_x + card_width - 0.02 - - if avg_delta_e < 2: - grade = "专业级" - grade_icon = "★★★" - grade_color = "darkgreen" - elif avg_delta_e < 3: - grade = "优秀" - grade_icon = "OK" - grade_color = "green" - elif avg_delta_e < 5: - grade = "良好" - grade_icon = "PASS" - grade_color = "orange" - else: - grade = "需要校准" - grade_icon = "[Error]" - grade_color = "red" - - self.accuracy_ax.text( - right_x, - stats_y + 0.020, - "总体评价:", - ha="right", - va="bottom", - fontsize=7, - fontweight="bold", - color="#111111", - transform=self.accuracy_ax.transAxes, - ) - - self.accuracy_ax.text( - right_x, - stats_y - 0.025, - f"{grade} {grade_icon}", - ha="right", - va="top", - fontsize=11, - fontweight="bold", - color=grade_color, - transform=self.accuracy_ax.transAxes, - ) + try: + standards = get_accuracy_color_standards(test_type) + except Exception: + standards = {} + _draw_uv_diagram(ax_uv, color_patches, measurements, standards) + _draw_result_judgement(ax_judge, accuracy_data) self.accuracy_canvas.draw() self.chart_notebook.select(self.accuracy_chart_frame) diff --git a/tools/demo_accuracy_plot.py b/tools/demo_accuracy_plot.py new file mode 100644 index 0000000..cdb106d --- /dev/null +++ b/tools/demo_accuracy_plot.py @@ -0,0 +1,188 @@ +"""离线色准图 Demo。 + +运行后会在 tools/demo_outputs/ 下生成一张 PNG, +用于在没有 UCD 设备时预览当前色准图表的 Calman 风格布局。 +""" + +from __future__ import annotations + +import argparse +import math +import sys +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np + +plt.rcParams["font.family"] = ["sans-serif"] +plt.rcParams["font.sans-serif"] = ["Microsoft YaHei", "SimHei", "DejaVu Sans"] +plt.rcParams["axes.unicode_minus"] = False + +REPO_ROOT = Path(__file__).resolve().parents[1] +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + +from app.plots.plot_accuracy import plot_accuracy +from app.tests.color_accuracy import ( + calculate_delta_e_2000, + get_accuracy_color_standards, +) + + +COLOR_NAMES = [ + "White", + "Gray 80", + "Gray 65", + "Gray 50", + "Gray 35", + "Dark Skin", + "Light Skin", + "Blue Sky", + "Foliage", + "Blue Flower", + "Bluish Green", + "Orange", + "Purplish Blue", + "Moderate Red", + "Purple", + "Yellow Green", + "Orange Yellow", + "Blue (Legacy)", + "Green (Legacy)", + "Red (Legacy)", + "Yellow (Legacy)", + "Magenta (Legacy)", + "Cyan (Legacy)", + "100% Red", + "100% Green", + "100% Blue", + "100% Cyan", + "100% Magenta", + "100% Yellow", +] + + +class _DummyNotebook: + def select(self, *_args, **_kwargs): + return None + + +class _DummyCanvas: + def draw(self): + return None + + +class _DemoApp: + def __init__(self, fig): + self.accuracy_fig = fig + self.accuracy_canvas = _DummyCanvas() + self.chart_notebook = _DummyNotebook() + self.accuracy_chart_frame = object() + + def get_test_type_name(self, test_type): + mapping = { + "sdr_movie": "SDR Movie", + "hdr_movie": "HDR Movie", + "screen_module": "屏模组", + } + return mapping.get(test_type, str(test_type)) + + +def _build_demo_data(test_type: str = "sdr_movie"): + standards = get_accuracy_color_standards(test_type) + rng = np.random.default_rng(20260527) + + measured = [] + color_patches = [] + delta_e_values = [] + + for idx, name in enumerate(COLOR_NAMES): + sx, sy = standards[name] + + # 构造一些“看起来像真实测量”的偏移: + # 大部分点轻微偏移,少数点更明显,便于看出方向和等级差异。 + if idx < 5: + offset_scale = 0.0012 + elif idx < 23: + offset_scale = 0.0028 + else: + offset_scale = 0.0045 + + angle = rng.uniform(0, 2 * math.pi) + radius = offset_scale * (0.55 + 0.85 * rng.random()) + dx = math.cos(angle) * radius + dy = math.sin(angle) * radius + + # 为了让图上连线不完全随机,给部分饱和色再加一点定向偏移。 + if idx >= 23: + dx += 0.002 * (1 if idx % 2 == 0 else -1) + dy += 0.0015 * (1 if idx % 3 == 0 else -1) + + mx = min(max(sx + dx, 0.0), 0.8) + my = min(max(sy + dy, 0.0), 0.9) + + # 亮度也做一点微小变化,避免所有点完全同一层。 + measured_lv = 70.0 + rng.normal(0, 4.0) + measured_lv = max(measured_lv, 1.0) + + delta_e = calculate_delta_e_2000(mx, my, measured_lv, sx, sy) + + measured.append((mx, my, measured_lv)) + color_patches.append(name) + delta_e_values.append(delta_e) + + avg_delta_e = float(np.mean(delta_e_values)) + max_delta_e = float(np.max(delta_e_values)) + min_delta_e = float(np.min(delta_e_values)) + + return { + "color_patches": color_patches, + "delta_e_values": delta_e_values, + "color_measurements": measured, + "avg_delta_e": avg_delta_e, + "max_delta_e": max_delta_e, + "min_delta_e": min_delta_e, + "excellent_count": sum(1 for value in delta_e_values if value < 3), + "good_count": sum(1 for value in delta_e_values if 3 <= value < 5), + "poor_count": sum(1 for value in delta_e_values if value >= 5), + "avg_delta_e_gray": float(np.mean(delta_e_values[0:5])), + "avg_delta_e_colorchecker": float(np.mean(delta_e_values[5:23])), + "avg_delta_e_saturated": float(np.mean(delta_e_values[23:29])), + "target_gamma": 2.2, + } + + +def main(): + parser = argparse.ArgumentParser(description="Generate an offline color accuracy demo PNG.") + parser.add_argument( + "--output", + type=Path, + default=Path(__file__).resolve().parent / "demo_outputs" / "accuracy_demo.png", + help="Output PNG path.", + ) + parser.add_argument( + "--test-type", + choices=["sdr_movie", "hdr_movie", "screen_module"], + default="sdr_movie", + help="Test type used for the title and standard color set.", + ) + args = parser.parse_args() + + args.output.parent.mkdir(parents=True, exist_ok=True) + + fig = plt.Figure(figsize=(14, 8), dpi=120, tight_layout=False) + app = _DemoApp(fig) + accuracy_data = _build_demo_data(args.test_type) + + plot_accuracy(app, accuracy_data, args.test_type) + fig.savefig(args.output, dpi=220) + + print(f"Saved demo image to: {args.output}") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/demo_outputs/accuracy_demo.png b/tools/demo_outputs/accuracy_demo.png new file mode 100644 index 0000000..298d370 Binary files /dev/null and b/tools/demo_outputs/accuracy_demo.png differ