diff --git a/app/plots/__init__.py b/app/plots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/plots/plot_accuracy.py b/app/plots/plot_accuracy.py new file mode 100644 index 0000000..b36434a --- /dev/null +++ b/app/plots/plot_accuracy.py @@ -0,0 +1,318 @@ +"""色准测试结果绘制。 + +Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_accuracy 原样搬迁。 +""" + +from matplotlib.patches import Rectangle + + +def plot_accuracy(app, accuracy_data, test_type): + """绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma)""" + self = app + + 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, + ) + + # 获取色准数据 + 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) + + # 获取 Gamma 值 + target_gamma = accuracy_data.get("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 + title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})" + + self.accuracy_fig.suptitle( + title, + fontsize=11, + y=0.98, + fontweight="bold", + ) + + # ========== 29色:6行5列布局 ========== + cols = 5 + rows = 6 + + patch_width = 0.135 + patch_height = 0.085 + x_start = 0.08 + y_start = 0.90 + x_gap = 0.035 + y_gap = 0.050 + + # ========== 绘制色块 ========== + for i, (color_name, delta_e) in enumerate(zip(color_patches, delta_e_values)): + row = i // cols + col = i % cols + + 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", + 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", + 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 = "✓✓" + grade_color = "green" + elif avg_delta_e < 5: + grade = "良好" + grade_icon = "✓" + grade_color = "orange" + else: + grade = "需要校准" + grade_icon = "✗" + grade_color = "red" + + self.accuracy_ax.text( + right_x, + stats_y + 0.020, + "总体评价:", + ha="right", + va="bottom", + fontsize=7, + fontweight="bold", + 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, + ) + + self.accuracy_canvas.draw() + self.chart_notebook.select(4) diff --git a/app/plots/plot_cct.py b/app/plots/plot_cct.py new file mode 100644 index 0000000..3d50719 --- /dev/null +++ b/app/plots/plot_cct.py @@ -0,0 +1,325 @@ +"""CCT / 色度一致性绘制。 + +Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。 +""" + +import numpy as np + + +def plot_cct(app, test_type): + """绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值""" + self = app + + self.cct_fig.clear() + + gray_data = self.results.get_intermediate_data("shared", "gray") + if not gray_data: + gray_data = self.results.get_intermediate_data("cct", "gray") + + if not gray_data or len(gray_data) < 2: + self.log_gui.log("⚠️ 无 xy 数据可用") + ax = self.cct_fig.add_subplot(111) + ax.text( + 0.5, + 0.5, + "无可用数据", + ha="center", + va="center", + fontsize=14, + color="red", + ) + ax.axis("off") + self.cct_canvas.draw() + return + + x_measured = [data[0] for data in gray_data] + y_measured = [data[1] for data in gray_data] + + # 反转数据顺序(从暗到亮) + x_measured = x_measured[::-1] + y_measured = y_measured[::-1] + + # 去掉第一个点 + x_measured = x_measured[1:] + y_measured = y_measured[1:] + + # 重新生成灰阶坐标 + total_points = len(gray_data) + grayscale = np.linspace(100 / total_points, 100, len(x_measured)) + + self.log_gui.log(f"✓ 已移除第一个数据点,当前数据点数: {len(x_measured)}") + self.log_gui.log(f" x范围: {min(x_measured):.6f} - {max(x_measured):.6f}") + self.log_gui.log(f" y范围: {min(y_measured):.6f} - {max(y_measured):.6f}") + + # ========== 根据测试类型读取对应参数 ========== + if test_type == "sdr_movie": + try: + x_ideal = float(self.sdr_cct_x_ideal_var.get()) + x_tolerance = float(self.sdr_cct_x_tolerance_var.get()) + y_ideal = float(self.sdr_cct_y_ideal_var.get()) + y_tolerance = float(self.sdr_cct_y_tolerance_var.get()) + self.log_gui.log("✓ 使用 SDR 色度参数") + except: + x_ideal = 0.3127 + x_tolerance = 0.003 + y_ideal = 0.3290 + y_tolerance = 0.003 + self.log_gui.log("⚠️ SDR 参数读取失败,使用默认值") + elif test_type == "hdr_movie": + try: + x_ideal = float(self.hdr_cct_x_ideal_var.get()) + x_tolerance = float(self.hdr_cct_x_tolerance_var.get()) + y_ideal = float(self.hdr_cct_y_ideal_var.get()) + y_tolerance = float(self.hdr_cct_y_tolerance_var.get()) + self.log_gui.log("✓ 使用 HDR 色度参数") + except: + x_ideal = 0.3127 + x_tolerance = 0.003 + y_ideal = 0.3290 + y_tolerance = 0.003 + self.log_gui.log("⚠️ HDR 参数读取失败,使用默认值") + else: # screen_module + try: + x_ideal = float(self.cct_x_ideal_var.get()) + x_tolerance = float(self.cct_x_tolerance_var.get()) + y_ideal = float(self.cct_y_ideal_var.get()) + y_tolerance = float(self.cct_y_tolerance_var.get()) + self.log_gui.log("✓ 使用屏模组色度参数") + except: + x_ideal = 0.306 + x_tolerance = 0.003 + y_ideal = 0.318 + y_tolerance = 0.003 + self.log_gui.log("⚠️ 屏模组参数读取失败,使用默认值") + + x_low = x_ideal - x_tolerance + x_high = x_ideal + x_tolerance + y_low = y_ideal - y_tolerance + y_high = y_ideal + y_tolerance + + self.log_gui.log(f"✓ 用户设置参数:") + self.log_gui.log(f" x-ideal={x_ideal:.4f}, tolerance={x_tolerance:.4f}") + self.log_gui.log(f" x范围: [{x_low:.4f}, {x_high:.4f}]") + self.log_gui.log(f" y-ideal={y_ideal:.4f}, tolerance={y_tolerance:.4f}") + self.log_gui.log(f" y范围: [{y_low:.4f}, {y_high:.4f}]") + + # 为所有测试类型创建子图 + ax1 = self.cct_fig.add_subplot(211) + ax2 = self.cct_fig.add_subplot(212) + + # ========== 上图:x coordinates ========== + ax1.plot( + grayscale, + x_measured, + "b-o", + label="屏本体", + linewidth=2, + markersize=4, + zorder=5, + ) + + # 为每个点添加数值标注(x 坐标) + for i, (gs, x_val) in enumerate(zip(grayscale, x_measured)): + ax1.annotate( + f"{x_val:.5f}", + xy=(gs, x_val), + xytext=(0, 8), + textcoords="offset points", + ha="center", + va="bottom", + fontsize=7, + color="blue", + bbox=dict( + boxstyle="round,pad=0.2", + facecolor="white", + edgecolor="blue", + alpha=0.8, + linewidth=0.5, + ), + ) + + # 绘制完整的参考线 + full_grayscale = np.linspace(0, 100, 100) + ax1.axhline( + y=x_ideal, + color="green", + linestyle="--", + linewidth=1.5, + label=f"x-ideal ({x_ideal:.4f})", + zorder=3, + ) + ax1.axhline( + y=x_low, + color="red", + linestyle=":", + linewidth=1, + alpha=0.7, + label=f"x-low ({x_low:.4f})", + zorder=2, + ) + ax1.axhline( + y=x_high, + color="red", + linestyle=":", + linewidth=1, + alpha=0.7, + label=f"x-high ({x_high:.4f})", + zorder=2, + ) + ax1.fill_between( + full_grayscale, x_low, x_high, alpha=0.15, color="blue", zorder=1 + ) + + ax1.set_xlabel("灰阶 (%)", fontsize=9) + ax1.set_ylabel("CIE x", fontsize=9) + ax1.grid(True, linestyle="--", alpha=0.3) + ax1.tick_params(labelsize=8) + ax1.set_xlim(0, 105) + + # 纵坐标范围由用户参数控制 + x_min_data = min(x_measured) + x_max_data = max(x_measured) + data_range_x = x_max_data - x_min_data + + self.log_gui.log(f" x数据波动: {data_range_x:.6f}") + + range_span = x_tolerance * 2 + margin_ratio = 0.20 + extra_margin = range_span * margin_ratio + + final_y_min = min(x_min_data, x_low) - extra_margin + final_y_max = max(x_max_data, x_high) + extra_margin + + if x_min_data >= x_low and x_max_data <= x_high: + self.log_gui.log(f" x数据在tolerance范围内,使用tolerance范围显示") + final_y_min = x_low - extra_margin + final_y_max = x_high + extra_margin + else: + self.log_gui.log(f" x数据超出tolerance范围,扩展显示范围") + + ax1.set_ylim(final_y_min, final_y_max) + self.log_gui.log( + f" x轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})" + ) + + # ========== 下图:y coordinates ========== + ax2.plot( + grayscale, + y_measured, + "r-o", + label="屏本体", + linewidth=2, + markersize=4, + zorder=5, + ) + + # 为每个点添加数值标注(y 坐标) + for i, (gs, y_val) in enumerate(zip(grayscale, y_measured)): + ax2.annotate( + f"{y_val:.5f}", + xy=(gs, y_val), + xytext=(0, 8), + textcoords="offset points", + ha="center", + va="bottom", + fontsize=7, + color="red", + bbox=dict( + boxstyle="round,pad=0.2", + facecolor="white", + edgecolor="red", + alpha=0.8, + linewidth=0.5, + ), + ) + + ax2.axhline( + y=y_ideal, + color="green", + linestyle="--", + linewidth=1.5, + label=f"y-ideal ({y_ideal:.4f})", + zorder=3, + ) + ax2.axhline( + y=y_low, + color="orange", + linestyle=":", + linewidth=1, + alpha=0.7, + label=f"y-low ({y_low:.4f})", + zorder=2, + ) + ax2.axhline( + y=y_high, + color="orange", + linestyle=":", + linewidth=1, + alpha=0.7, + label=f"y-high ({y_high:.4f})", + zorder=2, + ) + ax2.fill_between( + full_grayscale, y_low, y_high, alpha=0.15, color="orange", zorder=1 + ) + + ax2.set_xlabel("灰阶 (%)", fontsize=9) + ax2.set_ylabel("CIE y", fontsize=9) + ax2.grid(True, linestyle="--", alpha=0.3) + ax2.tick_params(labelsize=8) + ax2.set_xlim(0, 105) + + # 纵坐标范围由用户参数控制 + y_min_data = min(y_measured) + y_max_data = max(y_measured) + data_range_y = y_max_data - y_min_data + + self.log_gui.log(f" y数据波动: {data_range_y:.6f}") + + range_span = y_tolerance * 2 + extra_margin = range_span * margin_ratio + + final_y_min = min(y_min_data, y_low) - extra_margin + final_y_max = max(y_max_data, y_high) + extra_margin + + if y_min_data >= y_low and y_max_data <= y_high: + self.log_gui.log(f" y数据在tolerance范围内,使用tolerance范围显示") + final_y_min = y_low - extra_margin + final_y_max = y_high + extra_margin + else: + self.log_gui.log(f" y数据超出tolerance范围,扩展显示范围") + + ax2.set_ylim(final_y_min, final_y_max) + self.log_gui.log( + f" y轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})" + ) + + # ========== 总标题 - 统一格式(去掉统计信息)========== + test_type_name = self.get_test_type_name(test_type) + + self.cct_fig.suptitle( + f"{test_type_name} - 色度一致性测试", + fontsize=12, + y=0.98, + fontweight="bold", + ) + + self.cct_fig.subplots_adjust( + left=0.12, + right=0.82, + top=0.92, + bottom=0.08, + hspace=0.30, + ) + + ax1.legend( + fontsize=7, loc="center left", bbox_to_anchor=(1.05, 0.5), framealpha=1.0 + ) + ax2.legend( + fontsize=7, loc="center left", bbox_to_anchor=(1.05, 0.5), framealpha=1.0 + ) + + self.cct_canvas.draw() + self.chart_notebook.select(2) + + self.log_gui.log("✓ xy 色度坐标图绘制完成") diff --git a/app/plots/plot_contrast.py b/app/plots/plot_contrast.py new file mode 100644 index 0000000..3917e31 --- /dev/null +++ b/app/plots/plot_contrast.py @@ -0,0 +1,168 @@ +"""对比度测试结果绘制。 + +Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_contrast 原样搬迁。 +""" + +from matplotlib.patches import Rectangle + + +def plot_contrast(app, contrast_data, test_type): + """绘制对比度测试结果 - 固定布局版本""" + self = app + + # 清空并重置 + self.contrast_ax.clear() + self.contrast_ax.set_xlim(0, 1) + self.contrast_ax.set_ylim(0, 1) + self.contrast_ax.axis("off") + + # 强制重置布局 + self.contrast_fig.subplots_adjust( + left=0.02, + right=0.98, + top=0.90, + bottom=0.02, + ) + + max_lum = contrast_data["max_luminance"] + min_lum = contrast_data["min_luminance"] + contrast = contrast_data["contrast_ratio"] + + # 确定等级和颜色 + if contrast >= 5000: + grade, grade_color = "优秀", "#4CAF50" + elif contrast >= 3000: + grade, grade_color = "良好", "#8BC34A" + elif contrast >= 1000: + grade, grade_color = "合格", "#FFC107" + else: + grade, grade_color = "不合格", "#F44336" + + test_type_name = self.get_test_type_name(test_type) + + # ========== 顶部标题 - 统一格式 ========== + self.contrast_fig.suptitle( + f"{test_type_name} - 对比度测试", + fontsize=12, + y=0.98, + fontweight="bold", + ) + + # ========== 中央大对比度卡片 ========== + center_card = Rectangle( + (0.15, 0.48), + 0.70, + 0.32, + transform=self.contrast_ax.transAxes, + facecolor=grade_color, + edgecolor="black", + linewidth=2.5, + alpha=0.15, + ) + self.contrast_ax.add_patch(center_card) + + # 对比度数值 + self.contrast_ax.text( + 0.5, + 0.65, + f"{contrast:.0f} : 1", + ha="center", + va="center", + fontsize=36, + fontweight="bold", + color=grade_color, + transform=self.contrast_ax.transAxes, + ) + + # 等级标签 + self.contrast_ax.text( + 0.5, + 0.51, + f"等级: {grade}", + ha="center", + va="center", + fontsize=12, + fontweight="bold", + color=grade_color, + transform=self.contrast_ax.transAxes, + ) + + # ========== 两个信息卡片(缩小)========== + card_width = 0.32 + card_height = 0.22 + card_y = 0.12 + + gap = 0.05 + total_width = card_width * 2 + gap + start_x = (1 - total_width) / 2 + + cards_data = [ + { + "x": start_x, + "title": "白场亮度", + "value": f"{max_lum:.2f}", + "unit": "cd/m²", + "color": "#E3F2FD", + "edge_color": "#2196F3", + }, + { + "x": start_x + card_width + gap, + "title": "黑场亮度", + "value": f"{min_lum:.4f}", + "unit": "cd/m²", + "color": "#F3E5F5", + "edge_color": "#9C27B0", + }, + ] + + for card in cards_data: + # 绘制卡片背景 + rect = Rectangle( + (card["x"], card_y), + card_width, + card_height, + transform=self.contrast_ax.transAxes, + facecolor=card["color"], + edgecolor=card["edge_color"], + linewidth=2, + ) + self.contrast_ax.add_patch(rect) + + # 标题 + self.contrast_ax.text( + card["x"] + card_width / 2, + card_y + card_height - 0.03, + card["title"], + ha="center", + va="top", + fontsize=10, + fontweight="bold", + transform=self.contrast_ax.transAxes, + ) + + # 数值 + self.contrast_ax.text( + card["x"] + card_width / 2, + card_y + card_height / 2, + card["value"], + ha="center", + va="center", + fontsize=16, + fontweight="bold", + transform=self.contrast_ax.transAxes, + ) + + # 单位 + self.contrast_ax.text( + card["x"] + card_width / 2, + card_y + 0.03, + card["unit"], + ha="center", + va="bottom", + fontsize=9, + color="gray", + transform=self.contrast_ax.transAxes, + ) + + self.contrast_canvas.draw() + self.chart_notebook.select(3) diff --git a/app/plots/plot_eotf.py b/app/plots/plot_eotf.py new file mode 100644 index 0000000..ee1cd6f --- /dev/null +++ b/app/plots/plot_eotf.py @@ -0,0 +1,149 @@ +"""EOTF 曲线绘制(HDR)。 + +Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。 +""" + +import numpy as np + + +def plot_eotf(app, L_bar, results_with_eotf_list, test_type): + """绘制 EOTF 曲线 + 数据表格(HDR 专用,包含实测亮度)""" + self = app + + # ========== 1. 清空并重置左侧曲线 ========== + self.eotf_ax.clear() + self.eotf_ax.set_xlim(0, 105) + self.eotf_ax.set_ylim(0, 1.1) + self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10) + self.eotf_ax.set_ylabel("L_bar", fontsize=10) + self.eotf_ax.grid(True, linestyle="--", alpha=0.3) + self.eotf_ax.tick_params(labelsize=9) + + # 生成横坐标(灰阶百分比) + x_values = np.linspace(0, 100, len(L_bar)) + + # 反转 L_bar + if len(L_bar) > 1 and L_bar[0] > L_bar[-1]: + L_bar = L_bar[::-1] + results_with_eotf_list = results_with_eotf_list[::-1] + + # 计算平均 EOTF Gamma + eotf_values = [] + for item in results_with_eotf_list: + if isinstance(item, (list, tuple)) and len(item) >= 4: + eotf = item[3] + if 0.5 < eotf < 5.0: + eotf_values.append(eotf) + + avg_eotf = np.mean(eotf_values) if eotf_values else 2.2 + + # 绘制实测曲线 + self.eotf_ax.plot( + x_values, + L_bar, + "b-o", + label=f"实测 (平均γ={avg_eotf:.2f})", + linewidth=2, + markersize=4, + zorder=5, + ) + + # 绘制 PQ (ST.2084) 理想曲线 + pq_L_bar = self.calculate_pq_curve(x_values) + self.eotf_ax.plot( + x_values, + pq_L_bar, + "r--", + label="理想 PQ (ST.2084)", + linewidth=2, + alpha=0.7, + zorder=3, + ) + + # 图例 + self.eotf_ax.legend(fontsize=9, loc="upper left", framealpha=0.95) + + # ========== 2. 清空并绘制右侧表格 ========== + self.eotf_table_ax.clear() + self.eotf_table_ax.axis("off") + + # 构建表格数据(4列) + table_data = [["灰阶", "实测亮度\n(cd/m²)", "L_bar", "EOTF γ"]] + + for i, (x_val, L_val, result) in enumerate( + zip(x_values, L_bar, results_with_eotf_list) + ): + # 提取实测亮度 + if isinstance(result, (list, tuple)) and len(result) >= 3: + measured_lv = result[2] + measured_lv_str = f"{measured_lv:.2f}" + else: + measured_lv_str = "--" + + # 提取 EOTF + if isinstance(result, (list, tuple)) and len(result) >= 4: + eotf = result[3] + if eotf < 0.5 or eotf > 5.0: + eotf_str = "--" + else: + eotf_str = f"{eotf:.2f}" + else: + eotf_str = "--" + + table_data.append( + [ + f"{x_val:.0f}%", + measured_lv_str, + f"{L_val:.3f}", + eotf_str, + ] + ) + + # 绘制表格(4列) + table = self.eotf_table_ax.table( + cellText=table_data, + cellLoc="center", + loc="center", + colWidths=[0.18, 0.28, 0.27, 0.27], + ) + + # 美化表格 + table.auto_set_font_size(False) + table.set_fontsize(7.5) + table.scale(1, 1.5) + + # 表头样式 + for i in range(4): + cell = table[(0, i)] + cell.set_facecolor("#4472C4") + cell.set_text_props(weight="bold", color="white") + + # 数据行交替颜色 + for i in range(1, len(table_data)): + for j in range(4): + cell = table[(i, j)] + if i % 2 == 0: + cell.set_facecolor("#E7E6E6") + else: + cell.set_facecolor("#FFFFFF") + + # ========== 3. 总标题 ========== + test_type_name = self.get_test_type_name(test_type) + self.eotf_fig.suptitle( + f"{test_type_name} - EOTF 曲线(PQ ST.2084)", + fontsize=12, + y=0.98, + fontweight="bold", + ) + + # 选中 EOTF Tab + try: + eotf_tab_id = str(self.eotf_chart_frame) + current_tabs = list(self.chart_notebook.tabs()) + if eotf_tab_id in current_tabs: + eotf_index = current_tabs.index(eotf_tab_id) + self.chart_notebook.select(eotf_index) + except: + pass + + self.log_gui.log("EOTF 曲线 + 数据表格绘制完成") diff --git a/app/plots/plot_gamma.py b/app/plots/plot_gamma.py new file mode 100644 index 0000000..fd28a86 --- /dev/null +++ b/app/plots/plot_gamma.py @@ -0,0 +1,143 @@ +"""Gamma 曲线绘制。 + +Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。 +""" + +import numpy as np + + +def plot_gamma(app, L_bar, results_with_gamma_list, target_gamma, test_type): + """绘制Gamma曲线 + 数据表格(包含实测亮度)""" + self = app + + # ========== 1. 清空并重置左侧曲线 ========== + self.gamma_ax.clear() + self.gamma_ax.set_xlim(0, 105) + self.gamma_ax.set_ylim(0, 1.1) + self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10) + self.gamma_ax.set_ylabel("L_bar", fontsize=10) + self.gamma_ax.grid(True, linestyle="--", alpha=0.3) + self.gamma_ax.tick_params(labelsize=9) + + # 生成横坐标(灰阶百分比) + x_values = np.linspace(0, 100, len(L_bar)) + + # 反转 L_bar(确保从左到右是 0% → 100%) + if len(L_bar) > 1 and L_bar[0] > L_bar[-1]: + L_bar = L_bar[::-1] + results_with_gamma_list = results_with_gamma_list[::-1] + + # 计算平均Gamma + gamma_values = [] + for item in results_with_gamma_list: + if isinstance(item, (list, tuple)) and len(item) >= 4: + gamma = item[3] + if 0.5 < gamma < 5.0: + gamma_values.append(gamma) + + avg_gamma = np.mean(gamma_values) if gamma_values else target_gamma + + # 绘制实测曲线 + self.gamma_ax.plot( + x_values, + L_bar, + "b-o", + label=f"实测 (平均γ={avg_gamma:.2f})", + linewidth=2, + markersize=4, + zorder=5, + ) + + # 绘制理想曲线(使用 target_gamma) + ideal_L_bar = [(x / 100) ** target_gamma for x in x_values] + self.gamma_ax.plot( + x_values, + ideal_L_bar, + "r--", + label=f"理想 (γ={target_gamma})", + linewidth=2, + alpha=0.7, + zorder=3, + ) + + # 图例 + self.gamma_ax.legend(fontsize=9, loc="upper left", framealpha=0.95) + + # ========== 2. 清空并绘制右侧表格 ========== + self.gamma_table_ax.clear() + self.gamma_table_ax.axis("off") + + # 构建表格数据(4列) + table_data = [["灰阶", "实测亮度\n(cd/m²)", "L_bar", "Gamma"]] + + for i, (x_val, L_val, result) in enumerate( + zip(x_values, L_bar, results_with_gamma_list) + ): + # 提取实测亮度 + if isinstance(result, (list, tuple)) and len(result) >= 3: + measured_lv = result[2] + measured_lv_str = f"{measured_lv:.2f}" + else: + measured_lv_str = "--" + + # 提取 Gamma + if isinstance(result, (list, tuple)) and len(result) >= 4: + gamma = result[3] + if gamma < 0.5 or gamma > 5.0: + gamma_str = "--" + else: + gamma_str = f"{gamma:.2f}" + else: + gamma_str = "--" + + table_data.append( + [ + f"{x_val:.0f}%", + measured_lv_str, + f"{L_val:.3f}", + gamma_str, + ] + ) + + # 绘制表格(4列) + table = self.gamma_table_ax.table( + cellText=table_data, + cellLoc="center", + loc="center", + colWidths=[0.18, 0.28, 0.27, 0.27], + ) + + # 美化表格 + table.auto_set_font_size(False) + table.set_fontsize(7.5) + table.scale(1, 1.5) + + # 表头样式 + for i in range(4): + cell = table[(0, i)] + cell.set_facecolor("#4472C4") + cell.set_text_props(weight="bold", color="white") + + # 数据行交替颜色 + for i in range(1, len(table_data)): + for j in range(4): + cell = table[(i, j)] + if i % 2 == 0: + cell.set_facecolor("#E7E6E6") + else: + cell.set_facecolor("#FFFFFF") + + # ========== 3. 总标题 ========== + test_type_name = self.get_test_type_name(test_type) + self.gamma_fig.suptitle( + f"{test_type_name} - Gamma曲线", + fontsize=12, + y=0.98, + fontweight="bold", + ) + + # ========== 4. 绘制到画布 ========== + self.gamma_canvas.draw() + self.chart_notebook.select(1) + + self.log_gui.log("Gamma曲线 + 数据表格绘制完成") diff --git a/app/plots/plot_gamut.py b/app/plots/plot_gamut.py new file mode 100644 index 0000000..3001d33 --- /dev/null +++ b/app/plots/plot_gamut.py @@ -0,0 +1,539 @@ +"""色域图(Gamut)绘制。 + +Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamut 整体搬迁, +实现与原方法完全一致;原方法仅保留为一行转发。 +""" + +import matplotlib.image as mpimg + +import algorithm.pq_algorithm as pq_algorithm +from app.resources import get_resource_path + + +def plot_gamut(app, results, coverage, test_type): + """绘制色域图 - 根据用户选择的参考标准动态计算覆盖率""" + # 实现从原 PQAutomationApp 方法体原样搬迁,为减少修改面 + # 范围、保持行为一致,给 self 赋值为传入的 app 实例。 + self = app + + self.gamut_ax_xy.clear() + self.gamut_ax_uv.clear() + + # ==================== 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 + + # ========== ✅ 读取用户选择的参考标准 ========== + 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" + + # ========== ✅✅✅ 根据参考标准重新计算覆盖率(XY 空间)========== + xy_coverage = coverage # 默认使用传入的值 + uv_coverage = 0.0 + + try: + # 提取前 3 个 RGB 点的 xy 坐标 + if len(results) >= 3: + xy_points = [[result[0], result[1]] for result in results[:3]] + + # 根据参考标准计算 XY 覆盖率 + if current_ref == "BT.2020": + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020( + xy_points + ) + elif current_ref == "BT.709": + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709( + xy_points + ) + elif current_ref == "DCI-P3": + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( + xy_points + ) + 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") + _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( + xy_points + ) + current_ref = "DCI-P3" + + self.log_gui.log( + f"✓ XY 空间覆盖率({current_ref}): {xy_coverage:.1f}%" + ) + + except Exception as e: + self.log_gui.log(f"⚠️ 重新计算 XY 覆盖率失败: {str(e)}") + xy_coverage = coverage # 回退到传入值 + # ================================================= + + # ========== 左图:CIE 1931 xy ========== + try: + img_xy = mpimg.imread(get_resource_path("assets/cie.png")) + h_xy, w_xy = img_xy.shape[:2] + + self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}") + + 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] + + 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})" + ) + + # ========== 绘制测量三角形 ========== + 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, + ) + + # ========== 标注 RGB 点 ========== + labels = ["R", "G", "B"] + 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) + + # 自适应偏移 + 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, + ) + + except Exception as e: + self.log_gui.log(f"XY 图绘制失败: {str(e)}") + import traceback + + self.log_gui.log(traceback.format_exc()) + + # ========== 右图:CIE 1976 u'v' ========== + try: + img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png")) + h_uv, w_uv = img_uv.shape[:2] + + self.log_gui.log(f"加载 UV 色域图: {w_uv}x{h_uv}") + + 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}") + + # ========== ✅✅✅ 计算 u'v' 覆盖率(使用参考标准)========== + try: + uv_coverage = pq_algorithm.calculate_uv_gamut_coverage( + uv_coords, reference=current_ref + ) + self.log_gui.log( + f"✓ UV 空间覆盖率({current_ref}): {uv_coverage:.1f}%" + ) + except Exception as e: + self.log_gui.log(f"⚠️ 计算 UV 覆盖率失败: {str(e)}") + 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, + ) + + # ========== 标注 RGB 点 ========== + labels = ["R", "G", "B"] + for (u, v), label in zip(uv_coords, labels): + px, py = cie_uv_to_pixel(u, v) + + # 自适应偏移 + 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) + + 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, + ) + + except Exception as e: + self.log_gui.log(f"UV 图绘制失败: {str(e)}") + import traceback + + self.log_gui.log(traceback.format_exc()) + + # ========== 总标题 ========== + 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(0) + + self.log_gui.log("色域图绘制完成") diff --git a/pqAutomationApp.py b/pqAutomationApp.py index a3e0d60..ceb51f1 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -47,6 +47,12 @@ from app.tests.color_accuracy import ( from app.tests.eotf import calculate_pq_curve as _calc_pq_curve from app.tests.gamma import calculate_gamma as _calc_gamma from app.tests.gamut import calculate_gamut_coverage as _calc_gamut_coverage +from app.plots.plot_accuracy import plot_accuracy as _plot_accuracy +from app.plots.plot_cct import plot_cct as _plot_cct +from app.plots.plot_contrast import plot_contrast as _plot_contrast +from app.plots.plot_eotf import plot_eotf as _plot_eotf +from app.plots.plot_gamma import plot_gamma as _plot_gamma +from app.plots.plot_gamut import plot_gamut as _plot_gamut plt.rcParams["font.family"] = ["sans-serif"] plt.rcParams["font.sans-serif"] = ["Microsoft YaHei"] @@ -6885,1599 +6891,27 @@ class PQAutomationApp: return _calc_color_accuracy(measured, standard) def plot_gamut(self, results, coverage, test_type): - """绘制色域图 - 根据用户选择的参考标准动态计算覆盖率""" - - self.gamut_ax_xy.clear() - self.gamut_ax_uv.clear() - - # ==================== 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 - - # ========== ✅ 读取用户选择的参考标准 ========== - 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" - - # ========== ✅✅✅ 根据参考标准重新计算覆盖率(XY 空间)========== - xy_coverage = coverage # 默认使用传入的值 - uv_coverage = 0.0 - - try: - # 提取前 3 个 RGB 点的 xy 坐标 - if len(results) >= 3: - xy_points = [[result[0], result[1]] for result in results[:3]] - - # 根据参考标准计算 XY 覆盖率 - if current_ref == "BT.2020": - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020( - xy_points - ) - elif current_ref == "BT.709": - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709( - xy_points - ) - elif current_ref == "DCI-P3": - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( - xy_points - ) - 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") - _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( - xy_points - ) - current_ref = "DCI-P3" - - self.log_gui.log( - f"✓ XY 空间覆盖率({current_ref}): {xy_coverage:.1f}%" - ) - - except Exception as e: - self.log_gui.log(f"⚠️ 重新计算 XY 覆盖率失败: {str(e)}") - xy_coverage = coverage # 回退到传入值 - # ================================================= - - # ========== 左图:CIE 1931 xy ========== - try: - img_xy = mpimg.imread(get_resource_path("assets/cie.png")) - h_xy, w_xy = img_xy.shape[:2] - - self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}") - - 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] - - 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})" - ) - - # ========== 绘制测量三角形 ========== - 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, - ) - - # ========== 标注 RGB 点 ========== - labels = ["R", "G", "B"] - 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) - - # 自适应偏移 - 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, - ) - - except Exception as e: - self.log_gui.log(f"XY 图绘制失败: {str(e)}") - import traceback - - self.log_gui.log(traceback.format_exc()) - - # ========== 右图:CIE 1976 u'v' ========== - try: - img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png")) - h_uv, w_uv = img_uv.shape[:2] - - self.log_gui.log(f"加载 UV 色域图: {w_uv}x{h_uv}") - - 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}") - - # ========== ✅✅✅ 计算 u'v' 覆盖率(使用参考标准)========== - try: - uv_coverage = pq_algorithm.calculate_uv_gamut_coverage( - uv_coords, reference=current_ref # ← 传入用户选择的参考标准 - ) - self.log_gui.log( - f"✓ UV 空间覆盖率({current_ref}): {uv_coverage:.1f}%" - ) - except Exception as e: - self.log_gui.log(f"⚠️ 计算 UV 覆盖率失败: {str(e)}") - 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, - ) - - # ========== 标注 RGB 点 ========== - labels = ["R", "G", "B"] - for (u, v), label in zip(uv_coords, labels): - px, py = cie_uv_to_pixel(u, v) - - # 自适应偏移 - 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) - - 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], # Red - [0.0999, 0.5780], # Green - [0.1754, 0.1576], # Blue - [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], # Red - [0.0556, 0.5868], # Green - [0.1593, 0.1258], # Blue - [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], # Red - [0.1250, 0.5625], # Green - [0.1754, 0.1576], # Blue - [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], # Red - [0.1291, 0.5606], # Green - [0.1787, 0.1610], # Blue - [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: - self.log_gui.log(f"UV 图绘制失败: {str(e)}") - import traceback - - self.log_gui.log(traceback.format_exc()) - - # ========== 总标题 ========== - 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(0) - - self.log_gui.log("色域图绘制完成") - + """转发到 app.plots.plot_gamut.plot_gamut(Step 2 重构)""" + return _plot_gamut(self, results, coverage, test_type) def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type): - """绘制Gamma曲线 + 数据表格(包含实测亮度)""" - # ========== 1. 清空并重置左侧曲线 ========== - self.gamma_ax.clear() - self.gamma_ax.set_xlim(0, 105) - self.gamma_ax.set_ylim(0, 1.1) - self.gamma_ax.set_xlabel("灰阶 (%)", fontsize=10) - self.gamma_ax.set_ylabel("L_bar", fontsize=10) - self.gamma_ax.grid(True, linestyle="--", alpha=0.3) - self.gamma_ax.tick_params(labelsize=9) - - # 生成横坐标(灰阶百分比) - x_values = np.linspace(0, 100, len(L_bar)) - - # 反转 L_bar(确保从左到右是 0% → 100%) - if len(L_bar) > 1 and L_bar[0] > L_bar[-1]: - L_bar = L_bar[::-1] - results_with_gamma_list = results_with_gamma_list[::-1] - - # 计算平均Gamma - gamma_values = [] - for item in results_with_gamma_list: - if isinstance(item, (list, tuple)) and len(item) >= 4: - gamma = item[3] - if 0.5 < gamma < 5.0: - gamma_values.append(gamma) - - avg_gamma = np.mean(gamma_values) if gamma_values else target_gamma - - # 绘制实测曲线 - self.gamma_ax.plot( - x_values, - L_bar, - "b-o", - label=f"实测 (平均γ={avg_gamma:.2f})", - linewidth=2, - markersize=4, - zorder=5, - ) - - # 绘制理想曲线(使用 target_gamma) - ideal_L_bar = [(x / 100) ** target_gamma for x in x_values] - self.gamma_ax.plot( - x_values, - ideal_L_bar, - "r--", - label=f"理想 (γ={target_gamma})", # ← 显示实际的 target_gamma - linewidth=2, - alpha=0.7, - zorder=3, - ) - - # 图例 - self.gamma_ax.legend(fontsize=9, loc="upper left", framealpha=0.95) - - # ========== 2. 清空并绘制右侧表格 ========== - self.gamma_table_ax.clear() - self.gamma_table_ax.axis("off") - - # 构建表格数据(4列) - table_data = [["灰阶", "实测亮度\n(cd/m²)", "L_bar", "Gamma"]] - - for i, (x_val, L_val, result) in enumerate( - zip(x_values, L_bar, results_with_gamma_list) - ): - # 提取实测亮度 - if isinstance(result, (list, tuple)) and len(result) >= 3: - measured_lv = result[2] - measured_lv_str = f"{measured_lv:.2f}" - else: - measured_lv_str = "--" - - # 提取 Gamma - if isinstance(result, (list, tuple)) and len(result) >= 4: - gamma = result[3] - if gamma < 0.5 or gamma > 5.0: - gamma_str = "--" - else: - gamma_str = f"{gamma:.2f}" - else: - gamma_str = "--" - - table_data.append( - [ - f"{x_val:.0f}%", - measured_lv_str, # ← 实测亮度 - f"{L_val:.3f}", - gamma_str, - ] - ) - - # 绘制表格(4列) - table = self.gamma_table_ax.table( - cellText=table_data, - cellLoc="center", - loc="center", - colWidths=[0.18, 0.28, 0.27, 0.27], - ) - - # 美化表格 - table.auto_set_font_size(False) - table.set_fontsize(7.5) - table.scale(1, 1.5) - - # 表头样式 - for i in range(4): # ← 4列 - cell = table[(0, i)] - cell.set_facecolor("#4472C4") - cell.set_text_props(weight="bold", color="white") - - # 数据行交替颜色 - for i in range(1, len(table_data)): - for j in range(4): # ← 4列 - cell = table[(i, j)] - if i % 2 == 0: - cell.set_facecolor("#E7E6E6") - else: - cell.set_facecolor("#FFFFFF") - - # ========== 3. 总标题 ========== - test_type_name = self.get_test_type_name(test_type) - self.gamma_fig.suptitle( - f"{test_type_name} - Gamma曲线", - fontsize=12, - y=0.98, - fontweight="bold", - ) - - # ========== 4. 绘制到画布 ========== - self.gamma_canvas.draw() - self.chart_notebook.select(1) - - self.log_gui.log("Gamma曲线 + 数据表格绘制完成") - + """转发到 app.plots.plot_gamma.plot_gamma(Step 2 重构)""" + return _plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type) def plot_eotf(self, L_bar, results_with_eotf_list, test_type): - """绘制 EOTF 曲线 + 数据表格(HDR 专用,包含实测亮度)""" - # ========== 1. 清空并重置左侧曲线 ========== - self.eotf_ax.clear() - self.eotf_ax.set_xlim(0, 105) - self.eotf_ax.set_ylim(0, 1.1) - self.eotf_ax.set_xlabel("灰阶 (%)", fontsize=10) - self.eotf_ax.set_ylabel("L_bar", fontsize=10) - self.eotf_ax.grid(True, linestyle="--", alpha=0.3) - self.eotf_ax.tick_params(labelsize=9) - - # 生成横坐标(灰阶百分比) - x_values = np.linspace(0, 100, len(L_bar)) - - # 反转 L_bar - if len(L_bar) > 1 and L_bar[0] > L_bar[-1]: - L_bar = L_bar[::-1] - results_with_eotf_list = results_with_eotf_list[::-1] - - # 计算平均 EOTF Gamma - eotf_values = [] - for item in results_with_eotf_list: - if isinstance(item, (list, tuple)) and len(item) >= 4: - eotf = item[3] - if 0.5 < eotf < 5.0: - eotf_values.append(eotf) - - avg_eotf = np.mean(eotf_values) if eotf_values else 2.2 - - # 绘制实测曲线 - self.eotf_ax.plot( - x_values, - L_bar, - "b-o", - label=f"实测 (平均γ={avg_eotf:.2f})", - linewidth=2, - markersize=4, - zorder=5, - ) - - # 绘制 PQ (ST.2084) 理想曲线 - pq_L_bar = self.calculate_pq_curve(x_values) - self.eotf_ax.plot( - x_values, - pq_L_bar, - "r--", - label="理想 PQ (ST.2084)", - linewidth=2, - alpha=0.7, - zorder=3, - ) - - # 图例 - self.eotf_ax.legend(fontsize=9, loc="upper left", framealpha=0.95) - - # ========== 2. 清空并绘制右侧表格 ========== - self.eotf_table_ax.clear() - self.eotf_table_ax.axis("off") - - # 构建表格数据(4列) - table_data = [["灰阶", "实测亮度\n(cd/m²)", "L_bar", "EOTF γ"]] - - for i, (x_val, L_val, result) in enumerate( - zip(x_values, L_bar, results_with_eotf_list) - ): - # 提取实测亮度 - if isinstance(result, (list, tuple)) and len(result) >= 3: - measured_lv = result[2] - measured_lv_str = f"{measured_lv:.2f}" - else: - measured_lv_str = "--" - - # 提取 EOTF - if isinstance(result, (list, tuple)) and len(result) >= 4: - eotf = result[3] - if eotf < 0.5 or eotf > 5.0: - eotf_str = "--" - else: - eotf_str = f"{eotf:.2f}" - else: - eotf_str = "--" - - table_data.append( - [ - f"{x_val:.0f}%", - measured_lv_str, # ← 实测亮度 - f"{L_val:.3f}", - eotf_str, - ] - ) - - # 绘制表格(4列) - table = self.eotf_table_ax.table( - cellText=table_data, - cellLoc="center", - loc="center", - colWidths=[0.18, 0.28, 0.27, 0.27], - ) - - # 美化表格 - table.auto_set_font_size(False) - table.set_fontsize(7.5) - table.scale(1, 1.5) - - # 表头样式 - for i in range(4): # ← 4列 - cell = table[(0, i)] - cell.set_facecolor("#4472C4") - cell.set_text_props(weight="bold", color="white") - - # 数据行交替颜色 - for i in range(1, len(table_data)): - for j in range(4): # ← 4列 - cell = table[(i, j)] - if i % 2 == 0: - cell.set_facecolor("#E7E6E6") - else: - cell.set_facecolor("#FFFFFF") - - # ========== 3. 总标题 ========== - test_type_name = self.get_test_type_name(test_type) - self.eotf_fig.suptitle( - f"{test_type_name} - EOTF 曲线(PQ ST.2084)", - fontsize=12, - y=0.98, - fontweight="bold", - ) - - # ========== 4. 绘制到画布 ========== - self.eotf_canvas.draw() - - # 选中 EOTF Tab - try: - eotf_tab_id = str(self.eotf_chart_frame) - current_tabs = list(self.chart_notebook.tabs()) - if eotf_tab_id in current_tabs: - eotf_index = current_tabs.index(eotf_tab_id) - self.chart_notebook.select(eotf_index) - except: - pass - - self.log_gui.log("EOTF 曲线 + 数据表格绘制完成") - + """转发到 app.plots.plot_eotf.plot_eotf(Step 2 重构)""" + return _plot_eotf(self, L_bar, results_with_eotf_list, test_type) def calculate_pq_curve(self, gray_levels): """转发到 app.tests.eotf.calculate_pq_curve(Step 1 重构)""" return _calc_pq_curve(gray_levels) def plot_cct(self, test_type): - """绘制 x 和 y 坐标分离图 - 每个点标注纵坐标值""" - self.cct_fig.clear() - - gray_data = self.results.get_intermediate_data("shared", "gray") - if not gray_data: - gray_data = self.results.get_intermediate_data("cct", "gray") - - if not gray_data or len(gray_data) < 2: - self.log_gui.log("⚠️ 无 xy 数据可用") - ax = self.cct_fig.add_subplot(111) - ax.text( - 0.5, - 0.5, - "无可用数据", - ha="center", - va="center", - fontsize=14, - color="red", - ) - ax.axis("off") - self.cct_canvas.draw() - return - - x_measured = [data[0] for data in gray_data] - y_measured = [data[1] for data in gray_data] - - # 反转数据顺序(从暗到亮) - x_measured = x_measured[::-1] - y_measured = y_measured[::-1] - - # 去掉第一个点 - x_measured = x_measured[1:] - y_measured = y_measured[1:] - - # 重新生成灰阶坐标 - total_points = len(gray_data) - grayscale = np.linspace(100 / total_points, 100, len(x_measured)) - - self.log_gui.log(f"✓ 已移除第一个数据点,当前数据点数: {len(x_measured)}") - self.log_gui.log(f" x范围: {min(x_measured):.6f} - {max(x_measured):.6f}") - self.log_gui.log(f" y范围: {min(y_measured):.6f} - {max(y_measured):.6f}") - - # ========== 根据测试类型读取对应参数 ========== - if test_type == "sdr_movie": - try: - x_ideal = float(self.sdr_cct_x_ideal_var.get()) - x_tolerance = float(self.sdr_cct_x_tolerance_var.get()) - y_ideal = float(self.sdr_cct_y_ideal_var.get()) - y_tolerance = float(self.sdr_cct_y_tolerance_var.get()) - self.log_gui.log("✓ 使用 SDR 色度参数") - except: - x_ideal = 0.3127 - x_tolerance = 0.003 - y_ideal = 0.3290 - y_tolerance = 0.003 - self.log_gui.log("⚠️ SDR 参数读取失败,使用默认值") - elif test_type == "hdr_movie": - try: - x_ideal = float(self.hdr_cct_x_ideal_var.get()) - x_tolerance = float(self.hdr_cct_x_tolerance_var.get()) - y_ideal = float(self.hdr_cct_y_ideal_var.get()) - y_tolerance = float(self.hdr_cct_y_tolerance_var.get()) - self.log_gui.log("✓ 使用 HDR 色度参数") - except: - x_ideal = 0.3127 - x_tolerance = 0.003 - y_ideal = 0.3290 - y_tolerance = 0.003 - self.log_gui.log("⚠️ HDR 参数读取失败,使用默认值") - else: # screen_module - try: - x_ideal = float(self.cct_x_ideal_var.get()) - x_tolerance = float(self.cct_x_tolerance_var.get()) - y_ideal = float(self.cct_y_ideal_var.get()) - y_tolerance = float(self.cct_y_tolerance_var.get()) - self.log_gui.log("✓ 使用屏模组色度参数") - except: - x_ideal = 0.306 - x_tolerance = 0.003 - y_ideal = 0.318 - y_tolerance = 0.003 - self.log_gui.log("⚠️ 屏模组参数读取失败,使用默认值") - - x_low = x_ideal - x_tolerance - x_high = x_ideal + x_tolerance - y_low = y_ideal - y_tolerance - y_high = y_ideal + y_tolerance - - self.log_gui.log(f"✓ 用户设置参数:") - self.log_gui.log(f" x-ideal={x_ideal:.4f}, tolerance={x_tolerance:.4f}") - self.log_gui.log(f" x范围: [{x_low:.4f}, {x_high:.4f}]") - self.log_gui.log(f" y-ideal={y_ideal:.4f}, tolerance={y_tolerance:.4f}") - self.log_gui.log(f" y范围: [{y_low:.4f}, {y_high:.4f}]") - - # 为所有测试类型创建子图 - ax1 = self.cct_fig.add_subplot(211) - ax2 = self.cct_fig.add_subplot(212) - - # ========== 上图:x coordinates ========== - ax1.plot( - grayscale, - x_measured, - "b-o", - label="屏本体", - linewidth=2, - markersize=4, - zorder=5, - ) - - # 为每个点添加数值标注(x 坐标) - for i, (gs, x_val) in enumerate(zip(grayscale, x_measured)): - ax1.annotate( - f"{x_val:.5f}", # 显示 5 位小数 - xy=(gs, x_val), - xytext=(0, 8), # 向上偏移 8 点 - textcoords="offset points", - ha="center", - va="bottom", - fontsize=7, - color="blue", - bbox=dict( - boxstyle="round,pad=0.2", - facecolor="white", - edgecolor="blue", - alpha=0.8, - linewidth=0.5, - ), - ) - - # 绘制完整的参考线 - full_grayscale = np.linspace(0, 100, 100) - ax1.axhline( - y=x_ideal, - color="green", - linestyle="--", - linewidth=1.5, - label=f"x-ideal ({x_ideal:.4f})", - zorder=3, - ) - ax1.axhline( - y=x_low, - color="red", - linestyle=":", - linewidth=1, - alpha=0.7, - label=f"x-low ({x_low:.4f})", - zorder=2, - ) - ax1.axhline( - y=x_high, - color="red", - linestyle=":", - linewidth=1, - alpha=0.7, - label=f"x-high ({x_high:.4f})", - zorder=2, - ) - ax1.fill_between( - full_grayscale, x_low, x_high, alpha=0.15, color="blue", zorder=1 - ) - - ax1.set_xlabel("灰阶 (%)", fontsize=9) - ax1.set_ylabel("CIE x", fontsize=9) - ax1.grid(True, linestyle="--", alpha=0.3) - ax1.tick_params(labelsize=8) - ax1.set_xlim(0, 105) - - # 纵坐标范围由用户参数控制 - x_min_data = min(x_measured) - x_max_data = max(x_measured) - data_range_x = x_max_data - x_min_data - - self.log_gui.log(f" x数据波动: {data_range_x:.6f}") - - range_span = x_tolerance * 2 - margin_ratio = 0.20 # ← 增大边距以容纳标注 - extra_margin = range_span * margin_ratio - - final_y_min = min(x_min_data, x_low) - extra_margin - final_y_max = max(x_max_data, x_high) + extra_margin - - if x_min_data >= x_low and x_max_data <= x_high: - self.log_gui.log(f" x数据在tolerance范围内,使用tolerance范围显示") - final_y_min = x_low - extra_margin - final_y_max = x_high + extra_margin - else: - self.log_gui.log(f" x数据超出tolerance范围,扩展显示范围") - - ax1.set_ylim(final_y_min, final_y_max) - self.log_gui.log( - f" x轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})" - ) - - # ========== 下图:y coordinates ========== - ax2.plot( - grayscale, - y_measured, - "r-o", - label="屏本体", - linewidth=2, - markersize=4, - zorder=5, - ) - - # 为每个点添加数值标注(y 坐标) - for i, (gs, y_val) in enumerate(zip(grayscale, y_measured)): - ax2.annotate( - f"{y_val:.5f}", # 显示 5 位小数 - xy=(gs, y_val), - xytext=(0, 8), # 向上偏移 8 点 - textcoords="offset points", - ha="center", - va="bottom", - fontsize=7, - color="red", - bbox=dict( - boxstyle="round,pad=0.2", - facecolor="white", - edgecolor="red", - alpha=0.8, - linewidth=0.5, - ), - ) - - ax2.axhline( - y=y_ideal, - color="green", - linestyle="--", - linewidth=1.5, - label=f"y-ideal ({y_ideal:.4f})", - zorder=3, - ) - ax2.axhline( - y=y_low, - color="orange", - linestyle=":", - linewidth=1, - alpha=0.7, - label=f"y-low ({y_low:.4f})", - zorder=2, - ) - ax2.axhline( - y=y_high, - color="orange", - linestyle=":", - linewidth=1, - alpha=0.7, - label=f"y-high ({y_high:.4f})", - zorder=2, - ) - ax2.fill_between( - full_grayscale, y_low, y_high, alpha=0.15, color="orange", zorder=1 - ) - - ax2.set_xlabel("灰阶 (%)", fontsize=9) - ax2.set_ylabel("CIE y", fontsize=9) - ax2.grid(True, linestyle="--", alpha=0.3) - ax2.tick_params(labelsize=8) - ax2.set_xlim(0, 105) - - # 纵坐标范围由用户参数控制 - y_min_data = min(y_measured) - y_max_data = max(y_measured) - data_range_y = y_max_data - y_min_data - - self.log_gui.log(f" y数据波动: {data_range_y:.6f}") - - range_span = y_tolerance * 2 - extra_margin = range_span * margin_ratio - - final_y_min = min(y_min_data, y_low) - extra_margin - final_y_max = max(y_max_data, y_high) + extra_margin - - if y_min_data >= y_low and y_max_data <= y_high: - self.log_gui.log(f" y数据在tolerance范围内,使用tolerance范围显示") - final_y_min = y_low - extra_margin - final_y_max = y_high + extra_margin - else: - self.log_gui.log(f" y数据超出tolerance范围,扩展显示范围") - - ax2.set_ylim(final_y_min, final_y_max) - self.log_gui.log( - f" y轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})" - ) - - # ========== 总标题 - 统一格式(去掉统计信息)========== - test_type_name = self.get_test_type_name(test_type) - - self.cct_fig.suptitle( - f"{test_type_name} - 色度一致性测试", - fontsize=12, - y=0.98, - fontweight="bold", - ) - - self.cct_fig.subplots_adjust( - left=0.12, - right=0.82, - top=0.92, - bottom=0.08, - hspace=0.30, - ) - - ax1.legend( - fontsize=7, loc="center left", bbox_to_anchor=(1.05, 0.5), framealpha=1.0 - ) - ax2.legend( - fontsize=7, loc="center left", bbox_to_anchor=(1.05, 0.5), framealpha=1.0 - ) - - self.cct_canvas.draw() - self.chart_notebook.select(2) - - self.log_gui.log("✓ xy 色度坐标图绘制完成") - + """转发到 app.plots.plot_cct.plot_cct(Step 2 重构)""" + return _plot_cct(self, test_type) def plot_contrast(self, contrast_data, test_type): - """绘制对比度测试结果 - 固定布局版本""" - # 清空并重置 - self.contrast_ax.clear() - self.contrast_ax.set_xlim(0, 1) - self.contrast_ax.set_ylim(0, 1) - self.contrast_ax.axis("off") - - # 强制重置布局 - self.contrast_fig.subplots_adjust( - left=0.02, - right=0.98, - top=0.90, - bottom=0.02, - ) - - max_lum = contrast_data["max_luminance"] - min_lum = contrast_data["min_luminance"] - contrast = contrast_data["contrast_ratio"] - - # 确定等级和颜色 - if contrast >= 5000: - grade, grade_color = "优秀", "#4CAF50" - elif contrast >= 3000: - grade, grade_color = "良好", "#8BC34A" - elif contrast >= 1000: - grade, grade_color = "合格", "#FFC107" - else: - grade, grade_color = "不合格", "#F44336" - - test_type_name = self.get_test_type_name(test_type) - - # ========== 顶部标题 - 统一格式 ========== - self.contrast_fig.suptitle( - f"{test_type_name} - 对比度测试", - fontsize=12, - y=0.98, - fontweight="bold", - ) - - # ========== 中央大对比度卡片 ========== - from matplotlib.patches import Rectangle - - center_card = Rectangle( - (0.15, 0.48), - 0.70, - 0.32, - transform=self.contrast_ax.transAxes, - facecolor=grade_color, - edgecolor="black", - linewidth=2.5, - alpha=0.15, - ) - self.contrast_ax.add_patch(center_card) - - # 对比度数值 - self.contrast_ax.text( - 0.5, - 0.65, - f"{contrast:.0f} : 1", - ha="center", - va="center", - fontsize=36, - fontweight="bold", - color=grade_color, - transform=self.contrast_ax.transAxes, - ) - - # 等级标签 - self.contrast_ax.text( - 0.5, - 0.51, - f"等级: {grade}", - ha="center", - va="center", - fontsize=12, - fontweight="bold", - color=grade_color, - transform=self.contrast_ax.transAxes, - ) - - # ========== 两个信息卡片(缩小)========== - card_width = 0.32 - card_height = 0.22 - card_y = 0.12 - - gap = 0.05 - total_width = card_width * 2 + gap - start_x = (1 - total_width) / 2 - - cards_data = [ - { - "x": start_x, - "title": "白场亮度", - "value": f"{max_lum:.2f}", - "unit": "cd/m²", - "color": "#E3F2FD", - "edge_color": "#2196F3", - }, - { - "x": start_x + card_width + gap, - "title": "黑场亮度", - "value": f"{min_lum:.4f}", - "unit": "cd/m²", - "color": "#F3E5F5", - "edge_color": "#9C27B0", - }, - ] - - for card in cards_data: - # 绘制卡片背景 - rect = Rectangle( - (card["x"], card_y), - card_width, - card_height, - transform=self.contrast_ax.transAxes, - facecolor=card["color"], - edgecolor=card["edge_color"], - linewidth=2, - ) - self.contrast_ax.add_patch(rect) - - # 标题 - self.contrast_ax.text( - card["x"] + card_width / 2, - card_y + card_height - 0.03, - card["title"], - ha="center", - va="top", - fontsize=10, - fontweight="bold", - transform=self.contrast_ax.transAxes, - ) - - # 数值 - self.contrast_ax.text( - card["x"] + card_width / 2, - card_y + card_height / 2, - card["value"], - ha="center", - va="center", - fontsize=16, - fontweight="bold", - transform=self.contrast_ax.transAxes, - ) - - # 单位 - self.contrast_ax.text( - card["x"] + card_width / 2, - card_y + 0.03, - card["unit"], - ha="center", - va="bottom", - fontsize=9, - color="gray", - transform=self.contrast_ax.transAxes, - ) - - self.contrast_canvas.draw() - self.chart_notebook.select(3) - + """转发到 app.plots.plot_contrast.plot_contrast(Step 2 重构)""" + return _plot_contrast(self, contrast_data, test_type) def plot_accuracy(self, accuracy_data, test_type): - """绘制色准测试结果 - 29色显示 - 简洁版布局(显示 Gamma)""" - 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, - ) - - # 获取色准数据 - 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) - - # 获取 Gamma 值 - target_gamma = accuracy_data.get("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 - title = f"{test_type_name} - 色准测试(全 29色 | Gamma {target_gamma})" - - self.accuracy_fig.suptitle( - title, - fontsize=11, - y=0.98, - fontweight="bold", - ) - - # ========== 29色:6行5列布局 ========== - cols = 5 - rows = 6 - - patch_width = 0.135 - patch_height = 0.085 - x_start = 0.08 - y_start = 0.90 - x_gap = 0.035 - y_gap = 0.050 - - from matplotlib.patches import Rectangle - - # ========== 绘制色块 ========== - for i, (color_name, delta_e) in enumerate(zip(color_patches, delta_e_values)): - row = i // cols - col = i % cols - - 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", - 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", - 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 = "✓✓" - grade_color = "green" - elif avg_delta_e < 5: - grade = "良好" - grade_icon = "✓" - grade_color = "orange" - else: - grade = "需要校准" - grade_icon = "✗" - grade_color = "red" - - self.accuracy_ax.text( - right_x, - stats_y + 0.020, - "总体评价:", - ha="right", - va="bottom", - fontsize=7, - fontweight="bold", - 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, - ) - - self.accuracy_canvas.draw() - self.chart_notebook.select(4) - + """转发到 app.plots.plot_accuracy.plot_accuracy(Step 2 重构)""" + return _plot_accuracy(self, accuracy_data, test_type) def on_test_completed(self): """测试完成后的UI更新""" self.testing = False