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

1076 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""灰阶 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 共用此列表)",
style="Muted.TLabel",
).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="", style="SuccessState.TLabel", 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="", style="Muted.TLabel", 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%)",
style="Muted.TLabel", 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="", style="Muted.TLabel", 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}", style="SuccessState.TLabel"
)
elif active:
extra = "(有未保存改动)" if self._gamma_dirty else ""
self._gamma_active_label.config(
text=f"● 激活:{active} 编辑中:{current or '-'}{extra}",
style="WarningState.TLabel" if self._gamma_dirty else "Muted.TLabel",
)
else:
self._gamma_active_label.config(text="● 未激活任何预设", style="Muted.TLabel")
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)} 点)"
style_name = "SuccessState.TLabel"
else:
text = f"{len(params)} 点 | " + " ".join(msgs)
style_name = "WarningState.TLabel" if any(m.startswith("") for m in msgs) else "Muted.TLabel"
self._gamma_validate_label.config(text=text, style=style_name)
# ============================================================
# 初始加载
# ============================================================
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