Compare commits

..

10 Commits

Author SHA1 Message Date
xinzhu.yin
b26a3c398d 添加后台线程下载远程图片并写入缓存 2026-04-23 10:07:41 +08:00
xinzhu.yin
4073a6e999 优化AI图像显示,添加发送图片到UCD 2026-04-22 11:02:16 +08:00
xinzhu.yin
9a2ac69afb 修改日志/细节问题 2026-04-21 16:03:11 +08:00
xinzhu.yin
e27312d0a3 优化日志显示 2026-04-21 15:31:48 +08:00
xinzhu.yin
6cc3e55ebb 添加AI图片功能 2026-04-21 14:06:48 +08:00
xinzhu.yin
6b8bfe06b9 重构继续优化主文件中函数 2026-04-21 11:50:57 +08:00
xinzhu.yin
a5595b7e60 重构删除/移动主文件冗余函数 2026-04-21 10:48:15 +08:00
xinzhu.yin
982210a724 进一步优化save_result(提取函数) 2026-04-21 09:05:24 +08:00
xinzhu.yin
a2bfd6d123 重构提取部分面板切换函数 2026-04-20 17:33:50 +08:00
xinzhu.yin
b5b706ef4d 优化save_result函数 2026-04-20 17:18:03 +08:00
28 changed files with 3073 additions and 5633 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ Desktop.ini
# Local configuration overrides # Local configuration overrides
settings/*.local.json settings/*.local.json
settings/

View File

@@ -40,7 +40,7 @@ def calculate_gamut_coverage(points1, points2):
return intersection_area, coverage return intersection_area, coverage
except Exception as e: except Exception as e:
print(f"计算色域覆盖率失败: {e}") print(f"计算色域覆盖率失败: {e}")
return 0.0, 0.0 return 0.0, 0.0
@@ -227,7 +227,7 @@ def calculate_uv_gamut_coverage(uv_coords, reference="DCI-P3"):
return coverage return coverage
except Exception as e: except Exception as e:
print(f"计算 u'v' 色域覆盖率失败: {str(e)}") print(f"计算 u'v' 色域覆盖率失败: {str(e)}")
import traceback import traceback
traceback.print_exc() traceback.print_exc()
@@ -451,7 +451,7 @@ def calculate_cct_from_results(results):
""" """
从测量结果提取色度坐标xy 坐标) 从测量结果提取色度坐标xy 坐标)
⚠️ 注意:方法名保持不变(历史原因),实际功能是提取色度坐标 注意:方法名保持不变(历史原因),实际功能是提取色度坐标
Args: Args:
results: [[x, y, lv, ...], ...] 测量结果列表 results: [[x, y, lv, ...], ...] 测量结果列表
@@ -486,7 +486,7 @@ def calculate_cct_from_results(results):
lv_values.append(0.0) lv_values.append(0.0)
except Exception as e: except Exception as e:
print(f"⚠️ 提取色度坐标失败: {str(e)}") print(f"提取色度坐标失败: {str(e)}")
x_values.append(0.3127) x_values.append(0.3127)
y_values.append(0.3290) y_values.append(0.3290)
lv_values.append(0.0) lv_values.append(0.0)
@@ -555,5 +555,5 @@ if __name__ == "__main__":
print(f"{i+1}: Gamma={result[3]:.3f}") print(f"{i+1}: Gamma={result[3]:.3f}")
print("\n" + "=" * 60) print("\n" + "=" * 60)
print("测试完成!") print("测试完成!")
print("=" * 60) print("=" * 60)

View File

@@ -1,4 +1,4 @@
"""配置文件 I/OStep 4 重构)。 """配置文件 I/OStep 4 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app` 从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。 以保留原有 `self.xxx` 属性访问不变。
@@ -33,19 +33,19 @@ def get_config_path(self):
def load_pq_config(self): def load_pq_config(self):
"""加载PQ配置兼容打包后的程序""" """加载PQ配置兼容打包后的程序"""
try: try:
# 使用 self.config_file已经是动态路径 # 使用 self.config_file已经是动态路径
if os.path.exists(self.config_file): if os.path.exists(self.config_file):
with open(self.config_file, "r", encoding="utf-8") as f: with open(self.config_file, "r", encoding="utf-8") as f:
config_dict = json.load(f) config_dict = json.load(f)
self.config.from_dict(config_dict) self.config.from_dict(config_dict)
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log("配置文件加载成功") self.log_gui.log("配置文件加载成功", level="success")
else: else:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log("⚠️ 配置文件不存在,使用默认配置") self.log_gui.log("配置文件不存在,使用默认配置", level="error")
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"⚠️ 加载配置文件失败: {str(e)},使用默认配置") self.log_gui.log(f"加载配置文件失败: {str(e)},使用默认配置", level="error")
def save_pq_config(self): def save_pq_config(self):
@@ -58,7 +58,7 @@ def save_pq_config(self):
self.config.save_to_file(self.config_file) self.config.save_to_file(self.config_file)
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"保存配置文件失败: {str(e)}") self.log_gui.log(f"保存配置文件失败: {str(e)}", level="error")
def clear_config_file(self): def clear_config_file(self):
@@ -71,14 +71,14 @@ def clear_config_file(self):
if os.path.exists(config_file): if os.path.exists(config_file):
os.remove(config_file) os.remove(config_file)
self.config_cleared = True self.config_cleared = True
messagebox.showinfo("提示", "清理成功") messagebox.showinfo("提示", "清理成功")
self.log_gui.log("配置文件清理成功") self.log_gui.log("配置文件清理成功", level="success")
else: else:
messagebox.showinfo("提示", "配置文件不存在") messagebox.showinfo("提示", "配置文件不存在")
self.log_gui.log("⚠️ 配置文件不存在") self.log_gui.log("配置文件不存在", level="error")
except Exception as e: except Exception as e:
messagebox.showerror("错误", "清理失败") messagebox.showerror("错误", "清理失败")
self.log_gui.log(f"配置文件清理失败: {str(e)}") self.log_gui.log(f"配置文件清理失败: {str(e)}", level="error")

View File

@@ -73,7 +73,7 @@ class DataRangeConverter:
# Full Range 不需要转换 # Full Range 不需要转换
if data_range == "Full": if data_range == "Full":
if self.verbose: if self.verbose:
print("使用 Full Range (0-255),无需转换") print("使用 Full Range (0-255),无需转换")
return pattern_params return pattern_params
# Limited Range 需要转换 # Limited Range 需要转换
@@ -157,7 +157,7 @@ class DataRangeConverter:
def _print_footer(self, total_count): def _print_footer(self, total_count):
"""打印转换尾部信息""" """打印转换尾部信息"""
print(f"转换完成,共 {total_count} 个图案") print(f"转换完成,共 {total_count} 个图案")
print("=" * 80) print("=" * 80)
def get_info(self): def get_info(self):

View File

@@ -1,4 +1,4 @@
"""设备连接UCD323 / CA410相关逻辑Step 4 重构)。 """设备连接UCD323 / CA410相关逻辑Step 4 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app` 从 pqAutomationApp.PQAutomationApp 中搬迁。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。 以保留原有 `self.xxx` 属性访问不变。
@@ -23,7 +23,7 @@ def get_available_com_ports(self):
ports = serial.tools.list_ports.comports() ports = serial.tools.list_ports.comports()
return [port.device for port in ports] return [port.device for port in ports]
except Exception as e: except Exception as e:
self.log_gui.log(f"获取COM端口列表出错: {e}") self.log_gui.log(f"获取COM端口列表出错: {e}", level="error")
return [] return []
@@ -117,7 +117,7 @@ def check_port_connection(self, is_ucd=True):
if not self.ucd.open(self.ucd_list_var.get()): if not self.ucd.open(self.ucd_list_var.get()):
self.log_gui.log( self.log_gui.log(
f"设备 {self.ucd_list_var.get()} 异常UCD323连接失败" f"设备 {self.ucd_list_var.get()} 异常UCD323连接失败"
) , level="error")
return False return False
else: else:
return True return True
@@ -147,12 +147,12 @@ def check_port_connection(self, is_ucd=True):
else: else:
self.log_gui.log( self.log_gui.log(
f"端口 {self.config.device_config["ca_com"]} 异常,色温仪连接失败" f"端口 {self.config.device_config["ca_com"]} 异常,色温仪连接失败"
) , level="error")
self.ca.close() self.ca.close()
self.ca = None self.ca = None
return False return False
except Exception as e: except Exception as e:
self.log_gui.log(f"端口连接失败: {e}") self.log_gui.log(f"端口连接失败: {e}", level="error")
return False return False
@@ -173,7 +173,7 @@ def disconnect_com_connections(self):
pass pass
finally: finally:
self.ucd.status = False self.ucd.status = False
self.log_gui.log("UCD连接已断开") self.log_gui.log("UCD连接已断开", level="info")
# 断开CA连接 # 断开CA连接
if self.ca is not None: if self.ca is not None:
@@ -183,7 +183,7 @@ def disconnect_com_connections(self):
pass pass
finally: finally:
self.ca = None self.ca = None
self.log_gui.log("CA连接已断开") self.log_gui.log("CA连接已断开", level="info")
# 重新启用相关控件 # 重新启用相关控件
self.enable_com_widgets() self.enable_com_widgets()
@@ -192,7 +192,7 @@ def disconnect_com_connections(self):
self.status_var.set("串口连接已断开") self.status_var.set("串口连接已断开")
except Exception as e: except Exception as e:
self.log_gui.log(f"断开连接时发生错误: {str(e)}") self.log_gui.log(f"断开连接时发生错误: {str(e)}", level="info")
messagebox.showerror("错误", f"断开连接失败: {str(e)}") messagebox.showerror("错误", f"断开连接失败: {str(e)}")

5
app/export/__init__.py Normal file
View File

@@ -0,0 +1,5 @@
"""导出模块:测试结果图片与 Excel 报告。"""
from app.export.image_exporter import save_result_images
from app.export.excel_exporter import export_excel_report, EXCEL_EXPORT_CONFIG
__all__ = ["save_result_images", "export_excel_report", "EXCEL_EXPORT_CONFIG"]

View File

@@ -0,0 +1,506 @@
"""测试结果 Excel 导出screen_module / sdr_movie / hdr_movie 统一实现)。"""
import datetime
import os
import traceback
EXCEL_EXPORT_CONFIG = {
"screen_module": {
"title": "屏模组性能测试数据报告",
"type_label": "屏模组",
"log_prefix": "屏模组",
"curve_type": "gamma",
"has_accuracy": False,
"column_widths": {"A": 18, "B": 18, "C": 18, "D": 18, "E": 18,
"F": 15, "G": 15},
},
"sdr_movie": {
"title": "SDR Movie 性能测试数据报告",
"type_label": "SDR Movie",
"log_prefix": "SDR Movie",
"curve_type": "gamma",
"has_accuracy": True,
"column_widths": {c: 18 for c in "ABCDEFG"},
},
"hdr_movie": {
"title": "HDR Movie 性能测试数据报告",
"type_label": "HDR Movie",
"log_prefix": "HDR Movie",
"curve_type": "eotf",
"has_accuracy": True,
"column_widths": {c: 18 for c in "ABCDEFG"},
},
}
# ==================== 样式 ====================
def _build_styles(Font, Alignment, PatternFill, Border, Side):
thin = Side(style="thin")
return {
"title_font": Font(name="微软雅黑", size=16, bold=True, color="FFFFFF"),
"title_fill": PatternFill(start_color="4472C4", end_color="4472C4",
fill_type="solid"),
"title_alignment": Alignment(horizontal="center", vertical="center"),
"section_font": Font(name="微软雅黑", size=13, bold=True, color="FFFFFF"),
"section_fill": PatternFill(start_color="5B9BD5", end_color="5B9BD5",
fill_type="solid"),
"section_alignment": Alignment(horizontal="center", vertical="center"),
"header_font": Font(name="微软雅黑", size=10, bold=True, color="FFFFFF"),
"header_fill": PatternFill(start_color="70AD47", end_color="70AD47",
fill_type="solid"),
"header_alignment": Alignment(horizontal="center", vertical="center",
wrap_text=True),
"data_font": Font(name="微软雅黑", size=10),
"data_alignment": Alignment(horizontal="center", vertical="center"),
"label_font": Font(name="微软雅黑", size=10, bold=True),
"thin_border": Border(left=thin, right=thin, top=thin, bottom=thin),
}
# ==================== 通用写入 ====================
def _write_title(ws, styles, title_text):
ws.merge_cells("A1:G1")
ws["A1"] = title_text
ws["A1"].font = styles["title_font"]
ws["A1"].fill = styles["title_fill"]
ws["A1"].alignment = styles["title_alignment"]
ws.row_dimensions[1].height = 35
def _write_basic_info(ws, styles, row, type_label):
ws.merge_cells(f"A{row}:B{row}")
ws[f"A{row}"] = "📋 测试基本信息"
ws[f"A{row}"].font = styles["section_font"]
ws[f"A{row}"].fill = styles["section_fill"]
ws[f"A{row}"].alignment = styles["section_alignment"]
ws.row_dimensions[row].height = 25
row += 1
info_items = [
("测试时间", datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")),
("测试类型", type_label),
]
for label, value in info_items:
ws[f"A{row}"] = label
ws[f"B{row}"] = value
ws[f"A{row}"].font = styles["label_font"]
ws[f"B{row}"].font = styles["data_font"]
ws[f"A{row}"].border = styles["thin_border"]
ws[f"B{row}"].border = styles["thin_border"]
row += 1
return row
def _write_section_header(ws, styles, row, text, span="A{r}:G{r}"):
ws.merge_cells(span.format(r=row))
ws[f"A{row}"] = text
ws[f"A{row}"].font = styles["section_font"]
ws[f"A{row}"].fill = styles["section_fill"]
ws[f"A{row}"].alignment = styles["section_alignment"]
ws.row_dimensions[row].height = 25
return row + 1
def _write_headers(ws, styles, row, headers):
for col_idx, header in enumerate(headers, start=1):
cell = ws.cell(row=row, column=col_idx)
cell.value = header
cell.font = styles["header_font"]
cell.fill = styles["header_fill"]
cell.alignment = styles["header_alignment"]
cell.border = styles["thin_border"]
return row + 1
def _apply_row_style(ws, row, cols, styles, label_cols=()):
for col in cols:
cell = ws[f"{col}{row}"]
cell.font = (styles["label_font"] if col in label_cols
else styles["data_font"])
cell.border = styles["thin_border"]
def _apply_data_row(ws, row, cols, styles):
for col in cols:
cell = ws[f"{col}{row}"]
cell.font = styles["data_font"]
cell.alignment = styles["data_alignment"]
cell.border = styles["thin_border"]
# ==================== 分区:色域 ====================
def _section_gamut(ws, styles, row, results, log):
rgb_data = results.get_intermediate_data("gamut", "rgb")
gamut_final_result = None
if "gamut" in results.test_items:
gamut_final_result = results.test_items["gamut"].final_result
if not (rgb_data and len(rgb_data) >= 3):
return row
row = _write_section_header(ws, styles, row, "🎨 色域测试数据")
if gamut_final_result:
xy_coverage = gamut_final_result.get("coverage", 0)
uv_coverage = (
gamut_final_result.get("uv_coverage", 0)
or gamut_final_result.get("uv_space_coverage", 0)
or gamut_final_result.get("coverage_uv", 0)
or 0
)
ws[f"A{row}"] = "参考标准"
ws[f"B{row}"] = gamut_final_result.get("reference", "DCI-P3")
ws[f"A{row}"].font = styles["label_font"]
ws[f"B{row}"].font = styles["data_font"]
ws[f"A{row}"].border = styles["thin_border"]
ws[f"B{row}"].border = styles["thin_border"]
row += 1
ws[f"A{row}"] = "XY 色域覆盖率"
ws[f"B{row}"] = f"{xy_coverage:.2f}%"
ws[f"C{row}"] = "UV 色域覆盖率"
ws[f"D{row}"] = f"{uv_coverage:.2f}%"
_apply_row_style(ws, row, ["A", "B", "C", "D"], styles,
label_cols=("A", "C"))
row += 1
row = _write_headers(
ws, styles, row,
["点位", "x 坐标", "y 坐标", "亮度 (cd/m²)", "", "", ""],
)
rgb_labels = ["Red", "Green", "Blue"]
for i, result in enumerate(rgb_data[:3]):
x, y, lv = result[0], result[1], result[2]
ws[f"A{row}"] = rgb_labels[i]
ws[f"B{row}"] = x
ws[f"C{row}"] = y
ws[f"D{row}"] = lv
ws[f"B{row}"].number_format = "0.0000"
ws[f"C{row}"].number_format = "0.0000"
ws[f"D{row}"].number_format = "0.00"
_apply_data_row(ws, row, ["A", "B", "C", "D"], styles)
row += 1
log(" 添加色域数据", level="info")
return row + 1
# ==================== 分区Gamma / EOTF ====================
def _section_curve(ws, styles, row, results, log, curve_type):
if curve_type == "gamma":
section_title = "📊 Gamma 曲线数据"
label_prefix = "Gamma"
data_key = "gamma"
value_header = "Gamma 值"
else:
section_title = "📊 EOTF 曲线数据"
label_prefix = "EOTF"
data_key = "eotf"
value_header = "EOTF 值"
gray_data = results.get_intermediate_data("shared", "gray")
if not gray_data:
gray_data = results.get_intermediate_data(curve_type, "gray")
curve_final_result = None
if curve_type in results.test_items:
curve_final_result = results.test_items[curve_type].final_result
if not (gray_data and len(gray_data) > 0 and curve_final_result):
return row
value_list = curve_final_result.get(data_key, [])
L_bar_list = curve_final_result.get("L_bar", [])
row = _write_section_header(ws, styles, row, section_title)
valid_values = [
item[3]
for item in value_list
if isinstance(item, (list, tuple))
and len(item) >= 4
and 0.5 < item[3] < 5.0
]
if valid_values:
avg_v = sum(valid_values) / len(valid_values)
ws[f"A{row}"] = f"平均 {label_prefix}"
ws[f"B{row}"] = f"{avg_v:.3f}"
ws[f"C{row}"] = f"最大 {label_prefix}"
ws[f"D{row}"] = f"{max(valid_values):.3f}"
ws[f"E{row}"] = f"最小 {label_prefix}"
ws[f"F{row}"] = f"{min(valid_values):.3f}"
_apply_row_style(ws, row, ["A", "B", "C", "D", "E", "F"], styles,
label_cols=("A", "C", "E"))
row += 1
row = _write_headers(
ws, styles, row,
["灰阶 (%)", "x 坐标", "y 坐标", "实测亮度\n(cd/m²)",
"归一化亮度\n(L_bar)", value_header, ""],
)
total_points = len(gray_data)
for i in range(total_points - 1, -1, -1):
gray_level = (100 - int(i * 100 / (total_points - 1))
if total_points > 1 else 0)
x, y, lv = gray_data[i][0], gray_data[i][1], gray_data[i][2]
L_bar_val = L_bar_list[i] if i < len(L_bar_list) else 0
val = None
if (i < len(value_list)
and isinstance(value_list[i], (list, tuple))
and len(value_list[i]) >= 4):
val = value_list[i][3]
ws[f"A{row}"] = gray_level
ws[f"B{row}"] = x
ws[f"C{row}"] = y
ws[f"D{row}"] = lv
ws[f"E{row}"] = L_bar_val
if val is not None and 0.5 < val < 5.0:
ws[f"F{row}"] = val
ws[f"F{row}"].number_format = "0.000"
else:
ws[f"F{row}"] = "N/A"
ws[f"A{row}"].number_format = "0"
ws[f"B{row}"].number_format = "0.0000"
ws[f"C{row}"].number_format = "0.0000"
ws[f"D{row}"].number_format = "0.00"
ws[f"E{row}"].number_format = "0.0000"
_apply_data_row(ws, row, ["A", "B", "C", "D", "E", "F"], styles)
row += 1
log(f" 添加 {label_prefix} 数据", level="info")
return row + 1
# ==================== 分区:色度一致性 ====================
def _section_cct(ws, styles, row, results, log):
gray_data = results.get_intermediate_data("shared", "gray")
if not gray_data:
gray_data = results.get_intermediate_data("cct", "gray")
if not (gray_data and len(gray_data) > 1):
return row
gray_data_no_black = gray_data[:-1]
row = _write_section_header(ws, styles, row, "🌈 色度一致性数据")
x_coords = [d[0] for d in gray_data_no_black]
y_coords = [d[1] for d in gray_data_no_black]
ws[f"A{row}"] = "x 坐标范围"
ws[f"B{row}"] = f"{min(x_coords):.4f} ~ {max(x_coords):.4f}"
ws[f"C{row}"] = "y 坐标范围"
ws[f"D{row}"] = f"{min(y_coords):.4f} ~ {max(y_coords):.4f}"
_apply_row_style(ws, row, ["A", "B", "C", "D"], styles,
label_cols=("A", "C"))
row += 1
row = _write_headers(
ws, styles, row,
["灰阶 (%)", "x 坐标", "y 坐标", "亮度 (cd/m²)", "", "", ""],
)
total_points = len(gray_data)
for i in range(len(gray_data_no_black) - 1, -1, -1):
x, y, lv = (gray_data_no_black[i][0],
gray_data_no_black[i][1],
gray_data_no_black[i][2])
gray_level = (100 - int(i * 100 / (total_points - 1))
if total_points > 1 else 0)
ws[f"A{row}"] = gray_level
ws[f"B{row}"] = x
ws[f"C{row}"] = y
ws[f"D{row}"] = lv
ws[f"A{row}"].number_format = "0"
ws[f"B{row}"].number_format = "0.0000"
ws[f"C{row}"].number_format = "0.0000"
ws[f"D{row}"].number_format = "0.00"
_apply_data_row(ws, row, ["A", "B", "C", "D"], styles)
row += 1
log(" 添加色度一致性数据", level="info")
return row + 1
# ==================== 分区:对比度 ====================
def _section_contrast(ws, styles, row, results, log):
contrast_final_result = None
if "contrast" in results.test_items:
contrast_final_result = results.test_items["contrast"].final_result
if not contrast_final_result:
return row
row = _write_section_header(ws, styles, row, "⚫⚪ 对比度测试数据")
max_lv = contrast_final_result.get("max_luminance", 0)
min_lv = contrast_final_result.get("min_luminance", 0)
contrast_ratio = contrast_final_result.get("contrast_ratio", 0)
info_items = [
("最大亮度(白场)", f"{max_lv:.2f} cd/m²"),
("最小亮度(黑场)", f"{min_lv:.4f} cd/m²"),
("对比度", f"{contrast_ratio:.0f}:1"),
]
for label, value in info_items:
ws[f"A{row}"] = label
ws[f"B{row}"] = value
ws[f"A{row}"].font = styles["label_font"]
ws[f"B{row}"].font = styles["data_font"]
ws[f"A{row}"].border = styles["thin_border"]
ws[f"B{row}"].border = styles["thin_border"]
row += 1
log(" 添加对比度数据", level="info")
return row + 1
# ==================== 分区:色准 ====================
def _section_accuracy(ws, styles, row, results, log):
accuracy_final_result = None
if "accuracy" in results.test_items:
accuracy_final_result = results.test_items["accuracy"].final_result
if not accuracy_final_result:
return row
row = _write_section_header(ws, styles, row, "🎯 色准测试数据")
avg_delta_e = accuracy_final_result.get("avg_delta_e", 0)
max_delta_e = accuracy_final_result.get("max_delta_e", 0)
min_delta_e = accuracy_final_result.get("min_delta_e", 0)
excellent_count = accuracy_final_result.get("excellent_count", 0)
good_count = accuracy_final_result.get("good_count", 0)
poor_count = accuracy_final_result.get("poor_count", 0)
ws[f"A{row}"] = "平均 ΔE"
ws[f"B{row}"] = f"{avg_delta_e:.2f}"
ws[f"C{row}"] = "最大 ΔE"
ws[f"D{row}"] = f"{max_delta_e:.2f}"
ws[f"E{row}"] = "最小 ΔE"
ws[f"F{row}"] = f"{min_delta_e:.2f}"
_apply_row_style(ws, row, ["A", "B", "C", "D", "E", "F"], styles,
label_cols=("A", "C", "E"))
row += 1
ws[f"A{row}"] = "优秀 (ΔE<3)"
ws[f"B{row}"] = f"{excellent_count}"
ws[f"C{row}"] = "良好 (3≤ΔE<5)"
ws[f"D{row}"] = f"{good_count}"
ws[f"E{row}"] = "偏差 (ΔE≥5)"
ws[f"F{row}"] = f"{poor_count}"
_apply_row_style(ws, row, ["A", "B", "C", "D", "E", "F"], styles,
label_cols=("A", "C", "E"))
row += 1
color_patches = accuracy_final_result.get("color_patches", [])
delta_e_values = accuracy_final_result.get("delta_e_values", [])
color_measurements = accuracy_final_result.get("color_measurements", [])
if color_patches and delta_e_values:
row = _write_headers(
ws, styles, row,
["序号", "颜色名称", "x 坐标", "y 坐标",
"亮度 (cd/m²)", "ΔE 2000", "等级"],
)
for idx, (color_name, delta_e) in enumerate(
zip(color_patches, delta_e_values), start=1):
if delta_e < 3:
grade = "优秀"
elif delta_e < 5:
grade = "良好"
else:
grade = "偏差"
x_val, y_val, lv_val = "N/A", "N/A", "N/A"
if color_measurements and idx - 1 < len(color_measurements):
m = color_measurements[idx - 1]
if len(m) >= 3:
x_val, y_val, lv_val = m[0], m[1], m[2]
ws[f"A{row}"] = idx
ws[f"B{row}"] = color_name
ws[f"C{row}"] = x_val
ws[f"D{row}"] = y_val
ws[f"E{row}"] = lv_val
ws[f"F{row}"] = delta_e
ws[f"G{row}"] = grade
ws[f"A{row}"].number_format = "0"
if isinstance(x_val, (int, float)):
ws[f"C{row}"].number_format = "0.0000"
if isinstance(y_val, (int, float)):
ws[f"D{row}"].number_format = "0.0000"
if isinstance(lv_val, (int, float)):
ws[f"E{row}"].number_format = "0.00"
ws[f"F{row}"].number_format = "0.00"
_apply_data_row(ws, row, ["A", "B", "C", "D", "E", "F", "G"], styles)
row += 1
log(" 添加色准数据(含 xy 坐标和亮度)")
return row + 1
# ==================== 主入口 ====================
def export_excel_report(result_dir, current_test_type, selected_items,
results, log):
"""根据测试类型生成统一格式的 Excel 报告。
仅 current_test_type ∈ EXCEL_EXPORT_CONFIG 时应调用本函数;
内部吞掉 ImportErroropenpyxl 未装)与其他 Exception 并通过 log 记录。
"""
if current_test_type not in EXCEL_EXPORT_CONFIG:
return
cfg = EXCEL_EXPORT_CONFIG[current_test_type]
try:
import openpyxl
from openpyxl.styles import (
Font, Alignment, PatternFill, Border, Side,
)
log("=" * 60)
log(f"开始生成{cfg['log_prefix']} Excel 数据报告...")
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "测试数据"
styles = _build_styles(Font, Alignment, PatternFill, Border, Side)
_write_title(ws, styles, cfg["title"])
row = _write_basic_info(ws, styles, 3, cfg["type_label"])
row += 1 # 空行
if "gamut" in selected_items:
row = _section_gamut(ws, styles, row, results, log)
if cfg["curve_type"] == "gamma" and "gamma" in selected_items:
row = _section_curve(ws, styles, row, results, log, "gamma")
if cfg["curve_type"] == "eotf" and "eotf" in selected_items:
row = _section_curve(ws, styles, row, results, log, "eotf")
if "cct" in selected_items:
row = _section_cct(ws, styles, row, results, log)
if "contrast" in selected_items:
row = _section_contrast(ws, styles, row, results, log)
if cfg["has_accuracy"] and "accuracy" in selected_items:
row = _section_accuracy(ws, styles, row, results, log)
for col, width in cfg["column_widths"].items():
ws.column_dimensions[col].width = width
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
excel_filename = f"测试数据_{timestamp}.xlsx"
excel_path = os.path.join(result_dir, excel_filename)
wb.save(excel_path)
log(f"已保存: {excel_filename}", level="success")
log("=" * 60, level="seperator")
except ImportError:
log("未安装 openpyxl 库,跳过 Excel 导出", level="error")
log(" 安装方法: pip install openpyxl")
except Exception as e:
log(f"Excel 导出失败: {str(e)}", level="error")
log(traceback.format_exc())

View File

@@ -0,0 +1,38 @@
"""测试结果图表 PNG 导出。"""
import os
# (item_key, fig_attr, filename, allowed_test_types_or_None, default_bbox_inches_auto)
IMAGE_SPECS = [
("gamut", "gamut_fig", "色域测试结果.png", None, True),
("gamma", "gamma_fig", "Gamma曲线测试结果.png",
{"screen_module", "sdr_movie"}, True),
("eotf", "eotf_fig", "EOTF曲线测试结果.png", {"hdr_movie"}, True),
("cct", "cct_fig", "色度一致性测试结果.png", None, True),
("contrast", "contrast_fig", "对比度测试结果.png", None, False),
("accuracy", "accuracy_fig", "色准测试结果.png",
{"sdr_movie", "hdr_movie"}, True),
]
def save_result_images(result_dir, current_test_type, selected_items,
figure_provider, log):
"""根据测试类型和已选项将各测试图表保存为 PNG。
figure_provider: callable(attr_name) -> matplotlib Figure 或 None
log: callable(msg)
"""
for item_key, fig_attr, filename, allowed_types, default_bbox in IMAGE_SPECS:
if item_key not in selected_items:
continue
if allowed_types is not None and current_test_type not in allowed_types:
continue
fig = figure_provider(fig_attr)
if fig is None:
continue
path = os.path.join(result_dir, filename)
if default_bbox:
fig.savefig(path, dpi=300)
else:
fig.savefig(path, dpi=300, bbox_inches="tight")
log(f"已保存: {filename}")

View File

@@ -287,7 +287,7 @@ def plot_accuracy(self, accuracy_data, test_type):
grade_color = "orange" grade_color = "orange"
else: else:
grade = "需要校准" grade = "需要校准"
grade_icon = "" grade_icon = "[Error]"
grade_color = "red" grade_color = "red"
self.accuracy_ax.text( self.accuracy_ax.text(

View File

@@ -1,4 +1,4 @@
"""CCT / 色度一致性绘制。 """CCT / 色度一致性绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_cct 原样搬迁。
""" """
@@ -16,7 +16,7 @@ def plot_cct(self, test_type):
gray_data = self.results.get_intermediate_data("cct", "gray") gray_data = self.results.get_intermediate_data("cct", "gray")
if not gray_data or len(gray_data) < 2: if not gray_data or len(gray_data) < 2:
self.log_gui.log("⚠️ 无 xy 数据可用") self.log_gui.log("无 xy 数据可用", level="error")
ax = self.cct_fig.add_subplot(111) ax = self.cct_fig.add_subplot(111)
ax.text( ax.text(
0.5, 0.5,
@@ -46,9 +46,9 @@ def plot_cct(self, test_type):
total_points = len(gray_data) total_points = len(gray_data)
grayscale = np.linspace(100 / total_points, 100, len(x_measured)) grayscale = np.linspace(100 / total_points, 100, len(x_measured))
self.log_gui.log(f"已移除第一个数据点,当前数据点数: {len(x_measured)}") self.log_gui.log(f"已移除第一个数据点,当前数据点数: {len(x_measured)}", level="success")
self.log_gui.log(f" x范围: {min(x_measured):.6f} - {max(x_measured):.6f}") self.log_gui.log(f" x范围: {min(x_measured):.6f} - {max(x_measured):.6f}", level="info")
self.log_gui.log(f" y范围: {min(y_measured):.6f} - {max(y_measured):.6f}") self.log_gui.log(f" y范围: {min(y_measured):.6f} - {max(y_measured):.6f}", level="info")
# ========== 根据测试类型读取对应参数 ========== # ========== 根据测试类型读取对应参数 ==========
if test_type == "sdr_movie": if test_type == "sdr_movie":
@@ -57,50 +57,50 @@ def plot_cct(self, test_type):
x_tolerance = float(self.sdr_cct_x_tolerance_var.get()) x_tolerance = float(self.sdr_cct_x_tolerance_var.get())
y_ideal = float(self.sdr_cct_y_ideal_var.get()) y_ideal = float(self.sdr_cct_y_ideal_var.get())
y_tolerance = float(self.sdr_cct_y_tolerance_var.get()) y_tolerance = float(self.sdr_cct_y_tolerance_var.get())
self.log_gui.log("使用 SDR 色度参数") self.log_gui.log("使用 SDR 色度参数", level="success")
except: except:
x_ideal = 0.3127 x_ideal = 0.3127
x_tolerance = 0.003 x_tolerance = 0.003
y_ideal = 0.3290 y_ideal = 0.3290
y_tolerance = 0.003 y_tolerance = 0.003
self.log_gui.log("⚠️ SDR 参数读取失败,使用默认值") self.log_gui.log("SDR 参数读取失败,使用默认值", level="error")
elif test_type == "hdr_movie": elif test_type == "hdr_movie":
try: try:
x_ideal = float(self.hdr_cct_x_ideal_var.get()) x_ideal = float(self.hdr_cct_x_ideal_var.get())
x_tolerance = float(self.hdr_cct_x_tolerance_var.get()) x_tolerance = float(self.hdr_cct_x_tolerance_var.get())
y_ideal = float(self.hdr_cct_y_ideal_var.get()) y_ideal = float(self.hdr_cct_y_ideal_var.get())
y_tolerance = float(self.hdr_cct_y_tolerance_var.get()) y_tolerance = float(self.hdr_cct_y_tolerance_var.get())
self.log_gui.log("使用 HDR 色度参数") self.log_gui.log("使用 HDR 色度参数", level="success")
except: except:
x_ideal = 0.3127 x_ideal = 0.3127
x_tolerance = 0.003 x_tolerance = 0.003
y_ideal = 0.3290 y_ideal = 0.3290
y_tolerance = 0.003 y_tolerance = 0.003
self.log_gui.log("⚠️ HDR 参数读取失败,使用默认值") self.log_gui.log("HDR 参数读取失败,使用默认值", level="error")
else: # screen_module else: # screen_module
try: try:
x_ideal = float(self.cct_x_ideal_var.get()) x_ideal = float(self.cct_x_ideal_var.get())
x_tolerance = float(self.cct_x_tolerance_var.get()) x_tolerance = float(self.cct_x_tolerance_var.get())
y_ideal = float(self.cct_y_ideal_var.get()) y_ideal = float(self.cct_y_ideal_var.get())
y_tolerance = float(self.cct_y_tolerance_var.get()) y_tolerance = float(self.cct_y_tolerance_var.get())
self.log_gui.log("使用屏模组色度参数") self.log_gui.log("使用屏模组色度参数", level="success")
except: except:
x_ideal = 0.306 x_ideal = 0.306
x_tolerance = 0.003 x_tolerance = 0.003
y_ideal = 0.318 y_ideal = 0.318
y_tolerance = 0.003 y_tolerance = 0.003
self.log_gui.log("⚠️ 屏模组参数读取失败,使用默认值") self.log_gui.log("屏模组参数读取失败,使用默认值", level="error")
x_low = x_ideal - x_tolerance x_low = x_ideal - x_tolerance
x_high = x_ideal + x_tolerance x_high = x_ideal + x_tolerance
y_low = y_ideal - y_tolerance y_low = y_ideal - y_tolerance
y_high = y_ideal + y_tolerance y_high = y_ideal + y_tolerance
self.log_gui.log(f"用户设置参数:") self.log_gui.log(f"用户设置参数:", level="success")
self.log_gui.log(f" x-ideal={x_ideal:.4f}, tolerance={x_tolerance:.4f}") self.log_gui.log(f" x-ideal={x_ideal:.4f}, tolerance={x_tolerance:.4f}", level="info")
self.log_gui.log(f" x范围: [{x_low:.4f}, {x_high:.4f}]") self.log_gui.log(f" x范围: [{x_low:.4f}, {x_high:.4f}]", level="info")
self.log_gui.log(f" y-ideal={y_ideal:.4f}, tolerance={y_tolerance:.4f}") self.log_gui.log(f" y-ideal={y_ideal:.4f}, tolerance={y_tolerance:.4f}", level="info")
self.log_gui.log(f" y范围: [{y_low:.4f}, {y_high:.4f}]") self.log_gui.log(f" y范围: [{y_low:.4f}, {y_high:.4f}]", level="info")
# 为所有测试类型创建子图 # 为所有测试类型创建子图
ax1 = self.cct_fig.add_subplot(211) ax1 = self.cct_fig.add_subplot(211)
@@ -180,7 +180,7 @@ def plot_cct(self, test_type):
x_max_data = max(x_measured) x_max_data = max(x_measured)
data_range_x = x_max_data - x_min_data data_range_x = x_max_data - x_min_data
self.log_gui.log(f" x数据波动: {data_range_x:.6f}") self.log_gui.log(f" x数据波动: {data_range_x:.6f}", level="info")
range_span = x_tolerance * 2 range_span = x_tolerance * 2
margin_ratio = 0.20 margin_ratio = 0.20
@@ -190,16 +190,16 @@ def plot_cct(self, test_type):
final_y_max = max(x_max_data, x_high) + 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: if x_min_data >= x_low and x_max_data <= x_high:
self.log_gui.log(f" x数据在tolerance范围内使用tolerance范围显示") self.log_gui.log(f" x数据在tolerance范围内使用tolerance范围显示", level="info")
final_y_min = x_low - extra_margin final_y_min = x_low - extra_margin
final_y_max = x_high + extra_margin final_y_max = x_high + extra_margin
else: else:
self.log_gui.log(f" x数据超出tolerance范围扩展显示范围") self.log_gui.log(f" x数据超出tolerance范围扩展显示范围", level="info")
ax1.set_ylim(final_y_min, final_y_max) ax1.set_ylim(final_y_min, final_y_max)
self.log_gui.log( self.log_gui.log(
f" x轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})" f" x轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})"
) , level="info")
# ========== 下图y coordinates ========== # ========== 下图y coordinates ==========
ax2.plot( ax2.plot(
@@ -273,7 +273,7 @@ def plot_cct(self, test_type):
y_max_data = max(y_measured) y_max_data = max(y_measured)
data_range_y = y_max_data - y_min_data data_range_y = y_max_data - y_min_data
self.log_gui.log(f" y数据波动: {data_range_y:.6f}") self.log_gui.log(f" y数据波动: {data_range_y:.6f}", level="info")
range_span = y_tolerance * 2 range_span = y_tolerance * 2
extra_margin = range_span * margin_ratio extra_margin = range_span * margin_ratio
@@ -282,16 +282,16 @@ def plot_cct(self, test_type):
final_y_max = max(y_max_data, y_high) + 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: if y_min_data >= y_low and y_max_data <= y_high:
self.log_gui.log(f" y数据在tolerance范围内使用tolerance范围显示") self.log_gui.log(f" y数据在tolerance范围内使用tolerance范围显示", level="info")
final_y_min = y_low - extra_margin final_y_min = y_low - extra_margin
final_y_max = y_high + extra_margin final_y_max = y_high + extra_margin
else: else:
self.log_gui.log(f" y数据超出tolerance范围扩展显示范围") self.log_gui.log(f" y数据超出tolerance范围扩展显示范围", level="info")
ax2.set_ylim(final_y_min, final_y_max) ax2.set_ylim(final_y_min, final_y_max)
self.log_gui.log( self.log_gui.log(
f" y轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})" f" y轴显示范围: {final_y_min:.6f} - {final_y_max:.6f} (跨度: {final_y_max - final_y_min:.6f})"
) , level="info")
# ========== 总标题 - 统一格式(去掉统计信息)========== # ========== 总标题 - 统一格式(去掉统计信息)==========
test_type_name = self.get_test_type_name(test_type) test_type_name = self.get_test_type_name(test_type)
@@ -321,4 +321,4 @@ def plot_cct(self, test_type):
self.cct_canvas.draw() self.cct_canvas.draw()
self.chart_notebook.select(self.cct_chart_frame) self.chart_notebook.select(self.cct_chart_frame)
self.log_gui.log("xy 色度坐标图绘制完成") self.log_gui.log("xy 色度坐标图绘制完成", level="success")

View File

@@ -1,4 +1,4 @@
"""EOTF 曲线绘制HDR """EOTF 曲线绘制HDR
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_eotf 原样搬迁。
""" """
@@ -145,4 +145,4 @@ def plot_eotf(self, L_bar, results_with_eotf_list, test_type):
except: except:
pass pass
self.log_gui.log("EOTF 曲线 + 数据表格绘制完成") self.log_gui.log("EOTF 曲线 + 数据表格绘制完成", level="success")

View File

@@ -1,4 +1,4 @@
"""Gamma 曲线绘制。 """Gamma 曲线绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。 Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。
""" """
@@ -139,4 +139,4 @@ def plot_gamma(self, L_bar, results_with_gamma_list, target_gamma, test_type):
self.gamma_canvas.draw() self.gamma_canvas.draw()
self.chart_notebook.select(self.gamma_chart_frame) self.chart_notebook.select(self.gamma_chart_frame)
self.log_gui.log("Gamma曲线 + 数据表格绘制完成") self.log_gui.log("Gamma曲线 + 数据表格绘制完成", level="success")

View File

@@ -1,4 +1,4 @@
"""色域图Gamut绘制。 """色域图Gamut绘制。
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamut 整体搬迁, Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamut 整体搬迁,
实现与原方法完全一致;原方法仅保留为一行转发。 实现与原方法完全一致;原方法仅保留为一行转发。
@@ -30,7 +30,7 @@ def plot_gamut(self, results, coverage, test_type):
UV_PIXELS_PER_U = 615.7260 UV_PIXELS_PER_U = 615.7260
UV_PIXELS_PER_V = 599.8432 UV_PIXELS_PER_V = 599.8432
# ========== 读取用户选择的参考标准 ========== # ========== 读取用户选择的参考标准 ==========
if test_type == "screen_module": if test_type == "screen_module":
current_ref = self.screen_gamut_ref_var.get() current_ref = self.screen_gamut_ref_var.get()
elif test_type == "sdr_movie": elif test_type == "sdr_movie":
@@ -40,7 +40,7 @@ def plot_gamut(self, results, coverage, test_type):
else: else:
current_ref = "DCI-P3" current_ref = "DCI-P3"
# ========== ✅✅根据参考标准重新计算覆盖率XY 空间)========== # ========== ✅✅根据参考标准重新计算覆盖率XY 空间)==========
xy_coverage = coverage # 默认使用传入的值 xy_coverage = coverage # 默认使用传入的值
uv_coverage = 0.0 uv_coverage = 0.0
@@ -67,18 +67,18 @@ def plot_gamut(self, results, coverage, test_type):
xy_points xy_points
) )
else: else:
self.log_gui.log(f"⚠️ 未知参考标准 '{current_ref}',使用 DCI-P3") self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error")
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3( _, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(
xy_points xy_points
) )
current_ref = "DCI-P3" current_ref = "DCI-P3"
self.log_gui.log( self.log_gui.log(
f"XY 空间覆盖率({current_ref}: {xy_coverage:.1f}%" f"XY 空间覆盖率({current_ref}: {xy_coverage:.1f}%"
) , level="success")
except Exception as e: except Exception as e:
self.log_gui.log(f"⚠️ 重新计算 XY 覆盖率失败: {str(e)}") self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error")
xy_coverage = coverage # 回退到传入值 xy_coverage = coverage # 回退到传入值
# ================================================= # =================================================
@@ -87,7 +87,7 @@ def plot_gamut(self, results, coverage, test_type):
img_xy = mpimg.imread(get_resource_path("assets/cie.png")) img_xy = mpimg.imread(get_resource_path("assets/cie.png"))
h_xy, w_xy = img_xy.shape[:2] h_xy, w_xy = img_xy.shape[:2]
self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}") self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}", level="info")
self.gamut_ax_xy.imshow(img_xy, extent=[0, w_xy, h_xy, 0], aspect="equal") self.gamut_ax_xy.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_xlim(0, w_xy)
@@ -109,7 +109,7 @@ def plot_gamut(self, results, coverage, test_type):
self.log_gui.log( self.log_gui.log(
f"测量色域: R({red_x:.4f},{red_y:.4f}) " f"测量色域: R({red_x:.4f},{red_y:.4f}) "
f"G({green_x:.4f},{green_y:.4f}) B({blue_x:.4f},{blue_y:.4f})" f"G({green_x:.4f},{green_y:.4f}) B({blue_x:.4f},{blue_y:.4f})"
) , level="info")
# ========== 绘制测量三角形 ========== # ========== 绘制测量三角形 ==========
points = [ points = [
@@ -257,7 +257,7 @@ def plot_gamut(self, results, coverage, test_type):
zorder=3, zorder=3,
) )
# ========== XY 覆盖率标注(使用重新计算的值)========== # ========== XY 覆盖率标注(使用重新计算的值)==========
self.gamut_ax_xy.text( self.gamut_ax_xy.text(
w_xy * 0.85, w_xy * 0.85,
h_xy * 0.92, h_xy * 0.92,
@@ -287,17 +287,17 @@ def plot_gamut(self, results, coverage, test_type):
) )
except Exception as e: except Exception as e:
self.log_gui.log(f"XY 图绘制失败: {str(e)}") self.log_gui.log(f"XY 图绘制失败: {str(e)}", level="error")
import traceback import traceback
self.log_gui.log(traceback.format_exc()) self.log_gui.log(traceback.format_exc(), level="error")
# ========== 右图CIE 1976 u'v' ========== # ========== 右图CIE 1976 u'v' ==========
try: try:
img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png")) img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png"))
h_uv, w_uv = img_uv.shape[:2] h_uv, w_uv = img_uv.shape[:2]
self.log_gui.log(f"加载 UV 色域图: {w_uv}x{h_uv}") self.log_gui.log(f"加载 UV 色域图: {w_uv}x{h_uv}", level="info")
self.gamut_ax_uv.imshow(img_uv, extent=[0, w_uv, h_uv, 0], aspect="equal") self.gamut_ax_uv.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_xlim(0, w_uv)
@@ -329,18 +329,18 @@ def plot_gamut(self, results, coverage, test_type):
[u, v] for u, v in [xy_to_uv(r[0], r[1]) for r in rgb_results] [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}") self.log_gui.log(f"UV 坐标: {uv_coords}", level="info")
# ========== ✅✅计算 u'v' 覆盖率(使用参考标准)========== # ========== ✅✅计算 u'v' 覆盖率(使用参考标准)==========
try: try:
uv_coverage = pq_algorithm.calculate_uv_gamut_coverage( uv_coverage = pq_algorithm.calculate_uv_gamut_coverage(
uv_coords, reference=current_ref uv_coords, reference=current_ref
) )
self.log_gui.log( self.log_gui.log(
f"UV 空间覆盖率({current_ref}: {uv_coverage:.1f}%" f"UV 空间覆盖率({current_ref}: {uv_coverage:.1f}%"
) , level="success")
except Exception as e: except Exception as e:
self.log_gui.log(f"⚠️ 计算 UV 覆盖率失败: {str(e)}") self.log_gui.log(f"计算 UV 覆盖率失败: {str(e)}", level="error")
uv_coverage = 0.0 uv_coverage = 0.0
# ================================================= # =================================================
@@ -491,7 +491,7 @@ def plot_gamut(self, results, coverage, test_type):
zorder=3, zorder=3,
) )
# ========== UV 覆盖率标注(使用动态计算的值)========== # ========== UV 覆盖率标注(使用动态计算的值)==========
self.gamut_ax_uv.text( self.gamut_ax_uv.text(
w_uv * 0.85, w_uv * 0.85,
h_uv * 0.92, h_uv * 0.92,
@@ -521,10 +521,10 @@ def plot_gamut(self, results, coverage, test_type):
) )
except Exception as e: except Exception as e:
self.log_gui.log(f"UV 图绘制失败: {str(e)}") self.log_gui.log(f"UV 图绘制失败: {str(e)}", level="error")
import traceback import traceback
self.log_gui.log(traceback.format_exc()) self.log_gui.log(traceback.format_exc(), level="error")
# ========== 总标题 ========== # ========== 总标题 ==========
test_type_name = self.get_test_type_name(test_type) test_type_name = self.get_test_type_name(test_type)
@@ -535,4 +535,4 @@ def plot_gamut(self, results, coverage, test_type):
self.gamut_canvas.draw() self.gamut_canvas.draw()
self.chart_notebook.select(self.gamut_chart_frame) self.chart_notebook.select(self.gamut_chart_frame)
self.log_gui.log("色域图绘制完成") self.log_gui.log("色域图绘制完成", level="success")

View File

@@ -375,13 +375,13 @@ class PQConfig:
"default_pattern_gray", self.default_pattern_gray "default_pattern_gray", self.default_pattern_gray
) )
# ========== 强制使用新的 29色配置 ========== # ========== 强制使用新的 29色配置 ==========
loaded_accuracy = config_dict.get("default_pattern_accuracy", None) loaded_accuracy = config_dict.get("default_pattern_accuracy", None)
# 检查加载的配置是否是旧的 10色 # 检查加载的配置是否是旧的 10色
if loaded_accuracy and len(loaded_accuracy.get("pattern_params", [])) != 29: if loaded_accuracy and len(loaded_accuracy.get("pattern_params", [])) != 29:
print( print(
f"⚠️ 检测到旧的配置({len(loaded_accuracy.get('pattern_params', []))}色),强制使用新的 29色配置" f"检测到旧的配置({len(loaded_accuracy.get('pattern_params', []))}色),强制使用新的 29色配置"
) )
# 使用 __init__ 中定义的新配置 # 使用 __init__ 中定义的新配置
self.default_pattern_accuracy = self.default_pattern_accuracy self.default_pattern_accuracy = self.default_pattern_accuracy
@@ -431,7 +431,7 @@ class PQConfig:
self.current_pattern = self.default_pattern_rgb self.current_pattern = self.default_pattern_rgb
elif mode == "gray": elif mode == "gray":
self.current_pattern = self.default_pattern_gray self.current_pattern = self.default_pattern_gray
elif mode == "accuracy": # 色准模式SDR 和 HDR 通用 29色 elif mode == "accuracy": # 色准模式SDR 和 HDR 通用 29色
self.current_pattern = self.default_pattern_accuracy self.current_pattern = self.default_pattern_accuracy
elif mode == "custom": elif mode == "custom":
# self.current_pattern = self.custom_pattern # self.current_pattern = self.custom_pattern
@@ -459,7 +459,7 @@ class PQConfig:
self.custom_pattern["measurement_max_value"] = len(pattern_params) - 1 self.custom_pattern["measurement_max_value"] = len(pattern_params) - 1
return True return True
# ========== 获取 29色名称列表 ========== # ========== 获取 29色名称列表 ==========
def get_accuracy_color_names(self): def get_accuracy_color_names(self):
""" """
获取色准测试的 29个颜色名称SDR 和 HDR 通用) 获取色准测试的 29个颜色名称SDR 和 HDR 通用)
@@ -502,7 +502,7 @@ class PQConfig:
"100% Yellow", "100% Yellow",
] ]
# ========== 获取 29色的 RGB 值 ========== # ========== 获取 29色的 RGB 值 ==========
def get_accuracy_color_rgb(self): def get_accuracy_color_rgb(self):
""" """
获取色准测试的 RGB 值(用于标准值计算) 获取色准测试的 RGB 值(用于标准值计算)
@@ -579,7 +579,7 @@ class PQConfig:
return config_info return config_info
# ========== 验证代码(测试完成后可删除)========== # ========== 验证代码(测试完成后可删除)==========
if __name__ == "__main__": if __name__ == "__main__":
print("=" * 60) print("=" * 60)
print("验证 pq_config.py 配置") print("验证 pq_config.py 配置")
@@ -596,7 +596,7 @@ if __name__ == "__main__":
print(f" measurement_max_value: {max_value}") print(f" measurement_max_value: {max_value}")
if pattern_count == 29 and max_value == 28: if pattern_count == 29 and max_value == 28:
print("\n配置正确29色") print("\n配置正确29色")
# 显示前 5 个图案 # 显示前 5 个图案
print("\n前5个图案:") print("\n前5个图案:")
@@ -619,16 +619,16 @@ if __name__ == "__main__":
print(f" current_pattern 图案数量: {current_count}") print(f" current_pattern 图案数量: {current_count}")
if current_count == 29: if current_count == 29:
print(" set_current_pattern 工作正常") print(" set_current_pattern 工作正常")
else: else:
print(f" set_current_pattern 失败!只有 {current_count} 个图案") print(f" set_current_pattern 失败!只有 {current_count} 个图案")
else: else:
print(f"\n配置错误!") print(f"\n配置错误!")
print(f" 期望: 29 个图案, measurement_max_value=28") print(f" 期望: 29 个图案, measurement_max_value=28")
print(f" 实际: {pattern_count} 个图案, measurement_max_value={max_value}") print(f" 实际: {pattern_count} 个图案, measurement_max_value={max_value}")
print("\n请检查 default_pattern_accuracy 定义!") print("\n请检查 default_pattern_accuracy 定义!")
print(" 应该包含:") print(" 应该包含:")
print(" - 5个灰阶") print(" - 5个灰阶")
print(" - 18个 ColorChecker 色块") print(" - 18个 ColorChecker 色块")

View File

@@ -414,6 +414,122 @@ class PQResult:
} }
class PQResultStore:
"""按 test_type 管理多个 PQResult 实例。
解决两个问题:
1. `self.results` 在测试流程启动前就存在(避免 AttributeError / NoneType 访问)。
2. 支持同时保留多种 test_typescreen_module / sdr_movie / hdr_movie的历史结果
而不是每次启动新测试就把上一次的结果覆盖掉。
使用方式:
# 主类 __init__ 中:
self.results = PQResultStore()
# 启动一次新测试时:
self.results.new("screen_module", "屏模组性能测试")
# 现有调用点(隐式访问当前活跃结果)仍然生效:
self.results.add_intermediate_data("gamut", "rgb", data)
self.results.get_intermediate_data("shared", "gray")
# 跨类型显式访问某次历史结果:
pq = self.results.get("sdr_movie")
if pq is not None:
data = pq.get_intermediate_data("gamut", "rgb")
"""
# __getattr__ 代理时,这些方法若在无活跃结果时被调用,返回 None 而不是报错,
# 以兼容"尝试读取但数据还没来"的场景。
_SAFE_GETTERS = frozenset({
"get_intermediate_data",
"has_intermediate_data",
"get_all_intermediate_data",
"get_test_summary",
"get_progress_info",
"to_dict",
})
def __init__(self):
self._results: Dict[str, PQResult] = {}
self._active_type: Optional[str] = None
# ---------- 显式 API ----------
def new(self, test_type: str, test_name: str) -> PQResult:
"""为指定 test_type 创建新的 PQResult并置为当前活跃。
若该 test_type 已有旧结果,会被替换。
"""
result = PQResult(test_type=test_type, test_name=test_name)
self._results[test_type] = result
self._active_type = test_type
return result
def get(self, test_type: str) -> Optional[PQResult]:
"""按 test_type 取结果;不存在则返回 None。"""
return self._results.get(test_type)
def has(self, test_type: str) -> bool:
"""判断某个 test_type 是否有结果。"""
return test_type in self._results
def set_active(self, test_type: str) -> bool:
"""切换当前活跃结果。成功返回 True。"""
if test_type in self._results:
self._active_type = test_type
return True
return False
def clear(self, test_type: Optional[str] = None) -> None:
"""清空结果。不传参数清全部,否则只清指定 test_type。"""
if test_type is None:
self._results.clear()
self._active_type = None
else:
self._results.pop(test_type, None)
if self._active_type == test_type:
self._active_type = None
@property
def current(self) -> Optional[PQResult]:
"""当前活跃的 PQResult无则返回 None。"""
if self._active_type is None:
return None
return self._results.get(self._active_type)
@property
def current_test_type(self) -> Optional[str]:
return self._active_type
@property
def all_types(self) -> List[str]:
return list(self._results.keys())
# ---------- 兼容性:透明代理到 current ----------
def __bool__(self) -> bool:
"""`if self.results:` 仍可用于判断当前是否有活跃结果。"""
return self.current is not None
def __getattr__(self, name: str) -> Any:
# 只有常规属性查找失败时才会走到这里
if name.startswith("_"):
raise AttributeError(name)
current = self.current
if current is not None:
return getattr(current, name)
# 无活跃结果:读类方法返回空值 stub写类方法抛出清晰错误
if name in self._SAFE_GETTERS:
def _null_getter(*_args, **_kwargs):
return None
return _null_getter
raise AttributeError(
f"PQResultStore 当前没有活跃的 PQResult还未调用 new()"
f"无法访问 '{name}'。如需按 test_type 取历史结果,请用 .get(test_type)。"
)
# 使用示例和工具函数 # 使用示例和工具函数
def create_pq_result_from_config(config: Dict[str, Any]) -> PQResult: def create_pq_result_from_config(config: Dict[str, Any]) -> PQResult:
"""根据配置创建PQResult实例""" """根据配置创建PQResult实例"""

File diff suppressed because it is too large Load Diff

0
app/services/__init__.py Normal file
View File

363
app/services/ai_image.py Normal file
View File

@@ -0,0 +1,363 @@
"""AI 图片生成服务:后端请求 + 本地缓存管理。
API 端点待接入,当前通过 ``set_api_caller`` 注入具体实现。
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
"""
from __future__ import annotations
import datetime as _dt
import hashlib
import json
import mimetypes
import os
import shutil
import threading
from dataclasses import dataclass, asdict
from typing import Callable, List, Optional
from urllib.parse import urlparse
from urllib.request import Request, urlopen
# ---------- 常量 ----------
_CACHE_DIRNAME = os.path.join("settings", "ai_image_cache")
_META_SUFFIX = ".json"
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
# ---------- 数据结构 ----------
@dataclass
class AIImageRecord:
"""一条缓存记录。"""
id: str
prompt: str
image_path: str
created_at: str # ISO8601
extra: Optional[dict] = None
def to_json(self) -> str:
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
# ---------- API 注入 ----------
# 调用签名: ``fn(prompt: str) -> (image_bytes: bytes, image_ext: str, extra: dict|None)``
# ``image_ext`` 例如 ``".png"````extra`` 可为 None。
_ApiCaller = Callable[[str], tuple]
_api_caller: Optional[_ApiCaller] = None
def set_api_caller(fn: Optional[_ApiCaller]) -> None:
"""注入真实的后端 API 调用函数。在 API 就绪前可保持为 None。"""
global _api_caller
_api_caller = fn
def has_api() -> bool:
return _api_caller is not None
# ---------- 缓存路径工具 ----------
def get_cache_dir(base_dir: Optional[str] = None) -> str:
"""返回缓存目录,如不存在则创建。``base_dir`` 默认使用当前工作目录。"""
root = base_dir if base_dir else os.getcwd()
path = os.path.join(root, _CACHE_DIRNAME)
os.makedirs(path, exist_ok=True)
return path
def _make_id(prompt: str) -> str:
stamp = _dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
digest = hashlib.md5(prompt.encode("utf-8")).hexdigest()[:8]
return f"{stamp}_{digest}"
def _meta_path_for(image_path: str) -> str:
return os.path.splitext(image_path)[0] + _META_SUFFIX
# ---------- 读写 ----------
def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
"""列出缓存目录下的所有记录,按创建时间倒序(最新在前)。"""
cache_dir = get_cache_dir(base_dir)
records: List[AIImageRecord] = []
for name in os.listdir(cache_dir):
full = os.path.join(cache_dir, name)
if not (os.path.isfile(full) and name.lower().endswith(_SUPPORTED_IMG_EXT)):
continue
meta_path = _meta_path_for(full)
prompt = ""
created_at = ""
extra = None
rec_id = os.path.splitext(name)[0]
if os.path.isfile(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
data = json.load(f)
prompt = data.get("prompt", "")
created_at = data.get("created_at", "")
extra = data.get("extra")
rec_id = data.get("id", rec_id)
except Exception:
pass
if not created_at:
# fallback 到文件 mtime
try:
mtime = os.path.getmtime(full)
created_at = _dt.datetime.fromtimestamp(mtime).isoformat()
except Exception:
created_at = ""
records.append(
AIImageRecord(
id=rec_id,
prompt=prompt,
image_path=full,
created_at=created_at,
extra=extra,
)
)
if not records:
seeded = _seed_placeholder_record(cache_dir)
if seeded is not None:
records.append(seeded)
records.sort(key=lambda r: r.created_at, reverse=True)
return records
def _seed_placeholder_record(cache_dir: str) -> Optional[AIImageRecord]:
"""当缓存为空时,写入一张本地占位图,便于前端联调。"""
try:
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
src = os.path.join(repo_root, "assets", "entry_1.png")
if not os.path.isfile(src):
return None
rec_id = f"{_dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_placeholder"
image_path = os.path.join(cache_dir, f"{rec_id}.png")
shutil.copyfile(src, image_path)
record = AIImageRecord(
id=rec_id,
prompt="本地测试占位图(后端未接入)",
image_path=image_path,
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
extra={"source": "local-placeholder"},
)
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
f.write(record.to_json())
return record
except Exception:
return None
def save_image_to_cache(
prompt: str,
image_bytes: bytes,
image_ext: str = ".png",
extra: Optional[dict] = None,
base_dir: Optional[str] = None,
) -> AIImageRecord:
"""把生成的图片字节写入缓存,返回记录。"""
if not image_ext.startswith("."):
image_ext = "." + image_ext
if image_ext.lower() not in _SUPPORTED_IMG_EXT:
image_ext = ".png"
cache_dir = get_cache_dir(base_dir)
rec_id = _make_id(prompt)
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
with open(image_path, "wb") as f:
f.write(image_bytes)
record = AIImageRecord(
id=rec_id,
prompt=prompt,
image_path=image_path,
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
extra=extra,
)
try:
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
f.write(record.to_json())
except Exception:
pass
return record
def import_image_from_url(
image_url: str,
prompt: Optional[str] = None,
extra: Optional[dict] = None,
base_dir: Optional[str] = None,
timeout: float = 20.0,
) -> AIImageRecord:
"""下载远程图片并写入缓存。"""
url = (image_url or "").strip()
if not url:
raise ValueError("图片地址不能为空")
request = Request(
url,
headers={
"User-Agent": "pqAutomationApp/1.0",
"Accept": "image/*,*/*;q=0.8",
},
)
with urlopen(request, timeout=timeout) as response:
image_bytes = response.read()
if not image_bytes:
raise ValueError("下载结果为空")
image_ext = _guess_image_ext(
image_url=url,
content_type=response.headers.get_content_type(),
)
merged_extra = dict(extra or {})
merged_extra.update(
{
"source": "remote-url",
"source_url": url,
"content_type": response.headers.get_content_type(),
}
)
record_prompt = (prompt or _default_prompt_from_url(url)).strip()
return save_image_to_cache(
prompt=record_prompt,
image_bytes=image_bytes,
image_ext=image_ext,
extra=merged_extra,
base_dir=base_dir,
)
def delete_record(record: AIImageRecord) -> bool:
"""删除一条缓存记录(图片 + 侧车)。返回是否成功。"""
ok = True
for p in (record.image_path, _meta_path_for(record.image_path)):
try:
if os.path.isfile(p):
os.remove(p)
except Exception:
ok = False
return ok
def export_record(record: AIImageRecord, dest_path: str) -> None:
"""把缓存中的图片另存到 ``dest_path``。"""
shutil.copyfile(record.image_path, dest_path)
# ---------- 异步请求 ----------
def request_image_async(
prompt: str,
on_success: Callable[[AIImageRecord], None],
on_error: Callable[[Exception], None],
base_dir: Optional[str] = None,
) -> threading.Thread:
"""在后台线程请求 API → 写入缓存 → 回调。
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用UI 侧若需
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
"""
def _worker():
try:
if _api_caller is None:
raise RuntimeError("AI 图片 API 尚未接入,请调用 set_api_caller 注入")
image_bytes, image_ext, extra = _normalize_api_result(_api_caller(prompt))
record = save_image_to_cache(
prompt=prompt,
image_bytes=image_bytes,
image_ext=image_ext,
extra=extra,
base_dir=base_dir,
)
on_success(record)
except Exception as exc:
on_error(exc)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t
def import_image_from_url_async(
image_url: str,
on_success: Callable[[AIImageRecord], None],
on_error: Callable[[Exception], None],
prompt: Optional[str] = None,
extra: Optional[dict] = None,
base_dir: Optional[str] = None,
timeout: float = 20.0,
) -> threading.Thread:
"""在后台线程下载远程图片并写入缓存"""
def _worker():
try:
record = import_image_from_url(
image_url=image_url,
prompt=prompt,
extra=extra,
base_dir=base_dir,
timeout=timeout,
)
on_success(record)
except Exception as exc:
on_error(exc)
t = threading.Thread(target=_worker, daemon=True)
t.start()
return t
def _normalize_api_result(result):
"""允许 API 返回 ``bytes`` 或 ``(bytes, ext)`` 或 ``(bytes, ext, extra)``。"""
if isinstance(result, (bytes, bytearray)):
return bytes(result), ".png", None
if isinstance(result, tuple):
if len(result) == 2:
return bytes(result[0]), str(result[1]), None
if len(result) == 3:
return bytes(result[0]), str(result[1]), result[2]
raise ValueError("API 返回格式不支持,需为 bytes 或 (bytes, ext[, extra])")
def is_remote_image_url(value: str) -> bool:
"""判断输入是否为 http/https 图片地址。"""
url = (value or "").strip()
if not url:
return False
parsed = urlparse(url)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
def _guess_image_ext(image_url: str, content_type: Optional[str]) -> str:
if content_type:
guessed = mimetypes.guess_extension(content_type)
if guessed == ".jpe":
guessed = ".jpg"
if guessed and guessed.lower() in _SUPPORTED_IMG_EXT:
return guessed.lower()
url_path = urlparse(image_url).path
ext = os.path.splitext(url_path)[1].lower()
if ext in _SUPPORTED_IMG_EXT:
return ext
return ".png"
def _default_prompt_from_url(image_url: str) -> str:
path = urlparse(image_url).path
name = os.path.splitext(os.path.basename(path))[0].strip()
return name or "远程导入图片"

