优化AI图像显示,添加发送图片到UCD

This commit is contained in:
xinzhu.yin
2026-04-22 11:02:16 +08:00
parent 9a2ac69afb
commit 4073a6e999
8 changed files with 338 additions and 41 deletions

1
.gitignore vendored
View File

@@ -31,3 +31,4 @@ Desktop.ini
# Local configuration overrides # Local configuration overrides
settings/*.local.json settings/*.local.json
settings/

View File

@@ -490,10 +490,12 @@ def export_excel_report(result_dir, current_test_type, selected_items,
for col, width in cfg["column_widths"].items(): for col, width in cfg["column_widths"].items():
ws.column_dimensions[col].width = width ws.column_dimensions[col].width = width
excel_path = os.path.join(result_dir, "测试数据.xlsx") timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
excel_filename = f"测试数据_{timestamp}.xlsx"
excel_path = os.path.join(result_dir, excel_filename)
wb.save(excel_path) wb.save(excel_path)
log("已保存: 测试数据.xlsx", level="success") log(f"已保存: {excel_filename}", level="success")
log("=" * 60, level="seperator") log("=" * 60, level="seperator")
except ImportError: except ImportError:

View File

@@ -122,10 +122,40 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
extra=extra, extra=extra,
) )
) )
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) records.sort(key=lambda r: r.created_at, reverse=True)
return records 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( def save_image_to_cache(
prompt: str, prompt: str,
image_bytes: bytes, image_bytes: bytes,

View File

@@ -3,14 +3,16 @@
from __future__ import annotations from __future__ import annotations
import os import os
import threading
import tkinter as tk import tkinter as tk
from tkinter import filedialog, messagebox from tkinter import filedialog, messagebox, simpledialog
from typing import List, Optional
import ttkbootstrap as ttk import ttkbootstrap as ttk
from PIL import Image, ImageTk from PIL import Image, ImageTk
from app.services import ai_image as _svc from app.services import ai_image as _svc
from drivers.ucd_helpers import get_current_resolution, send_image_pattern
# ---------------- 面板创建 ---------------- # ---------------- 面板创建 ----------------
@@ -22,40 +24,83 @@ def create_ai_image_panel(self):
self.ai_image_frame = frame self.ai_image_frame = frame
# 内部状态 # 内部状态
self.ai_image_records: List[_svc.AIImageRecord] = [] self.ai_image_records = [] # list[AIImageRecord]
self.ai_image_current: Optional[_svc.AIImageRecord] = None self.ai_image_current = None # AIImageRecord | None
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
self._ai_image_requesting = False self._ai_image_requesting = False
container = ttk.Frame(frame, padding=10) container = ttk.Frame(frame, padding=10)
container.pack(fill=tk.BOTH, expand=True) container.pack(fill=tk.BOTH, expand=True)
# 左列:图片列表 # 左列:图片列表
left = ttk.Frame(container) # 使用 grid + 权重,让右侧预览区优先占据剩余空间。
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10)) container.columnconfigure(0, weight=0)
container.columnconfigure(1, weight=1)
container.rowconfigure(0, weight=1)
left = ttk.Frame(container, width=360)
left.grid(row=0, column=0, sticky=tk.NS, padx=(0, 10))
left.grid_propagate(False)
ttk.Label(left, text="历史图片", font=("微软雅黑", 10, "bold")).pack( ttk.Label(left, text="历史图片", font=("微软雅黑", 10, "bold")).pack(
anchor=tk.W, pady=(0, 4) anchor=tk.W, pady=(0, 4)
) )
list_wrap = ttk.Frame(left) list_wrap = ttk.Frame(left, padding=2)
list_wrap.pack(fill=tk.Y, expand=False) list_wrap.pack(fill=tk.BOTH, expand=True)
scroll = ttk.Scrollbar(list_wrap, orient=tk.VERTICAL) scroll = ttk.Scrollbar(list_wrap, orient=tk.VERTICAL)
self.ai_image_listbox = tk.Listbox( self.ai_image_listbox = tk.Listbox(
list_wrap, list_wrap,
width=28, width=34,
height=22, height=1, # 由 pack fill/expand 撑满height 仅为最小保底
activestyle="dotbox", activestyle="none",
font=("微软雅黑", 9),
bd=1,
relief=tk.FLAT,
highlightthickness=1,
highlightbackground="#d8d8d8",
highlightcolor="#4a90e2",
selectbackground="#2b6cb0",
selectforeground="#ffffff",
yscrollcommand=scroll.set, yscrollcommand=scroll.set,
) )
scroll.config(command=self.ai_image_listbox.yview) scroll.config(command=self.ai_image_listbox.yview)
self.ai_image_listbox.pack(side=tk.LEFT, fill=tk.Y) self.ai_image_listbox.pack(side=tk.LEFT, fill=tk.Y)
scroll.pack(side=tk.RIGHT, fill=tk.Y) scroll.pack(side=tk.RIGHT, fill=tk.Y)
self.ai_image_listbox.bind("<<ListboxSelect>>", lambda e: _on_list_select(self)) self.ai_image_listbox.bind("<<ListboxSelect>>", lambda e: _on_list_select(self))
# 右键菜单:发送到 UCD / 重命名 / 另存为 / 删除
# 索引: 0=发送, 1=sep, 2=重命名, 3=另存为, 4=删除
self.ai_image_menu = tk.Menu(self.root, tearoff=0)
self.ai_image_menu.add_command(
label="发送图片",
command=lambda: _send_to_ucd(self),
)
self.ai_image_menu.add_separator()
self.ai_image_menu.add_command(
label="重命名…",
command=lambda: _rename_current(self),
)
self.ai_image_menu.add_command(
label="另存为…",
command=lambda: _save_current(self),
)
self.ai_image_menu.add_command(
label="删除",
command=lambda: _delete_current(self),
)
self.ai_image_listbox.bind(
"<Button-3>",
lambda e: _show_list_context_menu(self, e),
)
btn_row = ttk.Frame(left) btn_row = ttk.Frame(left)
btn_row.pack(fill=tk.X, pady=(6, 0)) btn_row.pack(fill=tk.X, pady=(6, 0))
self.ai_image_send_ucd_btn = ttk.Button(
btn_row, text="发送", bootstyle="info-outline", width=8,
command=lambda: _send_to_ucd(self),
)
self.ai_image_send_ucd_btn.pack(side=tk.LEFT, padx=(0, 4))
ttk.Button( ttk.Button(
btn_row, text="保存", bootstyle="success-outline", width=8, btn_row, text="保存", bootstyle="success-outline", width=8,
command=lambda: _save_current(self), command=lambda: _save_current(self),
@@ -71,7 +116,7 @@ def create_ai_image_panel(self):
# 右列:预览 + 输入 # 右列:预览 + 输入
right = ttk.Frame(container) right = ttk.Frame(container)
right.pack(side=tk.LEFT, fill=tk.BOTH, expand=True) right.grid(row=0, column=1, sticky=tk.NSEW)
preview_frame = ttk.LabelFrame(right, text="图片预览", padding=6) preview_frame = ttk.LabelFrame(right, text="图片预览", padding=6)
preview_frame.pack(fill=tk.BOTH, expand=True) preview_frame.pack(fill=tk.BOTH, expand=True)
@@ -147,11 +192,25 @@ def reload_ai_image_list(self):
def _format_list_label(rec: _svc.AIImageRecord) -> str: def _format_list_label(rec: _svc.AIImageRecord) -> str:
when = (rec.created_at or "")[:19].replace("T", " ") # 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
preview = (rec.prompt or "(无提示)").strip().splitlines()[0] size_tag = ""
if len(preview) > 18: extra = rec.extra or {}
preview = preview[:18] + "" if isinstance(extra, dict) and extra.get("size"):
return f"{when} {preview}" if when else preview size_tag = f"[{extra['size']}] "
else:
try:
from PIL import Image as _Im
with _Im.open(rec.image_path) as _im:
size_tag = f"[{_im.width}×{_im.height}] "
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}"
def _on_list_select(self): def _on_list_select(self):
@@ -233,7 +292,7 @@ def _set_requesting(self, flag: bool):
pass pass
def _on_request_done(self, record: Optional[_svc.AIImageRecord], exc: Optional[Exception]): def _on_request_done(self, record, exc):
_set_requesting(self, False) _set_requesting(self, False)
if exc is not None: if exc is not None:
self.ai_image_status_var.set(f"失败: {exc}") self.ai_image_status_var.set(f"失败: {exc}")
@@ -283,3 +342,207 @@ def _delete_current(self):
return return
_svc.delete_record(rec) _svc.delete_record(rec)
reload_ai_image_list(self) reload_ai_image_list(self)
def _rename_current(self):
"""弹窗让用户修改当前记录的备注名称(即列表显示中 size_tag 之后的部分)。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
current_name = rec.prompt or ""
new_name = simpledialog.askstring(
"重命名",
"修改备注名称(显示在分辨率标签后面):",
initialvalue=current_name,
parent=self.root,
)
if new_name is None: # 用户点了取消
return
new_name = new_name.strip()
if not new_name:
messagebox.showwarning("提示", "备注名称不能为空")
return
if new_name == current_name:
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}")
return
# 同步内存中的记录并刷新列表
rec.prompt = new_name
reload_ai_image_list(self)
# 重新定位到刚才被重命名的图片
for i, r in enumerate(self.ai_image_records):
if r.id == rec.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)
_select_record(self, r)
break
# ---------------- 发送到 UCD ----------------
def _show_list_context_menu(self, event):
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
try:
idx = self.ai_image_listbox.nearest(event.y)
except Exception:
idx = -1
if 0 <= idx < 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])
has_selection = self.ai_image_current is not None
ucd = getattr(self, "ucd", None)
can_send = (
has_selection
and ucd is not None
and getattr(ucd, "status", False)
)
try:
self.ai_image_menu.entryconfigure(
0, state=("normal" if can_send else "disabled")
)
self.ai_image_menu.entryconfigure(
2, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.entryconfigure(
3, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.entryconfigure(
4, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.tk_popup(event.x_root, event.y_root)
finally:
self.ai_image_menu.grab_release()
def _send_to_ucd(self):
"""把当前选中的 AI 图片通过 UCD 发送到显示设备。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
if not os.path.isfile(rec.image_path):
messagebox.showerror("错误", f"图片文件不存在:\n{rec.image_path}")
return
ucd = getattr(self, "ucd", None)
if ucd is None or not getattr(ucd, "status", False):
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
image_path = rec.image_path
send_path = image_path
log = getattr(self, "log_gui", None)
# 发送前检查分辨率:建议与当前 UCD 输出分辨率一致。
try:
target_w, target_h = get_current_resolution(ucd)
except Exception:
target_w, target_h = 3840, 2160
try:
with Image.open(image_path) as _img:
src_w, src_h = _img.size
except Exception as exc:
messagebox.showerror("错误", f"无法读取图片尺寸:\n{exc}")
return
if log is not None:
log.log(
f"UCD分辨率: {target_w}x{target_h} | 图片分辨率: {src_w}x{src_h}",
level="info",
)
if (src_w, src_h) != (target_w, target_h):
if not messagebox.askyesno(
"分辨率不匹配",
(
f"当前 UCD 分辨率: {target_w}x{target_h}\n"
f"图片分辨率: {src_w}x{src_h}\n\n"
"是否自动缩放后再发送?"
),
):
if log is not None:
log.log("用户取消发送:图片分辨率与 UCD 不一致", level="warning")
return
try:
send_path = _build_ucd_resized_image(image_path, target_w, target_h)
if log is not None:
log.log(
f"已生成匹配分辨率副本: {os.path.basename(send_path)}",
level="info",
)
except Exception as exc:
messagebox.showerror("错误", f"自动缩放失败:\n{exc}")
return
if log is not None:
log.log(
f"发送 AI 图片到 UCD: {os.path.basename(send_path)}",
level="info",
)
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("发送中…")
def _worker():
err = None
try:
ok = send_image_pattern(ucd, send_path)
except Exception as exc:
ok = False
err = str(exc)
def _done():
if ok:
if log is not None:
log.log(
f"图片已发送到 UCD: {os.path.basename(send_path)}",
level="success",
)
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("已发送到 UCD")
else:
msg = f"UCD 发送失败: {err}" if err else "UCD 发送失败"
if log is not None:
log.log(msg, level="error")
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("UCD 发送失败")
try:
self.root.after(0, _done)
except Exception:
pass
threading.Thread(target=_worker, daemon=True).start()
def _build_ucd_resized_image(image_path: str, target_w: int, target_h: int) -> str:
"""生成与 UCD 分辨率匹配的临时副本(缓存目录内)。"""
base_dir = os.path.dirname(image_path)
base_name = os.path.splitext(os.path.basename(image_path))[0]
out_name = f"{base_name}_{target_w}x{target_h}_ucd.png"
out_path = os.path.join(base_dir, out_name)
with Image.open(image_path) as img:
resized = img.convert("RGB").resize((target_w, target_h), Image.LANCZOS)
resized.save(out_path, format="PNG")
return out_path

View File

@@ -405,9 +405,9 @@ def _save_cct_params_for(self, test_type):
"""保存指定测试类型的 CCT 参数。""" """保存指定测试类型的 CCT 参数。"""
try: try:
default_params = self.config.get_default_cct_params(test_type) default_params = self.config.get_default_cct_params(test_type)
var_dict = _get_cct_var_dict(test_type) var_dict = _get_cct_var_dict(self, test_type)
cct_params = { cct_params = {
key: _parse_cct_float(var_dict[key], default_params[key]) key: _parse_cct_float(self, var_dict[key], default_params[key])
for key in default_params for key in default_params
} }
@@ -452,22 +452,22 @@ def _handle_cct_focus_out(self, var, default_value, save_func, label):
def on_sdr_cct_param_focus_out(self, var, default_value): def on_sdr_cct_param_focus_out(self, var, default_value):
"""SDR 色度参数失去焦点时的处理。""" """SDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(var, default_value, self.save_sdr_cct_params, "SDR") _handle_cct_focus_out(self, var, default_value, self.save_sdr_cct_params, "SDR")
def save_sdr_cct_params(self): def save_sdr_cct_params(self):
"""保存 SDR 色度参数。""" """保存 SDR 色度参数。"""
_save_cct_params_for("sdr_movie") _save_cct_params_for(self, "sdr_movie")
def on_hdr_cct_param_focus_out(self, var, default_value): def on_hdr_cct_param_focus_out(self, var, default_value):
"""HDR 色度参数失去焦点时的处理。""" """HDR 色度参数失去焦点时的处理。"""
_handle_cct_focus_out(var, default_value, self.save_hdr_cct_params, "HDR") _handle_cct_focus_out(self, var, default_value, self.save_hdr_cct_params, "HDR")
def save_hdr_cct_params(self): def save_hdr_cct_params(self):
"""保存 HDR 色度参数。""" """保存 HDR 色度参数。"""
_save_cct_params_for("hdr_movie") _save_cct_params_for(self, "hdr_movie")
def recalculate_cct(self): def recalculate_cct(self):
@@ -682,12 +682,12 @@ def recalculate_gamut(self):
def on_cct_param_focus_out(self, var, default_value): def on_cct_param_focus_out(self, var, default_value):
"""色度参数失去焦点时的处理 - 空值恢复默认""" """色度参数失去焦点时的处理 - 空值恢复默认"""
_handle_cct_focus_out(var, default_value, self.save_cct_params, "屏模组") _handle_cct_focus_out(self, var, default_value, self.save_cct_params, "屏模组")
def save_cct_params(self): def save_cct_params(self):
"""保存色度参数 - 简化版""" """保存色度参数 - 简化版"""
_save_cct_params_for(self.config.current_test_type) _save_cct_params_for(self, self.config.current_test_type)
def reload_cct_params(self): def reload_cct_params(self):

View File

@@ -232,11 +232,11 @@ def start_custom_row_single_step(self):
children = list(self.custom_result_tree.get_children()) children = list(self.custom_result_tree.get_children())
row_no = children.index(item_id) + 1 if item_id in children else 1 row_no = children.index(item_id) + 1 if item_id in children else 1
_clear_custom_result_row(item_id, row_no) _clear_custom_result_row(self, item_id, row_no)
threading.Thread( threading.Thread(
target=_run_custom_row_single_step, target=_run_custom_row_single_step,
args=(item_id, row_no), args=(self, item_id, row_no),
daemon=True, daemon=True,
).start() ).start()
@@ -332,7 +332,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
} }
self._dispatch_ui( self._dispatch_ui(
_update_custom_result_row, item_id, row_no, row_data _update_custom_result_row, self, item_id, row_no, row_data
) )
self.log_gui.log(f"{row_no} 行单步测试完成并已覆盖", level="success") self.log_gui.log(f"{row_no} 行单步测试完成并已覆盖", level="success")

View File

@@ -11,8 +11,7 @@ import matplotlib.pyplot as plt
from app_version import APP_NAME, APP_VERSION, get_app_title from app_version import APP_NAME, APP_VERSION, get_app_title
from drivers.UCD323_Function import UCDController from drivers.UCD323_Function import UCDController
from app.pq.pq_config import PQConfig from app.pq.pq_config import PQConfig
from app.pq.pq_result import PQResult, PQResultStore from app.pq.pq_result import PQResultStore
from app.views.pq_debug_panel import PQDebugPanel
from app.export import ( from app.export import (
save_result_images as _save_result_images_impl, save_result_images as _save_result_images_impl,
export_excel_report as _export_excel_report_impl, export_excel_report as _export_excel_report_impl,
@@ -459,7 +458,6 @@ class PQAutomationApp:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
tab_names = ["屏模组测试", "SDR测试", "HDR"] tab_names = ["屏模组测试", "SDR测试", "HDR"]
self.log_gui.log(f"已切换到 {tab_names[target_tab]} 信号格式", level="success")
except Exception as e: except Exception as e:
if hasattr(self, "log_gui"): if hasattr(self, "log_gui"):
self.log_gui.log(f"切换信号格式失败: {str(e)}", level="error") self.log_gui.log(f"切换信号格式失败: {str(e)}", level="error")
@@ -619,7 +617,6 @@ class PQAutomationApp:
self.testing = False self.testing = False
self.log_gui.log("=" * 50, level="separator") self.log_gui.log("=" * 50, level="separator")
self.log_gui.log("正在停止测试...", level="info") self.log_gui.log("正在停止测试...", level="info")
self.log_gui.log("=" * 50, level="separator")
self.stop_btn.config(state=tk.DISABLED) self.stop_btn.config(state=tk.DISABLED)
self.status_var.set("正在停止测试,请稍候...") self.status_var.set("正在停止测试,请稍候...")
self.root.update() self.root.update()
@@ -707,7 +704,6 @@ class PQAutomationApp:
self._disable_debug_panel() self._disable_debug_panel()
self.log_gui.log("=" * 50, level="separator") self.log_gui.log("=" * 50, level="separator")
self.log_gui.log("测试已停止,所有数据已清空", level="success") self.log_gui.log("测试已停止,所有数据已清空", level="success")
self.log_gui.log("=" * 50, level="separator")
messagebox.showinfo( messagebox.showinfo(
"测试已停止", "测试已停止",
"测试已停止,本次测试数据已清空。\n\n可以重新开始新的测试。", "测试已停止,本次测试数据已清空。\n\n可以重新开始新的测试。",
@@ -761,7 +757,6 @@ class PQAutomationApp:
# 3) 成功提示 # 3) 成功提示
log("=" * 50, level="separator") log("=" * 50, level="separator")
log(f"测试结果已保存到目录: {result_dir}", level="success") log(f"测试结果已保存到目录: {result_dir}", level="success")
log("=" * 50, level="separator")
messagebox.showinfo("成功", f"测试结果已保存到目录:\n{result_dir}") messagebox.showinfo("成功", f"测试结果已保存到目录:\n{result_dir}")
except Exception as e: except Exception as e:

View File

@@ -52,11 +52,17 @@
"timing": "DMT 1920x 1080 @ 60Hz", "timing": "DMT 1920x 1080 @ 60Hz",
"color_format": "RGB", "color_format": "RGB",
"bpc": 8, "bpc": 8,
"colorimetry": "sRGB" "colorimetry": "sRGB",
"cct_params": {
"x_ideal": 0.3127,
"x_tolerance": 0.003,
"y_ideal": 0.329,
"y_tolerance": 0.003
}
} }
}, },
"device_config": { "device_config": {
"ca_com": "COM1", "ca_com": "COM3",
"ucd_list": "0: UCD-323 [2128C209]", "ucd_list": "0: UCD-323 [2128C209]",
"ca_channel": "0" "ca_channel": "0"
}, },