From 4073a6e9993bf15848834b230a344533402c3400 Mon Sep 17 00:00:00 2001 From: "xinzhu.yin" Date: Wed, 22 Apr 2026 11:02:16 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96AI=E5=9B=BE=E5=83=8F=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=EF=BC=8C=E6=B7=BB=E5=8A=A0=E5=8F=91=E9=80=81=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E5=88=B0UCD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- app/export/excel_exporter.py | 6 +- app/services/ai_image.py | 30 +++ app/views/panels/ai_image_panel.py | 301 ++++++++++++++++++++-- app/views/panels/cct_panel.py | 16 +- app/views/panels/custom_template_panel.py | 6 +- pqAutomationApp.py | 7 +- settings/pq_config.json | 10 +- 8 files changed, 338 insertions(+), 41 deletions(-) diff --git a/.gitignore b/.gitignore index 406b4fc..c2ed736 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,5 @@ Thumbs.db Desktop.ini # Local configuration overrides -settings/*.local.json \ No newline at end of file +settings/*.local.json +settings/ \ No newline at end of file diff --git a/app/export/excel_exporter.py b/app/export/excel_exporter.py index 33fcb03..0426176 100644 --- a/app/export/excel_exporter.py +++ b/app/export/excel_exporter.py @@ -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: diff --git a/app/services/ai_image.py b/app/services/ai_image.py index 0dedddf..72198e7 100644 --- a/app/services/ai_image.py +++ b/app/services/ai_image.py @@ -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, diff --git a/app/views/panels/ai_image_panel.py b/app/views/panels/ai_image_panel.py index a197595..7e23166 100644 --- a/app/views/panels/ai_image_panel.py +++ b/app/views/panels/ai_image_panel.py @@ -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("<>", 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( + "", + 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 diff --git a/app/views/panels/cct_panel.py b/app/views/panels/cct_panel.py index aa125d6..d901a84 100644 --- a/app/views/panels/cct_panel.py +++ b/app/views/panels/cct_panel.py @@ -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): diff --git a/app/views/panels/custom_template_panel.py b/app/views/panels/custom_template_panel.py index d2b0f8e..ce32bc4 100644 --- a/app/views/panels/custom_template_panel.py +++ b/app/views/panels/custom_template_panel.py @@ -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") diff --git a/pqAutomationApp.py b/pqAutomationApp.py index f6aaeb0..0282194 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -11,8 +11,7 @@ import matplotlib.pyplot as plt from app_version import APP_NAME, APP_VERSION, get_app_title from drivers.UCD323_Function import UCDController from app.pq.pq_config import PQConfig -from app.pq.pq_result import PQResult, PQResultStore -from app.views.pq_debug_panel import PQDebugPanel +from app.pq.pq_result import PQResultStore from app.export import ( save_result_images as _save_result_images_impl, export_excel_report as _export_excel_report_impl, @@ -459,7 +458,6 @@ class PQAutomationApp: if hasattr(self, "log_gui"): tab_names = ["屏模组测试", "SDR测试", "HDR"] - self.log_gui.log(f"已切换到 {tab_names[target_tab]} 信号格式", level="success") except Exception as e: if hasattr(self, "log_gui"): self.log_gui.log(f"切换信号格式失败: {str(e)}", level="error") @@ -619,7 +617,6 @@ class PQAutomationApp: self.testing = False self.log_gui.log("=" * 50, level="separator") self.log_gui.log("正在停止测试...", level="info") - self.log_gui.log("=" * 50, level="separator") self.stop_btn.config(state=tk.DISABLED) self.status_var.set("正在停止测试,请稍候...") self.root.update() @@ -707,7 +704,6 @@ class PQAutomationApp: self._disable_debug_panel() self.log_gui.log("=" * 50, level="separator") self.log_gui.log("测试已停止,所有数据已清空", level="success") - self.log_gui.log("=" * 50, level="separator") messagebox.showinfo( "测试已停止", "测试已停止,本次测试数据已清空。\n\n可以重新开始新的测试。", @@ -761,7 +757,6 @@ class PQAutomationApp: # 3) 成功提示 log("=" * 50, level="separator") log(f"测试结果已保存到目录: {result_dir}", level="success") - log("=" * 50, level="separator") messagebox.showinfo("成功", f"测试结果已保存到目录:\n{result_dir}") except Exception as e: diff --git a/settings/pq_config.json b/settings/pq_config.json index ce2ce57..d7b733a 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -52,11 +52,17 @@ "timing": "DMT 1920x 1080 @ 60Hz", "color_format": "RGB", "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": { - "ca_com": "COM1", + "ca_com": "COM3", "ucd_list": "0: UCD-323 [2128C209]", "ca_channel": "0" },