View File

@@ -1,4 +1,4 @@
"""Local Dimming 测试逻辑(应用层)。 """Local Dimming 测试逻辑(应用层)。
整合自原 drivers/local_dimming_test.py窗口图片生成与测试主循环 整合自原 drivers/local_dimming_test.py窗口图片生成与测试主循环
直接落在本模块UCD 通用操作下沉到 drivers.ucd_helpers。 直接落在本模块UCD 通用操作下沉到 drivers.ucd_helpers。
@@ -107,51 +107,51 @@ def start_local_dimming_test(self):
def worker(): def worker():
log = self.log_gui.log log = self.log_gui.log
log("=" * 60) log("=" * 60, level="separator")
log("开始 Local Dimming 测试") log("开始 Local Dimming 测试", level="info")
log("=" * 60) log("=" * 60, level="separator")
width, height = get_current_resolution(self.ucd) width, height = get_current_resolution(self.ucd)
total = len(DEFAULT_WINDOW_PERCENTAGES) total = len(DEFAULT_WINDOW_PERCENTAGES)
log(f" 分辨率: {width}x{height}") log(f" 分辨率: {width}x{height}", level="info")
log(f" 测试窗口: {DEFAULT_WINDOW_PERCENTAGES}") log(f" 测试窗口: {DEFAULT_WINDOW_PERCENTAGES}", level="info")
log(f" 等待时间: {wait_time}") log(f" 等待时间: {wait_time}", level="info")
results = [] results = []
for i, percentage in enumerate(DEFAULT_WINDOW_PERCENTAGES, 1): for i, percentage in enumerate(DEFAULT_WINDOW_PERCENTAGES, 1):
if stop_event.is_set(): if stop_event.is_set():
log("⚠️ 测试已停止") log("测试已停止", level="error")
break break
log(f"[{i}/{total}] 测试 {percentage}% 窗口...") log(f"[{i}/{total}] 测试 {percentage}% 窗口...", level="info")
try: try:
image_path = _ensure_window_image(width, height, percentage) image_path = _ensure_window_image(width, height, percentage)
except Exception as e: except Exception as e:
log(f" 图像生成失败: {e}") log(f" 图像生成失败: {e}", level="error")
continue continue
if not send_image_pattern(self.ucd, image_path): if not send_image_pattern(self.ucd, image_path):
log(f" {percentage}% 窗口发送失败,跳过") log(f" {percentage}% 窗口发送失败,跳过", level="error")
continue continue
log(f"等待 {wait_time} 秒...") log(f"等待 {wait_time} 秒...", level="info")
time.sleep(wait_time) time.sleep(wait_time)
try: try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay() x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
except Exception as e: except Exception as e:
log(f" 采集亮度异常: {e}") log(f" 采集亮度异常: {e}", level="error")
continue continue
if lv is None: if lv is None:
log(f" {percentage}% 窗口采集失败") log(f" {percentage}% 窗口采集失败", level="error")
continue continue
log(f"采集亮度: {lv:.2f} cd/m²") log(f"采集亮度: {lv:.2f} cd/m²", level="info")
results.append((percentage, x, y, lv, _X, _Y, _Z)) results.append((percentage, x, y, lv, _X, _Y, _Z))
log("=" * 60) log("=" * 60, level="separator")
log(f"Local Dimming 测试完成 ({len(results)}/{total})") log(f"Local Dimming 测试完成 ({len(results)}/{total})", level="success")
log("=" * 60) log("=" * 60, level="separator")
self.ld_test_results = results self.ld_test_results = results
self._dispatch_ui(self.update_ld_results, results) self._dispatch_ui(self.update_ld_results, results)
@@ -184,7 +184,7 @@ def send_ld_window(self, percentage):
messagebox.showwarning("警告", "请先连接 UCD323 设备") messagebox.showwarning("警告", "请先连接 UCD323 设备")
return return
self.log_gui.log(f"🔆 发送 {percentage}% 窗口...") self.log_gui.log(f"🔆 发送 {percentage}% 窗口...", level="info")
self.current_ld_percentage = percentage self.current_ld_percentage = percentage
def send(): def send():
@@ -192,12 +192,12 @@ def send_ld_window(self, percentage):
try: try:
image_path = _ensure_window_image(width, height, percentage) image_path = _ensure_window_image(width, height, percentage)
except Exception as e: except Exception as e:
self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}") self._dispatch_ui(self.log_gui.log, f"图像生成失败: {e}")
return return
ok = send_image_pattern(self.ucd, image_path) ok = send_image_pattern(self.ucd, image_path)
msg = ( msg = (
f"{percentage}% 窗口已发送" if ok f"{percentage}% 窗口已发送" if ok
else f"{percentage}% 窗口发送失败" else f"{percentage}% 窗口发送失败"
) )
self._dispatch_ui(self.log_gui.log, msg) self._dispatch_ui(self.log_gui.log, msg)
@@ -213,16 +213,16 @@ def measure_ld_luminance(self):
messagebox.showinfo("提示", "请先发送一个窗口图案") messagebox.showinfo("提示", "请先发送一个窗口图案")
return return
self.log_gui.log("📏 正在采集亮度...") self.log_gui.log("📏 正在采集亮度...", level="info")
def measure(): def measure():
try: try:
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay() x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
except Exception as e: except Exception as e:
self._dispatch_ui(self.log_gui.log, f"采集异常: {str(e)}") self._dispatch_ui(self.log_gui.log, f"采集异常: {str(e)}")
return return
if lv is None: if lv is None:
self._dispatch_ui(self.log_gui.log, "采集失败") self._dispatch_ui(self.log_gui.log, "采集失败")
return return
timestamp = datetime.datetime.now().strftime("%H:%M:%S") timestamp = datetime.datetime.now().strftime("%H:%M:%S")
self._dispatch_ui( self._dispatch_ui(
@@ -236,7 +236,7 @@ def measure_ld_luminance(self):
f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}", timestamp, f"{lv:.2f}", f"{x:.4f}", f"{y:.4f}", timestamp,
), ),
) )
self._dispatch_ui(self.log_gui.log, f"采集完成: {lv:.2f} cd/m²") self._dispatch_ui(self.log_gui.log, f"采集完成: {lv:.2f} cd/m²")
threading.Thread(target=measure, daemon=True).start() threading.Thread(target=measure, daemon=True).start()
@@ -247,7 +247,7 @@ def clear_ld_records(self):
self.ld_tree.delete(item) self.ld_tree.delete(item)
self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --") self.ld_result_label.config(text="亮度: -- cd/m² | x: -- | y: --")
self.current_ld_percentage = None self.current_ld_percentage = None
self.log_gui.log("🗑️ 测试记录已清空") self.log_gui.log("测试记录已清空", level="info")
def save_local_dimming_results(self): def save_local_dimming_results(self):
@@ -274,8 +274,8 @@ def save_local_dimming_results(self):
writer.writerow(["窗口百分比", "亮度 (cd/m²)", "x", "y", "时间"]) writer.writerow(["窗口百分比", "亮度 (cd/m²)", "x", "y", "时间"])
for item in self.ld_tree.get_children(): for item in self.ld_tree.get_children():
writer.writerow(self.ld_tree.item(item, "values")) writer.writerow(self.ld_tree.item(item, "values"))
self.log_gui.log(f"测试结果已保存: {save_path}") self.log_gui.log(f"测试结果已保存: {save_path}", level="success")
messagebox.showinfo("成功", f"测试结果已保存到:\n{save_path}") messagebox.showinfo("成功", f"测试结果已保存到:\n{save_path}")
except Exception as e: except Exception as e:
self.log_gui.log(f"保存失败: {str(e)}") self.log_gui.log(f"保存失败: {str(e)}", level="error")
messagebox.showerror("错误", f"保存失败: {str(e)}") messagebox.showerror("错误", f"保存失败: {str(e)}")

