Compare commits
10 Commits
e243fc4e94
...
c42287b7d7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c42287b7d7 | ||
|
|
405b6047b9 | ||
|
|
d7495734a5 | ||
|
|
9371defb6e | ||
|
|
0513f810bd | ||
|
|
9a52e34f2b | ||
|
|
3c519dde20 | ||
|
|
7bc8fd8557 | ||
|
|
afd83448ed | ||
|
|
377bba2a0b |
6
.gitignore
vendored
6
.gitignore
vendored
@@ -18,8 +18,8 @@ dist/
|
||||
*.log
|
||||
*.tmp
|
||||
*.bak
|
||||
*.csv
|
||||
*.xlsx
|
||||
/*.csv
|
||||
/*.xlsx
|
||||
|
||||
# IDE files
|
||||
.vscode/
|
||||
@@ -32,3 +32,5 @@ Desktop.ini
|
||||
# Local configuration overrides
|
||||
settings/*.local.json
|
||||
settings/
|
||||
log/
|
||||
results/
|
||||
@@ -2,6 +2,23 @@
|
||||
import os
|
||||
|
||||
|
||||
def _gamut_refs_for_type(test_type):
|
||||
"""按测试类型返回需要导出的参考色域列表。"""
|
||||
if test_type == "sdr_movie":
|
||||
return ["BT.709", "DCI-P3", "BT.2020", "BT.601"]
|
||||
return ["BT.709", "DCI-P3", "BT.2020"]
|
||||
|
||||
|
||||
def _gamut_ref_var_attr(test_type):
|
||||
if test_type == "screen_module":
|
||||
return "screen_gamut_ref_var"
|
||||
if test_type == "sdr_movie":
|
||||
return "sdr_gamut_ref_var"
|
||||
if test_type == "hdr_movie":
|
||||
return "hdr_gamut_ref_var"
|
||||
return None
|
||||
|
||||
|
||||
# (item_key, fig_attr, filename, allowed_test_types_or_None, default_bbox_inches_auto)
|
||||
IMAGE_SPECS = [
|
||||
("gamut", "gamut_fig", "色域测试结果.png", None, True),
|
||||
@@ -16,7 +33,7 @@ IMAGE_SPECS = [
|
||||
|
||||
|
||||
def save_result_images(result_dir, current_test_type, selected_items,
|
||||
figure_provider, log):
|
||||
figure_provider, log, app=None):
|
||||
"""根据测试类型和已选项将各测试图表保存为 PNG。
|
||||
|
||||
figure_provider: callable(attr_name) -> matplotlib Figure 或 None
|
||||
@@ -27,6 +44,39 @@ def save_result_images(result_dir, current_test_type, selected_items,
|
||||
continue
|
||||
if allowed_types is not None and current_test_type not in allowed_types:
|
||||
continue
|
||||
|
||||
# 色域图:按所有可切换参考状态逐个重绘并保存。
|
||||
if item_key == "gamut" and app is not None:
|
||||
var_attr = _gamut_ref_var_attr(current_test_type)
|
||||
ref_var = getattr(app, var_attr, None) if var_attr else None
|
||||
rgb_data = None
|
||||
coverage = 0.0
|
||||
|
||||
if hasattr(app, "results") and app.results:
|
||||
rgb_data = app.results.get_intermediate_data("gamut", "rgb")
|
||||
try:
|
||||
coverage = app.results.test_items["gamut"].final_result.get("coverage", 0.0)
|
||||
except Exception:
|
||||
coverage = 0.0
|
||||
|
||||
if ref_var is not None and rgb_data and len(rgb_data) >= 3 and hasattr(app, "plot_gamut"):
|
||||
original_ref = ref_var.get()
|
||||
try:
|
||||
for ref in _gamut_refs_for_type(current_test_type):
|
||||
ref_var.set(ref)
|
||||
app.plot_gamut(rgb_data, coverage, current_test_type)
|
||||
fig = figure_provider(fig_attr)
|
||||
if fig is None:
|
||||
continue
|
||||
per_ref_name = f"色域测试结果_{ref}.png"
|
||||
path = os.path.join(result_dir, per_ref_name)
|
||||
fig.savefig(path, dpi=300)
|
||||
log(f"已保存: {per_ref_name}")
|
||||
finally:
|
||||
ref_var.set(original_ref)
|
||||
app.plot_gamut(rgb_data, coverage, current_test_type)
|
||||
continue
|
||||
|
||||
fig = figure_provider(fig_attr)
|
||||
if fig is None:
|
||||
continue
|
||||
|
||||
122
app/logging_setup.py
Normal file
122
app/logging_setup.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
|
||||
# stdlib 级别 → PQLogGUI 标签
|
||||
_LEVEL_TO_GUI = {
|
||||
logging.DEBUG: "debug",
|
||||
logging.INFO: "info",
|
||||
logging.WARNING: "warning",
|
||||
logging.ERROR: "error",
|
||||
logging.CRITICAL: "error",
|
||||
}
|
||||
|
||||
# PQLogGUI 标签 → stdlib 级别
|
||||
GUI_LEVEL_TO_LOG = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"success": logging.INFO,
|
||||
"warning": logging.WARNING,
|
||||
"error": logging.ERROR,
|
||||
"separator": logging.INFO,
|
||||
"blank": logging.DEBUG,
|
||||
}
|
||||
|
||||
# 用于在 LogRecord 上做 GUI 来源标记,避免回环
|
||||
_FROM_GUI_FLAG = "_from_gui"
|
||||
|
||||
# 文件日志:头一行元信息,第二行正文,记录之间空行隔开,方便阅读
|
||||
_FILE_LOG_FORMAT = "%(asctime)s [%(levelname)s] %(name)s\n%(message)s\n"
|
||||
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
|
||||
|
||||
_initialized = False
|
||||
|
||||
|
||||
def _resolve_log_dir(log_dir: Optional[str]) -> str:
|
||||
if log_dir:
|
||||
return log_dir
|
||||
# 默认放在工作目录下的 log/,方便用户直接打开查看
|
||||
return os.path.join(os.getcwd(), "log")
|
||||
|
||||
|
||||
def setup_logging(
|
||||
level: int = logging.INFO,
|
||||
log_dir: Optional[str] = None,
|
||||
) -> str:
|
||||
"""配置全局 logging,返回日志目录。可重复调用,第二次起为空操作。
|
||||
|
||||
- 不再向终端输出,避免污染控制台。
|
||||
- 日志文件按日期命名:``log/YYYY-MM-DD.log``。
|
||||
"""
|
||||
global _initialized
|
||||
root = logging.getLogger()
|
||||
if _initialized:
|
||||
return _resolve_log_dir(log_dir)
|
||||
|
||||
resolved = _resolve_log_dir(log_dir)
|
||||
os.makedirs(resolved, exist_ok=True)
|
||||
|
||||
file_formatter = logging.Formatter(_FILE_LOG_FORMAT, datefmt=_DATE_FORMAT)
|
||||
|
||||
root.setLevel(level)
|
||||
|
||||
# 移除既有 handler,避免重复 / basicConfig 默认控制台输出
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
log_file = os.path.join(resolved, f"{today}.log")
|
||||
file_handler = logging.FileHandler(log_file, encoding="utf-8", delay=True)
|
||||
file_handler.setLevel(logging.DEBUG)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
root.addHandler(file_handler)
|
||||
|
||||
_initialized = True
|
||||
logging.getLogger(__name__).info("日志系统初始化完成 dir=%s", resolved)
|
||||
return resolved
|
||||
|
||||
|
||||
class TkLogHandler(logging.Handler):
|
||||
"""把 stdlib logging 的记录转发到 ``PQLogGUI`` 面板。
|
||||
|
||||
带 ``_from_gui`` 标记的记录会被忽略,避免 GUI → file → GUI 回环。
|
||||
"""
|
||||
|
||||
def __init__(self, log_gui):
|
||||
super().__init__()
|
||||
self._log_gui = log_gui
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None: # noqa: D401
|
||||
if getattr(record, _FROM_GUI_FLAG, False):
|
||||
return
|
||||
try:
|
||||
message = self.format(record)
|
||||
except Exception:
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception:
|
||||
return
|
||||
gui_level = _LEVEL_TO_GUI.get(record.levelno, "info")
|
||||
try:
|
||||
# PQLogGUI.log 内部已处理跨线程
|
||||
self._log_gui.log(message, level=gui_level, _from_logging=True)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
|
||||
def attach_gui_handler(log_gui) -> TkLogHandler:
|
||||
"""把 ``PQLogGUI`` 注册为 root logger 的 handler。已存在则替换。"""
|
||||
root = logging.getLogger()
|
||||
# 移除旧的 TkLogHandler,保证只挂一个
|
||||
for h in list(root.handlers):
|
||||
if isinstance(h, TkLogHandler):
|
||||
root.removeHandler(h)
|
||||
handler = TkLogHandler(log_gui)
|
||||
handler.setLevel(logging.INFO)
|
||||
handler.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
||||
root.addHandler(handler)
|
||||
return handler
|
||||
165
app/plots/gamut_background.py
Normal file
165
app/plots/gamut_background.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""CIE 色度图底图渲染与缓存。
|
||||
|
||||
将"重型图像渲染"(colour-science 的谱迹颜色填充)与"轻量框架数据层"
|
||||
(参考/实测三角形、标签、覆盖率)解耦。
|
||||
|
||||
底图:
|
||||
- 仅在首次调用或缓存失效时通过 colour-science 渲染一次;
|
||||
- 渲染结果保存为 numpy RGBA 数组,同时落盘到 settings/cache/,
|
||||
下次启动直接 imread 加载,避免重新跑色彩科学计算。
|
||||
|
||||
调用方在每次绘图时只需 `ax.imshow(bg, extent=bbox)`,再叠加自己的矢量层。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import threading
|
||||
from typing import Tuple
|
||||
|
||||
import numpy as np
|
||||
|
||||
# 谱迹底图分辨率(边长,单位像素)。1024 对于 14 inch 画布足够细腻,
|
||||
# 文件大小 ~1-2MB,单次渲染 ~0.5-1 s,缓存后毫秒级加载。
|
||||
_DIAGRAM_RES = 1024
|
||||
|
||||
# 缓存版本号:当渲染参数或风格调整时递增,强制重新生成。
|
||||
_CACHE_VERSION = "v1"
|
||||
|
||||
_BBox = Tuple[float, float, float, float] # (xmin, xmax, ymin, ymax)
|
||||
|
||||
_CIE1931_BBOX: _BBox = (0.0, 0.8, 0.0, 0.9)
|
||||
_CIE1976_BBOX: _BBox = (0.0, 0.65, 0.0, 0.6)
|
||||
|
||||
|
||||
_memory_cache: dict[str, np.ndarray] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _cache_dir() -> str:
|
||||
# 项目根目录通过本文件位置反推:app/plots/ -> 项目根
|
||||
here = os.path.dirname(os.path.abspath(__file__))
|
||||
root = os.path.abspath(os.path.join(here, "..", ".."))
|
||||
d = os.path.join(root, "settings", "cache")
|
||||
os.makedirs(d, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def _cache_key(kind: str, bbox: _BBox) -> str:
|
||||
sig = f"{kind}|{bbox}|{_DIAGRAM_RES}|{_CACHE_VERSION}"
|
||||
h = hashlib.md5(sig.encode("utf-8")).hexdigest()[:10]
|
||||
return f"chromaticity_{kind}_{h}.npy"
|
||||
|
||||
|
||||
def _cache_path(kind: str, bbox: _BBox) -> str:
|
||||
return os.path.join(_cache_dir(), _cache_key(kind, bbox))
|
||||
|
||||
|
||||
def _render_chromaticity(kind: str, bbox: _BBox) -> np.ndarray:
|
||||
"""通过 colour-science 离屏渲染谱迹底图,返回 RGBA float 数组。"""
|
||||
# 延迟导入:仅在缓存未命中时支付 colour.plotting 的加载开销。
|
||||
import matplotlib
|
||||
prev_backend = matplotlib.get_backend()
|
||||
try:
|
||||
matplotlib.use("Agg", force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
import matplotlib.pyplot as plt
|
||||
from colour.plotting import (
|
||||
plot_chromaticity_diagram_CIE1931,
|
||||
plot_chromaticity_diagram_CIE1976UCS,
|
||||
)
|
||||
|
||||
xmin, xmax, ymin, ymax = bbox
|
||||
aspect = (xmax - xmin) / (ymax - ymin)
|
||||
height = _DIAGRAM_RES
|
||||
width = int(round(height * aspect))
|
||||
|
||||
fig = plt.figure(figsize=(width / 100.0, height / 100.0), dpi=100)
|
||||
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0])
|
||||
|
||||
if kind == "cie1931":
|
||||
plot_chromaticity_diagram_CIE1931(
|
||||
axes=ax, show=False, title=False,
|
||||
tight_layout=False, transparent_background=True,
|
||||
bounding_box=bbox,
|
||||
)
|
||||
elif kind == "cie1976":
|
||||
plot_chromaticity_diagram_CIE1976UCS(
|
||||
axes=ax, show=False, title=False,
|
||||
tight_layout=False, transparent_background=True,
|
||||
bounding_box=bbox,
|
||||
)
|
||||
else:
|
||||
plt.close(fig)
|
||||
raise ValueError(f"unknown diagram kind: {kind!r}")
|
||||
|
||||
ax.set_xlim(xmin, xmax)
|
||||
ax.set_ylim(ymin, ymax)
|
||||
ax.set_axis_off()
|
||||
ax.set_position([0.0, 0.0, 1.0, 1.0])
|
||||
|
||||
fig.canvas.draw()
|
||||
# 从 canvas 抓取 RGBA 数组
|
||||
buf = np.asarray(fig.canvas.buffer_rgba()).copy()
|
||||
plt.close(fig)
|
||||
|
||||
try:
|
||||
matplotlib.use(prev_backend, force=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return buf
|
||||
|
||||
|
||||
def _load_or_render(kind: str, bbox: _BBox) -> np.ndarray:
|
||||
key = _cache_key(kind, bbox)
|
||||
with _lock:
|
||||
if key in _memory_cache:
|
||||
return _memory_cache[key]
|
||||
|
||||
disk = _cache_path(kind, bbox)
|
||||
if os.path.isfile(disk):
|
||||
try:
|
||||
arr = np.load(disk)
|
||||
_memory_cache[key] = arr
|
||||
return arr
|
||||
except Exception:
|
||||
# 缓存损坏则重新渲染
|
||||
try:
|
||||
os.remove(disk)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
arr = _render_chromaticity(kind, bbox)
|
||||
_memory_cache[key] = arr
|
||||
try:
|
||||
np.save(disk, arr)
|
||||
except Exception:
|
||||
pass
|
||||
return arr
|
||||
|
||||
|
||||
def get_cie1931_background() -> Tuple[np.ndarray, _BBox]:
|
||||
"""返回 (RGBA 数组, bbox),可直接 ax.imshow(arr, extent=[*bbox])。"""
|
||||
return _load_or_render("cie1931", _CIE1931_BBOX), _CIE1931_BBOX
|
||||
|
||||
|
||||
def get_cie1976_background() -> Tuple[np.ndarray, _BBox]:
|
||||
return _load_or_render("cie1976", _CIE1976_BBOX), _CIE1976_BBOX
|
||||
|
||||
|
||||
def clear_cache(*, disk: bool = False) -> None:
|
||||
"""清空内存缓存(可选连同磁盘)。供调试/样式调整时使用。"""
|
||||
with _lock:
|
||||
_memory_cache.clear()
|
||||
if disk:
|
||||
d = _cache_dir()
|
||||
for name in os.listdir(d):
|
||||
if name.startswith("chromaticity_") and name.endswith(".npy"):
|
||||
try:
|
||||
os.remove(os.path.join(d, name))
|
||||
except OSError:
|
||||
pass
|
||||
@@ -1,34 +1,208 @@
|
||||
"""色域图(Gamut)绘制。
|
||||
"""色域图(Gamut)绘制 - Calman 风格。
|
||||
|
||||
Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamut 整体搬迁,
|
||||
实现与原方法完全一致;原方法仅保留为一行转发。
|
||||
架构:**图像渲染层** 与 **基础数据/框架层** 分离
|
||||
------------------------------------------------
|
||||
- 图像渲染层(重):CIE 1931 / 1976 谱迹色域底图。
|
||||
由 `app.plots.gamut_background` 通过 colour-science 离屏渲染一次,
|
||||
结果以 numpy RGBA 数组缓存在内存与磁盘(settings/cache/),后续直接
|
||||
`ax.imshow(bg, extent=bbox)` 复用 → 主线程绘制开销可忽略。
|
||||
- 基础数据/框架层(轻):参考色域三角形、实测色域三角形、顶点标签、
|
||||
覆盖率信息框等矢量元素,每次绘制都在真实色度坐标系上重画。
|
||||
|
||||
视觉风格:参照 Calman colorspace 显示:
|
||||
- 当前选中的参考标准:亮色实线 + 顶点空心方框;
|
||||
- 其他参考标准:半透明虚线(便于对比,不喧宾夺主);
|
||||
- 实测色域:红色粗边 + 淡红填充 + 顶点圆点 + 浮动坐标标签;
|
||||
- 右下角白底红字覆盖率信息框。
|
||||
"""
|
||||
|
||||
import matplotlib.image as mpimg
|
||||
from matplotlib.patches import Polygon
|
||||
import numpy as np
|
||||
from matplotlib.patches import PathPatch
|
||||
from matplotlib.path import Path
|
||||
|
||||
import algorithm.pq_algorithm as pq_algorithm
|
||||
from app.resources import get_resource_path
|
||||
from app.plots.gamut_background import (
|
||||
get_cie1931_background,
|
||||
get_cie1976_background,
|
||||
)
|
||||
|
||||
|
||||
# ============ 参考色域定义(CIE 1931 xy)============
|
||||
_REF_GAMUTS_XY = {
|
||||
"BT.601": [(0.6300, 0.3400), (0.3100, 0.5950), (0.1550, 0.0700)],
|
||||
"BT.709": [(0.6400, 0.3300), (0.3000, 0.6000), (0.1500, 0.0600)],
|
||||
"DCI-P3": [(0.6800, 0.3200), (0.2650, 0.6900), (0.1500, 0.0600)],
|
||||
"BT.2020": [(0.7080, 0.2920), (0.1700, 0.7970), (0.1310, 0.0460)],
|
||||
}
|
||||
|
||||
_REF_COLORS = {
|
||||
"BT.601": "#FBD985",
|
||||
"BT.709": "#FFFFFF",
|
||||
"DCI-P3": "#6AA2F7",
|
||||
"BT.2020": "#73FC9C",
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 坐标转换
|
||||
# ============================================================
|
||||
|
||||
def _xy_to_uv(x, y):
|
||||
"""CIE 1931 xy → CIE 1976 u'v'"""
|
||||
denom = -2.0 * x + 12.0 * y + 3.0
|
||||
if abs(denom) < 1e-10:
|
||||
return 0.0, 0.0
|
||||
return (4.0 * x) / denom, (9.0 * y) / denom
|
||||
|
||||
|
||||
def _ref_gamut_uv(name):
|
||||
return [_xy_to_uv(x, y) for x, y in _REF_GAMUTS_XY[name]]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 数据/框架层绘制原语
|
||||
# ============================================================
|
||||
|
||||
def _draw_reference_triangle(ax, vertices, color, *, is_current, label):
|
||||
"""参考色域三角形"""
|
||||
xs = [p[0] for p in vertices] + [vertices[0][0]]
|
||||
ys = [p[1] for p in vertices] + [vertices[0][1]]
|
||||
|
||||
if is_current:
|
||||
poly = Polygon(
|
||||
vertices, closed=True,
|
||||
facecolor=(1, 1, 1, 0.18), # 半透明白
|
||||
edgecolor=color, linewidth=2.2, zorder=8,
|
||||
)
|
||||
ax.add_patch(poly)
|
||||
ax.plot(
|
||||
xs, ys,
|
||||
color=color, linewidth=2.2, linestyle="-",
|
||||
label=label, zorder=9,
|
||||
)
|
||||
ax.scatter(
|
||||
xs[:-1], ys[:-1],
|
||||
s=60, facecolors="none", edgecolors=color,
|
||||
linewidths=2, marker="s", zorder=10,
|
||||
)
|
||||
else:
|
||||
ax.plot(
|
||||
xs, ys,
|
||||
color=color, linewidth=1.2, linestyle="--",
|
||||
alpha=0.55, label=label, zorder=5,
|
||||
)
|
||||
|
||||
|
||||
def _draw_measured_triangle(ax, vertices, *, uv_space=False):
|
||||
xs = [p[0] for p in vertices] + [vertices[0][0]]
|
||||
ys = [p[1] for p in vertices] + [vertices[0][1]]
|
||||
|
||||
# 半透明红色填充
|
||||
poly = Polygon(
|
||||
vertices, closed=True,
|
||||
facecolor=(1.0, 0.1, 0.1, 0),
|
||||
edgecolor="#FF2A2A",
|
||||
linewidth=2.8, zorder=12,
|
||||
joinstyle="round"
|
||||
)
|
||||
ax.add_patch(poly)
|
||||
|
||||
# 顶点(白边红心)
|
||||
ax.scatter(
|
||||
xs[:-1], ys[:-1],
|
||||
s=60, facecolors="#FF2A2A",
|
||||
edgecolors="white", linewidths=1.5,
|
||||
marker="o", zorder=13,
|
||||
)
|
||||
# for (x, y), name in zip(vertices, label_prefix):
|
||||
# dx, dy = x - cx, y - cy
|
||||
# norm = max(1e-6, (dx * dx + dy * dy) ** 0.5)
|
||||
# ox = dx / norm * offset_pix
|
||||
# oy = dy / norm * offset_pix
|
||||
|
||||
# ax.annotate(
|
||||
# f"{name} ({x:.3f}, {y:.3f})",
|
||||
# xy=(x, y),
|
||||
# xytext=(ox, oy),
|
||||
# textcoords="offset points",
|
||||
# fontsize=8.5, color="white", fontweight="bold",
|
||||
# ha="center", va="center",
|
||||
# bbox=dict(
|
||||
# boxstyle="round,pad=0.35",
|
||||
# facecolor="#D81B1B",
|
||||
# edgecolor="white",
|
||||
# linewidth=1.2,
|
||||
# alpha=0.95,
|
||||
# ),
|
||||
# arrowprops=dict(
|
||||
# arrowstyle="-", color="#FF1F1F", lw=1.2, alpha=0.9,
|
||||
# ),
|
||||
# zorder=12,
|
||||
# clip_on=False,
|
||||
# )
|
||||
|
||||
|
||||
def _draw_coverage_box(ax, x_pos, y_pos, current_ref, coverage):
|
||||
ax.text(
|
||||
x_pos, y_pos,
|
||||
f"{current_ref}\n覆盖率: {coverage:.1f}%",
|
||||
ha="right", va="bottom",
|
||||
fontsize=11, fontweight="bold",
|
||||
color="#FFF",
|
||||
bbox=dict(
|
||||
boxstyle="round,pad=0.38",
|
||||
facecolor="#111",
|
||||
edgecolor="#FFF",
|
||||
linewidth=1.7,
|
||||
alpha=0.98,
|
||||
),
|
||||
zorder=30,
|
||||
)
|
||||
|
||||
def _style_axes(ax, *, title, xlabel, ylabel, xlim, ylim):
|
||||
ax.set_facecolor("#000")
|
||||
ax.set_title(title, fontsize=12, fontweight="bold", color="#FFF", pad=8)
|
||||
ax.set_xlabel(xlabel, fontsize=10, color="#FFF")
|
||||
ax.set_ylabel(ylabel, fontsize=10, color="#FFF")
|
||||
ax.set_xlim(*xlim)
|
||||
ax.set_ylim(*ylim)
|
||||
ax.set_aspect("equal", adjustable="datalim")
|
||||
ax.grid(True, linestyle=":", linewidth=0.7, color="#444", alpha=0.32)
|
||||
ax.tick_params(axis="both", labelsize=9, colors="#FFF")
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color("#888")
|
||||
spine.set_linewidth(0.8)
|
||||
ax.set_clip_on(False)
|
||||
|
||||
|
||||
def _blit_background(ax, background, bbox):
|
||||
"""渲染层贴底:将预渲染的谱迹底图贴到真实色度坐标。"""
|
||||
xmin, xmax, ymin, ymax = bbox
|
||||
ax.imshow(
|
||||
background,
|
||||
extent=(xmin, xmax, ymin, ymax),
|
||||
origin="upper", # canvas.buffer_rgba 行 0 为顶部
|
||||
interpolation="bicubic",
|
||||
zorder=0,
|
||||
aspect="auto", # 由 _style_axes 的 set_aspect("equal") 控制
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# 主入口
|
||||
# ============================================================
|
||||
|
||||
def plot_gamut(self, results, coverage, test_type):
|
||||
"""绘制色域图 - 根据用户选择的参考标准动态计算覆盖率"""
|
||||
# 实现从原 PQAutomationApp 方法体原样搬迁,为减少修改面
|
||||
# 范围、保持行为一致,给 self 赋值为传入的 app 实例。
|
||||
"""绘制色域图(图像层 + 框架层分离架构)。"""
|
||||
|
||||
self.gamut_ax_xy.clear()
|
||||
self.gamut_ax_uv.clear()
|
||||
ax_xy = self.gamut_ax_xy
|
||||
ax_uv = self.gamut_ax_uv
|
||||
|
||||
# ==================== XY 图校准参数 ====================
|
||||
XY_ORIGIN_X = 20.55
|
||||
XY_ORIGIN_Y = 378.00
|
||||
XY_PIXELS_PER_X = 510.6818
|
||||
XY_PIXELS_PER_Y = 429.8844
|
||||
|
||||
# ==================== UV 图校准参数 ====================
|
||||
UV_ORIGIN_U = 26.91
|
||||
UV_ORIGIN_V = 377.16
|
||||
UV_PIXELS_PER_U = 615.7260
|
||||
UV_PIXELS_PER_V = 599.8432
|
||||
ax_xy.clear()
|
||||
ax_uv.clear()
|
||||
# 全局黑色背景
|
||||
self.gamut_fig.patch.set_facecolor("#000")
|
||||
|
||||
# ========== 读取用户选择的参考标准 ==========
|
||||
if test_type == "screen_module":
|
||||
@@ -40,499 +214,197 @@ def plot_gamut(self, results, coverage, test_type):
|
||||
else:
|
||||
current_ref = "DCI-P3"
|
||||
|
||||
# ========== ✅✅根据参考标准重新计算覆盖率(XY 空间)==========
|
||||
xy_coverage = coverage # 默认使用传入的值
|
||||
if current_ref not in _REF_GAMUTS_XY:
|
||||
self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error")
|
||||
current_ref = "DCI-P3"
|
||||
|
||||
# ========== 重新计算 xy 覆盖率 ==========
|
||||
xy_coverage = coverage
|
||||
uv_coverage = 0.0
|
||||
measured_xy = None
|
||||
|
||||
try:
|
||||
# 提取前 3 个 RGB 点的 xy 坐标
|
||||
if len(results) >= 3:
|
||||
xy_points = [[result[0], result[1]] for result in results[:3]]
|
||||
|
||||
# 根据参考标准计算 XY 覆盖率
|
||||
if len(results) >= 3:
|
||||
measured_xy = [(float(r[0]), float(r[1])) for r in results[:3]]
|
||||
try:
|
||||
if current_ref == "BT.2020":
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020(
|
||||
xy_points
|
||||
)
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT2020(measured_xy)
|
||||
elif current_ref == "BT.709":
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709(
|
||||
xy_points
|
||||
)
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT709(measured_xy)
|
||||
elif current_ref == "DCI-P3":
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(
|
||||
xy_points
|
||||
)
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(measured_xy)
|
||||
elif current_ref == "BT.601":
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601(
|
||||
xy_points
|
||||
)
|
||||
else:
|
||||
self.log_gui.log(f"未知参考标准 '{current_ref}',使用 DCI-P3", level="error")
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_DCIP3(
|
||||
xy_points
|
||||
)
|
||||
current_ref = "DCI-P3"
|
||||
|
||||
_, xy_coverage = pq_algorithm.calculate_gamut_coverage_BT601(measured_xy)
|
||||
self.log_gui.log(
|
||||
f"XY 空间覆盖率({current_ref}): {xy_coverage:.1f}%"
|
||||
, level="success")
|
||||
f"XY 空间覆盖率({current_ref}): {xy_coverage:.1f}%", level="success"
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error")
|
||||
xy_coverage = coverage
|
||||
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"重新计算 XY 覆盖率失败: {str(e)}", level="error")
|
||||
xy_coverage = coverage # 回退到传入值
|
||||
# =================================================
|
||||
# 需要叠加的次要参考色域
|
||||
other_refs = [
|
||||
r for r in _REF_GAMUTS_XY.keys()
|
||||
if r != current_ref
|
||||
and (r != "BT.601" or test_type == "sdr_movie")
|
||||
]
|
||||
|
||||
# ========== 左图:CIE 1931 xy ==========
|
||||
# ============================================================
|
||||
# 左图:CIE 1931 xy
|
||||
# ============================================================
|
||||
try:
|
||||
img_xy = mpimg.imread(get_resource_path("assets/cie.png"))
|
||||
h_xy, w_xy = img_xy.shape[:2]
|
||||
bg_xy, bbox_xy = get_cie1931_background()
|
||||
_blit_background(ax_xy, bg_xy, bbox_xy)
|
||||
_style_axes(
|
||||
ax_xy,
|
||||
title="CIE 1931 xy",
|
||||
xlabel="x", ylabel="y",
|
||||
xlim=(bbox_xy[0], bbox_xy[1]),
|
||||
ylim=(bbox_xy[2], bbox_xy[3]),
|
||||
)
|
||||
|
||||
self.log_gui.log(f"加载 XY 色域图: {w_xy}x{h_xy}", level="info")
|
||||
|
||||
self.gamut_ax_xy.imshow(img_xy, extent=[0, w_xy, h_xy, 0], aspect="equal")
|
||||
self.gamut_ax_xy.set_xlim(0, w_xy)
|
||||
self.gamut_ax_xy.set_ylim(h_xy, 0)
|
||||
self.gamut_ax_xy.axis("off")
|
||||
self.gamut_ax_xy.set_clip_on(False)
|
||||
|
||||
def cie_xy_to_pixel(x, y):
|
||||
"""CIE xy → 像素坐标"""
|
||||
px = XY_ORIGIN_X + x * XY_PIXELS_PER_X
|
||||
py = XY_ORIGIN_Y - y * XY_PIXELS_PER_Y
|
||||
return px, py
|
||||
|
||||
if len(results) >= 3:
|
||||
red_x, red_y = results[0][0], results[0][1]
|
||||
green_x, green_y = results[1][0], results[1][1]
|
||||
blue_x, blue_y = results[2][0], results[2][1]
|
||||
for ref_name in other_refs:
|
||||
_draw_reference_triangle(
|
||||
ax_xy, _REF_GAMUTS_XY[ref_name],
|
||||
_REF_COLORS[ref_name],
|
||||
is_current=False, label=ref_name,
|
||||
)
|
||||
_draw_reference_triangle(
|
||||
ax_xy, _REF_GAMUTS_XY[current_ref],
|
||||
_REF_COLORS[current_ref],
|
||||
is_current=True, label=f"{current_ref} (参考)",
|
||||
)
|
||||
|
||||
if measured_xy is not None:
|
||||
r_xy, g_xy, b_xy = measured_xy
|
||||
self.log_gui.log(
|
||||
f"测量色域: R({red_x:.4f},{red_y:.4f}) "
|
||||
f"G({green_x:.4f},{green_y:.4f}) B({blue_x:.4f},{blue_y:.4f})"
|
||||
, level="info")
|
||||
|
||||
# ========== 绘制测量三角形 ==========
|
||||
points = [
|
||||
cie_xy_to_pixel(red_x, red_y),
|
||||
cie_xy_to_pixel(green_x, green_y),
|
||||
cie_xy_to_pixel(blue_x, blue_y),
|
||||
cie_xy_to_pixel(red_x, red_y),
|
||||
]
|
||||
|
||||
xs = [p[0] for p in points]
|
||||
ys = [p[1] for p in points]
|
||||
|
||||
self.gamut_ax_xy.plot(
|
||||
xs,
|
||||
ys,
|
||||
color="red",
|
||||
linewidth=2.5,
|
||||
marker="o",
|
||||
markersize=10,
|
||||
markerfacecolor="red",
|
||||
markeredgecolor="white",
|
||||
markeredgewidth=2,
|
||||
label="测量色域",
|
||||
zorder=10,
|
||||
f"测量色域: R({r_xy[0]:.4f},{r_xy[1]:.4f}) "
|
||||
f"G({g_xy[0]:.4f},{g_xy[1]:.4f}) B({b_xy[0]:.4f},{b_xy[1]:.4f})",
|
||||
level="info",
|
||||
)
|
||||
_draw_measured_triangle(ax_xy, measured_xy, uv_space=False)
|
||||
|
||||
# ========== 标注 RGB 点 ==========
|
||||
labels = ["R", "G", "B"]
|
||||
coords = [(red_x, red_y), (green_x, green_y), (blue_x, blue_y)]
|
||||
_draw_coverage_box(
|
||||
ax_xy, bbox_xy[1] - 0.02, bbox_xy[2] + 0.02, current_ref, xy_coverage
|
||||
)
|
||||
|
||||
for (x_cie, y_cie), label in zip(coords, labels):
|
||||
px, py = cie_xy_to_pixel(x_cie, y_cie)
|
||||
# 暗化三角形外部区域(黑色半透明遮罩)
|
||||
x0, x1 = bbox_xy[0], bbox_xy[1]
|
||||
y0, y1 = bbox_xy[2], bbox_xy[3]
|
||||
# 多边形路径:外框+三角形
|
||||
verts = [
|
||||
(x0, y0), (x0, y1), (x1, y1), (x1, y0), (x0, y0),
|
||||
*_REF_GAMUTS_XY[current_ref], _REF_GAMUTS_XY[current_ref][0]
|
||||
]
|
||||
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
|
||||
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
|
||||
path = Path(verts, codes)
|
||||
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
|
||||
ax_xy.add_patch(patch)
|
||||
|
||||
# 自适应偏移
|
||||
if label == "R":
|
||||
offset = (-60, -40) if x_cie > 0.6 else (0, -60)
|
||||
elif label == "G":
|
||||
offset = (0, -60)
|
||||
else: # B
|
||||
offset = (60, 40)
|
||||
|
||||
self.gamut_ax_xy.annotate(
|
||||
f"{label}\n({x_cie:.3f},{y_cie:.3f})",
|
||||
xy=(px, py),
|
||||
xytext=offset,
|
||||
textcoords="offset points",
|
||||
fontsize=9,
|
||||
color="white",
|
||||
fontweight="bold",
|
||||
bbox=dict(
|
||||
boxstyle="round,pad=0.5",
|
||||
facecolor="red",
|
||||
alpha=0.9,
|
||||
edgecolor="white",
|
||||
linewidth=2,
|
||||
),
|
||||
arrowprops=dict(arrowstyle="->", color="red", lw=2),
|
||||
zorder=11,
|
||||
clip_on=False,
|
||||
)
|
||||
|
||||
# ========== 绘制所有参考标准 ==========
|
||||
# DCI-P3
|
||||
dcip3 = [
|
||||
(0.6800, 0.3200),
|
||||
(0.2650, 0.6900),
|
||||
(0.1500, 0.0600),
|
||||
(0.6800, 0.3200),
|
||||
]
|
||||
dcip3_px = [cie_xy_to_pixel(x, y) for x, y in dcip3]
|
||||
self.gamut_ax_xy.plot(
|
||||
[p[0] for p in dcip3_px],
|
||||
[p[1] for p in dcip3_px],
|
||||
color="blue",
|
||||
linewidth=1.5,
|
||||
linestyle="--",
|
||||
marker="s",
|
||||
markersize=6,
|
||||
alpha=0.7,
|
||||
label="DCI-P3",
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
# BT.2020
|
||||
bt2020 = [
|
||||
(0.7080, 0.2920),
|
||||
(0.1700, 0.7970),
|
||||
(0.1310, 0.0460),
|
||||
(0.7080, 0.2920),
|
||||
]
|
||||
bt2020_px = [cie_xy_to_pixel(x, y) for x, y in bt2020]
|
||||
self.gamut_ax_xy.plot(
|
||||
[p[0] for p in bt2020_px],
|
||||
[p[1] for p in bt2020_px],
|
||||
color="green",
|
||||
linewidth=1.5,
|
||||
linestyle="-.",
|
||||
marker="D",
|
||||
markersize=5,
|
||||
alpha=0.7,
|
||||
label="BT.2020",
|
||||
zorder=4,
|
||||
)
|
||||
|
||||
# BT.709
|
||||
bt709 = [
|
||||
(0.6400, 0.3300),
|
||||
(0.3000, 0.6000),
|
||||
(0.1500, 0.0600),
|
||||
(0.6400, 0.3300),
|
||||
]
|
||||
bt709_px = [cie_xy_to_pixel(x, y) for x, y in bt709]
|
||||
self.gamut_ax_xy.plot(
|
||||
[p[0] for p in bt709_px],
|
||||
[p[1] for p in bt709_px],
|
||||
color="gray",
|
||||
linewidth=1.2,
|
||||
linestyle=":",
|
||||
marker="^",
|
||||
markersize=5,
|
||||
alpha=0.6,
|
||||
label="BT.709",
|
||||
zorder=3,
|
||||
)
|
||||
|
||||
# BT.601(仅 SDR 测试)
|
||||
if test_type == "sdr_movie":
|
||||
bt601 = [
|
||||
(0.6300, 0.3400),
|
||||
(0.3100, 0.5950),
|
||||
(0.1550, 0.0700),
|
||||
(0.6300, 0.3400),
|
||||
]
|
||||
bt601_px = [cie_xy_to_pixel(x, y) for x, y in bt601]
|
||||
self.gamut_ax_xy.plot(
|
||||
[p[0] for p in bt601_px],
|
||||
[p[1] for p in bt601_px],
|
||||
color="purple",
|
||||
linewidth=1.2,
|
||||
linestyle="-",
|
||||
marker="o",
|
||||
markersize=5,
|
||||
alpha=0.6,
|
||||
label="BT.601",
|
||||
zorder=3,
|
||||
)
|
||||
|
||||
# ========== XY 覆盖率标注(使用重新计算的值)==========
|
||||
self.gamut_ax_xy.text(
|
||||
w_xy * 0.85,
|
||||
h_xy * 0.92,
|
||||
f"参考: {current_ref}\n覆盖率: {xy_coverage:.1f}%",
|
||||
ha="right",
|
||||
va="bottom",
|
||||
fontsize=11,
|
||||
fontweight="bold",
|
||||
color="red",
|
||||
bbox=dict(
|
||||
boxstyle="round,pad=0.5",
|
||||
facecolor="white",
|
||||
alpha=0.95,
|
||||
edgecolor="red",
|
||||
linewidth=2,
|
||||
),
|
||||
zorder=12,
|
||||
)
|
||||
|
||||
# 图例
|
||||
self.gamut_ax_xy.legend(
|
||||
loc="upper right",
|
||||
fontsize=7,
|
||||
framealpha=0.95,
|
||||
edgecolor="black",
|
||||
fancybox=True,
|
||||
)
|
||||
legend = ax_xy.legend(
|
||||
loc="upper right", fontsize=8.5,
|
||||
framealpha=0.0, edgecolor="#000", fancybox=True,
|
||||
labelcolor="#FFF"
|
||||
)
|
||||
legend.set_zorder(200)
|
||||
legend.get_frame().set_facecolor("#000")
|
||||
legend.get_frame().set_alpha(0.5)
|
||||
legend.get_frame().set_edgecolor("#FFF")
|
||||
ax_xy.add_artist(legend)
|
||||
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"XY 图绘制失败: {str(e)}", level="error")
|
||||
import traceback
|
||||
|
||||
self.log_gui.log(traceback.format_exc(), level="error")
|
||||
|
||||
# ========== 右图:CIE 1976 u'v' ==========
|
||||
# ============================================================
|
||||
# 右图:CIE 1976 u'v'
|
||||
# ============================================================
|
||||
try:
|
||||
img_uv = mpimg.imread(get_resource_path("assets/cie_uv.png"))
|
||||
h_uv, w_uv = img_uv.shape[:2]
|
||||
bg_uv, bbox_uv = get_cie1976_background()
|
||||
_blit_background(ax_uv, bg_uv, bbox_uv)
|
||||
_style_axes(
|
||||
ax_uv,
|
||||
title="CIE 1976 u'v'",
|
||||
xlabel="u'", ylabel="v'",
|
||||
xlim=(bbox_uv[0], bbox_uv[1]),
|
||||
ylim=(bbox_uv[2], bbox_uv[3]),
|
||||
)
|
||||
|
||||
self.log_gui.log(f"加载 UV 色域图: {w_uv}x{h_uv}", level="info")
|
||||
|
||||
self.gamut_ax_uv.imshow(img_uv, extent=[0, w_uv, h_uv, 0], aspect="equal")
|
||||
self.gamut_ax_uv.set_xlim(0, w_uv)
|
||||
self.gamut_ax_uv.set_ylim(h_uv, 0)
|
||||
self.gamut_ax_uv.axis("off")
|
||||
self.gamut_ax_uv.set_clip_on(False)
|
||||
|
||||
def cie_uv_to_pixel(u, v):
|
||||
"""CIE u'v' → 像素坐标"""
|
||||
px = UV_ORIGIN_U + u * UV_PIXELS_PER_U
|
||||
py = UV_ORIGIN_V - v * UV_PIXELS_PER_V
|
||||
return px, py
|
||||
|
||||
if len(results) >= 3:
|
||||
# 只取前 3 个 RGB 点
|
||||
rgb_results = results[:3]
|
||||
|
||||
# 转换为 u'v' 坐标
|
||||
def xy_to_uv(x, y):
|
||||
"""xy → u'v' 转换"""
|
||||
denom = -2 * x + 12 * y + 3
|
||||
if abs(denom) < 1e-10:
|
||||
return 0, 0
|
||||
u = (4 * x) / denom
|
||||
v = (9 * y) / denom
|
||||
return u, v
|
||||
|
||||
uv_coords = [
|
||||
[u, v] for u, v in [xy_to_uv(r[0], r[1]) for r in rgb_results]
|
||||
]
|
||||
|
||||
self.log_gui.log(f"UV 坐标: {uv_coords}", level="info")
|
||||
|
||||
# ========== ✅✅计算 u'v' 覆盖率(使用参考标准)==========
|
||||
measured_uv = None
|
||||
if measured_xy is not None:
|
||||
measured_uv = [_xy_to_uv(x, y) for x, y in measured_xy]
|
||||
try:
|
||||
uv_coverage = pq_algorithm.calculate_uv_gamut_coverage(
|
||||
uv_coords, reference=current_ref
|
||||
[list(uv) for uv in measured_uv], reference=current_ref
|
||||
)
|
||||
self.log_gui.log(
|
||||
f"UV 空间覆盖率({current_ref}): {uv_coverage:.1f}%"
|
||||
, level="success")
|
||||
f"UV 空间覆盖率({current_ref}): {uv_coverage:.1f}%",
|
||||
level="success",
|
||||
)
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"计算 UV 覆盖率失败: {str(e)}", level="error")
|
||||
uv_coverage = 0.0
|
||||
# =================================================
|
||||
|
||||
# ========== 绘制测量三角形 ==========
|
||||
uv_coords_plot = uv_coords + [uv_coords[0]]
|
||||
points_uv = [cie_uv_to_pixel(u, v) for u, v in uv_coords_plot]
|
||||
xs_uv = [p[0] for p in points_uv]
|
||||
ys_uv = [p[1] for p in points_uv]
|
||||
|
||||
self.gamut_ax_uv.plot(
|
||||
xs_uv,
|
||||
ys_uv,
|
||||
color="red",
|
||||
linewidth=2.5,
|
||||
marker="o",
|
||||
markersize=10,
|
||||
markerfacecolor="red",
|
||||
markeredgecolor="white",
|
||||
markeredgewidth=2,
|
||||
label="测量色域",
|
||||
zorder=10,
|
||||
for ref_name in other_refs:
|
||||
_draw_reference_triangle(
|
||||
ax_uv, _ref_gamut_uv(ref_name),
|
||||
_REF_COLORS[ref_name],
|
||||
is_current=False, label=ref_name,
|
||||
)
|
||||
_draw_reference_triangle(
|
||||
ax_uv, _ref_gamut_uv(current_ref),
|
||||
_REF_COLORS[current_ref],
|
||||
is_current=True, label=f"{current_ref} (参考)",
|
||||
)
|
||||
|
||||
# ========== 标注 RGB 点 ==========
|
||||
labels = ["R", "G", "B"]
|
||||
for (u, v), label in zip(uv_coords, labels):
|
||||
px, py = cie_uv_to_pixel(u, v)
|
||||
if measured_uv is not None:
|
||||
_draw_measured_triangle(ax_uv, measured_uv, uv_space=True)
|
||||
|
||||
# 自适应偏移
|
||||
if label == "R":
|
||||
if u > 0.42 and v > 0.50:
|
||||
offset = (-70, 20)
|
||||
elif u > 0.45:
|
||||
offset = (30, 50)
|
||||
else:
|
||||
offset = (50, 45)
|
||||
elif label == "G":
|
||||
offset = (0, -60)
|
||||
else: # B
|
||||
offset = (60, 40)
|
||||
_draw_coverage_box(
|
||||
ax_uv, bbox_uv[1] - 0.015, bbox_uv[2] + 0.015, current_ref, uv_coverage
|
||||
)
|
||||
|
||||
self.gamut_ax_uv.annotate(
|
||||
f"{label}\n({u:.3f},{v:.3f})",
|
||||
xy=(px, py),
|
||||
xytext=offset,
|
||||
textcoords="offset points",
|
||||
fontsize=9,
|
||||
color="white",
|
||||
fontweight="bold",
|
||||
bbox=dict(
|
||||
boxstyle="round,pad=0.5",
|
||||
facecolor="red",
|
||||
alpha=0.9,
|
||||
edgecolor="white",
|
||||
linewidth=2,
|
||||
),
|
||||
arrowprops=dict(arrowstyle="->", color="red", lw=2),
|
||||
zorder=11,
|
||||
clip_on=False,
|
||||
)
|
||||
u0, u1 = bbox_uv[0], bbox_uv[1]
|
||||
v0, v1 = bbox_uv[2], bbox_uv[3]
|
||||
verts = [
|
||||
(u0, v0), (u0, v1), (u1, v1), (u1, v0), (u0, v0),
|
||||
*_ref_gamut_uv(current_ref), _ref_gamut_uv(current_ref)[0]
|
||||
]
|
||||
codes = [Path.MOVETO] + [Path.LINETO]*3 + [Path.CLOSEPOLY]
|
||||
codes += [Path.MOVETO] + [Path.LINETO]*2 + [Path.CLOSEPOLY]
|
||||
path = Path(verts, codes)
|
||||
patch = PathPatch(path, facecolor=(0,0,0,0.65), lw=0, zorder=7)
|
||||
ax_uv.add_patch(patch)
|
||||
|
||||
# ========== DCI-P3 参考(蓝色)==========
|
||||
dcip3_uv = [
|
||||
[0.4970, 0.5260],
|
||||
[0.0999, 0.5780],
|
||||
[0.1754, 0.1576],
|
||||
[0.4970, 0.5260],
|
||||
]
|
||||
dcip3_uv_px = [cie_uv_to_pixel(u, v) for u, v in dcip3_uv]
|
||||
|
||||
self.gamut_ax_uv.plot(
|
||||
[p[0] for p in dcip3_uv_px],
|
||||
[p[1] for p in dcip3_uv_px],
|
||||
color="blue",
|
||||
linewidth=1.5,
|
||||
linestyle="--",
|
||||
marker="s",
|
||||
markersize=6,
|
||||
alpha=0.7,
|
||||
label="DCI-P3",
|
||||
zorder=5,
|
||||
)
|
||||
|
||||
# ========== BT.2020 参考(绿色)==========
|
||||
bt2020_uv = [
|
||||
[0.5566, 0.5165],
|
||||
[0.0556, 0.5868],
|
||||
[0.1593, 0.1258],
|
||||
[0.5566, 0.5165],
|
||||
]
|
||||
bt2020_uv_px = [cie_uv_to_pixel(u, v) for u, v in bt2020_uv]
|
||||
|
||||
self.gamut_ax_uv.plot(
|
||||
[p[0] for p in bt2020_uv_px],
|
||||
[p[1] for p in bt2020_uv_px],
|
||||
color="green",
|
||||
linewidth=1.5,
|
||||
linestyle="-.",
|
||||
marker="D",
|
||||
markersize=5,
|
||||
alpha=0.7,
|
||||
label="BT.2020",
|
||||
zorder=4,
|
||||
)
|
||||
|
||||
# ========== BT.709 参考(灰色)==========
|
||||
bt709_uv = [
|
||||
[0.4507, 0.5229],
|
||||
[0.1250, 0.5625],
|
||||
[0.1754, 0.1576],
|
||||
[0.4507, 0.5229],
|
||||
]
|
||||
bt709_uv_px = [cie_uv_to_pixel(u, v) for u, v in bt709_uv]
|
||||
|
||||
self.gamut_ax_uv.plot(
|
||||
[p[0] for p in bt709_uv_px],
|
||||
[p[1] for p in bt709_uv_px],
|
||||
color="gray",
|
||||
linewidth=1.2,
|
||||
linestyle=":",
|
||||
marker="^",
|
||||
markersize=5,
|
||||
alpha=0.6,
|
||||
label="BT.709",
|
||||
zorder=3,
|
||||
)
|
||||
|
||||
# ========== BT.601 参考(紫色)- 仅 SDR 测试显示 ==========
|
||||
if test_type == "sdr_movie":
|
||||
bt601_uv = [
|
||||
[0.4510, 0.5236],
|
||||
[0.1291, 0.5606],
|
||||
[0.1787, 0.1610],
|
||||
[0.4510, 0.5236],
|
||||
]
|
||||
bt601_uv_px = [cie_uv_to_pixel(u, v) for u, v in bt601_uv]
|
||||
|
||||
self.gamut_ax_uv.plot(
|
||||
[p[0] for p in bt601_uv_px],
|
||||
[p[1] for p in bt601_uv_px],
|
||||
color="purple",
|
||||
linewidth=1.2,
|
||||
linestyle="-",
|
||||
marker="o",
|
||||
markersize=5,
|
||||
alpha=0.6,
|
||||
label="BT.601",
|
||||
zorder=3,
|
||||
)
|
||||
|
||||
# ========== UV 覆盖率标注(使用动态计算的值)==========
|
||||
self.gamut_ax_uv.text(
|
||||
w_uv * 0.85,
|
||||
h_uv * 0.92,
|
||||
f"参考: {current_ref}\n覆盖率: {uv_coverage:.1f}%",
|
||||
ha="right",
|
||||
va="bottom",
|
||||
fontsize=11,
|
||||
fontweight="bold",
|
||||
color="red",
|
||||
bbox=dict(
|
||||
boxstyle="round,pad=0.5",
|
||||
facecolor="white",
|
||||
alpha=0.95,
|
||||
edgecolor="red",
|
||||
linewidth=2,
|
||||
),
|
||||
zorder=12,
|
||||
)
|
||||
|
||||
# 图例
|
||||
self.gamut_ax_uv.legend(
|
||||
loc="upper right",
|
||||
fontsize=7,
|
||||
framealpha=0.95,
|
||||
edgecolor="black",
|
||||
fancybox=True,
|
||||
)
|
||||
legend_uv = ax_uv.legend(
|
||||
loc="upper right", fontsize=8.5,
|
||||
framealpha=0.0, edgecolor="#000", fancybox=True,
|
||||
labelcolor="#FFF"
|
||||
)
|
||||
legend_uv.set_zorder(200)
|
||||
legend_uv.get_frame().set_facecolor("#000")
|
||||
legend_uv.get_frame().set_alpha(0.72)
|
||||
legend_uv.get_frame().set_edgecolor("#FFF")
|
||||
ax_uv.add_artist(legend_uv)
|
||||
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"UV 图绘制失败: {str(e)}", level="error")
|
||||
import traceback
|
||||
|
||||
self.log_gui.log(traceback.format_exc(), level="error")
|
||||
|
||||
# ========== 总标题 ==========
|
||||
test_type_name = self.get_test_type_name(test_type)
|
||||
self.gamut_fig.suptitle(
|
||||
f"{test_type_name} - 色域测试", fontsize=12, y=0.98, fontweight="bold"
|
||||
f"{test_type_name} - 色域测试",
|
||||
fontsize=12, y=0.98, fontweight="bold",
|
||||
)
|
||||
|
||||
self.gamut_canvas.draw()
|
||||
self.chart_notebook.select(self.gamut_chart_frame)
|
||||
|
||||
# 同步工具栏按钮选中状态
|
||||
if hasattr(self, "sync_gamut_toolbar"):
|
||||
self.sync_gamut_toolbar()
|
||||
|
||||
self.log_gui.log("色域图绘制完成", level="success")
|
||||
|
||||
@@ -1,305 +1,246 @@
|
||||
# PQ自动化测试配置模块
|
||||
import json
|
||||
import copy
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Pattern 文件读写工具(统一存储格式:settings/patterns/{name}.json)
|
||||
# =============================================================================
|
||||
|
||||
_PATTERNS_DIR = Path("settings/patterns")
|
||||
|
||||
|
||||
def load_pattern_file(filepath) -> dict:
|
||||
"""
|
||||
从 JSON 文件加载 pattern 配置。
|
||||
|
||||
文件格式::
|
||||
|
||||
{
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": N,
|
||||
"pattern_params": [[R, G, B], ...]
|
||||
}
|
||||
"""
|
||||
with open(filepath, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_pattern_file(filepath, pattern: dict) -> None:
|
||||
"""将 pattern 配置保存到 JSON 文件(目录不存在时自动创建)。"""
|
||||
path = Path(filepath)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(pattern, f, indent=4, ensure_ascii=False)
|
||||
|
||||
|
||||
def _load_pattern_or_empty(filepath, default=None) -> dict:
|
||||
"""加载 pattern 文件;文件不存在或格式错误时返回 default(或空 pattern 结构)。"""
|
||||
try:
|
||||
return load_pattern_file(filepath)
|
||||
except (FileNotFoundError, json.JSONDecodeError, KeyError):
|
||||
if default is not None:
|
||||
return copy.deepcopy(default)
|
||||
return {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 0,
|
||||
"pattern_params": [],
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 静态默认值 —— 纯常量,永不在运行时修改
|
||||
# =============================================================================
|
||||
|
||||
_DEFAULT_CCT_PARAMS = {
|
||||
"screen_module": {
|
||||
"x_ideal": 0.3127,
|
||||
"x_tolerance": 0.003,
|
||||
"y_ideal": 0.3290,
|
||||
"y_tolerance": 0.003,
|
||||
},
|
||||
"sdr_movie": {
|
||||
"x_ideal": 0.3127,
|
||||
"x_tolerance": 0.003,
|
||||
"y_ideal": 0.3290,
|
||||
"y_tolerance": 0.003,
|
||||
},
|
||||
"hdr_movie": {
|
||||
"x_ideal": 0.3127,
|
||||
"x_tolerance": 0.003,
|
||||
"y_ideal": 0.3290,
|
||||
"y_tolerance": 0.003,
|
||||
},
|
||||
}
|
||||
|
||||
_DEFAULT_GAMUT_REFERENCE = {
|
||||
"screen_module": "DCI-P3",
|
||||
"sdr_movie": "BT.709",
|
||||
"hdr_movie": "BT.2020",
|
||||
}
|
||||
|
||||
_DEFAULT_TEST_TYPES = {
|
||||
"screen_module": {
|
||||
"name": "屏模组性能测试",
|
||||
"test_items": ["gamut", "gamma", "cct", "contrast"],
|
||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||
"color_format": "RGB",
|
||||
"bpc": 8,
|
||||
"colorimetry": "sRGB",
|
||||
"patterns": {"gamut": "rgb", "gamma": "gray", "cct": "gray", "contrast": "rgb"},
|
||||
},
|
||||
"sdr_movie": {
|
||||
"name": "SDR Movie测试",
|
||||
"test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"],
|
||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||
"color_format": "RGB",
|
||||
"bpc": 8,
|
||||
"colorimetry": "sRGB",
|
||||
"patterns": {"gamut": "rgb", "gamma": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
|
||||
},
|
||||
"hdr_movie": {
|
||||
"name": "HDR Movie测试",
|
||||
"test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"],
|
||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||
"color_format": "RGB",
|
||||
"bpc": 8,
|
||||
"colorimetry": "sRGB",
|
||||
"patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"},
|
||||
},
|
||||
}
|
||||
|
||||
_PATTERN_RGB = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 2,
|
||||
"pattern_params": [
|
||||
[255, 0, 0], # 红色
|
||||
[0, 255, 0], # 绿色
|
||||
[0, 0, 255], # 蓝色
|
||||
],
|
||||
}
|
||||
|
||||
# 11点灰阶硬编码兜底(文件缺失时使用),主要数据来自 settings/patterns/gray.json
|
||||
_PATTERN_GRAY_FALLBACK = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 10,
|
||||
"pattern_params": [
|
||||
[255, 255, 255], # 100% 白色
|
||||
[230, 230, 230], # 90%
|
||||
[205, 205, 205], # 80%
|
||||
[179, 179, 179], # 70%
|
||||
[154, 154, 154], # 60%
|
||||
[128, 128, 128], # 50%
|
||||
[102, 102, 102], # 40%
|
||||
[78, 78, 78], # 30%
|
||||
[52, 52, 52], # 20%
|
||||
[26, 26, 26], # 10%
|
||||
[0, 0, 0], # 0% 黑色
|
||||
],
|
||||
}
|
||||
# 灰阶 pattern 从文件加载,支持用户编辑;文件缺失时回退到硬编码兜底
|
||||
_PATTERN_GRAY = _load_pattern_or_empty(_PATTERNS_DIR / "gray.json", default=_PATTERN_GRAY_FALLBACK)
|
||||
|
||||
_PATTERN_ACCURACY = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 28, # 29个颜色,最大索引是28
|
||||
"pattern_params": [
|
||||
# ========== 灰阶 (5个) ==========
|
||||
[255, 255, 255], # 0: White
|
||||
[230, 230, 230], # 1: Gray 80
|
||||
[209, 209, 209], # 2: Gray 65
|
||||
[186, 186, 186], # 3: Gray 50
|
||||
[158, 158, 158], # 4: Gray 35
|
||||
# ========== ColorChecker 24色 (18个) ==========
|
||||
[115, 82, 66], # 5: Dark Skin
|
||||
[194, 150, 130], # 6: Light Skin
|
||||
[94, 122, 156], # 7: Blue Sky
|
||||
[89, 107, 66], # 8: Foliage
|
||||
[130, 128, 176], # 9: Blue Flower
|
||||
[99, 189, 168], # 10: Bluish Green
|
||||
[217, 120, 41], # 11: Orange
|
||||
[74, 92, 163], # 12: Purplish Blue
|
||||
[194, 84, 97], # 13: Moderate Red
|
||||
[92, 61, 107], # 14: Purple
|
||||
[158, 186, 64], # 15: Yellow Green
|
||||
[230, 161, 46], # 16: Orange Yellow
|
||||
[51, 61, 150], # 17: Blue (Legacy)
|
||||
[71, 148, 71], # 18: Green (Legacy)
|
||||
[176, 48, 59], # 19: Red (Legacy)
|
||||
[237, 199, 33], # 20: Yellow (Legacy)
|
||||
[186, 84, 145], # 21: Magenta (Legacy)
|
||||
[0, 133, 163], # 22: Cyan (Legacy)
|
||||
# ========== 100% 饱和色 (6个) ==========
|
||||
[255, 0, 0], # 23: 100% Red
|
||||
[0, 255, 0], # 24: 100% Green
|
||||
[0, 0, 255], # 25: 100% Blue
|
||||
[0, 255, 255], # 26: 100% Cyan
|
||||
[255, 0, 255], # 27: 100% Magenta
|
||||
[255, 255, 0], # 28: 100% Yellow
|
||||
],
|
||||
}
|
||||
|
||||
# _PATTERN_TEMP 从文件读取,可通过编辑 settings/patterns/temp.json 自定义
|
||||
_PATTERN_TEMP = _load_pattern_or_empty(_PATTERNS_DIR / "temp.json")
|
||||
|
||||
# =============================================================================
|
||||
# Pattern 注册表 —— 核心具名 pattern(启动时解析)
|
||||
# =============================================================================
|
||||
|
||||
# 内置常量 pattern(行业标准,不依赖文件)
|
||||
_BUILTIN_PATTERNS = {
|
||||
"rgb": _PATTERN_RGB,
|
||||
"accuracy": _PATTERN_ACCURACY,
|
||||
}
|
||||
|
||||
# 全部已知 pattern(启动时加载,用于 get_pattern 快速查找)
|
||||
_KNOWN_PATTERNS = {
|
||||
**_BUILTIN_PATTERNS,
|
||||
"gray": _PATTERN_GRAY, # 从 gray.json 加载,有硬编码兜底
|
||||
"custom": _PATTERN_TEMP, # 从 temp.json 加载(客户模板测试)
|
||||
}
|
||||
|
||||
|
||||
def get_pattern(name: str) -> dict:
|
||||
"""
|
||||
按名称返回 pattern 的深拷贝。
|
||||
|
||||
查找顺序:
|
||||
1. ``_KNOWN_PATTERNS``(核心四类,启动时解析)
|
||||
2. ``settings/patterns/{name}.json``(动态文件,如 pantone、客户定制等)
|
||||
|
||||
文件不存在时返回空 pattern(``pattern_params`` 为空列表)。
|
||||
"""
|
||||
if name in _KNOWN_PATTERNS:
|
||||
return copy.deepcopy(_KNOWN_PATTERNS[name])
|
||||
return _load_pattern_or_empty(_PATTERNS_DIR / f"{name}.json")
|
||||
|
||||
|
||||
|
||||
class PQConfig:
|
||||
def __init__(self, current_test_type="screen_module", device_config={}, pattern={}):
|
||||
self.default_cct_params_by_type = {
|
||||
"screen_module": {
|
||||
"x_ideal": 0.3127,
|
||||
"x_tolerance": 0.003,
|
||||
"y_ideal": 0.3290,
|
||||
"y_tolerance": 0.003,
|
||||
},
|
||||
"sdr_movie": {
|
||||
"x_ideal": 0.3127,
|
||||
"x_tolerance": 0.003,
|
||||
"y_ideal": 0.3290,
|
||||
"y_tolerance": 0.003,
|
||||
},
|
||||
"hdr_movie": {
|
||||
"x_ideal": 0.3127,
|
||||
"x_tolerance": 0.003,
|
||||
"y_ideal": 0.3290,
|
||||
"y_tolerance": 0.003,
|
||||
},
|
||||
}
|
||||
self.default_gamut_reference_by_type = {
|
||||
"screen_module": "DCI-P3",
|
||||
"sdr_movie": "BT.709",
|
||||
"hdr_movie": "BT.2020",
|
||||
}
|
||||
def __init__(self, current_test_type="screen_module"):
|
||||
# ---- 向后兼容:只读属性,指向模块级常量 ----
|
||||
self.default_cct_params_by_type = _DEFAULT_CCT_PARAMS
|
||||
self.default_gamut_reference_by_type = _DEFAULT_GAMUT_REFERENCE
|
||||
self.default_test_types = _DEFAULT_TEST_TYPES
|
||||
self.default_pattern_rgb = _PATTERN_RGB
|
||||
self.default_pattern_gray = _PATTERN_GRAY
|
||||
self.default_pattern_accuracy = _PATTERN_ACCURACY
|
||||
self.default_pattern_temp = _PATTERN_TEMP
|
||||
|
||||
self.default_test_types = {
|
||||
"screen_module": {
|
||||
"name": "屏模组性能测试",
|
||||
"test_items": ["gamut", "gamma", "cct", "contrast"],
|
||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||
"color_format": "RGB",
|
||||
"bpc": 8,
|
||||
"colorimetry": "sRGB",
|
||||
},
|
||||
"sdr_movie": {
|
||||
"name": "SDR Movie测试",
|
||||
"test_items": ["gamut", "gamma", "cct", "contrast", "accuracy"],
|
||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||
"color_format": "RGB",
|
||||
"bpc": 8,
|
||||
"colorimetry": "sRGB",
|
||||
},
|
||||
"hdr_movie": {
|
||||
"name": "HDR Movie测试",
|
||||
"test_items": ["gamut", "eotf", "cct", "contrast", "accuracy"],
|
||||
"timing": "DMT 1920x 1080 @ 60Hz",
|
||||
"color_format": "RGB",
|
||||
"bpc": 8,
|
||||
"colorimetry": "sRGB",
|
||||
},
|
||||
}
|
||||
|
||||
# 设备连接配置
|
||||
# ---- 设备连接配置 ----
|
||||
self.device_config = {
|
||||
"ca_com": "COM1",
|
||||
"ucd_list": "0: UCD-323 [2128C209]",
|
||||
"ca_channel": "0",
|
||||
}
|
||||
|
||||
# ========== RGB Pattern 配置 ==========
|
||||
self.default_pattern_rgb = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 2,
|
||||
"pattern_params": [
|
||||
[255, 0, 0], # 红色
|
||||
[0, 255, 0], # 绿色
|
||||
[0, 0, 255], # 蓝色
|
||||
],
|
||||
}
|
||||
|
||||
# ========== 灰阶 Pattern 配置 ==========
|
||||
self.default_pattern_gray = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 10,
|
||||
"pattern_params": [
|
||||
[255, 255, 255], # 100% 白色
|
||||
[230, 230, 230], # 90%
|
||||
[205, 205, 205], # 80%
|
||||
[179, 179, 179], # 70%
|
||||
[154, 154, 154], # 60%
|
||||
[128, 128, 128], # 50%
|
||||
[102, 102, 102], # 40%
|
||||
[78, 78, 78], # 30%
|
||||
[52, 52, 52], # 20%
|
||||
[26, 26, 26], # 10%
|
||||
[0, 0, 0], # 0% 黑色
|
||||
],
|
||||
}
|
||||
|
||||
# ========== 色准 Pattern 配置(29色 - SDR 和 HDR 通用)==========
|
||||
self.default_pattern_accuracy = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 28, # 29个颜色,最大索引是28
|
||||
"pattern_params": [
|
||||
# ========== 灰阶 (5个) ==========
|
||||
[255, 255, 255], # 0: White
|
||||
[230, 230, 230], # 1: Gray 80
|
||||
[209, 209, 209], # 2: Gray 65
|
||||
[186, 186, 186], # 3: Gray 50
|
||||
[158, 158, 158], # 4: Gray 35
|
||||
# ========== ColorChecker 24色 (18个) ==========
|
||||
[115, 82, 66], # 5: Dark Skin
|
||||
[194, 150, 130], # 6: Light Skin
|
||||
[94, 122, 156], # 7: Blue Sky
|
||||
[89, 107, 66], # 8: Foliage
|
||||
[130, 128, 176], # 9: Blue Flower
|
||||
[99, 189, 168], # 10: Bluish Green
|
||||
[217, 120, 41], # 11: Orange
|
||||
[74, 92, 163], # 12: Purplish Blue
|
||||
[194, 84, 97], # 13: Moderate Red
|
||||
[92, 61, 107], # 14: Purple
|
||||
[158, 186, 64], # 15: Yellow Green
|
||||
[230, 161, 46], # 16: Orange Yellow
|
||||
[51, 61, 150], # 17: Blue (Legacy)
|
||||
[71, 148, 71], # 18: Green (Legacy)
|
||||
[176, 48, 59], # 19: Red (Legacy)
|
||||
[237, 199, 33], # 20: Yellow (Legacy)
|
||||
[186, 84, 145], # 21: Magenta (Legacy)
|
||||
[0, 133, 163], # 22: Cyan (Legacy)
|
||||
# ========== 100% 饱和色 (6个) ==========
|
||||
[255, 0, 0], # 23: 100% Red
|
||||
[0, 255, 0], # 24: 100% Green
|
||||
[0, 0, 255], # 25: 100% Blue
|
||||
[0, 255, 255], # 26: 100% Cyan
|
||||
[255, 0, 255], # 27: 100% Magenta
|
||||
[255, 255, 0], # 28: 100% Yellow
|
||||
],
|
||||
}
|
||||
|
||||
self.default_pattern_temp = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
"measurement_max_value": 146,
|
||||
"pattern_params": [
|
||||
[255, 255, 255],
|
||||
[242, 242, 242],
|
||||
[230, 230, 230],
|
||||
[217, 217, 217],
|
||||
[204, 204, 204],
|
||||
[191, 191, 191],
|
||||
[179, 179, 179],
|
||||
[166, 166, 166],
|
||||
[153, 153, 153],
|
||||
[140, 140, 140],
|
||||
[128, 128, 128],
|
||||
[115, 115, 115],
|
||||
[102, 102, 102],
|
||||
[89, 89, 89],
|
||||
[77, 77, 77],
|
||||
[64, 64, 64],
|
||||
[51, 51, 51],
|
||||
[38, 38, 38],
|
||||
[26, 26, 26],
|
||||
[13, 13, 13],
|
||||
[0, 0, 0],
|
||||
|
||||
[255, 0, 0],
|
||||
[242, 0, 0],
|
||||
[230, 0, 0],
|
||||
[217, 0, 0],
|
||||
[204, 0, 0],
|
||||
[191, 0, 0],
|
||||
[179, 0, 0],
|
||||
[166, 0, 0],
|
||||
[153, 0, 0],
|
||||
[140, 0, 0],
|
||||
[128, 0, 0],
|
||||
[115, 0, 0],
|
||||
[102, 0, 0],
|
||||
[89, 0, 0],
|
||||
[77, 0, 0],
|
||||
[64, 0, 0],
|
||||
[51, 0, 0],
|
||||
[38, 0, 0],
|
||||
[26, 0, 0],
|
||||
[13, 0, 0],
|
||||
[0, 0, 0],
|
||||
|
||||
[0, 255, 0],
|
||||
[0, 242, 0],
|
||||
[0, 230, 0],
|
||||
[0, 217, 0],
|
||||
[0, 204, 0],
|
||||
[0, 191, 0],
|
||||
[0, 179, 0],
|
||||
[0, 166, 0],
|
||||
[0, 153, 0],
|
||||
[0, 140, 0],
|
||||
[0, 128, 0],
|
||||
[0, 115, 0],
|
||||
[0, 102, 0],
|
||||
[0, 89, 0],
|
||||
[0, 77, 0],
|
||||
[0, 64, 0],
|
||||
[0, 51, 0],
|
||||
[0, 38, 0],
|
||||
[0, 26, 0],
|
||||
[0, 13, 0],
|
||||
[0, 0, 0],
|
||||
|
||||
[0, 0, 255],
|
||||
[0, 0, 242],
|
||||
[0, 0, 230],
|
||||
[0, 0, 217],
|
||||
[0, 0, 204],
|
||||
[0, 0, 191],
|
||||
[0, 0, 179],
|
||||
[0, 0, 166],
|
||||
[0, 0, 153],
|
||||
[0, 0, 140],
|
||||
[0, 0, 128],
|
||||
[0, 0, 115],
|
||||
[0, 0, 102],
|
||||
[0, 0, 89],
|
||||
[0, 0, 77],
|
||||
[0, 0, 64],
|
||||
[0, 0, 51],
|
||||
[0, 0, 38],
|
||||
[0, 0, 26],
|
||||
[0, 0, 13],
|
||||
[0, 0, 0],
|
||||
|
||||
[255, 255, 0],
|
||||
[242, 242, 0],
|
||||
[230, 230, 0],
|
||||
[217, 217, 0],
|
||||
[204, 204, 0],
|
||||
[191, 191, 0],
|
||||
[179, 179, 0],
|
||||
[166, 166, 0],
|
||||
[153, 153, 0],
|
||||
[140, 140, 0],
|
||||
[128, 128, 0],
|
||||
[115, 115, 0],
|
||||
[102, 102, 0],
|
||||
[89, 89, 0],
|
||||
[77, 77, 0],
|
||||
[64, 64, 0],
|
||||
[51, 51, 0],
|
||||
[38, 38, 0],
|
||||
[26, 26, 0],
|
||||
[13, 13, 0],
|
||||
[0, 0, 0],
|
||||
|
||||
[0, 255, 255],
|
||||
[0, 242, 242],
|
||||
[0, 230, 230],
|
||||
[0, 217, 217],
|
||||
[0, 204, 204],
|
||||
[0, 191, 191],
|
||||
[0, 179, 179],
|
||||
[0, 166, 166],
|
||||
[0, 153, 153],
|
||||
[0, 140, 140],
|
||||
[0, 128, 128],
|
||||
[0, 115, 115],
|
||||
[0, 102, 102],
|
||||
[0, 89, 89],
|
||||
[0, 77, 77],
|
||||
[0, 64, 64],
|
||||
[0, 51, 51],
|
||||
[0, 38, 38],
|
||||
[0, 26, 26],
|
||||
[0, 13, 13],
|
||||
[0, 0, 0],
|
||||
|
||||
[255, 0, 255],
|
||||
[242, 0, 242],
|
||||
[230, 0, 230],
|
||||
[217, 0, 217],
|
||||
[204, 0, 204],
|
||||
[191, 0, 191],
|
||||
[179, 0, 179],
|
||||
[166, 0, 166],
|
||||
[153, 0, 153],
|
||||
[140, 0, 140],
|
||||
[128, 0, 128],
|
||||
[115, 0, 115],
|
||||
[102, 0, 102],
|
||||
[89, 0, 89],
|
||||
[77, 0, 77],
|
||||
[64, 0, 64],
|
||||
[51, 0, 51],
|
||||
[38, 0, 38],
|
||||
[26, 0, 26],
|
||||
[13, 0, 13],
|
||||
[0, 0, 0],
|
||||
]
|
||||
}
|
||||
|
||||
# 自定义图案
|
||||
# ---- 自定义图案(用户可变)----
|
||||
self.custom_pattern = {
|
||||
"pattern_mode": "SolidColor",
|
||||
"measurement_bit_depth": 8,
|
||||
@@ -307,9 +248,10 @@ class PQConfig:
|
||||
"pattern_params": [],
|
||||
}
|
||||
|
||||
self.current_test_types = self.default_test_types
|
||||
# ---- 运行态 ----
|
||||
self.current_test_types = copy.deepcopy(_DEFAULT_TEST_TYPES)
|
||||
self.current_test_type = current_test_type
|
||||
self.current_pattern = self.default_pattern_rgb
|
||||
self.current_pattern = copy.deepcopy(_PATTERN_RGB) # 深拷贝,避免引用污染
|
||||
|
||||
def get_default_cct_params(self, test_type):
|
||||
"""获取指定测试类型的默认 CCT 参数副本。"""
|
||||
@@ -338,12 +280,8 @@ class PQConfig:
|
||||
temp_config = copy.deepcopy(self)
|
||||
|
||||
# 2. 设置正确的 pattern 模式
|
||||
if mode == "rgb":
|
||||
temp_config.current_pattern = copy.deepcopy(self.default_pattern_rgb)
|
||||
elif mode == "gray":
|
||||
temp_config.current_pattern = copy.deepcopy(self.default_pattern_gray)
|
||||
elif mode == "accuracy":
|
||||
temp_config.current_pattern = copy.deepcopy(self.default_pattern_accuracy)
|
||||
_resolved = get_pattern(mode)
|
||||
temp_config.current_pattern = _resolved if _resolved["pattern_params"] else copy.deepcopy(_PATTERN_RGB)
|
||||
|
||||
# 3. 替换为转换后的参数
|
||||
temp_config.current_pattern["pattern_params"] = converted_params
|
||||
@@ -356,9 +294,6 @@ class PQConfig:
|
||||
"current_test_type": self.current_test_type,
|
||||
"test_types": self.current_test_types,
|
||||
"device_config": self.device_config,
|
||||
"default_pattern_rgb": self.default_pattern_rgb,
|
||||
"default_pattern_gray": self.default_pattern_gray,
|
||||
"default_pattern_accuracy": self.default_pattern_accuracy,
|
||||
"custom_pattern": self.custom_pattern,
|
||||
}
|
||||
|
||||
@@ -367,30 +302,6 @@ class PQConfig:
|
||||
self.current_test_type = config_dict.get("current_test_type", "screen_module")
|
||||
self.current_test_types = config_dict.get("test_types", self.current_test_types)
|
||||
self.device_config = config_dict.get("device_config", self.device_config)
|
||||
|
||||
self.default_pattern_rgb = config_dict.get(
|
||||
"default_pattern_rgb", self.default_pattern_rgb
|
||||
)
|
||||
self.default_pattern_gray = config_dict.get(
|
||||
"default_pattern_gray", self.default_pattern_gray
|
||||
)
|
||||
|
||||
# ========== 强制使用新的 29色配置 ==========
|
||||
loaded_accuracy = config_dict.get("default_pattern_accuracy", None)
|
||||
|
||||
# 检查加载的配置是否是旧的 10色
|
||||
if loaded_accuracy and len(loaded_accuracy.get("pattern_params", [])) != 29:
|
||||
print(
|
||||
f"检测到旧的配置({len(loaded_accuracy.get('pattern_params', []))}色),强制使用新的 29色配置"
|
||||
)
|
||||
# 使用 __init__ 中定义的新配置
|
||||
self.default_pattern_accuracy = self.default_pattern_accuracy
|
||||
else:
|
||||
self.default_pattern_accuracy = config_dict.get(
|
||||
"default_pattern_accuracy", self.default_pattern_accuracy
|
||||
)
|
||||
# ==========================================
|
||||
|
||||
self.custom_pattern = config_dict.get("custom_pattern", self.custom_pattern)
|
||||
|
||||
def save_to_file(self, filename):
|
||||
@@ -426,32 +337,32 @@ class PQConfig:
|
||||
return True
|
||||
|
||||
def set_current_pattern(self, mode):
|
||||
"""设置当前模式的测试图案"""
|
||||
if mode == "rgb":
|
||||
self.current_pattern = self.default_pattern_rgb
|
||||
elif mode == "gray":
|
||||
self.current_pattern = self.default_pattern_gray
|
||||
elif mode == "accuracy": # 色准模式(SDR 和 HDR 通用 29色)
|
||||
self.current_pattern = self.default_pattern_accuracy
|
||||
elif mode == "custom":
|
||||
# self.current_pattern = self.custom_pattern
|
||||
self.current_pattern = self.default_pattern_temp
|
||||
else:
|
||||
"""设置当前模式的测试图案(支持所有已注册名称及动态文件 pattern)。"""
|
||||
pattern = get_pattern(mode)
|
||||
if not pattern["pattern_params"]:
|
||||
return False
|
||||
|
||||
# 确保 measurement_max_value 是整数
|
||||
if "measurement_max_value" in self.current_pattern:
|
||||
value = self.current_pattern["measurement_max_value"]
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
self.current_pattern["measurement_max_value"] = int(value)
|
||||
except ValueError:
|
||||
self.current_pattern["measurement_max_value"] = (
|
||||
len(self.current_pattern["pattern_params"]) - 1
|
||||
)
|
||||
|
||||
self.current_pattern = pattern # get_pattern 已深拷贝
|
||||
return True
|
||||
|
||||
def get_test_item_pattern(self, test_item, test_type=None) -> str:
|
||||
"""
|
||||
返回指定测试项目所使用的 pattern 名称。
|
||||
|
||||
从当前测试类型的 ``patterns`` 字段查找;未配置时按通用规则回退。
|
||||
"""
|
||||
tt = test_type or self.current_test_type
|
||||
patterns = self.current_test_types.get(tt, {}).get("patterns", {})
|
||||
_fallback = {
|
||||
"gamut": "rgb",
|
||||
"gamma": "gray",
|
||||
"eotf": "gray",
|
||||
"cct": "gray",
|
||||
"contrast": "rgb",
|
||||
"accuracy": "accuracy",
|
||||
"custom": "custom",
|
||||
}
|
||||
return patterns.get(test_item) or _fallback.get(test_item, "gray")
|
||||
|
||||
def set_custom_pattern(self, pattern_mode, pattern_params):
|
||||
"""设置自定义模式的测试项"""
|
||||
self.custom_pattern["pattern_mode"] = pattern_mode
|
||||
@@ -511,7 +422,7 @@ class PQConfig:
|
||||
list: [(name, r, g, b), ...]
|
||||
"""
|
||||
names = self.get_accuracy_color_names()
|
||||
rgb_values = self.default_pattern_accuracy["pattern_params"]
|
||||
rgb_values = _PATTERN_ACCURACY["pattern_params"]
|
||||
|
||||
return [(name, r, g, b) for name, (r, g, b) in zip(names, rgb_values)]
|
||||
|
||||
@@ -525,7 +436,7 @@ class PQConfig:
|
||||
for value in percentages:
|
||||
names.append(f"{prefix} {value}%")
|
||||
|
||||
pattern_count = len(self.default_pattern_temp.get("pattern_params", []))
|
||||
pattern_count = len(_PATTERN_TEMP.get("pattern_params", []))
|
||||
|
||||
if pattern_count <= len(names):
|
||||
return names[:pattern_count]
|
||||
@@ -538,23 +449,15 @@ class PQConfig:
|
||||
|
||||
def get_test_item_chinese_names(self, test_items):
|
||||
"""获取测试项目的显示名称"""
|
||||
item_names = []
|
||||
for item in test_items:
|
||||
if item == "gamut":
|
||||
item_names.append("色域")
|
||||
elif item == "gamma":
|
||||
item_names.append("Gamma")
|
||||
elif item == "eotf":
|
||||
item_names.append("EOTF")
|
||||
elif item == "cct":
|
||||
item_names.append("色度一致性")
|
||||
elif item == "contrast":
|
||||
item_names.append("对比度")
|
||||
elif item == "accuracy":
|
||||
item_names.append("色准")
|
||||
else:
|
||||
item_names.append(item)
|
||||
return item_names
|
||||
_name_map = {
|
||||
"gamut": "色域",
|
||||
"gamma": "Gamma",
|
||||
"eotf": "EOTF",
|
||||
"cct": "色度一致性",
|
||||
"contrast": "对比度",
|
||||
"accuracy": "色准",
|
||||
}
|
||||
return [_name_map.get(item, item) for item in test_items]
|
||||
|
||||
def get_current_config(self):
|
||||
"""返回当前测试类型相关的所有配置信息"""
|
||||
@@ -577,62 +480,3 @@ class PQConfig:
|
||||
}
|
||||
|
||||
return config_info
|
||||
|
||||
|
||||
# ========== 验证代码(测试完成后可删除)==========
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("验证 pq_config.py 配置")
|
||||
print("=" * 60)
|
||||
|
||||
config = PQConfig()
|
||||
|
||||
# 检查 default_pattern_accuracy
|
||||
pattern_count = len(config.default_pattern_accuracy["pattern_params"])
|
||||
max_value = config.default_pattern_accuracy["measurement_max_value"]
|
||||
|
||||
print(f"\ndefault_pattern_accuracy:")
|
||||
print(f" 图案数量: {pattern_count}")
|
||||
print(f" measurement_max_value: {max_value}")
|
||||
|
||||
if pattern_count == 29 and max_value == 28:
|
||||
print("\n配置正确(29色)")
|
||||
|
||||
# 显示前 5 个图案
|
||||
print("\n前5个图案:")
|
||||
for i in range(5):
|
||||
rgb = config.default_pattern_accuracy["pattern_params"][i]
|
||||
names = config.get_accuracy_color_names()
|
||||
print(f" [{i}] {names[i]:15s} RGB{rgb}")
|
||||
|
||||
# 显示后 5 个图案
|
||||
print("\n后5个图案:")
|
||||
for i in range(24, 29):
|
||||
rgb = config.default_pattern_accuracy["pattern_params"][i]
|
||||
names = config.get_accuracy_color_names()
|
||||
print(f" [{i}] {names[i]:15s} RGB{rgb}")
|
||||
|
||||
# 测试 set_current_pattern
|
||||
print("\n测试 set_current_pattern('accuracy'):")
|
||||
config.set_current_pattern("accuracy")
|
||||
current_count = len(config.current_pattern["pattern_params"])
|
||||
print(f" current_pattern 图案数量: {current_count}")
|
||||
|
||||
if current_count == 29:
|
||||
print(" set_current_pattern 工作正常")
|
||||
else:
|
||||
print(f" set_current_pattern 失败!只有 {current_count} 个图案")
|
||||
|
||||
else:
|
||||
print(f"\n配置错误!")
|
||||
print(f" 期望: 29 个图案, measurement_max_value=28")
|
||||
print(f" 实际: {pattern_count} 个图案, measurement_max_value={max_value}")
|
||||
|
||||
print("\n请检查 default_pattern_accuracy 定义!")
|
||||
print(" 应该包含:")
|
||||
print(" - 5个灰阶")
|
||||
print(" - 18个 ColorChecker 色块")
|
||||
print(" - 6个 100% 饱和色")
|
||||
print(" - 总计 29 个 RGB 数组")
|
||||
|
||||
print("=" * 60)
|
||||
|
||||
@@ -13,7 +13,6 @@ import colour
|
||||
import numpy as np
|
||||
|
||||
import algorithm.pq_algorithm as pq_algorithm
|
||||
from app.data_range_converter import convert_pattern_params
|
||||
from app.pq.pq_result import PQResult
|
||||
|
||||
def new_pq_results(self, test_type, test_name):
|
||||
@@ -310,205 +309,12 @@ def send_fix_pattern(self, mode):
|
||||
results = []
|
||||
|
||||
try:
|
||||
# 1. 设置图案模式
|
||||
if mode == "rgb":
|
||||
self.config.set_current_pattern("rgb")
|
||||
elif mode == "gray":
|
||||
self.config.set_current_pattern("gray")
|
||||
elif mode == "accuracy": # 色准模式(SDR 和 HDR 通用 29色)
|
||||
self.config.set_current_pattern("accuracy")
|
||||
elif mode == "custom":
|
||||
self.config.set_current_pattern("custom")
|
||||
else:
|
||||
self.log_gui.log(f"未知的图案模式: {mode}", level="error")
|
||||
return None
|
||||
|
||||
# 2. 获取当前测试类型
|
||||
test_type = self.config.current_test_type
|
||||
|
||||
# 3. 根据测试类型设置信号格式和图案
|
||||
if test_type == "screen_module":
|
||||
# 屏模组测试:使用 Timing
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
self.log_gui.log("设置屏模组信号格式:", level="info")
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
|
||||
timing_str = self.config.current_test_types[test_type]["timing"]
|
||||
self.log_gui.log(f" Timing: {timing_str}", level="info")
|
||||
|
||||
# 屏模组测试:直接使用原始配置
|
||||
self.ucd.set_ucd_params(self.config)
|
||||
|
||||
elif test_type == "sdr_movie":
|
||||
# SDR 测试:设置色彩空间、Gamma 等
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
self.log_gui.log("设置 SDR 信号格式:", level="info")
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
|
||||
color_space = self.sdr_color_space_var.get()
|
||||
gamma = self.sdr_gamma_type_var.get()
|
||||
data_range = self.sdr_data_range_var.get()
|
||||
bit_depth = self.sdr_bit_depth_var.get()
|
||||
|
||||
self.log_gui.log(f" 色彩空间: {color_space}", level="info")
|
||||
self.log_gui.log(f" Gamma: {gamma}", level="info")
|
||||
self.log_gui.log(f" 数据范围: {data_range}", level="info")
|
||||
self.log_gui.log(f" 编码位深: {bit_depth}", level="info")
|
||||
|
||||
success = self.ucd.set_sdr_format(
|
||||
color_space=color_space,
|
||||
gamma=gamma,
|
||||
data_range=data_range,
|
||||
bit_depth=bit_depth,
|
||||
)
|
||||
|
||||
if success:
|
||||
self.log_gui.log("SDR 信号格式设置成功", level="success")
|
||||
else:
|
||||
self.log_gui.log("SDR 信号格式设置失败", level="error")
|
||||
|
||||
# 设置图案参数
|
||||
if mode == "accuracy":
|
||||
self.log_gui.log(f"设置 SDR 29色色准测试图案...", level="info")
|
||||
else:
|
||||
self.log_gui.log(f"设置 SDR 测试图案({mode} 模式)...", level="info")
|
||||
|
||||
# ========== ✅✅修改:使用临时配置对象 ==========
|
||||
import copy
|
||||
|
||||
# 从原始配置获取参数(每次都是干净的)
|
||||
if mode == "rgb":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_rgb["pattern_params"]
|
||||
)
|
||||
elif mode == "gray":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_gray["pattern_params"]
|
||||
)
|
||||
elif mode == "accuracy":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_accuracy["pattern_params"]
|
||||
)
|
||||
elif mode == "custom":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_temp["pattern_params"]
|
||||
)
|
||||
|
||||
self.log_gui.log(f" 使用原始 RGB 参数(前 3 个):", level="info")
|
||||
for i in range(min(3, len(original_params))):
|
||||
self.log_gui.log(f" [{i+1}] {original_params[i]}", level="info")
|
||||
|
||||
# 根据 data_range 转换
|
||||
converted_params = convert_pattern_params(
|
||||
pattern_params=original_params, data_range=data_range, verbose=False
|
||||
)
|
||||
|
||||
if data_range == "Limited":
|
||||
self.log_gui.log(" 转换为 Limited Range (16-235):", level="info")
|
||||
for i in range(min(3, len(converted_params))):
|
||||
self.log_gui.log(
|
||||
f" {original_params[i]} → {converted_params[i]}"
|
||||
, level="info")
|
||||
else:
|
||||
self.log_gui.log("Full Range,RGB 保持不变", level="success")
|
||||
|
||||
# 创建临时配置对象(不修改 self.config)
|
||||
temp_config = self.config.get_temp_config_with_converted_params(
|
||||
mode=mode, converted_params=converted_params
|
||||
)
|
||||
|
||||
# 使用临时配置设置参数
|
||||
self.ucd.set_ucd_params(temp_config)
|
||||
|
||||
self.log_gui.log(f"图案参数已设置,共 {len(converted_params)} 个图案", level="success")
|
||||
# ========== 修改结束 ==========
|
||||
|
||||
elif test_type == "hdr_movie":
|
||||
# HDR 测试:设置色彩空间、Metadata 等
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
self.log_gui.log("设置 HDR 信号格式:", level="info")
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
|
||||
color_space = self.hdr_color_space_var.get()
|
||||
data_range = self.hdr_data_range_var.get()
|
||||
bit_depth = self.hdr_bit_depth_var.get()
|
||||
max_cll = self.hdr_maxcll_var.get()
|
||||
max_fall = self.hdr_maxfall_var.get()
|
||||
|
||||
self.log_gui.log(f" 色彩空间: {color_space}", level="info")
|
||||
self.log_gui.log(f" 数据范围: {data_range}", level="info")
|
||||
self.log_gui.log(f" 编码位深: {bit_depth}", level="info")
|
||||
self.log_gui.log(f" MaxCLL: {max_cll}", level="info")
|
||||
self.log_gui.log(f" MaxFALL: {max_fall}", level="info")
|
||||
|
||||
success = self.ucd.set_hdr_format(
|
||||
color_space=color_space,
|
||||
data_range=data_range,
|
||||
bit_depth=bit_depth,
|
||||
max_cll=max_cll,
|
||||
max_fall=max_fall,
|
||||
)
|
||||
|
||||
if success:
|
||||
self.log_gui.log("HDR 信号格式设置成功", level="success")
|
||||
else:
|
||||
self.log_gui.log("HDR 信号格式设置失败", level="error")
|
||||
|
||||
# 设置图案参数
|
||||
if mode == "accuracy":
|
||||
self.log_gui.log(f"设置 HDR 29色色准测试图案...", level="info")
|
||||
else:
|
||||
self.log_gui.log(f"设置 HDR 测试图案({mode} 模式)...", level="info")
|
||||
|
||||
# ========== ✅✅修改:使用临时配置对象 ==========
|
||||
import copy
|
||||
|
||||
# 从原始配置获取参数
|
||||
if mode == "rgb":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_rgb["pattern_params"]
|
||||
)
|
||||
elif mode == "gray":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_gray["pattern_params"]
|
||||
)
|
||||
elif mode == "accuracy":
|
||||
original_params = copy.deepcopy(
|
||||
self.config.default_pattern_accuracy["pattern_params"]
|
||||
)
|
||||
|
||||
self.log_gui.log(f" 使用原始 RGB 参数(前 3 个):", level="info")
|
||||
for i in range(min(3, len(original_params))):
|
||||
self.log_gui.log(f" [{i+1}] {original_params[i]}", level="info")
|
||||
|
||||
# 根据 data_range 转换
|
||||
converted_params = convert_pattern_params(
|
||||
pattern_params=original_params, data_range=data_range, verbose=False
|
||||
)
|
||||
|
||||
if data_range == "Limited":
|
||||
self.log_gui.log(" 转换为 Limited Range (16-235):", level="info")
|
||||
for i in range(min(3, len(converted_params))):
|
||||
self.log_gui.log(
|
||||
f" {original_params[i]} → {converted_params[i]}"
|
||||
, level="info")
|
||||
else:
|
||||
self.log_gui.log("Full Range,RGB 保持不变", level="success")
|
||||
|
||||
# 创建临时配置对象
|
||||
temp_config = self.config.get_temp_config_with_converted_params(
|
||||
mode=mode, converted_params=converted_params
|
||||
)
|
||||
|
||||
self.ucd.set_ucd_params(temp_config)
|
||||
|
||||
self.log_gui.log(f"图案参数已设置,共 {len(converted_params)} 个图案", level="success")
|
||||
# ========== 修改结束 ==========
|
||||
session = self.pattern_service.prepare_session(mode, log_details=True)
|
||||
|
||||
self.log_gui.log("=" * 50, level="separator")
|
||||
|
||||
# 4. 循环发送图案并采集数据(使用原始配置的数量)
|
||||
total_patterns = len(self.config.current_pattern["pattern_params"])
|
||||
# 4. 循环发送图案并采集数据
|
||||
total_patterns = session.total_patterns
|
||||
self.log_gui.log(f"开始采集数据,共 {total_patterns} 个图案", level="info")
|
||||
settle_time = max(0.2, float(getattr(self, "pattern_settle_time", 1.0)))
|
||||
progress_step = max(
|
||||
@@ -518,14 +324,7 @@ def send_fix_pattern(self, mode):
|
||||
f"采集等待时间: {settle_time:.2f}s(可通过 pattern_settle_time 调整)"
|
||||
, level="info")
|
||||
|
||||
# 获取颜色名称列表(用于日志显示)
|
||||
color_names = None
|
||||
if mode == "accuracy":
|
||||
color_names = self.config.get_accuracy_color_names()
|
||||
|
||||
custom_pattern_names = []
|
||||
if mode == "custom" and hasattr(self.config, "get_temp_pattern_names"):
|
||||
custom_pattern_names = self.config.get_temp_pattern_names()
|
||||
display_names = session.display_names
|
||||
|
||||
for i in range(total_patterns):
|
||||
if not self.testing:
|
||||
@@ -538,17 +337,15 @@ def send_fix_pattern(self, mode):
|
||||
or ((i + 1) % progress_step == 0)
|
||||
)
|
||||
|
||||
# 设置下一个图案(显示颜色名称)
|
||||
if should_log_detail:
|
||||
if color_names and i < len(color_names):
|
||||
if display_names and i < len(display_names):
|
||||
self.log_gui.log(
|
||||
f"发送第 {i+1}/{total_patterns} 个图案: {color_names[i]}..."
|
||||
f"发送第 {i+1}/{total_patterns} 个图案: {display_names[i]}..."
|
||||
, level="info")
|
||||
else:
|
||||
self.log_gui.log(f"发送第 {i+1}/{total_patterns} 个图案...", level="info")
|
||||
|
||||
self.ucd.set_next_pattern()
|
||||
self.ucd.run()
|
||||
self.pattern_service.send_session_pattern(session, i)
|
||||
time.sleep(settle_time)
|
||||
|
||||
# 测量数据
|
||||
@@ -582,8 +379,8 @@ def send_fix_pattern(self, mode):
|
||||
)
|
||||
row_data = {
|
||||
"pattern_name": (
|
||||
custom_pattern_names[i]
|
||||
if i < len(custom_pattern_names)
|
||||
display_names[i]
|
||||
if i < len(display_names)
|
||||
else f"P {i + 1}"
|
||||
),
|
||||
"X": X,
|
||||
@@ -786,6 +583,7 @@ def test_gamut(self, test_type):
|
||||
|
||||
# 传递完整的 results 用于绘图
|
||||
self.plot_gamut(results, coverage, test_type)
|
||||
self._save_chart_snapshot(test_type, "gamut", (results, coverage, test_type))
|
||||
|
||||
self.log_gui.log("色域测试完成", level="success")
|
||||
|
||||
@@ -877,6 +675,7 @@ def test_gamma(self, test_type, gray_data=None):
|
||||
target_gamma = 2.2
|
||||
|
||||
self.plot_gamma(L_bar, results_with_gamma_list, target_gamma, test_type)
|
||||
self._save_chart_snapshot(test_type, "gamma", (L_bar, results_with_gamma_list, target_gamma, test_type))
|
||||
|
||||
self.log_gui.log("Gamma测试完成", level="success")
|
||||
|
||||
@@ -959,6 +758,7 @@ def test_eotf(self, test_type, gray_data=None):
|
||||
# ========== 绘制 EOTF 曲线 ==========
|
||||
# HDR 使用 PQ 曲线,目标 gamma 设为 None(不使用传统 gamma)
|
||||
self.plot_eotf(L_bar, results_with_eotf_list, test_type)
|
||||
self._save_chart_snapshot(test_type, "eotf", (L_bar, results_with_eotf_list, test_type))
|
||||
|
||||
self.log_gui.log("EOTF 测试完成", level="success")
|
||||
|
||||
@@ -999,6 +799,7 @@ def test_cct(self, test_type, gray_data=None):
|
||||
|
||||
# 绘制图表
|
||||
self.plot_cct(test_type)
|
||||
self._save_chart_snapshot(test_type, "cct", (test_type,))
|
||||
|
||||
self.log_gui.log("色度一致性测试完成", level="success")
|
||||
except Exception as e:
|
||||
@@ -1062,6 +863,7 @@ def test_contrast(self, test_type, gray_data=None):
|
||||
|
||||
# 绘制对比度图表
|
||||
self.plot_contrast(contrast_data, test_type)
|
||||
self._save_chart_snapshot(test_type, "contrast", (contrast_data, test_type))
|
||||
|
||||
self.log_gui.log("对比度测试完成", level="success")
|
||||
except Exception as e:
|
||||
@@ -1227,6 +1029,7 @@ def test_color_accuracy(self, test_type):
|
||||
|
||||
# ========== 绘制图表 ==========
|
||||
self.plot_accuracy(accuracy_data, test_type)
|
||||
self._save_chart_snapshot(test_type, "accuracy", (accuracy_data, test_type))
|
||||
|
||||
self.log_gui.log("色准测试完成", level="success")
|
||||
|
||||
@@ -1371,20 +1174,6 @@ def on_test_completed(self):
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"显示色度重新计算按钮失败: {str(e)}", level="error")
|
||||
|
||||
if "gamut" in selected_items:
|
||||
try:
|
||||
if test_type == "screen_module" and hasattr(self, "recalc_gamut_btn"):
|
||||
self.recalc_gamut_btn.grid()
|
||||
self.log_gui.log("屏模组色域参考调整按钮已启用", level="success")
|
||||
elif test_type == "sdr_movie" and hasattr(self, "sdr_recalc_gamut_btn"):
|
||||
self.sdr_recalc_gamut_btn.grid()
|
||||
self.log_gui.log("SDR 色域参考调整按钮已启用", level="success")
|
||||
elif test_type == "hdr_movie" and hasattr(self, "hdr_recalc_gamut_btn"):
|
||||
self.hdr_recalc_gamut_btn.grid()
|
||||
self.log_gui.log("HDR 色域参考调整按钮已启用", level="success")
|
||||
except Exception as e:
|
||||
self.log_gui.log(f"显示色域重新计算按钮失败: {str(e)}", level="error")
|
||||
|
||||
messagebox.showinfo("完成", "测试已完成!")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from app.services.pattern_service import PatternService, PatternSession
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
||||
|
||||
API 端点待接入,当前通过 ``set_api_caller`` 注入具体实现。
|
||||
后端接口(测试环境):
|
||||
POST {API_BASE_URL}{API_PATH}
|
||||
body: {"user_message": str, "session_id": str}
|
||||
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
||||
|
||||
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
||||
"""
|
||||
|
||||
@@ -9,15 +13,25 @@ from __future__ import annotations
|
||||
import datetime as _dt
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import Callable, List, Optional
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from PIL import Image
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------- 常量 ----------
|
||||
|
||||
@@ -25,41 +39,214 @@ _CACHE_DIRNAME = os.path.join("settings", "ai_image_cache")
|
||||
_META_SUFFIX = ".json"
|
||||
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||
|
||||
# 测试环境后端
|
||||
# API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
|
||||
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
|
||||
API_PATH = "api/v1/pqtest/generate"
|
||||
API_TIMEOUT = 300.0 # 后端最长 60s,留余量
|
||||
|
||||
# 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置
|
||||
_session_id: str = str(uuid.uuid4())
|
||||
_session_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_session_id() -> str:
|
||||
with _session_lock:
|
||||
return _session_id
|
||||
|
||||
|
||||
def set_session_id(session_id: str) -> str:
|
||||
"""切换到指定会话。空值会抛错"""
|
||||
global _session_id
|
||||
sid = (session_id or "").strip()
|
||||
if not sid:
|
||||
raise ValueError("session_id 不能为空")
|
||||
with _session_lock:
|
||||
old = _session_id
|
||||
_session_id = sid
|
||||
return _session_id
|
||||
|
||||
|
||||
def reset_session() -> str:
|
||||
"""开启新一轮会话,返回新的 session_id。"""
|
||||
global _session_id
|
||||
with _session_lock:
|
||||
old = _session_id
|
||||
_session_id = str(uuid.uuid4())
|
||||
return _session_id
|
||||
|
||||
|
||||
def _mask_sid(sid: str) -> str:
|
||||
"""日志安全展示:仅保留前 8 位。"""
|
||||
if not sid:
|
||||
return "(none)"
|
||||
return f"{sid[:8]}…"
|
||||
|
||||
|
||||
def _truncate(text: str, n: int = 80) -> str:
|
||||
s = (text or "").replace("\n", " ").strip()
|
||||
return s if len(s) <= n else s[:n] + "…"
|
||||
|
||||
|
||||
# ---------- 数据结构 ----------
|
||||
|
||||
|
||||
@dataclass
|
||||
class AIImageRecord:
|
||||
"""一条缓存记录。"""
|
||||
"""一条缓存记录。
|
||||
|
||||
字段说明:
|
||||
- ``id``: 唯一 id,等同于磁盘文件名(不含扩展名),格式 ``{时间戳}_{md5前8}``。
|
||||
- ``prompt``: 用户原始输入(完整保留,用于回溯/调试,不应被改写)。
|
||||
- ``title``: 用户自定义展示标题(重命名时写入),UI 优先使用,留空则回退 prompt 第一行截断。
|
||||
- ``image_path``: 图片在缓存目录中的绝对路径。
|
||||
- ``created_at``: ISO8601 时间字符串。
|
||||
- ``extra``: 其它元数据,至少包含 ``source`` 与 ``session_id``(标识属于哪一轮对话)。
|
||||
"""
|
||||
|
||||
id: str
|
||||
prompt: str
|
||||
image_path: str
|
||||
created_at: str # ISO8601
|
||||
extra: Optional[dict] = None
|
||||
title: Optional[str] = None
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
|
||||
|
||||
@property
|
||||
def display_name(self) -> str:
|
||||
"""UI 展示名:title 优先,否则回退 prompt 第一行。"""
|
||||
if self.title:
|
||||
return self.title.strip()
|
||||
first = (self.prompt or "").strip().splitlines()[0] if self.prompt else ""
|
||||
return first or "(未命名)"
|
||||
|
||||
# ---------- API 注入 ----------
|
||||
|
||||
# 调用签名: ``fn(prompt: str) -> (image_bytes: bytes, image_ext: str, extra: dict|None)``
|
||||
# ``image_ext`` 例如 ``".png"``;``extra`` 可为 None。
|
||||
_ApiCaller = Callable[[str], tuple]
|
||||
|
||||
_api_caller: Optional[_ApiCaller] = None
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
if isinstance(self.extra, dict):
|
||||
return str(self.extra.get("session_id") or "")
|
||||
return ""
|
||||
|
||||
|
||||
def set_api_caller(fn: Optional[_ApiCaller]) -> None:
|
||||
"""注入真实的后端 API 调用函数。在 API 就绪前可保持为 None。"""
|
||||
global _api_caller
|
||||
_api_caller = fn
|
||||
# ---------- 后端 API ----------
|
||||
|
||||
|
||||
def has_api() -> bool:
|
||||
return _api_caller is not None
|
||||
def _api_endpoint() -> str:
|
||||
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
|
||||
return base + API_PATH.lstrip("/")
|
||||
|
||||
|
||||
def _pretty_json_text(value) -> str:
|
||||
"""把对象或 JSON 字符串格式化为易读文本;失败则回退原始字符串。"""
|
||||
try:
|
||||
if isinstance(value, (dict, list)):
|
||||
return json.dumps(value, ensure_ascii=False, indent=2)
|
||||
text = "" if value is None else str(value)
|
||||
parsed = json.loads(text)
|
||||
return json.dumps(parsed, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str:
|
||||
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。"""
|
||||
payload = json.dumps(
|
||||
{"user_message": user_message,
|
||||
"session_id": session_id},
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
request_headers = {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "pqAutomationApp/1.0",
|
||||
}
|
||||
endpoint = _api_endpoint()
|
||||
logger.info(
|
||||
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
|
||||
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
|
||||
)
|
||||
logger.info(
|
||||
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
|
||||
endpoint,
|
||||
timeout,
|
||||
_pretty_json_text(request_headers),
|
||||
_pretty_json_text(payload.decode("utf-8", errors="replace")),
|
||||
)
|
||||
request = Request(
|
||||
endpoint,
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers=request_headers,
|
||||
)
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
with urlopen(request, timeout=timeout) as response:
|
||||
raw = response.read()
|
||||
http_status = response.status
|
||||
response_headers = dict(response.headers.items())
|
||||
raw_text = raw.decode("utf-8", errors="replace")
|
||||
logger.info(
|
||||
"[AIImage][RESPONSE]\nstatus=%s\nheaders=%s\nbody=%s",
|
||||
http_status,
|
||||
_pretty_json_text(response_headers),
|
||||
_pretty_json_text(raw_text),
|
||||
)
|
||||
except HTTPError as exc:
|
||||
elapsed = time.monotonic() - t0
|
||||
err_raw = b""
|
||||
try:
|
||||
err_raw = exc.read() or b""
|
||||
except Exception:
|
||||
err_raw = b""
|
||||
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
|
||||
err_headers = {}
|
||||
try:
|
||||
if exc.headers is not None:
|
||||
err_headers = dict(exc.headers.items())
|
||||
except Exception:
|
||||
err_headers = {}
|
||||
logger.error(
|
||||
"[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\nheaders=%s\nbody=%s",
|
||||
_mask_sid(session_id),
|
||||
elapsed,
|
||||
getattr(exc, "code", "?"),
|
||||
str(exc),
|
||||
_pretty_json_text(err_headers),
|
||||
_pretty_json_text(err_text),
|
||||
)
|
||||
raise
|
||||
except Exception as exc:
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.error(
|
||||
"[AIImage] 请求异常 sid=%s elapsed=%.2fs %s: %s",
|
||||
_mask_sid(session_id), elapsed, type(exc).__name__, exc,
|
||||
)
|
||||
raise
|
||||
elapsed = time.monotonic() - t0
|
||||
logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed)
|
||||
try:
|
||||
result = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raw_text = raw.decode("utf-8", errors="replace")
|
||||
logger.error("[AIImage] 响应解析失败 sid=%s raw=%s", _mask_sid(session_id), raw_text)
|
||||
raise RuntimeError(f"AI 接口返回非 JSON:{raw_text}") from exc
|
||||
|
||||
code = result.get("code")
|
||||
message = result.get("message") or ""
|
||||
data = result.get("data") or {}
|
||||
image_url = (data.get("imageUrl") or "").strip()
|
||||
if code != 200 or not image_url:
|
||||
logger.warning(
|
||||
"[AIImage] 接口失败 sid=%s code=%s msg=%r",
|
||||
_mask_sid(session_id), code, message,
|
||||
)
|
||||
raise RuntimeError(f"AI 接口失败 code={code} msg={message or '生成失败'}")
|
||||
logger.info(
|
||||
"[AIImage] 生成成功 sid=%s elapsed=%.2fs url=%s",
|
||||
_mask_sid(session_id), elapsed, image_url,
|
||||
)
|
||||
return image_url
|
||||
|
||||
|
||||
# ---------- 缓存路径工具 ----------
|
||||
@@ -83,6 +270,28 @@ def _meta_path_for(image_path: str) -> str:
|
||||
return os.path.splitext(image_path)[0] + _META_SUFFIX
|
||||
|
||||
|
||||
def _sanitize_image_bytes(image_bytes: bytes, image_ext: str) -> bytes:
|
||||
"""规范化图片字节,尽量去掉已知有问题的 PNG ICC profile。"""
|
||||
ext = (image_ext or "").lower()
|
||||
if ext not in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}:
|
||||
return image_bytes
|
||||
try:
|
||||
with Image.open(BytesIO(image_bytes)) as img:
|
||||
img.load()
|
||||
normalized = img.copy()
|
||||
output = BytesIO()
|
||||
save_kwargs = {}
|
||||
if ext == ".png":
|
||||
save_kwargs["icc_profile"] = None
|
||||
normalized.save(output, format=normalized.format or ext.lstrip(".").upper(), **save_kwargs)
|
||||
result = output.getvalue()
|
||||
if result:
|
||||
return result
|
||||
except Exception as exc:
|
||||
logger.warning("[AIImage] 图片规范化失败 ext=%s %s: %s", ext, type(exc).__name__, exc)
|
||||
return image_bytes
|
||||
|
||||
|
||||
# ---------- 读写 ----------
|
||||
|
||||
|
||||
@@ -98,6 +307,7 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
||||
prompt = ""
|
||||
created_at = ""
|
||||
extra = None
|
||||
title = None
|
||||
rec_id = os.path.splitext(name)[0]
|
||||
if os.path.isfile(meta_path):
|
||||
try:
|
||||
@@ -106,6 +316,7 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
||||
prompt = data.get("prompt", "")
|
||||
created_at = data.get("created_at", "")
|
||||
extra = data.get("extra")
|
||||
title = data.get("title")
|
||||
rec_id = data.get("id", rec_id)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -123,54 +334,27 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
||||
image_path=full,
|
||||
created_at=created_at,
|
||||
extra=extra,
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
if not records:
|
||||
seeded = _seed_placeholder_record(cache_dir)
|
||||
if seeded is not None:
|
||||
records.append(seeded)
|
||||
records.sort(key=lambda r: r.created_at, reverse=True)
|
||||
return records
|
||||
|
||||
|
||||
def _seed_placeholder_record(cache_dir: str) -> Optional[AIImageRecord]:
|
||||
"""当缓存为空时,写入一张本地占位图,便于前端联调。"""
|
||||
try:
|
||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
src = os.path.join(repo_root, "assets", "entry_1.png")
|
||||
if not os.path.isfile(src):
|
||||
return None
|
||||
|
||||
rec_id = f"{_dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_placeholder"
|
||||
image_path = os.path.join(cache_dir, f"{rec_id}.png")
|
||||
shutil.copyfile(src, image_path)
|
||||
|
||||
record = AIImageRecord(
|
||||
id=rec_id,
|
||||
prompt="本地测试占位图(后端未接入)",
|
||||
image_path=image_path,
|
||||
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
|
||||
extra={"source": "local-placeholder"},
|
||||
)
|
||||
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
|
||||
f.write(record.to_json())
|
||||
return record
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def save_image_to_cache(
|
||||
prompt: str,
|
||||
image_bytes: bytes,
|
||||
image_ext: str = ".png",
|
||||
extra: Optional[dict] = None,
|
||||
base_dir: Optional[str] = None,
|
||||
title: Optional[str] = None,
|
||||
) -> AIImageRecord:
|
||||
"""把生成的图片字节写入缓存,返回记录。"""
|
||||
if not image_ext.startswith("."):
|
||||
image_ext = "." + image_ext
|
||||
if image_ext.lower() not in _SUPPORTED_IMG_EXT:
|
||||
image_ext = ".png"
|
||||
image_bytes = _sanitize_image_bytes(image_bytes, image_ext)
|
||||
cache_dir = get_cache_dir(base_dir)
|
||||
rec_id = _make_id(prompt)
|
||||
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
|
||||
@@ -183,6 +367,7 @@ def save_image_to_cache(
|
||||
image_path=image_path,
|
||||
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
|
||||
extra=extra,
|
||||
title=title,
|
||||
)
|
||||
try:
|
||||
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
|
||||
@@ -256,6 +441,53 @@ def export_record(record: AIImageRecord, dest_path: str) -> None:
|
||||
shutil.copyfile(record.image_path, dest_path)
|
||||
|
||||
|
||||
def update_record_title(record: AIImageRecord, new_title: Optional[str]) -> bool:
|
||||
"""更新记录的展示标题并写回侧车 JSON。空串/None 视为清除标题。"""
|
||||
title = (new_title or "").strip() or None
|
||||
meta_path = _meta_path_for(record.image_path)
|
||||
try:
|
||||
data: dict = {}
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f) or {}
|
||||
if title is None:
|
||||
data.pop("title", None)
|
||||
else:
|
||||
data["title"] = title
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
return False
|
||||
record.title = title
|
||||
return True
|
||||
|
||||
|
||||
def group_records_by_session(records: List[AIImageRecord]) -> List[dict]:
|
||||
"""按 ``session_id`` 分组。
|
||||
|
||||
返回元素:``{"session_id", "records", "started_at", "latest_at"}``。
|
||||
会话按"最近使用时间"倒序,组内记录按时间倒序。
|
||||
没有 session_id 的记录归到空串 ``""`` 组。
|
||||
"""
|
||||
buckets: dict = {}
|
||||
for rec in records:
|
||||
buckets.setdefault(rec.session_id, []).append(rec)
|
||||
sessions = []
|
||||
for sid, recs in buckets.items():
|
||||
recs.sort(key=lambda r: r.created_at, reverse=True)
|
||||
started_at = min((r.created_at for r in recs if r.created_at), default="")
|
||||
sessions.append(
|
||||
{
|
||||
"session_id": sid,
|
||||
"records": recs,
|
||||
"started_at": started_at,
|
||||
"latest_at": recs[0].created_at if recs else "",
|
||||
}
|
||||
)
|
||||
sessions.sort(key=lambda s: s["latest_at"], reverse=True)
|
||||
return sessions
|
||||
|
||||
|
||||
# ---------- 异步请求 ----------
|
||||
|
||||
|
||||
@@ -264,27 +496,51 @@ def request_image_async(
|
||||
on_success: Callable[[AIImageRecord], None],
|
||||
on_error: Callable[[Exception], None],
|
||||
base_dir: Optional[str] = None,
|
||||
session_id: Optional[str] = None,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> threading.Thread:
|
||||
"""在后台线程请求 API → 写入缓存 → 回调。
|
||||
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
|
||||
|
||||
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用;UI 侧若需
|
||||
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
||||
|
||||
``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。
|
||||
"""
|
||||
|
||||
sid = session_id or get_session_id()
|
||||
cancel = cancel_event
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
if _api_caller is None:
|
||||
raise RuntimeError("AI 图片 API 尚未接入,请调用 set_api_caller 注入")
|
||||
image_bytes, image_ext, extra = _normalize_api_result(_api_caller(prompt))
|
||||
record = save_image_to_cache(
|
||||
if cancel is not None and cancel.is_set():
|
||||
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
|
||||
return
|
||||
image_url = _call_pqtest_generate(prompt, sid)
|
||||
if cancel is not None and cancel.is_set():
|
||||
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
|
||||
return
|
||||
record = import_image_from_url(
|
||||
image_url=image_url,
|
||||
prompt=prompt,
|
||||
image_bytes=image_bytes,
|
||||
image_ext=image_ext,
|
||||
extra=extra,
|
||||
extra={"source": "ai-api", "session_id": sid},
|
||||
base_dir=base_dir,
|
||||
)
|
||||
if cancel is not None and cancel.is_set():
|
||||
logger.info("[AIImage] 任务已取消(下载后) sid=%s", _mask_sid(sid))
|
||||
return
|
||||
logger.info(
|
||||
"[AIImage] 已写入缓存 sid=%s id=%s path=%s",
|
||||
_mask_sid(sid), record.id, record.image_path,
|
||||
)
|
||||
on_success(record)
|
||||
except Exception as exc:
|
||||
if cancel is not None and cancel.is_set():
|
||||
logger.info("[AIImage] 任务已取消(异常忽略) sid=%s", _mask_sid(sid))
|
||||
return
|
||||
logger.error(
|
||||
"[AIImage] 生成流程失败 sid=%s %s: %s",
|
||||
_mask_sid(sid), type(exc).__name__, exc,
|
||||
)
|
||||
on_error(exc)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
@@ -300,11 +556,15 @@ def import_image_from_url_async(
|
||||
extra: Optional[dict] = None,
|
||||
base_dir: Optional[str] = None,
|
||||
timeout: float = 20.0,
|
||||
cancel_event: Optional[threading.Event] = None,
|
||||
) -> threading.Thread:
|
||||
"""在后台线程下载远程图片并写入缓存"""
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.info("[AIImage] URL 导入任务已取消(请求前)")
|
||||
return
|
||||
record = import_image_from_url(
|
||||
image_url=image_url,
|
||||
prompt=prompt,
|
||||
@@ -312,8 +572,14 @@ def import_image_from_url_async(
|
||||
base_dir=base_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.info("[AIImage] URL 导入任务已取消(下载后)")
|
||||
return
|
||||
on_success(record)
|
||||
except Exception as exc:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
logger.info("[AIImage] URL 导入任务已取消(异常忽略)")
|
||||
return
|
||||
on_error(exc)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
@@ -321,18 +587,6 @@ def import_image_from_url_async(
|
||||
return t
|
||||
|
||||
|
||||
def _normalize_api_result(result):
|
||||
"""允许 API 返回 ``bytes`` 或 ``(bytes, ext)`` 或 ``(bytes, ext, extra)``。"""
|
||||
if isinstance(result, (bytes, bytearray)):
|
||||
return bytes(result), ".png", None
|
||||
if isinstance(result, tuple):
|
||||
if len(result) == 2:
|
||||
return bytes(result[0]), str(result[1]), None
|
||||
if len(result) == 3:
|
||||
return bytes(result[0]), str(result[1]), result[2]
|
||||
raise ValueError("API 返回格式不支持,需为 bytes 或 (bytes, ext[, extra])")
|
||||
|
||||
|
||||
def is_remote_image_url(value: str) -> bool:
|
||||
"""判断输入是否为 http/https 图片地址。"""
|
||||
url = (value or "").strip()
|
||||
|
||||
191
app/services/pattern_service.py
Normal file
191
app/services/pattern_service.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.data_range_converter import convert_pattern_params
|
||||
from app.pq.pq_config import get_pattern
|
||||
from drivers.ucd_helpers import send_solid_rgb_pattern
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternSession:
|
||||
mode: str
|
||||
test_type: str
|
||||
active_config: object
|
||||
pattern_params: list[list[int]]
|
||||
total_patterns: int
|
||||
display_names: list[str]
|
||||
|
||||
|
||||
class PatternService:
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
def prepare_session(self, mode, *, test_type=None, log_details=False):
|
||||
test_type = test_type or self.app.config.current_test_type
|
||||
if not self.app.config.set_current_pattern(mode):
|
||||
raise ValueError(f"未知的图案模式: {mode}")
|
||||
|
||||
active_config = self.app.config
|
||||
source_params = self._get_source_pattern_params(mode)
|
||||
|
||||
if test_type == "screen_module":
|
||||
if log_details:
|
||||
self._log("=" * 50, "separator")
|
||||
self._log("设置屏模组信号格式:", "info")
|
||||
self._log("=" * 50, "separator")
|
||||
self._log(
|
||||
f" Timing: {self.app.config.current_test_types[test_type]['timing']}",
|
||||
"info",
|
||||
)
|
||||
self.app.ucd.set_ucd_params(active_config)
|
||||
elif test_type == "sdr_movie":
|
||||
active_config = self._prepare_video_session(
|
||||
mode=mode,
|
||||
test_type=test_type,
|
||||
source_params=source_params,
|
||||
data_range=self.app.sdr_data_range_var.get(),
|
||||
log_title="设置 SDR 信号格式:",
|
||||
setup_message=f"设置 SDR 测试图案({mode} 模式)..."
|
||||
if mode != "accuracy"
|
||||
else "设置 SDR 29色色准测试图案...",
|
||||
setup_format=lambda: self.app.ucd.set_sdr_format(
|
||||
color_space=self.app.sdr_color_space_var.get(),
|
||||
gamma=self.app.sdr_gamma_type_var.get(),
|
||||
data_range=self.app.sdr_data_range_var.get(),
|
||||
bit_depth=self.app.sdr_bit_depth_var.get(),
|
||||
),
|
||||
log_items=[
|
||||
("色彩空间", self.app.sdr_color_space_var.get()),
|
||||
("Gamma", self.app.sdr_gamma_type_var.get()),
|
||||
("数据范围", self.app.sdr_data_range_var.get()),
|
||||
("编码位深", self.app.sdr_bit_depth_var.get()),
|
||||
],
|
||||
log_details=log_details,
|
||||
)
|
||||
elif test_type == "hdr_movie":
|
||||
active_config = self._prepare_video_session(
|
||||
mode=mode,
|
||||
test_type=test_type,
|
||||
source_params=source_params,
|
||||
data_range=self.app.hdr_data_range_var.get(),
|
||||
log_title="设置 HDR 信号格式:",
|
||||
setup_message=f"设置 HDR 测试图案({mode} 模式)..."
|
||||
if mode != "accuracy"
|
||||
else "设置 HDR 29色色准测试图案...",
|
||||
setup_format=lambda: self.app.ucd.set_hdr_format(
|
||||
color_space=self.app.hdr_color_space_var.get(),
|
||||
data_range=self.app.hdr_data_range_var.get(),
|
||||
bit_depth=self.app.hdr_bit_depth_var.get(),
|
||||
max_cll=self.app.hdr_maxcll_var.get(),
|
||||
max_fall=self.app.hdr_maxfall_var.get(),
|
||||
),
|
||||
log_items=[
|
||||
("色彩空间", self.app.hdr_color_space_var.get()),
|
||||
("数据范围", self.app.hdr_data_range_var.get()),
|
||||
("编码位深", self.app.hdr_bit_depth_var.get()),
|
||||
("MaxCLL", self.app.hdr_maxcll_var.get()),
|
||||
("MaxFALL", self.app.hdr_maxfall_var.get()),
|
||||
],
|
||||
log_details=log_details,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"不支持的测试类型: {test_type}")
|
||||
|
||||
pattern_params = copy.deepcopy(active_config.current_pattern["pattern_params"])
|
||||
return PatternSession(
|
||||
mode=mode,
|
||||
test_type=test_type,
|
||||
active_config=active_config,
|
||||
pattern_params=pattern_params,
|
||||
total_patterns=len(pattern_params),
|
||||
display_names=self._get_display_names(mode, len(pattern_params)),
|
||||
)
|
||||
|
||||
def send_session_pattern(self, session, index):
|
||||
if index < 0 or index >= session.total_patterns:
|
||||
raise IndexError(f"pattern 索引越界: {index}")
|
||||
|
||||
pattern_param = session.pattern_params[index]
|
||||
if not self.app.ucd.send_current_pattern_params(pattern_param):
|
||||
raise RuntimeError(f"发送 pattern 失败: {index}")
|
||||
return pattern_param
|
||||
|
||||
def send_rgb(self, rgb, *, session=None, test_type=None):
|
||||
active_session = session or self.prepare_session(
|
||||
"rgb",
|
||||
test_type=test_type,
|
||||
log_details=False,
|
||||
)
|
||||
converted_rgb = self._convert_rgb_for_test_type(rgb, active_session.test_type)
|
||||
send_solid_rgb_pattern(self.app.ucd, converted_rgb, raise_on_error=True)
|
||||
return True
|
||||
|
||||
def _prepare_video_session(
|
||||
self,
|
||||
*,
|
||||
mode,
|
||||
test_type,
|
||||
source_params,
|
||||
data_range,
|
||||
log_title,
|
||||
setup_message,
|
||||
setup_format,
|
||||
log_items,
|
||||
log_details,
|
||||
):
|
||||
if log_details:
|
||||
self._log("=" * 50, "separator")
|
||||
self._log(log_title, "info")
|
||||
self._log("=" * 50, "separator")
|
||||
for label, value in log_items:
|
||||
self._log(f" {label}: {value}", "info")
|
||||
|
||||
success = setup_format()
|
||||
if log_details:
|
||||
self._log(
|
||||
f"{test_type.split('_')[0].upper()} 信号格式设置{'成功' if success else '失败'}",
|
||||
"success" if success else "error",
|
||||
)
|
||||
self._log(setup_message, "info")
|
||||
|
||||
converted_params = convert_pattern_params(
|
||||
pattern_params=source_params,
|
||||
data_range=data_range,
|
||||
verbose=False,
|
||||
)
|
||||
active_config = self.app.config.get_temp_config_with_converted_params(
|
||||
mode=mode,
|
||||
converted_params=converted_params,
|
||||
)
|
||||
self.app.ucd.set_ucd_params(active_config)
|
||||
|
||||
if log_details:
|
||||
self._log(f"图案参数已设置,共 {len(converted_params)} 个图案", "success")
|
||||
|
||||
return active_config
|
||||
|
||||
def _get_source_pattern_params(self, mode):
|
||||
return copy.deepcopy(get_pattern(mode)["pattern_params"])
|
||||
|
||||
def _get_display_names(self, mode, total_patterns):
|
||||
if mode == "accuracy":
|
||||
return self.app.config.get_accuracy_color_names()
|
||||
if mode == "custom" and hasattr(self.app.config, "get_temp_pattern_names"):
|
||||
return self.app.config.get_temp_pattern_names()
|
||||
return [f"P {index + 1}" for index in range(total_patterns)]
|
||||
|
||||
def _convert_rgb_for_test_type(self, rgb, test_type):
|
||||
if test_type == "sdr_movie":
|
||||
data_range = self.app.sdr_data_range_var.get()
|
||||
elif test_type == "hdr_movie":
|
||||
data_range = self.app.hdr_data_range_var.get()
|
||||
else:
|
||||
data_range = "Full"
|
||||
|
||||
return convert_pattern_params([list(rgb)], data_range=data_range, verbose=False)[0]
|
||||
|
||||
def _log(self, message, level):
|
||||
if hasattr(self.app, "log_gui"):
|
||||
self.app.log_gui.log(message, level=level)
|
||||
@@ -15,6 +15,24 @@ def init_gamut_chart(self):
|
||||
container = ttk.Frame(self.gamut_chart_frame)
|
||||
container.pack(expand=True, fill=tk.BOTH)
|
||||
|
||||
# ---- 参考色域切换工具栏 ----
|
||||
toolbar = ttk.Frame(container)
|
||||
toolbar.pack(fill=tk.X, padx=8, pady=(4, 2))
|
||||
|
||||
ttk.Label(toolbar, text="参考色域标准:").pack(side=tk.LEFT, padx=(0, 8))
|
||||
|
||||
self._gamut_ref_toolbar_var = tk.StringVar(value="DCI-P3")
|
||||
for std in ["BT.709", "DCI-P3", "BT.2020", "BT.601"]:
|
||||
rb = ttk.Radiobutton(
|
||||
toolbar, text=std,
|
||||
variable=self._gamut_ref_toolbar_var,
|
||||
value=std,
|
||||
bootstyle="toolbutton",
|
||||
command=lambda s=std: self._on_gamut_toolbar_changed(s),
|
||||
)
|
||||
rb.pack(side=tk.LEFT, padx=2)
|
||||
|
||||
# ---- matplotlib 图表 ----
|
||||
self.gamut_fig = plt.Figure(figsize=(14, 6), dpi=100)
|
||||
self.gamut_canvas = FigureCanvasTkAgg(self.gamut_fig, master=container)
|
||||
|
||||
@@ -29,16 +47,16 @@ def init_gamut_chart(self):
|
||||
[0.52, 0.08, 0.46, 0.84]
|
||||
) # ← 改回 0.84
|
||||
|
||||
# 初始化XY图
|
||||
self.gamut_ax_xy.set_xlim(0, 600)
|
||||
self.gamut_ax_xy.set_ylim(600, 0)
|
||||
self.gamut_ax_xy.axis("off")
|
||||
# 初始化 XY 图(占位坐标系,真实绘制时由 plot_gamut 设置 CIE 1931 范围)
|
||||
self.gamut_ax_xy.set_xlim(0.0, 0.8)
|
||||
self.gamut_ax_xy.set_ylim(0.0, 0.9)
|
||||
self.gamut_ax_xy.set_aspect("equal", adjustable="datalim")
|
||||
self.gamut_ax_xy.set_clip_on(False)
|
||||
|
||||
# 初始化UV图
|
||||
self.gamut_ax_uv.set_xlim(0, 600)
|
||||
self.gamut_ax_uv.set_ylim(600, 0)
|
||||
self.gamut_ax_uv.axis("off")
|
||||
# 初始化 UV 图(占位坐标系,真实绘制时由 plot_gamut 设置 CIE 1976 范围)
|
||||
self.gamut_ax_uv.set_xlim(0.0, 0.65)
|
||||
self.gamut_ax_uv.set_ylim(0.0, 0.6)
|
||||
self.gamut_ax_uv.set_aspect("equal", adjustable="datalim")
|
||||
self.gamut_ax_uv.set_clip_on(False)
|
||||
|
||||
# 调整标题位置:y=0.98
|
||||
@@ -46,6 +64,47 @@ def init_gamut_chart(self):
|
||||
|
||||
self.gamut_canvas.draw()
|
||||
|
||||
|
||||
def sync_gamut_toolbar(self):
|
||||
"""将工具栏参考标准按钮同步为当前测试类型的 ref var 值。"""
|
||||
if not hasattr(self, "_gamut_ref_toolbar_var"):
|
||||
return
|
||||
test_type = getattr(self.config, "current_test_type", "screen_module")
|
||||
var_map = {
|
||||
"screen_module": "screen_gamut_ref_var",
|
||||
"sdr_movie": "sdr_gamut_ref_var",
|
||||
"hdr_movie": "hdr_gamut_ref_var",
|
||||
}
|
||||
attr = var_map.get(test_type)
|
||||
if attr and hasattr(self, attr):
|
||||
self._gamut_ref_toolbar_var.set(getattr(self, attr).get())
|
||||
|
||||
|
||||
def _on_gamut_toolbar_changed(self, std):
|
||||
"""用户点击工具栏参考标准按钮时:更新 var → 保存配置 → 重绘(有数据时)。"""
|
||||
test_type = self.config.current_test_type
|
||||
var_map = {
|
||||
"screen_module": "screen_gamut_ref_var",
|
||||
"sdr_movie": "sdr_gamut_ref_var",
|
||||
"hdr_movie": "hdr_gamut_ref_var",
|
||||
}
|
||||
attr = var_map.get(test_type)
|
||||
if attr and hasattr(self, attr):
|
||||
getattr(self, attr).set(std)
|
||||
|
||||
# 保存到配置
|
||||
if test_type not in self.config.current_test_types:
|
||||
self.config.current_test_types[test_type] = {}
|
||||
self.config.current_test_types[test_type]["gamut_reference"] = std
|
||||
self.save_pq_config()
|
||||
|
||||
# 仅在有色域数据时才重新绘制,避免无数据时弹出警告框
|
||||
if hasattr(self, "results") and self.results:
|
||||
rgb_data = self.results.get_intermediate_data("gamut", "rgb")
|
||||
if rgb_data and len(rgb_data) >= 3:
|
||||
self.recalculate_gamut()
|
||||
|
||||
|
||||
def init_gamma_chart(self):
|
||||
"""初始化Gamma曲线图表 - 左侧曲线 + 右侧表格(4列 + 通用说明)"""
|
||||
container = ttk.Frame(self.gamma_chart_frame)
|
||||
@@ -600,9 +659,12 @@ def clear_chart(self):
|
||||
self.eotf_canvas.draw()
|
||||
|
||||
# ========== 4. 清空色度图表 ==========
|
||||
if hasattr(self, "cct_ax1") and hasattr(self, "cct_ax2"):
|
||||
# 上图:x coordinates
|
||||
self.cct_ax1.clear()
|
||||
# 注意:plot_cct 会调用 cct_fig.clear() 并重建 subplots,导致 self.cct_ax1/ax2 变成
|
||||
# 过期引用。因此清空时必须同样重建,并更新引用,否则清不干净。
|
||||
if hasattr(self, "cct_fig") and hasattr(self, "cct_canvas"):
|
||||
self.cct_fig.clear()
|
||||
|
||||
self.cct_ax1 = self.cct_fig.add_subplot(211)
|
||||
self.cct_ax1.set_xlabel("灰阶 (%)", fontsize=9)
|
||||
self.cct_ax1.set_ylabel("CIE x", fontsize=9)
|
||||
self.cct_ax1.set_xlim(0, 105)
|
||||
@@ -610,8 +672,7 @@ def clear_chart(self):
|
||||
self.cct_ax1.grid(True, linestyle="--", alpha=0.3)
|
||||
self.cct_ax1.tick_params(labelsize=8)
|
||||
|
||||
# 下图:y coordinates
|
||||
self.cct_ax2.clear()
|
||||
self.cct_ax2 = self.cct_fig.add_subplot(212)
|
||||
self.cct_ax2.set_xlabel("灰阶 (%)", fontsize=9)
|
||||
self.cct_ax2.set_ylabel("CIE y", fontsize=9)
|
||||
self.cct_ax2.set_xlim(0, 105)
|
||||
@@ -620,8 +681,6 @@ def clear_chart(self):
|
||||
self.cct_ax2.tick_params(labelsize=8)
|
||||
|
||||
self.cct_fig.suptitle("色度一致性测试", fontsize=12, y=0.985)
|
||||
|
||||
# 重置布局
|
||||
self.cct_fig.subplots_adjust(
|
||||
left=0.12,
|
||||
right=0.88,
|
||||
@@ -629,7 +688,6 @@ def clear_chart(self):
|
||||
bottom=0.08,
|
||||
hspace=0.25,
|
||||
)
|
||||
|
||||
self.cct_canvas.draw()
|
||||
|
||||
# ========== 5. 清空对比度图表 ==========
|
||||
|
||||
@@ -28,6 +28,11 @@ def create_ai_image_panel(self):
|
||||
self.ai_image_current = None # AIImageRecord | None
|
||||
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
|
||||
self._ai_image_requesting = False
|
||||
self._ai_image_progress_job = None
|
||||
self._ai_image_progress_phase = 0
|
||||
self._ai_image_cancel_event = None
|
||||
self._ai_image_request_seq = 0
|
||||
self._ai_image_active_seq = 0
|
||||
|
||||
container = ttk.Frame(frame, padding=10)
|
||||
container.pack(fill=tk.BOTH, expand=True)
|
||||
@@ -150,11 +155,30 @@ def create_ai_image_panel(self):
|
||||
send_row, textvariable=self.ai_image_status_var,
|
||||
foreground="#888", font=("微软雅黑", 9),
|
||||
).pack(side=tk.LEFT)
|
||||
self.ai_image_progress = ttk.Progressbar(
|
||||
send_row,
|
||||
mode="indeterminate",
|
||||
length=120,
|
||||
bootstyle="info-striped",
|
||||
)
|
||||
self.ai_image_progress.pack(side=tk.LEFT, padx=(8, 0))
|
||||
self.ai_image_progress.pack_forget()
|
||||
self.ai_image_send_btn = ttk.Button(
|
||||
send_row, text="发送", bootstyle="primary", width=10,
|
||||
command=lambda: _send_prompt(self),
|
||||
)
|
||||
self.ai_image_send_btn.pack(side=tk.RIGHT)
|
||||
self.ai_image_new_session_btn = ttk.Button(
|
||||
send_row, text="新对话", bootstyle="secondary-outline", width=10,
|
||||
command=lambda: _start_new_session(self),
|
||||
)
|
||||
self.ai_image_new_session_btn.pack(side=tk.RIGHT, padx=(0, 6))
|
||||
self.ai_image_stop_btn = ttk.Button(
|
||||
send_row, text="停止", bootstyle="danger-outline", width=10,
|
||||
command=lambda: _stop_request(self),
|
||||
)
|
||||
self.ai_image_stop_btn.pack(side=tk.RIGHT, padx=(0, 6))
|
||||
self.ai_image_stop_btn.configure(state=tk.DISABLED)
|
||||
|
||||
# 注册面板
|
||||
self.register_panel("ai_image", frame, None, "ai_image_visible")
|
||||
@@ -172,18 +196,51 @@ def toggle_ai_image_panel(self):
|
||||
# ---------------- 列表 / 选中 ----------------
|
||||
|
||||
|
||||
def reload_ai_image_list(self):
|
||||
"""重新扫描缓存并刷新列表。"""
|
||||
def reload_ai_image_list(self, auto_select_first=True):
|
||||
"""重新扫描缓存并刷新列表。
|
||||
|
||||
按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``),
|
||||
其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。
|
||||
auto_select_first: 是否自动选中第一张图片(默认 True)。
|
||||
"""
|
||||
self.ai_image_records = _svc.list_records()
|
||||
self.ai_image_listbox.delete(0, tk.END)
|
||||
for rec in self.ai_image_records:
|
||||
label = _format_list_label(rec)
|
||||
self.ai_image_listbox.insert(tk.END, label)
|
||||
if self.ai_image_records:
|
||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||
self.ai_image_listbox.selection_set(0)
|
||||
self.ai_image_listbox.activate(0)
|
||||
_select_record(self, self.ai_image_records[0])
|
||||
# 维护行号 → 记录索引的映射;分隔头处为 None
|
||||
self._ai_image_row_map = []
|
||||
self._ai_image_row_session_map = []
|
||||
sessions = _svc.group_records_by_session(self.ai_image_records)
|
||||
flat = []
|
||||
current_sid = _svc.get_session_id()
|
||||
for idx, sess in enumerate(sessions, start=1):
|
||||
sid = sess["session_id"]
|
||||
is_current = sid and sid == current_sid
|
||||
header = _format_session_header(idx, sess, is_current=is_current)
|
||||
self.ai_image_listbox.insert(tk.END, header)
|
||||
# 头部行:禁用选中(视觉上变灰)
|
||||
last = self.ai_image_listbox.size() - 1
|
||||
self.ai_image_listbox.itemconfig(
|
||||
last, foreground="#888", selectforeground="#888",
|
||||
background="#f5f5f5", selectbackground="#f5f5f5",
|
||||
)
|
||||
self._ai_image_row_map.append(None)
|
||||
self._ai_image_row_session_map.append(sid)
|
||||
for rec in sess["records"]:
|
||||
label = " " + _format_list_label(rec)
|
||||
self.ai_image_listbox.insert(tk.END, label)
|
||||
self._ai_image_row_map.append(len(flat))
|
||||
self._ai_image_row_session_map.append(rec.session_id)
|
||||
flat.append(rec)
|
||||
# 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等)
|
||||
self.ai_image_records = flat
|
||||
if self.ai_image_records and auto_select_first:
|
||||
# 选中第一张实际记录
|
||||
for row, ridx in enumerate(self._ai_image_row_map):
|
||||
if ridx is not None:
|
||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||
self.ai_image_listbox.selection_set(row)
|
||||
self.ai_image_listbox.activate(row)
|
||||
_select_record(self, self.ai_image_records[ridx])
|
||||
break
|
||||
else:
|
||||
self.ai_image_current = None
|
||||
self.ai_image_photo = None
|
||||
@@ -191,6 +248,14 @@ def reload_ai_image_list(self):
|
||||
self.ai_image_meta_var.set("暂无缓存图片")
|
||||
|
||||
|
||||
def _format_session_header(index: int, sess: dict, is_current: bool) -> str:
|
||||
started = (sess.get("started_at") or "").replace("T", " ")[:16]
|
||||
tag = "(当前)" if is_current else ""
|
||||
if sess.get("session_id"):
|
||||
return f"── 会话 #{index} · {started} {tag}──"
|
||||
return f"── 未归类 · {started} ──"
|
||||
|
||||
|
||||
def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
||||
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
|
||||
size_tag = ""
|
||||
@@ -205,21 +270,34 @@ def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
prompt_line = (rec.prompt or "(无提示)").strip().splitlines()[0]
|
||||
# 剩余可用宽度(width=34)去掉 size_tag
|
||||
max_prompt = 34 - len(size_tag) - 2
|
||||
if max_prompt > 4 and len(prompt_line) > max_prompt:
|
||||
prompt_line = prompt_line[:max_prompt] + "…"
|
||||
return f"{size_tag}{prompt_line}"
|
||||
name_line = rec.display_name.splitlines()[0] if rec.display_name else "(未命名)"
|
||||
# 列表宽度 width=34,需要扣除两格缩进 + size_tag
|
||||
max_name = 34 - 2 - len(size_tag) - 2
|
||||
if max_name > 4 and len(name_line) > max_name:
|
||||
name_line = name_line[:max_name] + "…"
|
||||
return f"{size_tag}{name_line}"
|
||||
|
||||
|
||||
def _on_list_select(self):
|
||||
sel = self.ai_image_listbox.curselection()
|
||||
if not sel:
|
||||
return
|
||||
idx = sel[0]
|
||||
if 0 <= idx < len(self.ai_image_records):
|
||||
_select_record(self, self.ai_image_records[idx])
|
||||
row = sel[0]
|
||||
row_map = getattr(self, "_ai_image_row_map", None) or []
|
||||
if row >= len(row_map):
|
||||
return
|
||||
ridx = row_map[row]
|
||||
if ridx is None:
|
||||
session_id = _session_id_for_row(self, row)
|
||||
if session_id:
|
||||
_switch_to_session(self, session_id, show_message=False)
|
||||
self.ai_image_listbox.selection_clear(row)
|
||||
return
|
||||
if 0 <= ridx < len(self.ai_image_records):
|
||||
rec = self.ai_image_records[ridx]
|
||||
if rec.session_id:
|
||||
_switch_to_session(self, rec.session_id, show_message=False, target_record_id=rec.id)
|
||||
_select_record(self, rec)
|
||||
|
||||
|
||||
def _select_record(self, rec: _svc.AIImageRecord):
|
||||
@@ -243,6 +321,7 @@ def _redraw_preview(self):
|
||||
ch = canvas.winfo_height() or 1
|
||||
try:
|
||||
img = Image.open(rec.image_path)
|
||||
img.load()
|
||||
except Exception as exc:
|
||||
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
|
||||
return
|
||||
@@ -257,6 +336,57 @@ def _redraw_preview(self):
|
||||
# ---------------- 发送 / 保存 / 删除 ----------------
|
||||
|
||||
|
||||
def _start_new_session(self):
|
||||
"""开启新的对话会话,后续生成将使用新的 session_id。"""
|
||||
if getattr(self, "_ai_image_requesting", False):
|
||||
messagebox.showinfo("提示", "请等待当前请求完成")
|
||||
return
|
||||
_svc.reset_session()
|
||||
self.ai_image_status_var.set("已开启新对话")
|
||||
reload_ai_image_list(self, auto_select_first=False)
|
||||
|
||||
|
||||
def _session_id_for_row(self, row: int) -> str:
|
||||
session_map = getattr(self, "_ai_image_row_session_map", None) or []
|
||||
if row < 0 or row >= len(session_map):
|
||||
return ""
|
||||
return session_map[row] or ""
|
||||
|
||||
|
||||
def _switch_to_session(self, session_id: str, show_message: bool = True, target_record_id: str = ""):
|
||||
sid = (session_id or "").strip()
|
||||
if not sid:
|
||||
return
|
||||
if sid == _svc.get_session_id():
|
||||
return
|
||||
_svc.set_session_id(sid)
|
||||
reload_ai_image_list(self)
|
||||
if target_record_id:
|
||||
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
|
||||
if ridx is None:
|
||||
continue
|
||||
rec = self.ai_image_records[ridx]
|
||||
if rec.id == target_record_id:
|
||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||
self.ai_image_listbox.selection_set(row)
|
||||
self.ai_image_listbox.activate(row)
|
||||
self.ai_image_listbox.see(row)
|
||||
_select_record(self, rec)
|
||||
break
|
||||
self.ai_image_status_var.set("已切换到历史对话")
|
||||
if show_message:
|
||||
messagebox.showinfo("提示", "已切换到所选历史对话")
|
||||
|
||||
|
||||
def _update_request_progress(self):
|
||||
if not getattr(self, "_ai_image_requesting", False):
|
||||
self._ai_image_progress_job = None
|
||||
return
|
||||
self.ai_image_status_var.set("正在生成图片…")
|
||||
self._ai_image_progress_phase += 1
|
||||
self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self))
|
||||
|
||||
|
||||
def _send_prompt(self):
|
||||
if getattr(self, "_ai_image_requesting", False):
|
||||
return
|
||||
@@ -265,47 +395,69 @@ def _send_prompt(self):
|
||||
messagebox.showinfo("提示", "请输入内容")
|
||||
return
|
||||
|
||||
self._ai_image_request_seq += 1
|
||||
req_seq = self._ai_image_request_seq
|
||||
self._ai_image_active_seq = req_seq
|
||||
self._ai_image_cancel_event = threading.Event()
|
||||
_set_requesting(self, True)
|
||||
is_remote_url = _svc.is_remote_image_url(prompt)
|
||||
self.ai_image_status_var.set("下载中…" if is_remote_url else "请求中…")
|
||||
self.ai_image_status_var.set("下载中…" if is_remote_url else "后端处理中…")
|
||||
|
||||
def _success(record):
|
||||
self.root.after(0, lambda: _on_request_done(self, record, None))
|
||||
self.root.after(0, lambda: _on_request_done(self, record, None, req_seq))
|
||||
|
||||
def _error(exc):
|
||||
self.root.after(0, lambda: _on_request_done(self, None, exc))
|
||||
self.root.after(0, lambda: _on_request_done(self, None, exc, req_seq))
|
||||
|
||||
if is_remote_url:
|
||||
_svc.import_image_from_url_async(
|
||||
prompt,
|
||||
on_success=_success,
|
||||
on_error=_error,
|
||||
cancel_event=self._ai_image_cancel_event,
|
||||
)
|
||||
return
|
||||
|
||||
if not _svc.has_api():
|
||||
_set_requesting(self, False)
|
||||
self.ai_image_status_var.set("就绪")
|
||||
messagebox.showerror(
|
||||
"API 未配置",
|
||||
"AI 图片 API 尚未接入。\n"
|
||||
"可直接输入图片 URL 导入,或在启动时通过 "
|
||||
"app.services.ai_image.set_api_caller(...) 注入真实实现。",
|
||||
)
|
||||
return
|
||||
|
||||
_svc.request_image_async(prompt, on_success=_success, on_error=_error)
|
||||
_svc.request_image_async(
|
||||
prompt,
|
||||
on_success=_success,
|
||||
on_error=_error,
|
||||
cancel_event=self._ai_image_cancel_event,
|
||||
)
|
||||
|
||||
|
||||
def _set_requesting(self, flag: bool):
|
||||
self._ai_image_requesting = flag
|
||||
try:
|
||||
self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
|
||||
self.ai_image_new_session_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
|
||||
self.ai_image_stop_btn.configure(state=tk.NORMAL if flag else tk.DISABLED)
|
||||
except Exception:
|
||||
pass
|
||||
if flag:
|
||||
self._ai_image_progress_phase = 0
|
||||
self.ai_image_progress.pack(side=tk.LEFT, padx=(8, 0))
|
||||
self.ai_image_progress.start(10)
|
||||
if self._ai_image_progress_job is not None:
|
||||
self.root.after_cancel(self._ai_image_progress_job)
|
||||
self._ai_image_progress_job = self.root.after(0, lambda: _update_request_progress(self))
|
||||
else:
|
||||
if self._ai_image_progress_job is not None:
|
||||
self.root.after_cancel(self._ai_image_progress_job)
|
||||
self._ai_image_progress_job = None
|
||||
try:
|
||||
self.ai_image_progress.stop()
|
||||
self.ai_image_progress.pack_forget()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _on_request_done(self, record, exc):
|
||||
def _on_request_done(self, record, exc, req_seq):
|
||||
# 旧请求回调(例如用户已点击停止后)直接忽略
|
||||
if req_seq != getattr(self, "_ai_image_active_seq", 0):
|
||||
return
|
||||
self._ai_image_active_seq = 0
|
||||
self._ai_image_cancel_event = None
|
||||
_set_requesting(self, False)
|
||||
if exc is not None:
|
||||
self.ai_image_status_var.set(f"失败: {exc}")
|
||||
@@ -314,17 +466,32 @@ def _on_request_done(self, record, exc):
|
||||
self.ai_image_status_var.set("完成")
|
||||
self.ai_image_input.delete("1.0", tk.END)
|
||||
reload_ai_image_list(self)
|
||||
# 定位到新生成项(最新在前)
|
||||
if record is not None and self.ai_image_records:
|
||||
for i, r in enumerate(self.ai_image_records):
|
||||
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
|
||||
if ridx is None:
|
||||
continue
|
||||
r = self.ai_image_records[ridx]
|
||||
if r.id == record.id:
|
||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||
self.ai_image_listbox.selection_set(i)
|
||||
self.ai_image_listbox.activate(i)
|
||||
self.ai_image_listbox.selection_set(row)
|
||||
self.ai_image_listbox.activate(row)
|
||||
self.ai_image_listbox.see(row)
|
||||
_select_record(self, r)
|
||||
break
|
||||
|
||||
|
||||
def _stop_request(self):
|
||||
"""停止当前生成任务(协作取消:屏蔽后续回调并恢复 UI)。"""
|
||||
if not getattr(self, "_ai_image_requesting", False):
|
||||
return
|
||||
event = getattr(self, "_ai_image_cancel_event", None)
|
||||
if event is not None:
|
||||
event.set()
|
||||
self._ai_image_active_seq = 0
|
||||
_set_requesting(self, False)
|
||||
self.ai_image_status_var.set("已停止生成")
|
||||
|
||||
|
||||
def _save_current(self):
|
||||
rec = getattr(self, "ai_image_current", None)
|
||||
if rec is None:
|
||||
@@ -358,53 +525,41 @@ def _delete_current(self):
|
||||
|
||||
|
||||
def _rename_current(self):
|
||||
"""弹窗让用户修改当前记录的备注名称(即列表显示中 size_tag 之后的部分)。"""
|
||||
"""弹窗让用户修改当前记录的展示标题(保存到侧车 ``title`` 字段,原始 prompt 不变)。"""
|
||||
rec = getattr(self, "ai_image_current", None)
|
||||
if rec is None:
|
||||
messagebox.showinfo("提示", "请先选择一张图片")
|
||||
return
|
||||
|
||||
current_name = rec.prompt or ""
|
||||
current = rec.title or rec.display_name
|
||||
new_name = simpledialog.askstring(
|
||||
"重命名",
|
||||
"修改备注名称(显示在分辨率标签后面):",
|
||||
initialvalue=current_name,
|
||||
"修改显示标题(留空可恢复使用原始提示词):",
|
||||
initialvalue=current,
|
||||
parent=self.root,
|
||||
)
|
||||
if new_name is None: # 用户点了取消
|
||||
if new_name is None: # 取消
|
||||
return
|
||||
new_name = new_name.strip()
|
||||
if not new_name:
|
||||
messagebox.showwarning("提示", "备注名称不能为空")
|
||||
return
|
||||
if new_name == current_name:
|
||||
if new_name == (rec.title or ""):
|
||||
return
|
||||
|
||||
# 写回 JSON 元数据
|
||||
try:
|
||||
import json
|
||||
meta_path = os.path.splitext(rec.image_path)[0] + ".json"
|
||||
meta = {}
|
||||
if os.path.isfile(meta_path):
|
||||
with open(meta_path, "r", encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
meta["prompt"] = new_name
|
||||
with open(meta_path, "w", encoding="utf-8") as f:
|
||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("保存失败", f"无法更新元数据:\n{exc}")
|
||||
if not _svc.update_record_title(rec, new_name):
|
||||
messagebox.showerror("保存失败", "无法更新元数据,请检查文件权限。")
|
||||
return
|
||||
|
||||
# 同步内存中的记录并刷新列表
|
||||
rec.prompt = new_name
|
||||
target_id = rec.id
|
||||
reload_ai_image_list(self)
|
||||
# 重新定位到刚才被重命名的图片
|
||||
for i, r in enumerate(self.ai_image_records):
|
||||
if r.id == rec.id:
|
||||
# 重新定位
|
||||
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
|
||||
if ridx is None:
|
||||
continue
|
||||
r = self.ai_image_records[ridx]
|
||||
if r.id == target_id:
|
||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||
self.ai_image_listbox.selection_set(i)
|
||||
self.ai_image_listbox.activate(i)
|
||||
self.ai_image_listbox.see(i)
|
||||
self.ai_image_listbox.selection_set(row)
|
||||
self.ai_image_listbox.activate(row)
|
||||
self.ai_image_listbox.see(row)
|
||||
_select_record(self, r)
|
||||
break
|
||||
|
||||
@@ -415,14 +570,16 @@ def _rename_current(self):
|
||||
def _show_list_context_menu(self, event):
|
||||
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
|
||||
try:
|
||||
idx = self.ai_image_listbox.nearest(event.y)
|
||||
row = self.ai_image_listbox.nearest(event.y)
|
||||
except Exception:
|
||||
idx = -1
|
||||
if 0 <= idx < len(self.ai_image_records):
|
||||
row = -1
|
||||
row_map = getattr(self, "_ai_image_row_map", None) or []
|
||||
ridx = row_map[row] if 0 <= row < len(row_map) else None
|
||||
if ridx is not None and 0 <= ridx < len(self.ai_image_records):
|
||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||
self.ai_image_listbox.selection_set(idx)
|
||||
self.ai_image_listbox.activate(idx)
|
||||
_select_record(self, self.ai_image_records[idx])
|
||||
self.ai_image_listbox.selection_set(row)
|
||||
self.ai_image_listbox.activate(row)
|
||||
_select_record(self, self.ai_image_records[ridx])
|
||||
|
||||
has_selection = self.ai_image_current is not None
|
||||
ucd = getattr(self, "ucd", None)
|
||||
|
||||
@@ -111,18 +111,6 @@ def create_cct_params_frame(self):
|
||||
)
|
||||
self.recalc_cct_btn.grid_remove()
|
||||
|
||||
# 色域重新计算按钮
|
||||
self.recalc_gamut_btn = ttk.Button(
|
||||
self.cct_params_frame,
|
||||
text="应用色域参考并重绘",
|
||||
command=self.recalculate_gamut,
|
||||
bootstyle="warning",
|
||||
)
|
||||
self.recalc_gamut_btn.grid(
|
||||
row=4, column=2, columnspan=2, pady=10, padx=5, sticky="ew"
|
||||
)
|
||||
self.recalc_gamut_btn.grid_remove()
|
||||
|
||||
# 提示文字
|
||||
ttk.Label(
|
||||
self.cct_params_frame,
|
||||
@@ -228,18 +216,6 @@ def create_cct_params_frame(self):
|
||||
)
|
||||
self.sdr_recalc_cct_btn.grid_remove()
|
||||
|
||||
# 色域重新计算按钮(SDR)
|
||||
self.sdr_recalc_gamut_btn = ttk.Button(
|
||||
self.sdr_cct_params_frame,
|
||||
text="应用色域参考并重绘",
|
||||
command=self.recalculate_gamut,
|
||||
bootstyle="warning",
|
||||
)
|
||||
self.sdr_recalc_gamut_btn.grid(
|
||||
row=4, column=2, columnspan=2, pady=10, padx=5, sticky="ew"
|
||||
)
|
||||
self.sdr_recalc_gamut_btn.grid_remove()
|
||||
|
||||
# 提示文字
|
||||
ttk.Label(
|
||||
self.sdr_cct_params_frame,
|
||||
@@ -345,18 +321,6 @@ def create_cct_params_frame(self):
|
||||
)
|
||||
self.hdr_recalc_cct_btn.grid_remove()
|
||||
|
||||
# 色域重新计算按钮(HDR)
|
||||
self.hdr_recalc_gamut_btn = ttk.Button(
|
||||
self.hdr_cct_params_frame,
|
||||
text="应用色域参考并重绘",
|
||||
command=self.recalculate_gamut,
|
||||
bootstyle="warning",
|
||||
)
|
||||
self.hdr_recalc_gamut_btn.grid(
|
||||
row=4, column=2, columnspan=2, pady=10, padx=5, sticky="ew"
|
||||
)
|
||||
self.hdr_recalc_gamut_btn.grid_remove()
|
||||
|
||||
# 提示文字
|
||||
ttk.Label(
|
||||
self.hdr_cct_params_frame,
|
||||
|
||||
@@ -136,12 +136,15 @@ def create_custom_template_result_panel(self):
|
||||
label="单步测试",
|
||||
command=self.start_custom_row_single_step,
|
||||
)
|
||||
|
||||
# self.custom_result_menu.add_separator()
|
||||
# self.custom_result_menu.add_command(
|
||||
# label="单步测试",
|
||||
# command=self.fill_custom_result_test_data,
|
||||
# )
|
||||
self.custom_result_menu.add_separator()
|
||||
self.custom_result_menu.add_command(
|
||||
label="生成模板",
|
||||
command=self.export_custom_template_excel,
|
||||
)
|
||||
self.custom_result_menu.add_command(
|
||||
label="生成图表",
|
||||
command=self.export_custom_template_charts,
|
||||
)
|
||||
self.custom_result_tree.bind("<Button-3>", self.show_custom_result_context_menu)
|
||||
|
||||
table_container.grid_rowconfigure(0, weight=1)
|
||||
@@ -181,6 +184,14 @@ def show_custom_result_context_menu(self, event):
|
||||
1,
|
||||
state=("normal" if can_single_step else "disabled"),
|
||||
)
|
||||
self.custom_result_menu.entryconfigure(
|
||||
3,
|
||||
state=("normal" if has_rows else "disabled"),
|
||||
)
|
||||
self.custom_result_menu.entryconfigure(
|
||||
4,
|
||||
state=("normal" if has_rows else "disabled"),
|
||||
)
|
||||
self.custom_result_menu.tk_popup(event.x_root, event.y_root)
|
||||
finally:
|
||||
self.custom_result_menu.grab_release()
|
||||
@@ -614,3 +625,290 @@ def update_custom_button_visibility(self):
|
||||
# self.status_var.set("已填充 147 行客户模板测试数据")
|
||||
# if hasattr(self, "log_gui"):
|
||||
# self.log_gui.log("已填充 147 行客户模板测试数据", level="success")
|
||||
|
||||
|
||||
def export_custom_template_excel(self):
|
||||
"""将客户模板结果表导出为 Excel 文件(14 列完整数据)"""
|
||||
if not hasattr(self, "custom_result_tree"):
|
||||
return
|
||||
|
||||
items = self.custom_result_tree.get_children()
|
||||
if not items:
|
||||
messagebox.showinfo("提示", "当前没有可导出的数据")
|
||||
return
|
||||
|
||||
import datetime
|
||||
from tkinter import filedialog
|
||||
|
||||
default_name = (
|
||||
f"客户模板结果_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
)
|
||||
save_path = filedialog.asksaveasfilename(
|
||||
title="保存客户模板 Excel 报告",
|
||||
defaultextension=".xlsx",
|
||||
filetypes=[("Excel 文件", "*.xlsx")],
|
||||
initialfile=default_name,
|
||||
)
|
||||
if not save_path:
|
||||
return
|
||||
|
||||
try:
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "客户模板测试结果"
|
||||
|
||||
columns = tuple(self.custom_result_tree["columns"])
|
||||
num_cols = len(columns)
|
||||
# 列字母辅助(A-N,共 14 列,全在单字母范围内)
|
||||
def col_letter(idx_1based):
|
||||
return chr(64 + idx_1based)
|
||||
|
||||
last_col = col_letter(num_cols)
|
||||
|
||||
# ---- 标题行 ----
|
||||
ws.merge_cells(f"A1:{last_col}1")
|
||||
ws["A1"] = "客户模板测试结果"
|
||||
ws["A1"].font = Font(name="微软雅黑", size=16, bold=True, color="FFFFFF")
|
||||
ws["A1"].fill = PatternFill(
|
||||
start_color="4472C4", end_color="4472C4", fill_type="solid"
|
||||
)
|
||||
ws["A1"].alignment = Alignment(horizontal="center", vertical="center")
|
||||
ws.row_dimensions[1].height = 35
|
||||
|
||||
# 写入测试时间
|
||||
ws.merge_cells(f"A2:{last_col}2")
|
||||
ws["A2"] = (
|
||||
f"测试时间:{datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
)
|
||||
ws["A2"].font = Font(name="微软雅黑", size=10, color="CCCCCC")
|
||||
ws["A2"].fill = PatternFill(
|
||||
start_color="2F2F2F", end_color="2F2F2F", fill_type="solid"
|
||||
)
|
||||
ws["A2"].alignment = Alignment(horizontal="left", vertical="center")
|
||||
ws.row_dimensions[2].height = 20
|
||||
|
||||
# ---- 表头行 ----
|
||||
thin = Side(style="thin")
|
||||
border = Border(left=thin, right=thin, top=thin, bottom=thin)
|
||||
header_font = Font(name="微软雅黑", size=10, bold=True, color="FFFFFF")
|
||||
header_fill = PatternFill(
|
||||
start_color="70AD47", end_color="70AD47", fill_type="solid"
|
||||
)
|
||||
header_align = Alignment(
|
||||
horizontal="center", vertical="center", wrap_text=True
|
||||
)
|
||||
|
||||
for col_idx, col_name in enumerate(columns, start=1):
|
||||
cell = ws.cell(row=3, column=col_idx, value=col_name)
|
||||
cell.font = header_font
|
||||
cell.fill = header_fill
|
||||
cell.alignment = header_align
|
||||
cell.border = border
|
||||
ws.row_dimensions[3].height = 22
|
||||
|
||||
# ---- 数据行 ----
|
||||
data_font = Font(name="微软雅黑", size=10)
|
||||
data_align = Alignment(horizontal="center", vertical="center")
|
||||
# 数值列(跳过 Pattern 和 No.)以 4 位小数格式输出
|
||||
numeric_col_indices = set(range(2, num_cols))
|
||||
|
||||
for row_offset, item in enumerate(items):
|
||||
row_num = 4 + row_offset
|
||||
values = self.custom_result_tree.item(item, "values")
|
||||
for col_idx, value in enumerate(values, start=1):
|
||||
cell = ws.cell(row=row_num, column=col_idx)
|
||||
cell.font = data_font
|
||||
cell.alignment = data_align
|
||||
cell.border = border
|
||||
# 占位符保持文本,非占位符数值列尝试转为浮点数
|
||||
if (
|
||||
col_idx - 1 in numeric_col_indices
|
||||
and str(value) not in ("---", "--", "")
|
||||
):
|
||||
try:
|
||||
cell.value = float(value)
|
||||
cell.number_format = "0.0000"
|
||||
except (ValueError, TypeError):
|
||||
cell.value = value
|
||||
else:
|
||||
cell.value = value
|
||||
ws.row_dimensions[row_num].height = 20
|
||||
|
||||
# ---- 列宽 ----
|
||||
col_widths = {
|
||||
"Pattern": 14,
|
||||
"No.": 8,
|
||||
"X": 11,
|
||||
"Y": 11,
|
||||
"Z": 11,
|
||||
"x": 10,
|
||||
"y": 10,
|
||||
"Lv": 10,
|
||||
"u'": 10,
|
||||
"v'": 10,
|
||||
"Tcp": 12,
|
||||
"duv": 10,
|
||||
"\u03bbd/\u03bbc": 12,
|
||||
"Pe": 10,
|
||||
}
|
||||
for col_idx, col_name in enumerate(columns, start=1):
|
||||
ws.column_dimensions[col_letter(col_idx)].width = col_widths.get(
|
||||
col_name, 11
|
||||
)
|
||||
|
||||
wb.save(save_path)
|
||||
|
||||
if hasattr(self, "status_var"):
|
||||
self.status_var.set("已导出客户模板 Excel 报告")
|
||||
if hasattr(self, "log_gui"):
|
||||
self.log_gui.log(
|
||||
f"已导出客户模板 Excel 报告: {save_path}", level="success"
|
||||
)
|
||||
messagebox.showinfo("成功", f"Excel 报告已保存到:\n{save_path}")
|
||||
|
||||
except Exception as e:
|
||||
if hasattr(self, "log_gui"):
|
||||
self.log_gui.log(f"导出 Excel 失败: {str(e)}", level="error")
|
||||
messagebox.showerror("错误", f"导出失败:{str(e)}")
|
||||
|
||||
|
||||
def export_custom_template_charts(self):
|
||||
"""生成客户模板图表:xy 色度散点图 + Lv 亮度曲线图,保存为 PNG"""
|
||||
if not hasattr(self, "custom_result_tree"):
|
||||
return
|
||||
|
||||
items = self.custom_result_tree.get_children()
|
||||
if not items:
|
||||
messagebox.showinfo("提示", "当前没有可绘制的数据")
|
||||
return
|
||||
|
||||
import datetime
|
||||
from tkinter import filedialog
|
||||
|
||||
default_name = (
|
||||
f"客户模板图表_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||
)
|
||||
save_path = filedialog.asksaveasfilename(
|
||||
title="保存客户模板图表",
|
||||
defaultextension=".png",
|
||||
filetypes=[("PNG 图片", "*.png")],
|
||||
initialfile=default_name,
|
||||
)
|
||||
if not save_path:
|
||||
return
|
||||
|
||||
try:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
columns = tuple(self.custom_result_tree["columns"])
|
||||
col_idx_map = {col: idx for idx, col in enumerate(columns)}
|
||||
|
||||
pattern_names, x_vals, y_vals, lv_vals = [], [], [], []
|
||||
for item in items:
|
||||
vals = self.custom_result_tree.item(item, "values")
|
||||
pattern_names.append(str(vals[col_idx_map.get("Pattern", 0)]))
|
||||
for container, key, fallback in (
|
||||
(x_vals, "x", 5),
|
||||
(y_vals, "y", 6),
|
||||
(lv_vals, "Lv", 7),
|
||||
):
|
||||
raw = vals[col_idx_map.get(key, fallback)]
|
||||
try:
|
||||
v = float(raw)
|
||||
container.append(v if np.isfinite(v) and v > -99999998 else None)
|
||||
except (ValueError, TypeError):
|
||||
container.append(None)
|
||||
|
||||
# ---- 绘图 ----
|
||||
fig, (ax_xy, ax_lv) = plt.subplots(
|
||||
1, 2, figsize=(16, 7), facecolor="#1a1a2e"
|
||||
)
|
||||
|
||||
# ── 左图:xy 色度散点图 ──
|
||||
ax_xy.set_facecolor("#0f0f23")
|
||||
ax_xy.set_xlim(0, 0.8)
|
||||
ax_xy.set_ylim(0, 0.9)
|
||||
ax_xy.set_xlabel("x", color="#cccccc", fontsize=11)
|
||||
ax_xy.set_ylabel("y", color="#cccccc", fontsize=11)
|
||||
ax_xy.set_title("xy 色度图", color="#ffffff", fontsize=13, fontweight="bold")
|
||||
ax_xy.tick_params(colors="#aaaaaa", which="both")
|
||||
for spine in ax_xy.spines.values():
|
||||
spine.set_color("#444444")
|
||||
ax_xy.grid(color="#333333", linestyle="--", linewidth=0.5, alpha=0.7)
|
||||
|
||||
# D65 白点标注
|
||||
ax_xy.scatter(
|
||||
[0.3127], [0.3290],
|
||||
c="#ffffff", s=100, zorder=5,
|
||||
marker="+", linewidths=2, label="D65",
|
||||
)
|
||||
|
||||
valid_pairs = [
|
||||
(x, y, i)
|
||||
for i, (x, y) in enumerate(zip(x_vals, y_vals))
|
||||
if x is not None and y is not None
|
||||
]
|
||||
if valid_pairs:
|
||||
xs, ys, idxs = zip(*valid_pairs)
|
||||
sc = ax_xy.scatter(
|
||||
xs, ys,
|
||||
c=idxs,
|
||||
cmap="plasma",
|
||||
s=60, zorder=4,
|
||||
edgecolors="#cccccc", linewidths=0.5, alpha=0.9,
|
||||
)
|
||||
cbar = fig.colorbar(sc, ax=ax_xy, pad=0.01)
|
||||
cbar.set_label("测量序号", color="#cccccc", fontsize=10)
|
||||
cbar.ax.yaxis.set_tick_params(color="#aaaaaa")
|
||||
plt.setp(cbar.ax.yaxis.get_ticklabels(), color="#aaaaaa")
|
||||
|
||||
ax_xy.legend(
|
||||
fontsize=9,
|
||||
facecolor="#2a2a3e",
|
||||
edgecolor="#555555",
|
||||
labelcolor="#cccccc",
|
||||
)
|
||||
|
||||
# ── 右图:Lv 亮度曲线 ──
|
||||
ax_lv.set_facecolor("#0f0f23")
|
||||
ax_lv.set_title(
|
||||
"Lv 亮度曲线", color="#ffffff", fontsize=13, fontweight="bold"
|
||||
)
|
||||
ax_lv.set_xlabel("测量序号", color="#cccccc", fontsize=11)
|
||||
ax_lv.set_ylabel("Lv (cd/m²)", color="#cccccc", fontsize=11)
|
||||
ax_lv.tick_params(colors="#aaaaaa", which="both")
|
||||
for spine in ax_lv.spines.values():
|
||||
spine.set_color("#444444")
|
||||
ax_lv.grid(color="#333333", linestyle="--", linewidth=0.5, alpha=0.7)
|
||||
|
||||
valid_lv = [(i + 1, lv) for i, lv in enumerate(lv_vals) if lv is not None]
|
||||
if valid_lv:
|
||||
seq, lvs = zip(*valid_lv)
|
||||
ax_lv.plot(
|
||||
seq, lvs,
|
||||
color="#4fc3f7", linewidth=1.5,
|
||||
marker="o", markersize=4,
|
||||
markerfacecolor="#ff8c00", markeredgecolor="#ff8c00",
|
||||
)
|
||||
ax_lv.fill_between(seq, lvs, alpha=0.15, color="#4fc3f7")
|
||||
|
||||
plt.tight_layout(pad=2.0)
|
||||
fig.savefig(save_path, dpi=200, bbox_inches="tight",
|
||||
facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
|
||||
if hasattr(self, "status_var"):
|
||||
self.status_var.set("已生成客户模板图表")
|
||||
if hasattr(self, "log_gui"):
|
||||
self.log_gui.log(
|
||||
f"已生成客户模板图表: {save_path}", level="success"
|
||||
)
|
||||
messagebox.showinfo("成功", f"图表已保存到:\n{save_path}")
|
||||
|
||||
except Exception as e:
|
||||
if hasattr(self, "log_gui"):
|
||||
self.log_gui.log(f"生成图表失败: {str(e)}", level="error")
|
||||
messagebox.showerror("错误", f"生成图表失败:{str(e)}")
|
||||
|
||||
@@ -435,6 +435,24 @@ def create_test_type_frame(self):
|
||||
)
|
||||
self.ai_image_btn.pack(fill=tk.X, padx=0, pady=1)
|
||||
|
||||
# self.single_step_btn = ttk.Button(
|
||||
# self.sidebar_frame,
|
||||
# text="单步调试",
|
||||
# style="Sidebar.TButton",
|
||||
# command=self.toggle_single_step_panel,
|
||||
# takefocus=False,
|
||||
# )
|
||||
# self.single_step_btn.pack(fill=tk.X, padx=0, pady=1)
|
||||
|
||||
self.pantone_baseline_btn = ttk.Button(
|
||||
self.sidebar_frame,
|
||||
text="Pantone认证摸底测试",
|
||||
style="Sidebar.TButton",
|
||||
command=self.toggle_pantone_baseline_panel,
|
||||
takefocus=False,
|
||||
)
|
||||
self.pantone_baseline_btn.pack(fill=tk.X, padx=0, pady=1)
|
||||
|
||||
# 注册面板按钮
|
||||
if hasattr(self, "panels"):
|
||||
if "log" in self.panels:
|
||||
@@ -443,6 +461,10 @@ def create_test_type_frame(self):
|
||||
self.panels["local_dimming"]["button"] = self.local_dimming_btn
|
||||
if "ai_image" in self.panels:
|
||||
self.panels["ai_image"]["button"] = self.ai_image_btn
|
||||
if "single_step" in self.panels:
|
||||
self.panels["single_step"]["button"] = self.single_step_btn
|
||||
if "pantone_baseline" in self.panels:
|
||||
self.panels["pantone_baseline"]["button"] = self.pantone_baseline_btn
|
||||
|
||||
|
||||
def update_config_info_display(self):
|
||||
|
||||
547
app/views/panels/pantone_baseline_panel.py
Normal file
547
app/views/panels/pantone_baseline_panel.py
Normal file
@@ -0,0 +1,547 @@
|
||||
"""Pantone 认证摸底测试面板。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
|
||||
import ttkbootstrap as ttk
|
||||
|
||||
|
||||
_TEMPLATE_FILE = "pantone\xa02670\xa0colors.xlsx"
|
||||
|
||||
|
||||
def create_pantone_baseline_panel(self):
|
||||
"""创建 Pantone 认证摸底测试面板。"""
|
||||
frame = ttk.Frame(self.content_frame)
|
||||
self.pantone_baseline_frame = frame
|
||||
self.pantone_baseline_visible = False
|
||||
self.pantone_patterns = []
|
||||
self.pantone_results = []
|
||||
self._pantone_control_event = None
|
||||
self._pantone_running = False
|
||||
self._pantone_paused = False
|
||||
self._pantone_pause_requested = False
|
||||
self._pantone_stop_requested = False
|
||||
self._pantone_next_index = 0
|
||||
self._pantone_target_count = 0
|
||||
|
||||
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, 8))
|
||||
ttk.Label(
|
||||
title_row,
|
||||
text="Pantone认证摸底测试",
|
||||
font=("微软雅黑", 14, "bold"),
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
self.pantone_status_var = tk.StringVar(value="未开始")
|
||||
self.pantone_progress_var = tk.StringVar(value="0 / 0")
|
||||
self.pantone_settle_var = tk.StringVar(value="0.3")
|
||||
|
||||
config_row = ttk.LabelFrame(root, text="任务配置", padding=8)
|
||||
config_row.pack(fill=tk.X)
|
||||
ttk.Label(config_row, text=f"Pattern来源: settings/{_TEMPLATE_FILE}").pack(
|
||||
side=tk.LEFT
|
||||
)
|
||||
ttk.Label(config_row, text="稳定等待(s):").pack(side=tk.LEFT, padx=(14, 4))
|
||||
ttk.Entry(config_row, textvariable=self.pantone_settle_var, width=8).pack(
|
||||
side=tk.LEFT
|
||||
)
|
||||
ttk.Label(config_row, textvariable=self.pantone_progress_var).pack(
|
||||
side=tk.RIGHT, padx=(8, 0)
|
||||
)
|
||||
ttk.Label(config_row, textvariable=self.pantone_status_var, foreground="#666").pack(
|
||||
side=tk.RIGHT
|
||||
)
|
||||
|
||||
btn_row = ttk.Frame(root)
|
||||
btn_row.pack(fill=tk.X, pady=(8, 8))
|
||||
self.pantone_start_btn = ttk.Button(
|
||||
btn_row,
|
||||
text="开始",
|
||||
bootstyle="primary",
|
||||
command=lambda: _start_pantone_baseline(self),
|
||||
)
|
||||
self.pantone_start_btn.pack(side=tk.LEFT, padx=(0, 6))
|
||||
self.pantone_pause_btn = ttk.Button(
|
||||
btn_row,
|
||||
text="暂停",
|
||||
bootstyle="warning-outline",
|
||||
command=lambda: _pause_pantone_baseline(self),
|
||||
state=tk.DISABLED,
|
||||
)
|
||||
self.pantone_pause_btn.pack(side=tk.LEFT, padx=(0, 6))
|
||||
self.pantone_resume_btn = ttk.Button(
|
||||
btn_row,
|
||||
text="继续",
|
||||
bootstyle="info-outline",
|
||||
command=lambda: _resume_pantone_baseline(self),
|
||||
state=tk.DISABLED,
|
||||
)
|
||||
self.pantone_resume_btn.pack(side=tk.LEFT, padx=(0, 6))
|
||||
self.pantone_end_btn = ttk.Button(
|
||||
btn_row,
|
||||
text="结束",
|
||||
bootstyle="danger-outline",
|
||||
command=lambda: _end_pantone_baseline(self),
|
||||
state=tk.DISABLED,
|
||||
)
|
||||
self.pantone_end_btn.pack(side=tk.LEFT, padx=(0, 6))
|
||||
ttk.Button(
|
||||
btn_row,
|
||||
text="清空结果",
|
||||
bootstyle="secondary-outline",
|
||||
command=lambda: _clear_results(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 6))
|
||||
ttk.Button(
|
||||
btn_row,
|
||||
text="另存为模板",
|
||||
bootstyle="success",
|
||||
command=lambda: _save_as_template(self),
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
table_frame = ttk.LabelFrame(root, text="测试结果(R,G,B,L,x,y)", padding=8)
|
||||
table_frame.pack(fill=tk.BOTH, expand=True)
|
||||
columns = ("idx", "r", "g", "b", "l", "x", "y", "time")
|
||||
self.pantone_tree = ttk.Treeview(
|
||||
table_frame,
|
||||
columns=columns,
|
||||
show="headings",
|
||||
height=18,
|
||||
)
|
||||
headings = {
|
||||
"idx": "序号",
|
||||
"r": "R",
|
||||
"g": "G",
|
||||
"b": "B",
|
||||
"l": "L",
|
||||
"x": "x",
|
||||
"y": "y",
|
||||
"time": "时间",
|
||||
}
|
||||
widths = {
|
||||
"idx": 70,
|
||||
"r": 70,
|
||||
"g": 70,
|
||||
"b": 70,
|
||||
"l": 90,
|
||||
"x": 90,
|
||||
"y": 90,
|
||||
"time": 110,
|
||||
}
|
||||
for col in columns:
|
||||
self.pantone_tree.heading(col, text=headings[col])
|
||||
self.pantone_tree.column(col, width=widths[col], anchor=tk.CENTER)
|
||||
self.pantone_tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
scrollbar = ttk.Scrollbar(table_frame, orient=tk.VERTICAL, command=self.pantone_tree.yview)
|
||||
scrollbar.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.pantone_tree.configure(yscrollcommand=scrollbar.set)
|
||||
|
||||
self.register_panel("pantone_baseline", frame, None, "pantone_baseline_visible")
|
||||
_set_button_states(self)
|
||||
|
||||
|
||||
def toggle_pantone_baseline_panel(self):
|
||||
"""切换 Pantone 认证摸底测试面板。"""
|
||||
self.show_panel("pantone_baseline")
|
||||
|
||||
|
||||
def _load_patterns(self):
|
||||
path = os.path.join("settings", _TEMPLATE_FILE)
|
||||
if not os.path.isfile(path):
|
||||
raise FileNotFoundError(f"未找到模板文件: {path}")
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
patterns = []
|
||||
wb = load_workbook(path, read_only=True, data_only=True)
|
||||
ws = wb.active
|
||||
try:
|
||||
for row in ws.iter_rows(min_row=2, values_only=True):
|
||||
if not row:
|
||||
continue
|
||||
try:
|
||||
r = int(row[0]) if row[0] is not None else None
|
||||
g = int(row[1]) if len(row) > 1 and row[1] is not None else None
|
||||
b = int(row[2]) if len(row) > 2 and row[2] is not None else None
|
||||
except Exception:
|
||||
continue
|
||||
if r is None or g is None or b is None:
|
||||
continue
|
||||
if min(r, g, b) < 0 or max(r, g, b) > 255:
|
||||
continue
|
||||
patterns.append((r, g, b))
|
||||
finally:
|
||||
wb.close()
|
||||
|
||||
if not patterns:
|
||||
raise RuntimeError("模板中未找到有效 RGB 列表(需包含 R/G/B 三列)")
|
||||
return patterns
|
||||
|
||||
|
||||
def _start_pantone_baseline(self):
|
||||
if self._pantone_running:
|
||||
messagebox.showinfo("提示", "Pantone 任务正在执行")
|
||||
return
|
||||
if not getattr(self, "ucd", None) or not self.ucd.status:
|
||||
messagebox.showwarning("警告", "请先连接 UCD323")
|
||||
return
|
||||
if not getattr(self, "ca", None):
|
||||
messagebox.showwarning("警告", "请先连接 CA410")
|
||||
return
|
||||
|
||||
try:
|
||||
settle = float(self.pantone_settle_var.get().strip())
|
||||
if settle < 0:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
messagebox.showerror("参数错误", "稳定等待时间必须是非负数字")
|
||||
return
|
||||
|
||||
try:
|
||||
self.pantone_patterns = _load_patterns(self)
|
||||
self._pantone_target_count = len(self.pantone_patterns)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("读取失败", str(exc))
|
||||
return
|
||||
|
||||
if self.pantone_results:
|
||||
if not messagebox.askyesno("确认开始", "开始将清空当前内存中的测试结果,是否继续?"):
|
||||
return
|
||||
|
||||
self._pantone_running = True
|
||||
self._pantone_paused = False
|
||||
self._pantone_pause_requested = False
|
||||
self._pantone_stop_requested = False
|
||||
self._pantone_control_event = threading.Event()
|
||||
self._pantone_next_index = 0
|
||||
self.pantone_status_var.set("执行中")
|
||||
self.pantone_progress_var.set(f"0 / {self._pantone_target_count}")
|
||||
self.pantone_results = []
|
||||
for item in self.pantone_tree.get_children():
|
||||
self.pantone_tree.delete(item)
|
||||
_set_button_states(self)
|
||||
|
||||
_launch_worker(self, start_index=0, settle=settle)
|
||||
|
||||
|
||||
def _resume_pantone_baseline(self):
|
||||
if self._pantone_running:
|
||||
messagebox.showinfo("提示", "Pantone 任务正在执行")
|
||||
return
|
||||
if not self._pantone_paused:
|
||||
messagebox.showinfo("提示", "当前没有可继续的暂停任务")
|
||||
return
|
||||
if not getattr(self, "ucd", None) or not self.ucd.status:
|
||||
messagebox.showwarning("警告", "请先连接 UCD323")
|
||||
return
|
||||
if not getattr(self, "ca", None):
|
||||
messagebox.showwarning("警告", "请先连接 CA410")
|
||||
return
|
||||
|
||||
try:
|
||||
settle = float(self.pantone_settle_var.get().strip())
|
||||
if settle < 0:
|
||||
raise ValueError()
|
||||
except Exception:
|
||||
messagebox.showerror("参数错误", "稳定等待时间必须是非负数字")
|
||||
return
|
||||
|
||||
try:
|
||||
self.pantone_patterns = _load_patterns(self)
|
||||
self._pantone_target_count = len(self.pantone_patterns)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("读取失败", str(exc))
|
||||
return
|
||||
|
||||
if self._pantone_next_index >= self._pantone_target_count:
|
||||
messagebox.showinfo("提示", "任务已完成,无需继续")
|
||||
return
|
||||
|
||||
self._pantone_running = True
|
||||
self._pantone_paused = False
|
||||
self._pantone_pause_requested = False
|
||||
self._pantone_stop_requested = False
|
||||
self._pantone_control_event = threading.Event()
|
||||
self.pantone_status_var.set("执行中")
|
||||
_set_button_states(self)
|
||||
|
||||
_launch_worker(self, start_index=self._pantone_next_index, settle=settle)
|
||||
|
||||
|
||||
def _launch_worker(self, start_index, settle):
|
||||
total = self._pantone_target_count or len(self.pantone_patterns)
|
||||
|
||||
def worker():
|
||||
end_state = "completed"
|
||||
try:
|
||||
src = self.pantone_patterns
|
||||
src_count = len(src)
|
||||
rgb_session = self.pattern_service.prepare_session("rgb", log_details=False)
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"Pantone 认证摸底启动: source={src_count}, target={total}, start={start_index + 1}",
|
||||
"info",
|
||||
)
|
||||
for i in range(start_index, total):
|
||||
if self._pantone_stop_requested:
|
||||
end_state = "stopped"
|
||||
break
|
||||
if self._pantone_pause_requested:
|
||||
end_state = "paused"
|
||||
break
|
||||
|
||||
r, g, b = src[i % src_count]
|
||||
try:
|
||||
self.pattern_service.send_rgb((r, g, b), session=rgb_session)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"第 {i + 1} 组发送失败: {exc}") from exc
|
||||
|
||||
if settle > 0 and self._pantone_control_event is not None:
|
||||
self._pantone_control_event.clear()
|
||||
self._pantone_control_event.wait(timeout=settle)
|
||||
|
||||
if self._pantone_stop_requested:
|
||||
end_state = "stopped"
|
||||
break
|
||||
if self._pantone_pause_requested:
|
||||
end_state = "paused"
|
||||
break
|
||||
|
||||
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
|
||||
if lv is None:
|
||||
raise RuntimeError(f"第 {i + 1} 组 CA410 采集失败")
|
||||
|
||||
record = {
|
||||
"idx": i + 1,
|
||||
"r": r,
|
||||
"g": g,
|
||||
"b": b,
|
||||
"l": float(lv),
|
||||
"x": float(x),
|
||||
"y": float(y),
|
||||
"time": datetime.datetime.now().strftime("%H:%M:%S"),
|
||||
}
|
||||
self.pantone_results.append(record)
|
||||
self._pantone_next_index = i + 1
|
||||
self._dispatch_ui(_append_result_row, self, record, total)
|
||||
|
||||
if end_state == "paused":
|
||||
self._pantone_paused = True
|
||||
self._dispatch_ui(self.pantone_status_var.set, "已暂停")
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"Pantone 任务已暂停,断点 {self._pantone_next_index} / {total}",
|
||||
"warning",
|
||||
)
|
||||
elif end_state == "stopped":
|
||||
self._pantone_paused = False
|
||||
self._pantone_next_index = 0
|
||||
self._dispatch_ui(self.pantone_status_var.set, "已结束")
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"Pantone 任务已结束,保留 {len(self.pantone_results)} 条内存结果",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
self._pantone_paused = False
|
||||
self._pantone_next_index = total
|
||||
self._dispatch_ui(self.pantone_status_var.set, "已完成")
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"Pantone 任务完成,共 {len(self.pantone_results)} 条数据",
|
||||
"success",
|
||||
)
|
||||
try:
|
||||
auto_path = _auto_save_template(self)
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"Pantone 模板已自动保存: {auto_path}",
|
||||
"success",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"Pantone 自动保存模板失败: {exc}",
|
||||
"error",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._pantone_paused = False
|
||||
self._dispatch_ui(self.pantone_status_var.set, "执行失败")
|
||||
self._dispatch_ui(self.log_gui.log, f"Pantone 任务失败: {exc}", "error")
|
||||
self._dispatch_ui(messagebox.showerror, "执行失败", str(exc))
|
||||
finally:
|
||||
self._pantone_running = False
|
||||
self._pantone_pause_requested = False
|
||||
self._pantone_stop_requested = False
|
||||
self._dispatch_ui(_set_button_states, self)
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
def _append_result_row(self, record, total):
|
||||
self.pantone_tree.insert(
|
||||
"",
|
||||
tk.END,
|
||||
values=(
|
||||
record["idx"],
|
||||
record["r"],
|
||||
record["g"],
|
||||
record["b"],
|
||||
f"{record['l']:.2f}",
|
||||
f"{record['x']:.4f}",
|
||||
f"{record['y']:.4f}",
|
||||
record["time"],
|
||||
),
|
||||
)
|
||||
self.pantone_progress_var.set(f"{record['idx']} / {total}")
|
||||
# 每次插入都滚到末尾,便于观察采集进度。
|
||||
children = self.pantone_tree.get_children()
|
||||
if children:
|
||||
self.pantone_tree.see(children[-1])
|
||||
|
||||
|
||||
def _pause_pantone_baseline(self):
|
||||
if not self._pantone_running:
|
||||
messagebox.showinfo("提示", "当前没有运行中的任务")
|
||||
return
|
||||
self._pantone_pause_requested = True
|
||||
self.pantone_status_var.set("暂停中...")
|
||||
if self._pantone_control_event is not None:
|
||||
self._pantone_control_event.set()
|
||||
|
||||
|
||||
def _end_pantone_baseline(self):
|
||||
if self._pantone_running:
|
||||
self._pantone_stop_requested = True
|
||||
self.pantone_status_var.set("结束中...")
|
||||
if self._pantone_control_event is not None:
|
||||
self._pantone_control_event.set()
|
||||
return
|
||||
|
||||
# 非运行态下点击“结束”:清除断点,保留当前内存结果。
|
||||
self._pantone_paused = False
|
||||
self._pantone_next_index = 0
|
||||
self.pantone_status_var.set("已结束")
|
||||
_set_button_states(self)
|
||||
|
||||
|
||||
def _clear_results(self):
|
||||
if self._pantone_running:
|
||||
messagebox.showinfo("提示", "任务执行中,无法清空")
|
||||
return
|
||||
self.pantone_results = []
|
||||
self._pantone_paused = False
|
||||
self._pantone_next_index = 0
|
||||
for item in self.pantone_tree.get_children():
|
||||
self.pantone_tree.delete(item)
|
||||
self._pantone_target_count = 0
|
||||
self.pantone_progress_var.set("0 / 0")
|
||||
self.pantone_status_var.set("结果已清空")
|
||||
_set_button_states(self)
|
||||
|
||||
|
||||
def _set_button_states(self):
|
||||
if self._pantone_running:
|
||||
self.pantone_start_btn.configure(state=tk.DISABLED)
|
||||
self.pantone_pause_btn.configure(state=tk.NORMAL)
|
||||
self.pantone_resume_btn.configure(state=tk.DISABLED)
|
||||
self.pantone_end_btn.configure(state=tk.NORMAL)
|
||||
return
|
||||
|
||||
self.pantone_start_btn.configure(state=tk.NORMAL)
|
||||
self.pantone_pause_btn.configure(state=tk.DISABLED)
|
||||
self.pantone_end_btn.configure(state=tk.NORMAL if (self._pantone_paused or self.pantone_results) else tk.DISABLED)
|
||||
|
||||
can_resume = self._pantone_paused and self._pantone_next_index < self._pantone_target_count
|
||||
self.pantone_resume_btn.configure(state=tk.NORMAL if can_resume else tk.DISABLED)
|
||||
|
||||
|
||||
def _save_as_template(self):
|
||||
if not self.pantone_results:
|
||||
messagebox.showinfo("提示", "暂无可导出的结果")
|
||||
return
|
||||
|
||||
default_name = _TEMPLATE_FILE.replace("\xa0", " ")
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="另存为 Pantone 模板",
|
||||
defaultextension=".xlsx",
|
||||
initialfile=default_name,
|
||||
filetypes=[("Excel 文件", "*.xlsx")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
|
||||
try:
|
||||
_write_template_xlsx(self, path)
|
||||
self.log_gui.log(f"Pantone 模板已保存: {path}", level="success")
|
||||
self.pantone_status_var.set(f"已保存: {os.path.basename(path)}")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("保存失败", f"写入 xlsx 失败: {exc}")
|
||||
|
||||
|
||||
def _resolve_results_dir(self):
|
||||
if getattr(self, "config_file", None):
|
||||
root_dir = os.path.dirname(os.path.dirname(self.config_file))
|
||||
else:
|
||||
root_dir = os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
)
|
||||
results_dir = os.path.join(root_dir, "results")
|
||||
os.makedirs(results_dir, exist_ok=True)
|
||||
return results_dir
|
||||
|
||||
|
||||
def _auto_save_template(self):
|
||||
results_dir = _resolve_results_dir(self)
|
||||
target_count = len(self.pantone_results)
|
||||
filename = (
|
||||
f"pantone_{target_count}_baseline_"
|
||||
f"{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
|
||||
)
|
||||
path = os.path.join(results_dir, filename)
|
||||
_write_template_xlsx(self, path)
|
||||
return path
|
||||
|
||||
|
||||
def _write_template_xlsx(self, path):
|
||||
# 优先复制 settings 模板,再覆盖数据区;没有模板时自动创建同结构表。
|
||||
template_path = os.path.join("settings", _TEMPLATE_FILE)
|
||||
from openpyxl import load_workbook, Workbook
|
||||
|
||||
if os.path.isfile(template_path):
|
||||
wb = load_workbook(template_path)
|
||||
ws = wb.active
|
||||
else:
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
ws.title = "Sheet1"
|
||||
ws.cell(row=1, column=1, value="R")
|
||||
ws.cell(row=1, column=2, value="G")
|
||||
ws.cell(row=1, column=3, value="B")
|
||||
ws.cell(row=1, column=4, value="L")
|
||||
ws.cell(row=1, column=5, value="x")
|
||||
ws.cell(row=1, column=6, value="y")
|
||||
|
||||
# 清空旧数据
|
||||
max_row = max(ws.max_row, 2)
|
||||
for row in range(2, max_row + 1):
|
||||
for col in range(1, 7):
|
||||
ws.cell(row=row, column=col, value=None)
|
||||
|
||||
for idx, item in enumerate(self.pantone_results, start=2):
|
||||
ws.cell(row=idx, column=1, value=int(item["r"]))
|
||||
ws.cell(row=idx, column=2, value=int(item["g"]))
|
||||
ws.cell(row=idx, column=3, value=int(item["b"]))
|
||||
ws.cell(row=idx, column=4, value=float(item["l"]))
|
||||
ws.cell(row=idx, column=5, value=float(item["x"]))
|
||||
ws.cell(row=idx, column=6, value=float(item["y"]))
|
||||
|
||||
wb.save(path)
|
||||
551
app/views/panels/single_step_panel.py
Normal file
551
app/views/panels/single_step_panel.py
Normal file
@@ -0,0 +1,551 @@
|
||||
"""单步调试面板。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import datetime
|
||||
import os
|
||||
import tempfile
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
|
||||
import ttkbootstrap as ttk
|
||||
from PIL import Image
|
||||
|
||||
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
|
||||
|
||||
|
||||
_DEFAULT_SAMPLES = [
|
||||
{"name": "Red Sample", "hex": "#D22630", "x": "0.6400", "y": "0.3300"},
|
||||
{"name": "Green Sample", "hex": "#00A651", "x": "0.3000", "y": "0.6000"},
|
||||
{"name": "Blue Sample", "hex": "#21409A", "x": "0.1500", "y": "0.0600"},
|
||||
{"name": "Orange Sample", "hex": "#FF6A13", "x": "0.5500", "y": "0.4000"},
|
||||
{"name": "Violet Sample", "hex": "#6A0DAD", "x": "0.2700", "y": "0.1400"},
|
||||
{"name": "Gray Sample", "hex": "#8A8D8F", "x": "0.3127", "y": "0.3290"},
|
||||
]
|
||||
|
||||
|
||||
def create_single_step_panel(self):
|
||||
"""创建单步调试面板。"""
|
||||
frame = ttk.Frame(self.content_frame)
|
||||
self.single_step_frame = frame
|
||||
self.single_step_visible = False
|
||||
self.single_step_samples = []
|
||||
self.single_step_results = []
|
||||
self.single_step_current_index = None
|
||||
self.single_step_current_image_path = None
|
||||
|
||||
root = ttk.Frame(frame, padding=10)
|
||||
root.pack(fill=tk.BOTH, expand=True)
|
||||
root.columnconfigure(0, weight=0)
|
||||
root.columnconfigure(1, weight=1)
|
||||
root.rowconfigure(1, weight=1)
|
||||
|
||||
title_row = ttk.Frame(root)
|
||||
title_row.grid(row=0, column=0, columnspan=2, sticky=tk.EW, pady=(0, 10))
|
||||
ttk.Label(
|
||||
title_row,
|
||||
text="单步调试",
|
||||
font=("微软雅黑", 14, "bold"),
|
||||
).pack(side=tk.LEFT)
|
||||
ttk.Label(
|
||||
title_row,
|
||||
text="录入目标色块,发送纯色色块并采集 xyY / ΔE2000。",
|
||||
foreground="#666",
|
||||
).pack(side=tk.LEFT, padx=(12, 0))
|
||||
|
||||
left = ttk.LabelFrame(root, text="样本列表", padding=8)
|
||||
left.grid(row=1, column=0, sticky=tk.NS, padx=(0, 10))
|
||||
left.grid_propagate(False)
|
||||
left.configure(width=340)
|
||||
|
||||
self.single_step_listbox = tk.Listbox(
|
||||
left,
|
||||
width=34,
|
||||
activestyle="none",
|
||||
font=("微软雅黑", 9),
|
||||
highlightthickness=1,
|
||||
highlightbackground="#d8d8d8",
|
||||
highlightcolor="#4a90e2",
|
||||
selectbackground="#2b6cb0",
|
||||
selectforeground="#ffffff",
|
||||
)
|
||||
self.single_step_listbox.pack(fill=tk.BOTH, expand=True)
|
||||
self.single_step_listbox.bind(
|
||||
"<<ListboxSelect>>", lambda e: _on_sample_select(self)
|
||||
)
|
||||
|
||||
list_btn_row = ttk.Frame(left)
|
||||
list_btn_row.pack(fill=tk.X, pady=(8, 0))
|
||||
ttk.Button(
|
||||
list_btn_row,
|
||||
text="导入 CSV",
|
||||
bootstyle="secondary-outline",
|
||||
command=lambda: _import_samples_csv(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 4))
|
||||
ttk.Button(
|
||||
list_btn_row,
|
||||
text="载入示例",
|
||||
bootstyle="secondary-outline",
|
||||
command=lambda: _load_default_samples(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 4))
|
||||
ttk.Button(
|
||||
list_btn_row,
|
||||
text="删除",
|
||||
bootstyle="danger-outline",
|
||||
command=lambda: _delete_current_sample(self),
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
right = ttk.Frame(root)
|
||||
right.grid(row=1, column=1, sticky=tk.NSEW)
|
||||
|
||||
form_frame = ttk.LabelFrame(right, text="样本配置", padding=8)
|
||||
form_frame.pack(fill=tk.X)
|
||||
for column in range(6):
|
||||
form_frame.columnconfigure(column, weight=1 if column in (1, 3, 5) else 0)
|
||||
|
||||
self.single_step_name_var = tk.StringVar()
|
||||
self.single_step_hex_var = tk.StringVar(value="#FFFFFF")
|
||||
self.single_step_target_x_var = tk.StringVar()
|
||||
self.single_step_target_y_var = tk.StringVar()
|
||||
self.single_step_measured_x_var = tk.StringVar()
|
||||
self.single_step_measured_y_var = tk.StringVar()
|
||||
self.single_step_measured_lv_var = tk.StringVar()
|
||||
self.single_step_status_var = tk.StringVar(value="未选择样本")
|
||||
|
||||
ttk.Label(form_frame, text="名称").grid(row=0, column=0, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_name_var).grid(
|
||||
row=0, column=1, sticky=tk.EW, padx=(0, 8), pady=4
|
||||
)
|
||||
ttk.Label(form_frame, text="HEX").grid(row=0, column=2, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_hex_var, width=12).grid(
|
||||
row=0, column=3, sticky=tk.EW, padx=(0, 8), pady=4
|
||||
)
|
||||
ttk.Label(form_frame, text="目标 x").grid(row=0, column=4, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_target_x_var, width=10).grid(
|
||||
row=0, column=5, sticky=tk.EW, pady=4
|
||||
)
|
||||
|
||||
ttk.Label(form_frame, text="目标 y").grid(row=1, column=0, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_target_y_var, width=10).grid(
|
||||
row=1, column=1, sticky=tk.EW, padx=(0, 8), pady=4
|
||||
)
|
||||
ttk.Label(form_frame, text="实测 x").grid(row=1, column=2, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_measured_x_var, width=10).grid(
|
||||
row=1, column=3, sticky=tk.EW, padx=(0, 8), pady=4
|
||||
)
|
||||
ttk.Label(form_frame, text="实测 y").grid(row=1, column=4, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_measured_y_var, width=10).grid(
|
||||
row=1, column=5, sticky=tk.EW, pady=4
|
||||
)
|
||||
|
||||
ttk.Label(form_frame, text="实测 Lv").grid(row=2, column=0, sticky=tk.W, pady=4)
|
||||
ttk.Entry(form_frame, textvariable=self.single_step_measured_lv_var, width=10).grid(
|
||||
row=2, column=1, sticky=tk.EW, padx=(0, 8), pady=4
|
||||
)
|
||||
ttk.Label(
|
||||
form_frame,
|
||||
textvariable=self.single_step_status_var,
|
||||
foreground="#666",
|
||||
).grid(row=2, column=2, columnspan=4, sticky=tk.W, pady=4)
|
||||
|
||||
action_row = ttk.Frame(form_frame)
|
||||
action_row.grid(row=3, column=0, columnspan=6, sticky=tk.EW, pady=(6, 0))
|
||||
ttk.Button(
|
||||
action_row,
|
||||
text="新增 / 更新样本",
|
||||
bootstyle="primary",
|
||||
command=lambda: _upsert_sample(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 6))
|
||||
ttk.Button(
|
||||
action_row,
|
||||
text="发送当前色块",
|
||||
bootstyle="info-outline",
|
||||
command=lambda: _send_current_patch(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 6))
|
||||
ttk.Button(
|
||||
action_row,
|
||||
text="CA410 采集",
|
||||
bootstyle="success-outline",
|
||||
command=lambda: _measure_current_sample(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 6))
|
||||
ttk.Button(
|
||||
action_row,
|
||||
text="记录结果",
|
||||
bootstyle="warning-outline",
|
||||
command=lambda: _commit_result(self),
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
result_frame = ttk.LabelFrame(right, text="调试结果", padding=8)
|
||||
result_frame.pack(fill=tk.BOTH, expand=True, pady=(10, 0))
|
||||
|
||||
columns = (
|
||||
"name",
|
||||
"hex",
|
||||
"target_x",
|
||||
"target_y",
|
||||
"measured_x",
|
||||
"measured_y",
|
||||
"lv",
|
||||
"delta_e",
|
||||
"time",
|
||||
)
|
||||
self.single_step_result_tree = ttk.Treeview(
|
||||
result_frame,
|
||||
columns=columns,
|
||||
show="headings",
|
||||
height=12,
|
||||
)
|
||||
headings = {
|
||||
"name": "名称",
|
||||
"hex": "HEX",
|
||||
"target_x": "目标 x",
|
||||
"target_y": "目标 y",
|
||||
"measured_x": "实测 x",
|
||||
"measured_y": "实测 y",
|
||||
"lv": "Lv",
|
||||
"delta_e": "ΔE2000",
|
||||
"time": "时间",
|
||||
}
|
||||
widths = {
|
||||
"name": 130,
|
||||
"hex": 90,
|
||||
"target_x": 80,
|
||||
"target_y": 80,
|
||||
"measured_x": 80,
|
||||
"measured_y": 80,
|
||||
"lv": 80,
|
||||
"delta_e": 90,
|
||||
"time": 120,
|
||||
}
|
||||
for column in columns:
|
||||
self.single_step_result_tree.heading(column, text=headings[column])
|
||||
self.single_step_result_tree.column(
|
||||
column, width=widths[column], anchor=tk.CENTER
|
||||
)
|
||||
self.single_step_result_tree.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
bottom_row = ttk.Frame(right)
|
||||
bottom_row.pack(fill=tk.X, pady=(8, 0))
|
||||
ttk.Button(
|
||||
bottom_row,
|
||||
text="导出结果 CSV",
|
||||
bootstyle="success",
|
||||
command=lambda: _export_results_csv(self),
|
||||
).pack(side=tk.LEFT, padx=(0, 6))
|
||||
ttk.Button(
|
||||
bottom_row,
|
||||
text="清空结果",
|
||||
bootstyle="danger-outline",
|
||||
command=lambda: _clear_results(self),
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
self.register_panel("single_step", frame, None, "single_step_visible")
|
||||
_load_default_samples(self)
|
||||
|
||||
|
||||
def toggle_single_step_panel(self):
|
||||
"""切换单步调试面板。"""
|
||||
self.show_panel("single_step")
|
||||
|
||||
|
||||
def _load_default_samples(self):
|
||||
self.single_step_samples = [dict(item) for item in _DEFAULT_SAMPLES]
|
||||
_refresh_sample_list(self, select_index=0 if self.single_step_samples else None)
|
||||
self.single_step_status_var.set(
|
||||
f"已载入 {len(self.single_step_samples)} 个示例样本"
|
||||
)
|
||||
|
||||
|
||||
def _refresh_sample_list(self, select_index=None):
|
||||
self.single_step_listbox.delete(0, tk.END)
|
||||
for sample in self.single_step_samples:
|
||||
self.single_step_listbox.insert(
|
||||
tk.END,
|
||||
f"{sample['name']} {sample['hex']} ({sample['x']}, {sample['y']})",
|
||||
)
|
||||
if select_index is not None and 0 <= select_index < len(self.single_step_samples):
|
||||
self.single_step_listbox.selection_clear(0, tk.END)
|
||||
self.single_step_listbox.selection_set(select_index)
|
||||
self.single_step_listbox.activate(select_index)
|
||||
_select_sample(self, select_index)
|
||||
elif not self.single_step_samples:
|
||||
self.single_step_current_index = None
|
||||
self.single_step_name_var.set("")
|
||||
self.single_step_hex_var.set("#FFFFFF")
|
||||
self.single_step_target_x_var.set("")
|
||||
self.single_step_target_y_var.set("")
|
||||
self.single_step_status_var.set("样本列表为空")
|
||||
|
||||
|
||||
def _on_sample_select(self):
|
||||
selection = self.single_step_listbox.curselection()
|
||||
if not selection:
|
||||
return
|
||||
_select_sample(self, selection[0])
|
||||
|
||||
|
||||
def _select_sample(self, index):
|
||||
sample = self.single_step_samples[index]
|
||||
self.single_step_current_index = index
|
||||
self.single_step_name_var.set(sample["name"])
|
||||
self.single_step_hex_var.set(sample["hex"])
|
||||
self.single_step_target_x_var.set(sample["x"])
|
||||
self.single_step_target_y_var.set(sample["y"])
|
||||
self.single_step_status_var.set(f"当前样本: {sample['name']}")
|
||||
|
||||
|
||||
def _import_samples_csv(self):
|
||||
path = filedialog.askopenfilename(
|
||||
title="选择单步调试样本 CSV",
|
||||
filetypes=[("CSV 文件", "*.csv"), ("所有文件", "*.*")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
samples = []
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
|
||||
reader = csv.DictReader(fp)
|
||||
for row in reader:
|
||||
name = (row.get("name") or row.get("sample") or "").strip()
|
||||
hex_value = (row.get("hex") or row.get("rgb") or "").strip()
|
||||
target_x = (row.get("x") or "").strip()
|
||||
target_y = (row.get("y") or "").strip()
|
||||
if not name or not hex_value or not target_x or not target_y:
|
||||
continue
|
||||
samples.append(
|
||||
{
|
||||
"name": name,
|
||||
"hex": _normalize_hex(hex_value),
|
||||
"x": target_x,
|
||||
"y": target_y,
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("导入失败", f"读取 CSV 失败: {exc}")
|
||||
return
|
||||
if not samples:
|
||||
messagebox.showwarning("导入失败", "CSV 中没有有效样本,要求列包含 name/hex/x/y")
|
||||
return
|
||||
self.single_step_samples = samples
|
||||
_refresh_sample_list(self, select_index=0)
|
||||
self.log_gui.log(f"单步调试样本已导入: {len(samples)} 条", level="success")
|
||||
|
||||
|
||||
def _delete_current_sample(self):
|
||||
if self.single_step_current_index is None:
|
||||
return
|
||||
removed = self.single_step_samples.pop(self.single_step_current_index)
|
||||
next_index = min(self.single_step_current_index, len(self.single_step_samples) - 1)
|
||||
_refresh_sample_list(self, select_index=next_index if next_index >= 0 else None)
|
||||
self.single_step_status_var.set(f"已删除样本: {removed['name']}")
|
||||
|
||||
|
||||
def _upsert_sample(self):
|
||||
try:
|
||||
sample = {
|
||||
"name": self.single_step_name_var.get().strip(),
|
||||
"hex": _normalize_hex(self.single_step_hex_var.get()),
|
||||
"x": _format_float(self.single_step_target_x_var.get()),
|
||||
"y": _format_float(self.single_step_target_y_var.get()),
|
||||
}
|
||||
except ValueError as exc:
|
||||
messagebox.showerror("参数错误", str(exc))
|
||||
return
|
||||
if not sample["name"]:
|
||||
messagebox.showwarning("提示", "请输入样本名称")
|
||||
return
|
||||
if self.single_step_current_index is None:
|
||||
self.single_step_samples.append(sample)
|
||||
select_index = len(self.single_step_samples) - 1
|
||||
self.single_step_status_var.set(f"已新增样本: {sample['name']}")
|
||||
else:
|
||||
self.single_step_samples[self.single_step_current_index] = sample
|
||||
select_index = self.single_step_current_index
|
||||
self.single_step_status_var.set(f"已更新样本: {sample['name']}")
|
||||
_refresh_sample_list(self, select_index=select_index)
|
||||
|
||||
|
||||
def _normalize_hex(value):
|
||||
text = (value or "").strip().upper()
|
||||
if not text:
|
||||
raise ValueError("HEX 不能为空")
|
||||
if not text.startswith("#"):
|
||||
text = "#" + text
|
||||
if len(text) != 7 or any(ch not in "#0123456789ABCDEF" for ch in text):
|
||||
raise ValueError("HEX 格式应为 #RRGGBB")
|
||||
return text
|
||||
|
||||
|
||||
def _format_float(value):
|
||||
try:
|
||||
number = float(str(value).strip())
|
||||
except Exception as exc:
|
||||
raise ValueError("xy 值必须是数字") from exc
|
||||
return f"{number:.4f}"
|
||||
|
||||
|
||||
def _build_color_patch(self, hex_value):
|
||||
if not getattr(self, "ucd", None) or not self.ucd.status:
|
||||
raise RuntimeError("请先连接 UCD323 设备")
|
||||
width, height = get_current_resolution(self.ucd)
|
||||
rgb = tuple(int(hex_value[i:i + 2], 16) for i in (1, 3, 5))
|
||||
temp_dir = os.path.join(tempfile.gettempdir(), "pq_single_step_patches")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
file_path = os.path.join(
|
||||
temp_dir, f"single_step_{hex_value[1:]}_{width}x{height}.png"
|
||||
)
|
||||
Image.new("RGB", (width, height), rgb).save(file_path, format="PNG")
|
||||
return file_path
|
||||
|
||||
|
||||
def _send_current_patch(self):
|
||||
if self.single_step_current_index is None:
|
||||
messagebox.showinfo("提示", "请先选择一个样本")
|
||||
return
|
||||
sample = self.single_step_samples[self.single_step_current_index]
|
||||
|
||||
def worker():
|
||||
try:
|
||||
image_path = _build_color_patch(self, sample["hex"])
|
||||
ok = send_image_pattern(self.ucd, image_path)
|
||||
if not ok:
|
||||
raise RuntimeError("UCD323 发送失败")
|
||||
self.single_step_current_image_path = image_path
|
||||
self._dispatch_ui(
|
||||
self.single_step_status_var.set,
|
||||
f"已发送色块: {sample['name']} {sample['hex']}",
|
||||
)
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"单步调试色块已发送: {sample['name']} {sample['hex']}",
|
||||
"success",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._dispatch_ui(self.single_step_status_var.set, f"发送失败: {exc}")
|
||||
self._dispatch_ui(self.log_gui.log, f"单步调试色块发送失败: {exc}", "error")
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
def _measure_current_sample(self):
|
||||
if self.single_step_current_index is None:
|
||||
messagebox.showinfo("提示", "请先选择一个样本")
|
||||
return
|
||||
if not getattr(self, "ca", None):
|
||||
messagebox.showwarning("警告", "请先连接 CA410 色度计")
|
||||
return
|
||||
|
||||
def worker():
|
||||
try:
|
||||
x, y, lv, _X, _Y, _Z = self.ca.readAllDisplay()
|
||||
if lv is None:
|
||||
raise RuntimeError("CA410 未返回有效亮度")
|
||||
self._dispatch_ui(self.single_step_measured_x_var.set, f"{x:.4f}")
|
||||
self._dispatch_ui(self.single_step_measured_y_var.set, f"{y:.4f}")
|
||||
self._dispatch_ui(self.single_step_measured_lv_var.set, f"{lv:.2f}")
|
||||
self._dispatch_ui(self.single_step_status_var.set, "采集完成,可记录结果")
|
||||
self._dispatch_ui(
|
||||
self.log_gui.log,
|
||||
f"单步调试采集完成: x={x:.4f}, y={y:.4f}, Lv={lv:.2f}",
|
||||
"success",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._dispatch_ui(self.single_step_status_var.set, f"采集失败: {exc}")
|
||||
self._dispatch_ui(self.log_gui.log, f"单步调试采集失败: {exc}", "error")
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
def _commit_result(self):
|
||||
if self.single_step_current_index is None:
|
||||
messagebox.showinfo("提示", "请先选择一个样本")
|
||||
return
|
||||
sample = self.single_step_samples[self.single_step_current_index]
|
||||
try:
|
||||
measured_x = float(self.single_step_measured_x_var.get().strip())
|
||||
measured_y = float(self.single_step_measured_y_var.get().strip())
|
||||
measured_lv = float(self.single_step_measured_lv_var.get().strip())
|
||||
target_x = float(sample["x"])
|
||||
target_y = float(sample["y"])
|
||||
delta_e = self.calculate_delta_e_2000(
|
||||
measured_x, measured_y, measured_lv, target_x, target_y
|
||||
)
|
||||
except Exception as exc:
|
||||
messagebox.showerror("记录失败", f"请先准备完整的目标值与实测值: {exc}")
|
||||
return
|
||||
|
||||
timestamp = datetime.datetime.now().strftime("%H:%M:%S")
|
||||
record = {
|
||||
"name": sample["name"],
|
||||
"hex": sample["hex"],
|
||||
"target_x": f"{target_x:.4f}",
|
||||
"target_y": f"{target_y:.4f}",
|
||||
"measured_x": f"{measured_x:.4f}",
|
||||
"measured_y": f"{measured_y:.4f}",
|
||||
"lv": f"{measured_lv:.2f}",
|
||||
"delta_e": f"{delta_e:.3f}",
|
||||
"time": timestamp,
|
||||
}
|
||||
self.single_step_results.append(record)
|
||||
self.single_step_result_tree.insert(
|
||||
"",
|
||||
tk.END,
|
||||
values=tuple(
|
||||
record[key]
|
||||
for key in (
|
||||
"name",
|
||||
"hex",
|
||||
"target_x",
|
||||
"target_y",
|
||||
"measured_x",
|
||||
"measured_y",
|
||||
"lv",
|
||||
"delta_e",
|
||||
"time",
|
||||
)
|
||||
),
|
||||
)
|
||||
self.single_step_status_var.set(f"已记录结果,ΔE2000={record['delta_e']}")
|
||||
|
||||
|
||||
def _clear_results(self):
|
||||
self.single_step_results = []
|
||||
for item in self.single_step_result_tree.get_children():
|
||||
self.single_step_result_tree.delete(item)
|
||||
self.single_step_status_var.set("结果已清空")
|
||||
|
||||
|
||||
def _export_results_csv(self):
|
||||
if not self.single_step_results:
|
||||
messagebox.showinfo("提示", "暂无可导出的调试结果")
|
||||
return
|
||||
path = filedialog.asksaveasfilename(
|
||||
title="导出单步调试结果",
|
||||
defaultextension=".csv",
|
||||
initialfile=f"single_step_debug_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
|
||||
filetypes=[("CSV 文件", "*.csv")],
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
fieldnames = [
|
||||
"name",
|
||||
"hex",
|
||||
"target_x",
|
||||
"target_y",
|
||||
"measured_x",
|
||||
"measured_y",
|
||||
"lv",
|
||||
"delta_e",
|
||||
"time",
|
||||
]
|
||||
try:
|
||||
with open(path, "w", encoding="utf-8-sig", newline="") as fp:
|
||||
writer = csv.DictWriter(fp, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(self.single_step_results)
|
||||
self.log_gui.log(f"单步调试结果已导出: {path}", level="success")
|
||||
self.single_step_status_var.set(f"结果已导出: {os.path.basename(path)}")
|
||||
except Exception as exc:
|
||||
messagebox.showerror("导出失败", f"写入 CSV 失败: {exc}")
|
||||
@@ -768,28 +768,25 @@ class PQDebugPanel:
|
||||
# 禁用按钮
|
||||
self._disable_test_button(test_type, test_item)
|
||||
|
||||
# 根据测试类型设置信号格式
|
||||
self._setup_signal_format(test_type)
|
||||
|
||||
# 获取图案索引并发送
|
||||
if test_item in ["gamma", "eotf"]:
|
||||
pattern_index = self.get_gray_index(selected)
|
||||
self.app.config.set_current_pattern("gray")
|
||||
pattern_mode = "gray"
|
||||
elif test_item == "accuracy":
|
||||
pattern_index = self.get_color_index(selected)
|
||||
self.app.config.set_current_pattern("accuracy")
|
||||
pattern_mode = "accuracy"
|
||||
elif test_item == "rgb":
|
||||
pattern_index = self.get_rgb_index(selected)
|
||||
self.app.config.set_current_pattern("rgb")
|
||||
pattern_mode = "rgb"
|
||||
else:
|
||||
raise ValueError(f"不支持的测试项目: {test_item}")
|
||||
|
||||
# 设置图案
|
||||
self.app.ucd.set_ucd_params(self.app.config)
|
||||
|
||||
# 跳转到目标图案
|
||||
for i in range(pattern_index + 1):
|
||||
self.app.ucd.set_next_pattern()
|
||||
|
||||
self.app.ucd.run()
|
||||
session = self.app.pattern_service.prepare_session(
|
||||
pattern_mode,
|
||||
test_type=test_type,
|
||||
log_details=False,
|
||||
)
|
||||
self.app.pattern_service.send_session_pattern(session, pattern_index)
|
||||
time.sleep(1.5)
|
||||
|
||||
# 测量数据
|
||||
@@ -815,28 +812,6 @@ class PQDebugPanel:
|
||||
self.app.log_gui.log(traceback.format_exc(), level="error")
|
||||
self._enable_test_button(test_type, test_item)
|
||||
|
||||
def _setup_signal_format(self, test_type):
|
||||
"""设置信号格式"""
|
||||
if test_type == "screen_module":
|
||||
self.app.ucd.set_ucd_params(self.app.config)
|
||||
|
||||
elif test_type == "sdr_movie":
|
||||
self.app.ucd.set_sdr_format(
|
||||
color_space=self.app.sdr_color_space_var.get(),
|
||||
gamma=self.app.sdr_gamma_type_var.get(),
|
||||
data_range=self.app.sdr_data_range_var.get(),
|
||||
bit_depth=self.app.sdr_bit_depth_var.get(),
|
||||
)
|
||||
|
||||
elif test_type == "hdr_movie":
|
||||
self.app.ucd.set_hdr_format(
|
||||
color_space=self.app.hdr_color_space_var.get(),
|
||||
data_range=self.app.hdr_data_range_var.get(),
|
||||
bit_depth=self.app.hdr_bit_depth_var.get(),
|
||||
max_cll=self.app.hdr_maxcll_var.get(),
|
||||
max_fall=self.app.hdr_maxfall_var.get(),
|
||||
)
|
||||
|
||||
def _compare_and_display(self, test_type, test_item, selected, new_data):
|
||||
"""对比数据并显示"""
|
||||
# 获取原始数据
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
import tkinter as tk
|
||||
import ttkbootstrap as ttk
|
||||
|
||||
|
||||
# 与 app.logging_setup 共享的映射;放在这里避免循环 import
|
||||
_GUI_LEVEL_TO_LOG = {
|
||||
"debug": logging.DEBUG,
|
||||
"info": logging.INFO,
|
||||
"success": logging.INFO,
|
||||
"warning": logging.WARNING,
|
||||
"error": logging.ERROR,
|
||||
"separator": logging.INFO,
|
||||
"blank": logging.DEBUG,
|
||||
}
|
||||
|
||||
|
||||
class PQLogGUI(ttk.Frame):
|
||||
VALID_LEVELS = {"info", "success", "warning", "error", "debug", "separator", "blank"}
|
||||
|
||||
@@ -10,6 +24,8 @@ class PQLogGUI(ttk.Frame):
|
||||
super().__init__(parent)
|
||||
self._line_count = 0
|
||||
self._max_lines = 1500
|
||||
# 与 stdlib logging 联通的 logger,由 logging_setup 配置 file handler
|
||||
self._logger = logging.getLogger("pq.gui")
|
||||
self.create_widgets()
|
||||
|
||||
def create_widgets(self):
|
||||
@@ -62,13 +78,25 @@ class PQLogGUI(ttk.Frame):
|
||||
self._configure_tags()
|
||||
self.log_text.config(state=tk.DISABLED)
|
||||
|
||||
def log(self, message, level="info"):
|
||||
def log(self, message, level="info", _from_logging=False):
|
||||
if threading.current_thread() is not threading.main_thread():
|
||||
self.after(0, self.log, message, level)
|
||||
self.after(0, self.log, message, level, _from_logging)
|
||||
return
|
||||
|
||||
text = "" if message is None else str(message)
|
||||
normalized_level = self._normalize_level(level, text)
|
||||
|
||||
# 转发到 stdlib logging,落到文件(_from_logging=True 表示来源已是
|
||||
# logging,不再回写避免重复)。带 _from_gui 标记,TkLogHandler 会跳过。
|
||||
if not _from_logging and normalized_level != "blank":
|
||||
log_level = _GUI_LEVEL_TO_LOG.get(normalized_level, logging.INFO)
|
||||
try:
|
||||
self._logger.log(
|
||||
log_level, text, extra={"_from_gui": True}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.log_text.config(state=tk.NORMAL)
|
||||
self._append_message(text, normalized_level)
|
||||
self.log_text.see(tk.END)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
APP_NAME = "PQ 自动化测试工具"
|
||||
APP_VERSION = "1.1.0"
|
||||
APP_VERSION = "0.1.0"
|
||||
|
||||
|
||||
def get_app_title():
|
||||
|
||||
15
docs/UCD-API文档/UniTAP.html
Normal file
15
docs/UCD-API文档/UniTAP.html
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
<!DOCTYPE HTML>
|
||||
<html>
|
||||
<body>
|
||||
<script>
|
||||
function myFunction()
|
||||
{
|
||||
window.location.href = './content/index.html';
|
||||
}
|
||||
myFunction();
|
||||
window.open('file.extension');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
289
docs/UCD-API文档/content/DUT_TEST_GROUPS_INFO.html
Normal file
289
docs/UCD-API文档/content/DUT_TEST_GROUPS_INFO.html
Normal file
File diff suppressed because one or more lines are too long
1763
docs/UCD-API文档/content/Examples.html
Normal file
1763
docs/UCD-API文档/content/Examples.html
Normal file
File diff suppressed because one or more lines are too long
278
docs/UCD-API文档/content/Quickstart.html
Normal file
278
docs/UCD-API文档/content/Quickstart.html
Normal file
File diff suppressed because one or more lines are too long
727
docs/UCD-API文档/content/UniTAP/common/audio_mode.html
Normal file
727
docs/UCD-API文档/content/UniTAP/common/audio_mode.html
Normal file
File diff suppressed because one or more lines are too long
1005
docs/UCD-API文档/content/UniTAP/common/color_info.html
Normal file
1005
docs/UCD-API文档/content/UniTAP/common/color_info.html
Normal file
File diff suppressed because one or more lines are too long
669
docs/UCD-API文档/content/UniTAP/common/data_info.html
Normal file
669
docs/UCD-API文档/content/UniTAP/common/data_info.html
Normal file
File diff suppressed because one or more lines are too long
556
docs/UCD-API文档/content/UniTAP/common/dsc_compression_info.html
Normal file
556
docs/UCD-API文档/content/UniTAP/common/dsc_compression_info.html
Normal file
File diff suppressed because one or more lines are too long
323
docs/UCD-API文档/content/UniTAP/common/dsc_video_frame.html
Normal file
323
docs/UCD-API文档/content/UniTAP/common/dsc_video_frame.html
Normal file
File diff suppressed because one or more lines are too long
363
docs/UCD-API文档/content/UniTAP/common/timestamp.html
Normal file
363
docs/UCD-API文档/content/UniTAP/common/timestamp.html
Normal file
File diff suppressed because one or more lines are too long
817
docs/UCD-API文档/content/UniTAP/common/timing.html
Normal file
817
docs/UCD-API文档/content/UniTAP/common/timing.html
Normal file
File diff suppressed because one or more lines are too long
507
docs/UCD-API文档/content/UniTAP/common/video_frame.html
Normal file
507
docs/UCD-API文档/content/UniTAP/common/video_frame.html
Normal file
File diff suppressed because one or more lines are too long
350
docs/UCD-API文档/content/UniTAP/common/video_mode.html
Normal file
350
docs/UCD-API文档/content/UniTAP/common/video_mode.html
Normal file
File diff suppressed because one or more lines are too long
1445
docs/UCD-API文档/content/UniTAP/dev/dev_3xx_roles.html
Normal file
1445
docs/UCD-API文档/content/UniTAP/dev/dev_3xx_roles.html
Normal file
File diff suppressed because one or more lines are too long
798
docs/UCD-API文档/content/UniTAP/dev/dev_4xx_roles.html
Normal file
798
docs/UCD-API文档/content/UniTAP/dev/dev_4xx_roles.html
Normal file
File diff suppressed because one or more lines are too long
869
docs/UCD-API文档/content/UniTAP/dev/dev_5xx_roles.html
Normal file
869
docs/UCD-API文档/content/UniTAP/dev/dev_5xx_roles.html
Normal file
File diff suppressed because one or more lines are too long
446
docs/UCD-API文档/content/UniTAP/dev/device.html
Normal file
446
docs/UCD-API文档/content/UniTAP/dev/device.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1409
docs/UCD-API文档/content/UniTAP/dev/modules/dut_tests/test_info.html
Normal file
1409
docs/UCD-API文档/content/UniTAP/dev/modules/dut_tests/test_info.html
Normal file
File diff suppressed because one or more lines are too long
304
docs/UCD-API文档/content/UniTAP/dev/modules/opf/handler.html
Normal file
304
docs/UCD-API文档/content/UniTAP/dev/modules/opf/handler.html
Normal file
File diff suppressed because one or more lines are too long
430
docs/UCD-API文档/content/UniTAP/dev/ports/dprx.html
Normal file
430
docs/UCD-API文档/content/UniTAP/dev/ports/dprx.html
Normal file
File diff suppressed because one or more lines are too long
416
docs/UCD-API文档/content/UniTAP/dev/ports/dprx4xx.html
Normal file
416
docs/UCD-API文档/content/UniTAP/dev/ports/dprx4xx.html
Normal file
File diff suppressed because one or more lines are too long
311
docs/UCD-API文档/content/UniTAP/dev/ports/dprx5xx.html
Normal file
311
docs/UCD-API文档/content/UniTAP/dev/ports/dprx5xx.html
Normal file
File diff suppressed because one or more lines are too long
430
docs/UCD-API文档/content/UniTAP/dev/ports/dptx.html
Normal file
430
docs/UCD-API文档/content/UniTAP/dev/ports/dptx.html
Normal file
File diff suppressed because one or more lines are too long
370
docs/UCD-API文档/content/UniTAP/dev/ports/dptx4xx.html
Normal file
370
docs/UCD-API文档/content/UniTAP/dev/ports/dptx4xx.html
Normal file
File diff suppressed because one or more lines are too long
301
docs/UCD-API文档/content/UniTAP/dev/ports/dptx5xx.html
Normal file
301
docs/UCD-API文档/content/UniTAP/dev/ports/dptx5xx.html
Normal file
File diff suppressed because one or more lines are too long
430
docs/UCD-API文档/content/UniTAP/dev/ports/hdrx.html
Normal file
430
docs/UCD-API文档/content/UniTAP/dev/ports/hdrx.html
Normal file
File diff suppressed because one or more lines are too long
296
docs/UCD-API文档/content/UniTAP/dev/ports/hdrx4xx.html
Normal file
296
docs/UCD-API文档/content/UniTAP/dev/ports/hdrx4xx.html
Normal file
File diff suppressed because one or more lines are too long
430
docs/UCD-API文档/content/UniTAP/dev/ports/hdtx.html
Normal file
430
docs/UCD-API文档/content/UniTAP/dev/ports/hdtx.html
Normal file
File diff suppressed because one or more lines are too long
296
docs/UCD-API文档/content/UniTAP/dev/ports/hdtx4xx.html
Normal file
296
docs/UCD-API文档/content/UniTAP/dev/ports/hdtx4xx.html
Normal file
File diff suppressed because one or more lines are too long
402
docs/UCD-API文档/content/UniTAP/dev/ports/modules/ag/ag.html
Normal file
402
docs/UCD-API文档/content/UniTAP/dev/ports/modules/ag/ag.html
Normal file
File diff suppressed because one or more lines are too long
336
docs/UCD-API文档/content/UniTAP/dev/ports/modules/ag/ag_utils.html
Normal file
336
docs/UCD-API文档/content/UniTAP/dev/ports/modules/ag/ag_utils.html
Normal file
File diff suppressed because one or more lines are too long
463
docs/UCD-API文档/content/UniTAP/dev/ports/modules/ag/types.html
Normal file
463
docs/UCD-API文档/content/UniTAP/dev/ports/modules/ag/types.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
528
docs/UCD-API文档/content/UniTAP/dev/ports/modules/dpcd/dpcd.html
Normal file
528
docs/UCD-API文档/content/UniTAP/dev/ports/modules/dpcd/dpcd.html
Normal file
File diff suppressed because one or more lines are too long
844
docs/UCD-API文档/content/UniTAP/dev/ports/modules/edid/edid.html
Normal file
844
docs/UCD-API文档/content/UniTAP/dev/ports/modules/edid/edid.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
423
docs/UCD-API文档/content/UniTAP/dev/ports/modules/fec/fec_rx.html
Normal file
423
docs/UCD-API文档/content/UniTAP/dev/ports/modules/fec/fec_rx.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
474
docs/UCD-API文档/content/UniTAP/dev/ports/modules/fec/fec_tx.html
Normal file
474
docs/UCD-API文档/content/UniTAP/dev/ports/modules/fec/fec_tx.html
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user