优化AI图像显示,添加发送图片到UCD
This commit is contained in:
@@ -490,10 +490,12 @@ def export_excel_report(result_dir, current_test_type, selected_items,
|
||||
for col, width in cfg["column_widths"].items():
|
||||
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)
|
||||
|
||||
log("已保存: 测试数据.xlsx", level="success")
|
||||
log(f"已保存: {excel_filename}", level="success")
|
||||
log("=" * 60, level="seperator")
|
||||
|
||||
except ImportError:
|
||||
|
||||
@@ -122,10 +122,40 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
||||
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)
|
||||
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,
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import threading
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
from typing import List, Optional
|
||||
from tkinter import filedialog, messagebox, simpledialog
|
||||
|
||||
|
||||
import ttkbootstrap as ttk
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
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_records: List[_svc.AIImageRecord] = []
|
||||
self.ai_image_current: Optional[_svc.AIImageRecord] = None
|
||||
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
|
||||
self.ai_image_records = [] # list[AIImageRecord]
|
||||
self.ai_image_current = None # AIImageRecord | None
|
||||
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
|
||||
self._ai_image_requesting = False
|
||||
|
||||
container = ttk.Frame(frame, padding=10)
|
||||
container.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 左列:图片列表
|
||||
left = ttk.Frame(container)
|
||||
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
|
||||
# 使用 grid + 权重,让右侧预览区优先占据剩余空间。
|
||||
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(
|
||||
anchor=tk.W, pady=(0, 4)
|
||||
)
|
||||
|
||||
list_wrap = ttk.Frame(left)
|
||||
list_wrap.pack(fill=tk.Y, expand=False)
|
||||
list_wrap = ttk.Frame(left, padding=2)
|
||||
list_wrap.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
scroll = ttk.Scrollbar(list_wrap, orient=tk.VERTICAL)
|
||||
self.ai_image_listbox = tk.Listbox(
|
||||
list_wrap,
|
||||
width=28,
|
||||
height=22,
|
||||
activestyle="dotbox",
|
||||
width=34,
|
||||
height=1, # 由 pack fill/expand 撑满,height 仅为最小保底
|
||||
activestyle="none",
|
||||
font=("微软雅黑", 9),
|
||||
bd=1,
|
||||
relief=tk.FLAT,
|
||||
highlightthickness=1,
|
||||
highlightbackground="#d8d8d8",
|
||||
highlightcolor="#4a90e2",
|
||||
selectbackground="#2b6cb0",
|
||||
selectforeground="#ffffff",
|
||||
yscrollcommand=scroll.set,
|
||||
)
|
||||
scroll.config(command=self.ai_image_listbox.yview)
|
||||
self.ai_image_listbox.pack(side=tk.LEFT, fill=tk.Y)
|
||||
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
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.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(
|
||||
btn_row, text="保存", bootstyle="success-outline", width=8,
|
||||
command=lambda: _save_current(self),
|
||||
@@ -71,7 +116,7 @@ def create_ai_image_panel(self):
|
||||
|
||||
# 右列:预览 + 输入
|
||||
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.pack(fill=tk.BOTH, expand=True)
|
||||
@@ -147,11 +192,25 @@ def reload_ai_image_list(self):
|
||||
|
||||
|
||||
def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
||||
when = (rec.created_at or "")[:19].replace("T", " ")
|
||||
preview = (rec.prompt or "(无提示)").strip().splitlines()[0]
|
||||
if len(preview) > 18:
|
||||
preview = preview[:18] + "…"
|
||||
return f"{when} {preview}" if when else preview
|
||||
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
|
||||
size_tag = ""
|
||||
extra = rec.extra or {}
|
||||
if isinstance(extra, dict) and extra.get("size"):
|
||||
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):
|
||||
@@ -233,7 +292,7 @@ def _set_requesting(self, flag: bool):
|
||||
pass
|
||||
|
||||
|
||||
def _on_request_done(self, record: Optional[_svc.AIImageRecord], exc: Optional[Exception]):
|
||||
def _on_request_done(self, record, exc):
|
||||
_set_requesting(self, False)
|
||||
if exc is not None:
|
||||
self.ai_image_status_var.set(f"失败: {exc}")
|
||||
@@ -283,3 +342,207 @@ def _delete_current(self):
|
||||
return
|
||||
_svc.delete_record(rec)
|
||||
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
|
||||
|
||||
@@ -405,9 +405,9 @@ def _save_cct_params_for(self, test_type):
|
||||
"""保存指定测试类型的 CCT 参数。"""
|
||||
try:
|
||||
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 = {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
"""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):
|
||||
"""保存 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):
|
||||
"""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):
|
||||
"""保存 HDR 色度参数。"""
|
||||
_save_cct_params_for("hdr_movie")
|
||||
_save_cct_params_for(self, "hdr_movie")
|
||||
|
||||
|
||||
def recalculate_cct(self):
|
||||
@@ -682,12 +682,12 @@ def recalculate_gamut(self):
|
||||
|
||||
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):
|
||||
"""保存色度参数 - 简化版"""
|
||||
_save_cct_params_for(self.config.current_test_type)
|
||||
_save_cct_params_for(self, self.config.current_test_type)
|
||||
|
||||
|
||||
def reload_cct_params(self):
|
||||
|
||||
@@ -232,11 +232,11 @@ def start_custom_row_single_step(self):
|
||||
children = list(self.custom_result_tree.get_children())
|
||||
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(
|
||||
target=_run_custom_row_single_step,
|
||||
args=(item_id, row_no),
|
||||
args=(self, item_id, row_no),
|
||||
daemon=True,
|
||||
).start()
|
||||
|
||||
@@ -332,7 +332,7 @@ def _run_custom_row_single_step(self, item_id, row_no):
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user