修改引用逻辑、新增Pattern更改界面、新增Calman灰阶界面
This commit is contained in:
@@ -221,6 +221,401 @@ def get_pattern(name: str) -> dict:
|
||||
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json")
|
||||
|
||||
|
||||
def reload_gray_pattern() -> dict:
|
||||
"""重新从 ``settings/patterns/gray.json`` 加载灰阶 pattern。
|
||||
|
||||
原地更新 ``_PATTERN_GRAY``,让 ``PQConfig.default_pattern_gray``
|
||||
与 ``_KNOWN_PATTERNS['gray']`` 等所有现有引用同步生效,
|
||||
无需重启程序即可应用新 pattern 列表。
|
||||
"""
|
||||
new_data = _load_pattern_or_empty(
|
||||
_PATTERNS_DIR / "gray.json", default=_PATTERN_GRAY_FALLBACK
|
||||
)
|
||||
_PATTERN_GRAY.clear()
|
||||
_PATTERN_GRAY.update(new_data)
|
||||
return copy.deepcopy(_PATTERN_GRAY)
|
||||
|
||||
|
||||
def get_gray_pattern_fallback() -> dict:
|
||||
"""返回硬编码默认 11 点灰阶 pattern 的深拷贝(用于 UI 的"恢复默认")。"""
|
||||
return copy.deepcopy(_PATTERN_GRAY_FALLBACK)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 灰阶 Pattern 预设管理(settings/patterns/presets/gray/*.json)
|
||||
# =============================================================================
|
||||
#
|
||||
# 设计要点:
|
||||
# - 每个预设独立 JSON 文件,文件名(不含 .json)即预设名。
|
||||
# - 内置预设以 ``_builtin_`` 前缀命名,并在 _meta.locked=True,UI 禁止删除/改名/覆盖。
|
||||
# - 当前激活预设记录在 settings/patterns/presets/_active.json,便于 UI 显示。
|
||||
# - 应用某预设 = 把它复制写入 settings/patterns/gray.json + reload_gray_pattern()。
|
||||
# - gamma/cct/contrast/eotf 共用同一份 gray 预设(与 runner 现有共享灰阶采集对齐)。
|
||||
#
|
||||
|
||||
_PRESETS_DIR = _PATTERNS_DIR / "presets"
|
||||
_ACTIVE_INDEX_FILE = _PRESETS_DIR / "_active.json"
|
||||
|
||||
|
||||
def _gray_presets_dir() -> Path:
|
||||
p = _PRESETS_DIR / "gray"
|
||||
p.mkdir(parents=True, exist_ok=True)
|
||||
return p
|
||||
|
||||
|
||||
def _safe_preset_name(name: str) -> str:
|
||||
"""清洗预设名,去掉文件系统危险字符。"""
|
||||
name = (name or "").strip()
|
||||
bad = '<>:"/\\|?*\n\r\t'
|
||||
for ch in bad:
|
||||
name = name.replace(ch, "_")
|
||||
return name[:80] or "untitled"
|
||||
|
||||
|
||||
def _preset_path(test_kind: str, name: str) -> Path:
|
||||
if test_kind != "gray":
|
||||
raise ValueError(f"暂仅支持 test_kind='gray',收到: {test_kind}")
|
||||
return _gray_presets_dir() / f"{_safe_preset_name(name)}.json"
|
||||
|
||||
|
||||
def _load_active_index() -> dict:
|
||||
try:
|
||||
with open(_ACTIVE_INDEX_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (FileNotFoundError, json.JSONDecodeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _save_active_index(data: dict) -> None:
|
||||
_PRESETS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with open(_ACTIVE_INDEX_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
|
||||
def list_presets(test_kind: str = "gray") -> list[dict]:
|
||||
"""
|
||||
列出指定类别下的所有预设。
|
||||
|
||||
Returns:
|
||||
list of {name, locked, description, point_count, file_path, generator}
|
||||
"""
|
||||
d = _gray_presets_dir() if test_kind == "gray" else None
|
||||
if d is None:
|
||||
return []
|
||||
items: list[dict] = []
|
||||
for fp in sorted(d.glob("*.json")):
|
||||
try:
|
||||
data = load_pattern_file(fp)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
meta = data.get("_meta", {}) or {}
|
||||
items.append({
|
||||
"name": fp.stem,
|
||||
"locked": bool(meta.get("locked", False)),
|
||||
"description": meta.get("description", ""),
|
||||
"generator": meta.get("generator", ""),
|
||||
"created": meta.get("created", ""),
|
||||
"point_count": len(data.get("pattern_params") or []),
|
||||
"file_path": str(fp),
|
||||
})
|
||||
# 内置 _builtin_ 排前,其余按名字排序
|
||||
items.sort(key=lambda x: (not x["name"].startswith("_builtin_"), x["name"]))
|
||||
return items
|
||||
|
||||
|
||||
def load_preset(test_kind: str, name: str) -> dict:
|
||||
"""加载预设的完整 pattern 数据(含 _meta)。"""
|
||||
return load_pattern_file(_preset_path(test_kind, name))
|
||||
|
||||
|
||||
def save_preset(
|
||||
test_kind: str,
|
||||
name: str,
|
||||
pattern: dict,
|
||||
*,
|
||||
description: str = "",
|
||||
generator: str = "",
|
||||
locked: bool = False,
|
||||
overwrite: bool = True,
|
||||
) -> Path:
|
||||
"""保存预设。若 overwrite=False 且文件已存在或目标为锁定预设则抛错。"""
|
||||
from datetime import datetime
|
||||
name = _safe_preset_name(name)
|
||||
path = _preset_path(test_kind, name)
|
||||
|
||||
if path.exists():
|
||||
try:
|
||||
existing = load_pattern_file(path)
|
||||
if (existing.get("_meta") or {}).get("locked"):
|
||||
raise PermissionError(f"预设 '{name}' 已锁定,不可覆盖")
|
||||
except (json.JSONDecodeError, OSError):
|
||||
pass
|
||||
if not overwrite:
|
||||
raise FileExistsError(f"预设 '{name}' 已存在")
|
||||
|
||||
data = {
|
||||
"pattern_mode": pattern.get("pattern_mode", "SolidColor"),
|
||||
"measurement_bit_depth": pattern.get("measurement_bit_depth", 8),
|
||||
"measurement_max_value": max(0, len(pattern.get("pattern_params") or []) - 1),
|
||||
"pattern_params": [list(map(int, rgb)) for rgb in (pattern.get("pattern_params") or [])],
|
||||
"_meta": {
|
||||
"description": description,
|
||||
"generator": generator,
|
||||
"locked": locked,
|
||||
"created": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
},
|
||||
}
|
||||
save_pattern_file(path, data)
|
||||
return path
|
||||
|
||||
|
||||
def delete_preset(test_kind: str, name: str) -> None:
|
||||
"""删除预设;锁定预设不可删除。"""
|
||||
path = _preset_path(test_kind, name)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"预设 '{name}' 不存在")
|
||||
data = load_pattern_file(path)
|
||||
if (data.get("_meta") or {}).get("locked"):
|
||||
raise PermissionError(f"预设 '{name}' 已锁定,不可删除")
|
||||
path.unlink()
|
||||
# 若被删的恰是激活预设,清理记录
|
||||
idx = _load_active_index()
|
||||
if idx.get(test_kind) == name:
|
||||
idx.pop(test_kind, None)
|
||||
_save_active_index(idx)
|
||||
|
||||
|
||||
def rename_preset(test_kind: str, old: str, new: str) -> Path:
|
||||
"""重命名;锁定预设不可改名。"""
|
||||
src = _preset_path(test_kind, old)
|
||||
if not src.exists():
|
||||
raise FileNotFoundError(f"预设 '{old}' 不存在")
|
||||
data = load_pattern_file(src)
|
||||
if (data.get("_meta") or {}).get("locked"):
|
||||
raise PermissionError(f"预设 '{old}' 已锁定,不可重命名")
|
||||
dst = _preset_path(test_kind, new)
|
||||
if dst.exists():
|
||||
raise FileExistsError(f"目标预设 '{new}' 已存在")
|
||||
src.rename(dst)
|
||||
idx = _load_active_index()
|
||||
if idx.get(test_kind) == old:
|
||||
idx[test_kind] = dst.stem
|
||||
_save_active_index(idx)
|
||||
return dst
|
||||
|
||||
|
||||
def duplicate_preset(test_kind: str, src_name: str, new_name: str) -> Path:
|
||||
"""复制一个预设为新副本(解除锁定)。"""
|
||||
data = load_preset(test_kind, src_name)
|
||||
meta = dict(data.get("_meta") or {})
|
||||
meta["locked"] = False
|
||||
meta["description"] = f"复制自 {src_name}" + (
|
||||
f";{meta.get('description', '')}" if meta.get("description") else ""
|
||||
)
|
||||
data["_meta"] = meta
|
||||
return save_preset(
|
||||
test_kind,
|
||||
new_name,
|
||||
data,
|
||||
description=meta["description"],
|
||||
generator=meta.get("generator", ""),
|
||||
locked=False,
|
||||
overwrite=False,
|
||||
)
|
||||
|
||||
|
||||
def activate_preset(test_kind: str, name: str) -> dict:
|
||||
"""
|
||||
将指定预设应用为当前 gray pattern:
|
||||
- 写入 settings/patterns/gray.json(剥离 _meta 以保持原格式)
|
||||
- reload_gray_pattern() 让运行时立即生效
|
||||
- 在 _active.json 记录激活预设名
|
||||
"""
|
||||
data = load_preset(test_kind, name)
|
||||
clean = {
|
||||
"pattern_mode": data.get("pattern_mode", "SolidColor"),
|
||||
"measurement_bit_depth": data.get("measurement_bit_depth", 8),
|
||||
"measurement_max_value": data.get("measurement_max_value", 0),
|
||||
"pattern_params": data.get("pattern_params") or [],
|
||||
}
|
||||
save_pattern_file(_PATTERNS_DIR / "gray.json", clean)
|
||||
reload_gray_pattern()
|
||||
idx = _load_active_index()
|
||||
idx[test_kind] = _safe_preset_name(name)
|
||||
_save_active_index(idx)
|
||||
return clean
|
||||
|
||||
|
||||
def get_active_preset_name(test_kind: str = "gray") -> str | None:
|
||||
"""返回当前激活预设名(来自 _active.json);不存在则返回 None。"""
|
||||
return _load_active_index().get(test_kind)
|
||||
|
||||
|
||||
def import_preset_from_file(test_kind: str, src_file, *, name: str | None = None) -> Path:
|
||||
"""从外部 JSON 文件导入为预设。"""
|
||||
data = load_pattern_file(src_file)
|
||||
if not data.get("pattern_params"):
|
||||
raise ValueError("文件中未找到 pattern_params")
|
||||
preset_name = name or Path(src_file).stem
|
||||
return save_preset(
|
||||
test_kind,
|
||||
preset_name,
|
||||
data,
|
||||
description=(data.get("_meta") or {}).get("description", "导入"),
|
||||
generator=(data.get("_meta") or {}).get("generator", ""),
|
||||
locked=False,
|
||||
overwrite=True,
|
||||
)
|
||||
|
||||
|
||||
def export_preset_to_file(test_kind: str, name: str, dst_file) -> Path:
|
||||
"""将预设导出到指定外部 JSON 文件。"""
|
||||
data = load_preset(test_kind, name)
|
||||
save_pattern_file(dst_file, data)
|
||||
return Path(dst_file)
|
||||
|
||||
|
||||
# ---- 内置预设生成器 ----------------------------------------------------------
|
||||
|
||||
def _gen_even_gray(n: int) -> list[list[int]]:
|
||||
"""N 点等分 (100%→0%)。"""
|
||||
if n < 2:
|
||||
n = 2
|
||||
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_gray(n: int) -> list[list[int]]:
|
||||
"""在 PQ 编码空间 N 点等分(亮度按 PQ 曲线均匀分布,低端更密集)。
|
||||
|
||||
采样规则:取 PQ 信号值从 1.0 到 0.0 等分,转 8-bit RGB 灰阶。
|
||||
(PQ 信号本身在感知亮度上即为线性,故"等分编码值"≈"等分感知亮度"。)
|
||||
"""
|
||||
if n < 2:
|
||||
n = 2
|
||||
out = []
|
||||
for i in range(n):
|
||||
v_pq = 1.0 - i / (n - 1)
|
||||
v = int(round(v_pq * 255))
|
||||
out.append([v, v, v])
|
||||
return out
|
||||
|
||||
|
||||
def _gen_gamma_gray(n: int, gamma: float = 2.2) -> list[list[int]]:
|
||||
"""在线性光强空间 N 点等分,再用 gamma 编码到 8-bit 灰阶(暗端更密集)。"""
|
||||
if n < 2:
|
||||
n = 2
|
||||
out = []
|
||||
for i in range(n):
|
||||
lin = 1.0 - i / (n - 1) # 线性光 1→0
|
||||
code = lin ** (1.0 / gamma) # gamma 编码
|
||||
v = int(round(code * 255))
|
||||
out.append([v, v, v])
|
||||
return out
|
||||
|
||||
|
||||
_BUILTIN_GRAY_PRESETS = [
|
||||
("_builtin_even_11pt",
|
||||
"11 点等分 (100%→0%),行业标准灰阶",
|
||||
"even-11",
|
||||
_gen_even_gray(11)),
|
||||
("_builtin_even_21pt",
|
||||
"21 点等分 (5% 步长),更精细的 SDR 灰阶",
|
||||
"even-21",
|
||||
_gen_even_gray(21)),
|
||||
("_builtin_gamma22_17pt",
|
||||
"17 点 Gamma 2.2 分布(暗端更密集),用于 SDR Gamma 拟合",
|
||||
"gamma2.2-17",
|
||||
_gen_gamma_gray(17, 2.2)),
|
||||
("_builtin_pq_17pt",
|
||||
"17 点 PQ 编码等分,用于 HDR EOTF 评估",
|
||||
"pq-17",
|
||||
_gen_pq_gray(17)),
|
||||
]
|
||||
|
||||
|
||||
def ensure_builtin_presets() -> None:
|
||||
"""启动时调用:确保内置预设存在;若已存在则跳过(不覆盖用户修改)。
|
||||
|
||||
同时迁移:若 presets/ 目录为空但 settings/patterns/gray.json 存在,
|
||||
将其作为 ``user_current`` 预设引入,避免用户原有自定义丢失。
|
||||
"""
|
||||
presets_dir = _gray_presets_dir()
|
||||
for name, desc, gen, params in _BUILTIN_GRAY_PRESETS:
|
||||
path = presets_dir / f"{name}.json"
|
||||
if path.exists():
|
||||
continue
|
||||
save_preset(
|
||||
"gray",
|
||||
name,
|
||||
{
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": len(params) - 1,
|
||||
"pattern_params": params,
|
||||
},
|
||||
description=desc,
|
||||
generator=gen,
|
||||
locked=True,
|
||||
)
|
||||
|
||||
# 迁移:用户原有 gray.json 入库为 user_current(仅在 presets 目录还没有任何用户预设时)
|
||||
user_presets = [p for p in presets_dir.glob("*.json") if not p.stem.startswith("_builtin_")]
|
||||
if not user_presets:
|
||||
gray_file = _PATTERNS_DIR / "gray.json"
|
||||
if gray_file.exists():
|
||||
try:
|
||||
cur = load_pattern_file(gray_file)
|
||||
if cur.get("pattern_params"):
|
||||
save_preset(
|
||||
"gray",
|
||||
"user_current",
|
||||
cur,
|
||||
description="迁移自原 gray.json",
|
||||
generator="migrated",
|
||||
locked=False,
|
||||
overwrite=False,
|
||||
)
|
||||
idx = _load_active_index()
|
||||
idx.setdefault("gray", "user_current")
|
||||
_save_active_index(idx)
|
||||
except (json.JSONDecodeError, OSError, FileExistsError):
|
||||
pass
|
||||
|
||||
# 若无激活预设记录,按规则推断(优先匹配当前 gray.json 内容)
|
||||
idx = _load_active_index()
|
||||
if "gray" not in idx:
|
||||
try:
|
||||
cur = load_pattern_file(_PATTERNS_DIR / "gray.json")
|
||||
cur_params = cur.get("pattern_params") or []
|
||||
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||
cur_params = []
|
||||
match: str | None = None
|
||||
for fp in presets_dir.glob("*.json"):
|
||||
try:
|
||||
if (load_pattern_file(fp).get("pattern_params") or []) == cur_params:
|
||||
match = fp.stem
|
||||
break
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
if match:
|
||||
idx["gray"] = match
|
||||
_save_active_index(idx)
|
||||
|
||||
|
||||
# 自动确保内置预设存在(首次启动会创建文件)
|
||||
try:
|
||||
ensure_builtin_presets()
|
||||
except OSError:
|
||||
# 启动期目录不可写则跳过;UI 层有兜底
|
||||
pass
|
||||
|
||||
|
||||
|
||||
class PQConfig:
|
||||
def __init__(self, current_test_type="screen_module"):
|
||||
|
||||
Reference in New Issue
Block a user