View File

@@ -1,4 +1,4 @@
"""图表框架相关逻辑Step 3 重构)。 """图表框架相关逻辑Step 3 重构)。
从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app` 从 pqAutomationApp.PQAutomationApp 中搬迁而来。每个函数第一行 `self = app`
以保留原有 `self.xxx` 属性访问不变。 以保留原有 `self.xxx` 属性访问不变。
@@ -21,7 +21,7 @@ def init_gamut_chart(self):
canvas_widget = self.gamut_canvas.get_tk_widget() canvas_widget = self.gamut_canvas.get_tk_widget()
canvas_widget.pack(expand=True, fill=tk.BOTH) canvas_widget.pack(expand=True, fill=tk.BOTH)
# 恢复原来的大尺寸0.84 高度 # 恢复原来的大尺寸0.84 高度
self.gamut_ax_xy = self.gamut_fig.add_axes( self.gamut_ax_xy = self.gamut_fig.add_axes(
[0.02, 0.08, 0.46, 0.84] [0.02, 0.08, 0.46, 0.84]
) # ← 改回 0.84 ) # ← 改回 0.84
@@ -47,7 +47,7 @@ def init_gamut_chart(self):
self.gamut_canvas.draw() self.gamut_canvas.draw()
def init_gamma_chart(self): def init_gamma_chart(self):
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格(4列 + 通用说明)""" """初始化Gamma曲线图表 - 左侧曲线 + 右侧表格4列 + 通用说明)"""
container = ttk.Frame(self.gamma_chart_frame) container = ttk.Frame(self.gamma_chart_frame)
container.pack(expand=True, fill=tk.BOTH) container.pack(expand=True, fill=tk.BOTH)
@@ -156,7 +156,7 @@ def init_gamma_chart(self):
self.gamma_canvas.draw() self.gamma_canvas.draw()
def init_eotf_chart(self): def init_eotf_chart(self):
"""初始化 EOTF 曲线图表HDR 专用)- 左侧曲线 + 右侧表格(4列""" """初始化 EOTF 曲线图表HDR 专用)- 左侧曲线 + 右侧表格4列"""
container = ttk.Frame(self.eotf_chart_frame) container = ttk.Frame(self.eotf_chart_frame)
container.pack(expand=True, fill=tk.BOTH) container.pack(expand=True, fill=tk.BOTH)
@@ -741,7 +741,7 @@ def update_chart_tabs_state(self):
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"更新Tab状态失败: {str(e)}") self.log_gui.log(f"更新Tab状态失败: {str(e)}", level="error")
def create_result_chart_frame(self): def create_result_chart_frame(self):
"""创建结果图表区域 - 6个独立TabGamma 和 EOTF 分离)""" """创建结果图表区域 - 6个独立TabGamma 和 EOTF 分离)"""
@@ -790,10 +790,10 @@ def create_result_chart_frame(self):
# 绑定Tab切换事件 # 绑定Tab切换事件
self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed) self.chart_notebook.bind("<<NotebookTabChanged>>", self.on_chart_tab_changed)
# ==================== 在图表下方创建单步调试面板 ==================== # ==================== 在图表下方创建单步调试面板 ====================
self.debug_container = ttk.LabelFrame( self.debug_container = ttk.LabelFrame(
self.result_frame, # ← 放在 result_frame 内,图表正下方 self.result_frame, # ← 放在 result_frame 内,图表正下方
text="🔧 单步调试", text=" 单步调试",
padding=10, padding=10,
) )
# 默认不显示 # 默认不显示
@@ -801,8 +801,6 @@ def create_result_chart_frame(self):
# 创建单步调试面板实例 # 创建单步调试面板实例
self.debug_panel = PQDebugPanel(self.debug_container, self) self.debug_panel = PQDebugPanel(self.debug_container, self)
self.log_gui.log("✓ 单步调试面板已创建(放在测试结果图表下方)")
def on_chart_tab_changed(self, event): def on_chart_tab_changed(self, event):
"""Tab切换时的事件处理""" """Tab切换时的事件处理"""
try: try:
@@ -810,5 +808,5 @@ def on_chart_tab_changed(self, event):
self.chart_notebook.select() self.chart_notebook.select()
) )
except Exception as e: except Exception as e:
self.log_gui.log(f"Tab切换事件处理失败: {str(e)}") self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error")

View File

@@ -0,0 +1,561 @@
"""AI 图片对话面板:输入提示 → 生成 → 显示 → 列表切换/保存/删除。"""
from __future__ import annotations
import os
import threading
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
import ttkbootstrap as ttk
from PIL import Image, ImageTk
from app.services import ai_image as _svc
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
# ---------------- 面板创建 ----------------
def create_ai_image_panel(self):
"""创建 AI 图片对话面板,并注册到面板管理。"""
frame = ttk.Frame(self.content_frame)
self.ai_image_frame = frame
# 内部状态
self.ai_image_records = [] # list[AIImageRecord]
self.ai_image_current = None # AIImageRecord | None
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
self._ai_image_requesting = False
container = ttk.Frame(frame, padding=10)
container.pack(fill=tk.BOTH, expand=True)
# 左列:图片列表
# 使用 grid + 权重,让右侧预览区优先占据剩余空间。
container.columnconfigure(0, weight=0)
container.columnconfigure(1, weight=1)
container.rowconfigure(0, weight=1)
left = ttk.Frame(container, width=360)
left.grid(row=0, column=0, sticky=tk.NS, padx=(0, 10))
left.grid_propagate(False)
ttk.Label(left, text="历史图片", font=("微软雅黑", 10, "bold")).pack(
anchor=tk.W, pady=(0, 4)
)
list_wrap = ttk.Frame(left, padding=2)
list_wrap.pack(fill=tk.BOTH, expand=True)
scroll = ttk.Scrollbar(list_wrap, orient=tk.VERTICAL)
self.ai_image_listbox = tk.Listbox(
list_wrap,
width=34,
height=1, # 由 pack fill/expand 撑满height 仅为最小保底
activestyle="none",
font=("微软雅黑", 9),
bd=1,
relief=tk.FLAT,
highlightthickness=1,
highlightbackground="#d8d8d8",
highlightcolor="#4a90e2",
selectbackground="#2b6cb0",
selectforeground="#ffffff",
yscrollcommand=scroll.set,
)
scroll.config(command=self.ai_image_listbox.yview)
self.ai_image_listbox.pack(side=tk.LEFT, fill=tk.Y)
scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.ai_image_listbox.bind("<<ListboxSelect>>", lambda e: _on_list_select(self))
# 右键菜单:发送到 UCD / 重命名 / 另存为 / 删除
# 索引: 0=发送, 1=sep, 2=重命名, 3=另存为, 4=删除
self.ai_image_menu = tk.Menu(self.root, tearoff=0)
self.ai_image_menu.add_command(
label="发送图片",
command=lambda: _send_to_ucd(self),
)
self.ai_image_menu.add_separator()
self.ai_image_menu.add_command(
label="重命名…",
command=lambda: _rename_current(self),
)
self.ai_image_menu.add_command(
label="另存为…",
command=lambda: _save_current(self),
)
self.ai_image_menu.add_command(
label="删除",
command=lambda: _delete_current(self),
)
self.ai_image_listbox.bind(
"<Button-3>",
lambda e: _show_list_context_menu(self, e),
)
btn_row = ttk.Frame(left)
btn_row.pack(fill=tk.X, pady=(6, 0))
self.ai_image_send_ucd_btn = ttk.Button(
btn_row, text="发送", bootstyle="info-outline", width=8,
command=lambda: _send_to_ucd(self),
)
self.ai_image_send_ucd_btn.pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
btn_row, text="保存", bootstyle="success-outline", width=8,
command=lambda: _save_current(self),
).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
btn_row, text="删除", bootstyle="danger-outline", width=8,
command=lambda: _delete_current(self),
).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
btn_row, text="刷新", bootstyle="secondary-outline", width=8,
command=lambda: reload_ai_image_list(self),
).pack(side=tk.LEFT)
# 右列:预览 + 输入
right = ttk.Frame(container)
right.grid(row=0, column=1, sticky=tk.NSEW)
preview_frame = ttk.LabelFrame(right, text="图片预览", padding=6)
preview_frame.pack(fill=tk.BOTH, expand=True)
self.ai_image_canvas = tk.Canvas(
preview_frame, bg="#1e1e1e", highlightthickness=0
)
self.ai_image_canvas.pack(fill=tk.BOTH, expand=True)
self.ai_image_canvas.bind("<Configure>", lambda e: _redraw_preview(self))
meta_row = ttk.Frame(right)
meta_row.pack(fill=tk.X, pady=(4, 4))
self.ai_image_meta_var = tk.StringVar(value="未选择图片")
ttk.Label(
meta_row, textvariable=self.ai_image_meta_var,
foreground="#666", font=("微软雅黑", 9),
).pack(side=tk.LEFT)
# 输入区
input_frame = ttk.LabelFrame(right, text="提示输入Ctrl+Enter 发送)", padding=6)
input_frame.pack(fill=tk.X, pady=(4, 0))
self.ai_image_input = tk.Text(input_frame, height=3, wrap=tk.WORD)
self.ai_image_input.pack(fill=tk.X, side=tk.TOP)
self.ai_image_input.bind("<Control-Return>", lambda e: (_send_prompt(self), "break"))
send_row = ttk.Frame(input_frame)
send_row.pack(fill=tk.X, pady=(4, 0))
self.ai_image_status_var = tk.StringVar(value="就绪")
ttk.Label(
send_row, textvariable=self.ai_image_status_var,
foreground="#888", font=("微软雅黑", 9),
).pack(side=tk.LEFT)
self.ai_image_send_btn = ttk.Button(
send_row, text="发送", bootstyle="primary", width=10,
command=lambda: _send_prompt(self),
)
self.ai_image_send_btn.pack(side=tk.RIGHT)
# 注册面板
self.register_panel("ai_image", frame, None, "ai_image_visible")
self.ai_image_visible = False
# 初次加载缓存
reload_ai_image_list(self)
def toggle_ai_image_panel(self):
"""切换 AI 图片面板显隐。"""
self.show_panel("ai_image")
# ---------------- 列表 / 选中 ----------------
def reload_ai_image_list(self):
"""重新扫描缓存并刷新列表。"""
self.ai_image_records = _svc.list_records()
self.ai_image_listbox.delete(0, tk.END)
for rec in self.ai_image_records:
label = _format_list_label(rec)
self.ai_image_listbox.insert(tk.END, label)
if self.ai_image_records:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(0)
self.ai_image_listbox.activate(0)
_select_record(self, self.ai_image_records[0])
else:
self.ai_image_current = None
self.ai_image_photo = None
self.ai_image_canvas.delete("all")
self.ai_image_meta_var.set("暂无缓存图片")
def _format_list_label(rec: _svc.AIImageRecord) -> str:
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
size_tag = ""
extra = rec.extra or {}
if isinstance(extra, dict) and extra.get("size"):
size_tag = f"[{extra['size']}] "
else:
try:
from PIL import Image as _Im
with _Im.open(rec.image_path) as _im:
size_tag = f"[{_im.width}×{_im.height}] "
except Exception:
pass
prompt_line = (rec.prompt or "(无提示)").strip().splitlines()[0]
# 剩余可用宽度width=34去掉 size_tag
max_prompt = 34 - len(size_tag) - 2
if max_prompt > 4 and len(prompt_line) > max_prompt:
prompt_line = prompt_line[:max_prompt] + ""
return f"{size_tag}{prompt_line}"
def _on_list_select(self):
sel = self.ai_image_listbox.curselection()
if not sel:
return
idx = sel[0]
if 0 <= idx < len(self.ai_image_records):
_select_record(self, self.ai_image_records[idx])
def _select_record(self, rec: _svc.AIImageRecord):
self.ai_image_current = rec
self.ai_image_meta_var.set(
f"{os.path.basename(rec.image_path)} | {rec.created_at}"
)
_redraw_preview(self)
# ---------------- 预览绘制 ----------------
def _redraw_preview(self):
rec = getattr(self, "ai_image_current", None)
canvas = self.ai_image_canvas
canvas.delete("all")
if rec is None or not os.path.isfile(rec.image_path):
return
cw = canvas.winfo_width() or 1
ch = canvas.winfo_height() or 1
try:
img = Image.open(rec.image_path)
except Exception as exc:
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
return
iw, ih = img.size
scale = min(cw / iw, ch / ih, 1.0)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
img_resized = img.resize((nw, nh), Image.LANCZOS)
self.ai_image_photo = ImageTk.PhotoImage(img_resized)
canvas.create_image(cw // 2, ch // 2, image=self.ai_image_photo, anchor="center")
# ---------------- 发送 / 保存 / 删除 ----------------
def _send_prompt(self):
if getattr(self, "_ai_image_requesting", False):
return
prompt = self.ai_image_input.get("1.0", tk.END).strip()
if not prompt:
messagebox.showinfo("提示", "请输入内容")
return
_set_requesting(self, True)
is_remote_url = _svc.is_remote_image_url(prompt)
self.ai_image_status_var.set("下载中…" if is_remote_url else "请求中…")
def _success(record):
self.root.after(0, lambda: _on_request_done(self, record, None))
def _error(exc):
self.root.after(0, lambda: _on_request_done(self, None, exc))
if is_remote_url:
_svc.import_image_from_url_async(
prompt,
on_success=_success,
on_error=_error,
)
return
if not _svc.has_api():
_set_requesting(self, False)
self.ai_image_status_var.set("就绪")
messagebox.showerror(
"API 未配置",
"AI 图片 API 尚未接入。\n"
"可直接输入图片 URL 导入,或在启动时通过 "
"app.services.ai_image.set_api_caller(...) 注入真实实现。",
)
return
_svc.request_image_async(prompt, on_success=_success, on_error=_error)
def _set_requesting(self, flag: bool):
self._ai_image_requesting = flag
try:
self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
except Exception:
pass
def _on_request_done(self, record, exc):
_set_requesting(self, False)
if exc is not None:
self.ai_image_status_var.set(f"失败: {exc}")
messagebox.showerror("生成失败", str(exc))
return
self.ai_image_status_var.set("完成")
self.ai_image_input.delete("1.0", tk.END)
reload_ai_image_list(self)
# 定位到新生成项(最新在前)
if record is not None and self.ai_image_records:
for i, r in enumerate(self.ai_image_records):
if r.id == record.id:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(i)
self.ai_image_listbox.activate(i)
_select_record(self, r)
break
def _save_current(self):
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
ext = os.path.splitext(rec.image_path)[1] or ".png"
dest = filedialog.asksaveasfilename(
title="另存为",
defaultextension=ext,
initialfile=os.path.basename(rec.image_path),
filetypes=[("图片", "*.png;*.jpg;*.jpeg;*.bmp;*.webp"), ("所有文件", "*.*")],
)
if not dest:
return
try:
_svc.export_record(rec, dest)
messagebox.showinfo("成功", f"已保存到:\n{dest}")
except Exception as exc:
messagebox.showerror("保存失败", str(exc))
def _delete_current(self):
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
if not messagebox.askyesno("确认删除", f"确定删除这张缓存图片吗?\n{os.path.basename(rec.image_path)}"):
return
_svc.delete_record(rec)
reload_ai_image_list(self)
def _rename_current(self):
"""弹窗让用户修改当前记录的备注名称(即列表显示中 size_tag 之后的部分)。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
current_name = rec.prompt or ""
new_name = simpledialog.askstring(
"重命名",
"修改备注名称(显示在分辨率标签后面):",
initialvalue=current_name,
parent=self.root,
)
if new_name is None: # 用户点了取消
return
new_name = new_name.strip()
if not new_name:
messagebox.showwarning("提示", "备注名称不能为空")
return
if new_name == current_name:
return
# 写回 JSON 元数据
try:
import json
meta_path = os.path.splitext(rec.image_path)[0] + ".json"
meta = {}
if os.path.isfile(meta_path):
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f)
meta["prompt"] = new_name
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False, indent=2)
except Exception as exc:
messagebox.showerror("保存失败", f"无法更新元数据:\n{exc}")
return
# 同步内存中的记录并刷新列表
rec.prompt = new_name
reload_ai_image_list(self)
# 重新定位到刚才被重命名的图片
for i, r in enumerate(self.ai_image_records):
if r.id == rec.id:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(i)
self.ai_image_listbox.activate(i)
self.ai_image_listbox.see(i)
_select_record(self, r)
break
# ---------------- 发送到 UCD ----------------
def _show_list_context_menu(self, event):
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
try:
idx = self.ai_image_listbox.nearest(event.y)
except Exception:
idx = -1
if 0 <= idx < len(self.ai_image_records):
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(idx)
self.ai_image_listbox.activate(idx)
_select_record(self, self.ai_image_records[idx])
has_selection = self.ai_image_current is not None
ucd = getattr(self, "ucd", None)
can_send = (
has_selection
and ucd is not None
and getattr(ucd, "status", False)
)
try:
self.ai_image_menu.entryconfigure(
0, state=("normal" if can_send else "disabled")
)
self.ai_image_menu.entryconfigure(
2, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.entryconfigure(
3, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.entryconfigure(
4, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.tk_popup(event.x_root, event.y_root)
finally:
self.ai_image_menu.grab_release()
def _send_to_ucd(self):
"""把当前选中的 AI 图片通过 UCD 发送到显示设备。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
if not os.path.isfile(rec.image_path):
messagebox.showerror("错误", f"图片文件不存在:\n{rec.image_path}")
return
ucd = getattr(self, "ucd", None)
if ucd is None or not getattr(ucd, "status", False):
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
image_path = rec.image_path
send_path = image_path
log = getattr(self, "log_gui", None)
# 发送前检查分辨率:建议与当前 UCD 输出分辨率一致。
try:
target_w, target_h = get_current_resolution(ucd)
except Exception:
target_w, target_h = 3840, 2160
try:
with Image.open(image_path) as _img:
src_w, src_h = _img.size
except Exception as exc:
messagebox.showerror("错误", f"无法读取图片尺寸:\n{exc}")
return
if log is not None:
log.log(
f"UCD分辨率: {target_w}x{target_h} | 图片分辨率: {src_w}x{src_h}",
level="info",
)
if (src_w, src_h) != (target_w, target_h):
if not messagebox.askyesno(
"分辨率不匹配",
(
f"当前 UCD 分辨率: {target_w}x{target_h}\n"
f"图片分辨率: {src_w}x{src_h}\n\n"
"是否自动缩放后再发送?"
),
):
if log is not None:
log.log("用户取消发送:图片分辨率与 UCD 不一致", level="warning")
return
try:
send_path = _build_ucd_resized_image(image_path, target_w, target_h)
if log is not None:
log.log(
f"已生成匹配分辨率副本: {os.path.basename(send_path)}",
level="info",
)
except Exception as exc:
messagebox.showerror("错误", f"自动缩放失败:\n{exc}")
return
if log is not None:
log.log(
f"发送 AI 图片到 UCD: {os.path.basename(send_path)}",
level="info",
)
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("发送中…")
def _worker():
err = None
try:
ok = send_image_pattern(ucd, send_path)
except Exception as exc:
ok = False
err = str(exc)
def _done():
if ok:
if log is not None:
log.log(
f"图片已发送到 UCD: {os.path.basename(send_path)}",
level="success",
)
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("已发送到 UCD")
else:
msg = f"UCD 发送失败: {err}" if err else "UCD 发送失败"
if log is not None:
log.log(msg, level="error")
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("UCD 发送失败")
try:
self.root.after(0, _done)
except Exception:
pass
threading.Thread(target=_worker, daemon=True).start()
def _build_ucd_resized_image(image_path: str, target_w: int, target_h: int) -> str:
"""生成与 UCD 分辨率匹配的临时副本(缓存目录内)。"""
base_dir = os.path.dirname(image_path)
base_name = os.path.splitext(os.path.basename(image_path))[0]
out_name = f"{base_name}_{target_w}x{target_h}_ucd.png"
out_path = os.path.join(base_dir, out_name)
with Image.open(image_path) as img:
resized = img.convert("RGB").resize((target_w, target_h), Image.LANCZOS)
resized.save(out_path, format="PNG")
return out_path

View File

@@ -1,4 +1,4 @@
"""CCT 参数面板及其处理函数(Step 6 重构)。""" """CCT 参数面板及其处理函数(与主文件重构版本保持同步)。"""
import time import time
import traceback import traceback
@@ -10,7 +10,7 @@ import algorithm.pq_algorithm as pq_algorithm
def create_cct_params_frame(self): def create_cct_params_frame(self):
"""创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)""" """创建色度参数设置区域 - 屏模组、SDR、HDR 独立(增加色域参考标准选择 + 单步调试按钮)"""
# ==================== 屏模组色度参数 Frame ==================== # ==================== 屏模组色度参数 Frame ====================
self.cct_params_frame = ttk.LabelFrame( self.cct_params_frame = ttk.LabelFrame(
@@ -18,21 +18,16 @@ def create_cct_params_frame(self):
) )
# 默认值 # 默认值
self.DEFAULT_CCT_PARAMS = { screen_default_cct_params = self.config.get_default_cct_params("screen_module")
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.3290,
"y_tolerance": 0.003,
}
# 从配置读取屏模组参数 # 从配置读取屏模组参数
saved_params = self.config.current_test_types.get("screen_module", {}).get( saved_params = self.config.current_test_types.get("screen_module", {}).get(
"cct_params", self.DEFAULT_CCT_PARAMS.copy() "cct_params", screen_default_cct_params.copy()
) )
# 色域参考标准 # 色域参考标准
saved_gamut_ref = self.config.current_test_types.get("screen_module", {}).get( saved_gamut_ref = self.config.current_test_types.get("screen_module", {}).get(
"gamut_reference", "DCI-P3" "gamut_reference", self.config.get_default_gamut_reference("screen_module")
) )
# 创建屏模组变量 # 创建屏模组变量
@@ -66,7 +61,7 @@ def create_cct_params_frame(self):
entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3) entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3)
# 绑定失去焦点事件 # 绑定失去焦点事件
default_val = self.DEFAULT_CCT_PARAMS[key] default_val = screen_default_cct_params[key]
entry.bind( entry.bind(
"<FocusOut>", "<FocusOut>",
lambda e, v=var, d=default_val: self.on_cct_param_focus_out(v, d), lambda e, v=var, d=default_val: self.on_cct_param_focus_out(v, d),
@@ -89,7 +84,7 @@ def create_cct_params_frame(self):
) )
self.screen_gamut_combo = screen_gamut_combo self.screen_gamut_combo = screen_gamut_combo
# ==================== 单步调试按钮(右侧第二行)==================== # ==================== 单步调试按钮(右侧第二行)====================
ttk.Label(self.cct_params_frame, text="单步调试:").grid( ttk.Label(self.cct_params_frame, text="单步调试:").grid(
row=1, column=2, sticky=tk.W, padx=(20, 5), pady=3 row=1, column=2, sticky=tk.W, padx=(20, 5), pady=3
) )
@@ -142,21 +137,16 @@ def create_cct_params_frame(self):
) )
# SDR 默认值 # SDR 默认值
self.SDR_DEFAULT_CCT_PARAMS = { sdr_default_cct_params = self.config.get_default_cct_params("sdr_movie")
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.3290,
"y_tolerance": 0.003,
}
# 从配置读取 SDR 参数 # 从配置读取 SDR 参数
sdr_saved_params = self.config.current_test_types.get("sdr_movie", {}).get( sdr_saved_params = self.config.current_test_types.get("sdr_movie", {}).get(
"cct_params", self.SDR_DEFAULT_CCT_PARAMS.copy() "cct_params", sdr_default_cct_params.copy()
) )
# 色域参考标准 # 色域参考标准
sdr_saved_gamut_ref = self.config.current_test_types.get("sdr_movie", {}).get( sdr_saved_gamut_ref = self.config.current_test_types.get("sdr_movie", {}).get(
"gamut_reference", "BT.709" "gamut_reference", self.config.get_default_gamut_reference("sdr_movie")
) )
# 创建 SDR 变量 # 创建 SDR 变量
@@ -190,7 +180,7 @@ def create_cct_params_frame(self):
entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3) entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3)
# 绑定失去焦点事件 # 绑定失去焦点事件
default_val = self.SDR_DEFAULT_CCT_PARAMS[key] default_val = sdr_default_cct_params[key]
entry.bind( entry.bind(
"<FocusOut>", "<FocusOut>",
lambda e, v=var, d=default_val: self.on_sdr_cct_param_focus_out(v, d), lambda e, v=var, d=default_val: self.on_sdr_cct_param_focus_out(v, d),
@@ -211,7 +201,7 @@ def create_cct_params_frame(self):
sdr_gamut_combo.bind("<<ComboboxSelected>>", self.on_sdr_gamut_ref_changed) sdr_gamut_combo.bind("<<ComboboxSelected>>", self.on_sdr_gamut_ref_changed)
self.sdr_gamut_combo = sdr_gamut_combo self.sdr_gamut_combo = sdr_gamut_combo
# ==================== SDR 单步调试按钮(右侧第二行)==================== # ==================== SDR 单步调试按钮(右侧第二行)====================
ttk.Label(self.sdr_cct_params_frame, text="单步调试:").grid( ttk.Label(self.sdr_cct_params_frame, text="单步调试:").grid(
row=1, column=2, sticky=tk.W, padx=(20, 5), pady=3 row=1, column=2, sticky=tk.W, padx=(20, 5), pady=3
) )
@@ -264,21 +254,16 @@ def create_cct_params_frame(self):
) )
# HDR 默认值 # HDR 默认值
self.HDR_DEFAULT_CCT_PARAMS = { hdr_default_cct_params = self.config.get_default_cct_params("hdr_movie")
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.3290,
"y_tolerance": 0.003,
}
# 从配置读取 HDR 参数 # 从配置读取 HDR 参数
hdr_saved_params = self.config.current_test_types.get("hdr_movie", {}).get( hdr_saved_params = self.config.current_test_types.get("hdr_movie", {}).get(
"cct_params", self.HDR_DEFAULT_CCT_PARAMS.copy() "cct_params", hdr_default_cct_params.copy()
) )
# 色域参考标准 # 色域参考标准
hdr_saved_gamut_ref = self.config.current_test_types.get("hdr_movie", {}).get( hdr_saved_gamut_ref = self.config.current_test_types.get("hdr_movie", {}).get(
"gamut_reference", "BT.2020" "gamut_reference", self.config.get_default_gamut_reference("hdr_movie")
) )
# 创建 HDR 变量 # 创建 HDR 变量
@@ -312,7 +297,7 @@ def create_cct_params_frame(self):
entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3) entry.grid(row=i, column=1, sticky=tk.W, padx=5, pady=3)
# 绑定失去焦点事件 # 绑定失去焦点事件
default_val = self.HDR_DEFAULT_CCT_PARAMS[key] default_val = hdr_default_cct_params[key]
entry.bind( entry.bind(
"<FocusOut>", "<FocusOut>",
lambda e, v=var, d=default_val: self.on_hdr_cct_param_focus_out(v, d), lambda e, v=var, d=default_val: self.on_hdr_cct_param_focus_out(v, d),
@@ -333,7 +318,7 @@ def create_cct_params_frame(self):
hdr_gamut_combo.bind("<<ComboboxSelected>>", self.on_hdr_gamut_ref_changed) hdr_gamut_combo.bind("<<ComboboxSelected>>", self.on_hdr_gamut_ref_changed)
self.hdr_gamut_combo = hdr_gamut_combo self.hdr_gamut_combo = hdr_gamut_combo
# ==================== HDR 单步调试按钮(右侧第二行)==================== # ==================== HDR 单步调试按钮(右侧第二行)====================
ttk.Label(self.hdr_cct_params_frame, text="单步调试:").grid( ttk.Label(self.hdr_cct_params_frame, text="单步调试:").grid(
row=1, column=2, sticky=tk.W, padx=(20, 5), pady=3 row=1, column=2, sticky=tk.W, padx=(20, 5), pady=3
) )
@@ -381,15 +366,68 @@ def create_cct_params_frame(self):
).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5) ).grid(row=5, column=0, columnspan=4, sticky=tk.W, padx=5, pady=5)
def on_sdr_cct_param_focus_out(self, var, default_value): def _get_cct_var_dict(self, test_type):
"""SDR 色度参数失去焦点时的处理""" """按测试类型返回 CCT 变量映射。"""
if test_type == "sdr_movie":
return {
"x_ideal": self.sdr_cct_x_ideal_var,
"x_tolerance": self.sdr_cct_x_tolerance_var,
"y_ideal": self.sdr_cct_y_ideal_var,
"y_tolerance": self.sdr_cct_y_tolerance_var,
}
if test_type == "hdr_movie":
return {
"x_ideal": self.hdr_cct_x_ideal_var,
"x_tolerance": self.hdr_cct_x_tolerance_var,
"y_ideal": self.hdr_cct_y_ideal_var,
"y_tolerance": self.hdr_cct_y_tolerance_var,
}
return {
"x_ideal": self.cct_x_ideal_var,
"x_tolerance": self.cct_x_tolerance_var,
"y_ideal": self.cct_y_ideal_var,
"y_tolerance": self.cct_y_tolerance_var,
}
def _parse_cct_float(self, var, default):
"""读取并解析 CCT 输入值,失败时回落默认值。"""
try: try:
value = var.get().strip() value = var.get().strip()
if value == "":
return default
return float(value)
except Exception:
return default
def _save_cct_params_for(self, test_type):
"""保存指定测试类型的 CCT 参数。"""
try:
default_params = self.config.get_default_cct_params(test_type)
var_dict = _get_cct_var_dict(self, test_type)
cct_params = {
key: _parse_cct_float(self, var_dict[key], default_params[key])
for key in default_params
}
if test_type not in self.config.current_test_types:
self.config.current_test_types[test_type] = {}
self.config.current_test_types[test_type]["cct_params"] = cct_params
self.save_pq_config()
except Exception:
pass
def _handle_cct_focus_out(self, var, default_value, save_func, label):
"""统一处理 CCT 参数失焦校验并保存。"""
try:
value = var.get().strip()
if value == "": if value == "":
var.set(str(default_value)) var.set(str(default_value))
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"✓ SDR 参数为空,恢复默认值: {default_value}") self.log_gui.log(f"{label} 参数为空,恢复默认值: {default_value}", level="success")
else: else:
try: try:
float_val = float(value) float_val = float(value)
@@ -397,124 +435,39 @@ def on_sdr_cct_param_focus_out(self, var, default_value):
var.set(str(default_value)) var.set(str(default_value))
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log( self.log_gui.log(
f"⚠️ SDR 参数超出范围,恢复默认值: {default_value}" f"{label} 参数超出范围,恢复默认值: {default_value}"
) , level="error")
except ValueError: except ValueError:
var.set(str(default_value)) var.set(str(default_value))
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"⚠️ SDR 参数无效,恢复默认值: {default_value}") self.log_gui.log(
f"{label} 参数无效,恢复默认值: {default_value}"
, level="error")
self.save_sdr_cct_params() save_func()
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"处理 SDR 参数失败: {str(e)}") self.log_gui.log(f"处理 {label} 参数失败: {str(e)}", level="error")
def on_sdr_cct_param_focus_out(self, var, default_value):
"""SDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "SDR")
def save_sdr_cct_params(self): def save_sdr_cct_params(self):
"""保存 SDR 色度参数""" """保存 SDR 色度参数"""
try: _save_cct_params_for(self, "sdr_movie")
def get_float(var, default):
try:
value = var.get().strip()
if value == "":
return default
return float(value)
except:
return default
sdr_cct_params = {
"x_ideal": get_float(
self.sdr_cct_x_ideal_var, self.SDR_DEFAULT_CCT_PARAMS["x_ideal"]
),
"x_tolerance": get_float(
self.sdr_cct_x_tolerance_var,
self.SDR_DEFAULT_CCT_PARAMS["x_tolerance"],
),
"y_ideal": get_float(
self.sdr_cct_y_ideal_var, self.SDR_DEFAULT_CCT_PARAMS["y_ideal"]
),
"y_tolerance": get_float(
self.sdr_cct_y_tolerance_var,
self.SDR_DEFAULT_CCT_PARAMS["y_tolerance"],
),
}
if "sdr_movie" not in self.config.current_test_types:
self.config.current_test_types["sdr_movie"] = {}
self.config.current_test_types["sdr_movie"]["cct_params"] = sdr_cct_params
self.save_pq_config()
except:
pass
def on_hdr_cct_param_focus_out(self, var, default_value): def on_hdr_cct_param_focus_out(self, var, default_value):
"""HDR 色度参数失去焦点时的处理""" """HDR 色度参数失去焦点时的处理"""
try: _handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "HDR")
value = var.get().strip()
if value == "":
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(f"✓ HDR 参数为空,恢复默认值: {default_value}")
else:
try:
float_val = float(value)
if float_val < 0 or float_val > 1:
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(
f"⚠️ HDR 参数超出范围,恢复默认值: {default_value}"
)
except ValueError:
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(f"⚠️ HDR 参数无效,恢复默认值: {default_value}")
self.save_hdr_cct_params()
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"处理 HDR 参数失败: {str(e)}")
def save_hdr_cct_params(self): def save_hdr_cct_params(self):
"""保存 HDR 色度参数""" """保存 HDR 色度参数"""
try: _save_cct_params_for(self, "hdr_movie")
def get_float(var, default):
try:
value = var.get().strip()
if value == "":
return default
return float(value)
except:
return default
hdr_cct_params = {
"x_ideal": get_float(
self.hdr_cct_x_ideal_var, self.HDR_DEFAULT_CCT_PARAMS["x_ideal"]
),
"x_tolerance": get_float(
self.hdr_cct_x_tolerance_var,
self.HDR_DEFAULT_CCT_PARAMS["x_tolerance"],
),
"y_ideal": get_float(
self.hdr_cct_y_ideal_var, self.HDR_DEFAULT_CCT_PARAMS["y_ideal"]
),
"y_tolerance": get_float(
self.hdr_cct_y_tolerance_var,
self.HDR_DEFAULT_CCT_PARAMS["y_tolerance"],
),
}
if "hdr_movie" not in self.config.current_test_types:
self.config.current_test_types["hdr_movie"] = {}
self.config.current_test_types["hdr_movie"]["cct_params"] = hdr_cct_params
self.save_pq_config()
except:
pass
def recalculate_cct(self): def recalculate_cct(self):
@@ -522,7 +475,7 @@ def recalculate_cct(self):
try: try:
# 1. 保存新参数 # 1. 保存新参数
self.save_cct_params() self.save_cct_params()
self.log_gui.log("色度参数已更新") self.log_gui.log("色度参数已更新", level="success")
# 2. 收起配置项 # 2. 收起配置项
if hasattr(self, "config_panel_frame"): if hasattr(self, "config_panel_frame"):
@@ -540,7 +493,7 @@ def recalculate_cct(self):
# 4. 检查是否有数据 # 4. 检查是否有数据
if not hasattr(self, "results") or not self.results: if not hasattr(self, "results") or not self.results:
self.log_gui.log("⚠️ 没有测试数据,无法重新绘制") self.log_gui.log("没有测试数据,无法重新绘制", level="error")
messagebox.showwarning("警告", "请先完成测试后再重新计算") messagebox.showwarning("警告", "请先完成测试后再重新计算")
return return
@@ -550,14 +503,14 @@ def recalculate_cct(self):
gray_data = self.results.get_intermediate_data("cct", "gray") gray_data = self.results.get_intermediate_data("cct", "gray")
if not gray_data or len(gray_data) < 2: if not gray_data or len(gray_data) < 2:
self.log_gui.log("⚠️ 没有可用的灰阶数据") self.log_gui.log("没有可用的灰阶数据", level="error")
messagebox.showwarning("警告", "没有找到色度测试数据") messagebox.showwarning("警告", "没有找到色度测试数据")
return return
# 6. 重新计算 CCT # 6. 重新计算 CCT
self.log_gui.log("=" * 50) self.log_gui.log("=" * 50, level="separator")
self.log_gui.log("开始重新计算色度一致性...") self.log_gui.log("开始重新计算色度一致性...", level="info")
self.log_gui.log("=" * 50) self.log_gui.log("=" * 50, level="separator")
cct_values = pq_algorithm.calculate_cct_from_results(gray_data) cct_values = pq_algorithm.calculate_cct_from_results(gray_data)
@@ -568,14 +521,14 @@ def recalculate_cct(self):
test_type = self.config.current_test_type test_type = self.config.current_test_type
self.plot_cct(test_type) self.plot_cct(test_type)
self.log_gui.log("色度图已重新绘制") self.log_gui.log("色度图已重新绘制", level="success")
self.log_gui.log("=" * 50) self.log_gui.log("=" * 50, level="separator")
messagebox.showinfo("成功", "色度图已根据新参数重新绘制!") messagebox.showinfo("成功", "色度图已根据新参数重新绘制!")
except Exception as e: except Exception as e:
self.log_gui.log(f"重新计算失败: {str(e)}") self.log_gui.log(f"重新计算失败: {str(e)}", level="error")
self.log_gui.log(traceback.format_exc()) self.log_gui.log(traceback.format_exc(), level="error")
messagebox.showerror("错误", f"重新计算失败: {str(e)}") messagebox.showerror("错误", f"重新计算失败: {str(e)}")
@@ -598,7 +551,7 @@ def recalculate_gamut(self):
# 3. 检查是否有数据 # 3. 检查是否有数据
if not hasattr(self, "results") or not self.results: if not hasattr(self, "results") or not self.results:
self.log_gui.log("⚠️ 没有测试数据,无法重新绘制") self.log_gui.log("没有测试数据,无法重新绘制", level="error")
messagebox.showwarning("警告", "请先完成测试后再重新计算") messagebox.showwarning("警告", "请先完成测试后再重新计算")
return return
@@ -606,7 +559,7 @@ def recalculate_gamut(self):
rgb_data = self.results.get_intermediate_data("gamut", "rgb") rgb_data = self.results.get_intermediate_data("gamut", "rgb")
if not rgb_data or len(rgb_data) < 3: if not rgb_data or len(rgb_data) < 3:
self.log_gui.log("⚠️ 没有可用的色域数据") self.log_gui.log("没有可用的色域数据", level="error")
messagebox.showwarning("警告", "没有找到色域测试数据") messagebox.showwarning("警告", "没有找到色域测试数据")
return return
@@ -623,13 +576,14 @@ def recalculate_gamut(self):
else: else:
reference_standard = "DCI-P3" reference_standard = "DCI-P3"
self.log_gui.log("=" * 50) self.log_gui.log("=" * 50, level="separator")
self.log_gui.log(f"开始重新计算色域(参考标准: {reference_standard}...") self.log_gui.log(f"开始重新计算色域(参考标准: {reference_standard}...", level="info")
self.log_gui.log("=" * 50) self.log_gui.log("=" * 50, level="separator")
# 7. 重新计算 XY 色域覆盖率 # 7. 重新计算 XY 色域覆盖率
xy_points = [[result[0], result[1]] for result in rgb_data] xy_points = [[result[0], result[1]] for result in rgb_data]
# 根据参考标准计算 XY 覆盖率
if reference_standard == "BT.2020": if reference_standard == "BT.2020":
area_xy, coverage_xy = pq_algorithm.calculate_gamut_coverage_BT2020( area_xy, coverage_xy = pq_algorithm.calculate_gamut_coverage_BT2020(
xy_points xy_points
@@ -648,13 +602,15 @@ def recalculate_gamut(self):
) )
reference_standard = "DCI-P3" reference_standard = "DCI-P3"
self.log_gui.log(f"参考标准: {reference_standard}") self.log_gui.log(f"参考标准: {reference_standard}", level="success")
self.log_gui.log(f"XY 色域覆盖率: {coverage_xy:.1f}%") self.log_gui.log(f"XY 色域覆盖率: {coverage_xy:.1f}%", level="success")
# 8. 重新计算 UV 色域覆盖率 # ========== ✅✅8. 重新计算 UV 色域覆盖率 ==========
# 将 XY 坐标转换为 UV 坐标
uv_points = [] uv_points = []
for x, y in xy_points: for x, y in xy_points:
try: try:
# XY转UV公式
denom = -2 * x + 12 * y + 3 denom = -2 * x + 12 * y + 3
if abs(denom) < 1e-10: if abs(denom) < 1e-10:
u, v = 0, 0 u, v = 0, 0
@@ -665,8 +621,9 @@ def recalculate_gamut(self):
except ZeroDivisionError: except ZeroDivisionError:
continue continue
self.log_gui.log(f"转换后的 UV 点数量: {len(uv_points)}") self.log_gui.log(f"转换后的 UV 点数量: {len(uv_points)}", level="success")
# 根据参考标准计算 UV 覆盖率
if reference_standard == "BT.2020": if reference_standard == "BT.2020":
area_uv, coverage_uv = pq_algorithm.calculate_gamut_coverage_BT2020_uv( area_uv, coverage_uv = pq_algorithm.calculate_gamut_coverage_BT2020_uv(
uv_points uv_points
@@ -684,30 +641,31 @@ def recalculate_gamut(self):
uv_points uv_points
) )
self.log_gui.log(f"UV 色域覆盖率: {coverage_uv:.1f}%") self.log_gui.log(f"UV 色域覆盖率: {coverage_uv:.1f}%", level="success")
# ========================================================
# 9. 更新结果 # 9. 更新结果(同时保存 XY 和 UV 覆盖率)
self.results.set_test_item_result( self.results.set_test_item_result(
"gamut", "gamut",
{ {
"area": area_xy, "area": area_xy, # ← 兼容旧字段
"coverage": coverage_xy, "coverage": coverage_xy, # ← 兼容旧字段
"area_xy": area_xy, "area_xy": area_xy, # ← XY 面积
"coverage_xy": coverage_xy, "coverage_xy": coverage_xy, # ← XY 覆盖率
"area_uv": area_uv, "area_uv": area_uv, # ← UV 面积
"coverage_uv": coverage_uv, "coverage_uv": coverage_uv, # ← UV 覆盖率
"uv_coverage": coverage_uv, "uv_coverage": coverage_uv, # ← 兼容字段Excel 导出用)
"reference": reference_standard, "reference": reference_standard,
}, },
) )
self.log_gui.log("测试结果已更新到 results 对象") self.log_gui.log("测试结果已更新到 results 对象", level="success")
# 10. 重新绘制色域图 # 10. 重新绘制色域图
self.plot_gamut(rgb_data, coverage_xy, test_type) self.plot_gamut(rgb_data, coverage_xy, test_type)
self.log_gui.log("色域图已重新绘制") self.log_gui.log("色域图已重新绘制", level="success")
self.log_gui.log("=" * 50) self.log_gui.log("=" * 50, level="separator")
messagebox.showinfo( messagebox.showinfo(
"成功", "成功",
@@ -717,108 +675,19 @@ def recalculate_gamut(self):
) )
except Exception as e: except Exception as e:
self.log_gui.log(f"重新计算失败: {str(e)}") self.log_gui.log(f"重新计算失败: {str(e)}", level="error")
self.log_gui.log(traceback.format_exc()) self.log_gui.log(traceback.format_exc(), level="error")
messagebox.showerror("错误", f"重新计算失败: {str(e)}") messagebox.showerror("错误", f"重新计算失败: {str(e)}")
def on_cct_param_change(self, var, default_value):
"""色度参数改变时的处理 - 空值恢复默认"""
try:
value = var.get().strip()
if value == "":
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(f"输入框为空,恢复默认值: {default_value}")
else:
try:
float_val = float(value)
if float_val < 0 or float_val > 1:
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(
f"参数超出范围 [0, 1],恢复默认值: {default_value}"
)
except ValueError:
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(f"无效的参数值,恢复默认值: {default_value}")
self.save_cct_params()
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"处理参数变化失败: {str(e)}")
def on_cct_param_focus_out(self, var, default_value): def on_cct_param_focus_out(self, var, default_value):
"""色度参数失去焦点时的处理 - 空值恢复默认""" """色度参数失去焦点时的处理 - 空值恢复默认"""
try: _handle_cct_focus_out(self, var, default_value, self.save_cct_params, "屏模组")
value = var.get().strip()
if value == "":
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(f"✓ 输入框为空,恢复默认值: {default_value}")
else:
try:
float_val = float(value)
if float_val < 0 or float_val > 1:
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(
f"⚠️ 参数超出范围 [0, 1],恢复默认值: {default_value}"
)
except ValueError:
var.set(str(default_value))
if hasattr(self, "log_gui"):
self.log_gui.log(f"⚠️ 无效的参数值,恢复默认值: {default_value}")
self.save_cct_params()
except Exception as e:
if hasattr(self, "log_gui"):
self.log_gui.log(f"处理参数变化失败: {str(e)}")
def save_cct_params(self): def save_cct_params(self):
"""保存色度参数 - 简化版""" """保存色度参数 - 简化版"""
try: _save_cct_params_for(self, self.config.current_test_type)
current_type = self.config.current_test_type
def get_float(var, default):
try:
value = var.get().strip()
if value == "":
return default
return float(value)
except:
return default
cct_params = {
"x_ideal": get_float(
self.cct_x_ideal_var, self.DEFAULT_CCT_PARAMS["x_ideal"]
),
"x_tolerance": get_float(
self.cct_x_tolerance_var, self.DEFAULT_CCT_PARAMS["x_tolerance"]
),
"y_ideal": get_float(
self.cct_y_ideal_var, self.DEFAULT_CCT_PARAMS["y_ideal"]
),
"y_tolerance": get_float(
self.cct_y_tolerance_var, self.DEFAULT_CCT_PARAMS["y_tolerance"]
),
}
if current_type not in self.config.current_test_types:
self.config.current_test_types[current_type] = {}
self.config.current_test_types[current_type]["cct_params"] = cct_params
self.save_pq_config()
except:
pass
def reload_cct_params(self): def reload_cct_params(self):
@@ -830,7 +699,7 @@ def reload_cct_params(self):
) )
if saved_params is None: if saved_params is None:
saved_params = self.DEFAULT_CCT_PARAMS.copy() saved_params = self.config.get_default_cct_params(current_type)
# 更新输入框的值 # 更新输入框的值
self.cct_x_ideal_var.set(str(saved_params["x_ideal"])) self.cct_x_ideal_var.set(str(saved_params["x_ideal"]))
@@ -840,4 +709,72 @@ def reload_cct_params(self):
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"重新加载色度参数失败: {str(e)}") self.log_gui.log(f"重新加载色度参数失败: {str(e)}", level="error")
def toggle_cct_params_frame(self):
"""根据测试类型和测试项的选中状态显示对应参数框"""
selected_items = self.get_selected_test_items()
current_test_type = self.config.current_test_type
# ========== 默认隐藏所有参数框 ==========
self.cct_params_frame.pack_forget()
self.sdr_cct_params_frame.pack_forget()
# HDR 色度参数框(如果存在的话)
if hasattr(self, "hdr_cct_params_frame"):
self.hdr_cct_params_frame.pack_forget()
# ========== 根据测试类型和选中项显示对应参数框 ==========
if current_test_type == "screen_module":
# 屏模组:只有色度参数
if "cct" in selected_items:
self.cct_params_frame.pack(fill=tk.X, padx=5, pady=5)
elif current_test_type == "sdr_movie":
# SDR只有色度参数色准不需要参数设置框
if "cct" in selected_items:
self.sdr_cct_params_frame.pack(fill=tk.X, padx=5, pady=5)
elif current_test_type == "hdr_movie":
# HDR只有色度参数色准不需要参数设置框
if "cct" in selected_items:
if hasattr(self, "hdr_cct_params_frame"):
self.hdr_cct_params_frame.pack(fill=tk.X, padx=5, pady=5)
else:
if hasattr(self, "log_gui"):
self.log_gui.log("HDR 色度参数框尚未创建", level="error")
# ---- gamut 参考标准改变回调(统一实现) ----
_GAMUT_REF_CONFIGS = {
"screen_module": {"var_attr": "screen_gamut_ref_var", "label": "屏模组"},
"sdr_movie": {"var_attr": "sdr_gamut_ref_var", "label": "SDR"},
"hdr_movie": {"var_attr": "hdr_gamut_ref_var", "label": "HDR"},
}
def _on_gamut_ref_changed(self, test_type, event=None):
cfg = _GAMUT_REF_CONFIGS[test_type]
try:
new_ref = getattr(self, cfg["var_attr"]).get()
self.log_gui.log(f"{cfg['label']} 色域参考标准已更改为: {new_ref}", level="success")
if test_type not in self.config.current_test_types:
self.config.current_test_types[test_type] = {}
self.config.current_test_types[test_type]["gamut_reference"] = new_ref
self.save_pq_config()
except Exception as e:
self.log_gui.log(f"保存 {cfg['label']} 色域参考标准失败: {str(e)}", level="error")
def on_screen_gamut_ref_changed(self, event=None):
_on_gamut_ref_changed(self, "screen_module", event)
def on_sdr_gamut_ref_changed(self, event=None):
_on_gamut_ref_changed(self, "sdr_movie", event)
def on_hdr_gamut_ref_changed(self, event=None):
_on_gamut_ref_changed(self, "hdr_movie", event)

View File

@@ -1,4 +1,4 @@
"""自定义模板结果面板Step 6 重构)。""" """自定义模板结果面板Step 6 重构)。"""
import threading import threading
import time import time
@@ -232,11 +232,11 @@ def start_custom_row_single_step(self):
children = list(self.custom_result_tree.get_children()) children = list(self.custom_result_tree.get_children())
row_no = children.index(item_id) + 1 if item_id in children else 1 row_no = children.index(item_id) + 1 if item_id in children else 1
self._clear_custom_result_row(item_id, row_no) _clear_custom_result_row(self, item_id, row_no)
threading.Thread( threading.Thread(
target=self._run_custom_row_single_step, target=_run_custom_row_single_step,
args=(item_id, row_no), args=(self, item_id, row_no),
daemon=True, daemon=True,
).start() ).start()
@@ -274,7 +274,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
"""后台执行客户模板单步测试""" """后台执行客户模板单步测试"""
try: try:
self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...") self._dispatch_ui(self.status_var.set, f"单步测试第 {row_no} 行...")
self.log_gui.log(f"开始单步测试第 {row_no}") self.log_gui.log(f"开始单步测试第 {row_no}", level="info")
self.config.set_current_pattern("custom") self.config.set_current_pattern("custom")
@@ -295,7 +295,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
) )
if row_no > len(converted_params): if row_no > len(converted_params):
self.log_gui.log(f"行号超出 pattern 范围: {row_no}/{len(converted_params)}") self.log_gui.log(f"行号超出 pattern 范围: {row_no}/{len(converted_params)}", level="error")
self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围") self._dispatch_ui(self.status_var.set, "单步测试失败:行号超范围")
return return
@@ -332,14 +332,14 @@ def _run_custom_row_single_step(self, item_id, row_no):
} }
self._dispatch_ui( self._dispatch_ui(
self._update_custom_result_row, item_id, row_no, row_data _update_custom_result_row, self, item_id, row_no, row_data
) )
self.log_gui.log(f"{row_no} 行单步测试完成并已覆盖") self.log_gui.log(f"{row_no} 行单步测试完成并已覆盖", level="success")
self._dispatch_ui(self.status_var.set, f"{row_no} 行单步测试完成") self._dispatch_ui(self.status_var.set, f"{row_no} 行单步测试完成")
except Exception as e: except Exception as e:
self.log_gui.log(f"单步测试失败: {str(e)}") self.log_gui.log(f"单步测试失败: {str(e)}", level="error")
self._dispatch_ui(self.status_var.set, "单步测试失败") self._dispatch_ui(self.status_var.set, "单步测试失败")
@@ -423,50 +423,7 @@ def copy_custom_result_table(self):
if hasattr(self, "status_var"): if hasattr(self, "status_var"):
self.status_var.set(f"已复制 {len(items)} 行客户模板数据到剪贴板") self.status_var.set(f"已复制 {len(items)} 行客户模板数据到剪贴板")
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)") self.log_gui.log(f"已复制客户模板表格数据({len(items)} 行)", level="success")
def fill_custom_result_test_data(self):
"""填充 147 行客户模板测试数据(用于界面验证)"""
if not hasattr(self, "custom_result_tree"):
return
self.clear_custom_template_results()
pattern_names = []
if hasattr(self, "config") and hasattr(self.config, "get_temp_pattern_names"):
pattern_names = self.config.get_temp_pattern_names()
total_rows = 147
for i in range(1, total_rows + 1):
ratio = (i - 1) / (total_rows - 1) if total_rows > 1 else 0
row_data = {
"pattern_name": (
pattern_names[i - 1] if i - 1 < len(pattern_names) else f"P {i}"
),
"X": 0.8 + ratio * 120,
"Y": 0.9 + ratio * 135,
"Z": 1.1 + ratio * 145,
"x": 0.24 + ratio * 0.10,
"y": 0.26 + ratio * 0.10,
"Lv": 1.0 + ratio * 500,
"u_prime": 0.16 + ratio * 0.12,
"v_prime": 0.42 + ratio * 0.08,
"Tcp": 1800 + ratio * 12000,
"duv": -0.01 + ratio * 0.03,
"lambda_d": 430 + ratio * 200,
"Pe": 10 + ratio * 90,
}
self.append_custom_template_result(i, row_data)
if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
self.chart_notebook.select(self.custom_template_tab_frame)
if hasattr(self, "status_var"):
self.status_var.set("已填充 147 行客户模板测试数据")
if hasattr(self, "log_gui"):
self.log_gui.log("✓ 已填充 147 行客户模板测试数据")
def clear_custom_template_results(self): def clear_custom_template_results(self):
"""清空客户模板结果表格""" """清空客户模板结果表格"""
@@ -511,7 +468,7 @@ def auto_expand_custom_result_view(self):
self.root.update_idletasks() self.root.update_idletasks()
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"⚠️ 自动扩展客户模板窗口失败: {str(e)}") self.log_gui.log(f"自动扩展客户模板窗口失败: {str(e)}", level="error")
def append_custom_template_result(self, row_no, result_data): def append_custom_template_result(self, row_no, result_data):
@@ -605,4 +562,55 @@ def start_custom_template_test(self):
self.test_thread.daemon = True self.test_thread.daemon = True
self.test_thread.start() self.test_thread.start()
def update_custom_button_visibility(self):
"""只在 SDR 测试时显示客户模版按钮"""
if not hasattr(self, "custom_btn") or not hasattr(self, "test_type_var"):
return
if self.test_type_var.get() == "sdr_movie":
if not self.custom_btn.winfo_manager():
self.custom_btn.pack(side=tk.LEFT, padx=5)
else:
if self.custom_btn.winfo_manager():
self.custom_btn.pack_forget()
# def fill_custom_result_test_data(self):
# """填充 147 行客户模板测试数据(用于界面验证)"""
# if not hasattr(self, "custom_result_tree"):
# return
# self.clear_custom_template_results()
# pattern_names = []
# if hasattr(self, "config") and hasattr(self.config, "get_temp_pattern_names"):
# pattern_names = self.config.get_temp_pattern_names()
# total_rows = 147
# for i in range(1, total_rows + 1):
# ratio = (i - 1) / (total_rows - 1) if total_rows > 1 else 0
# row_data = {
# "pattern_name": (
# pattern_names[i - 1] if i - 1 < len(pattern_names) else f"P {i}"
# ),
# "X": 0.8 + ratio * 120,
# "Y": 0.9 + ratio * 135,
# "Z": 1.1 + ratio * 145,
# "x": 0.24 + ratio * 0.10,
# "y": 0.26 + ratio * 0.10,
# "Lv": 1.0 + ratio * 500,
# "u_prime": 0.16 + ratio * 0.12,
# "v_prime": 0.42 + ratio * 0.08,
# "Tcp": 1800 + ratio * 12000,
# "duv": -0.01 + ratio * 0.03,
# "lambda_d": 430 + ratio * 200,
# "Pe": 10 + ratio * 90,
# }
# self.append_custom_template_result(i, row_data)
# if hasattr(self, "chart_notebook") and hasattr(self, "custom_template_tab_frame"):
# self.chart_notebook.select(self.custom_template_tab_frame)
# if hasattr(self, "status_var"):
# self.status_var.set("已填充 147 行客户模板测试数据")
# if hasattr(self, "log_gui"):
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success")

View File

@@ -1,5 +1,6 @@
"""主布局面板创建函数Step 6 重构)。""" """主布局面板创建函数Step 6 重构)。"""
import re
import tkinter as tk import tkinter as tk
import ttkbootstrap as ttk import ttkbootstrap as ttk
@@ -404,7 +405,7 @@ def create_test_type_frame(self):
fill=tk.X, padx=10, pady=10 fill=tk.X, padx=10, pady=10
) )
# 只保留日志按钮 # 只保留日志按钮
self.log_btn = ttk.Button( self.log_btn = ttk.Button(
self.sidebar_frame, self.sidebar_frame,
text="测试日志", text="测试日志",
@@ -424,12 +425,24 @@ def create_test_type_frame(self):
) )
self.local_dimming_btn.pack(fill=tk.X, padx=0, pady=1) self.local_dimming_btn.pack(fill=tk.X, padx=0, pady=1)
# 注册面板按钮(只保留日志) # AI 图片对话按钮
self.ai_image_btn = ttk.Button(
self.sidebar_frame,
text="AI 图片",
style="Sidebar.TButton",
command=self.toggle_ai_image_panel,
takefocus=False,
)
self.ai_image_btn.pack(fill=tk.X, padx=0, pady=1)
# 注册面板按钮
if hasattr(self, "panels"): if hasattr(self, "panels"):
if "log" in self.panels: if "log" in self.panels:
self.panels["log"]["button"] = self.log_btn self.panels["log"]["button"] = self.log_btn
if "local_dimming" in self.panels: if "local_dimming" in self.panels:
self.panels["local_dimming"]["button"] = self.local_dimming_btn self.panels["local_dimming"]["button"] = self.local_dimming_btn
if "ai_image" in self.panels:
self.panels["ai_image"]["button"] = self.ai_image_btn
def update_config_info_display(self): def update_config_info_display(self):
@@ -496,3 +509,97 @@ def create_operation_frame(self):
self.update_custom_button_visibility() self.update_custom_button_visibility()
def on_screen_module_timing_changed(self, event=None):
"""屏模组信号格式改变时的回调"""
try:
selected_timing = self.screen_module_timing_var.get()
# 记录日志
self.log_gui.log(f"屏模组信号格式已更改为: {selected_timing}", level="info")
match = re.search(r"(\d+)x(\d+)\s*@\s*(\d+)", selected_timing)
if match:
width = int(match.group(1))
height = int(match.group(2))
refresh_rate = int(match.group(3))
self.log_gui.log(f" ├─ 分辨率: {width}x{height}", level="info")
self.log_gui.log(f" └─ 刷新率: {refresh_rate}Hz", level="info")
# 根据分辨率给出提示
if width >= 3840: # 4K及以上
self.log_gui.log(" 检测到4K分辨率", level="info")
if refresh_rate >= 120:
self.log_gui.log(" 检测到高刷新率", level="info")
# 更新配置
self.config.set_current_timing(selected_timing)
# 如果正在测试,提示用户
if self.testing:
self.log_gui.log("警告: 测试进行中,信号格式更改将在下次测试时生效", level="error")
# 保存配置
self.save_pq_config()
except Exception as e:
self.log_gui.log(f"屏模组信号格式更改失败: {str(e)}", level="error")
def update_test_items(self):
"""根据当前测试类型更新测试项目复选框"""
# 先隐藏所有测试项目框架
for config in self.test_items.values():
config["frame"].pack_forget()
current_test_type = self.config.current_test_type
self.test_vars = {}
if current_test_type in self.test_items:
config = self.test_items[current_test_type]
frame = config["frame"]
frame.pack(fill=tk.X, padx=5, pady=5)
# 添加测试类型标签
type_label = ttk.Label(
frame,
text=self.get_test_type_name(current_test_type),
style="primary.TLabel",
)
type_label.grid(row=0, column=0, columnspan=2, sticky=tk.W, padx=5, pady=3)
# 从配置中读取保存的选择状态
saved_test_items = self.config.current_test_types[current_test_type].get(
"test_items", []
)
# 添加复选框
for i, (text, var_name) in enumerate(config["items"]):
is_checked = var_name in saved_test_items
var = tk.BooleanVar(value=is_checked)
self.test_vars[f"{current_test_type}_{var_name}"] = var
ttk.Checkbutton(
frame,
text=text,
variable=var,
bootstyle="round-toggle",
command=self.update_config_and_tabs,
).grid(row=i // 2 + 1, column=i % 2, sticky=tk.W, padx=10, pady=5)
if hasattr(self, "chart_notebook"):
self.update_chart_tabs_state()
if hasattr(self, "cct_params_frame"):
self.toggle_cct_params_frame()
def on_test_type_change(self):
"""根据测试类型更新内容区域"""
# 更新配置信息显示
if hasattr(self, "config") and hasattr(self.config, "get_current_config"):
self.update_config_info_display()
# SDR 选中时显示客户模版按钮
self.update_custom_button_visibility()

View File

@@ -1,4 +1,4 @@
"""侧边面板(日志 / Local Dimming / 调试)Step 6 重构)。""" """侧边面板(日志 / Local Dimming / 调试)"""
import traceback import traceback
import tkinter as tk import tkinter as tk
@@ -141,7 +141,7 @@ def create_local_dimming_panel(self):
self.ld_clear_btn = ttk.Button( self.ld_clear_btn = ttk.Button(
bottom_frame, bottom_frame,
text="🗑️ 清空记录", text="清空记录",
command=self.clear_ld_records, command=self.clear_ld_records,
bootstyle="danger-outline", bootstyle="danger-outline",
width=12, width=12,
@@ -182,231 +182,137 @@ def toggle_log_panel(self):
self.show_panel("log") self.show_panel("log")
def toggle_screen_debug_panel(self): # ---- 单步调试面板(统一实现) ----
"""打开/关闭屏模组单步调试面板(独立窗口)""" DEBUG_PANEL_CONFIGS = {
"screen_module": {
"window_attr": "debug_window",
"btn_attr": "screen_debug_btn",
"title": " 单步调试面板",
"window_log_prefix": "",
"data_log_prefix": "屏模组",
"failure_data_label": "调试数据",
# (item_key, debug_key, (category, subkey), data_label, enable_desc)
"data_items": [
("gamma", "gamma", ("shared", "gray"), "灰阶", "Gamma 单步调试"),
("gamut", "rgb", ("gamut", "rgb"), "RGB", "RGB 单步调试"),
],
},
"sdr_movie": {
"window_attr": "sdr_debug_window",
"btn_attr": "sdr_debug_btn",
"title": " SDR 单步调试面板",
"window_log_prefix": "SDR ",
"data_log_prefix": "SDR",
"failure_data_label": "SDR 调试数据",
"data_items": [
("gamma", "gamma", ("shared", "gray"), "灰阶", "Gamma 单步调试"),
("accuracy", "accuracy", ("accuracy", "measured"), "色准", "色准单步调试"),
("gamut", "rgb", ("gamut", "rgb"), "RGB", "RGB 单步调试"),
],
},
"hdr_movie": {
"window_attr": "hdr_debug_window",
"btn_attr": "hdr_debug_btn",
"title": " HDR 单步调试面板",
"window_log_prefix": "HDR ",
"data_log_prefix": "HDR",
"failure_data_label": "HDR 调试数据",
"data_items": [
("eotf", "eotf", ("shared", "gray"), "灰阶", "EOTF 单步调试"),
("accuracy", "accuracy", ("accuracy", "measured"), "色准", "色准单步调试"),
("gamut", "rgb", ("gamut", "rgb"), "RGB", "RGB 单步调试"),
],
},
}
def _toggle_debug_panel(self, test_type):
"""打开/关闭对应测试类型的单步调试面板(独立窗口)。"""
cfg = DEBUG_PANEL_CONFIGS[test_type]
win_attr = cfg["window_attr"]
btn = getattr(self, cfg["btn_attr"])
wlp = cfg["window_log_prefix"]
# 如果窗口已存在且可见,关闭它 # 如果窗口已存在且可见,关闭它
if hasattr(self, "debug_window") and self.debug_window.winfo_exists(): existing = getattr(self, win_attr, None)
self.debug_window.destroy() if existing is not None and existing.winfo_exists():
self.screen_debug_btn.config(text="打开调试面板") existing.destroy()
self.log_gui.log("✓ 单步调试面板已关闭") btn.config(text="打开调试面板")
return return
# 创建新窗口 # 创建新窗口
self.debug_window = ttk.Toplevel(self.root) win = ttk.Toplevel(self.root)
self.debug_window.title("🔧 单步调试面板") win.title(cfg["title"])
self.debug_window.geometry("900x400") win.geometry("900x400")
self.debug_window.transient(self.root) win.transient(self.root)
setattr(self, win_attr, win)
# 创建调试面板容器 # 调试面板容器
debug_container = ttk.Frame(self.debug_window, padding=10) debug_container = ttk.Frame(win, padding=10)
debug_container.pack(fill=tk.BOTH, expand=True) # ← 这个 pack 是对的 debug_container.pack(fill=tk.BOTH, expand=True)
# 创建调试面板实例
from app.views.pq_debug_panel import PQDebugPanel
# 创建调试面板实例(不要对它调用 pack
debug_panel_instance = PQDebugPanel(debug_container, self) debug_panel_instance = PQDebugPanel(debug_container, self)
# ← 这里不应该有任何 pack 调用!
self.log_gui.log("✓ 单步调试面板实例已创建")
# 重新启用调试(如果有数据) # 重新启用调试(如果有数据)
try: try:
test_type = self.config.current_test_type
selected_items = self.get_selected_test_items() selected_items = self.get_selected_test_items()
dlp = cfg["data_log_prefix"]
if test_type == "screen_module": # 显式按 test_type 拿历史结果,避免依赖"当前活跃"状态
if "gamma" in selected_items: results_store = getattr(self, "results", None)
gray_data = self.results.get_intermediate_data("shared", "gray") results_obj = results_store.get(test_type) if results_store is not None else None
if gray_data: if results_obj is None:
self.log_gui.log(f" → 加载 {len(gray_data)} 个灰阶数据点") self.log_gui.log(f"{dlp} 暂无 {test_type} 的测试结果,面板已打开", level="warning")
debug_panel_instance.enable_debug(
"screen_module", "gamma", gray_data
)
self.log_gui.log("✓ 屏模组 Gamma 单步调试已重新启用")
else: else:
self.log_gui.log(" ✗ 没有可用的灰阶数据") for item_key, debug_key, (cat, sub), data_label, enable_desc in cfg["data_items"]:
if item_key not in selected_items:
if "gamut" in selected_items: continue
rgb_data = self.results.get_intermediate_data("gamut", "rgb") data = results_obj.get_intermediate_data(cat, sub)
if rgb_data: if not data:
self.log_gui.log(f" → 加载 {len(rgb_data)} 个RGB数据点") if test_type == "screen_module" and item_key == "gamma":
debug_panel_instance.enable_debug( self.log_gui.log("没有可用的灰阶数据", level="error")
"screen_module", "rgb", rgb_data continue
) self.log_gui.log(f" → 加载 {len(data)}{data_label}数据点", level="info")
self.log_gui.log("✓ 屏模组 RGB 单步调试已重新启用") debug_panel_instance.enable_debug(test_type, debug_key, data)
except Exception as e: except Exception as e:
self.log_gui.log(f"⚠️ 加载调试数据失败: {str(e)}") self.log_gui.log(f"加载{cfg['failure_data_label']}失败: {str(e)}", level="error")
import traceback self.log_gui.log(traceback.format_exc(), level="error")
self.log_gui.log(traceback.format_exc()) btn.config(text="关闭调试面板")
# 更新按钮文字
self.screen_debug_btn.config(text="关闭调试面板")
# 窗口关闭时的回调
def on_closing(): def on_closing():
self.screen_debug_btn.config(text="打开调试面板") btn.config(text="打开调试面板")
self.debug_window.destroy() getattr(self, win_attr).destroy()
self.log_gui.log("✓ 单步调试窗口已关闭")
self.debug_window.protocol("WM_DELETE_WINDOW", on_closing) win.protocol("WM_DELETE_WINDOW", on_closing)
self.debug_window.update_idletasks() win.update_idletasks()
self.log_gui.log("✓ 单步调试面板已打开(独立窗口)")
def toggle_screen_debug_panel(self):
_toggle_debug_panel(self, "screen_module")
def toggle_sdr_debug_panel(self): def toggle_sdr_debug_panel(self):
"""打开/关闭 SDR 单步调试面板(独立窗口)""" _toggle_debug_panel(self, "sdr_movie")
# 如果窗口已存在且可见,关闭它
if hasattr(self, "sdr_debug_window") and self.sdr_debug_window.winfo_exists():
self.sdr_debug_window.destroy()
self.sdr_debug_btn.config(text="打开调试面板")
self.log_gui.log("✓ SDR 单步调试面板已关闭")
return
# 创建新窗口
self.sdr_debug_window = ttk.Toplevel(self.root)
self.sdr_debug_window.title("🔧 SDR 单步调试面板")
self.sdr_debug_window.geometry("900x400")
self.sdr_debug_window.transient(self.root)
# 创建调试面板容器
debug_container = ttk.Frame(self.sdr_debug_window, padding=10)
debug_container.pack(fill=tk.BOTH, expand=True)
# ✅ 创建调试面板实例(不要对它调用 pack
from app.views.pq_debug_panel import PQDebugPanel
debug_panel_instance = PQDebugPanel(debug_container, self)
# ← 删除debug_panel_instance.pack(...)
self.log_gui.log("✓ SDR 单步调试面板实例已创建")
# ✅ 重新启用调试(如果有数据)
try:
selected_items = self.get_selected_test_items()
if "gamma" in selected_items:
gray_data = self.results.get_intermediate_data("shared", "gray")
if gray_data:
self.log_gui.log(f" → 加载 {len(gray_data)} 个灰阶数据点")
debug_panel_instance.enable_debug("sdr_movie", "gamma", gray_data)
self.log_gui.log("✓ SDR Gamma 单步调试已重新启用")
if "accuracy" in selected_items:
accuracy_data = self.results.get_intermediate_data(
"accuracy", "measured"
)
if accuracy_data:
self.log_gui.log(f" → 加载 {len(accuracy_data)} 个色准数据点")
debug_panel_instance.enable_debug(
"sdr_movie", "accuracy", accuracy_data
)
self.log_gui.log("✓ SDR 色准单步调试已重新启用")
if "gamut" in selected_items:
rgb_data = self.results.get_intermediate_data("gamut", "rgb")
if rgb_data:
self.log_gui.log(f" → 加载 {len(rgb_data)} 个RGB数据点")
debug_panel_instance.enable_debug("sdr_movie", "rgb", rgb_data)
self.log_gui.log("✓ SDR RGB 单步调试已重新启用")
except Exception as e:
self.log_gui.log(f"⚠️ 加载 SDR 调试数据失败: {str(e)}")
import traceback
self.log_gui.log(traceback.format_exc())
# 更新按钮文字
self.sdr_debug_btn.config(text="关闭调试面板")
# 窗口关闭时的回调
def on_closing():
self.sdr_debug_btn.config(text="打开调试面板")
self.sdr_debug_window.destroy()
self.log_gui.log("✓ SDR 单步调试窗口已关闭")
self.sdr_debug_window.protocol("WM_DELETE_WINDOW", on_closing)
self.sdr_debug_window.update_idletasks()
self.log_gui.log("✓ SDR 单步调试面板已打开(独立窗口)")
def toggle_hdr_debug_panel(self): def toggle_hdr_debug_panel(self):
"""打开/关闭 HDR 单步调试面板(独立窗口)""" _toggle_debug_panel(self, "hdr_movie")
# 如果窗口已存在且可见,关闭它
if hasattr(self, "hdr_debug_window") and self.hdr_debug_window.winfo_exists():
self.hdr_debug_window.destroy()
self.hdr_debug_btn.config(text="打开调试面板")
self.log_gui.log("✓ HDR 单步调试面板已关闭")
return
# 创建新窗口
self.hdr_debug_window = ttk.Toplevel(self.root)
self.hdr_debug_window.title("🔧 HDR 单步调试面板")
self.hdr_debug_window.geometry("900x400")
self.hdr_debug_window.transient(self.root)
# 创建调试面板容器
debug_container = ttk.Frame(self.hdr_debug_window, padding=10)
debug_container.pack(fill=tk.BOTH, expand=True)
# ✅ 创建调试面板实例(不要对它调用 pack
from app.views.pq_debug_panel import PQDebugPanel
debug_panel_instance = PQDebugPanel(debug_container, self)
# ← 删除debug_panel_instance.pack(...)
self.log_gui.log("✓ HDR 单步调试面板实例已创建")
# ✅ 重新启用调试(如果有数据)
try:
selected_items = self.get_selected_test_items()
if "eotf" in selected_items:
gray_data = self.results.get_intermediate_data("shared", "gray")
if gray_data:
self.log_gui.log(f" → 加载 {len(gray_data)} 个灰阶数据点")
debug_panel_instance.enable_debug("hdr_movie", "eotf", gray_data)
self.log_gui.log("✓ HDR EOTF 单步调试已重新启用")
if "accuracy" in selected_items:
accuracy_data = self.results.get_intermediate_data(
"accuracy", "measured"
)
if accuracy_data:
self.log_gui.log(f" → 加载 {len(accuracy_data)} 个色准数据点")
debug_panel_instance.enable_debug(
"hdr_movie", "accuracy", accuracy_data
)
self.log_gui.log("✓ HDR 色准单步调试已重新启用")
if "gamut" in selected_items:
rgb_data = self.results.get_intermediate_data("gamut", "rgb")
if rgb_data:
self.log_gui.log(f" → 加载 {len(rgb_data)} 个RGB数据点")
debug_panel_instance.enable_debug("hdr_movie", "rgb", rgb_data)
self.log_gui.log("✓ HDR RGB 单步调试已重新启用")
except Exception as e:
self.log_gui.log(f"⚠️ 加载 HDR 调试数据失败: {str(e)}")
import traceback
self.log_gui.log(traceback.format_exc())
# 更新按钮文字
self.hdr_debug_btn.config(text="关闭调试面板")
# 窗口关闭时的回调
def on_closing():
self.hdr_debug_btn.config(text="打开调试面板")
self.hdr_debug_window.destroy()
self.log_gui.log("✓ HDR 单步调试窗口已关闭")
self.hdr_debug_window.protocol("WM_DELETE_WINDOW", on_closing)
self.hdr_debug_window.update_idletasks()
self.log_gui.log("✓ HDR 单步调试面板已打开(独立窗口)")
def update_sidebar_selection(self):
"""更新侧边栏按钮的选中状态"""
# 重置所有按钮样式为默认
self.screen_module_btn.configure(style="Sidebar.TButton")
self.sdr_movie_btn.configure(style="Sidebar.TButton")
self.hdr_movie_btn.configure(style="Sidebar.TButton")
# 设置当前选中按钮的样式
current_type = self.test_type_var.get()
if current_type == "screen_module":
self.screen_module_btn.configure(style="SidebarSelected.TButton")
elif current_type == "sdr_movie":
self.sdr_movie_btn.configure(style="SidebarSelected.TButton")
elif current_type == "hdr_movie":
self.hdr_movie_btn.configure(style="SidebarSelected.TButton")

View File

@@ -1,4 +1,4 @@
""" """
PQ 单步调试面板 PQ 单步调试面板
支持屏模组、SDR、HDR 三种测试类型的单步调试功能 支持屏模组、SDR、HDR 三种测试类型的单步调试功能
""" """
@@ -34,7 +34,7 @@ class PQDebugPanel:
# 原始测试数据(用于对比) # 原始测试数据(用于对比)
self.original_data = {} self.original_data = {}
# ==================== 创建主容器并自动 pack ==================== # ==================== 创建主容器并自动 pack ====================
self.main_container = ttk.Frame(parent) self.main_container = ttk.Frame(parent)
self.main_container.pack(fill=tk.BOTH, expand=True) self.main_container.pack(fill=tk.BOTH, expand=True)
@@ -600,13 +600,13 @@ class PQDebugPanel:
if test_type == "screen_module": if test_type == "screen_module":
self.screen_frame.pack(fill=tk.BOTH, expand=True) self.screen_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示屏模组调试面板") self.app.log_gui.log("显示屏模组调试面板", level="success")
elif test_type == "sdr_movie": elif test_type == "sdr_movie":
self.sdr_frame.pack(fill=tk.BOTH, expand=True) self.sdr_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示 SDR 调试面板") self.app.log_gui.log("显示 SDR 调试面板", level="success")
elif test_type == "hdr_movie": elif test_type == "hdr_movie":
self.hdr_frame.pack(fill=tk.BOTH, expand=True) self.hdr_frame.pack(fill=tk.BOTH, expand=True)
self.app.log_gui.log("显示 HDR 调试面板") self.app.log_gui.log("显示 HDR 调试面板", level="success")
# ==================== 启用/禁用控制 ==================== # ==================== 启用/禁用控制 ====================
@@ -631,39 +631,39 @@ class PQDebugPanel:
if test_item == "gamma": if test_item == "gamma":
self.screen_gray_combo.config(state="readonly") self.screen_gray_combo.config(state="readonly")
self.screen_test_btn.config(state=tk.NORMAL) self.screen_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("屏模组 Gamma 单步调试已启用") self.app.log_gui.log("屏模组 Gamma 单步调试已启用", level="success")
elif test_item == "rgb": elif test_item == "rgb":
self.screen_rgb_combo.config(state="readonly") self.screen_rgb_combo.config(state="readonly")
self.screen_rgb_test_btn.config(state=tk.NORMAL) self.screen_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("屏模组 RGB 单步调试已启用") self.app.log_gui.log("屏模组 RGB 单步调试已启用", level="success")
elif test_type == "sdr_movie": elif test_type == "sdr_movie":
if test_item == "gamma": if test_item == "gamma":
self.sdr_gray_combo.config(state="readonly") self.sdr_gray_combo.config(state="readonly")
self.sdr_gamma_test_btn.config(state=tk.NORMAL) self.sdr_gamma_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR Gamma 单步调试已启用") self.app.log_gui.log("SDR Gamma 单步调试已启用", level="success")
elif test_item == "accuracy": elif test_item == "accuracy":
self.sdr_color_combo.config(state="readonly") self.sdr_color_combo.config(state="readonly")
self.sdr_accuracy_test_btn.config(state=tk.NORMAL) self.sdr_accuracy_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR 色准单步调试已启用") self.app.log_gui.log("SDR 色准单步调试已启用", level="success")
elif test_item == "rgb": elif test_item == "rgb":
self.sdr_rgb_combo.config(state="readonly") self.sdr_rgb_combo.config(state="readonly")
self.sdr_rgb_test_btn.config(state=tk.NORMAL) self.sdr_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("SDR RGB 单步调试已启用") self.app.log_gui.log("SDR RGB 单步调试已启用", level="success")
elif test_type == "hdr_movie": elif test_type == "hdr_movie":
if test_item == "eotf": if test_item == "eotf":
self.hdr_gray_combo.config(state="readonly") self.hdr_gray_combo.config(state="readonly")
self.hdr_eotf_test_btn.config(state=tk.NORMAL) self.hdr_eotf_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR EOTF 单步调试已启用") self.app.log_gui.log("HDR EOTF 单步调试已启用", level="success")
elif test_item == "accuracy": elif test_item == "accuracy":
self.hdr_color_combo.config(state="readonly") self.hdr_color_combo.config(state="readonly")
self.hdr_accuracy_test_btn.config(state=tk.NORMAL) self.hdr_accuracy_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR 色准单步调试已启用") self.app.log_gui.log("HDR 色准单步调试已启用", level="success")
elif test_item == "rgb": elif test_item == "rgb":
self.hdr_rgb_combo.config(state="readonly") self.hdr_rgb_combo.config(state="readonly")
self.hdr_rgb_test_btn.config(state=tk.NORMAL) self.hdr_rgb_test_btn.config(state=tk.NORMAL)
self.app.log_gui.log("HDR RGB 单步调试已启用") self.app.log_gui.log("HDR RGB 单步调试已启用", level="success")
def disable_all_debug(self): def disable_all_debug(self):
"""禁用所有单步调试(新测试开始时调用)""" """禁用所有单步调试(新测试开始时调用)"""
@@ -759,11 +759,11 @@ class PQDebugPanel:
def _run_single_step_thread(self, test_type, test_item, selected): def _run_single_step_thread(self, test_type, test_item, selected):
"""单步测试线程""" """单步测试线程"""
try: try:
self.app.log_gui.log("=" * 50) self.app.log_gui.log("=" * 50, level="info")
self.app.log_gui.log( self.app.log_gui.log(
f"开始单步调试: {test_type} - {test_item} - {selected}" f"开始单步调试: {test_type} - {test_item} - {selected}"
) , level="info")
self.app.log_gui.log("=" * 50) self.app.log_gui.log("=" * 50, level="info")
# 禁用按钮 # 禁用按钮
self._disable_test_button(test_type, test_item) self._disable_test_button(test_type, test_item)
@@ -796,9 +796,9 @@ class PQDebugPanel:
x, y, lv, X, Y, Z = self.app.ca.readAllDisplay() x, y, lv, X, Y, Z = self.app.ca.readAllDisplay()
self.app.log_gui.log( self.app.log_gui.log(
f"测量完成: x={x:.4f}, y={y:.4f}, lv={lv:.2f}, " f"测量完成: x={x:.4f}, y={y:.4f}, lv={lv:.2f}, "
f"X={X:.4f}, Y={Y:.4f}, Z={Z:.4f}" f"X={X:.4f}, Y={Y:.4f}, Z={Z:.4f}"
) , level="success")
# 对比数据 # 对比数据
self._compare_and_display( self._compare_and_display(
@@ -809,10 +809,10 @@ class PQDebugPanel:
self._enable_test_button(test_type, test_item) self._enable_test_button(test_type, test_item)
except Exception as e: except Exception as e:
self.app.log_gui.log(f"单步测试失败: {str(e)}") self.app.log_gui.log(f"单步测试失败: {str(e)}", level="error")
import traceback import traceback
self.app.log_gui.log(traceback.format_exc()) self.app.log_gui.log(traceback.format_exc(), level="error")
self._enable_test_button(test_type, test_item) self._enable_test_button(test_type, test_item)
def _setup_signal_format(self, test_type): def _setup_signal_format(self, test_type):
@@ -842,7 +842,7 @@ class PQDebugPanel:
# 获取原始数据 # 获取原始数据
key = f"{test_type}_{test_item}" key = f"{test_type}_{test_item}"
if key not in self.original_data: if key not in self.original_data:
self.app.log_gui.log("⚠️ 未找到原始测试数据") self.app.log_gui.log("未找到原始测试数据", level="error")
return return
original_data_list = self.original_data[key] original_data_list = self.original_data[key]
@@ -856,12 +856,12 @@ class PQDebugPanel:
index = self.get_rgb_index(selected) index = self.get_rgb_index(selected)
if index >= len(original_data_list): if index >= len(original_data_list):
self.app.log_gui.log(f"⚠️ 索引超出范围: {index}") self.app.log_gui.log(f"索引超出范围: {index}", level="error")
return return
original_data = original_data_list[index] original_data = original_data_list[index]
# ==================== 构建对比数据 ==================== # ==================== 构建对比数据 ====================
comparison = {} comparison = {}
if test_item == "gamma": if test_item == "gamma":
@@ -1140,57 +1140,57 @@ class PQDebugPanel:
def _get_delta_e_from_results(self, test_type, color_name): def _get_delta_e_from_results(self, test_type, color_name):
"""从原始测试结果中读取 ΔE 值""" """从原始测试结果中读取 ΔE 值"""
try: try:
self.app.log_gui.log(f"[读取 ΔE] 开始:") self.app.log_gui.log(f"[读取 ΔE] 开始:", level="info")
self.app.log_gui.log(f" test_type = {test_type}") self.app.log_gui.log(f" test_type = {test_type}", level="info")
self.app.log_gui.log(f" color_name = {color_name}") self.app.log_gui.log(f" color_name = {color_name}", level="info")
# 正确的访问方式:通过 test_items # 正确的访问方式:通过 test_items
if "accuracy" not in self.app.results.test_items: if "accuracy" not in self.app.results.test_items:
self.app.log_gui.log("未找到 accuracy 测试项") self.app.log_gui.log("未找到 accuracy 测试项", level="error")
return 0.0 return 0.0
test_item = self.app.results.test_items["accuracy"] test_item = self.app.results.test_items["accuracy"]
accuracy_result = test_item.final_result accuracy_result = test_item.final_result
self.app.log_gui.log(f" accuracy_result = {accuracy_result is not None}") self.app.log_gui.log(f" accuracy_result = {accuracy_result is not None}", level="info")
if not accuracy_result: if not accuracy_result:
self.app.log_gui.log("accuracy_result 为空") self.app.log_gui.log("accuracy_result 为空", level="error")
return 0.0 return 0.0
# 获取色块名称列表和 ΔE 值列表 # 获取色块名称列表和 ΔE 值列表
color_patches = accuracy_result.get("color_patches", []) color_patches = accuracy_result.get("color_patches", [])
delta_e_values = accuracy_result.get("delta_e_values", []) delta_e_values = accuracy_result.get("delta_e_values", [])
self.app.log_gui.log(f" color_patches 数量: {len(color_patches)}") self.app.log_gui.log(f" color_patches 数量: {len(color_patches)}", level="info")
self.app.log_gui.log(f" delta_e_values 数量: {len(delta_e_values)}") self.app.log_gui.log(f" delta_e_values 数量: {len(delta_e_values)}", level="info")
if color_patches: if color_patches:
self.app.log_gui.log(f" 前3个色块: {color_patches[:3]}") self.app.log_gui.log(f" 前3个色块: {color_patches[:3]}", level="info")
if delta_e_values: if delta_e_values:
self.app.log_gui.log(f" 前3个ΔE: {delta_e_values[:3]}") self.app.log_gui.log(f" 前3个ΔE: {delta_e_values[:3]}", level="info")
# 查找对应色块的索引 # 查找对应色块的索引
try: try:
index = color_patches.index(color_name) index = color_patches.index(color_name)
delta_e = delta_e_values[index] delta_e = delta_e_values[index]
self.app.log_gui.log( self.app.log_gui.log(
f" 找到 {color_name}: index={index}, ΔE={delta_e:.2f}" f" 找到 {color_name}: index={index}, ΔE={delta_e:.2f}"
) , level="success")
return delta_e return delta_e
except ValueError: except ValueError:
self.app.log_gui.log(f"未找到色块 '{color_name}'") self.app.log_gui.log(f"未找到色块 '{color_name}'", level="error")
self.app.log_gui.log(f" 可用色块: {color_patches}") self.app.log_gui.log(f" 可用色块: {color_patches}", level="info")
return 0.0 return 0.0
except IndexError: except IndexError:
self.app.log_gui.log(f"索引超出范围: {index}/{len(delta_e_values)}") self.app.log_gui.log(f"索引超出范围: {index}/{len(delta_e_values)}", level="error")
return 0.0 return 0.0
except Exception as e: except Exception as e:
self.app.log_gui.log(f"⚠️ 读取 ΔE 失败: {str(e)}") self.app.log_gui.log(f"读取 ΔE 失败: {str(e)}", level="error")
import traceback import traceback
self.app.log_gui.log(traceback.format_exc()) self.app.log_gui.log(traceback.format_exc(), level="error")
return 0.0 return 0.0
def _calculate_delta_e_for_color( def _calculate_delta_e_for_color(
@@ -1215,5 +1215,5 @@ class PQDebugPanel:
return delta_e return delta_e
except Exception as e: except Exception as e:
self.app.log_gui.log(f"⚠️ 计算 ΔE 失败: {str(e)}") self.app.log_gui.log(f"计算 ΔE 失败: {str(e)}", level="error")
return 0.0 return 0.0

View File

@@ -1,31 +1,160 @@
import threading
from datetime import datetime
import tkinter as tk import tkinter as tk
import ttkbootstrap as ttk import ttkbootstrap as ttk
class PQLogGUI(ttk.Frame): class PQLogGUI(ttk.Frame):
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
def __init__(self, parent): def __init__(self, parent):
super().__init__(parent) super().__init__(parent)
self._line_count = 0
self._max_lines = 1500
self.create_widgets() self.create_widgets()
def create_widgets(self): def create_widgets(self):
log_frame = ttk.LabelFrame(self, text="测试日志") log_frame = ttk.LabelFrame(self, text="测试日志")
log_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5) log_frame.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
self.log_text = ttk.Text(log_frame, height=8, width=50) toolbar = ttk.Frame(log_frame)
toolbar.pack(fill=tk.X, padx=6, pady=(6, 2))
self.log_summary_var = tk.StringVar(value="0 条日志")
ttk.Label(
toolbar,
textvariable=self.log_summary_var,
bootstyle="secondary",
).pack(side=tk.LEFT)
ttk.Button(
toolbar,
text="清空日志",
command=self.clear_log,
bootstyle="secondary-outline",
width=10,
).pack(side=tk.RIGHT)
text_container = ttk.Frame(log_frame)
text_container.pack(fill=tk.BOTH, expand=True, padx=6, pady=(0, 6))
self.log_text = tk.Text(
text_container,
height=10,
width=50,
wrap=tk.WORD,
font=("Consolas", 10),
bg="#fbfcfe",
fg="#1f2937",
relief=tk.FLAT,
bd=0,
padx=10,
pady=8,
spacing1=2,
spacing3=2,
insertbackground="#1f2937",
)
self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT) self.log_text.pack(fill=tk.BOTH, expand=True, side=tk.LEFT)
log_scrollbar = ttk.Scrollbar(log_frame, command=self.log_text.yview) log_scrollbar = ttk.Scrollbar(text_container, command=self.log_text.yview)
log_scrollbar.pack(fill=tk.Y, side=tk.RIGHT) log_scrollbar.pack(fill=tk.Y, side=tk.RIGHT)
self.log_text.config(yscrollcommand=log_scrollbar.set) self.log_text.config(yscrollcommand=log_scrollbar.set)
self._configure_tags()
self.log_text.config(state=tk.DISABLED) self.log_text.config(state=tk.DISABLED)
def log(self, message): def log(self, message, level="info"):
if threading.current_thread() is not threading.main_thread():
self.after(0, self.log, message, level)
return
text = "" if message is None else str(message)
normalized_level = self._normalize_level(level, text)
self.log_text.config(state=tk.NORMAL) self.log_text.config(state=tk.NORMAL)
self.log_text.insert(tk.END, message + "\n") self._append_message(text, normalized_level)
self.log_text.see(tk.END) self.log_text.see(tk.END)
self.log_text.config(state=tk.DISABLED) self.log_text.config(state=tk.DISABLED)
def clear_log(self): def clear_log(self):
if threading.current_thread() is not threading.main_thread():
self.after(0, self.clear_log)
return
self.log_text.config(state=tk.NORMAL) self.log_text.config(state=tk.NORMAL)
self.log_text.delete(1.0, tk.END) self.log_text.delete(1.0, tk.END)
self.log_text.config(state=tk.DISABLED) self.log_text.config(state=tk.DISABLED)
self._line_count = 0
self._update_summary()
def _configure_tags(self):
self.log_text.tag_configure("timestamp", foreground="#6b7280")
self.log_text.tag_configure("level_info", foreground="#2563eb")
self.log_text.tag_configure("level_success", foreground="#0f766e")
self.log_text.tag_configure("level_warning", foreground="#b45309")
self.log_text.tag_configure("level_error", foreground="#b91c1c")
self.log_text.tag_configure("level_debug", foreground="#7c3aed")
self.log_text.tag_configure("message", foreground="#1f2937")
self.log_text.tag_configure("message_success", foreground="#0f766e")
self.log_text.tag_configure("message_warning", foreground="#b45309")
self.log_text.tag_configure("message_error", foreground="#991b1b")
self.log_text.tag_configure("message_debug", foreground="#6d28d9")
self.log_text.tag_configure("separator", foreground="#94a3b8")
self.log_text.tag_configure("traceback", foreground="#7f1d1d")
self.log_text.tag_configure("blank", spacing1=4, spacing3=4)
def _append_message(self, message, level):
lines = message.splitlines() or [""]
for line in lines:
self._append_line(line, level)
self._trim_excess_lines()
self._update_summary()
def _append_line(self, line, level):
timestamp = datetime.now().strftime("%H:%M:%S")
rendered = "" if line is None else str(line).strip()
if level == "blank" or not rendered:
self.log_text.insert(tk.END, "\n", ("blank",))
self._line_count += 1
return
if level == "separator":
self.log_text.insert(tk.END, f"[{timestamp}] ", ("timestamp",))
self.log_text.insert(tk.END, "[SECTION] ", ("level_info",))
self.log_text.insert(tk.END, rendered + "\n", ("separator",))
self._line_count += 1
return
level_tag = f"level_{level}"
level_label = level.upper().ljust(7)
if level == "error" and rendered.startswith("Traceback"):
message_tag = "traceback"
elif level in {"success", "warning", "error", "debug"}:
message_tag = f"message_{level}"
else:
message_tag = "message"
self.log_text.insert(tk.END, f"[{timestamp}] ", ("timestamp",))
self.log_text.insert(tk.END, f"[{level_label}] ", (level_tag,))
self.log_text.insert(tk.END, rendered + "\n", (message_tag,))
self._line_count += 1
def _normalize_level(self, level, message):
normalized = "info" if level is None else str(level).strip().lower()
if normalized not in self.VALID_LEVELS:
normalized = "info"
if normalized == "info" and (message is None or str(message).strip() == ""):
return "blank"
return normalized
def _trim_excess_lines(self):
overflow = self._line_count - self._max_lines
if overflow <= 0:
return
self.log_text.delete("1.0", f"{overflow + 1}.0")
self._line_count = self._max_lines
def _update_summary(self):
self.log_summary_var.set(f"{self._line_count} 条日志")

File diff suppressed because it is too large Load Diff