"""单步调试面板。""" from __future__ import annotations import csv import datetime import os import tempfile import threading import tkinter as tk from tkinter import filedialog, messagebox import ttkbootstrap as ttk from PIL import Image _DEFAULT_SAMPLES = [ {"name": "Red Sample", "hex": "#D22630", "x": "0.6400", "y": "0.3300"}, {"name": "Green Sample", "hex": "#00A651", "x": "0.3000", "y": "0.6000"}, {"name": "Blue Sample", "hex": "#21409A", "x": "0.1500", "y": "0.0600"}, {"name": "Orange Sample", "hex": "#FF6A13", "x": "0.5500", "y": "0.4000"}, {"name": "Violet Sample", "hex": "#6A0DAD", "x": "0.2700", "y": "0.1400"}, {"name": "Gray Sample", "hex": "#8A8D8F", "x": "0.3127", "y": "0.3290"}, ] def create_single_step_panel(self): """创建单步调试面板。""" frame = ttk.Frame(self.content_frame) self.single_step_frame = frame self.single_step_visible = False self.single_step_samples = [] self.single_step_results = [] self.single_step_current_index = None self.single_step_current_image_path = None root = ttk.Frame(frame, padding=10) root.pack(fill=tk.BOTH, expand=True) root.columnconfigure(0, weight=0) root.columnconfigure(1, weight=1) root.rowconfigure(1, weight=1) title_row = ttk.Frame(root) title_row.grid(row=0, column=0, columnspan=2, sticky=tk.EW, pady=(0, 10)) ttk.Label( title_row, text="单步调试", font=("微软雅黑", 14, "bold"), ).pack(side=tk.LEFT) ttk.Label( title_row, text="录入目标色块,发送纯色色块并采集 xyY / ΔE2000。", foreground="#666", ).pack(side=tk.LEFT, padx=(12, 0)) left = ttk.LabelFrame(root, text="样本列表", padding=8) left.grid(row=1, column=0, sticky=tk.NS, padx=(0, 10)) left.grid_propagate(False) left.configure(width=340) self.single_step_listbox = tk.Listbox( left, width=34, activestyle="none", font=("微软雅黑", 9), highlightthickness=1, highlightbackground="#d8d8d8", highlightcolor="#4a90e2", selectbackground="#2b6cb0", selectforeground="#ffffff", ) self.single_step_listbox.pack(fill=tk.BOTH, expand=True) self.single_step_listbox.bind( "<>", lambda e: _on_sample_select(self) ) list_btn_row = ttk.Frame(left) list_btn_row.pack(fill=tk.X, pady=(8, 0)) ttk.Button( list_btn_row, text="导入 CSV", bootstyle="secondary-outline", command=lambda: _import_samples_csv(self), ).pack(side=tk.LEFT, padx=(0, 4)) ttk.Button( list_btn_row, text="载入示例", bootstyle="secondary-outline", command=lambda: _load_default_samples(self), ).pack(side=tk.LEFT, padx=(0, 4)) ttk.Button( list_btn_row, text="删除", bootstyle="danger-outline", command=lambda: _delete_current_sample(self), ).pack(side=tk.LEFT) right = ttk.Frame(root) right.grid(row=1, column=1, sticky=tk.NSEW) form_frame = ttk.LabelFrame(right, text="样本配置", padding=8) form_frame.pack(fill=tk.X) for column in range(6): form_frame.columnconfigure(column, weight=1 if column in (1, 3, 5) else 0) self.single_step_name_var = tk.StringVar() self.single_step_hex_var = tk.StringVar(value="#FFFFFF") self.single_step_target_x_var = tk.StringVar() self.single_step_target_y_var = tk.StringVar() self.single_step_measured_x_var = tk.StringVar() self.single_step_measured_y_var = tk.StringVar() self.single_step_measured_lv_var = tk.StringVar() self.single_step_status_var = tk.StringVar(value="未选择样本") ttk.Label(form_frame, text="名称").grid(row=0, column=0, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_name_var).grid( row=0, column=1, sticky=tk.EW, padx=(0, 8), pady=4 ) ttk.Label(form_frame, text="HEX").grid(row=0, column=2, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_hex_var, width=12).grid( row=0, column=3, sticky=tk.EW, padx=(0, 8), pady=4 ) ttk.Label(form_frame, text="目标 x").grid(row=0, column=4, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_target_x_var, width=10).grid( row=0, column=5, sticky=tk.EW, pady=4 ) ttk.Label(form_frame, text="目标 y").grid(row=1, column=0, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_target_y_var, width=10).grid( row=1, column=1, sticky=tk.EW, padx=(0, 8), pady=4 ) ttk.Label(form_frame, text="实测 x").grid(row=1, column=2, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_measured_x_var, width=10).grid( row=1, column=3, sticky=tk.EW, padx=(0, 8), pady=4 ) ttk.Label(form_frame, text="实测 y").grid(row=1, column=4, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_measured_y_var, width=10).grid( row=1, column=5, sticky=tk.EW, pady=4 ) ttk.Label(form_frame, text="实测 Lv").grid(row=2, column=0, sticky=tk.W, pady=4) ttk.Entry(form_frame, textvariable=self.single_step_measured_lv_var, width=10).grid( row=2, column=1, sticky=tk.EW, padx=(0, 8), pady=4 ) ttk.Label( form_frame, textvariable=self.single_step_status_var, foreground="#666", ).grid(row=2, column=2, columnspan=4, sticky=tk.W, pady=4) action_row = ttk.Frame(form_frame) action_row.grid(row=3, column=0, columnspan=6, sticky=tk.EW, pady=(6, 0)) ttk.Button( action_row, text="新增 / 更新样本", bootstyle="primary", command=lambda: _upsert_sample(self), ).pack(side=tk.LEFT, padx=(0, 6)) ttk.Button( action_row, text="发送当前色块", bootstyle="info-outline", command=lambda: _send_current_patch(self), ).pack(side=tk.LEFT, padx=(0, 6)) ttk.Button( action_row, text="CA410 采集", bootstyle="success-outline", command=lambda: _measure_current_sample(self), ).pack(side=tk.LEFT, padx=(0, 6)) ttk.Button( action_row, text="记录结果", bootstyle="warning-outline", command=lambda: _commit_result(self), ).pack(side=tk.LEFT) result_frame = ttk.LabelFrame(right, text="调试结果", padding=8) result_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0)) columns = ( "name", "hex", "target_x", "target_y", "measured_x", "measured_y", "lv", "delta_e", "time", ) self.single_step_result_tree = ttk.Treeview( result_frame, columns=columns, show="headings", height=12, ) headings = { "name": "名称", "hex": "HEX", "target_x": "目标 x", "target_y": "目标 y", "measured_x": "实测 x", "measured_y": "实测 y", "lv": "Lv", "delta_e": "ΔE2000", "time": "时间", } widths = { "name": 130, "hex": 90, "target_x": 80, "target_y": 80, "measured_x": 80, "measured_y": 80, "lv": 80, "delta_e": 90, "time": 120, } for column in columns: self.single_step_result_tree.heading(column, text=headings[column]) self.single_step_result_tree.column( column, width=widths[column], anchor=tk.CENTER ) self.single_step_result_tree.pack(fill=tk.BOTH, expand=True) bottom_row = ttk.Frame(right) bottom_row.pack(fill=tk.X, pady=(8, 0)) ttk.Button( bottom_row, text="导出结果 CSV", bootstyle="success", command=lambda: _export_results_csv(self), ).pack(side=tk.LEFT, padx=(0, 6)) ttk.Button( bottom_row, text="清空结果", bootstyle="danger-outline", command=lambda: _clear_results(self), ).pack(side=tk.LEFT) self.register_panel("single_step", frame, None, "single_step_visible") _load_default_samples(self) def toggle_single_step_panel(self): """切换单步调试面板。""" self.show_panel("single_step") def _load_default_samples(self): self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES] _refresh_sample_list(self, select_index=0 if self.single_step_samples else None) self.single_step_status_var.set( f"已载入 {len(self.single_step_samples)} 个示例样本" ) def _refresh_sample_list(self, select_index=None): self.single_step_listbox.delete(0, tk.END) for sample in self.single_step_samples: self.single_step_listbox.insert( tk.END, f"{sample['name']} {sample['hex']} ({sample['x']}, {sample['y']})", ) if select_index is not None and 0 <= select_index < len(self.single_step_samples): self.single_step_listbox.selection_clear(0, tk.END) self.single_step_listbox.selection_set(select_index) self.single_step_listbox.activate(select_index) _select_sample(self, select_index) elif not self.single_step_samples: self.single_step_current_index = None self.single_step_name_var.set("") self.single_step_hex_var.set("#FFFFFF") self.single_step_target_x_var.set("") self.single_step_target_y_var.set("") self.single_step_status_var.set("样本列表为空") def _on_sample_select(self): selection = self.single_step_listbox.curselection() if not selection: return _select_sample(self, selection[0]) def _select_sample(self, index): sample = self.single_step_samples[index] self.single_step_current_index = index self.single_step_name_var.set(sample["name"]) self.single_step_hex_var.set(sample["hex"]) self.single_step_target_x_var.set(sample["x"]) self.single_step_target_y_var.set(sample["y"]) self.single_step_status_var.set(f"当前样本: {sample['name']}") def _import_samples_csv(self): path = filedialog.askopenfilename( title="选择单步调试样本 CSV", filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")], ) if not path: return samples = [] try: with open(path, "r", encoding="utf-8-sig", newline="") as fp: reader = csv.DictReader(fp) for row in reader: name = (row.get("name") or row.get("sample") or "").strip() hex_value = (row.get("hex") or row.get("rgb") or "").strip() target_x = (row.get("x") or "").strip() target_y = (row.get("y") or "").strip() if not name or not hex_value or not target_x or not target_y: continue samples.append( { "name": name, "hex": _normalize_hex(hex_value), "x": target_x, "y": target_y, } ) except Exception as exc: messagebox.showerror("导入失败", f"读取 CSV 失败: {exc}") return if not samples: messagebox.showwarning("导入失败", "CSV 中没有有效样本,要求列包含 name/hex/x/y") return self.single_step_samples = samples _refresh_sample_list(self, select_index=0) self.log_gui.log(f"单步调试样本已导入: {len(samples)} 条", level="success") def _delete_current_sample(self): if self.single_step_current_index is None: return removed = self.single_step_samples.pop(self.single_step_current_index) next_index = min(self.single_step_current_index, len(self.single_step_samples) - 1) _refresh_sample_list(self, select_index=next_index if next_index >= 0 else None) self.single_step_status_var.set(f"已删除样本: {removed['name']}") def _upsert_sample(self): try: sample = { "name": self.single_step_name_var.get().strip(), "hex": _normalize_hex(self.single_step_hex_var.get()), "x": _format_float(self.single_step_target_x_var.get()), "y": _format_float(self.single_step_target_y_var.get()), } except ValueError as exc: messagebox.showerror("参数错误", str(exc)) return if not sample["name"]: messagebox.showwarning("提示", "请输入样本名称") return if self.single_step_current_index is None: self.single_step_samples.append(sample) select_index = len(self.single_step_samples) - 1 self.single_step_status_var.set(f"已新增样本: {sample['name']}") else: self.single_step_samples[self.single_step_current_index] = sample select_index = self.single_step_current_index self.single_step_status_var.set(f"已更新样本: {sample['name']}") _refresh_sample_list(self, select_index=select_index) def _normalize_hex(value): text = (value or "").strip().upper() if not text: raise ValueError("HEX 不能为空") if not text.startswith("#"): text = "#" + text if len(text) != 7 or any(ch not in "#0123456789ABCDEF" for ch in text): raise ValueError("HEX 格式应为 #RRGGBB") return text def _format_float(value): try: number = float(str(value).strip()) except Exception as exc: raise ValueError("xy 值必须是数字") from exc return f"{number:.4f}" def _build_color_patch(self, hex_value): if not self.signal_service.is_connected: raise RuntimeError("请先连接 UCD323 设备") width, height = self.signal_service.current_resolution() rgb = tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5)) temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches") os.makedirs(temp_dir, exist_ok=True) file_path = os.path.join( temp_dir, f"single_step_{hex_value[1:]}_{width}x{height}.png" ) Image.new("RGB", (width, height), rgb).save(file_path, format="PNG") return file_path def _send_current_patch(self): if self.single_step_current_index is None: messagebox.showinfo("提示", "请先选择一个样本") return sample = self.single_step_samples[self.single_step_current_index] def worker(): try: image_path = _build_color_patch(self, sample["hex"]) self.signal_service.send_image(image_path) self.single_step_current_image_path = image_path self._dispatch_ui( self.single_step_status_var.set, f"已发送色块: {sample['name']} {sample['hex']}", ) self._dispatch_ui( self.log_gui.log, f"单步调试色块已发送: {sample['name']} {sample['hex']}", "success", ) except Exception as exc: self._dispatch_ui(self.single_step_status_var.set, f"发送失败: {exc}") self._dispatch_ui(self.log_gui.log, f"单步调试色块发送失败: {exc}", "error") threading.Thread(target=worker, daemon=True).start() def _measure_current_sample(self): if self.single_step_current_index is None: messagebox.showinfo("提示", "请先选择一个样本") return if not getattr(self, "ca", None): messagebox.showwarning("警告", "请先连接 CA410 色度计") return def worker(): try: x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay() if lv is None: raise RuntimeError("CA410 未返回有效亮度") self._dispatch_ui(self.single_step_measured_x_var.set, f"{x:.4f}") self._dispatch_ui(self.single_step_measured_y_var.set, f"{y:.4f}") self._dispatch_ui(self.single_step_measured_lv_var.set, f"{lv:.2f}") self._dispatch_ui(self.single_step_status_var.set, "采集完成,可记录结果") self._dispatch_ui( self.log_gui.log, f"单步调试采集完成: x={x:.4f}, y={y:.4f}, Lv={lv:.2f}", "success", ) except Exception as exc: self._dispatch_ui(self.single_step_status_var.set, f"采集失败: {exc}") self._dispatch_ui(self.log_gui.log, f"单步调试采集失败: {exc}", "error") threading.Thread(target=worker, daemon=True).start() def _commit_result(self): if self.single_step_current_index is None: messagebox.showinfo("提示", "请先选择一个样本") return sample = self.single_step_samples[self.single_step_current_index] try: measured_x = float(self.single_step_measured_x_var.get().strip()) measured_y = float(self.single_step_measured_y_var.get().strip()) measured_lv = float(self.single_step_measured_lv_var.get().strip()) target_x = float(sample["x"]) target_y = float(sample["y"]) delta_e = self.calculate_delta_e_2000( measured_x, measured_y, measured_lv, target_x, target_y ) except Exception as exc: messagebox.showerror("记录失败", f"请先准备完整的目标值与实测值: {exc}") return timestamp = datetime.datetime.now().strftime("%H:%M:%S") record = { "name": sample["name"], "hex": sample["hex"], "target_x": f"{target_x:.4f}", "target_y": f"{target_y:.4f}", "measured_x": f"{measured_x:.4f}", "measured_y": f"{measured_y:.4f}", "lv": f"{measured_lv:.2f}", "delta_e": f"{delta_e:.3f}", "time": timestamp, } self.single_step_results.append(record) self.single_step_result_tree.insert( "", tk.END, values=tuple( record[key] for key in ( "name", "hex", "target_x", "target_y", "measured_x", "measured_y", "lv", "delta_e", "time", ) ), ) self.single_step_status_var.set(f"已记录结果,ΔE2000={record['delta_e']}") def _clear_results(self): self.single_step_results = [] for item in self.single_step_result_tree.get_children(): self.single_step_result_tree.delete(item) self.single_step_status_var.set("结果已清空") def _export_results_csv(self): if not self.single_step_results: messagebox.showinfo("提示", "暂无可导出的调试结果") return path = filedialog.asksaveasfilename( title="导出单步调试结果", defaultextension=".csv", initialfile=f"single_step_debug_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv", filetypes=[("CSV 文件", "*.csv")], ) if not path: return fieldnames = [ "name", "hex", "target_x", "target_y", "measured_x", "measured_y", "lv", "delta_e", "time", ] try: with open(path, "w", encoding="utf-8-sig", newline="") as fp: writer = csv.DictWriter(fp, fieldnames=fieldnames) writer.writeheader() writer.writerows(self.single_step_results) self.log_gui.log(f"单步调试结果已导出: {path}", level="success") self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}") except Exception as exc: messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")