Files
pqAutomationApp/app/views/panels/gamma_pattern_panel.py

1076 lines
36 KiB
Python
Raw Normal View History

"""灰阶 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 点:线性 nits0..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(
"<<ComboboxSelected>>", 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(
"<<TreeviewSelect>>", 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