diff --git a/app/pq/pq_config.py b/app/pq/pq_config.py index fde143d..e4eaa7b 100644 --- a/app/pq/pq_config.py +++ b/app/pq/pq_config.py @@ -74,12 +74,19 @@ _DEFAULT_CCT_PARAMS = { "y_ideal": 0.3290, "y_tolerance": 0.003, }, + "local_dimming": { + "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", + "local_dimming": "DCI-P3", } _DEFAULT_TEST_TYPES = { @@ -113,6 +120,16 @@ _DEFAULT_TEST_TYPES = { "colorimetry": "sRGB", "patterns": {"gamut": "rgb", "eotf": "gray", "cct": "gray", "contrast": "rgb", "accuracy": "accuracy"}, }, + "local_dimming": { + "name": "Local Dimming", + "test_items": [], + "timing": "DMT 1920x 1080 @ 60Hz", + "data_range": "Full", + "color_format": "RGB", + "bpc": 8, + "colorimetry": "sRGB", + "patterns": {}, + }, } _PATTERN_RGB = { diff --git a/app/runner/test_runner.py b/app/runner/test_runner.py index 6693c7c..d5ae7ef 100644 --- a/app/runner/test_runner.py +++ b/app/runner/test_runner.py @@ -57,6 +57,11 @@ def run_test(self: "PQAutomationApp", test_type, test_items): self.run_sdr_movie_test(test_items) elif test_type == "hdr_movie": self.run_hdr_movie_test(test_items) + elif test_type == "local_dimming": + self.log_gui.log( + "Local Dimming 为手动模式,请在 Local Dimming 面板发送图案并采集亮度", + level="info", + ) # 测试完成后更新UI状态 if self.testing: # 如果没有被中途停止 @@ -1073,226 +1078,6 @@ def test_color_accuracy(self: "PQAutomationApp", test_type): self.log_gui.log("色准测试完成", level="success") -def run_simulation_test(self: "PQAutomationApp"): - """运行模拟测试(无需 UCD/CA),直接在 UI 展示结果。""" - try: - test_type = self.config.current_test_type - selected_items = self.get_selected_test_items() - - if not selected_items: - self.log_gui.log("未选择测试项目,无法执行模拟测试", level="error") - messagebox.showwarning("提示", "请先勾选至少一个测试项目") - return - - self.log_gui.log("=" * 60, level="separator") - self.log_gui.log("开始执行模拟测试(无需 UCD/CA 设备)", level="info") - self.log_gui.log(f"测试类型: {self.get_test_type_name(test_type)}", level="info") - self.log_gui.log( - f"测试项目: {', '.join(self.config.get_test_item_chinese_names(selected_items))}", - level="info", - ) - - if hasattr(self, "update_chart_tabs_state"): - self.update_chart_tabs_state() - if hasattr(self, "clear_chart"): - self.clear_chart() - - self.new_pq_results(test_type, f"{self.get_test_type_name(test_type)} 模拟测试") - self.status_var.set("模拟测试进行中...") - - rng = np.random.default_rng() - - def _read_ideal_xy(): - try: - if test_type == "sdr_movie": - return float(self.sdr_cct_x_ideal_var.get()), float(self.sdr_cct_y_ideal_var.get()) - if test_type == "hdr_movie": - return float(self.hdr_cct_x_ideal_var.get()), float(self.hdr_cct_y_ideal_var.get()) - return float(self.cct_x_ideal_var.get()), float(self.cct_y_ideal_var.get()) - except Exception: - return 0.3127, 0.3290 - - def _xyY_to_xyz_row(x, y, lv): - if y <= 1e-8: - return [x, y, lv, 0.0, lv, 0.0] - X = x * lv / y - Z = (1 - x - y) * lv / y - return [x, y, lv, X, lv, Z] - - # 共享灰阶数据:用于 Gamma/EOTF/CCT/对比度 - gray_results = [] - x_ideal, y_ideal = _read_ideal_xy() - peak_lv = 900.0 if test_type == "hdr_movie" else 220.0 - gamma_shape = 2.25 if test_type == "hdr_movie" else 2.20 - - for i in range(11): - p = i / 10.0 - lv = 0.08 + peak_lv * (p ** gamma_shape) - lv *= 1.0 + float(rng.normal(0.0, 0.015)) - lv = max(lv, 0.03) - - x = x_ideal + float(rng.normal(0.0, 0.0012)) - y = y_ideal + float(rng.normal(0.0, 0.0012)) - gray_results.append(_xyY_to_xyz_row(x, y, lv)) - - if any(item in selected_items for item in ("gamma", "eotf", "cct", "contrast")): - self.results.add_intermediate_data("shared", "gray", gray_results) - - # 色域模拟 - if "gamut" in selected_items: - ref_map = { - "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)], - "BT.601": [(0.6300, 0.3400), (0.3100, 0.5950), (0.1550, 0.0700)], - } - - if test_type == "hdr_movie": - reference = self.hdr_gamut_ref_var.get() if hasattr(self, "hdr_gamut_ref_var") else "BT.2020" - elif test_type == "sdr_movie": - reference = self.sdr_gamut_ref_var.get() if hasattr(self, "sdr_gamut_ref_var") else "BT.709" - else: - reference = self.screen_gamut_ref_var.get() if hasattr(self, "screen_gamut_ref_var") else "DCI-P3" - - if reference not in ref_map: - reference = "DCI-P3" - - gamut_results = [] - for rx, ry in ref_map[reference]: - mx = rx + float(rng.normal(0.0, 0.006)) - my = ry + float(rng.normal(0.0, 0.006)) - gamut_results.append(_xyY_to_xyz_row(mx, my, 120.0)) - - self.results.add_intermediate_data("gamut", "rgb", gamut_results) - self.results.set_test_item_result( - "gamut", - { - "area": 0.0, - "coverage": 95.0, - "uv_coverage": 93.0, - "reference": reference, - }, - ) - self.plot_gamut(gamut_results, 95.0, test_type) - - # Gamma / EOTF 模拟 - if "gamma" in selected_items and test_type != "hdr_movie": - pattern_params = self.config.default_pattern_gray.get("pattern_params", None) - results_with_gamma, L_bar = self.calculate_gamma( - gray_results, len(gray_results) - 1, pattern_params - ) - self.results.set_test_item_result("gamma", {"gamma": results_with_gamma, "L_bar": L_bar}) - try: - target_gamma = float(self.sdr_gamma_type_var.get()) if test_type == "sdr_movie" else 2.2 - except Exception: - target_gamma = 2.2 - self.plot_gamma(L_bar, results_with_gamma, target_gamma, test_type) - - if "eotf" in selected_items and test_type == "hdr_movie": - pattern_params = self.config.default_pattern_gray.get("pattern_params", None) - results_with_eotf, L_bar = self.calculate_gamma( - gray_results, len(gray_results) - 1, pattern_params - ) - self.results.set_test_item_result("eotf", {"eotf": results_with_eotf, "L_bar": L_bar}) - self.plot_eotf(L_bar, results_with_eotf, test_type) - - # CCT 模拟 - if "cct" in selected_items: - cct_values = pq_algorithm.calculate_cct_from_results(gray_results) - self.results.set_test_item_result("cct", {"cct_values": cct_values}) - self.plot_cct(test_type) - - # 对比度模拟 - if "contrast" in selected_items: - luminance_values = [row[2] for row in gray_results] - max_luminance = max(luminance_values) - min_luminance = max(min(luminance_values), 0.001) - contrast_ratio = max_luminance / min_luminance - contrast_data = { - "max_luminance": max_luminance, - "min_luminance": min_luminance, - "contrast_ratio": contrast_ratio, - "luminance_values": luminance_values, - } - self.results.set_test_item_result("contrast", contrast_data) - self.plot_contrast(contrast_data, test_type) - - # 色准模拟 - if "accuracy" in selected_items: - color_names = self.config.get_accuracy_color_names() - standards = self.get_accuracy_color_standards(test_type) - - color_patches = [] - measured_data = [] - delta_e_values = [] - - for idx, name in enumerate(color_names): - sx, sy = standards.get(name, (0.3127, 0.3290)) - - # 前 20 个色块偏差更小,后 9 个稍大,方便 UI 看出差异 - noise_sigma = 0.0008 if idx < 20 else 0.0018 - mx = sx + float(rng.normal(0.0, noise_sigma)) - my = sy + float(rng.normal(0.0, noise_sigma)) - lv = max(5.0, 40.0 + idx * 2.3 + float(rng.normal(0.0, 4.0))) - - row = _xyY_to_xyz_row(mx, my, lv) - measured_data.append(row) - color_patches.append(name) - - delta_e = self.calculate_delta_e_2000(mx, my, lv, sx, sy) - delta_e_values.append(delta_e) - - avg_delta_e = float(np.mean(delta_e_values)) if delta_e_values else 0.0 - max_delta_e = float(np.max(delta_e_values)) if delta_e_values else 0.0 - min_delta_e = float(np.min(delta_e_values)) if delta_e_values else 0.0 - - excellent_count = sum(1 for d in delta_e_values if d < 3) - good_count = sum(1 for d in delta_e_values if 3 <= d < 5) - poor_count = sum(1 for d in delta_e_values if d >= 5) - - delta_e_gray = delta_e_values[0:5] - delta_e_colorchecker = delta_e_values[5:23] - delta_e_saturated = delta_e_values[23:29] - - try: - target_gamma = float(self.sdr_gamma_type_var.get()) if test_type == "sdr_movie" else 2.2 - except Exception: - target_gamma = 2.2 - - accuracy_data = { - "color_patches": color_patches, - "delta_e_values": delta_e_values, - "color_measurements": measured_data, - "avg_delta_e": avg_delta_e, - "max_delta_e": max_delta_e, - "min_delta_e": min_delta_e, - "excellent_count": excellent_count, - "good_count": good_count, - "poor_count": poor_count, - "avg_delta_e_gray": float(np.mean(delta_e_gray)) if delta_e_gray else 0.0, - "avg_delta_e_colorchecker": float(np.mean(delta_e_colorchecker)) if delta_e_colorchecker else 0.0, - "avg_delta_e_saturated": float(np.mean(delta_e_saturated)) if delta_e_saturated else 0.0, - "target_gamma": target_gamma, - } - - self.results.add_intermediate_data("accuracy", "measured", measured_data) - self.results.set_test_item_result("accuracy", accuracy_data) - self.plot_accuracy(accuracy_data, test_type) - - self.save_btn.config(state=tk.NORMAL) - self.status_var.set("模拟测试完成") - self.log_gui.log("模拟测试完成,结果已显示到 UI", level="success") - self.log_gui.log("=" * 60, level="separator") - messagebox.showinfo("完成", "模拟测试已完成(无需 UCD/CA)") - - except Exception as e: - self.status_var.set("模拟测试失败") - self.log_gui.log(f"模拟测试失败: {str(e)}", level="error") - import traceback - self.log_gui.log(traceback.format_exc(), level="error") - messagebox.showerror("错误", f"模拟测试失败: {str(e)}") - - def on_test_completed(self: "PQAutomationApp"): """测试完成后的UI更新""" self.testing = False @@ -1530,7 +1315,6 @@ class TestRunnerMixin: test_cct = test_cct test_contrast = test_contrast test_color_accuracy = test_color_accuracy - run_simulation_test = run_simulation_test on_test_completed = on_test_completed on_custom_template_test_completed = on_custom_template_test_completed get_current_test_result = get_current_test_result diff --git a/app/services/ai_image.py b/app/services/ai_image.py index 01b04a8..a47da28 100644 --- a/app/services/ai_image.py +++ b/app/services/ai_image.py @@ -1,9 +1,14 @@ """AI 图片生成服务:后端请求 + 本地缓存管理。 -后端接口(测试环境): - POST {API_BASE_URL}{API_PATH} - body: {"user_message": str, "session_id": str} - resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}} +后端接口(生产/测试环境): + POST {API_BASE_URL}{API_GENERATE_PATH} + body: {"user_message": str, "session_id": str, "upload_image_url"?: str} + resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}} + POST {API_BASE_URL}{API_UPLOAD_PATH} # multipart/form-data, field: file + resp: {"code": 200, "message": "", "data": {"upload_image_url": "..."}} + +带 ``upload_image_url`` 启用"图生图"模式;多轮对话需将上一轮返回的 imageUrl +作为下一轮请求的 upload_image_url(由 panel 通过会话级缓存自动维护)。 缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。 """ @@ -41,10 +46,20 @@ _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 = "http://10.201.44.70:9008/ai-agent/" API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/" -API_PATH = "api/v1/pqtest/generate" -API_TIMEOUT = 300.0 # 后端最长 60s,留余量 +API_GENERATE_PATH = "api/v1/pqtest/generate" +API_UPLOAD_PATH = "api/v1/pqtest/upload" +API_TIMEOUT = 300.0 # 后端最长 120s,留余量 +API_UPLOAD_TIMEOUT = 60.0 + +# 上传接口限制(来自接口文档) +UPLOAD_MAX_BYTES = 10 * 1024 * 1024 +UPLOAD_MAX_PIXELS = 4096 +UPLOAD_ALLOWED_EXT = (".png", ".jpg", ".jpeg") + +# 兼容旧名(如其他模块仍引用) +API_PATH = API_GENERATE_PATH # 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置 _session_id: str = str(uuid.uuid4()) @@ -133,9 +148,9 @@ class AIImageRecord: # ---------- 后端 API ---------- -def _api_endpoint() -> str: +def _api_endpoint(path: str = API_GENERATE_PATH) -> str: base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/" - return base + API_PATH.lstrip("/") + return base + path.lstrip("/") def _pretty_json_text(value) -> str: @@ -150,22 +165,33 @@ def _pretty_json_text(value) -> str: 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") +def _call_pqtest_generate( + user_message: str, + session_id: str, + upload_image_url: Optional[str] = None, + timeout: float = API_TIMEOUT, +) -> str: + """调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。 + + ``upload_image_url`` 传入时启用"图生图"模式。 + """ + body: dict = {"user_message": user_message, "session_id": session_id} + if upload_image_url: + body["upload_image_url"] = upload_image_url + payload = json.dumps(body, 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() + endpoint = _api_endpoint(API_GENERATE_PATH) logger.info( - "[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r", - _mask_sid(session_id), len(user_message or ""), _truncate(user_message), + "[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s", + _mask_sid(session_id), + "img2img" if upload_image_url else "txt2img", + len(user_message or ""), + _truncate(user_message), + upload_image_url or "-", ) logger.info( "[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s", @@ -250,6 +276,137 @@ def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = A return image_url +def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, auto_resize: bool = True) -> str: + """以 multipart/form-data 上传本地图片,返回 ``upload_image_url``。失败抛异常。 + + 参数: + auto_resize: True 时,若图片超过 4096×4096 或 10MB 则自动缩放/重压 + """ + if not file_path or not os.path.isfile(file_path): + raise FileNotFoundError(f"图片文件不存在: {file_path}") + ext = os.path.splitext(file_path)[1].lower() + if ext not in UPLOAD_ALLOWED_EXT: + raise ValueError(f"不支持的图片格式 ({ext}),仅支持 PNG/JPG/JPEG") + + try: + with Image.open(file_path) as img: + iw, ih = img.size + except Exception as exc: + raise ValueError(f"无法读取图片: {exc}") from exc + + # 检查大小,如需则缩放 + size = os.path.getsize(file_path) + needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES) + + if needs_resize: + if not auto_resize: + if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS: + raise ValueError(f"分辨率超过 4096×4096(当前 {iw}×{ih})") + else: + raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB)") + + # 自动缩放:等比例缩放至 4096×4096 以内 + logger.info("[AIImage][UPLOAD] 自动缩放 %dx%d (%.1fMB) 至 ≤4096×4096", + iw, ih, size/1024/1024) + scale = min(UPLOAD_MAX_PIXELS / iw, UPLOAD_MAX_PIXELS / ih, 1.0) + new_w, new_h = max(1, int(iw * scale)), max(1, int(ih * scale)) + + with Image.open(file_path) as img: + img_resized = img.resize((new_w, new_h), Image.LANCZOS) + # 重压至 10MB 以下 + # 首先尝试原格式 + tmp_io = BytesIO() + fmt = "PNG" if ext == ".png" else "JPEG" + save_kw = {"format": fmt} + img_resized.save(tmp_io, **save_kw) + tmp_bytes = tmp_io.getvalue() + + if len(tmp_bytes) <= UPLOAD_MAX_BYTES: + file_bytes = tmp_bytes + else: + # 原格式太大,转换为 JPEG 并压缩 + logger.info("[AIImage][UPLOAD] 原格式超过限制,转为 JPEG 并压缩") + quality = 95 + while quality >= 50: + tmp_io = BytesIO() + img_resized.save(tmp_io, format="JPEG", quality=quality, optimize=True) + tmp_bytes = tmp_io.getvalue() + if len(tmp_bytes) <= UPLOAD_MAX_BYTES: + file_bytes = tmp_bytes + break + quality -= 5 + else: + # 即使 quality=50 还是太大,也用这个版本上传(后端会处理) + file_bytes = tmp_bytes + + logger.info("[AIImage][UPLOAD] 缩放完成 %dx%d (%.1fMB)", + new_w, new_h, len(file_bytes)/1024/1024) + iw, ih = new_w, new_h + else: + with open(file_path, "rb") as f: + file_bytes = f.read() + + mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream" + boundary = "----pqAuto" + uuid.uuid4().hex + filename = os.path.basename(file_path) + crlf = b"\r\n" + body = b"".join([ + b"--", boundary.encode("ascii"), crlf, + b'Content-Disposition: form-data; name="file"; filename="', + filename.encode("utf-8"), b'"', crlf, + b"Content-Type: ", mime.encode("ascii"), crlf, crlf, + file_bytes, crlf, + b"--", boundary.encode("ascii"), b"--", crlf, + ]) + + endpoint = _api_endpoint(API_UPLOAD_PATH) + headers = { + "Content-Type": f"multipart/form-data; boundary={boundary}", + "Accept": "application/json", + "User-Agent": "pqAutomationApp/1.0", + "Content-Length": str(len(body)), + } + logger.info( + "[AIImage][UPLOAD] file=%s size=%dB mime=%s wh=%dx%d -> %s", + filename, len(file_bytes), mime, iw, ih, endpoint, + ) + request = Request(endpoint, data=body, method="POST", headers=headers) + t0 = time.monotonic() + try: + with urlopen(request, timeout=timeout) as response: + raw = response.read() + http_status = response.status + raw_text = raw.decode("utf-8", errors="replace") + logger.info("[AIImage][UPLOAD_RESP]\nstatus=%s\nbody=%s", + http_status, _pretty_json_text(raw_text)) + except HTTPError as exc: + err_raw = b"" + try: + err_raw = exc.read() or b"" + except Exception: + pass + err_text = err_raw.decode("utf-8", errors="replace") if err_raw else "" + logger.error("[AIImage][UPLOAD_ERR] status=%s reason=%s body=%s", + getattr(exc, "code", "?"), str(exc), _pretty_json_text(err_text)) + raise + except Exception as exc: + logger.error("[AIImage][UPLOAD_ERR] %s: %s", type(exc).__name__, exc) + raise + elapsed = time.monotonic() - t0 + try: + result = json.loads(raw.decode("utf-8")) + except Exception as exc: + raise RuntimeError(f"上传接口返回非 JSON:{raw_text}") from exc + code = result.get("code") + message = result.get("message") or "" + data = result.get("data") or {} + url = (data.get("upload_image_url") or "").strip() + if code != 200 or not url: + raise RuntimeError(f"上传失败 code={code} msg={message or '未知错误'}") + logger.info("[AIImage][UPLOAD_OK] elapsed=%.2fs url=%s", elapsed, url) + return url + + # ---------- 缓存路径工具 ---------- @@ -504,6 +661,7 @@ def request_image_async( base_dir: Optional[str] = None, session_id: Optional[str] = None, cancel_event: Optional[threading.Event] = None, + upload_image_url: Optional[str] = None, ) -> threading.Thread: """在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。 @@ -511,24 +669,33 @@ def request_image_async( 切回主线程,请在回调内部自行用 ``root.after(0, ...)``。 ``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。 + ``upload_image_url`` 传入后启用"图生图"模式。 """ sid = session_id or get_session_id() cancel = cancel_event + ref_url = (upload_image_url or "").strip() or None def _worker(): try: 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) + image_url = _call_pqtest_generate(prompt, sid, upload_image_url=ref_url) if cancel is not None and cancel.is_set(): logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid)) return + extra = { + "source": "ai-api", + "session_id": sid, + "mode": "img2img" if ref_url else "txt2img", + } + if ref_url: + extra["upload_image_url"] = ref_url record = import_image_from_url( image_url=image_url, prompt=prompt, - extra={"source": "ai-api", "session_id": sid}, + extra=extra, base_dir=base_dir, ) if cancel is not None and cancel.is_set(): @@ -593,6 +760,38 @@ def import_image_from_url_async( return t +def upload_image_async( + file_path: str, + on_success: Callable[[str], None], + on_error: Callable[[Exception], None], + cancel_event: Optional[threading.Event] = None, + timeout: float = API_UPLOAD_TIMEOUT, + auto_resize: bool = True, +) -> threading.Thread: + """后台上传本地图片到后端,成功回调返回 ``upload_image_url``。 + + 参数: + auto_resize: True 时自动缩放超大图片 + """ + + def _worker(): + try: + if cancel_event is not None and cancel_event.is_set(): + return + url = _call_pqtest_upload(file_path, timeout=timeout, auto_resize=auto_resize) + if cancel_event is not None and cancel_event.is_set(): + return + on_success(url) + except Exception as exc: + if cancel_event is not None and cancel_event.is_set(): + return + on_error(exc) + + t = threading.Thread(target=_worker, daemon=True) + t.start() + return t + + def is_remote_image_url(value: str) -> bool: """判断输入是否为 http/https 图片地址。""" url = (value or "").strip() diff --git a/app/tests/local_dimming.py b/app/tests/local_dimming.py index 73436af..3ba97d2 100644 --- a/app/tests/local_dimming.py +++ b/app/tests/local_dimming.py @@ -181,6 +181,134 @@ def _send_ld_image(self: "PQAutomationApp", image_path): self.signal_service.send_image(image_path) +def _apply_ld_ucd_params(self: "PQAutomationApp") -> bool: + """发送 Local Dimming 图案前,按当前测试类型写入 UCD 参数。""" + test_type = getattr(self.config, "current_test_type", "screen_module") + cfg = self.config.current_test_types.get(test_type, {}) + + try: + self.signal_service.apply_config(self.config) + + if test_type == "screen_module": + ok = self.signal_service.update_signal_format( + color_space=( + self.screen_module_color_space_var.get() + if hasattr(self, "screen_module_color_space_var") + else cfg.get("colorimetry", "sRGB") + ), + data_range=( + self.screen_module_data_range_var.get() + if hasattr(self, "screen_module_data_range_var") + else cfg.get("data_range", "Full") + ), + bit_depth=( + self.screen_module_bit_depth_var.get() + if hasattr(self, "screen_module_bit_depth_var") + else f"{int(cfg.get('bpc', 8))}bit" + ), + output_format=( + self.screen_module_output_format_var.get() + if hasattr(self, "screen_module_output_format_var") + else cfg.get("color_format", "RGB") + ), + ) + elif test_type == "sdr_movie": + ok = self.signal_service.update_signal_format( + color_space=( + self.sdr_color_space_var.get() + if hasattr(self, "sdr_color_space_var") + else cfg.get("colorimetry", "sRGB") + ), + data_range=( + self.sdr_data_range_var.get() + if hasattr(self, "sdr_data_range_var") + else cfg.get("data_range", "Full") + ), + bit_depth=( + self.sdr_bit_depth_var.get() + if hasattr(self, "sdr_bit_depth_var") + else f"{int(cfg.get('bpc', 8))}bit" + ), + output_format=( + self.sdr_output_format_var.get() + if hasattr(self, "sdr_output_format_var") + else cfg.get("color_format", "RGB") + ), + ) + elif test_type == "hdr_movie": + ok = self.signal_service.update_signal_format( + color_space=( + self.hdr_color_space_var.get() + if hasattr(self, "hdr_color_space_var") + else cfg.get("colorimetry", "sRGB") + ), + data_range=( + self.hdr_data_range_var.get() + if hasattr(self, "hdr_data_range_var") + else cfg.get("data_range", "Full") + ), + bit_depth=( + self.hdr_bit_depth_var.get() + if hasattr(self, "hdr_bit_depth_var") + else f"{int(cfg.get('bpc', 8))}bit" + ), + output_format=( + self.hdr_output_format_var.get() + if hasattr(self, "hdr_output_format_var") + else cfg.get("color_format", "RGB") + ), + max_cll=( + self.hdr_maxcll_var.get() + if hasattr(self, "hdr_maxcll_var") + else None + ), + max_fall=( + self.hdr_maxfall_var.get() + if hasattr(self, "hdr_maxfall_var") + else None + ), + ) + elif test_type == "local_dimming": + ok = self.signal_service.update_signal_format( + color_space=( + self.local_dimming_color_space_var.get() + if hasattr(self, "local_dimming_color_space_var") + else cfg.get("colorimetry", "sRGB") + ), + data_range=( + self.local_dimming_data_range_var.get() + if hasattr(self, "local_dimming_data_range_var") + else cfg.get("data_range", "Full") + ), + bit_depth=( + self.local_dimming_bit_depth_var.get() + if hasattr(self, "local_dimming_bit_depth_var") + else f"{int(cfg.get('bpc', 8))}bit" + ), + output_format=( + self.local_dimming_output_format_var.get() + if hasattr(self, "local_dimming_output_format_var") + else cfg.get("color_format", "RGB") + ), + ) + else: + self._dispatch_ui( + self.log_gui.log, + f"Local Dimming 不支持的测试类型: {test_type}", + "error", + ) + return False + + if not ok: + self._dispatch_ui(self.log_gui.log, "Local Dimming UCD 参数设置失败", "error") + return False + + return True + except Exception as e: + self._dispatch_ui(self.log_gui.log, f"Local Dimming UCD 参数设置异常: {e}", "error") + return False + + def _run_ld_measurement_step(self: "PQAutomationApp", width, height, wait_time, step, log): label = step["label"] test_item = step["test_item"] @@ -270,6 +398,8 @@ def send_ld_window(self: "PQAutomationApp", percentage): _set_current_ld_pattern(self, "峰值亮度", f"{percentage}%窗口", percentage) def send(): + if not _apply_ld_ucd_params(self): + return width, height = self.signal_service.current_resolution() try: image_path = _ensure_window_image(width, height, percentage) @@ -301,6 +431,8 @@ def send_ld_checkerboard(self: "PQAutomationApp", center_white): _set_current_ld_pattern(self, "棋盘格对比度", pattern_label) def send(): + if not _apply_ld_ucd_params(self): + return width, height = self.signal_service.current_resolution() try: image_path = _ensure_checkerboard_image( @@ -335,6 +467,8 @@ def send_ld_black_pattern(self: "PQAutomationApp"): _set_current_ld_pattern(self, "黑电平", "全黑画面") def send(): + if not _apply_ld_ucd_params(self): + return width, height = self.signal_service.current_resolution() try: image_path = _ensure_solid_image(width, height, (0, 0, 0), "black") @@ -370,6 +504,8 @@ def send_ld_instant_peak(self: "PQAutomationApp"): ) def send(): + if not _apply_ld_ucd_params(self): + return width, height = self.signal_service.current_resolution() try: black_image = _ensure_solid_image(width, height, (0, 0, 0), "black") @@ -397,6 +533,8 @@ def send_ld_instant_peak(self: "PQAutomationApp"): ) self._dispatch_ui(self.log_gui.log, msg) + threading.Thread(target=send, daemon=True).start() + def measure_ld_luminance(self: "PQAutomationApp"): """测量当前显示的亮度并追加一行到 Treeview。""" diff --git a/app/views/chart_frame.py b/app/views/chart_frame.py index 22cfeba..566d5f7 100644 --- a/app/views/chart_frame.py +++ b/app/views/chart_frame.py @@ -1046,9 +1046,11 @@ def create_result_chart_frame(self: "PQAutomationApp"): def on_chart_tab_changed(self: "PQAutomationApp", event): """Tab切换时的事件处理""" try: - self._last_tab_index = self.chart_notebook.index( - self.chart_notebook.select() - ) + selected_tab = self.chart_notebook.select() + # 在动态 add/forget tab 的过程中,可能短暂出现“无选中页签”。 + if not selected_tab: + return + self._last_tab_index = self.chart_notebook.index(selected_tab) except Exception as e: self.log_gui.log(f"Tab切换事件处理失败: {str(e)}", level="error") diff --git a/app/views/panel_manager.py b/app/views/panel_manager.py index b215c19..7820dfa 100644 --- a/app/views/panel_manager.py +++ b/app/views/panel_manager.py @@ -36,10 +36,22 @@ def show_panel(self: "PQAutomationApp", panel_name): # 显示指定面板 panel_info = self.panels[panel_name] - # 隐藏主内容区域 - self.control_frame_top.pack_forget() - self.control_frame_middle.pack_forget() - self.control_frame_bottom.pack_forget() + # 隐藏主内容区域。 + # Local Dimming 作为并列测试类型时,需要保留顶部配置区, + # 让用户在面板上方直接看到并修改配置项。 + if panel_name == "local_dimming": + # 重新按“自适应高度”布局顶部配置区,避免其占用可扩展空间把 + # Local Dimming 主面板整体向下挤出大块空白。 + self.control_frame_top.pack_forget() + self.control_frame_top.pack( + side=tk.TOP, fill=tk.X, expand=False, padx=0, pady=5 + ) + self.control_frame_middle.pack_forget() + self.control_frame_bottom.pack_forget() + else: + self.control_frame_top.pack_forget() + self.control_frame_middle.pack_forget() + self.control_frame_bottom.pack_forget() # 显示目标面板 panel_info["frame"].pack(side=tk.TOP, fill=tk.BOTH, expand=True, padx=5, pady=5) diff --git a/app/views/panels/ai_image_panel.py b/app/views/panels/ai_image_panel.py index 8977181..e013777 100644 --- a/app/views/panels/ai_image_panel.py +++ b/app/views/panels/ai_image_panel.py @@ -184,6 +184,13 @@ def create_ai_image_panel(self: "PQAutomationApp"): self._ai_image_tooltip = None self._ai_image_tooltip_label = None self._ai_image_tooltip_item = "" + # 会话级参考图 URL(图生图模式):session_id -> upload_image_url + self._ai_image_session_refs = {} + # 本轮发送前手动上传的参考图(覆盖会话级) + self._ai_image_pending_ref_url = "" + self._ai_image_pending_ref_name = "" + self._ai_image_uploading = False + self.ai_image_ref_var = tk.StringVar(value="未设置参考图(文生图模式)") container = ttk.Frame(frame, padding=10) container.pack(fill=tk.BOTH, expand=True) @@ -328,6 +335,29 @@ def create_ai_image_panel(self: "PQAutomationApp"): input_frame = ttk.LabelFrame(right, text="提示输入(Ctrl+Enter 发送)", padding=6) input_frame.pack(fill=tk.X, pady=(4, 0)) + # 参考图行(图生图) + ref_row = ttk.Frame(input_frame) + ref_row.pack(fill=tk.X, pady=(0, 4)) + ttk.Label( + ref_row, text="参考图:", + foreground=palette["muted"], font=("微软雅黑", 9), + ).pack(side=tk.LEFT) + self.ai_image_ref_label = ttk.Label( + ref_row, textvariable=self.ai_image_ref_var, + foreground=palette["fg"], font=("微软雅黑", 9), + ) + self.ai_image_ref_label.pack(side=tk.LEFT, padx=(4, 0), fill=tk.X, expand=True) + self.ai_image_clear_ref_btn = ttk.Button( + ref_row, text="清除", width=6, bootstyle="secondary-outline", + command=lambda: _clear_reference_image(self), + ) + self.ai_image_clear_ref_btn.pack(side=tk.RIGHT, padx=(4, 0)) + self.ai_image_upload_ref_btn = ttk.Button( + ref_row, text="上传参考图…", width=12, bootstyle="info-outline", + command=lambda: _upload_reference_image(self), + ) + self.ai_image_upload_ref_btn.pack(side=tk.RIGHT) + self.ai_image_input = tk.Text( input_frame, height=3, @@ -435,6 +465,20 @@ def reload_ai_image_list(self: "PQAutomationApp", auto_select_first=True): self.ai_image_records = records sessions = _svc.group_records_by_session(self.ai_image_records) + # \u4f1a\u8bdd\u7ea7\u53c2\u8003\u56fe\u94fe\u8def\uff1a\u4ee5\u6bcf\u4e2a\u4f1a\u8bdd\u6700\u8fd1\u4e00\u5f20\u751f\u6210\u56fe\u7684 imageUrl \u4f5c\u4e3a\u53c2\u8003 + refs_map = getattr(self, "_ai_image_session_refs", None) + if isinstance(refs_map, dict): + for sess in sessions: + sid = sess.get("session_id") or "" + if not sid or sid in refs_map: + continue + for r in sess.get("records") or []: + src = "" + if isinstance(r.extra, dict): + src = (r.extra.get("source_url") or "").strip() + if src: + refs_map[sid] = src + break flat = [] current_sid = _svc.get_session_id() for idx, sess in enumerate(sessions, start=1): @@ -493,6 +537,10 @@ def reload_ai_image_list(self: "PQAutomationApp", auto_select_first=True): self._ai_image_list_loaded = True finally: self._ai_image_reloading = False + try: + _refresh_ref_label(self) + except Exception: + pass def _format_session_header(index: int, sess: dict, is_current: bool) -> str: @@ -655,6 +703,10 @@ def _start_new_session(self: "PQAutomationApp"): return _svc.reset_session() self.ai_image_status_var.set("已开启新对话") + # 新会话清除参考图(未设置时默认为文生图模式) + self._ai_image_pending_ref_url = "" + self._ai_image_pending_ref_name = "" + _refresh_ref_label(self) # 新对话创建后不要自动选中历史图片,否则会立即把 session 切回旧会话。 reload_ai_image_list(self, auto_select_first=False) try: @@ -667,6 +719,103 @@ def _start_new_session(self: "PQAutomationApp"): self.ai_image_meta_var.set("新对话已开启,等待生成图片") +def _resolve_pending_ref_url(self: "PQAutomationApp") -> str: + """返回本次发送应使用的 upload_image_url。 + + 优先使用用户在当前会话中手动上传的参考图;其次使用上一轮 imageUrl 自动链路。 + """ + pending = (getattr(self, "_ai_image_pending_ref_url", "") or "").strip() + if pending: + return pending + sid = _svc.get_session_id() + refs = getattr(self, "_ai_image_session_refs", None) or {} + return (refs.get(sid) or "").strip() + + +def _refresh_ref_label(self: "PQAutomationApp"): + """根据当前 pending 上传 / 会话链路状态刷新参考图提示标签。""" + var = getattr(self, "ai_image_ref_var", None) + if var is None: + return + pending = (getattr(self, "_ai_image_pending_ref_url", "") or "").strip() + if pending: + name = getattr(self, "_ai_image_pending_ref_name", "") or "参考图" + var.set(f"[图生图] {name}") + return + sid = _svc.get_session_id() + refs = getattr(self, "_ai_image_session_refs", None) or {} + chained = (refs.get(sid) or "").strip() + if chained: + # 自动链路:显示来源是"上一轮" + var.set("[图生图] 沿用上一轮生成图为参考") + return + var.set("未设置参考图(文生图模式)") + + +def _upload_reference_image(self: "PQAutomationApp"): + """选择本地图片并上传到后端,成功后作为本次发送的参考图。""" + if getattr(self, "_ai_image_requesting", False): + messagebox.showinfo("提示", "请等待当前请求完成") + return + if getattr(self, "_ai_image_uploading", False): + return + path = filedialog.askopenfilename( + title="选择参考图(PNG/JPG/JPEG,超过限制将自动缩放)", + filetypes=[("图片", "*.png;*.jpg;*.jpeg"), ("所有文件", "*.*")], + ) + if not path: + return + + self._ai_image_uploading = True + try: + self.ai_image_upload_ref_btn.configure(state=tk.DISABLED, text="上传中…") + self.ai_image_clear_ref_btn.configure(state=tk.DISABLED) + except Exception: + pass + self.ai_image_status_var.set("正在上传参考图…") + + name = os.path.basename(path) + + def _ok(url: str): + self.root.after(0, lambda: _on_upload_done(self, name, url, None)) + + def _err(exc: Exception): + self.root.after(0, lambda: _on_upload_done(self, name, "", exc)) + + _svc.upload_image_async(path, on_success=_ok, on_error=_err) + + +def _on_upload_done(self: "PQAutomationApp", name: str, url: str, exc): + self._ai_image_uploading = False + try: + self.ai_image_upload_ref_btn.configure(state=tk.NORMAL, text="上传参考图…") + self.ai_image_clear_ref_btn.configure(state=tk.NORMAL) + except Exception: + pass + if exc is not None: + self.ai_image_status_var.set(f"上传失败: {exc}") + messagebox.showerror("上传失败", str(exc)) + return + self._ai_image_pending_ref_url = url + self._ai_image_pending_ref_name = name + _refresh_ref_label(self) + self.ai_image_status_var.set(f"参考图已上传:{name}") + + +def _clear_reference_image(self: "PQAutomationApp"): + """清除手动上传的参考图,同时清除当前会话的自动链路参考。""" + if getattr(self, "_ai_image_requesting", False): + return + self._ai_image_pending_ref_url = "" + self._ai_image_pending_ref_name = "" + sid = _svc.get_session_id() + refs = getattr(self, "_ai_image_session_refs", None) + if isinstance(refs, dict): + refs.pop(sid, None) + _refresh_ref_label(self) + self.ai_image_status_var.set("已清除参考图,切换为文生图模式") + + def _session_id_for_item(self: "PQAutomationApp", item_id: str) -> str: session_map = getattr(self, "_ai_image_session_node_map", None) or {} parent = item_id @@ -704,6 +853,10 @@ def _switch_to_session( if item_id: _set_tree_selection(self, item_id) self.ai_image_status_var.set("已切换到历史对话") + # 切换会话后刷新参考图标签(pending 仅当前会话有效,故清除) + self._ai_image_pending_ref_url = "" + self._ai_image_pending_ref_name = "" + _refresh_ref_label(self) if show_message: messagebox.showinfo("提示", "已切换到所选历史对话") @@ -720,6 +873,9 @@ def _update_request_progress(self: "PQAutomationApp"): def _send_prompt(self: "PQAutomationApp"): if getattr(self, "_ai_image_requesting", False): return + if getattr(self, "_ai_image_uploading", False): + messagebox.showinfo("提示", "参考图上传中,请稍后再发送") + return prompt = self.ai_image_input.get("1.0", tk.END).strip() if not prompt: messagebox.showinfo("提示", "请输入内容") @@ -749,12 +905,17 @@ def _send_prompt(self: "PQAutomationApp"): ) return + ref_url = _resolve_pending_ref_url(self) + if ref_url: + self.ai_image_status_var.set("后端处理中(图生图)…") + _svc.request_image_async( prompt, on_success=_success, on_error=_error, base_dir=_get_app_base_dir(self), cancel_event=self._ai_image_cancel_event, + upload_image_url=ref_url or None, ) @@ -764,6 +925,10 @@ def _set_requesting(self: "PQAutomationApp", flag: bool): 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) + if hasattr(self, "ai_image_upload_ref_btn"): + self.ai_image_upload_ref_btn.configure(state=tk.DISABLED if flag else tk.NORMAL) + if hasattr(self, "ai_image_clear_ref_btn"): + self.ai_image_clear_ref_btn.configure(state=tk.DISABLED if flag else tk.NORMAL) except Exception: pass if flag: @@ -797,9 +962,24 @@ def _on_request_done(self: "PQAutomationApp", record, exc, req_seq): return self.ai_image_status_var.set("完成") self.ai_image_input.delete("1.0", tk.END) + # 多轮对话链路:本轮返回的 imageUrl 作为下一轮的参考图 + next_ref = "" + try: + if record is not None and isinstance(record.extra, dict): + next_ref = (record.extra.get("source_url") or "").strip() + except Exception: + next_ref = "" + sid = (record.session_id if record is not None else "") or _svc.get_session_id() + if next_ref and sid: + self._ai_image_session_refs[sid] = next_ref + # 手动上传的 pending 参考图只对本次发送生效,发完清除 + self._ai_image_pending_ref_url = "" + self._ai_image_pending_ref_name = "" + _refresh_ref_label(self) reload_ai_image_list(self) if record is not None: - logger.info("[AIImagePanel] 生成完成并入列 id=%s sid=%s", record.id, (record.session_id or "")[:8]) + logger.info("[AIImagePanel] 生成完成并入列 id=%s sid=%s next_ref=%s", + record.id, (record.session_id or "")[:8], next_ref or "-") if record is not None and self.ai_image_records: item_id = _find_tree_item_by_record_id(self, record.id) if item_id: diff --git a/app/views/panels/main_layout.py b/app/views/panels/main_layout.py index 1219697..e1160bf 100644 --- a/app/views/panels/main_layout.py +++ b/app/views/panels/main_layout.py @@ -36,13 +36,7 @@ def _make_card(parent, icon: str, title: str) -> ttk.Frame: def create_floating_config_panel(self: "PQAutomationApp"): - """创建顶部"配置项"现代化折叠面板。 - - 布局变化(vs 旧版): - - 用 Unicode chevron + 整条 header 可点击折叠/展开; - - header 上额外显示折叠状态预览(``config_preview_var``); - - header 右侧承载常驻操作工具条(开始/停止/保存 等),不再放中部; - - 内部三个区段从 LabelFrame 改为统一的 Card 样式。 + """创建顶部"配置项"现代化折叠面板 """ cf = CollapsingFrame(self.control_frame_top) cf.pack(fill="both") @@ -54,8 +48,7 @@ def create_floating_config_panel(self: "PQAutomationApp"): # 折叠预览:呈现"测试类型 · 已选测试项" self.config_preview_var = tk.StringVar(value="") - # header 右侧工具条占位 —— create_operation_frame 之后向这里挂按钮 - self.toolbar_actions_frame: ttk.Frame | None = None + self.toolbar_actions_frame = None def _header_actions(parent: ttk.Frame): # 暴露给 create_operation_frame 使用 @@ -90,7 +83,7 @@ def create_floating_config_panel(self: "PQAutomationApp"): signal_card.pack(side=tk.LEFT, fill=tk.BOTH, expand=True, padx=(6, 0)) self.signal_format_frame = signal_card._body # type: ignore[attr-defined] - # 创建卡片内部内容(沿用旧函数,父级已是 body Frame) + # 创建卡片内部内容 self.create_connection_content() self.create_test_items_content() self.create_signal_format_content() @@ -111,6 +104,7 @@ def refresh_config_preview(self: "PQAutomationApp") -> None: "screen_module": "屏模组", "sdr_movie": "SDR Movie", "hdr_movie": "HDR Movie", + "local_dimming": "Local Dimming", } current_type = getattr(self.config, "current_test_type", "") type_label = type_labels.get(current_type, "") @@ -171,6 +165,10 @@ def create_test_items_content(self: "PQAutomationApp"): ("色准", "accuracy"), ], }, + "local_dimming": { + "frame": ttk.Frame(self.test_items_frame), + "items": [], + }, } # 根据当前测试类型创建复选框 @@ -473,10 +471,103 @@ def create_signal_format_content(self: "PQAutomationApp"): hdr_output_format_combo.bind("<>", self.on_hdr_output_format_changed) hdr_output_format_combo.grid(row=5, column=1, sticky=tk.W, padx=5, pady=2) + # ==================== Local Dimming 信号格式设置 ==================== + self.local_dimming_signal_frame = ttk.Frame(self.signal_tabs) + self.local_dimming_signal_frame.grid_columnconfigure(0, weight=0) + self.local_dimming_signal_frame.grid_columnconfigure(1, weight=1) + self.signal_tabs.add(self.local_dimming_signal_frame, text="Local Dimming") + + ld_cfg = self.config.current_test_types.get("local_dimming", {}) + + ttk.Label(self.local_dimming_signal_frame, text="分辨率:").grid( + row=0, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.local_dimming_timing_var = tk.StringVar( + value=ld_cfg.get("timing", "DMT 1920x 1080 @ 60Hz") + ) + ld_timing_combo = ttk.Combobox( + self.local_dimming_signal_frame, + textvariable=self.local_dimming_timing_var, + values=UCDEnum.TimingInfo.get_formatted_resolution_list(), + width=20, + state="readonly", + ) + ld_timing_combo.bind("<>", self.on_local_dimming_timing_changed) + ld_timing_combo.grid(row=0, column=1, sticky=tk.W, padx=5, pady=2) + + ttk.Label(self.local_dimming_signal_frame, text="色彩空间:").grid( + row=1, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.local_dimming_color_space_var = tk.StringVar( + value=ld_cfg.get("colorimetry", "sRGB") + ) + ld_color_space_combo = ttk.Combobox( + self.local_dimming_signal_frame, + textvariable=self.local_dimming_color_space_var, + values=["sRGB", "BT.709", "BT.601", "BT.2020", "DCI-P3"], + width=10, + state="readonly", + ) + ld_color_space_combo.bind("<>", self.on_local_dimming_signal_format_changed) + ld_color_space_combo.grid(row=1, column=1, sticky=tk.W, padx=5, pady=2) + + ttk.Label(self.local_dimming_signal_frame, text="数据范围:").grid( + row=2, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.local_dimming_data_range_var = tk.StringVar( + value=ld_cfg.get("data_range", UCDEnum.SignalFormat.DataRange.FULL) + ) + ld_data_range_combo = ttk.Combobox( + self.local_dimming_signal_frame, + textvariable=self.local_dimming_data_range_var, + values=UCDEnum.SignalFormat.DataRange.get_list(), + width=10, + state="readonly", + ) + ld_data_range_combo.bind("<>", self.on_local_dimming_signal_format_changed) + ld_data_range_combo.grid(row=2, column=1, sticky=tk.W, padx=5, pady=2) + + default_ld_bpc = int(ld_cfg.get("bpc", 8)) + default_ld_bit_depth = ( + f"{default_ld_bpc}bit" + if f"{default_ld_bpc}bit" in UCDEnum.SignalFormat.BitDepth.get_list() + else UCDEnum.SignalFormat.BitDepth.BIT_8 + ) + ttk.Label(self.local_dimming_signal_frame, text="编码位深:").grid( + row=3, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.local_dimming_bit_depth_var = tk.StringVar(value=default_ld_bit_depth) + ld_bit_depth_combo = ttk.Combobox( + self.local_dimming_signal_frame, + textvariable=self.local_dimming_bit_depth_var, + values=UCDEnum.SignalFormat.BitDepth.get_list(), + width=10, + state="readonly", + ) + ld_bit_depth_combo.bind("<>", self.on_local_dimming_signal_format_changed) + ld_bit_depth_combo.grid(row=3, column=1, sticky=tk.W, padx=5, pady=2) + + ttk.Label(self.local_dimming_signal_frame, text="色彩格式:").grid( + row=4, column=0, sticky=tk.W, padx=5, pady=2 + ) + self.local_dimming_output_format_var = tk.StringVar( + value=ld_cfg.get("color_format", UCDEnum.SignalFormat.OutputFormat.RGB) + ) + ld_output_format_combo = ttk.Combobox( + self.local_dimming_signal_frame, + textvariable=self.local_dimming_output_format_var, + values=UCDEnum.SignalFormat.OutputFormat.get_list(), + width=10, + state="readonly", + ) + ld_output_format_combo.bind("<>", self.on_local_dimming_signal_format_changed) + ld_output_format_combo.grid(row=4, column=1, sticky=tk.W, padx=5, pady=2) + # ==================== 初始化:默认只启用屏模组 Tab ==================== self.signal_tabs.select(0) # 选中屏模组 self.signal_tabs.tab(1, state="disabled") # 禁用 SDR self.signal_tabs.tab(2, state="disabled") # 禁用 HDR + self.signal_tabs.tab(3, state="disabled") # 禁用 Local Dimming def create_connection_content(self: "PQAutomationApp"): @@ -513,41 +604,50 @@ def create_connection_content(self: "PQAutomationApp"): # 添加按钮框架 button_frame = ttk.Frame(com_frame) - button_frame.grid(row=3, column=0, columnspan=3, pady=3, sticky="w") + button_frame.grid(row=3, column=0, columnspan=3, pady=3, sticky="ew") + button_frame.grid_columnconfigure(0, weight=1) + button_frame.grid_columnconfigure(1, weight=1) + button_frame.grid_columnconfigure(2, weight=1) - connect_icon = load_icon("assets/connect-svgrepo-com.png") + # connect_icon = load_icon("assets/connect-svgrepo-com.png") self.check_button = ttk.Button( button_frame, - image=connect_icon, - bootstyle="link", + # image=connect_icon, + # bootstyle="link", + text="连接", + bootstyle="success", takefocus=False, command=self.check_com_connections, ) - self.check_button.image = connect_icon - self.check_button.pack(side="left", padx=0, pady=3) + # self.check_button.image = connect_icon + self.check_button.grid(row=0, column=0, padx=(0, 4), pady=3, sticky="ew") - disconnect_icon = load_icon("assets/disconnect-svgrepo-com.png") + # disconnect_icon = load_icon("assets/disconnect-svgrepo-com.png") # 断开连接按钮 self.disconnect_button = ttk.Button( button_frame, - image=disconnect_icon, - bootstyle="link", + # image=disconnect_icon, + # bootstyle="link", + text="断开", + bootstyle="danger", takefocus=False, command=self.disconnect_com_connections, ) - self.disconnect_button.image = disconnect_icon # 防止图标被垃圾回收 - self.disconnect_button.pack(side="left", padx=0, pady=3) + # self.disconnect_button.image = disconnect_icon # 防止图标被垃圾回收 + self.disconnect_button.grid(row=0, column=1, padx=4, pady=3, sticky="ew") - refresh_icon = load_icon("assets/refresh-svgrepo-com.png") + # refresh_icon = load_icon("assets/refresh-svgrepo-com.png") self.refresh_button = ttk.Button( button_frame, - image=refresh_icon, - bootstyle="link", + # image=refresh_icon, + # bootstyle="link", + text="刷新", + bootstyle="info", takefocus=False, command=self.refresh_com_ports, ) - self.refresh_button.image = refresh_icon # 防止图标被垃圾回收 - self.refresh_button.pack(side="left", padx=0, pady=3) + # self.refresh_button.image = refresh_icon # 防止图标被垃圾回收 + self.refresh_button.grid(row=0, column=2, padx=(4, 0), pady=3, sticky="ew") # CA端口 ttk.Label(com_frame, text="CA端口:").grid( @@ -591,12 +691,6 @@ def create_connection_content(self: "PQAutomationApp"): def create_test_type_frame(self: "PQAutomationApp"): """创建测试类型选择区域(侧边栏形式)。 - - 新版(v3)改进: - - 深灰分层背景,接近 Calman 的侧栏密度; - - 纯文字按钮,不使用 emoji; - - 用更克制的字号 / 间距做层级区分; - - 不再使用 padding=10 硬覆盖(交给 Sidebar.TButton 样式统一管理)。 """ # 设置测试类型变量 self.test_type_var = tk.StringVar(value="screen_module") @@ -621,6 +715,7 @@ def create_test_type_frame(self: "PQAutomationApp"): ("屏模组性能测试", "screen_module"), ("SDR Movie", "sdr_movie"), ("HDR Movie", "hdr_movie"), + ("Local Dimming", "local_dimming"), ] for text, type_value in test_types: @@ -643,7 +738,6 @@ def create_test_type_frame(self: "PQAutomationApp"): panel_buttons = [ ("log_btn", "测试日志", self.toggle_log_panel), - ("local_dimming_btn", "Local Dimming", self.toggle_local_dimming_panel), ("ai_image_btn", "AI 图片", self.toggle_ai_image_panel), ("pantone_baseline_btn", "Pantone 摸底", self.toggle_pantone_baseline_panel), ("gamma_pattern_btn", "Gamma 图案", self.toggle_gamma_pattern_panel), @@ -688,8 +782,6 @@ def create_test_type_frame(self: "PQAutomationApp"): if hasattr(self, "panels"): if "log" in self.panels: self.panels["log"]["button"] = self.log_btn - if "local_dimming" in self.panels: - 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: @@ -785,16 +877,6 @@ def create_operation_frame(self: "PQAutomationApp"): ) self.start_btn.pack(side=tk.LEFT, **btn_pad) - self.simulate_btn = ttk.Button( - parent, - text="模拟测试", - command=self.run_simulation_test, - bootstyle="warning-outline", - padding=(12, 6), - takefocus=False, - ) - self.simulate_btn.pack(side=tk.LEFT, **btn_pad) - self.stop_btn = ttk.Button( parent, text="\u25a0 停止", @@ -995,6 +1077,65 @@ def on_hdr_output_format_changed(self: "PQAutomationApp", event=None): self.log_gui.log(f"HDR色彩格式更改失败: {str(e)}", level="error") +def on_local_dimming_timing_changed(self: "PQAutomationApp", event=None): + """Local Dimming 分辨率改变时的回调。""" + try: + selected_timing = self.local_dimming_timing_var.get() + self.log_gui.log(f"Local Dimming 分辨率已更改为: {selected_timing}", level="info") + + self.config.current_test_types.setdefault("local_dimming", {})["timing"] = selected_timing + + if self.testing: + self.log_gui.log("警告: 测试进行中,分辨率更改将在下次测试时生效", level="error") + + self.save_pq_config() + except Exception as e: + self.log_gui.log(f"Local Dimming 分辨率更改失败: {str(e)}", level="error") + + +def on_local_dimming_signal_format_changed(self: "PQAutomationApp", event=None): + """Local Dimming ColorInfo 相关选项变更回调。""" + try: + color_space = self.local_dimming_color_space_var.get() + data_range = self.local_dimming_data_range_var.get() + bit_depth = self.local_dimming_bit_depth_var.get() + output_format = self.local_dimming_output_format_var.get() + + ld_cfg = self.config.current_test_types.setdefault("local_dimming", {}) + ld_cfg["colorimetry"] = color_space + ld_cfg["color_format"] = output_format + ld_cfg["bpc"] = UCDEnum.SignalFormat.BitDepth.get_bit_value(bit_depth) + ld_cfg["data_range"] = data_range + + self.log_gui.log( + ( + "Local Dimming 信号格式已更新: " + f"色彩空间={color_space}, 数据范围={data_range}, " + f"位深={bit_depth}, 色彩格式={output_format}" + ), + level="info", + ) + + if self.testing: + self.log_gui.log("警告: 测试进行中,格式更改将在下次测试时生效", level="error") + self.save_pq_config() + return + + if getattr(self.ucd, "status", False): + ok = self.signal_service.update_signal_format( + color_space=color_space, + data_range=data_range, + bit_depth=bit_depth, + output_format=output_format, + ) + if not ok: + self.log_gui.log("Local Dimming 信号格式应用到UCD失败", level="error") + + self.save_pq_config() + except Exception as e: + self.log_gui.log(f"Local Dimming 信号格式更改失败: {str(e)}", level="error") + + def update_test_items(self: "PQAutomationApp"): """根据当前测试类型更新测试项目复选框""" # 先隐藏所有测试项目框架 @@ -1023,6 +1164,7 @@ def update_test_items(self: "PQAutomationApp"): ) # 添加复选框 + toggle_bootstyle = "success-round-toggle" for i, (text, var_name) in enumerate(config["items"]): is_checked = var_name in saved_test_items var = tk.BooleanVar(value=is_checked) @@ -1032,7 +1174,7 @@ def update_test_items(self: "PQAutomationApp"): frame, text=text, variable=var, - bootstyle="round-toggle", + bootstyle=toggle_bootstyle, command=self.update_config_and_tabs, ).grid(row=i // 2 + 1, column=i % 2, sticky=tk.W, padx=10, pady=5) @@ -1054,6 +1196,12 @@ def on_test_type_change(self: "PQAutomationApp"): # SDR 选中时显示客户模版按钮 self.update_custom_button_visibility() + # Local Dimming 作为并列测试类型时,自动显示其专用面板。 + if self.config.current_test_type == "local_dimming": + self.show_panel("local_dimming") + elif getattr(self, "current_panel", None) == "local_dimming": + self.hide_all_panels() + class MainLayoutMixin: @@ -1074,5 +1222,7 @@ class MainLayoutMixin: on_sdr_timing_changed = on_sdr_timing_changed on_sdr_output_format_changed = on_sdr_output_format_changed on_hdr_output_format_changed = on_hdr_output_format_changed + on_local_dimming_timing_changed = on_local_dimming_timing_changed + on_local_dimming_signal_format_changed = on_local_dimming_signal_format_changed update_test_items = update_test_items on_test_type_change = on_test_type_change diff --git a/app/views/panels/side_panels.py b/app/views/panels/side_panels.py index cfde3a8..2223e9a 100644 --- a/app/views/panels/side_panels.py +++ b/app/views/panels/side_panels.py @@ -365,6 +365,7 @@ def update_sidebar_selection(self: "PQAutomationApp"): self.screen_module_btn.configure(style="Sidebar.TButton") self.sdr_movie_btn.configure(style="Sidebar.TButton") self.hdr_movie_btn.configure(style="Sidebar.TButton") + self.local_dimming_btn.configure(style="Sidebar.TButton") # 设置当前选中按钮的样式 current_type = self.test_type_var.get() @@ -374,6 +375,8 @@ def update_sidebar_selection(self: "PQAutomationApp"): self.sdr_movie_btn.configure(style="SidebarSelected.TButton") elif current_type == "hdr_movie": self.hdr_movie_btn.configure(style="SidebarSelected.TButton") + elif current_type == "local_dimming": + self.local_dimming_btn.configure(style="SidebarSelected.TButton") class SidePanelsMixin: diff --git a/docs/PQ生图后端接口文档v2.pdf b/docs/PQ生图后端接口文档v2.pdf new file mode 100644 index 0000000..aa079fb Binary files /dev/null and b/docs/PQ生图后端接口文档v2.pdf differ diff --git a/docs/接口文档-测试环境.pdf b/docs/接口文档-测试环境.pdf deleted file mode 100644 index 86f9b5d..0000000 Binary files a/docs/接口文档-测试环境.pdf and /dev/null differ diff --git a/pqAutomationApp.py b/pqAutomationApp.py index 7b20ac7..dd4b150 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -358,10 +358,11 @@ class PQAutomationApp( "screen_module": 0, "sdr_movie": 1, "hdr_movie": 2, + "local_dimming": 3, } target_tab = tab_mapping.get(test_type, 0) - for i in range(3): + for i in range(4): self.signal_tabs.tab(i, state="normal") self.signal_tabs.select(target_tab) @@ -374,8 +375,10 @@ class PQAutomationApp( self.sdr_signal_frame.tkraise() elif target_tab == 2: self.hdr_signal_frame.tkraise() + elif target_tab == 3: + self.local_dimming_signal_frame.tkraise() - for i in range(3): + for i in range(4): if i != target_tab: self.signal_tabs.tab(i, state="disabled") @@ -397,16 +400,16 @@ class PQAutomationApp( if test_type == "hdr_movie": if gamma_tab_id in current_tabs: - gamma_index = current_tabs.index(gamma_tab_id) - self.chart_notebook.forget(gamma_index) + self.chart_notebook.forget(self.gamma_chart_frame) if eotf_tab_id not in current_tabs: - self.chart_notebook.insert(1, self.eotf_chart_frame, text="EOTF 曲线") + insert_pos = min(1, len(self.chart_notebook.tabs())) + self.chart_notebook.insert(insert_pos, self.eotf_chart_frame, text="EOTF 曲线") else: if eotf_tab_id in current_tabs: - eotf_index = current_tabs.index(eotf_tab_id) - self.chart_notebook.forget(eotf_index) + self.chart_notebook.forget(self.eotf_chart_frame) if gamma_tab_id not in current_tabs: - self.chart_notebook.insert(1, self.gamma_chart_frame, text="Gamma 曲线") + insert_pos = min(1, len(self.chart_notebook.tabs())) + self.chart_notebook.insert(insert_pos, self.gamma_chart_frame, text="Gamma 曲线") custom_tab_id = str(self.custom_template_tab_frame) current_tabs = list(self.chart_notebook.tabs()) @@ -533,6 +536,8 @@ class PQAutomationApp( return "开始 SDR Movie 测试,请设置正确的图像模式" if test_type == "hdr_movie": return "开始 HDR Movie 测试,请设置正确的图像模式" + if test_type == "local_dimming": + return "Local Dimming 为手动模式,请在右侧面板发送图案并采集数据" return f"开始{self.get_test_type_name(test_type)}测试" def _launch_test_thread(self, test_type, test_items): @@ -743,6 +748,8 @@ class PQAutomationApp( return "SDR Movie测试" elif test_type == "hdr_movie": return "HDR Movie测试" + elif test_type == "local_dimming": + return "Local Dimming" return test_type def get_selected_test_items(self): @@ -772,6 +779,11 @@ class PQAutomationApp( and hasattr(self, "sdr_timing_var") ): self.config.set_current_timing(self.sdr_timing_var.get()) + elif ( + self.config.current_test_type == "local_dimming" + and hasattr(self, "local_dimming_timing_var") + ): + self.config.set_current_timing(self.local_dimming_timing_var.get()) # 自动保存配置到文件 self.save_pq_config() diff --git a/settings/pq_config.json b/settings/pq_config.json index 3b0c675..eb26e0b 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -81,6 +81,16 @@ "y_ideal": 0.329, "y_tolerance": 0.003 } + }, + "local_dimming": { + "name": "Local Dimming", + "test_items": [], + "timing": "DMT 1920x 1080 @ 60Hz", + "data_range": "Full", + "color_format": "RGB", + "bpc": 8, + "colorimetry": "sRGB", + "patterns": {} } }, "device_config": {