"""灰阶 Pattern 配置面板(Gamma / CCT / 对比度 / EOTF 共用)。 特性: - 多预设管理:内置 + 用户保存,支持 新建 / 另存为 / 复制 / 重命名 / 删除 / 导入 / 导出 - 内置预设锁定,不可覆盖、删除、改名 - 行内色块预览,前景色根据亮度自适应 - 生成器:N 点等分 / PQ 编码 / Gamma 2.2/2.4 曲线 - 剪贴板批量粘贴(每行 "R,G,B" / "R G B" / "灰度%") - 自动校验:重复 / 越界 / 非灰阶 / 非单调 为保持向后兼容,仍导出 ``create_gamma_pattern_panel`` / ``toggle_gamma_pattern_panel``。 """ from __future__ import annotations import json import os import sys import tkinter as tk from pathlib import Path from tkinter import filedialog, messagebox, simpledialog from typing import TYPE_CHECKING import ttkbootstrap as ttk from app.pq import pq_config if TYPE_CHECKING: from pqAutomationApp import PQAutomationApp # ============================================================ # 列定义 # ============================================================ _COLUMNS = ("idx", "pct", "r", "g", "b", "hex") _HEADINGS = { "idx": "#", "pct": "灰度 %", "r": "R", "g": "G", "b": "B", "hex": "HEX", } _WIDTHS = {"idx": 40, "pct": 70, "r": 60, "g": 60, "b": 60, "hex": 80} _TEST_KIND = "gray" # 当前仅管理灰阶系测试共用的 pattern # ============================================================ # 工具 # ============================================================ def _gray_pct_of(rgb) -> str: try: r = int(rgb[0]) except Exception: return "" return str(int(round(r / 255 * 100))) def _hex_of(rgb) -> str: try: r, g, b = int(rgb[0]), int(rgb[1]), int(rgb[2]) except Exception: return "" return f"#{r:02X}{g:02X}{b:02X}" def _luminance(rgb) -> float: r, g, b = rgb return 0.2126 * r + 0.7152 * g + 0.0722 * b def _fg_for_bg(rgb) -> str: return "#000000" if _luminance(rgb) >= 140 else "#ffffff" def _get_settings_dir(self: "PQAutomationApp") -> str: if getattr(self, "config_file", None): return os.path.dirname(self.config_file) if getattr(sys, "frozen", False): base_dir = os.path.dirname(sys.executable) else: base_dir = os.path.dirname( os.path.dirname( os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ) ) return os.path.join(base_dir, "settings") # ============================================================ # 内置生成器 # ============================================================ def _gen_even(n: int) -> list[list[int]]: n = max(2, n) out = [] for i in range(n): pct = 100.0 - (100.0 / (n - 1)) * i v = int(round(pct / 100.0 * 255)) out.append([v, v, v]) return out def _gen_pq(n: int) -> list[list[int]]: """N 点:线性 nits(0..10000)等分后 PQ 编码到 8-bit(低端更密集)。""" n = max(2, n) c1, c2, c3 = 0.8359375, 18.8515625, 18.6875 m1, m2 = 0.1593017578125, 78.84375 out = [] for i in range(n): nits = 10000.0 * (1.0 - i / (n - 1)) L = max(0.0, nits / 10000.0) if L <= 0: v_pq = 0.0 else: Lm1 = L ** m1 v_pq = ((c1 + c2 * Lm1) / (1.0 + c3 * Lm1)) ** m2 v = int(round(max(0.0, min(1.0, v_pq)) * 255)) out.append([v, v, v]) return out def _gen_gamma(n: int, gamma: float = 2.2) -> list[list[int]]: """N 点:线性光 0..1 等分后 ^(1/gamma) 编码到 8-bit(暗端更密集)。""" n = max(2, n) out = [] for i in range(n): lin = 1.0 - i / (n - 1) v = int(round((lin ** (1.0 / gamma)) * 255)) out.append([v, v, v]) return out _GENERATORS = { "等分 (linear code)": _gen_even, "PQ 编码 (HDR)": _gen_pq, "Gamma 2.2 (SDR)": lambda n: _gen_gamma(n, 2.2), "Gamma 2.4 (SDR)": lambda n: _gen_gamma(n, 2.4), } # ============================================================ # 面板入口 # ============================================================ def create_gamma_pattern_panel(self: "PQAutomationApp"): """创建灰阶 Pattern 配置面板。""" frame = ttk.Frame(self.content_frame) self.gamma_pattern_frame = frame self.gamma_pattern_visible = False # 内部状态 self._gamma_pattern_params: list[list[int]] = [] self._gamma_current_preset: str | None = None self._gamma_preset_locked: bool = False self._gamma_dirty: bool = False self._gamma_preset_list: list[dict] = [] root = ttk.Frame(frame, padding=10) root.pack(fill=tk.BOTH, expand=True) # ===== 标题 ===== title_row = ttk.Frame(root) title_row.pack(fill=tk.X, pady=(0, 6)) ttk.Label( title_row, text="灰阶 Pattern 配置", font=("微软雅黑", 14, "bold") ).pack(side=tk.LEFT) ttk.Label( title_row, text="(Gamma / CCT / 对比度 / EOTF 共用此列表)", foreground="#888", ).pack(side=tk.LEFT, padx=(8, 0)) # ===== 预设管理行 ===== preset_box = ttk.LabelFrame(root, text="预设", padding=8) preset_box.pack(fill=tk.X, pady=(0, 8)) preset_row1 = ttk.Frame(preset_box) preset_row1.pack(fill=tk.X) ttk.Label(preset_row1, text="选择:").pack(side=tk.LEFT) self._gamma_preset_var = tk.StringVar() self._gamma_preset_combo = ttk.Combobox( preset_row1, textvariable=self._gamma_preset_var, state="readonly", width=32, ) self._gamma_preset_combo.pack(side=tk.LEFT, padx=(4, 6)) self._gamma_preset_combo.bind( "<>", lambda e: _on_preset_selected(self) ) ttk.Button( preset_row1, text="加载", bootstyle="info-outline", width=8, command=lambda: _load_selected_preset(self), ).pack(side=tk.LEFT, padx=2) ttk.Button( preset_row1, text="应用为当前", bootstyle="success", width=12, command=lambda: _activate_selected_preset(self), ).pack(side=tk.LEFT, padx=2) self._gamma_active_label = ttk.Label( preset_row1, text="", foreground="#0a8", font=("微软雅黑", 9, "bold") ) self._gamma_active_label.pack(side=tk.LEFT, padx=(10, 0)) # 第二行:CRUD preset_row2 = ttk.Frame(preset_box) preset_row2.pack(fill=tk.X, pady=(6, 0)) for txt, style, cmd in [ ("新建空预设", "secondary-outline", lambda: _new_preset(self)), ("另存为...", "primary-outline", lambda: _save_as_preset(self)), ("复制...", "secondary-outline", lambda: _duplicate_preset(self)), ("重命名...", "secondary-outline", lambda: _rename_preset(self)), ("删除", "danger-outline", lambda: _delete_preset(self)), ("导入...", "secondary-outline", lambda: _import_preset(self)), ("导出...", "secondary-outline", lambda: _export_preset(self)), ]: ttk.Button( preset_row2, text=txt, bootstyle=style, width=11, command=cmd ).pack(side=tk.LEFT, padx=2) # 描述行 self._gamma_meta_label = ttk.Label( preset_box, text="", foreground="#666", font=("微软雅黑", 9) ) self._gamma_meta_label.pack(anchor=tk.W, pady=(6, 0)) # ===== 中部:左表格 + 右编辑区 ===== mid = ttk.Frame(root) mid.pack(fill=tk.BOTH, expand=True) mid.columnconfigure(0, weight=1) mid.columnconfigure(1, weight=0) mid.rowconfigure(0, weight=1) # ---- 左:表格 ---- table_frame = ttk.LabelFrame(mid, text="灰阶点列表", padding=6) table_frame.grid(row=0, column=0, sticky=tk.NSEW, padx=(0, 8)) tree_container = ttk.Frame(table_frame) tree_container.pack(fill=tk.BOTH, expand=True) self.gamma_pattern_tree = ttk.Treeview( tree_container, columns=_COLUMNS, show="headings", height=18, selectmode="browse", ) for col in _COLUMNS: self.gamma_pattern_tree.heading(col, text=_HEADINGS[col]) self.gamma_pattern_tree.column(col, width=_WIDTHS[col], anchor=tk.CENTER) self.gamma_pattern_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) scrollbar = ttk.Scrollbar( tree_container, orient=tk.VERTICAL, command=self.gamma_pattern_tree.yview ) scrollbar.pack(side=tk.RIGHT, fill=tk.Y) self.gamma_pattern_tree.configure(yscrollcommand=scrollbar.set) self.gamma_pattern_tree.bind( "<>", lambda e: _on_select(self) ) # ---- 右:编辑区 ---- right = ttk.Frame(mid) right.grid(row=0, column=1, sticky=tk.NS) edit_frame = ttk.LabelFrame(right, text="编辑选中点", padding=8) edit_frame.pack(fill=tk.X) self._gamma_edit_r_var = tk.StringVar() self._gamma_edit_g_var = tk.StringVar() self._gamma_edit_b_var = tk.StringVar() self._gamma_edit_pct_var = tk.StringVar() ttk.Label(edit_frame, text="灰度 %").grid(row=0, column=0, sticky=tk.W, pady=3) ttk.Entry(edit_frame, textvariable=self._gamma_edit_pct_var, width=10).grid( row=0, column=1, sticky=tk.W, pady=3, padx=(4, 0) ) ttk.Button( edit_frame, text="按 % 填充", bootstyle="secondary-outline", width=10, command=lambda: _fill_rgb_from_pct(self), ).grid(row=0, column=2, padx=(6, 0), pady=3) for i, ch in enumerate(("R", "G", "B"), start=1): ttk.Label(edit_frame, text=ch).grid(row=i, column=0, sticky=tk.W, pady=3) var = getattr(self, f"_gamma_edit_{ch.lower()}_var") ttk.Entry(edit_frame, textvariable=var, width=10).grid( row=i, column=1, sticky=tk.W, pady=3, padx=(4, 0) ) btn_row1 = ttk.Frame(edit_frame) btn_row1.grid(row=4, column=0, columnspan=3, sticky=tk.EW, pady=(8, 0)) for txt, style, cmd in [ ("更新选中", "primary", lambda: _update_selected(self)), ("新增", "success", lambda: _add_new(self)), ("删除", "danger-outline", lambda: _delete_selected(self)), ]: ttk.Button(btn_row1, text=txt, bootstyle=style, width=8, command=cmd).pack( side=tk.LEFT, padx=(0, 4) ) btn_row2 = ttk.Frame(edit_frame) btn_row2.grid(row=5, column=0, columnspan=3, sticky=tk.EW, pady=(4, 0)) ttk.Button( btn_row2, text="↑ 上移", bootstyle="secondary-outline", width=8, command=lambda: _move_selected(self, -1), ).pack(side=tk.LEFT, padx=(0, 4)) ttk.Button( btn_row2, text="↓ 下移", bootstyle="secondary-outline", width=8, command=lambda: _move_selected(self, 1), ).pack(side=tk.LEFT) # ---- 生成器 ---- gen_frame = ttk.LabelFrame(right, text="按曲线生成", padding=8) gen_frame.pack(fill=tk.X, pady=(10, 0)) ttk.Label(gen_frame, text="类型").grid(row=0, column=0, sticky=tk.W) self._gamma_gen_type_var = tk.StringVar(value=next(iter(_GENERATORS))) ttk.Combobox( gen_frame, textvariable=self._gamma_gen_type_var, values=list(_GENERATORS.keys()), state="readonly", width=18, ).grid(row=0, column=1, sticky=tk.W, padx=(4, 0)) ttk.Label(gen_frame, text="点数 N").grid(row=1, column=0, sticky=tk.W, pady=(6, 0)) self._gamma_n_points_var = tk.StringVar(value="11") ttk.Entry(gen_frame, textvariable=self._gamma_n_points_var, width=10).grid( row=1, column=1, sticky=tk.W, padx=(4, 0), pady=(6, 0) ) ttk.Button( gen_frame, text="生成", bootstyle="info-outline", command=lambda: _generate_by_curve(self), ).grid(row=2, column=0, columnspan=2, sticky=tk.EW, pady=(6, 0)) # ---- 剪贴板粘贴 ---- paste_frame = ttk.LabelFrame(right, text="批量粘贴", padding=8) paste_frame.pack(fill=tk.X, pady=(10, 0)) ttk.Label( paste_frame, text="每行:R,G,B 或 R G B\n或:灰度% (如 50%)", foreground="#888", justify=tk.LEFT, ).pack(anchor=tk.W) ttk.Button( paste_frame, text="从剪贴板导入", bootstyle="secondary-outline", command=lambda: _paste_from_clipboard(self), ).pack(fill=tk.X, pady=(6, 0)) # ===== 底部 ===== bottom = ttk.LabelFrame(root, text="校验与保存", padding=8) bottom.pack(fill=tk.X, pady=(10, 0)) self._gamma_validate_label = ttk.Label( bottom, text="", foreground="#666", justify=tk.LEFT ) self._gamma_validate_label.pack(anchor=tk.W) save_row = ttk.Frame(bottom) save_row.pack(fill=tk.X, pady=(6, 0)) ttk.Button( save_row, text="保存改动到当前预设", bootstyle="primary", command=lambda: _save_to_current_preset(self), ).pack(side=tk.LEFT) ttk.Button( save_row, text="应用到运行时 (gray.json)", bootstyle="success", command=lambda: _apply_current_to_runtime(self), ).pack(side=tk.LEFT, padx=(6, 0)) ttk.Button( save_row, text="另存为新预设...", bootstyle="info-outline", command=lambda: _save_as_preset(self), ).pack(side=tk.RIGHT) # 注册并初始化 self.register_panel("gamma_pattern", frame, None, "gamma_pattern_visible") _refresh_preset_combo(self) _load_initial(self) def toggle_gamma_pattern_panel(self: "PQAutomationApp"): """切换面板显隐。""" self.show_panel("gamma_pattern") # ============================================================ # 预设管理 # ============================================================ def _refresh_preset_combo(self: "PQAutomationApp", select: str | None = None): presets = pq_config.list_presets(_TEST_KIND) self._gamma_preset_list = presets names = [p["name"] for p in presets] labels = [ f"{'🔒 ' if p['locked'] else ''}{p['name']} ({p['point_count']}点)" for p in presets ] self._gamma_preset_combo["values"] = labels target = select or self._gamma_current_preset if target and target in names: self._gamma_preset_var.set(labels[names.index(target)]) elif labels: self._gamma_preset_var.set(labels[0]) _update_active_label(self) def _selected_preset_name(self: "PQAutomationApp") -> str | None: label = self._gamma_preset_var.get() if not label: return None for p in self._gamma_preset_list: prefix = "🔒 " if p["locked"] else "" if label.startswith(prefix + p["name"]): return p["name"] return None def _update_active_label(self: "PQAutomationApp"): active = pq_config.get_active_preset_name(_TEST_KIND) current = self._gamma_current_preset if active and current == active and not self._gamma_dirty: self._gamma_active_label.config( text=f"✔ 当前激活:{active}", foreground="#0a8" ) elif active: extra = "(有未保存改动)" if self._gamma_dirty else "" self._gamma_active_label.config( text=f"● 激活:{active} 编辑中:{current or '-'}{extra}", foreground="#a60" if self._gamma_dirty else "#888", ) else: self._gamma_active_label.config(text="● 未激活任何预设", foreground="#888") def _on_preset_selected(self: "PQAutomationApp"): """选中下拉项时直接加载(含未保存提醒)。""" if self._gamma_dirty: if not messagebox.askyesno( "未保存改动", "当前预设有未保存改动,确定切换?切换后改动将丢失。" ): _refresh_preset_combo(self, select=self._gamma_current_preset) return _load_selected_preset(self) def _load_selected_preset(self: "PQAutomationApp"): name = _selected_preset_name(self) if not name: return try: data = pq_config.load_preset(_TEST_KIND, name) except (FileNotFoundError, json.JSONDecodeError) as exc: messagebox.showerror("加载失败", str(exc)) return self._gamma_current_preset = name self._gamma_preset_locked = bool((data.get("_meta") or {}).get("locked")) self._gamma_pattern_params = [ list(map(int, rgb)) for rgb in (data.get("pattern_params") or []) ] self._gamma_dirty = False _refresh_tree(self) _update_meta_label(self, data) _update_active_label(self) def _activate_selected_preset(self: "PQAutomationApp"): name = _selected_preset_name(self) if not name: messagebox.showinfo("提示", "请先选择一个预设") return if self._gamma_dirty and self._gamma_current_preset == name: if not messagebox.askyesno( "未保存改动", "当前编辑未保存,将使用磁盘上原始预设进行激活。继续?" ): return try: pq_config.activate_preset(_TEST_KIND, name) except Exception as exc: messagebox.showerror("激活失败", str(exc)) return if hasattr(self, "log_gui"): self.log_gui.log(f"已激活灰阶预设:{name}", level="success") _update_active_label(self) messagebox.showinfo("成功", f"已应用预设 '{name}' 到运行时。") def _new_preset(self: "PQAutomationApp"): name = simpledialog.askstring( "新建预设", "预设名(建议英文/数字/下划线):", parent=self.gamma_pattern_frame, ) if not name: return try: path = pq_config.save_preset( _TEST_KIND, name, {"pattern_params": _gen_even(11)}, description="新建空白预设", generator="manual", overwrite=False, ) except (FileExistsError, PermissionError, ValueError) as exc: messagebox.showerror("创建失败", str(exc)) return _refresh_preset_combo(self, select=Path(path).stem) _load_selected_preset(self) def _save_as_preset(self: "PQAutomationApp"): if not self._gamma_pattern_params: messagebox.showerror("错误", "当前列表为空,无法另存") return name = simpledialog.askstring( "另存为预设", "新预设名:", parent=self.gamma_pattern_frame, initialvalue=(self._gamma_current_preset or "untitled").lstrip("_") + "_copy", ) if not name: return try: path = pq_config.save_preset( _TEST_KIND, name, {"pattern_params": self._gamma_pattern_params}, description="用户另存", generator="manual", overwrite=False, ) except (FileExistsError, PermissionError, ValueError) as exc: messagebox.showerror("保存失败", str(exc)) return self._gamma_dirty = False _refresh_preset_combo(self, select=Path(path).stem) _load_selected_preset(self) def _duplicate_preset(self: "PQAutomationApp"): src = _selected_preset_name(self) if not src: return new_name = simpledialog.askstring( "复制预设", "新名字:", parent=self.gamma_pattern_frame, initialvalue=src.lstrip("_") + "_copy", ) if not new_name: return try: path = pq_config.duplicate_preset(_TEST_KIND, src, new_name) except (FileExistsError, FileNotFoundError, ValueError) as exc: messagebox.showerror("复制失败", str(exc)) return _refresh_preset_combo(self, select=Path(path).stem) def _rename_preset(self: "PQAutomationApp"): src = _selected_preset_name(self) if not src: return new_name = simpledialog.askstring( "重命名预设", "新名字:", parent=self.gamma_pattern_frame, initialvalue=src ) if not new_name or new_name == src: return try: path = pq_config.rename_preset(_TEST_KIND, src, new_name) except (FileExistsError, FileNotFoundError, PermissionError, ValueError) as exc: messagebox.showerror("重命名失败", str(exc)) return if self._gamma_current_preset == src: self._gamma_current_preset = Path(path).stem _refresh_preset_combo(self, select=Path(path).stem) def _delete_preset(self: "PQAutomationApp"): src = _selected_preset_name(self) if not src: return if not messagebox.askyesno("确认", f"确定删除预设 '{src}' ?"): return try: pq_config.delete_preset(_TEST_KIND, src) except (FileNotFoundError, PermissionError) as exc: messagebox.showerror("删除失败", str(exc)) return if self._gamma_current_preset == src: self._gamma_current_preset = None self._gamma_pattern_params = [] _refresh_tree(self) _refresh_preset_combo(self) def _import_preset(self: "PQAutomationApp"): fp = filedialog.askopenfilename( title="选择 Pattern JSON 文件", filetypes=[("JSON", "*.json"), ("All", "*.*")], initialdir=_get_settings_dir(self), ) if not fp: return name = simpledialog.askstring( "导入预设", "保存为预设名:", parent=self.gamma_pattern_frame, initialvalue=Path(fp).stem, ) if not name: return try: path = pq_config.import_preset_from_file(_TEST_KIND, fp, name=name) except (ValueError, json.JSONDecodeError, OSError) as exc: messagebox.showerror("导入失败", str(exc)) return _refresh_preset_combo(self, select=Path(path).stem) _load_selected_preset(self) def _export_preset(self: "PQAutomationApp"): src = _selected_preset_name(self) if not src: return fp = filedialog.asksaveasfilename( title="导出预设到文件", filetypes=[("JSON", "*.json")], defaultextension=".json", initialfile=f"{src}.json", initialdir=_get_settings_dir(self), ) if not fp: return try: pq_config.export_preset_to_file(_TEST_KIND, src, fp) except Exception as exc: messagebox.showerror("导出失败", str(exc)) return messagebox.showinfo("导出成功", f"已导出到\n{fp}") def _save_to_current_preset(self: "PQAutomationApp"): name = self._gamma_current_preset if not name: messagebox.showinfo("提示", "没有当前预设,请先'新建'或'另存为'") return if self._gamma_preset_locked: messagebox.showwarning( "预设已锁定", f"'{name}' 是内置预设,不能直接覆盖。请使用'另存为'保存为新预设。", ) return if len(self._gamma_pattern_params) < 2: messagebox.showerror("错误", "至少需要 2 个灰阶点") return try: existing_meta = (pq_config.load_preset(_TEST_KIND, name).get("_meta") or {}) except Exception: existing_meta = {} try: pq_config.save_preset( _TEST_KIND, name, {"pattern_params": self._gamma_pattern_params}, description=existing_meta.get("description", ""), generator=existing_meta.get("generator", "manual"), overwrite=True, ) except (PermissionError, ValueError) as exc: messagebox.showerror("保存失败", str(exc)) return self._gamma_dirty = False _refresh_preset_combo(self, select=name) if hasattr(self, "log_gui"): self.log_gui.log(f"已保存预设:{name}", level="success") messagebox.showinfo("成功", f"已保存到预设 '{name}'。\n如需生效请点'应用为当前'或'应用到运行时'。") def _apply_current_to_runtime(self: "PQAutomationApp"): """把当前编辑结果直接写入 gray.json 并热加载(不必先保存为预设)。""" if len(self._gamma_pattern_params) < 2: messagebox.showerror("错误", "至少需要 2 个灰阶点") return pattern = { "pattern_mode": "SolidColor", "measurement_bit_depth": 8, "measurement_max_value": len(self._gamma_pattern_params) - 1, "pattern_params": [list(map(int, rgb)) for rgb in self._gamma_pattern_params], } path = Path(_get_settings_dir(self)) / "patterns" / "gray.json" try: pq_config.save_pattern_file(path, pattern) pq_config.reload_gray_pattern() except Exception as exc: messagebox.showerror("应用失败", str(exc)) return # 若当前编辑与磁盘上同名预设内容一致,则把该预设标记为激活 if self._gamma_current_preset: try: disk = pq_config.load_preset(_TEST_KIND, self._gamma_current_preset) if (disk.get("pattern_params") or []) == self._gamma_pattern_params: pq_config.activate_preset(_TEST_KIND, self._gamma_current_preset) except Exception: pass if hasattr(self, "log_gui"): self.log_gui.log( f"已应用 {len(self._gamma_pattern_params)} 点灰阶 pattern 到运行时", level="success", ) _update_active_label(self) messagebox.showinfo("成功", "已应用到运行时,立即生效。") # ============================================================ # 表格 / 编辑 # ============================================================ def _refresh_tree(self: "PQAutomationApp"): tree = self.gamma_pattern_tree tree.delete(*tree.get_children()) for i, rgb in enumerate(self._gamma_pattern_params): try: r, g, b = int(rgb[0]), int(rgb[1]), int(rgb[2]) except (TypeError, ValueError, IndexError): r = g = b = 0 tag = f"row_{i}" bg = f"#{r:02x}{g:02x}{b:02x}" fg = _fg_for_bg([r, g, b]) tree.tag_configure(tag, background=bg, foreground=fg) tree.insert( "", tk.END, iid=str(i), values=(i, _gray_pct_of([r, g, b]), r, g, b, _hex_of([r, g, b])), tags=(tag,), ) _run_validation(self) def _selected_index(self: "PQAutomationApp"): sel = self.gamma_pattern_tree.selection() if not sel: return None try: return int(sel[0]) except ValueError: return None def _on_select(self: "PQAutomationApp"): idx = _selected_index(self) if idx is None or idx >= len(self._gamma_pattern_params): return r, g, b = self._gamma_pattern_params[idx] self._gamma_edit_r_var.set(str(r)) self._gamma_edit_g_var.set(str(g)) self._gamma_edit_b_var.set(str(b)) self._gamma_edit_pct_var.set(_gray_pct_of([r, g, b])) def _parse_rgb_from_form(self: "PQAutomationApp"): try: r = int(self._gamma_edit_r_var.get().strip()) g = int(self._gamma_edit_g_var.get().strip()) b = int(self._gamma_edit_b_var.get().strip()) except ValueError: messagebox.showerror("输入错误", "R/G/B 必须为整数") return None if not all(0 <= v <= 255 for v in (r, g, b)): messagebox.showerror("输入错误", "R/G/B 必须在 0~255 范围内") return None return [r, g, b] def _fill_rgb_from_pct(self: "PQAutomationApp"): raw = self._gamma_edit_pct_var.get().strip().rstrip("%") try: pct = float(raw) except ValueError: messagebox.showerror("输入错误", "灰度 % 必须为数字") return pct = max(0.0, min(100.0, pct)) v = int(round(pct / 100.0 * 255)) self._gamma_edit_r_var.set(str(v)) self._gamma_edit_g_var.set(str(v)) self._gamma_edit_b_var.set(str(v)) self._gamma_edit_pct_var.set(str(int(round(pct)))) def _mark_dirty(self: "PQAutomationApp"): self._gamma_dirty = True _update_active_label(self) def _check_locked_or_prompt(self: "PQAutomationApp") -> bool: """锁定预设编辑前提示用户。返回 True 表示允许继续编辑。""" if not self._gamma_preset_locked: return True return messagebox.askyesno( "预设已锁定", "当前是内置预设,改动无法直接覆盖(保存时需'另存为')。仍要编辑吗?", ) def _update_selected(self: "PQAutomationApp"): idx = _selected_index(self) if idx is None: messagebox.showinfo("提示", "请先在列表中选中一行") return rgb = _parse_rgb_from_form(self) if rgb is None: return if not _check_locked_or_prompt(self): return self._gamma_pattern_params[idx] = rgb _mark_dirty(self) _refresh_tree(self) try: self.gamma_pattern_tree.selection_set(str(idx)) except Exception: pass def _add_new(self: "PQAutomationApp"): rgb = _parse_rgb_from_form(self) if rgb is None: return if not _check_locked_or_prompt(self): return idx = _selected_index(self) insert_at = (idx + 1) if idx is not None else len(self._gamma_pattern_params) self._gamma_pattern_params.insert(insert_at, rgb) _mark_dirty(self) _refresh_tree(self) try: self.gamma_pattern_tree.selection_set(str(insert_at)) except Exception: pass def _delete_selected(self: "PQAutomationApp"): idx = _selected_index(self) if idx is None: messagebox.showinfo("提示", "请先在列表中选中一行") return if len(self._gamma_pattern_params) <= 2: messagebox.showwarning("提示", "至少保留 2 个灰阶点") return if not _check_locked_or_prompt(self): return del self._gamma_pattern_params[idx] _mark_dirty(self) _refresh_tree(self) def _move_selected(self: "PQAutomationApp", delta: int): idx = _selected_index(self) if idx is None: return new_idx = idx + delta if new_idx < 0 or new_idx >= len(self._gamma_pattern_params): return if not _check_locked_or_prompt(self): return params = self._gamma_pattern_params params[idx], params[new_idx] = params[new_idx], params[idx] _mark_dirty(self) _refresh_tree(self) try: self.gamma_pattern_tree.selection_set(str(new_idx)) except Exception: pass def _generate_by_curve(self: "PQAutomationApp"): try: n = int(self._gamma_n_points_var.get().strip()) except ValueError: messagebox.showerror("输入错误", "点数 N 必须为整数") return if n < 2 or n > 256: messagebox.showerror("输入错误", "点数 N 必须在 2~256 之间") return gen_name = self._gamma_gen_type_var.get() func = _GENERATORS.get(gen_name) if func is None: return if not _check_locked_or_prompt(self): return if not messagebox.askyesno("确认", f"将用 [{gen_name}] N={n} 替换当前列表,继续?"): return self._gamma_pattern_params = func(n) _mark_dirty(self) _refresh_tree(self) def _paste_from_clipboard(self: "PQAutomationApp"): try: raw = self.gamma_pattern_frame.clipboard_get() except tk.TclError: messagebox.showinfo("提示", "剪贴板为空") return rows: list[list[int]] = [] errors: list[str] = [] for ln_no, line in enumerate(raw.splitlines(), 1): s = line.strip() if not s: continue if s.endswith("%"): try: pct = float(s.rstrip("%")) v = int(round(max(0, min(100, pct)) / 100.0 * 255)) rows.append([v, v, v]) continue except ValueError: errors.append(f"第 {ln_no} 行无法解析:{s}") continue parts = [p for p in s.replace(",", " ").replace(";", " ").split() if p] if len(parts) == 1: try: v = int(parts[0]) if 0 <= v <= 255: rows.append([v, v, v]) continue except ValueError: pass errors.append(f"第 {ln_no} 行无法解析:{s}") continue if len(parts) < 3: errors.append(f"第 {ln_no} 行字段不足:{s}") continue try: r, g, b = int(parts[0]), int(parts[1]), int(parts[2]) except ValueError: errors.append(f"第 {ln_no} 行非整数:{s}") continue if not all(0 <= v <= 255 for v in (r, g, b)): errors.append(f"第 {ln_no} 行越界:{s}") continue rows.append([r, g, b]) if not rows: messagebox.showerror("粘贴失败", "未识别到任何有效行\n" + "\n".join(errors[:5])) return if not _check_locked_or_prompt(self): return msg = f"识别到 {len(rows)} 行有效数据。" if errors: msg += f"\n忽略 {len(errors)} 行错误(示例):\n" + "\n".join(errors[:3]) msg += "\n\n是 = 替换当前列表 / 否 = 追加到末尾 / 取消" choice = messagebox.askyesnocancel("确认", msg) if choice is None: return if choice: self._gamma_pattern_params = rows else: self._gamma_pattern_params.extend(rows) _mark_dirty(self) _refresh_tree(self) # ============================================================ # 元信息显示 & 校验 # ============================================================ def _update_meta_label(self: "PQAutomationApp", data: dict): meta = data.get("_meta") or {} parts = [] if meta.get("locked"): parts.append("🔒 内置(只读,需另存为修改)") if meta.get("description"): parts.append(meta["description"]) if meta.get("generator"): parts.append(f"生成器:{meta['generator']}") if meta.get("created"): parts.append(f"创建:{meta['created']}") self._gamma_meta_label.config(text=" | ".join(parts) if parts else "") def _run_validation(self: "PQAutomationApp"): params = self._gamma_pattern_params msgs: list[str] = [] if len(params) < 2: msgs.append("⚠ 至少需要 2 个灰阶点") bad = [ i for i, rgb in enumerate(params) if not all(0 <= int(v) <= 255 for v in rgb) ] if bad: msgs.append(f"⚠ 越界点 (索引): {bad}") non_gray = [ i for i, (r, g, b) in enumerate(params) if not (int(r) == int(g) == int(b)) ] if non_gray: msgs.append(f"ⓘ 非纯灰点 (R≠G≠B): {non_gray}(gamma 计算按 R 通道)") seen: dict[tuple, int] = {} dups: list[int] = [] for i, rgb in enumerate(params): key = tuple(rgb) if key in seen: dups.append(i) else: seen[key] = i if dups: msgs.append(f"⚠ 重复点 (索引): {dups}") r_seq = [int(rgb[0]) for rgb in params] if len(r_seq) >= 2: is_desc = all(a >= b for a, b in zip(r_seq, r_seq[1:])) is_asc = all(a <= b for a, b in zip(r_seq, r_seq[1:])) if not (is_desc or is_asc): msgs.append("ⓘ R 通道非单调递增/递减") if not msgs: text = f"✔ 校验通过(共 {len(params)} 点)" color = "#0a8" else: text = f"共 {len(params)} 点 | " + " ".join(msgs) color = "#a60" if any(m.startswith("⚠") for m in msgs) else "#666" self._gamma_validate_label.config(text=text, foreground=color) # ============================================================ # 初始加载 # ============================================================ def _load_initial(self: "PQAutomationApp"): """启动时优先加载激活预设;如无则加载当前 gray.json。""" active = pq_config.get_active_preset_name(_TEST_KIND) if active: try: data = pq_config.load_preset(_TEST_KIND, active) self._gamma_current_preset = active self._gamma_preset_locked = bool((data.get("_meta") or {}).get("locked")) self._gamma_pattern_params = [ list(map(int, rgb)) for rgb in (data.get("pattern_params") or []) ] _refresh_tree(self) _refresh_preset_combo(self, select=active) _update_meta_label(self, data) _update_active_label(self) return except Exception: pass pattern = pq_config.get_pattern("gray") self._gamma_pattern_params = [ list(map(int, rgb)) for rgb in (pattern.get("pattern_params") or []) ] self._gamma_current_preset = None self._gamma_preset_locked = False _refresh_tree(self) _update_meta_label(self, {}) _update_active_label(self) # ============================================================ # Mixin # ============================================================ class GammaPatternPanelMixin: """挂到 PQAutomationApp 上的方法集合。""" create_gamma_pattern_panel = create_gamma_pattern_panel toggle_gamma_pattern_panel = toggle_gamma_pattern_panel