From 32aaef1c0fc1e609cc381e79556795813abba97a Mon Sep 17 00:00:00 2001 From: "xinzhu.yin" Date: Tue, 7 Jul 2026 18:59:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=A0=B9=E6=8D=AE=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E8=B0=83=E6=95=B4set=5Fpattern=EF=BC=9A0~65535?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- UniTAP/libs/lib_tsi/tsi_io.py | 7 + UniTAP/tsi_lib.py | 5 + algorithm/pq_algorithm.py | 8 +- app/data_range_converter.py | 114 +- app/plots/plot_gamma.py | 20 +- app/pq/pq_config.py | 101 +- app/runner/test_runner.py | 52 +- app/solid_color_scale.py | 118 ++ app/tests/color_accuracy.py | 60 +- app/ucd/__init__.py | 25 + app/ucd/device.py | 152 +- app/ucd/pg_status.py | 111 ++ app/ucd/service.py | 64 +- app/ucd/solid_color_scale.py | 2 + app/views/panels/calman_panel.py | 50 +- app/views/panels/gamma_pattern_panel.py | 34 +- app/views/panels/pantone_baseline_panel.py | 13 +- .../examples/work_with_packet_generation.py | 14 +- .../examples/work_with_solid_color.py | 14 +- hdr10_demo.py | 1464 +++++++++++++++++ pqAutomationApp.py | 12 + settings/pq_config.json | 6 +- .../pq_white_1920x1080_1000nits.png | Bin 0 -> 8594 bytes test.py | 849 +++++++++- 24 files changed, 3016 insertions(+), 279 deletions(-) create mode 100644 app/solid_color_scale.py create mode 100644 app/ucd/pg_status.py create mode 100644 app/ucd/solid_color_scale.py create mode 100644 hdr10_demo.py create mode 100644 temp_hdr10_images/pq_white_1920x1080_1000nits.png diff --git a/UniTAP/libs/lib_tsi/tsi_io.py b/UniTAP/libs/lib_tsi/tsi_io.py index 3ad0717..abd541b 100644 --- a/UniTAP/libs/lib_tsi/tsi_io.py +++ b/UniTAP/libs/lib_tsi/tsi_io.py @@ -243,6 +243,13 @@ class BaseIO: self.__ref_count = tsi.TSI_Clean() logging.info(f"[UniTAP] BaseIO.del: {self}; Ref count: {self.__ref_count}") + def rescan_devices(self): + if self.__ref_count > 0: + tsi.TSIX_DEV_RescanDevices() + return + logging.error("[UniTAP] BaseIO.rescan_devices call without TSI_Init") + + def get_device_count(self) -> int: if self.__ref_count > 0: return tsi.TSIX_DEV_GetDeviceCount() diff --git a/UniTAP/tsi_lib.py b/UniTAP/tsi_lib.py index 0be63c4..daafe42 100644 --- a/UniTAP/tsi_lib.py +++ b/UniTAP/tsi_lib.py @@ -70,6 +70,11 @@ class TsiLib: self.__io.cleanup() logging.info(f"[UniTAP] TsiLib.cleanup {self}") + def rescan_devices(self): + """Rescan USB for newly connected TSI devices without tearing down the library.""" + self.__io.rescan_devices() + logging.info(f"[UniTAP] TsiLib.rescan_devices {self}") + def __del__(self): """ Automatically call the destructor after the script completes. diff --git a/algorithm/pq_algorithm.py b/algorithm/pq_algorithm.py index 080e12e..73eb010 100644 --- a/algorithm/pq_algorithm.py +++ b/algorithm/pq_algorithm.py @@ -2,6 +2,8 @@ import math import numpy as np from shapely.geometry import Polygon +from app.solid_color_scale import SOLID_COLOR_MAX + # ================== # 色域覆盖率计算公式 @@ -335,7 +337,7 @@ def calculate_gamma(results, max_index_fix, pattern_params=None): results: [[x, y, lv, X, Y, Z], ...] 测量结果 max_index_fix: 最大灰阶索引 pattern_params: [[R, G, B], ...] 8bit pattern参数,用于计算input_level - 如果提供,则使用 pattern_value/255 作为底数 + 如果提供,则使用 pattern_value/SOLID_COLOR_MAX 作为底数 如果不提供,则使用传统的 灰阶百分比 作为底数 Returns: @@ -372,10 +374,10 @@ def calculate_gamma(results, max_index_fix, pattern_params=None): # 计算输入灰阶值 input_level if pattern_params is not None and i < len(pattern_params): - # 使用 8bit pattern 数据 / 255 作为底数(22293 Gamma 数据对齐) + # 使用 SolidColor pattern 数据 / SOLID_COLOR_MAX 作为底数(22293 Gamma 数据对齐) # 取 R 通道值(灰阶图案 R=G=B) pattern_value = pattern_params[i][0] - input_level = pattern_value / 255.0 + input_level = pattern_value / SOLID_COLOR_MAX else: # 传统计算方式:根据数据顺序计算输入灰阶值 if is_descending: diff --git a/app/data_range_converter.py b/app/data_range_converter.py index 9e8dbe8..4a393d3 100644 --- a/app/data_range_converter.py +++ b/app/data_range_converter.py @@ -1,15 +1,17 @@ # -*- coding: UTF-8 -*- """ 数据范围转换器 -将 Full Range (0-255) 转换为 Limited Range (16-235) - -使用方法: - from app.data_range_converter import DataRangeConverter - - converter = DataRangeConverter() - converted_params = converter.convert(pattern_params, "Limited") +将 Full Range (0–65535) 转换为 Limited Range(SolidColor 刻度) """ +from app.solid_color_scale import ( + LIMITED_SOLID_COLOR_BLACK, + LIMITED_SOLID_COLOR_WHITE, + SOLID_COLOR_MAX, + SOLID_COLOR_MIN, + clamp_solid_color, +) + class DataRangeConverter: """数据范围转换器""" @@ -25,38 +27,22 @@ class DataRangeConverter: def convert_value(self, value): """ - 将单个 Full Range 值转换为 Limited Range + 将单个 Full Range 值转换为 Limited Range(SolidColor 刻度)。 - 转换公式: - limited = 16 + (value / 255) × (235 - 16) - - Args: - value: Full Range 值 (0-255) - - Returns: - int: Limited Range 值 (16-235) + 转换公式(归一化后线性映射): + limited = L_black + (value / MAX) × (L_white - L_black) """ - # 边界值直接映射 - if value == 0: - return 16 - elif value == 255: - return 235 - else: - # 线性映射 - limited = 16 + round((value / 255.0) * (235 - 16)) - # 限制范围 - return max(16, min(235, limited)) + value = clamp_solid_color(value) + if value <= SOLID_COLOR_MIN: + return LIMITED_SOLID_COLOR_BLACK + if value >= SOLID_COLOR_MAX: + return LIMITED_SOLID_COLOR_WHITE + span = LIMITED_SOLID_COLOR_WHITE - LIMITED_SOLID_COLOR_BLACK + limited = LIMITED_SOLID_COLOR_BLACK + round((value / SOLID_COLOR_MAX) * span) + return max(LIMITED_SOLID_COLOR_BLACK, min(LIMITED_SOLID_COLOR_WHITE, limited)) def convert_rgb(self, r, g, b): - """ - 转换单个 RGB 值 - - Args: - r, g, b: Full Range RGB 值 (0-255) - - Returns: - tuple: Limited Range RGB 值 (16-235) - """ + """转换单个 RGB 值(SolidColor Full → Limited)。""" return (self.convert_value(r), self.convert_value(g), self.convert_value(b)) def convert(self, pattern_params, data_range="Full"): @@ -73,7 +59,7 @@ class DataRangeConverter: # Full Range 不需要转换 if data_range == "Full": if self.verbose: - print("使用 Full Range (0-255),无需转换") + print(f"使用 Full Range (0–{SOLID_COLOR_MAX}),无需转换") return pattern_params # Limited Range 需要转换 @@ -112,8 +98,11 @@ class DataRangeConverter: def _print_header(self): """打印转换头部信息""" print("=" * 80) - print("【数据范围转换】Limited Range (16-235)") - print(" 转换公式: 16 + (value / 255) × (235 - 16)") + print("【数据范围转换】Limited Range (SolidColor 刻度)") + print( + f" 映射: {SOLID_COLOR_MIN}–{SOLID_COLOR_MAX} → " + f"{LIMITED_SOLID_COLOR_BLACK}–{LIMITED_SOLID_COLOR_WHITE}" + ) print("=" * 80) def _print_conversion(self, index, r_orig, g_orig, b_orig, r_new, g_new, b_new): @@ -126,25 +115,23 @@ class DataRangeConverter: """ # 判断是否需要打印 should_print = False + label = "" + mid = SOLID_COLOR_MAX // 2 - # 第一个和最后一个 if index == 0: should_print = True label = "黑色" elif index == len([]) - 1: # 需要在外部判断 should_print = True label = "白色" - # 关键 RGB 值 elif ( - r_orig in [0, 128, 255] - or g_orig in [0, 128, 255] - or b_orig in [0, 128, 255] + r_orig in [SOLID_COLOR_MIN, mid, SOLID_COLOR_MAX] + or g_orig in [SOLID_COLOR_MIN, mid, SOLID_COLOR_MAX] + or b_orig in [SOLID_COLOR_MIN, mid, SOLID_COLOR_MAX] ): should_print = True - if r_orig == 128: + if r_orig == mid: label = "50% 灰" - else: - label = "" if should_print: diff = abs(r_new - r_orig) @@ -165,9 +152,9 @@ class DataRangeConverter: return { "name": "Data Range Converter", "version": "1.0.0", - "full_range": "0-255", - "limited_range": "16-235", - "formula": "16 + (value / 255) × 219", + "full_range": f"0-{SOLID_COLOR_MAX}", + "limited_range": f"{LIMITED_SOLID_COLOR_BLACK}-{LIMITED_SOLID_COLOR_WHITE}", + "formula": "L_black + (value / MAX) × (L_white - L_black)", } @@ -188,9 +175,9 @@ def convert_pattern_params(pattern_params, data_range="Full", verbose=True): 示例: >>> from app.data_range_converter import convert_pattern_params - >>> params = [[0,0,0], [255,255,255]] + >>> params = [[0,0,0], [65535,65535,65535]] >>> converted = convert_pattern_params(params, "Limited") - [[16,16,16], [235,235,235]] + [[4112,4112,4112], [60492,60492,60492]] """ converter = DataRangeConverter(verbose=verbose) return converter.convert(pattern_params, data_range) @@ -201,7 +188,7 @@ def convert_single_rgb(r, g, b, data_range="Full"): 便捷函数:转换单个 RGB 值 Args: - r, g, b: RGB 值 (0-255) + r, g, b: RGB 值 (0–65535) data_range: "Full" 或 "Limited" Returns: @@ -232,7 +219,8 @@ if __name__ == "__main__": print("\n[测试 1] 基本转换...") converter = DataRangeConverter(verbose=False) - test_values = [0, 16, 64, 128, 192, 235, 255] + test_values = [0, LIMITED_SOLID_COLOR_BLACK, SOLID_COLOR_MAX // 4, SOLID_COLOR_MAX // 2, + SOLID_COLOR_MAX * 3 // 4, LIMITED_SOLID_COLOR_WHITE, SOLID_COLOR_MAX] print(" Full Range → Limited Range:") for v in test_values: limited = converter.convert_value(v) @@ -243,8 +231,8 @@ if __name__ == "__main__": print("\n[测试 2] RGB 转换...") test_rgb = [ (0, 0, 0), - (128, 128, 128), - (255, 255, 255), + (SOLID_COLOR_MAX // 2, SOLID_COLOR_MAX // 2, SOLID_COLOR_MAX // 2), + (SOLID_COLOR_MAX, SOLID_COLOR_MAX, SOLID_COLOR_MAX), ] for r, g, b in test_rgb: @@ -253,12 +241,14 @@ if __name__ == "__main__": # 测试 3: 完整转换流程 print("\n[测试 3] 完整转换流程...") + from app.ucd.solid_color_scale import byte_to_solid_color as _sc + pattern_params = [ - [255, 255, 255], # 100% 白 - [230, 230, 230], # 90% - [204, 204, 204], # 80% - [128, 128, 128], # 50% - [0, 0, 0], # 0% 黑 + [_sc(255)] * 3, + [_sc(230)] * 3, + [_sc(204)] * 3, + [_sc(128)] * 3, + [0, 0, 0], ] converted = converter.convert(pattern_params, "Limited") @@ -270,11 +260,11 @@ if __name__ == "__main__": # 测试 4: 便捷函数 print("\n[测试 4] 便捷函数...") result = convert_pattern_params( - [[0, 0, 0], [255, 255, 255]], "Limited", verbose=False + [[0, 0, 0], [SOLID_COLOR_MAX, SOLID_COLOR_MAX, SOLID_COLOR_MAX]], "Limited", verbose=False ) print(f" 结果: {result}") - r, g, b = convert_single_rgb(128, 128, 128, "Limited") + r, g, b = convert_single_rgb(SOLID_COLOR_MAX // 2, SOLID_COLOR_MAX // 2, SOLID_COLOR_MAX // 2, "Limited") print(f" RGB(128,128,128) → RGB({r},{g},{b})") # 测试 5: 获取信息 diff --git a/app/plots/plot_gamma.py b/app/plots/plot_gamma.py index e916f65..21dbaa5 100644 --- a/app/plots/plot_gamma.py +++ b/app/plots/plot_gamma.py @@ -4,6 +4,7 @@ Step 2 重构:从 pqAutomationApp.PQAutomationApp.plot_gamma 原样搬迁。 """ import numpy as np +from app.solid_color_scale import solid_color_to_pct from app.views.modern_styles import get_theme_palette from typing import TYPE_CHECKING @@ -25,7 +26,14 @@ def _is_dark_palette(palette: dict[str, str]) -> bool: -def plot_gamma(self: "PQAutomationApp", L_bar, results_with_gamma_list, target_gamma, test_type): +def plot_gamma( + self: "PQAutomationApp", + L_bar, + results_with_gamma_list, + target_gamma, + test_type, + pattern_params=None, +): """绘制Gamma曲线 + 数据表格(包含实测亮度)""" palette = get_theme_palette() dark_mode = _is_dark_palette(palette) @@ -51,13 +59,19 @@ def plot_gamma(self: "PQAutomationApp", L_bar, results_with_gamma_list, target_g for spine in self.gamma_ax.spines.values(): spine.set_color(palette["border"]) - # 生成横坐标(灰阶百分比) - x_values = np.linspace(0, 100, len(L_bar)) + # 横坐标:优先用 pattern 码值换算的灰阶百分比,否则等分 + if pattern_params and len(pattern_params) == len(L_bar): + x_values = np.array([ + solid_color_to_pct(row[0]) for row in pattern_params + ]) + else: + x_values = np.linspace(0, 100, len(L_bar)) # 反转 L_bar(确保从左到右是 0% → 100%) if len(L_bar) > 1 and L_bar[0] > L_bar[-1]: L_bar = L_bar[::-1] results_with_gamma_list = results_with_gamma_list[::-1] + x_values = x_values[::-1] # 计算平均Gamma gamma_values = [] diff --git a/app/pq/pq_config.py b/app/pq/pq_config.py index 0f1ecd4..01e22fe 100644 --- a/app/pq/pq_config.py +++ b/app/pq/pq_config.py @@ -3,6 +3,13 @@ import json import copy from pathlib import Path +from app.solid_color_scale import ( + SOLID_COLOR_MAX, + ensure_solid_color_pattern, + pct_to_solid_color, + solid_color_rgb as _rgb, +) + # ============================================================================= # Pattern 文件读写工具(统一存储格式:settings/patterns/{name}.json) @@ -25,7 +32,7 @@ def load_pattern_file(filepath) -> dict: } """ with open(filepath, encoding="utf-8") as f: - return json.load(f) + return ensure_solid_color_pattern(json.load(f)) def save_pattern_file(filepath, pattern: dict) -> None: @@ -137,9 +144,9 @@ _PATTERN_RGB = { "measurement_bit_depth": 8, "measurement_max_value": 2, "pattern_params": [ - [255, 0, 0], # 红色 - [0, 255, 0], # 绿色 - [0, 0, 255], # 蓝色 + _rgb(255, 0, 0), # 红色 + _rgb(0, 255, 0), # 绿色 + _rgb(0, 0, 255), # 蓝色 ], } @@ -149,17 +156,17 @@ _PATTERN_GRAY_FALLBACK = { "measurement_bit_depth": 8, "measurement_max_value": 10, "pattern_params": [ - [255, 255, 255], # 100% 白色 - [230, 230, 230], # 90% - [205, 205, 205], # 80% - [179, 179, 179], # 70% - [154, 154, 154], # 60% - [128, 128, 128], # 50% - [102, 102, 102], # 40% - [78, 78, 78], # 30% - [52, 52, 52], # 20% - [26, 26, 26], # 10% - [0, 0, 0], # 0% 黑色 + _rgb(255, 255, 255), # 100% 白色 + _rgb(230, 230, 230), # 90% + _rgb(205, 205, 205), # 80% + _rgb(179, 179, 179), # 70% + _rgb(154, 154, 154), # 60% + _rgb(128, 128, 128), # 50% + _rgb(102, 102, 102), # 40% + _rgb(78, 78, 78), # 30% + _rgb(52, 52, 52), # 20% + _rgb(26, 26, 26), # 10% + _rgb(0, 0, 0), # 0% 黑色 ], } # 灰阶 pattern 从文件加载,支持用户编辑;文件缺失时回退到硬编码兜底 @@ -171,37 +178,37 @@ _PATTERN_ACCURACY = { "measurement_max_value": 28, # 29个颜色,最大索引是28 "pattern_params": [ # ========== 灰阶 (5个) ========== - [255, 255, 255], # 0: White - [230, 230, 230], # 1: Gray 80 - [209, 209, 209], # 2: Gray 65 - [186, 186, 186], # 3: Gray 50 - [158, 158, 158], # 4: Gray 35 + _rgb(255, 255, 255), # 0: White + _rgb(230, 230, 230), # 1: Gray 80 + _rgb(209, 209, 209), # 2: Gray 65 + _rgb(186, 186, 186), # 3: Gray 50 + _rgb(158, 158, 158), # 4: Gray 35 # ========== ColorChecker 24色 (18个) ========== - [115, 82, 66], # 5: Dark Skin - [194, 150, 130], # 6: Light Skin - [94, 122, 156], # 7: Blue Sky - [89, 107, 66], # 8: Foliage - [130, 128, 176], # 9: Blue Flower - [99, 189, 168], # 10: Bluish Green - [217, 120, 41], # 11: Orange - [74, 92, 163], # 12: Purplish Blue - [194, 84, 97], # 13: Moderate Red - [92, 61, 107], # 14: Purple - [158, 186, 64], # 15: Yellow Green - [230, 161, 46], # 16: Orange Yellow - [51, 61, 150], # 17: Blue (Legacy) - [71, 148, 71], # 18: Green (Legacy) - [176, 48, 59], # 19: Red (Legacy) - [237, 199, 33], # 20: Yellow (Legacy) - [186, 84, 145], # 21: Magenta (Legacy) - [0, 133, 163], # 22: Cyan (Legacy) + _rgb(115, 82, 66), # 5: Dark Skin + _rgb(194, 150, 130), # 6: Light Skin + _rgb(94, 122, 156), # 7: Blue Sky + _rgb(89, 107, 66), # 8: Foliage + _rgb(130, 128, 176), # 9: Blue Flower + _rgb(99, 189, 168), # 10: Bluish Green + _rgb(217, 120, 41), # 11: Orange + _rgb(74, 92, 163), # 12: Purplish Blue + _rgb(194, 84, 97), # 13: Moderate Red + _rgb(92, 61, 107), # 14: Purple + _rgb(158, 186, 64), # 15: Yellow Green + _rgb(230, 161, 46), # 16: Orange Yellow + _rgb(51, 61, 150), # 17: Blue (Legacy) + _rgb(71, 148, 71), # 18: Green (Legacy) + _rgb(176, 48, 59), # 19: Red (Legacy) + _rgb(237, 199, 33), # 20: Yellow (Legacy) + _rgb(186, 84, 145), # 21: Magenta (Legacy) + _rgb(0, 133, 163), # 22: Cyan (Legacy) # ========== 100% 饱和色 (6个) ========== - [255, 0, 0], # 23: 100% Red - [0, 255, 0], # 24: 100% Green - [0, 0, 255], # 25: 100% Blue - [0, 255, 255], # 26: 100% Cyan - [255, 0, 255], # 27: 100% Magenta - [255, 255, 0], # 28: 100% Yellow + _rgb(255, 0, 0), # 23: 100% Red + _rgb(0, 255, 0), # 24: 100% Green + _rgb(0, 0, 255), # 25: 100% Blue + _rgb(0, 255, 255), # 26: 100% Cyan + _rgb(255, 0, 255), # 27: 100% Magenta + _rgb(255, 255, 0), # 28: 100% Yellow ], } @@ -505,7 +512,7 @@ def _gen_even_gray(n: int) -> list[list[int]]: out = [] for i in range(n): pct = 100.0 - (100.0 / (n - 1)) * i - v = int(round(pct / 100.0 * 255)) + v = pct_to_solid_color(pct) out.append([v, v, v]) return out @@ -521,7 +528,7 @@ def _gen_pq_gray(n: int) -> list[list[int]]: out = [] for i in range(n): v_pq = 1.0 - i / (n - 1) - v = int(round(v_pq * 255)) + v = int(round(v_pq * SOLID_COLOR_MAX)) out.append([v, v, v]) return out @@ -534,7 +541,7 @@ def _gen_gamma_gray(n: int, gamma: float = 2.2) -> list[list[int]]: for i in range(n): lin = 1.0 - i / (n - 1) # 线性光 1→0 code = lin ** (1.0 / gamma) # gamma 编码 - v = int(round(code * 255)) + v = int(round(code * SOLID_COLOR_MAX)) out.append([v, v, v]) return out diff --git a/app/runner/test_runner.py b/app/runner/test_runner.py index 6cf2891..27b488e 100644 --- a/app/runner/test_runner.py +++ b/app/runner/test_runner.py @@ -51,12 +51,13 @@ def run_test(self: "PQAutomationApp", test_type, test_items): , level="info") # 根据测试类型执行不同的测试流程 + ok = True if test_type == "screen_module": - self.run_screen_module_test(test_items) + ok = self.run_screen_module_test(test_items) elif test_type == "sdr_movie": - self.run_sdr_movie_test(test_items) + ok = self.run_sdr_movie_test(test_items) elif test_type == "hdr_movie": - self.run_hdr_movie_test(test_items) + ok = self.run_hdr_movie_test(test_items) elif test_type == "local_dimming": self.log_gui.log( "Local Dimming 为手动模式,请在 Local Dimming 面板发送图案并采集亮度", @@ -65,7 +66,10 @@ def run_test(self: "PQAutomationApp", test_type, test_items): # 测试完成后更新UI状态 if self.testing: # 如果没有被中途停止 - self._dispatch_ui(self.on_test_completed) + if ok: + self._dispatch_ui(self.on_test_completed) + else: + self._dispatch_ui(self.on_test_error) except Exception as e: self.log_gui.log(f"测试过程中发生错误: {str(e)}", level="info") import traceback @@ -82,7 +86,7 @@ def run_screen_module_test(self: "PQAutomationApp", test_items): self.new_pq_results("screen_module", "屏模组性能测试") else: self.log_gui.log("未选择任何测试项目", level="info") - return + return False # 判断是否需要灰阶数据 needs_gray_data = any( @@ -96,7 +100,7 @@ def run_screen_module_test(self: "PQAutomationApp", test_items): for item in test_items: if not self.testing: # 检查是否被停止 - return + return False current_item += 1 self.status_var.set(f"测试进行中... ({current_item}/{total_items})") @@ -120,7 +124,7 @@ def run_screen_module_test(self: "PQAutomationApp", test_items): if not shared_gray_data or len(shared_gray_data) < 2: self.log_gui.log("灰阶数据采集失败或数据不足,跳过相关测试", level="error") - return + return False self.log_gui.log( f"灰阶数据采集完成,共 {len(shared_gray_data)} 个数据点" @@ -148,6 +152,8 @@ def run_screen_module_test(self: "PQAutomationApp", test_items): elif item == "contrast": self.test_contrast("screen_module", shared_gray_data) + return True + def run_custom_sdr_test(self: "PQAutomationApp", test_items): """执行客户定制 SDR 测试 - 升级版""" @@ -173,7 +179,7 @@ def run_sdr_movie_test(self: "PQAutomationApp", test_items): self.new_pq_results("sdr_movie", "SDR Movie测试") else: self.log_gui.log("未选择任何测试项目", level="info") - return + return False # 获取信号格式设置 color_space = self.sdr_color_space_var.get() # BT.709/BT.601/BT.2020 @@ -194,7 +200,7 @@ def run_sdr_movie_test(self: "PQAutomationApp", test_items): for item in test_items: if not self.testing: - return + return False current_item += 1 self.status_var.set(f"测试进行中... ({current_item}/{total_items})") @@ -212,7 +218,7 @@ def run_sdr_movie_test(self: "PQAutomationApp", test_items): if not shared_gray_data or len(shared_gray_data) < 2: self.log_gui.log("灰阶数据采集失败或数据不足", level="error") - return + return False self.results.add_intermediate_data("shared", "gray", shared_gray_data) @@ -235,6 +241,8 @@ def run_sdr_movie_test(self: "PQAutomationApp", test_items): elif item == "accuracy": self.test_color_accuracy("sdr_movie") + return True + def run_hdr_movie_test(self: "PQAutomationApp", test_items): """执行HDR Movie测试""" @@ -244,7 +252,7 @@ def run_hdr_movie_test(self: "PQAutomationApp", test_items): self.new_pq_results("hdr_movie", "HDR Movie测试") else: self.log_gui.log("未选择任何测试项目", level="info") - return + return False # 获取信号格式设置 color_space = self.hdr_color_space_var.get() @@ -269,7 +277,7 @@ def run_hdr_movie_test(self: "PQAutomationApp", test_items): for item in test_items: if not self.testing: - return + return False current_item += 1 self.status_var.set(f"测试进行中... ({current_item}/{total_items})") @@ -287,7 +295,7 @@ def run_hdr_movie_test(self: "PQAutomationApp", test_items): if not shared_gray_data or len(shared_gray_data) < 2: self.log_gui.log("灰阶数据采集失败或数据不足", level="error") - return + return False self.results.add_intermediate_data("shared", "gray", shared_gray_data) @@ -310,6 +318,8 @@ def run_hdr_movie_test(self: "PQAutomationApp", test_items): elif item == "accuracy": self.test_color_accuracy("hdr_movie") + return True + def send_fix_pattern(self: "PQAutomationApp", mode): """发送固定图案并采集数据 - 支持不同测试类型的信号格式""" @@ -693,9 +703,9 @@ def test_gamma(self: "PQAutomationApp", test_type, gray_data=None): # ======================================================== # 获取灰阶 pattern 参数(用于22293 Gamma数据对齐) - pattern_params = self.config.default_pattern_gray.get( + pattern_params = self.config.current_pattern.get( "pattern_params", None - ) + ) or self.config.default_pattern_gray.get("pattern_params", None) # 计算Gamma值(使用修正后的 max_index_fix 和 8bit pattern参数) results_with_gamma_list, L_bar = self.calculate_gamma( @@ -713,8 +723,12 @@ def test_gamma(self: "PQAutomationApp", test_type, gray_data=None): target_gamma = 2.2 else: target_gamma = 2.2 - self.plot_gamma(L_bar, results_with_gamma_list, target_gamma, test_type) - self._save_chart_snapshot(test_type, "gamma", (L_bar, results_with_gamma_list, target_gamma, test_type)) + self.plot_gamma( + L_bar, results_with_gamma_list, target_gamma, test_type, pattern_params + ) + self._save_chart_snapshot( + test_type, "gamma", (L_bar, results_with_gamma_list, target_gamma, test_type) + ) self.log_gui.log("Gamma测试完成", level="success") @@ -780,9 +794,9 @@ def test_eotf(self: "PQAutomationApp", test_type, gray_data=None): self.log_gui.log(f"最终使用的 max_index_fix = {max_index_fix}", level="info") # 获取灰阶 pattern 参数(用于22293 Gamma数据对齐) - pattern_params = self.config.default_pattern_gray.get( + pattern_params = self.config.current_pattern.get( "pattern_params", None - ) + ) or self.config.default_pattern_gray.get("pattern_params", None) # ========== 计算 EOTF(复用 Gamma 计算逻辑,使用8bit pattern参数)========== results_with_eotf_list, L_bar = self.calculate_gamma( diff --git a/app/solid_color_scale.py b/app/solid_color_scale.py new file mode 100644 index 0000000..0454660 --- /dev/null +++ b/app/solid_color_scale.py @@ -0,0 +1,118 @@ +"""UCD SolidColor 码值刻度(与 UniTAP ``SolidColorParams`` 一致,0–65535)。""" +from __future__ import annotations + +SOLID_COLOR_MIN = 0 +SOLID_COLOR_MAX = 65535 +# 官方 work_with_solid_color.py:65535 刻度需 bpc>=10 +SOLID_COLOR_MIN_BPC = 10 + +# Limited Range 在 SolidColor 刻度下的端点(等价于 8bit 的 16 / 235) +_LIMITED_8BIT_BLACK = 16 +_LIMITED_8BIT_WHITE = 235 +LIMITED_SOLID_COLOR_BLACK = round(_LIMITED_8BIT_BLACK / 255 * SOLID_COLOR_MAX) +LIMITED_SOLID_COLOR_WHITE = round(_LIMITED_8BIT_WHITE / 255 * SOLID_COLOR_MAX) + + +def clamp_solid_color(value: int | float) -> int: + """将码值限制在 SolidColor 合法范围内。""" + return max(SOLID_COLOR_MIN, min(SOLID_COLOR_MAX, int(round(value)))) + + +def pct_to_solid_color(pct: float) -> int: + """灰阶/亮度百分比 (0–100) → SolidColor 码值。""" + pct = max(0.0, min(100.0, float(pct))) + return int(round(pct / 100.0 * SOLID_COLOR_MAX)) + + +def solid_color_to_pct(value: int | float) -> float: + """SolidColor 码值 → 百分比 (0–100)。""" + if SOLID_COLOR_MAX <= 0: + return 0.0 + return clamp_solid_color(value) / SOLID_COLOR_MAX * 100.0 + + +def solid_color_to_byte(value: int | float) -> int: + """SolidColor 码值 → 8bit(UI 色块 / HEX 显示用)。""" + return int(round(clamp_solid_color(value) / SOLID_COLOR_MAX * 255)) + + +def byte_to_solid_color(value: int | float) -> int: + """旧 8bit 码值 (0–255) → SolidColor 刻度。""" + byte = max(0, min(255, int(round(value)))) + return int(round(byte / 255.0 * SOLID_COLOR_MAX)) + + +def solid_color_rgb(r: int, g: int, b: int) -> list[int]: + """将旧 8bit RGB 三元组转为 SolidColor 刻度。""" + return [byte_to_solid_color(r), byte_to_solid_color(g), byte_to_solid_color(b)] + + +def is_legacy_8bit_pattern_params(params: list) -> bool: + """判断 ``pattern_params`` 是否仍为旧 0–255 刻度。""" + if not params: + return False + max_val = 0 + for row in params: + if not row: + continue + for channel in row[:3]: + try: + max_val = max(max_val, int(channel)) + except (TypeError, ValueError): + pass + return max_val <= 255 + + +def migrate_pattern_params(params: list) -> list: + """将 0–255 刻度的 pattern 参数迁移到 SolidColor 刻度。""" + if not is_legacy_8bit_pattern_params(params): + return params + migrated: list = [] + for row in params: + head = [byte_to_solid_color(v) for v in row[:3]] + migrated.append(head + list(row[3:])) + return migrated + + +def ensure_solid_color_pattern(pattern: dict) -> dict: + """确保 pattern 字典中的 ``pattern_params`` 使用 SolidColor 刻度。""" + params = pattern.get("pattern_params") + if params: + pattern["pattern_params"] = migrate_pattern_params(params) + return pattern + + +def pack_solid_color_hw_registers( + rgb: tuple[int, int, int] | list[int], +) -> tuple[int, int]: + """将 65535 刻度 RGB 打包为 PG 硬件寄存器值(``shifter=0``,新版 UCD API)。 + + 旧版 UniTAP SDK 在 ``set_pattern_params`` 内还会按 ``16-bpc`` 左移; + 65535 刻度下左移会导致 32 位溢出(30%/50%/80% 灰阶发暗)。 + 新版 UCD 直接接受 0–65535,须绕过 SDK 移位。 + """ + r = clamp_solid_color(rgb[0]) + g = clamp_solid_color(rgb[1]) + b = clamp_solid_color(rgb[2]) + param0 = ((r << 16) | g) & 0xFFFFFFFF + param1 = b & 0xFFFFFFFF + return param0, param1 + + +__all__ = [ + "SOLID_COLOR_MIN", + "SOLID_COLOR_MAX", + "SOLID_COLOR_MIN_BPC", + "LIMITED_SOLID_COLOR_BLACK", + "LIMITED_SOLID_COLOR_WHITE", + "clamp_solid_color", + "pct_to_solid_color", + "solid_color_to_pct", + "solid_color_to_byte", + "byte_to_solid_color", + "solid_color_rgb", + "is_legacy_8bit_pattern_params", + "migrate_pattern_params", + "ensure_solid_color_pattern", + "pack_solid_color_hw_registers", +] diff --git a/app/tests/color_accuracy.py b/app/tests/color_accuracy.py index d3a1eea..88d4c2f 100644 --- a/app/tests/color_accuracy.py +++ b/app/tests/color_accuracy.py @@ -18,6 +18,8 @@ import math import numpy as np +from app.solid_color_scale import solid_color_rgb as _rgb + D65_X = 0.3127 D65_Y = 0.3290 @@ -150,35 +152,35 @@ _GRAYSCALE_SIGNAL = { _SDR_COLOR_PATTERNS = [ - ("White", 255, 255, 255), - ("Gray 80", 230, 230, 230), - ("Gray 65", 209, 209, 209), - ("Gray 50", 186, 186, 186), - ("Gray 35", 158, 158, 158), - ("Dark Skin", 115, 82, 66), - ("Light Skin", 194, 150, 130), - ("Blue Sky", 94, 122, 156), - ("Foliage", 89, 107, 66), - ("Blue Flower", 130, 128, 176), - ("Bluish Green", 99, 189, 168), - ("Orange", 217, 120, 41), - ("Purplish Blue", 74, 92, 163), - ("Moderate Red", 194, 84, 97), - ("Purple", 92, 61, 107), - ("Yellow Green", 158, 186, 64), - ("Orange Yellow", 230, 161, 46), - ("Blue (Legacy)", 51, 61, 150), - ("Green (Legacy)", 71, 148, 71), - ("Red (Legacy)", 176, 48, 59), - ("Yellow (Legacy)", 237, 199, 33), - ("Magenta (Legacy)", 186, 84, 145), - ("Cyan (Legacy)", 0, 133, 163), - ("100% Red", 255, 0, 0), - ("100% Green", 0, 255, 0), - ("100% Blue", 0, 0, 255), - ("100% Cyan", 0, 255, 255), - ("100% Magenta", 255, 0, 255), - ("100% Yellow", 255, 255, 0), + ("White", *_rgb(255, 255, 255)), + ("Gray 80", *_rgb(230, 230, 230)), + ("Gray 65", *_rgb(209, 209, 209)), + ("Gray 50", *_rgb(186, 186, 186)), + ("Gray 35", *_rgb(158, 158, 158)), + ("Dark Skin", *_rgb(115, 82, 66)), + ("Light Skin", *_rgb(194, 150, 130)), + ("Blue Sky", *_rgb(94, 122, 156)), + ("Foliage", *_rgb(89, 107, 66)), + ("Blue Flower", *_rgb(130, 128, 176)), + ("Bluish Green", *_rgb(99, 189, 168)), + ("Orange", *_rgb(217, 120, 41)), + ("Purplish Blue", *_rgb(74, 92, 163)), + ("Moderate Red", *_rgb(194, 84, 97)), + ("Purple", *_rgb(92, 61, 107)), + ("Yellow Green", *_rgb(158, 186, 64)), + ("Orange Yellow", *_rgb(230, 161, 46)), + ("Blue (Legacy)", *_rgb(51, 61, 150)), + ("Green (Legacy)", *_rgb(71, 148, 71)), + ("Red (Legacy)", *_rgb(176, 48, 59)), + ("Yellow (Legacy)", *_rgb(237, 199, 33)), + ("Magenta (Legacy)", *_rgb(186, 84, 145)), + ("Cyan (Legacy)", *_rgb(0, 133, 163)), + ("100% Red", *_rgb(255, 0, 0)), + ("100% Green", *_rgb(0, 255, 0)), + ("100% Blue", *_rgb(0, 0, 255)), + ("100% Cyan", *_rgb(0, 255, 255)), + ("100% Magenta", *_rgb(255, 0, 255)), + ("100% Yellow", *_rgb(255, 255, 0)), ] diff --git a/app/ucd/__init__.py b/app/ucd/__init__.py index 283ef4e..02fbd40 100644 --- a/app/ucd/__init__.py +++ b/app/ucd/__init__.py @@ -13,6 +13,20 @@ from app.ucd.device import ( list_devices, ) from app.ucd.service import PatternService, PatternSession, SignalService +from app.solid_color_scale import ( + SOLID_COLOR_MAX, + SOLID_COLOR_MIN, + SOLID_COLOR_MIN_BPC, + byte_to_solid_color, + clamp_solid_color, + ensure_solid_color_pattern, + migrate_pattern_params, + pack_solid_color_hw_registers, + pct_to_solid_color, + solid_color_rgb, + solid_color_to_byte, + solid_color_to_pct, +) __all__ = [ "SignalService", @@ -25,4 +39,15 @@ __all__ = [ "EventBus", "ConnectionChanged", "DeviceKind", + "SOLID_COLOR_MIN", + "SOLID_COLOR_MAX", + "SOLID_COLOR_MIN_BPC", + "clamp_solid_color", + "pct_to_solid_color", + "solid_color_to_pct", + "solid_color_to_byte", + "byte_to_solid_color", + "solid_color_rgb", + "migrate_pattern_params", + "ensure_solid_color_pattern", ] diff --git a/app/ucd/device.py b/app/ucd/device.py index ad20b2b..bc8f157 100644 --- a/app/ucd/device.py +++ b/app/ucd/device.py @@ -43,6 +43,8 @@ from app.ucd.domain import ( is_ycbcr, ) from app.ucd.enum import UCDEnum +from app.ucd.pg_status import read_pattern_generator_status_lines +from app.solid_color_scale import SOLID_COLOR_MIN_BPC, clamp_solid_color, pack_solid_color_hw_registers log = logging.getLogger(__name__) _DEVICE_LOCK_TIMEOUT_SECONDS = 8.0 @@ -108,6 +110,7 @@ class _UcdSdkBackend: self.current_pattern_params = None self.current_pattern_index = 0 self.last_error = None + self._solid_color_stream_ready = False def search_device(self): """搜索可用设备""" @@ -213,6 +216,10 @@ class _UcdSdkBackend: self.current_pattern_params = None self.current_pattern_index = 0 self.current_interface = "HDMI" + self._solid_color_stream_ready = False + + def _invalidate_solid_color_stream(self) -> None: + self._solid_color_stream_ready = False def _force_cleanup(self): """强制清理所有状态""" @@ -440,6 +447,7 @@ class _UcdSdkBackend: pg, _ = self.get_tx_modules() log.info("UcdSdk.set_video_mode calling pg.set_vm()") pg.set_vm(vm=video_mode) + self._invalidate_solid_color_stream() self._stop_audio_output() log.info("UcdSdk.set_video_mode done") self._last_sent_config = current_config @@ -681,6 +689,104 @@ class _UcdSdkBackend: log.warning("UcdSdk.set_pattern_params unsupported pattern=%s", getattr(pattern, "name", pattern)) return False + def _solid_color_pattern_ids(self): + return { + UCDEnum.VideoPatternInfo.VideoPatternParams.SolidColor, + UCDEnum.VideoPatternInfo.VideoPattern.SolidColor, + } + + def _is_solid_color_pattern(self, pattern) -> bool: + return pattern in self._solid_color_pattern_ids() + + def _write_pg_solid_color_registers(self, pg, rgb_list) -> None: + """直接写入 SolidColor 硬件寄存器(65535 刻度,无 SDK 左移)。""" + from ctypes import c_uint + + from UniTAP.libs.lib_tsi import ci + + param0, param1 = pack_solid_color_hw_registers(rgb_list) + pg._become_active() + pg._PatternGenerator__io.set(ci.TSI_PG_PREDEF_PATTERN_PARAM_1, param0, c_uint) + pg._PatternGenerator__io.set(ci.TSI_PG_PREDEF_PATTERN_PARAM_2, param1, c_uint) + log.info( + "UcdSdk._write_pg_solid_color_registers rgb=%s hw=(0x%08X, 0x%08X)", + rgb_list, + param0, + param1, + ) + + def apply_solid_color(self, rgb, *, force_reapply: bool = False) -> bool: + """发送 SolidColor,顺序对齐 ``work_with_solid_color.py``。 + + 首次(或信号格式变化后):``set_pattern → set_vm → apply() → set_pattern_params``。 + 同一会话内后续仅 ``set_pattern_params``,避免重复 ``apply()`` 导致 HDMI 重锁、 + 灰阶测量出现锯齿形 Gamma 曲线。 + """ + if not self.status or not self.role: + return False + + try: + pg, _ = self.get_tx_modules() + pattern = UCDEnum.VideoPatternInfo.get_video_pattern("solidcolor") + if pattern is None: + log.error("UcdSdk.apply_solid_color failed: solidcolor pattern not found") + return False + + rgb_list = [clamp_solid_color(v) for v in list(rgb)[:3]] + if len(rgb_list) < 3: + log.error("UcdSdk.apply_solid_color invalid rgb=%s", rgb) + return False + + if self.current_timing is None: + self.current_timing = self._resolve_timing(pg) + if self.current_timing is None: + log.error("UcdSdk.apply_solid_color current_timing is None") + self.last_error = "current_timing is None" + return False + + bpc_bumped = False + if self.color_info.bpc < SOLID_COLOR_MIN_BPC: + log.info( + "UcdSdk.apply_solid_color bump bpc %s -> %s for SolidColor video mode", + self.color_info.bpc, + SOLID_COLOR_MIN_BPC, + ) + self.color_info.bpc = SOLID_COLOR_MIN_BPC + bpc_bumped = True + + self.current_pattern = pattern + self.current_pattern_param = UniTAP.SolidColorParams( + first=rgb_list[0], + second=rgb_list[1], + third=rgb_list[2], + ) + + need_full_setup = ( + force_reapply + or bpc_bumped + or not self._solid_color_stream_ready + ) + + if need_full_setup: + log.info("UcdSdk.apply_solid_color full setup rgb=%s", rgb_list) + pg.set_pattern(pattern=pattern) + self.set_video_mode() + if not self._apply_pg_output(pg): + log.error("UcdSdk.apply_solid_color pg.apply failed") + self._invalidate_solid_color_stream() + return False + self._solid_color_stream_ready = True + else: + log.info("UcdSdk.apply_solid_color params-only rgb=%s", rgb_list) + + self._write_pg_solid_color_registers(pg, rgb_list) + log.info("UcdSdk.apply_solid_color done") + return True + except Exception: + log.exception("UcdSdk.apply_solid_color exception") + self._invalidate_solid_color_stream() + return False + def apply_pattern(self): """应用当前pattern""" if self.current_pattern is not None: @@ -715,6 +821,7 @@ class _UcdSdkBackend: def set_ucd_params(self, config): """设置UCD323参数""" + self._invalidate_solid_color_stream() self.last_error = None self.config = config test_type = self.config.current_test_type @@ -799,6 +906,10 @@ class _UcdSdkBackend: log.error("UcdSdk.send_current_pattern_params failed: set_pattern returned False") return False + if self._is_solid_color_pattern(self.current_pattern): + log.info("UcdSdk.send_current_pattern_params using apply_solid_color") + return self.apply_solid_color(pattern_params) + log.info("UcdSdk.send_current_pattern_params calling run()") self.run() log.info("UcdSdk.send_current_pattern_params done") @@ -814,12 +925,7 @@ class _UcdSdkBackend: try: log.info("UcdSdk.send_solid_rgb_pattern rgb=%s", rgb) - self.current_pattern = UCDEnum.VideoPatternInfo.get_video_pattern("solidcolor") - if self.current_pattern is None: - log.error("UcdSdk.send_solid_rgb_pattern failed: solidcolor pattern not found") - return False - - return self.send_current_pattern_params(list(rgb)) + return self.apply_solid_color(list(rgb)) except Exception: log.exception("UcdSdk.send_solid_rgb_pattern exception") return False @@ -839,6 +945,20 @@ class _UcdSdkBackend: except Exception: return False + def get_pattern_generator_status_lines( + self, + pattern_hint: str | None = None, + ) -> list[str]: + """读取 PG 当前状态,格式对齐 UCD Console 的 Pattern Generator Status。""" + if not self.status or not self.role: + return [] + try: + pg, _ = self.get_tx_modules() + return read_pattern_generator_status_lines(pg, pattern_hint=pattern_hint) + except Exception: + log.exception("UcdSdk.get_pattern_generator_status_lines failed") + return [] + # ─── §3 DeviceInfo / list_devices ──────────────────────────────── @@ -1244,6 +1364,13 @@ class UCD323Device(IUcdDevice): self._state = UcdState.APPLIED return ok + def get_pattern_generator_status_lines( + self, + pattern_hint: str | None = None, + ) -> list[str]: + with self._acquire_device_lock("get_pattern_generator_status_lines"): + return self._sdk.get_pattern_generator_status_lines(pattern_hint=pattern_hint) + # --- 内部 --- def _apply_dynamic_range(self, signal: SignalFormat) -> None: @@ -1262,6 +1389,13 @@ class UCD323Device(IUcdDevice): raise UcdConfigError("IMAGE pattern 必须提供 image_path") return bool(self._sdk.send_image_pattern(pattern.image_path)) + if pattern.kind is PatternKind.SOLID: + if pattern.solid_rgb is None: + raise UcdConfigError("SOLID pattern 必须提供 solid_rgb") + if not self._sdk.apply_solid_color(list(pattern.solid_rgb)): + raise UcdApplyFailed("apply_solid_color 返回 False") + return True + # 预定义图案路径:复用 controller.set_pattern + run() video_pattern = UCDEnum.VideoPatternInfo.get_video_pattern(pattern.kind.value) @@ -1270,11 +1404,7 @@ class UCD323Device(IUcdDevice): self._sdk.current_pattern = video_pattern params: list[int] | None = None - if pattern.kind is PatternKind.SOLID: - if pattern.solid_rgb is None: - raise UcdConfigError("SOLID pattern 必须提供 solid_rgb") - params = list(pattern.solid_rgb) - elif pattern.extras: + if pattern.extras: params = list(pattern.extras) if not self._sdk.set_pattern(video_pattern, params): diff --git a/app/ucd/pg_status.py b/app/ucd/pg_status.py new file mode 100644 index 0000000..9853ff3 --- /dev/null +++ b/app/ucd/pg_status.py @@ -0,0 +1,111 @@ +"""从 UniTAP Pattern Generator 读取状态并格式化为日志行。""" +from __future__ import annotations + +import logging +import os +import re +from typing import Any + +import UniTAP +from UniTAP.common import ColorInfo + +log = logging.getLogger(__name__) + + +def _enum_display_name(name: str) -> str: + spaced = re.sub(r"([a-z])([A-Z])", r"\1 \2", name) + return spaced.replace("_", " ") + + +def _format_pattern_name(pg, pattern_hint: str | None = None) -> str: + if pattern_hint: + return pattern_hint + + pattern = getattr(pg, "_pattern", None) + if isinstance(pattern, UniTAP.VideoPattern): + return _enum_display_name(pattern.name) + if isinstance(pattern, str): + return os.path.basename(pattern) or "Image File" + + pattern_id = getattr(pg, "_pattern_id", None) + if pattern_id is not None: + return _enum_display_name(getattr(pattern_id, "name", str(pattern_id))) + return "—" + + +def _format_cef(ci: ColorInfo) -> str: + fmt_map = { + ColorInfo.ColorFormat.CF_RGB: "RGB", + ColorInfo.ColorFormat.CF_YCbCr_422: "YCbCr 4:2:2", + ColorInfo.ColorFormat.CF_YCbCr_444: "YCbCr 4:4:4", + ColorInfo.ColorFormat.CF_YCbCr_420: "YCbCr 4:2:0", + ColorInfo.ColorFormat.CF_Y_ONLY: "Y only", + ColorInfo.ColorFormat.CF_RAW: "RAW", + ColorInfo.ColorFormat.CF_DSC: "DSC", + } + fmt = fmt_map.get( + ci.color_format, + ci.color_format.name.replace("CF_", "").replace("_", " "), + ) + + if ci.color_format == ColorInfo.ColorFormat.CF_RGB: + range_str = ( + "Legacy RGB mode" + if ci.dynamic_range == ColorInfo.DynamicRange.DR_VESA + else "Limited range" + ) + elif ci.dynamic_range == ColorInfo.DynamicRange.DR_VESA: + range_str = "Full range" + else: + range_str = "Limited range" + return f"{fmt}/{range_str}" + + +def _max_bitrate_gbps(pg, bpp: int) -> float: + caps = pg._caps() + effective_bpp = bpp if bpp > 0 else 24 + return round(caps.max_pix_rate * effective_bpp / 1_000_000_000.0, 3) + + +def collect_pattern_generator_status( + pg, + *, + pattern_hint: str | None = None, +) -> dict[str, Any]: + vm = pg.get_stream_video_mode() + pg_status = pg.status() + ci = vm.color_info + + refresh_hz = vm.timing.frame_rate / 1000.0 if vm.timing.frame_rate else 0.0 + timing = f"{vm.timing.hactive} x {vm.timing.vactive} @ {refresh_hz:g}Hz" + current_br = vm.bit_rate + max_br = _max_bitrate_gbps(pg, ci.bpp) + + return { + "total_bitrate": f"{current_br:.3f} / {max_br:.3f} Gbps", + "timing": timing, + "pattern": _format_pattern_name(pg, pattern_hint), + "cef": _format_cef(ci), + "bpc": str(ci.bpc), + "status": str(pg_status.error), + } + + +def format_pattern_generator_status_log(status: dict[str, Any]) -> list[str]: + return [ + f"Total bitrate: {status['total_bitrate']}", + f" Timing: {status['timing']}", + f" Pattern: {status['pattern']}", + f" CEF: {status['cef']}", + f" BPC: {status['bpc']}", + f" Status: {status['status']}", + ] + + +def read_pattern_generator_status_lines( + pg, + *, + pattern_hint: str | None = None, +) -> list[str]: + status = collect_pattern_generator_status(pg, pattern_hint=pattern_hint) + return format_pattern_generator_status_log(status) diff --git a/app/ucd/service.py b/app/ucd/service.py index aa16309..e0d5fea 100644 --- a/app/ucd/service.py +++ b/app/ucd/service.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging +import os from app.ucd.domain import ( Colorimetry, @@ -33,6 +34,21 @@ class SignalService: def __init__(self, device: IUcdDevice, bus: EventBus): self._dev = device self._bus = bus + self._pg_status_log_callback = None + + def set_pg_status_log_callback(self, callback) -> None: + """设置 PG 状态日志回调:``callback(lines: list[str]) -> None``。""" + self._pg_status_log_callback = callback + + def _notify_pattern_sent(self, pattern_hint: str | None = None) -> None: + if not self._pg_status_log_callback: + return + try: + lines = self.get_pattern_generator_status_lines(pattern_hint) + if lines: + self._pg_status_log_callback(lines) + except Exception: + log.exception("SignalService._notify_pattern_sent failed") # -- 高层接口 ------------------------------------------------ @@ -59,17 +75,30 @@ class SignalService: self._dev.apply() return changed - def send_pattern(self, pattern: PatternSpec) -> None: + def send_pattern( + self, + pattern: PatternSpec, + *, + pattern_hint: str | None = None, + ) -> None: """在已 configure 的信号上仅更新图案后 apply。""" log.info("SignalService.send_pattern pattern=%s", pattern.kind.value) self._dev.set_pattern(pattern) self._dev.apply() + self._notify_pattern_sent(pattern_hint) def send_solid_rgb(self, rgb: tuple[int, int, int] | list[int]) -> None: - self.send_pattern(solid_rgb_pattern(rgb)) + rgb_list = list(rgb) + self.send_pattern( + solid_rgb_pattern(rgb_list), + pattern_hint=f"Solid Color {rgb_list}", + ) def send_image(self, path: str) -> None: - self.send_pattern(image_pattern(path)) + self.send_pattern( + image_pattern(path), + pattern_hint=os.path.basename(path) or "Image File", + ) def update_signal_format( self, @@ -127,9 +156,26 @@ class SignalService: """按 :class:`PQConfig` 写入色彩 / Timing / 当前 Pattern(不 apply 输出)。""" return bool(self._dev.set_ucd_params(config)) - def send_pattern_params(self, params) -> bool: + def send_pattern_params( + self, + params, + *, + pattern_hint: str | None = None, + ) -> bool: """以 ``params`` 更新当前 pattern 的参数并 apply。""" - return bool(self._dev.send_current_pattern_params(params)) + ok = bool(self._dev.send_current_pattern_params(params)) + if ok: + self._notify_pattern_sent(pattern_hint) + return ok + + def get_pattern_generator_status_lines( + self, + pattern_hint: str | None = None, + ) -> list[str]: + getter = getattr(self._dev, "get_pattern_generator_status_lines", None) + if not callable(getter): + return [] + return list(getter(pattern_hint) or []) def apply_and_run(self, config, pattern_params) -> bool: """``set_ucd_params`` + ``set_pattern`` + ``run`` 的复合操作。""" @@ -380,7 +426,13 @@ class PatternService: raise IndexError(f"pattern 索引越界: {index}") pattern_param = session.pattern_params[index] - if not self.app.signal_service.send_pattern_params(pattern_param): + pattern_hint = None + if session.display_names and index < len(session.display_names): + pattern_hint = session.display_names[index] + if not self.app.signal_service.send_pattern_params( + pattern_param, + pattern_hint=pattern_hint, + ): raise RuntimeError(f"发送 pattern 失败: {index}") return pattern_param diff --git a/app/ucd/solid_color_scale.py b/app/ucd/solid_color_scale.py new file mode 100644 index 0000000..8b89752 --- /dev/null +++ b/app/ucd/solid_color_scale.py @@ -0,0 +1,2 @@ +"""兼容层:请改用 ``app.solid_color_scale``。""" +from app.solid_color_scale import * # noqa: F403 diff --git a/app/views/panels/calman_panel.py b/app/views/panels/calman_panel.py index 86967a2..3382b59 100644 --- a/app/views/panels/calman_panel.py +++ b/app/views/panels/calman_panel.py @@ -22,6 +22,12 @@ from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg from matplotlib.figure import Figure from app.tests.color_accuracy import calculate_delta_e_2000 +from app.solid_color_scale import ( + SOLID_COLOR_MAX, + pct_to_solid_color, + solid_color_to_byte, + solid_color_to_pct, +) from app.views.modern_styles import get_theme_palette if TYPE_CHECKING: @@ -41,10 +47,15 @@ DE_FORMULAS = ["2000", "94", "76"] def _pct_to_gray_rgb(pct: int) -> tuple[int, int, int]: - value = int(round(pct * 255 / 100)) + value = solid_color_to_byte(pct_to_solid_color(pct)) return value, value, value +def _pct_to_pattern_rgb(pct: int) -> int: + """灰阶百分比 → SolidColor 码值(0–65535)。""" + return pct_to_solid_color(pct) + + def _rgb_to_hex(rgb: tuple[int, int, int]) -> str: r, g, b = rgb return f"#{r:02x}{g:02x}{b:02x}" @@ -325,6 +336,8 @@ def create_calman_panel(self: "PQAutomationApp") -> None: self.calman_stop_event = threading.Event() self.calman_running = False self.calman_patch_send_busy = False + self.calman_rgb_session = None + self.calman_rgb_session_test_type = None self.calman_current_level = None self.calman_last_record = None self.calman_last_step_seconds = None @@ -728,6 +741,29 @@ def toggle_calman_panel(self: "PQAutomationApp") -> None: # --------------------------------------------------------------------------- +def _get_calman_rgb_session(self: "PQAutomationApp", test_type: str): + """复用 Calman 发图 session,避免每次点击都重新 stage profile。""" + cached = getattr(self, "calman_rgb_session", None) + if cached is not None and getattr(self, "calman_rgb_session_test_type", None) == test_type: + return cached + if not hasattr(self, "pattern_service") or self.pattern_service is None: + return None + session = self.pattern_service.prepare_session( + "rgb", + test_type=test_type, + log_details=False, + ) + self.calman_rgb_session = session + self.calman_rgb_session_test_type = test_type + _calman_log(self, f"calman rgb session prepared test_type={test_type}") + return session + + +def _clear_calman_rgb_session(self: "PQAutomationApp") -> None: + self.calman_rgb_session = None + self.calman_rgb_session_test_type = None + + def send_patch(self: "PQAutomationApp", pct: int) -> None: """点击色块时,发送对应灰阶图案到信号发生器。""" if not self.signal_service.is_connected: @@ -738,7 +774,7 @@ def send_patch(self: "PQAutomationApp", pct: int) -> None: self.calman_status_var.set("发送进行中,请稍候...") return - rgb_val = int(round(pct * 255 / 100)) + rgb_val = _pct_to_pattern_rgb(pct) self.calman_current_level = pct self.calman_status_var.set(f"发送 {pct}%(RGB={rgb_val})...") _highlight_patch(self, pct) @@ -751,8 +787,10 @@ def send_patch(self: "PQAutomationApp", pct: int) -> None: _calman_log(self, f"send_solid_rgb start pct={pct}") test_type = getattr(self.config, "current_test_type", "screen_module") if hasattr(self, "pattern_service") and self.pattern_service is not None: + rgb_session = _get_calman_rgb_session(self, test_type) self.pattern_service.send_rgb( (rgb_val, rgb_val, rgb_val), + session=rgb_session, test_type=test_type, ) else: @@ -902,11 +940,7 @@ def start_sequence_test(self: "PQAutomationApp") -> None: test_type = getattr(self.config, "current_test_type", "screen_module") rgb_session = None if hasattr(self, "pattern_service") and self.pattern_service is not None: - rgb_session = self.pattern_service.prepare_session( - "rgb", - test_type=test_type, - log_details=False, - ) + rgb_session = _get_calman_rgb_session(self, test_type) _calman_log(self, f"sequence ucd profile applied test_type={test_type}") order = sorted(self.calman_levels, reverse=True) @@ -918,7 +952,7 @@ def start_sequence_test(self: "PQAutomationApp") -> None: self._dispatch_ui(self.log_gui.log, "已停止连续测试", "warning") break step_t0 = time.perf_counter() - rgb_val = int(round(pct * 255 / 100)) + rgb_val = _pct_to_pattern_rgb(pct) self._dispatch_ui( self.calman_status_var.set, f"[{i}/{total}] 发送 {pct}%" ) diff --git a/app/views/panels/gamma_pattern_panel.py b/app/views/panels/gamma_pattern_panel.py index 72e1311..2412cf6 100644 --- a/app/views/panels/gamma_pattern_panel.py +++ b/app/views/panels/gamma_pattern_panel.py @@ -24,6 +24,12 @@ from typing import TYPE_CHECKING import ttkbootstrap as ttk from app.pq import pq_config +from app.solid_color_scale import ( + SOLID_COLOR_MAX, + pct_to_solid_color, + solid_color_to_byte, + solid_color_to_pct, +) if TYPE_CHECKING: from pqAutomationApp import PQAutomationApp @@ -56,19 +62,21 @@ def _gray_pct_of(rgb) -> str: r = int(rgb[0]) except Exception: return "" - return str(int(round(r / 255 * 100))) + return str(int(round(solid_color_to_pct(r)))) def _hex_of(rgb) -> str: try: - r, g, b = int(rgb[0]), int(rgb[1]), int(rgb[2]) + r = solid_color_to_byte(rgb[0]) + g = solid_color_to_byte(rgb[1]) + b = solid_color_to_byte(rgb[2]) except Exception: return "" return f"#{r:02X}{g:02X}{b:02X}" def _luminance(rgb) -> float: - r, g, b = rgb + r, g, b = solid_color_to_byte(rgb[0]), solid_color_to_byte(rgb[1]), solid_color_to_byte(rgb[2]) return 0.2126 * r + 0.7152 * g + 0.0722 * b @@ -99,7 +107,7 @@ def _gen_even(n: int) -> list[list[int]]: out = [] for i in range(n): pct = 100.0 - (100.0 / (n - 1)) * i - v = int(round(pct / 100.0 * 255)) + v = pct_to_solid_color(pct) out.append([v, v, v]) return out @@ -118,7 +126,7 @@ def _gen_pq(n: int) -> list[list[int]]: else: Lm1 = L ** m1 v_pq = ((c1 + c2 * Lm1) / (1.0 + c3 * Lm1)) ** m2 - v = int(round(max(0.0, min(1.0, v_pq)) * 255)) + v = int(round(max(0.0, min(1.0, v_pq)) * SOLID_COLOR_MAX)) out.append([v, v, v]) return out @@ -129,7 +137,7 @@ def _gen_gamma(n: int, gamma: float = 2.2) -> list[list[int]]: out = [] for i in range(n): lin = 1.0 - i / (n - 1) - v = int(round((lin ** (1.0 / gamma)) * 255)) + v = int(round((lin ** (1.0 / gamma)) * SOLID_COLOR_MAX)) out.append([v, v, v]) return out @@ -761,8 +769,8 @@ def _parse_rgb_from_form(self: "PQAutomationApp"): except ValueError: messagebox.showerror("输入错误", "R/G/B 必须为整数") return None - if not all(0 <= v <= 255 for v in (r, g, b)): - messagebox.showerror("输入错误", "R/G/B 必须在 0~255 范围内") + if not all(0 <= v <= SOLID_COLOR_MAX for v in (r, g, b)): + messagebox.showerror("输入错误", f"R/G/B 必须在 0~{SOLID_COLOR_MAX} 范围内") return None return [r, g, b] @@ -775,7 +783,7 @@ def _fill_rgb_from_pct(self: "PQAutomationApp"): messagebox.showerror("输入错误", "灰度 % 必须为数字") return pct = max(0.0, min(100.0, pct)) - v = int(round(pct / 100.0 * 255)) + v = pct_to_solid_color(pct) self._gamma_edit_r_var.set(str(v)) self._gamma_edit_g_var.set(str(v)) self._gamma_edit_b_var.set(str(v)) @@ -904,7 +912,7 @@ def _paste_from_clipboard(self: "PQAutomationApp"): if s.endswith("%"): try: pct = float(s.rstrip("%")) - v = int(round(max(0, min(100, pct)) / 100.0 * 255)) + v = pct_to_solid_color(max(0, min(100, pct))) rows.append([v, v, v]) continue except ValueError: @@ -914,7 +922,7 @@ def _paste_from_clipboard(self: "PQAutomationApp"): if len(parts) == 1: try: v = int(parts[0]) - if 0 <= v <= 255: + if 0 <= v <= SOLID_COLOR_MAX: rows.append([v, v, v]) continue except ValueError: @@ -929,7 +937,7 @@ def _paste_from_clipboard(self: "PQAutomationApp"): except ValueError: errors.append(f"第 {ln_no} 行非整数:{s}") continue - if not all(0 <= v <= 255 for v in (r, g, b)): + if not all(0 <= v <= SOLID_COLOR_MAX for v in (r, g, b)): errors.append(f"第 {ln_no} 行越界:{s}") continue rows.append([r, g, b]) @@ -980,7 +988,7 @@ def _run_validation(self: "PQAutomationApp"): bad = [ i for i, rgb in enumerate(params) - if not all(0 <= int(v) <= 255 for v in rgb) + if not all(0 <= int(v) <= SOLID_COLOR_MAX for v in rgb) ] if bad: msgs.append(f"⚠ 越界点 (索引): {bad}") diff --git a/app/views/panels/pantone_baseline_panel.py b/app/views/panels/pantone_baseline_panel.py index a58bdc9..16b52ab 100644 --- a/app/views/panels/pantone_baseline_panel.py +++ b/app/views/panels/pantone_baseline_panel.py @@ -9,9 +9,14 @@ import threading import tkinter as tk from tkinter import filedialog, messagebox +from typing import TYPE_CHECKING + import ttkbootstrap as ttk -from typing import TYPE_CHECKING +from app.solid_color_scale import ( + SOLID_COLOR_MAX, + byte_to_solid_color, +) if TYPE_CHECKING: from pqAutomationApp import PQAutomationApp @@ -196,8 +201,12 @@ def _load_patterns(self: "PQAutomationApp"): continue if r is None or g is None or b is None: continue - if min(r, g, b) < 0 or max(r, g, b) > 255: + if min(r, g, b) < 0 or max(r, g, b) > SOLID_COLOR_MAX: continue + if max(r, g, b) <= 255: + r = byte_to_solid_color(r) + g = byte_to_solid_color(g) + b = byte_to_solid_color(b) patterns.append((r, g, b)) finally: wb.close() diff --git a/docs/UCD-API文档/examples/work_with_packet_generation.py b/docs/UCD-API文档/examples/work_with_packet_generation.py index 71c3c0c..1303e49 100644 --- a/docs/UCD-API文档/examples/work_with_packet_generation.py +++ b/docs/UCD-API文档/examples/work_with_packet_generation.py @@ -13,15 +13,15 @@ lUniTAP = UniTAP.TsiLib() # # For opening device, please, put serial number of the device as 8 symbol str or put index of device. # -dev = lUniTAP.open("NNNNNNNN") +dev = lUniTAP.open(0) # After opening device as in UCD Console device role should be selected. -role = dev.select_role(UniTAP.dev.UCD422.HDMISourceHDMISink) -# role = dev.select_role(UniTAP.dev.UCD323.HDMISource) -role.hdtx.sdpg.add_packet(bytearray([0x81, 0x01, 0x1B, 0x16, 0x8B, 0x84, 0x90, 0x40, 0x00, 0x00, 0x06, 0x64, 0x00, 0x00, 0x00, 0x00, - 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00])) -role.hdtx.sdpg.load_packet("PATH_TO_PACKET_FILE") +# role = dev.select_role(UniTAP.dev.UCD422.HDMISourceHDMISink) +role = dev.select_role(UniTAP.dev.UCD323.HDMISource) +# role.hdtx.sdpg.add_packet(bytearray([0x81, 0x01, 0x1B, 0x16, 0x8B, 0x84, 0x90, 0x40, 0x00, 0x00, 0x06, 0x64, 0x00, 0x00, 0x00, 0x00, +# 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +# 0x00, 0x00, 0x00, 0x00])) +role.hdtx.sdpg.load_packet("UniTAP/dev/modules/dut_tests/cfg/hdr10/vsif1.bin") role.hdtx.sdpg.stop() diff --git a/docs/UCD-API文档/examples/work_with_solid_color.py b/docs/UCD-API文档/examples/work_with_solid_color.py index a4e73ef..94ed58f 100644 --- a/docs/UCD-API文档/examples/work_with_solid_color.py +++ b/docs/UCD-API文档/examples/work_with_solid_color.py @@ -13,8 +13,8 @@ lUniTAP = UniTAP.TsiLib() # # For opening device, please, put serial number of the device as 8 symbol str or put index of device. # -# dev = lUniTAP.open(0) # Open first device -dev = lUniTAP.open("NNNNNNNN") # Put your device serial number here +dev = lUniTAP.open(0) # Open first device +# dev = lUniTAP.open("NNNNNNNN") # Put your device serial number here # After opening device as in UCD Console device role should be selected. role = dev.select_role(UniTAP.dev.UCD323.HDMISource) @@ -26,10 +26,12 @@ role.hdtx.pg.set_pattern(pattern=UniTAP.VideoPattern.SolidColor) role.hdtx.pg.set_vm(vm=video_mode) role.hdtx.pg.apply() -for i in range(1024): - print(f"Set color: {hex(i)} {hex(i)} {hex(i)}") - role.hdtx.pg.set_pattern_params(pattern_params=UniTAP.SolidColorParams(i, i, i)) - time.sleep(0.01) +# for i in range(1024): +# print(f"Set color: {hex(i)} {hex(i)} {hex(i)}") +# role.hdtx.pg.set_pattern_params(pattern_params=UniTAP.SolidColorParams(i, i, i)) +# time.sleep(0.01) +print(f"Set color: {hex(0)} {hex(65535)} {hex(65535)}") +role.hdtx.pg.set_pattern_params(pattern_params=UniTAP.SolidColorParams(0, 65535, 65535)) # # Since the 3.5 version, TsiLib and TSIDevice objects have the option to be closed earlier. diff --git a/hdr10_demo.py b/hdr10_demo.py new file mode 100644 index 0000000..599509e --- /dev/null +++ b/hdr10_demo.py @@ -0,0 +1,1464 @@ +#!/usr/bin/env python3 +""" +HDR10 信号输出演示(UCD-323 HDMI Source)。 + +合并官方示例 work_with_solid_color.py(PG 白场)与 +work_with_packet_generation.py(sdpg InfoFrame),并补充 HDR10 必需的 +DRM (0x87) 与 AVI BT.2020 (0x82) 包。 + +用法(在项目根目录):: + + python hdr10_demo.py + python hdr10_demo.py --device 0 --vic 16 --hold 60 + python hdr10_demo.py --sdr # SDR 对照组(亮度不会升高) + python hdr10_demo.py --no-vsif # 仅 AVI + DRM + python hdr10_demo.py --output image # PQ 编码白场图(默认,测峰值亮度推荐) + python hdr10_demo.py --output solid # 旧 SolidColor 路径 + python hdr10_demo.py --peak-nits 1000 --max-cll 1000 + python hdr10_demo.py --image path/to/pq_white.png + python hdr10_demo.py --verify-link # 打印 TX InfoFrame 回读与信令/亮度分层结论(默认开启) + python hdr10_demo.py --no-verify-link + +验收:脚本保持白场输出后,用 CA-410 测 DUT 中心亮度。 + HDR10 成功时峰值应显著高于 SDR(通常 400–1000+ nits,视电视能力而定)。 + 运行前请关闭 pqAutomationApp,避免 UCD 设备被占用。 +""" + +from __future__ import annotations + +import argparse +import math +import sys +import time +from contextlib import contextmanager, nullcontext +from ctypes import c_byte, c_uint32 +from pathlib import Path + +from PIL import Image + +import UniTAP +import UniTAP.libs.lib_tsi.tsi_types as tsi_ci +from UniTAP.common import ColorInfo +from UniTAP.dev.modules import MemoryManager + + +def log_step(msg: str) -> None: + print(msg, flush=True) + + +def get_memory_manager(role) -> MemoryManager: + return role.hdtx.sdpg._PacketGenerator__memory_manager # noqa: SLF001 + + +def ensure_sdpg_memory(role) -> int: + """ + 在 PG 出图前预分配 MO_Events 内存块。 + + sdpg.start() 在 MO_Events 未分配时会调用 make_default(); + 若此时 PG 已在输出,重置显存布局会导致原生层崩溃、进程静默退出。 + """ + mm = get_memory_manager(role) + layout = mm.get_memory_layout() + need_default = ( + len(layout) <= MemoryManager.MemoryOwner.MO_Events.value + or layout[MemoryManager.MemoryOwner.MO_Events.value] == 0 + ) + if need_default: + log_step(" [内存] 预分配默认布局(PG 出图前,含 MO_Events)...") + mm.make_default() + layout = mm.get_memory_layout() + events_sz = ( + layout[MemoryManager.MemoryOwner.MO_Events.value] + if len(layout) > MemoryManager.MemoryOwner.MO_Events.value + else 0 + ) + log_step(f" [内存] layout={layout}, MO_Events={events_sz} bytes") + return events_sz + + +def calculate_pg_frame_block_size(width: int, height: int) -> int: + """对齐 UniTAP PatternGenerator.__calculate_frame_size。""" + width = int(width) + height = int(height) + + def aligned(value: int, alignment: int) -> int: + return ((value + alignment - 1) // alignment) * alignment + + line_stride_bytes = width * 4 + line_stride_aligned_bytes = aligned(line_stride_bytes, 1024) + line_stride_bytes2 = width * 2 + line_stride_aligned_bytes2 = aligned(line_stride_bytes2, 1024) + return int((line_stride_aligned_bytes + line_stride_aligned_bytes2) * height) + + +@contextmanager +def patch_image_memory_layout(role, events_bytes: int): + """ + 拦截 ImageFile apply 内的 set_memory_layout([pg_block])。 + + UniTAP 会把布局收成单块导致 sdpg 无 MO_Events;在 apply 当下扩展为 + [PG, AG, MO_Events] 可避免事后再次改 layout(会黑屏)。 + """ + mm = get_memory_manager(role) + prior = list(mm.get_memory_layout()) + original_set = mm.set_memory_layout + + def expanded_set(layout: list[int]) -> None: + if len(layout) == 1: + pg_block = layout[0] + ag_block = ( + prior[MemoryManager.MemoryOwner.MO_AudioGenerator.value] + if len(prior) > MemoryManager.MemoryOwner.MO_AudioGenerator.value + and prior[MemoryManager.MemoryOwner.MO_AudioGenerator.value] > 0 + else MemoryManager.DEFAULT_MEMOTY_ALLOCATION[1] + ) + ev_block = events_bytes + if ev_block <= 0 and len(prior) > MemoryManager.MemoryOwner.MO_Events.value: + ev_block = prior[MemoryManager.MemoryOwner.MO_Events.value] + if ev_block <= 0: + ev_block = 12288 + layout = [pg_block, ag_block, ev_block] + if len(prior) > 3: + layout.append(prior[3]) + log_step(f" [内存] ImageFile apply 内扩展布局: {layout}") + original_set(layout) + + mm.set_memory_layout = expanded_set # type: ignore[method-assign] + try: + yield + finally: + mm.set_memory_layout = original_set # type: ignore[method-assign] + + +def resolve_sdpg_first(args, *, use_sdpg: bool, output_mode: str) -> bool: + """SolidColor 默认先 sdpg;ImageFile 必须先 PG(apply 内扩展 layout,再启 sdpg)。""" + if not use_sdpg: + return False + if args.pg_before_sdpg: + return False + if output_mode == "image": + return False + return True + +from app.solid_color_scale import SOLID_COLOR_MAX +PACKET_PAD_BYTES = 36 + +# work_with_packet_generation.py 内联 VSIF(HDR10+,type 0x81) +OFFICIAL_HDR10PLUS_VSIF = bytearray([ + 0x81, 0x01, 0x1B, 0x16, 0x8B, 0x84, 0x90, 0x40, 0x00, 0x00, 0x06, 0x64, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]) + +ROOT = Path(__file__).resolve().parent +VSIF_PATH = ROOT / "UniTAP/dev/modules/dut_tests/cfg/hdr10/vsif1.bin" +HDR_IMAGE_DIR = ROOT / "temp_hdr10_images" + +# 常见 CTA VIC 分辨率(开设备前预生成 PNG 用;连上设备后会按实际 timing 校正) +VIC_PIXEL_SIZE: dict[int, tuple[int, int]] = { + 1: (640, 480), + 2: (720, 480), + 4: (1280, 720), + 16: (1920, 1080), + 19: (1280, 720), + 31: (1920, 1080), + 32: (1920, 1080), + 33: (1920, 1080), + 34: (1920, 1080), + 93: (3840, 2160), + 94: (3840, 2160), + 95: (3840, 2160), + 96: (3840, 2160), + 97: (3840, 2160), + 98: (3840, 2160), + 99: (3840, 2160), + 100: (3840, 2160), + 101: (3840, 2160), + 102: (3840, 2160), +} + + +def guess_vic_pixel_size(vic: int) -> tuple[int, int]: + return VIC_PIXEL_SIZE.get(vic, (1920, 1080)) + + +def is_bt2020_colorimetry(name: str) -> bool: + return name in ( + ColorInfo.Colorimetry.CM_ITUR_BT2020_RGB.name, + ColorInfo.Colorimetry.CM_ITUR_BT2020_YCbCr.name, + ) + + +def pq_oetf_from_nits(nits: float) -> float: + """ST 2084 PQ OETF:绝对亮度 (nits) → 非线性信号 V ∈ [0, 1]。""" + if nits <= 0: + return 0.0 + m1 = 2610 / 16384 + m2 = 78.84375 + c1 = 3424 / 4096 + c2 = 2413 / 128 + c3 = 2392 / 128 + y = min(nits / 10000.0, 1.0) + ym1 = pow(y, m1) + return pow((c1 + c2 * ym1) / (1.0 + c3 * ym1), m2) + + +def generate_pq_white_image( + width: int, + height: int, + *, + peak_nits: float, +) -> tuple[Path, dict]: + """ + 生成 PQ 编码全屏白场 PNG。 + + 像素值为 ST 2084 PQ 码(8bit 存储,UICL 会按 VideoMode 升到 10bit)。 + """ + width = int(width) + height = int(height) + HDR_IMAGE_DIR.mkdir(parents=True, exist_ok=True) + pq_v = pq_oetf_from_nits(peak_nits) + level = max(0, min(255, int(round(pq_v * 255.0)))) + path = HDR_IMAGE_DIR / f"pq_white_{width}x{height}_{int(peak_nits)}nits.png" + log_step(f" [图片] 生成 PQ 白场 {width}x{height} → {path.name} (RGB={level}) ...") + Image.new("RGB", (width, height), (level, level, level)).save(path, format="PNG") + meta = { + "encoding": "PQ ST2084", + "peak_nits": peak_nits, + "pq_v": pq_v, + "png_rgb": level, + "approx_10bit": int(round(pq_v * 1023)), + "width": width, + "height": height, + } + return path, meta + + +def generate_sdr_white_image(width: int, height: int, *, level: int = 255) -> Path: + width = int(width) + height = int(height) + HDR_IMAGE_DIR.mkdir(parents=True, exist_ok=True) + lv = max(0, min(255, level)) + path = HDR_IMAGE_DIR / f"sdr_white_{width}x{height}_{lv}.png" + log_step(f" [图片] 生成 SDR 白场 {width}x{height} → {path.name} ...") + Image.new("RGB", (width, height), (lv, lv, lv)).save(path, format="PNG") + return path + + +def ensure_output_image( + width: int, + height: int, + *, + hdr: bool, + peak_nits: float | None, + max_cll: int, + image_path: str | None, + solid_white_code: int, +) -> tuple[Path, dict]: + width = int(width) + height = int(height) + if image_path: + path = Path(image_path).expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(f"找不到图片: {path}") + return path, {"encoding": "user", "path": str(path), "width": width, "height": height} + + if hdr: + nits = peak_nits if peak_nits is not None else float(max_cll) + path, meta = generate_pq_white_image(width, height, peak_nits=nits) + return path, meta + + path = generate_sdr_white_image(width, height, level=min(255, solid_white_code)) + return path, { + "encoding": "SDR 8bit", + "png_rgb": min(255, solid_white_code), + "width": width, + "height": height, + } + + +def prepare_output_assets( + args, + *, + hdr_mode: bool, + output_mode: str, + infoframe_only: bool, +) -> tuple[Path | None, dict]: + """在打开 UCD 设备之前生成/校验 PNG,避免 PIL 与 TSI 驱动同进程争用导致静默崩溃。""" + if output_mode != "image": + return None, {} + + white_code = ( + args.white_code if hdr_mode or infoframe_only else min(args.white_code, 255) + ) + guess_w, guess_h = guess_vic_pixel_size(args.vic) + log_step(f" [图片] 预生成(开设备前)VIC {args.vic} 预估 {guess_w}x{guess_h} ...") + return ensure_output_image( + guess_w, + guess_h, + hdr=hdr_mode, + peak_nits=args.peak_nits, + max_cll=args.max_cll, + image_path=args.image, + solid_white_code=white_code, + ) + + +def align_output_image_to_timing( + image_path: Path | None, + image_meta: dict, + timing, + *, + hdr_mode: bool, + peak_nits: float | None, + max_cll: int, + image_arg: str | None, + solid_white_code: int, +) -> tuple[Path | None, dict]: + """设备连上后按真实 timing 校正图片尺寸(用户自定义 --image 时不重生成)。""" + if image_path is None: + return None, image_meta + if image_arg: + return image_path, image_meta + + want_w = int(timing.hactive) + want_h = int(timing.vactive) + have_w = int(image_meta.get("width", want_w)) + have_h = int(image_meta.get("height", want_h)) + if want_w == have_w and want_h == have_h: + return image_path, image_meta + + log_step( + f" [图片] timing 为 {want_w}x{want_h},与预生成 {have_w}x{have_h} 不符,重新生成 ..." + ) + return ensure_output_image( + want_w, + want_h, + hdr=hdr_mode, + peak_nits=peak_nits, + max_cll=max_cll, + image_path=None, + solid_white_code=solid_white_code, + ) + + +def xy_to_be16(value: float) -> tuple[int, int]: + """CIE xy → 大端 uint16(单位 0.00002)。""" + u = int(round(value / 0.00002)) + u = max(0, min(u, 0xFFFF)) + return (u >> 8) & 0xFF, u & 0xFF + + +def build_infoframe_packet( + frame_type: int, + version: int, + body: bytearray, + *, + pad_to: int = PACKET_PAD_BYTES, +) -> bytearray: + """构造 HDMI InfoFrame 包(header + body + 可选 padding)。""" + header = bytearray([frame_type, version, len(body), 0x00]) + header[3] = (256 - (sum(header[:3]) + sum(body)) % 256) & 0xFF + packet = header + body + if len(packet) < pad_to: + packet.extend(b"\x00" * (pad_to - len(packet))) + return packet + + +def build_avi_bt2020_packet(vic: int = 16, *, full_range: bool = True) -> bytearray: + """AVI InfoFrame(0x82):RGB / BT.2020 / Full / 指定 VIC。""" + body = bytearray(13) + body[0] = 0x00 # RGB, no scan info + body[1] = (0x03 << 6) | (0x02 << 4) # Extended colorimetry, 16:9 + q = 0x01 if full_range else 0x00 + body[2] = (0x06 << 4) | (q << 2) # BT.2020, full range + body[3] = vic & 0x7F + return build_infoframe_packet(0x82, 0x02, body) + + +def build_drm_hdr10_packet( + max_cll: int = 1000, + max_fall: int = 400, + max_master: int = 1000, + min_master_nits: float = 0.005, +) -> bytearray: + """构造 HDR10 静态 DRM InfoFrame(Type 0x87, Static Metadata Type 1)。""" + body = bytearray(26) + body[0] = 0x04 # SMPTE ST 2084 (PQ) + body[1] = 0x01 # Static Metadata Type 1 + + primaries = [ + (0.708, 0.292), # R BT.2020 + (0.170, 0.797), # G + (0.131, 0.046), # B + (0.3127, 0.3290), # W D65 + ] + for i, (x, y) in enumerate(primaries): + o = 2 + i * 4 + body[o], body[o + 1] = xy_to_be16(x) + body[o + 2], body[o + 3] = xy_to_be16(y) + + body[18:20] = [(max_master >> 8) & 0xFF, max_master & 0xFF] + min_code = int(round(min_master_nits / 0.0001)) + body[20:22] = [(min_code >> 8) & 0xFF, min_code & 0xFF] + body[22:24] = [(max_cll >> 8) & 0xFF, max_cll & 0xFF] + body[24:26] = [(max_fall >> 8) & 0xFF, max_fall & 0xFF] + + header = bytearray([0x87, 0x01, 0x1A, 0x00]) + header[3] = (256 - (sum(header[:3]) + sum(body)) % 256) & 0xFF + + packet = header + body + if len(packet) < PACKET_PAD_BYTES: + packet.extend(b"\x00" * (PACKET_PAD_BYTES - len(packet))) + return packet + + +def verify_drm_checksum(packet: bytearray) -> bool: + """校验 DRM InfoFrame checksum。""" + if len(packet) < 30 or packet[0] != 0x87: + return False + length = packet[2] + end = 4 + length + if end > len(packet): + return False + return sum(packet[0:3] + packet[4:end] + packet[3:4]) % 256 == 0 + + +def format_packet_hex(packet: bytes, bytes_per_line: int = 16) -> str: + lines = [] + for i in range(0, len(packet), bytes_per_line): + chunk = packet[i : i + bytes_per_line] + hex_part = " ".join(f"{b:02X}" for b in chunk) + lines.append(f" {i:04X}: {hex_part}") + return "\n".join(lines) + + +def parse_device_arg(value: str) -> int | str: + try: + return int(value) + except ValueError: + if len(value) != 8: + raise argparse.ArgumentTypeError( + "设备参数应为索引(整数)或 8 位序列号" + ) from None + return value + + +def wait_for_hpd(role, *, timeout: float = 10.0, interval: float = 0.5) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if role.hdtx.link.status.hpd_status: + return True + time.sleep(interval) + return False + + +def prepare_device(role) -> None: + try: + role.hdtx.sdpg.stop() + except Exception: + pass + try: + role.hdtx.pg.reset() + except Exception: + pass + try: + role.hdtx.ag.stop_generate() + except Exception: + pass + + +def resolve_timing(role, *, vic: int): + timing = role.hdtx.pg.timing_manager.get_cta(vic) + if timing is None: + raise RuntimeError(f"设备不支持 CTA VIC={vic}") + return timing + + +def build_image_video_mode(timing, *, hdr: bool, bpc: int = 10) -> UniTAP.VideoMode: + """ImageFile 专用 VideoMode(对齐 test.py build_video_mode)。""" + color = UniTAP.ColorInfo() + color.color_format = UniTAP.ColorInfo.ColorFormat.CF_RGB + color.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA + if hdr: + # UICL 仅将 CM_ITUR_BT2020_YCbCr 映射到 BT.2020;RGB 枚举会落到 Unknown 并在 convert 时崩溃 + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_YCbCr + color.bpc = max(10, bpc) + else: + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT709 + color.bpc = 8 + return UniTAP.VideoMode(timing=timing, color_info=color) + + +def build_solid_video_mode(timing, *, hdr: bool, bpc: int = 10) -> UniTAP.VideoMode: + """ + SolidColor 专用 VideoMode。 + + SDR:CM_sRGB + bpc>=10(对齐 work_with_solid_color.py) + HDR:CM_ITUR_BT2020_RGB + bpc>=10 + """ + color = UniTAP.ColorInfo() + color.color_format = UniTAP.ColorInfo.ColorFormat.CF_RGB + color.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA + color.bpc = max(10, bpc) + if hdr: + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_RGB + else: + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_sRGB + return UniTAP.VideoMode(timing=timing, color_info=color) + + +def read_pg_diagnostics(pg) -> dict: + st = pg.status() + vm = pg.get_stream_video_mode() + ci = vm.color_info + refresh_hz = vm.timing.frame_rate / 1000.0 if vm.timing.frame_rate else 0.0 + return { + "pg_error": st.error.name, + "timing": f"{vm.timing.hactive}x{vm.timing.vactive}@{refresh_hz:g}Hz", + "colorimetry": ci.colorimetry.name, + "bpc": ci.bpc, + "dynamic_range": ci.dynamic_range.name, + "color_format": ci.color_format.name, + } + + +def print_pg_diagnostics(role, *, label: str = "PG") -> dict: + diag = read_pg_diagnostics(role.hdtx.pg) + print(f" [{label}] pg_error={diag['pg_error']}, timing={diag['timing']}") + print( + f" [{label}] 读回: {diag['color_format']}, " + f"{diag['colorimetry']}, bpc={diag['bpc']}, " + f"range={diag['dynamic_range']}" + ) + return diag + + +def read_link_diagnostics(role) -> dict: + st = role.hdtx.link.status + locks = st.channel_lock + return { + "hpd": st.hpd_status, + "video": st.video_status, + "hdmi_mode": st.hdmi_mode.name, + "channel_lock": locks, + "tmds_locked": any(locks) if locks else False, + } + + +def print_link_diagnostics(role, *, label: str = "链路") -> dict: + link = read_link_diagnostics(role) + print( + f" [{label}] HPD={'High' if link['hpd'] else 'Low'}, " + f"video={'On' if link['video'] else 'Off'}, " + f"hdmi_mode={link['hdmi_mode']}, " + f"TMDS_lock={link['tmds_locked']}" + ) + return link + + +def read_sdpg_caps_raw(sdpg) -> int: + io = sdpg._PacketGenerator__io # noqa: SLF001 — 诊断用 + return io.get(tsi_ci.TSI_PACKETGEN_CAPS_R, c_uint32)[1] + + +def read_tx_infoframe(sdpg, reg: int, expected_type: int, *, count: int = 36) -> bytes | None: + """读 TX 侧 HDMI InfoFrame 回读寄存器(未完全文档化,仅供诊断)。""" + try: + io = sdpg._PacketGenerator__io # noqa: SLF001 + raw = io.get(reg, c_byte, count)[1] + if raw and len(raw) >= 4 and raw[0] == expected_type: + return bytes(raw) + except Exception: + pass + return None + + +def read_infoframe_update_flags(sdpg) -> int | None: + try: + io = sdpg._PacketGenerator__io # noqa: SLF001 + return io.get(tsi_ci.TSI_HDMI_INFOFRAME_UPDATE_FLAGS_R, c_uint32)[1] + except Exception: + return None + + +def parse_drm_on_wire(data: bytes) -> dict: + """解析 DRM InfoFrame 回读(header 4B + body)。""" + length = data[2] + body = data[4 : 4 + length] + max_cll = max_fall = None + if len(body) >= 24: + max_cll = (body[22] << 8) | body[23] + max_fall = (body[24] << 8) | body[25] if len(body) >= 26 else None + return { + "eotf": body[0] if body else -1, + "metadata_type": body[1] if len(body) > 1 else -1, + "max_cll": max_cll, + "max_fall": max_fall, + "pq": bool(body) and body[0] == 0x04, + } + + +def parse_avi_on_wire(data: bytes) -> dict: + """解析 AVI InfoFrame 回读。""" + body = data[4 : 4 + data[2]] if len(data) >= 4 else b"" + ext_color = (body[2] >> 4) & 0x0F if len(body) > 2 else -1 + vic = body[3] & 0x7F if len(body) > 3 else -1 + full_range = ((body[2] >> 2) & 0x03) == 0x01 if len(body) > 2 else False + return { + "vic": vic, + "extended_colorimetry": ext_color, + "bt2020": ext_color == 0x06, + "full_range": full_range, + } + + +def verify_tx_link_signaling( + role, + *, + vic: int, + use_sdpg: bool, + sdpg_st, +) -> dict: + """汇总 TX 侧 InfoFrame 回读,判断信令层 HDR 是否在管线上。""" + sdpg = role.hdtx.sdpg + out: dict = { + "drm_raw": None, + "avi_raw": None, + "vsif_raw": None, + "drm": None, + "avi": None, + "update_flags": None, + "drm_present": False, + "drm_pq": False, + "avi_present": False, + "avi_bt2020": False, + "avi_vic_match": False, + "vsif_present": False, + "sdpg_ok": use_sdpg and not sdpg_st.error and not sdpg_st.overflow, + "signaling_hdr_on_wire": False, + } + if not use_sdpg: + return out + + drm_raw = read_tx_infoframe(sdpg, tsi_ci.TSI_HDMI_INFOFRAME_DRM_R, 0x87) + if drm_raw: + out["drm_raw"] = drm_raw + out["drm"] = parse_drm_on_wire(drm_raw) + out["drm_present"] = True + out["drm_pq"] = out["drm"]["pq"] + + avi_raw = read_tx_infoframe(sdpg, tsi_ci.TSI_HDMI_INFOFRAME_AVI_R, 0x82) + if avi_raw: + out["avi_raw"] = avi_raw + out["avi"] = parse_avi_on_wire(avi_raw) + out["avi_present"] = True + out["avi_bt2020"] = out["avi"]["bt2020"] + out["avi_vic_match"] = out["avi"]["vic"] == vic + + vsif_raw = read_tx_infoframe(sdpg, tsi_ci.TSI_HDMI_INFOFRAME_VSI_R, 0x81) + if vsif_raw: + out["vsif_raw"] = vsif_raw + out["vsif_present"] = True + + out["update_flags"] = read_infoframe_update_flags(sdpg) + out["signaling_hdr_on_wire"] = ( + out["sdpg_ok"] + and out["drm_present"] + and out["drm_pq"] + and out["avi_present"] + and out["avi_bt2020"] + ) + return out + + +def print_link_verification_report( + verify: dict, + *, + vic: int, + use_sdpg: bool, + hdr_mode: bool, + output_mode: str = "solid", + image_meta: dict | None = None, +) -> None: + """打印链路信令验证,区分「信令 HDR」与「峰值亮度 HDR」。""" + print("--- 链路 HDR 信令验证 (--verify-link) ---", flush=True) + if not use_sdpg: + print(" 未启用 sdpg,无 InfoFrame 可验证。", flush=True) + print("------------------------", flush=True) + return + + print(f" sdpg 硬件: error={not verify['sdpg_ok']}", flush=True) + + if verify["drm_raw"]: + drm = verify["drm"] + print(f" DRM (0x87) 回读: {verify['drm_raw'][:20].hex(' ')}...", flush=True) + eotf_label = "PQ/HDR10" if drm["pq"] else f"0x{drm['eotf']:02X}" + print( + f" EOTF={eotf_label}, metadata_type={drm['metadata_type']}, " + f"MaxCLL={drm['max_cll']}, MaxFALL={drm['max_fall']}", + flush=True, + ) + else: + print(" DRM (0x87) 回读: 无有效数据(寄存器未回读或尚未更新)", flush=True) + + if verify["avi_raw"]: + avi = verify["avi"] + print(f" AVI (0x82) 回读: {verify['avi_raw'][:16].hex(' ')}...", flush=True) + print( + f" VIC={avi['vic']} (期望 {vic}), " + f"extended_colorimetry={avi['extended_colorimetry']} " + f"({'BT.2020' if avi['bt2020'] else '非 BT.2020'}), " + f"range={'Full' if avi['full_range'] else 'Limited'}", + flush=True, + ) + else: + print(" AVI (0x82) 回读: 无有效数据", flush=True) + + if verify["vsif_present"]: + print(f" VSIF (0x81) 回读: 有 ({len(verify['vsif_raw'])} bytes)", flush=True) + else: + print(" VSIF (0x81) 回读: 无(未发送或寄存器无数据)", flush=True) + + flags = verify["update_flags"] + if flags is not None: + print(f" InfoFrame update flags: 0x{flags:08X}", flush=True) + else: + print(" InfoFrame update flags: 无法读取", flush=True) + + print(flush=True) + sig = verify["signaling_hdr_on_wire"] + print(f" 【信令层 HDR10】 {'是' if sig else '否/不完整'}", flush=True) + print( + " 判定: DRM EOTF=PQ + AVI BT.2020 + sdpg 无 error" + + (" + VIC 匹配" if verify.get("avi_vic_match") else ""), + flush=True, + ) + if not sig and verify["sdpg_ok"]: + print( + " 提示: sdpg 正常但寄存器无回读时,仍可用电视 HDR 标志或 --no-sdpg 对照。", + flush=True, + ) + + print(" 【像素层 PQ 编码】 无法由 InfoFrame 确认", flush=True) + if hdr_mode: + if output_mode == "image" and image_meta: + enc = image_meta.get("encoding", "") + if enc == "PQ ST2084": + pq_v = image_meta.get("pq_v", 0) + nits = image_meta.get("peak_nits", "?") + lv = image_meta.get("png_rgb", "?") + print( + f" ImageFile: ST 2084 PQ 白场,目标峰值 {nits} nits," + f"PQ V≈{pq_v:.4f},PNG RGB={lv}", + flush=True, + ) + else: + print(f" ImageFile: 用户图片 ({image_meta.get('path', '?')})", flush=True) + else: + print( + " SolidColor 仅保证高码值/BT.2020 配置;" + "亮度不变常见于像素未按 PQ 曲线编码。" + "请改用 --output image(默认)。", + flush=True, + ) + + print(" 【Sink 峰值亮度】 需 CA-410 HDR/PQ 模式 + 电视 HDR 开启", flush=True) + print(flush=True) + print(" 建议对照:", flush=True) + print(" 1. 观察电视输入信息是否显示 HDR10 / BT.2020", flush=True) + print(" 2. python hdr10_demo.py --no-sdpg → HDR 标志应消失", flush=True) + print(" 3. python hdr10_demo.py --sdr → 记录 SDR 亮度/色度基准", flush=True) + print(" 4. CA-410 使用 Flicker-Free + HDR10 测量模式", flush=True) + print("------------------------", flush=True) + + +def read_drm_eotf_from_tx(sdpg) -> int | None: + """尝试读 TX 侧 DRM InfoFrame 的 EOTF 字节(未文档化,仅供诊断)。""" + try: + io = sdpg._PacketGenerator__io # noqa: SLF001 + drm = io.get(tsi_ci.TSI_HDMI_INFOFRAME_DRM_R, c_byte, 32)[1] + if drm and len(drm) >= 5 and drm[0] == 0x87: + return int(drm[4]) + except Exception: + pass + return None + + +def poll_sdpg_status(sdpg, *, seconds: float = 2.0, interval: float = 0.5) -> list: + samples = [] + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + samples.append(sdpg.status()) + time.sleep(interval) + return samples + + +def interpret_sdpg_notes(status, link: dict) -> list[str]: + notes: list[str] = [] + if not link["hpd"]: + notes.append("HPD=Low:InfoFrame 可能未送到 Sink。") + if not link["video"]: + notes.append("video=Off:建议 sdpg 后重新 apply PG。") + if status.error: + notes.append("sdpg error=True:InfoFrame 格式/长度可能不被接受。") + elif not status.enabled and not status.active: + notes.append( + "Enabled/Active 均为 False:UCD-323 固件常见,包仍可能已在链路上;" + "请以 CA-410 或电视 HDR 标志为准。" + ) + elif status.enabled or status.active: + notes.append("sdpg Enabled/Active:信令发生器运行中。") + return notes + + +def assess_hdr_ready( + *, + hdr_mode: bool, + use_sdpg: bool, + pg_diag: dict, + link: dict, + sdpg_st, + configured_hdr_pixels: bool = False, + configured_pq_pixels: bool = False, +) -> dict: + pixels_hdr = configured_hdr_pixels or configured_pq_pixels or ( + is_bt2020_colorimetry(pg_diag["colorimetry"]) and pg_diag["bpc"] >= 10 + ) + pq_pixels = configured_pq_pixels + pg_ok = pg_diag["pg_error"] == "OK" + signaling_ok = ( + use_sdpg + and link["hpd"] + and not sdpg_st.error + and not sdpg_st.overflow + ) + hdr_ready = hdr_mode and use_sdpg and pg_ok and pixels_hdr and signaling_ok + return { + "pg_ok": pg_ok, + "pixels_hdr": pixels_hdr, + "pq_pixels": pq_pixels, + "signaling_ok": signaling_ok, + "hdr_ready": hdr_ready, + } + + +def print_hdr_ready_summary( + assessment: dict, + *, + hdr_mode: bool, + use_sdpg: bool, + infoframe_only: bool = False, + sdr: bool = False, + configured_colorimetry: str | None = None, + output_mode: str = "solid", +) -> None: + print("--- HDR 就绪条件摘要 ---", flush=True) + print(f" PG 无错误: {'是' if assessment['pg_ok'] else '否'}") + if hdr_mode: + note = "" + if output_mode == "image" and assessment.get("pq_pixels"): + note = "(PQ ST2084 ImageFile)" + elif configured_colorimetry and not is_bt2020_colorimetry(configured_colorimetry): + note = "(配置色域见 PG 读回说明)" + elif configured_colorimetry and output_mode == "solid" and configured_colorimetry: + note = "(SolidColor 读回常为 CM_sRGB,以配置 BT.2020 为准)" + elif output_mode == "image" and configured_colorimetry: + note = "(ImageFile 配置 BT.2020,UICL 用 YCbCr 枚举)" + print( + f" PG HDR 像素: {'是 (BT.2020 ≥10bit)' if assessment['pixels_hdr'] else '否'}" + f"{note}" + ) + if output_mode == "image": + print( + f" PQ 编码像素: {'是' if assessment.get('pq_pixels') else '否/未确认'}" + ) + if use_sdpg: + print(f" sdpg 信令层 OK: {'是' if assessment['signaling_ok'] else '否'}") + if hdr_mode and use_sdpg: + ready = assessment["hdr_ready"] + print( + f" 源端 HDR10 就绪: {'是' if ready else '否'}" + "(仍需 CA-410 确认 Sink 峰值亮度)" + ) + if not ready: + print(" → 源端未就绪,CA-410 亮度大概率不会升高。") + else: + print(" → 源端配置完整;若亮度仍不变,请查 CA-410 HDR 模式与电视 HDR 设置。") + elif infoframe_only: + print(" 模式: SDR 像素 + sdpg — 电视可能显示 HDR 标志,亮度通常不会升高。") + elif sdr: + print(" 模式: SDR 对照 — 无 sdpg,亮度约 100–200 nits。") + print("------------------------") + + +def apply_pg_image( + role, + video_mode: UniTAP.VideoMode, + image_path: Path, + *, + sdpg_events_bytes: int = 0, +) -> dict: + """ImageFile:set_vm → set_pattern(路径) → apply()(对齐 app/ucd device.send_image_pattern)。""" + pg = role.hdtx.pg + log_step(f" [PG] ImageFile set_vm ({video_mode.color_info.colorimetry.name}, bpc={video_mode.color_info.bpc}) ...") + pg.set_vm(vm=video_mode) + log_step(f" [PG] ImageFile set_pattern → {image_path}") + pg.set_pattern(pattern=str(image_path)) + log_step(" [PG] ImageFile apply() ...") + layout_patch = ( + patch_image_memory_layout(role, sdpg_events_bytes) + if sdpg_events_bytes > 0 + else nullcontext() + ) + with layout_patch: + if not pg.apply(): + diag = read_pg_diagnostics(pg) + raise RuntimeError(f"ImageFile apply() 失败: {diag['pg_error']}") + try: + role.hdtx.ag.stop_generate() + except Exception: + pass + return read_pg_diagnostics(pg) + + +def apply_pg_output( + role, + *, + output_mode: str, + video_mode: UniTAP.VideoMode, + image_path: Path | None, + white_code: int, + sdpg_events_bytes: int = 0, +) -> dict: + if output_mode == "image": + if image_path is None: + raise ValueError("image 模式需要 image_path") + return apply_pg_image( + role, + video_mode, + image_path, + sdpg_events_bytes=sdpg_events_bytes, + ) + return apply_pg_solid(role, video_mode, white_code) + + +def resolve_output_mode(args, *, hdr_mode: bool, infoframe_only: bool) -> str: + if args.output != "auto": + return args.output + if hdr_mode and not infoframe_only: + return "image" + return "solid" + + +def apply_pg_solid(role, video_mode: UniTAP.VideoMode, white_code: int) -> dict: + """顺序对齐 work_with_solid_color.py:set_pattern → set_vm → apply → params。""" + pg = role.hdtx.pg + code = max(0, min(white_code, SOLID_COLOR_MAX)) + pg.set_pattern(pattern=UniTAP.VideoPattern.SolidColor) + pg.set_vm(vm=video_mode) + if not pg.apply(): + diag = read_pg_diagnostics(pg) + raise RuntimeError(f"SolidColor apply() 失败: {diag['pg_error']}") + pg.set_pattern_params( + pattern_params=UniTAP.SolidColorParams(code, code, code) + ) + try: + role.hdtx.ag.stop_generate() + except Exception: + pass + return read_pg_diagnostics(pg) + + +def load_vsif_packet() -> bytearray: + if VSIF_PATH.is_file(): + return bytearray(VSIF_PATH.read_bytes()) + return bytearray(OFFICIAL_HDR10PLUS_VSIF) + + +def build_sdpg_packets( + *, + vic: int, + max_cll: int, + max_fall: int, + include_vsif: bool, +) -> list[tuple[str, bytearray]]: + packets: list[tuple[str, bytearray]] = [] + if include_vsif: + packets.append(("VSIF HDR10+ (0x81)", load_vsif_packet())) + packets.append((f"AVI BT.2020 VIC{vic} (0x82)", build_avi_bt2020_packet(vic))) + drm = build_drm_hdr10_packet(max_cll=max_cll, max_fall=max_fall) + if not verify_drm_checksum(drm): + raise RuntimeError("DRM 包 checksum 校验失败") + packets.append((f"DRM HDR10 PQ (0x87, MaxCLL={max_cll})", drm)) + return packets + + +def start_sdpg(sdpg, packets: list[bytearray], *, delay: float): + """ + 启动 sdpg(对齐官方 work_with_packet_generation.py)。 + + SolidColor 须在 PG 出图前调用;ImageFile 须在 PG apply(含 layout 扩展)之后调用。 + """ + sdpg.reset() + for idx, packet in enumerate(packets): + sdpg.add_packet(packet) + log_step( + f" sdpg add_packet[{idx}]: type=0x{packet[0]:02X}, " + f"len={len(packet)} bytes" + ) + sdpg.stop() + log_step(f" sdpg stop 后: {sdpg.status()}") + if delay > 0: + log_step(f" sdpg 等待 {delay:g}s(官方示例 5s)...") + time.sleep(delay) + log_step(" sdpg 调用 start() ...") + try: + sdpg.start() + except Exception as exc: + raise RuntimeError(f"sdpg.start() 失败: {exc}") from exc + log_step(" sdpg start() 返回 OK") + samples = poll_sdpg_status(sdpg, seconds=2.0, interval=0.5) + final = samples[-1] if samples else sdpg.status() + log_step(f" sdpg start 后 (最终): {final}") + if len(samples) > 1: + log_step( + f" sdpg 轮询 ({len(samples)} 次): " + + " → ".join(str(s) for s in samples) + ) + caps = read_sdpg_caps_raw(sdpg) + log_step(f" sdpg caps (raw): 0x{caps:08X}") + eotf = read_drm_eotf_from_tx(sdpg) + if eotf is not None: + eotf_name = "PQ (HDR10)" if eotf == 0x04 else f"0x{eotf:02X}" + log_step(f" TX DRM EOTF 回读: {eotf_name}") + if final.error: + raise RuntimeError("sdpg 启动后报错(请检查 InfoFrame 格式)") + return final + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="UCD-323 HDR10 演示:PQ ImageFile 白场 + sdpg InfoFrame" + ) + parser.add_argument( + "--device", + type=parse_device_arg, + default=0, + help="设备索引或 8 位序列号(默认 0)", + ) + parser.add_argument( + "--vic", + type=int, + default=16, + help="CTA VIC(默认 16 = 1080p60)", + ) + parser.add_argument( + "--white-code", + type=int, + default=SOLID_COLOR_MAX, + help=f"SolidColor 白场码值 0–{SOLID_COLOR_MAX}(--output solid 时有效)", + ) + parser.add_argument( + "--output", + choices=("auto", "solid", "image"), + default="auto", + help="像素输出:auto=HDR 用 PQ ImageFile、其余用 SolidColor(默认 auto)", + ) + parser.add_argument( + "--peak-nits", + type=float, + default=None, + help="PQ 白场目标峰值亮度 nits(默认与 --max-cll 相同)", + ) + parser.add_argument( + "--image", + type=str, + default=None, + help="自定义 PNG 路径(--output image 时;HDR 请使用 PQ 编码图)", + ) + parser.add_argument( + "--bpc", + type=int, + default=10, + help="ImageFile VideoMode 位深(默认 10)", + ) + parser.add_argument( + "--max-cll", + type=int, + default=1000, + help="DRM MaxCLL(nits,默认 1000)", + ) + parser.add_argument( + "--max-fall", + type=int, + default=400, + help="DRM MaxFALL(nits,默认 400)", + ) + parser.add_argument( + "--hold", + type=float, + default=60.0, + help="保持输出秒数(默认 60)", + ) + parser.add_argument( + "--settle", + type=float, + default=3.0, + help="PG/sdpg 生效后等待秒数(默认 3)", + ) + parser.add_argument( + "--sdpg-delay", + type=float, + default=5.0, + help="sdpg stop 与 start 间隔秒数(官方示例 5s,默认 5)", + ) + parser.add_argument( + "--hpd-wait", + type=float, + default=10.0, + help="等待 HPD 秒数(默认 10)", + ) + parser.add_argument( + "--sdr", + action="store_true", + help="SDR 对照:sRGB 8bit 白场,不发 sdpg(亮度约 100–200 nits)", + ) + parser.add_argument( + "--infoframe-only", + action="store_true", + help="SDR 白场 + 仅 sdpg(电视可能显示 HDR 标志,亮度不会升高)", + ) + parser.add_argument( + "--no-vsif", + action="store_true", + help="sdpg 不发 VSIF,仅 AVI + DRM", + ) + parser.add_argument( + "--no-sdpg", + action="store_true", + help="仅 PG 出图,不发 sdpg InfoFrame", + ) + parser.add_argument( + "--pg-before-sdpg", + action="store_true", + help="先 PG 再 sdpg(ImageFile 默认;SolidColor 请保持默认先 sdpg)", + ) + parser.add_argument( + "--verify-link", + action=argparse.BooleanOptionalAction, + default=True, + help="验证 TX InfoFrame 回读,输出信令 HDR 与峰值亮度 HDR 的分层结论(默认开启)", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + hdr_mode = not args.sdr and not args.infoframe_only + use_sdpg = not args.sdr and not args.no_sdpg + output_mode = resolve_output_mode( + args, hdr_mode=hdr_mode, infoframe_only=args.infoframe_only + ) + + print("=== HDR10 演示 (hdr10_demo.py) ===") + print("提示: 运行前请关闭 pqAutomationApp。") + if args.sdr: + print("模式: SDR 对照(sRGB 白场,无 sdpg)") + elif args.infoframe_only: + print("模式: SDR 白场 + sdpg(验证电视 HDR 标志,亮度不会升高)") + elif args.no_sdpg: + print("模式: HDR 像素(PQ ImageFile + BT.2020 10bit),无 sdpg") + else: + print("模式: HDR10 完整(PQ ImageFile + VSIF/AVI/DRM)") + if output_mode == "image": + print("像素: ImageFile(ST 2084 PQ 编码白场,CA-410 测峰值推荐)") + else: + print("像素: SolidColor(信令对照;亮度通常不会升高)") + print() + + image_path: Path | None = None + image_meta: dict = {} + white_code = args.white_code if hdr_mode or args.infoframe_only else min(args.white_code, 255) + if output_mode == "image": + image_path, image_meta = prepare_output_assets( + args, + hdr_mode=hdr_mode, + output_mode=output_mode, + infoframe_only=args.infoframe_only, + ) + + tsi = UniTAP.TsiLib() + dev = None + try: + try: + tsi.rescan_devices() + except Exception as exc: + print(f" 警告: rescan_devices 失败: {exc}", file=sys.stderr) + + print(f"正在打开设备: {args.device!r} ...") + dev = tsi.open(args.device) + role = dev.select_role(UniTAP.dev.UCD323.HDMISource) + prepare_device(role) + if use_sdpg: + sdpg_events_bytes = ensure_sdpg_memory(role) + else: + sdpg_events_bytes = 0 + + if not wait_for_hpd(role, timeout=args.hpd_wait): + print( + f" 警告: {args.hpd_wait:g}s 内 HPD 仍为 Low," + "请确认电视已开机且 HDMI 已连接。", + file=sys.stderr, + ) + + timing = resolve_timing(role, vic=args.vic) + refresh_hz = timing.frame_rate / 1000.0 if timing.frame_rate else 0.0 + print( + f"Timing: CTA VIC {args.vic} → " + f"{timing.hactive}x{timing.vactive} @ {refresh_hz:g} Hz" + ) + + if output_mode == "image": + image_path, image_meta = align_output_image_to_timing( + image_path, + image_meta, + timing, + hdr_mode=hdr_mode, + peak_nits=args.peak_nits, + max_cll=args.max_cll, + image_arg=args.image, + solid_white_code=white_code, + ) + vm = build_image_video_mode(timing, hdr=hdr_mode, bpc=args.bpc) + if args.sdr: + vm.color_info.bpc = 8 + color_label = ( + f"BT.2020 {vm.color_info.bpc}bit" + if hdr_mode + else f"BT.709 {vm.color_info.bpc}bit" + ) + print(f"PG 配置: ImageFile {color_label}") + print(f" 图片: {image_path}") + if image_meta.get("encoding") == "PQ ST2084": + print( + f" PQ: 峰值 {image_meta['peak_nits']} nits, " + f"V≈{image_meta['pq_v']:.4f}, PNG RGB={image_meta['png_rgb']}" + ) + else: + vm = build_solid_video_mode(timing, hdr=hdr_mode, bpc=args.bpc) + color_label = ( + f"BT.2020 {vm.color_info.bpc}bit" + if hdr_mode + else f"sRGB {vm.color_info.bpc}bit" + ) + if args.sdr: + vm.color_info.bpc = 8 + color_label = "sRGB 8bit" + print(f"PG 配置: SolidColor {color_label}, 码值={white_code}") + if hdr_mode: + print( + " 说明: SolidColor 硬件读回常为 CM_sRGB;" + "测峰值亮度请用默认 ImageFile(--output image)。" + ) + + configured_cm = vm.color_info.colorimetry.name + configured_hdr_pixels = ( + hdr_mode + and is_bt2020_colorimetry(configured_cm) + and vm.color_info.bpc >= 10 + ) + configured_pq_pixels = ( + hdr_mode + and output_mode == "image" + and image_meta.get("encoding") == "PQ ST2084" + ) + + sdpg_st = None + raw_packets: list[bytearray] = [] + + if use_sdpg: + packet_defs = build_sdpg_packets( + vic=args.vic, + max_cll=args.max_cll, + max_fall=args.max_fall, + include_vsif=not args.no_vsif, + ) + print("sdpg 包:") + for label, packet in packet_defs: + print(f" [{label}]") + print(format_packet_hex(packet)) + raw_packets.append(packet) + + sdpg_first = resolve_sdpg_first(args, use_sdpg=use_sdpg, output_mode=output_mode) + + if sdpg_first: + log_step("顺序: 先 sdpg 再 PG(SolidColor 推荐)") + log_step("启动 sdpg ...") + sdpg_st = start_sdpg(role.hdtx.sdpg, raw_packets, delay=args.sdpg_delay) + for note in interpret_sdpg_notes(sdpg_st, read_link_diagnostics(role)): + log_step(f" → {note}") + + log_step("配置 PG ...") + pg_events = sdpg_events_bytes if use_sdpg and output_mode == "image" else 0 + pg_diag = apply_pg_output( + role, + output_mode=output_mode, + video_mode=vm, + image_path=image_path, + white_code=white_code, + sdpg_events_bytes=pg_events, + ) + print_pg_diagnostics(role, label="PG apply") + time.sleep(args.settle) + link = print_link_diagnostics(role, label="PG 后链路") + + if use_sdpg and not sdpg_first: + log_step("顺序: 先 PG 再 sdpg(ImageFile 必需)") + log_step("启动 sdpg ...") + sdpg_st = start_sdpg(role.hdtx.sdpg, raw_packets, delay=args.sdpg_delay) + link_after_sdpg = print_link_diagnostics(role, label="sdpg 后链路") + for note in interpret_sdpg_notes(sdpg_st, link_after_sdpg): + log_step(f" → {note}") + if not link_after_sdpg["video"]: + if output_mode == "image": + log_step( + " video=Off:ImageFile 不能在 sdpg 后再 apply(会重置显存导致崩溃)," + "请检查 HDMI 连接或改用 --output solid。" + ) + else: + log_step("video=Off,重新 apply PG ...") + pg_diag = apply_pg_output( + role, + output_mode=output_mode, + video_mode=vm, + image_path=image_path, + white_code=white_code, + sdpg_events_bytes=pg_events, + ) + print_pg_diagnostics(role, label="PG 再次 apply") + time.sleep(args.settle) + link = print_link_diagnostics(role, label="最终链路") + elif use_sdpg: + link = print_link_diagnostics(role, label="sdpg+PG 后链路") + + if sdpg_st is None: + sdpg_st = role.hdtx.sdpg.status() + + assessment = assess_hdr_ready( + hdr_mode=hdr_mode, + use_sdpg=use_sdpg, + pg_diag=pg_diag, + link=link, + sdpg_st=sdpg_st, + configured_hdr_pixels=configured_hdr_pixels, + configured_pq_pixels=configured_pq_pixels, + ) + print() + print_hdr_ready_summary( + assessment, + hdr_mode=hdr_mode, + use_sdpg=use_sdpg, + infoframe_only=args.infoframe_only, + sdr=args.sdr, + configured_colorimetry=configured_cm if hdr_mode else None, + output_mode=output_mode, + ) + + if args.verify_link: + link_verify = verify_tx_link_signaling( + role, + vic=args.vic, + use_sdpg=use_sdpg, + sdpg_st=sdpg_st, + ) + print() + print_link_verification_report( + link_verify, + vic=args.vic, + use_sdpg=use_sdpg, + hdr_mode=hdr_mode, + output_mode=output_mode, + image_meta=image_meta if output_mode == "image" else None, + ) + print() + + if hdr_mode and use_sdpg: + print( + f"HDR10 PQ 白场已输出,保持 {args.hold:g}s。" + "请用 CA-410 测中心亮度(预期显著高于 SDR)。" + ) + elif args.infoframe_only: + print( + f"SDR 白场 + sdpg 已输出,保持 {args.hold:g}s。" + "观察电视 HDR 标志;亮度不会升高。" + ) + elif args.sdr: + print(f"SDR 白场已输出,保持 {args.hold:g}s(对照组)。") + else: + print(f"输出已保持 {args.hold:g}s。") + + time.sleep(args.hold) + + print() + print("保持期结束,最终状态:") + pg_diag_final = print_pg_diagnostics(role, label="最终 PG") + link_final = print_link_diagnostics(role, label="最终链路") + sdpg_final = role.hdtx.sdpg.status() if use_sdpg else sdpg_st + if use_sdpg: + print(f" [最终 sdpg] {sdpg_final}") + assessment_final = assess_hdr_ready( + hdr_mode=hdr_mode, + use_sdpg=use_sdpg, + pg_diag=pg_diag_final, + link=link_final, + sdpg_st=sdpg_final, + configured_hdr_pixels=configured_hdr_pixels, + configured_pq_pixels=configured_pq_pixels, + ) + print_hdr_ready_summary( + assessment_final, + hdr_mode=hdr_mode, + use_sdpg=use_sdpg, + infoframe_only=args.infoframe_only, + sdr=args.sdr, + configured_colorimetry=configured_cm if hdr_mode else None, + output_mode=output_mode, + ) + if args.verify_link and use_sdpg: + print() + link_verify_final = verify_tx_link_signaling( + role, + vic=args.vic, + use_sdpg=use_sdpg, + sdpg_st=sdpg_final, + ) + print_link_verification_report( + link_verify_final, + vic=args.vic, + use_sdpg=use_sdpg, + hdr_mode=hdr_mode, + output_mode=output_mode, + image_meta=image_meta if output_mode == "image" else None, + ) + return 0 + + except KeyboardInterrupt: + print("\n用户中断。") + return 130 + except Exception as exc: + print(f"错误: {exc}", file=sys.stderr) + return 1 + finally: + if dev is not None: + try: + role = dev.select_role(UniTAP.dev.UCD323.HDMISource) + role.hdtx.sdpg.stop() + except Exception: + pass + tsi.close(dev) + tsi.cleanup() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/pqAutomationApp.py b/pqAutomationApp.py index 537fac1..2584422 100644 --- a/pqAutomationApp.py +++ b/pqAutomationApp.py @@ -208,6 +208,7 @@ class PQAutomationApp( # 创建日志显示区域 self.create_log_panel() + self.signal_service.set_pg_status_log_callback(self._on_pg_status_log) # 创建 Local Dimming 面板 self.create_local_dimming_panel() # 创建 AI 图片对话面板 @@ -271,6 +272,9 @@ class PQAutomationApp( def on_connection_changed(evt: ConnectionChanged) -> None: if evt.device is DeviceKind.UCD: indicator = getattr(self, "ucd_status_indicator", None) + if not evt.connected: + from app.views.panels.calman_panel import _clear_calman_rgb_session + _clear_calman_rgb_session(self) elif evt.device is DeviceKind.CA: indicator = getattr(self, "ca_status_indicator", None) else: @@ -282,6 +286,14 @@ class PQAutomationApp( self.event_bus.subscribe(ConnectionChanged, on_connection_changed) + def _on_pg_status_log(self, lines: list[str]) -> None: + """每次 UCD 发图后,在 GUI 日志中打印 Pattern Generator 状态。""" + if not hasattr(self, "log_gui"): + return + self.log_gui.log("─ Pattern Generator Status ─", level="separator") + for line in lines: + self.log_gui.log(line, level="info") + def _dispatch_ui(self, fn, *args, **kwargs): """把 ``fn(*args, **kwargs)`` 调度到 Tk 主线程执行。 diff --git a/settings/pq_config.json b/settings/pq_config.json index 38ba782..fb402e4 100644 --- a/settings/pq_config.json +++ b/settings/pq_config.json @@ -9,8 +9,8 @@ "cct", "contrast" ], - "timing": "DMT 1920x 1080 @ 60Hz", - "data_range": "Limited", + "timing": "CTA 1920x 1080 @ 60Hz", + "data_range": "Full", "color_format": "RGB", "bpc": 8, "colorimetry": "DCI-P3", @@ -97,7 +97,7 @@ }, "device_config": { "ca_com": "COM3", - "ucd_list": "0: UCD-323 [2422C809]", + "ucd_list": "0: UCD-323 [2128C209]", "ca_channel": "0" }, "custom_pattern": { diff --git a/temp_hdr10_images/pq_white_1920x1080_1000nits.png b/temp_hdr10_images/pq_white_1920x1080_1000nits.png new file mode 100644 index 0000000000000000000000000000000000000000..c376dd59a5278de9fa2ca3123a80df2949e73eb1 GIT binary patch literal 8594 zcmeAS@N?(olHy`uVBq!ia0y~yU~gbxV6os}0*a(>3=?5sP>l3+aSW-L^Y(@#FN31M zfdh~KbVp^*SfHqA%=PQtteN~kl?)7izw_C_j1OweQXob{3nMRxb~x0)2BH-b9hgD% z1cL>PAlgIX0*GQz;mH8gLTn~rnsZe9XwZ!&8&F;t%`&6K1t=Yi7L}vb1SlPhR*?)0 zqs@cSmK887jJ7IAn+KpGakMuv+B^WIgVA2(X!8J+4n~^?qs;?gG8k@M84@R2@z+^DmJQ!^rfQp0B=D}$50F(|!n+K!K15i4kbMwIL X<`k|Cj-de+AiF(X{an^LB{Ts5@z2TR literal 0 HcmV?d00001 diff --git a/test.py b/test.py index 8bae577..fd143ea 100644 --- a/test.py +++ b/test.py @@ -1,17 +1,19 @@ #!/usr/bin/env python3 """ -HDR10 信号输出验证脚本(UCD-323 HDMI Source + PG + sdpg DRM)。 +HDR10 信号输出验证脚本(UCD-323 HDMI Source + SolidColor + sdpg)。 -验证 PG(BT.2020 / 10bit / Full RGB)与 sdpg 自定义 DRM(0x87) InfoFrame 能否让 DUT 进入 HDR 模式。 +通过 SolidColor 白场(0–65535,默认 65535)并配合 sdpg InfoFrame, +验证 DUT 能否进入 HDR 模式并输出峰值亮度。 用法(在项目根目录):: python test.py python test.py --device 0 python test.py --device ABCD1234 --max-cll 1000 --max-fall 400 --hold 60 + python test.py --image path/to/white.png python test.py --save-bin drm_hdr10.bin -验收:脚本发出 100% PQ 白场后,用 CA-410 测 DUT 峰值亮度。 +验收:脚本发出 100% 白场后,用 CA-410 测 DUT 峰值亮度。 若仍约 100–200 nits(SDR 水平),说明信令或 DUT 设置未生效。 """ @@ -19,20 +21,39 @@ from __future__ import annotations import argparse import sys +import tempfile import time +from ctypes import c_uint32 from pathlib import Path -import UniTAP +from PIL import Image -# CTA-861 VIC 16 ≈ 3840×2160 @ 60Hz(设备需支持该 timing) -DEFAULT_VIC = 16 +import UniTAP +import UniTAP.libs.lib_tsi.tsi_types as tsi_ci + +from UniTAP.common.timing import Timing + +from app.ucd.pg_status import read_pattern_generator_status_lines + +# 与 pq_config 默认一致:DMT 1080p60(勿用 CTA VIC16 代替,时序参数不同) +DEFAULT_WIDTH = 1920 +DEFAULT_HEIGHT = 1080 +DEFAULT_REFRESH_HZ = 60.0 +DEFAULT_STANDARD = "dmt" DEFAULT_MAX_CLL = 1000 DEFAULT_MAX_FALL = 400 DEFAULT_HOLD_SECONDS = 60 -# 10bit PQ 白场码值(与 work_with_solid_color.py 一致) -PQ_WHITE_10BIT = 1023 +from app.solid_color_scale import SOLID_COLOR_MAX, SOLID_COLOR_MIN +SOLID_WHITE_PEAK = 65535 PACKET_PAD_BYTES = 36 +# work_with_packet_generation.py 内联 VSIF(HDR10+,type 0x81) +OFFICIAL_HDR10PLUS_VSIF = bytearray([ + 0x81, 0x01, 0x1B, 0x16, 0x8B, 0x84, 0x90, 0x40, 0x00, 0x00, 0x06, 0x64, + 0x00, 0x00, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]) + def xy_to_be16(value: float) -> tuple[int, int]: """CIE xy → 大端 uint16(单位 0.00002)。""" @@ -41,6 +62,33 @@ def xy_to_be16(value: float) -> tuple[int, int]: return (u >> 8) & 0xFF, u & 0xFF +def build_infoframe_packet( + frame_type: int, + version: int, + body: bytearray, + *, + pad_to: int = PACKET_PAD_BYTES, +) -> bytearray: + """构造 HDMI InfoFrame 包(header + body + 可选 padding)。""" + header = bytearray([frame_type, version, len(body), 0x00]) + header[3] = (256 - (sum(header[:3]) + sum(body)) % 256) & 0xFF + packet = header + body + if len(packet) < pad_to: + packet.extend(b"\x00" * (pad_to - len(packet))) + return packet + + +def build_avi_bt2020_packet(vic: int = 16, *, full_range: bool = True) -> bytearray: + """AVI InfoFrame(0x82):RGB / BT.2020 / Full / 指定 VIC。""" + body = bytearray(13) + body[0] = 0x00 # RGB, no scan info + body[1] = (0x03 << 6) | (0x02 << 4) # Extended colorimetry, 16:9 + q = 0x01 if full_range else 0x00 + body[2] = (0x06 << 4) | (q << 2) # BT.2020, full range + body[3] = vic & 0x7F + return build_infoframe_packet(0x82, 0x02, body) + + def build_drm_hdr10_packet( max_cll: int = DEFAULT_MAX_CLL, max_fall: int = DEFAULT_MAX_FALL, @@ -79,6 +127,39 @@ def build_drm_hdr10_packet( return packet +def build_sdpg_packet_list( + args, + *, + drm: bytearray, + vsif_path: Path, + vic: int, +) -> tuple[list[bytearray], str]: + """组装 sdpg 待发 InfoFrame 列表(可多包)。""" + if args.packet in ("official-vsif", "vendor-vsif"): + if args.packet == "official-vsif": + data = bytearray(OFFICIAL_HDR10PLUS_VSIF) + label = "official VSIF HDR10+ (0x81, 同官方示例)" + else: + data = bytearray(vsif_path.read_bytes()) + label = f"vendor VSIF (HDR10+) {vsif_path.name}" + return [data], label + + packets: list[bytearray] = [] + labels: list[str] = [] + + if args.packet == "full": + packets.append(bytearray(OFFICIAL_HDR10PLUS_VSIF)) + labels.append("VSIF HDR10+ (0x81)") + + if not args.no_avi: + packets.append(build_avi_bt2020_packet(vic)) + labels.append(f"AVI BT.2020 VIC{vic}") + + packets.append(drm) + labels.append("DRM HDR10 (0x87)") + return packets, " + ".join(labels) + + def verify_drm_checksum(packet: bytearray) -> bool: """校验 InfoFrame checksum(type + version + length + payload + checksum ≡ 0 mod 256)。""" if len(packet) < 30 or packet[0] != 0x87: @@ -99,42 +180,338 @@ def format_packet_hex(packet: bytes, bytes_per_line: int = 16) -> str: return "\n".join(lines) -def build_hdr10_video_mode(role, vic: int) -> UniTAP.VideoMode: - timing_mgr = role.hdtx.pg.timing_manager - timing = timing_mgr.get_cta(vic) - if timing is None: - available = timing_mgr.get_all() - raise RuntimeError( - f"设备不支持 CTA VIC={vic}。" - f"可用 timing 数量: {len(available)},请尝试 --vic 其它值。" - ) +def resolve_timing( + role, + *, + width: int = DEFAULT_WIDTH, + height: int = DEFAULT_HEIGHT, + refresh_hz: float = DEFAULT_REFRESH_HZ, + standard: str = DEFAULT_STANDARD, + vic: int | None = None, +) -> Timing: + """按分辨率查找 timing;默认 DMT 1080p60(与主程序 pq_config 一致)。""" + mgr = role.hdtx.pg.timing_manager + f_rate = int(round(refresh_hz * 1000)) - color = UniTAP.ColorInfo() - color.color_format = UniTAP.ColorInfo.ColorFormat.CF_RGB - color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_RGB - color.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA # Full - color.bpc = 10 + if vic is not None: + timing = mgr.get_cta(vic) + if timing is not None: + return timing + raise RuntimeError(f"设备不支持 CTA VIC={vic}") - return UniTAP.VideoMode(timing=timing, color_info=color) + std_map = { + "dmt": Timing.Standard.SD_DMT, + "cta": Timing.Standard.SD_CTA, + "cvt": Timing.Standard.SD_CVT, + } + std = std_map.get(standard.lower()) + if std is None: + raise ValueError(f"未知 standard={standard},可选 dmt/cta/cvt") + timing = mgr.search( + h_active=width, + v_active=height, + f_rate=f_rate, + standard=std, + ) + if timing is not None: + return timing -def apply_pg_white(role, video_mode: UniTAP.VideoMode, white_code: int) -> None: - pg = role.hdtx.pg - pg.set_pattern(pattern=UniTAP.VideoPattern.SolidColor) - pg.set_vm(vm=video_mode) - if not pg.apply(): - raise RuntimeError("Pattern Generator apply() 失败") - pg.set_pattern_params( - pattern_params=UniTAP.SolidColorParams(white_code, white_code, white_code) + hint = mgr.print_all() + raise RuntimeError( + f"未找到 timing: {standard.upper()} {width}x{height}@{refresh_hz}Hz\n" + f"可用列表:\n{hint}" ) -def start_sdpg(role, drm_packet: bytearray): +def build_video_mode( + timing: Timing, + *, + content_mode: str = "sdr", + bpc: int = 10, +) -> UniTAP.VideoMode: + """ImageFile 等自定义 pattern 用。""" + color = UniTAP.ColorInfo() + color.color_format = UniTAP.ColorInfo.ColorFormat.CF_RGB + color.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA + if content_mode == "hdr-content": + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_RGB + color.bpc = bpc + else: + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT709 + color.bpc = 8 + return UniTAP.VideoMode(timing=timing, color_info=color) + + +def build_solid_video_mode( + timing: Timing, + *, + content_mode: str = "sdr", + bpc: int = 10, +) -> UniTAP.VideoMode: + """ + SolidColor 专用 VideoMode,对齐 work_with_solid_color.py。 + + RGB SolidColor 硬件只接受 CM_sRGB(设 BT.709 会 WrongColorimetry); + 码值 65535 须 bpc=10(PG 内部按 16-bpc 左移 16-bpc 位)。 + """ + color = UniTAP.ColorInfo() + color.color_format = UniTAP.ColorInfo.ColorFormat.CF_RGB + color.dynamic_range = UniTAP.ColorInfo.DynamicRange.DR_VESA + color.bpc = max(10, bpc) + if content_mode == "hdr-content": + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_ITUR_BT2020_RGB + else: + color.colorimetry = UniTAP.ColorInfo.Colorimetry.CM_sRGB + return UniTAP.VideoMode(timing=timing, color_info=color) + + +def resolve_content_mode(args) -> str: + """PG 像素:full 包 + 非 pg-only 时默认 10bit;否则 SDR 8bit 保出图。""" + if args.safe: + return "sdr" + if args.hdr_content or args.sdr_content: + return "sdr" if args.sdr_content else "hdr-content" + if args.pg_only or args.infoframe_only: + return "sdr" + if args.packet == "full": + return "hdr-content" + return "sdr" + + +def resolve_output_mode(args, content_mode: str) -> str: + """默认 SolidColor(0–65535);Image 易触发 WrongColorimetry。""" + if args.no_pg: + return "none" + if args.output is not None: + return args.output + return "solid" + + +def resolve_white_code(args, *, content_mode: str, output_mode: str) -> int: + """SolidColor 白场码值;Image 模式 SDR 仍映射到 8bit RGB。""" + code = max(0, min(args.white_code, SOLID_COLOR_MAX)) + if output_mode == "image" and content_mode == "sdr": + return min(code, 255) + return code + + +def content_mode_label(mode: str, bpc: int, *, output_mode: str = "solid") -> str: + if mode == "hdr-content": + return f"BT.2020 {bpc}bit" + if output_mode == "solid": + return f"sRGB {max(10, bpc)}bit" + return "BT.709 8bit" + + +def stop_audio(role) -> None: + try: + role.hdtx.ag.stop_generate() + except Exception: + pass + + +def read_pg_diagnostics(pg) -> dict: + st = pg.status() + return { + "pg_error": st.error.name, + "pg_error_str": str(st.error), + } + + +def rgb_from_white_code(white_code: int, *, hdr_content: bool) -> tuple[int, int, int]: + """将码值映射为 8bit RGB(ImageFile 经 UICL 按 VideoMode 转换)。""" + if not hdr_content: + level = max(0, min(255, white_code if white_code <= 255 else 255)) + else: + level = max(0, min(255, int(round(white_code * 255 / SOLID_COLOR_MAX)))) + return level, level, level + + +def ensure_white_image( + width: int, + height: int, + *, + hdr_content: bool, + white_code: int, + image_path: str | None = None, +) -> Path: + """返回用于发图的 PNG 路径;未指定 --image 时在临时目录生成全屏白场。""" + if image_path: + path = Path(image_path).expanduser().resolve() + if not path.is_file(): + raise FileNotFoundError(f"找不到图片: {path}") + return path + + rgb = rgb_from_white_code(white_code, hdr_content=hdr_content) + temp_dir = Path(tempfile.gettempdir()) / "pq_hdr_test_images" + temp_dir.mkdir(parents=True, exist_ok=True) + suffix = "hdr" if hdr_content else "sdr" + out = temp_dir / f"white_{width}x{height}_{suffix}_{rgb[0]:03d}.png" + Image.new("RGB", (width, height), rgb).save(out, format="PNG") + return out + + +def print_pg_status(pg, hint: str | None = None) -> None: + for line in read_pattern_generator_status_lines(pg, pattern_hint=hint): + print(f" {line}") + + +def wait_for_hpd(role, *, timeout: float = 10.0, interval: float = 0.5) -> bool: + """等待 Sink HPD 置位。""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if role.hdtx.link.status.hpd_status: + return True + time.sleep(interval) + return False + + +def prepare_device(role, *, reset_pg: bool = True) -> None: + """打开设备后清理 sdpg/PG 残留状态(避免此前测试黑屏)。""" + try: + role.hdtx.sdpg.stop() + except Exception: + pass + if reset_pg: + try: + role.hdtx.pg.reset() + except Exception: + pass + stop_audio(role) + + +def read_edid_summary(role) -> dict: + edid = role.hdtx.edid + local = edid.read_i2c() + remote = edid.read_sbm(0) + return {"local_len": len(local), "remote_len": len(remote)} + + +def apply_pg_solid(role, video_mode: UniTAP.VideoMode, white_code: int) -> dict: + """ + 发 SolidColor 白场。顺序对齐 work_with_solid_color.py: + set_pattern → set_vm → apply() → set_pattern_params(0–65535)。 + """ + pg = role.hdtx.pg + code = max(0, min(white_code, SOLID_COLOR_MAX)) + pg.set_pattern(pattern=UniTAP.VideoPattern.SolidColor) + pg.set_vm(vm=video_mode) + if not pg.apply(): + diag = read_pg_diagnostics(pg) + raise RuntimeError( + f"SolidColor apply() 失败: {diag['pg_error_str']} ({diag['pg_error']})" + ) + pg.set_pattern_params( + pattern_params=UniTAP.SolidColorParams(code, code, code) + ) + stop_audio(role) + return read_pg_diagnostics(pg) + + +def bootstrap_sdr_white(role, timing: Timing, *, settle: float) -> None: + """先用 sRGB 10bit SolidColor 建立链路,再切目标格式。""" + boot_vm = build_solid_video_mode(timing, content_mode="sdr") + print(f" [bootstrap] sRGB 10bit SolidColor 白场 (码值 {SOLID_WHITE_PEAK}) ...") + apply_pg_solid(role, boot_vm, SOLID_WHITE_PEAK) + time.sleep(settle) + link = read_link_diagnostics(role) + print( + f" [bootstrap] pg_error=OK, video={'On' if link['video'] else 'Off'}, " + f"TMDS_lock={link['tmds_locked']}" + ) + print_pg_status(role.hdtx.pg, "Solid Color bootstrap") + + +def apply_pg_image(role, video_mode: UniTAP.VideoMode, image_path: Path) -> dict: + """ + 通过 ImageFile 发图。顺序对齐 device.send_image_pattern: + set_vm → set_pattern(图片路径) → apply()。 + """ + pg = role.hdtx.pg + pg.set_vm(vm=video_mode) + pg.set_pattern(pattern=str(image_path)) + if not pg.apply(): + diag = read_pg_diagnostics(pg) + raise RuntimeError( + f"Image apply() 失败: {diag['pg_error_str']} ({diag['pg_error']})" + ) + stop_audio(role) + return read_pg_diagnostics(pg) + + +def read_sdpg_caps_raw(sdpg) -> int: + """读取 TSI_PACKETGEN_CAPS(Python API 未封装)。""" + io = sdpg._PacketGenerator__io # noqa: SLF001 — 诊断用 + return io.get(tsi_ci.TSI_PACKETGEN_CAPS_R, c_uint32)[1] + + +def read_link_diagnostics(role) -> dict: + st = role.hdtx.link.status + locks = st.channel_lock + return { + "hpd": st.hpd_status, + "video": st.video_status, + "hdmi_mode": st.hdmi_mode.name, + "channel_lock": locks, + "tmds_locked": any(locks) if locks else False, + } + + +def poll_sdpg_status(sdpg, seconds: float = 3.0, interval: float = 0.5): + """start() 后轮询 status;部分固件需数帧后才置位。""" + samples = [] + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + samples.append(sdpg.status()) + time.sleep(interval) + return samples + + +def start_sdpg(role, packets: list[bytearray], *, pre_stop_delay: float = 0.5) -> None: + """ + 启动 sdpg。流程对齐 work_with_packet_generation.py: + reset → add_packet(s) → stop → 等待 → start。 + """ sdpg = role.hdtx.sdpg sdpg.reset() - sdpg.add_packet(drm_packet) + for idx, packet in enumerate(packets): + sdpg.add_packet(packet) + print( + f" sdpg add_packet[{idx}]: type=0x{packet[0]:02X}, " + f"len={len(packet)} bytes" + ) + sdpg.stop() + print(f" sdpg stop 后: {sdpg.status()}") + if pre_stop_delay > 0: + time.sleep(pre_stop_delay) sdpg.start() - return sdpg.status() + + +def interpret_sdpg_status(status, link: dict) -> list[str]: + """根据 status / 链路状态给出可读结论。""" + notes = [] + notes.append( + "sdpg 通过 add_packet/load_packet 发送自定义 InfoFrame(见官方 work_with_packet_generation.py);" + "caps 寄存器为 0 时仍可发送。" + ) + if not link["hpd"]: + notes.append( + "HPD=Low:未检测到 Sink 热插拔。InfoFrame 可能不会在链路上送出," + "请确认 HDMI 已接 DUT 且电视已开机。" + ) + if not link["video"]: + notes.append("video_status=False:TX 视频未 enable,请先确认 Image 已有输出。") + if not status.error and not status.enabled and not status.active: + notes.append( + "start() 后 Enabled/Active 仍为 False:UCD-323 固件常如此," + "包仍可能已在链路上;请以电视 HDR 状态或 CA-410 nits 为准。" + ) + elif status.error: + notes.append("sdpg error=True:包格式或长度可能不被接受,可尝试 --packet vendor-vsif 对比。") + elif status.enabled or status.active: + notes.append("sdpg 已 Enabled/Active,信令发生器运行中。") + return notes def parse_device_arg(value: str): @@ -159,21 +536,31 @@ def list_devices(tsi: UniTAP.TsiLib) -> None: def main() -> int: parser = argparse.ArgumentParser( - description="UCD-323 HDR10 信号输出验证(PG + sdpg DRM InfoFrame)" + description="UCD-323 HDR10 信号输出验证(SolidColor 0–65535 + sdpg InfoFrame)" ) parser.add_argument( "--device", default="0", help="设备序号(整数)或 8 位序列号,默认 0", ) - parser.add_argument("--vic", type=int, default=DEFAULT_VIC, help=f"CTA VIC,默认 {DEFAULT_VIC}") + parser.add_argument("--vic", type=int, default=None, help="改用 CTA VIC(指定后忽略 --standard/--width 等)") + parser.add_argument("--standard", default=DEFAULT_STANDARD, choices=("dmt", "cta", "cvt")) + parser.add_argument("--width", type=int, default=DEFAULT_WIDTH) + parser.add_argument("--height", type=int, default=DEFAULT_HEIGHT) + parser.add_argument("--refresh", type=float, default=DEFAULT_REFRESH_HZ, help="Hz") + parser.add_argument("--settle", type=float, default=2.0, help="apply 后等待秒数,默认 2") parser.add_argument("--max-cll", type=int, default=DEFAULT_MAX_CLL, help="DRM MaxCLL (nits)") parser.add_argument("--max-fall", type=int, default=DEFAULT_MAX_FALL, help="DRM MaxFALL (nits)") parser.add_argument( "--white-code", type=int, - default=PQ_WHITE_10BIT, - help=f"PQ 10bit 白场码值,默认 {PQ_WHITE_10BIT}", + default=SOLID_WHITE_PEAK, + help=f"SolidColor 白场 RGB 码值 0–65535,默认 {SOLID_WHITE_PEAK}(峰值白)", + ) + parser.add_argument( + "--image", + metavar="PATH", + help="发图文件路径(png/bmp/jpeg 等);省略则按分辨率自动生成白场 PNG", ) parser.add_argument( "--hold", @@ -187,6 +574,119 @@ def main() -> int: help="将 DRM 包保存为 .bin 文件(便于 sdpg.load_packet 调试)", ) parser.add_argument("--list-devices", action="store_true", help="列出设备后退出") + parser.add_argument("--list-timings", action="store_true", help="列出设备 timing 后退出") + parser.add_argument( + "--packet", + choices=("full", "official-vsif", "drm", "vendor-vsif"), + default="full", + help="sdpg:full=VSIF+AVI+DRM(默认);official-vsif=仅官方 VSIF;drm=AVI+DRM", + ) + parser.add_argument( + "--no-avi", + action="store_true", + help="drm 模式仅发 DRM、不发 AVI(对比测试用)", + ) + parser.add_argument( + "--sdpg-before-pg", + action="store_true", + default=False, + help="先 sdpg 再出图", + ) + parser.add_argument( + "--pg-before-sdpg", + action="store_false", + dest="sdpg_before_pg", + help="先出图再 sdpg(默认)", + ) + parser.add_argument( + "--sdpg-reset", + action="store_true", + help="已废弃:start_sdpg 每次都会 reset()", + ) + parser.add_argument( + "--sdpg-delay", + type=float, + default=5.0, + help="sdpg stop 与 start 等待秒数(官方 5s),默认 5", + ) + parser.add_argument( + "--pg-only", + action="store_true", + help="仅 PG 出图,不发 sdpg", + ) + parser.add_argument( + "--infoframe-only", + action="store_true", + help="SDR 白场 + 仅测 sdpg(不切 HDR 像素;默认仍出图,建议加 --vic 16)", + ) + parser.add_argument( + "--no-pg", + action="store_true", + help="完全不出图,仅 sdpg(复刻官方 work_with_packet_generation.py,电视通常黑屏)", + ) + parser.add_argument( + "--force-sdpg", + action="store_true", + help="已废弃:sdpg 默认始终发送(除非 --pg-only)", + ) + parser.add_argument( + "--safe", + action="store_true", + help="同默认 SDR 模式(8bit BT.709),显式指定时使用", + ) + parser.add_argument( + "--hdr-content", + action="store_true", + help="强制 PG 10bit BT.2020(--packet full 已默认)", + ) + parser.add_argument( + "--sdr-content", + action="store_true", + help="强制 PG 8bit SDR(只测电视 HDR 标志,亮度不会升高)", + ) + parser.add_argument( + "--output", + choices=("image", "solid"), + default=None, + help="白场输出:默认 SolidColor(推荐);image=ImageFile(可能 WrongColorimetry)", + ) + parser.add_argument( + "--bootstrap", + action="store_true", + default=False, + help="solid 输出前先用 SolidColor 建链(UCD-323 上易导致闪屏后黑屏,默认关闭)", + ) + parser.add_argument( + "--no-bootstrap", + action="store_false", + dest="bootstrap", + help="同默认行为:跳过 bootstrap", + ) + parser.add_argument( + "--reset-pg", + action="store_true", + default=True, + help="打开设备后 reset PG 并 stop sdpg(默认开启)", + ) + parser.add_argument( + "--no-reset-pg", + action="store_false", + dest="reset_pg", + help="不 reset PG", + ) + parser.add_argument( + "--hpd-wait", + type=float, + default=10.0, + help="等待 HPD 置位的超时秒数,默认 10", + ) + parser.add_argument( + "--bpc", + type=int, + choices=(8, 10, 12), + default=10, + help="HDR 内容色深(非 --safe / 非 --pg-only SDR 时),默认 10", + ) args = parser.parse_args() tsi = UniTAP.TsiLib() @@ -195,62 +695,291 @@ def main() -> int: tsi.cleanup() return 0 + if args.list_timings: + dev = tsi.open(parse_device_arg(args.device)) + role = dev.select_role(UniTAP.dev.UCD323.HDMISource) + print(role.hdtx.pg.timing_manager.print_all()) + tsi.close(dev) + tsi.cleanup() + return 0 + drm = build_drm_hdr10_packet(max_cll=args.max_cll, max_fall=args.max_fall) if not verify_drm_checksum(drm): print("错误: DRM 包 checksum 校验失败。", file=sys.stderr) return 1 + vsif_path = ( + Path(__file__).resolve().parent + / "UniTAP/dev/modules/dut_tests/cfg/hdr10/vsif1.bin" + ) + if args.packet == "vendor-vsif" and not vsif_path.is_file(): + print(f"错误: 找不到 {vsif_path}", file=sys.stderr) + return 1 + if args.save_bin: out = Path(args.save_bin) out.write_bytes(drm) print(f"已保存 DRM 包: {out.resolve()} ({len(drm)} bytes)") + preview_mode = resolve_content_mode(args) + output_mode = resolve_output_mode(args, preview_mode) + white_code = resolve_white_code( + args, content_mode=preview_mode, output_mode=output_mode + ) + color_desc = content_mode_label(preview_mode, args.bpc, output_mode=output_mode) + preview_vic = args.vic if args.vic is not None else 16 + preview_packets, preview_label = build_sdpg_packet_list( + args, drm=drm, vsif_path=vsif_path, vic=preview_vic + ) + print("=== HDR10 信号输出测试 ===") - print(f"DRM MaxCLL={args.max_cll}, MaxFALL={args.max_fall}") - print(f"CTA VIC={args.vic}, bpc=10, BT.2020 RGB Full, PQ 白场码值={args.white_code}") - print("DRM 包内容:") - print(format_packet_hex(drm)) + print("提示: 运行前请关闭 pqAutomationApp,避免 UCD 设备被占用。") + if args.infoframe_only: + print("模式: SDR 白场 + 仅 sdpg InfoFrame(观察电视 HDR 标志,亮度不会升高)") + elif args.no_pg: + print("模式: 无 PG 出图,仅 sdpg(官方示例,电视通常无画面)") + elif args.pg_only: + print("模式: 仅出图(跳过 sdpg InfoFrame)") + else: + print(f"模式: {color_desc} + sdpg ({preview_label})") + output_desc = { + "solid": "SolidColor", + "image": "ImageFile", + "none": "无(--no-pg)", + }[output_mode] + print(f"输出: {output_desc}" + (" + bootstrap SDR" if args.bootstrap else "")) + if args.infoframe_only: + print( + " 说明: 仍会输出 SDR 8bit 白场;只验证 sdpg 是否让电视进 HDR,亮度不会升高。" + " 测峰值亮度: python test.py --vic 16 --sdpg-delay 5" + ) + if args.vic is None: + print(" 提示: 建议加 --vic 16(与 --pg-only 成功出图时序一致)。") + if preview_mode == "sdr" and not args.pg_only and not args.infoframe_only: + print( + " 注意: SDR 8bit 像素 + InfoFrame 通常不会提高亮度(CA-410 仍 ~100–200 nits)。" + " 要测峰值亮度请用默认 --packet full(10bit SolidColor + VSIF/AVI/DRM)。" + ) + elif preview_mode == "hdr-content": + print( + f" 策略: SolidColor 码值 {white_code}(0–65535 峰值)+ sdpg 三包。" + " 若黑屏可试 --sdr-content 或 --pg-only --vic 16。" + ) + if not args.pg_only and not args.no_pg: + print(f"sdpg: {preview_label}") + if args.packet in ("full", "drm"): + print(f"DRM MaxCLL={args.max_cll}, MaxFALL={args.max_fall}") + timing_desc = ( + f"CTA VIC {args.vic}" if args.vic is not None + else f"{args.standard.upper()} {args.width}x{args.height}@{args.refresh}Hz" + ) + white_desc = ( + f"SolidColor 码值 {white_code}" + if output_mode == "solid" + else (args.image or f"白场 PNG (white_code={white_code})") + ) + print(f"Timing: {timing_desc}, PG={color_desc} RGB Full, 内容={white_desc}, settle={args.settle}s") + if not args.pg_only: + for idx, pkt in enumerate(preview_packets): + print(f"sdpg 包[{idx}] (type=0x{pkt[0]:02X}):") + print(format_packet_hex(pkt)) print() dev = None role = None + sdpg_started = False try: device_id = parse_device_arg(args.device) print(f"正在打开设备: {device_id!r} ...") + try: + tsi.rescan_devices() + except Exception as exc: + print(f" 警告: rescan_devices 失败: {exc}", file=sys.stderr) dev = tsi.open(device_id) role = dev.select_role(UniTAP.dev.UCD323.HDMISource) + prepare_device(role, reset_pg=args.reset_pg) - print("配置 Pattern Generator ...") - vm = build_hdr10_video_mode(role, args.vic) - t = vm.timing - refresh_hz = t.frame_rate / 1000.0 if t.frame_rate else 0.0 - print(f" Timing: {t.hactive}x{t.vactive} @ {refresh_hz:g} Hz") + if not wait_for_hpd(role, timeout=args.hpd_wait): + print( + f" 警告: {args.hpd_wait:g}s 内 HPD 仍为 Low," + "请确认电视已开机且 HDMI 已连接。", + file=sys.stderr, + ) + edid_info = read_edid_summary(role) + print( + f" EDID: local={edid_info['local_len']}B, " + f"remote(DUT)={edid_info['remote_len']}B" + ) + + print("配置输出 ...") + timing = resolve_timing( + role, + width=args.width, + height=args.height, + refresh_hz=args.refresh, + standard=args.standard, + vic=args.vic, + ) + sdpg = role.hdtx.sdpg + caps = read_sdpg_caps_raw(sdpg) + print(f" sdpg caps (raw): 0x{caps:08X}") + + content_mode = resolve_content_mode(args) + output_mode = resolve_output_mode(args, content_mode) + white_code = resolve_white_code( + args, content_mode=content_mode, output_mode=output_mode + ) + avi_vic = args.vic if args.vic is not None else timing.id + sdpg_packets, packet_label = build_sdpg_packet_list( + args, drm=drm, vsif_path=vsif_path, vic=avi_vic + ) + if args.infoframe_only: + print(" 内容格式: sRGB 10bit SolidColor + sdpg(像素 SDR,信令由 sdpg 声明 HDR)。") + elif args.no_pg: + print(" 内容格式: 不出图,仅 sdpg。") + elif content_mode == "hdr-content": + print( + f" 内容格式: PG 10bit BT.2020 SolidColor 码值 {white_code}(0–65535)。" + ) + elif not args.pg_only: + print(" 内容格式: PG 8bit BT.709 + sdpg(亮度不会升高,仅验证信令)。") + else: + print(" 内容格式: SDR 8bit BT.709。") + + vm = ( + build_solid_video_mode(timing, content_mode=content_mode, bpc=args.bpc) + if output_mode == "solid" + else build_video_mode(timing, content_mode=content_mode, bpc=args.bpc) + ) + hdr_content = content_mode == "hdr-content" + image_path = None + if output_mode == "image": + image_path = ensure_white_image( + timing.hactive, + timing.vactive, + hdr_content=hdr_content, + white_code=white_code, + image_path=args.image, + ) + print(f" Image: {image_path}") + elif output_mode == "none": + print(" Image: (跳过,--no-pg)") + print(f" 内容格式: {content_mode_label(content_mode, args.bpc, output_mode=output_mode)}") + refresh_hz = timing.frame_rate / 1000.0 if timing.frame_rate else 0.0 + std_name = timing.standard.name.replace("SD_", "") + print( + f" Timing: {std_name} {timing.hactive}x{timing.vactive} @ {refresh_hz:g} Hz" + f" (id={timing.id})" + ) print( f" Color: {vm.color_info.colorimetry.name}, " f"bpc={vm.color_info.bpc}, range={vm.color_info.dynamic_range.name}" ) - apply_pg_white(role, vm, args.white_code) - print(" PG apply() 成功,已输出 PQ 白场。") + link = read_link_diagnostics(role) + print( + f" 链路: HPD={'High' if link['hpd'] else 'Low'}, " + f"video={'On' if link['video'] else 'Off'}, " + f"TMDS_lock={link['tmds_locked']}, locks={link['channel_lock']}, " + f"mode={link['hdmi_mode']}" + ) + if link["hpd"] and not link["tmds_locked"]: + print( + " 提示: UCD-323 TX 侧 TMDS lock 位常不可靠;" + "请以电视是否实际有画面为准(video=On 即可继续)。" + ) - print("启动 sdpg (DRM InfoFrame) ...") - status = start_sdpg(role, drm) - print(f" sdpg status: {status}") - if status.error: - print(" 警告: sdpg 报告 error=True,请检查固件或包格式。", file=sys.stderr) - if not status.active and not status.enabled: - print(" 警告: sdpg 未 active/enabled。", file=sys.stderr) + def run_output(): + if output_mode == "none": + return + if args.bootstrap: + if output_mode == "image": + print( + " 跳过 bootstrap:Image 模式直接出图即可;" + "SolidColor 建链在本机上会闪屏后黑屏。" + ) + else: + bootstrap_sdr_white(role, timing, settle=args.settle) + if output_mode == "solid": + pg_diag = apply_pg_solid(role, vm, white_code) + hint = f"Solid Color {white_code} (0x{white_code:X})" + else: + pg_diag = apply_pg_image(role, vm, image_path) + hint = image_path.name + time.sleep(args.settle) + link_after = read_link_diagnostics(role) + print( + f" {output_mode} apply() OK, pg_error={pg_diag['pg_error']}, " + f"video={'On' if link_after['video'] else 'Off'}, " + f"TMDS_lock={link_after['tmds_locked']}" + ) + if pg_diag["pg_error"] != "OK": + print(f" 警告: PG 状态异常 — {pg_diag['pg_error_str']}", file=sys.stderr) + print_pg_status(role.hdtx.pg, hint) + vm_rb = role.hdtx.pg.get_stream_video_mode() + print( + f" PG 读回: {vm_rb.timing.hactive}x{vm_rb.timing.vactive}, " + f"bpc={vm_rb.color_info.bpc}, cm={vm_rb.color_info.colorimetry.name}" + ) + + def run_sdpg(*, reapply_pg: bool = True) -> bool: + nonlocal sdpg_started + if args.pg_only: + print(" 跳过 sdpg(--pg-only)。") + return False + + print(f"启动 sdpg ({packet_label}) ...") + start_sdpg(role, sdpg_packets, pre_stop_delay=args.sdpg_delay) + sdpg_started = True + samples = poll_sdpg_status(sdpg, seconds=1.0, interval=0.25) + status = samples[-1] + print(f" sdpg status (start 后): {status}") + link_mid = read_link_diagnostics(role) + print( + f" sdpg 后链路: video={'On' if link_mid['video'] else 'Off'}" + f"(若为 Off,将重新 apply 出图)" + ) + for note in interpret_sdpg_status(status, link_mid): + print(f" → {note}") + + if reapply_pg and output_mode != "none": + print(" sdpg 后再次 apply 出图,保持画面 ...") + run_output() + return True + + if args.no_pg: + run_sdpg(reapply_pg=False) + elif args.infoframe_only or args.pg_only: + run_output() + if args.infoframe_only: + run_sdpg(reapply_pg=True) + elif args.sdpg_before_pg: + run_sdpg(reapply_pg=False) + run_output() + else: + run_output() + run_sdpg(reapply_pg=True) print() print("--- 请用 CA-410 测量 DUT 100% 白场峰值亮度 ---") - print(" HDR 生效: 通常显著高于 SDR(具体取决于电视峰值)") - print(" 仍为 ~100–200 nits: 检查 DUT HDR 开关 / HDMI 口 / 信令") + if preview_mode == "hdr-content": + print(" HDR 生效: 应显著高于 SDR(电视峰值通常 400–1000+ nits)") + else: + print(" 当前为 SDR 像素: 亮度约 100–200 nits 属正常,不代表 sdpg 无效") + print(" 对比: 先跑 --pg-only --vic 16 记 SDR 基准") print(f"--- 保持输出 {args.hold:.0f} 秒,Ctrl+C 可提前结束 ---") print() deadline = time.monotonic() + args.hold while time.monotonic() < deadline: - time.sleep(1.0) + time.sleep(2.0) + link_hold = read_link_diagnostics(role) + if not link_hold["video"] and output_mode != "none": + print(" [watchdog] video=Off,重新 apply 输出 ...") + if output_mode == "solid": + apply_pg_solid(role, vm, white_code) + else: + apply_pg_image(role, vm, image_path) print("测试完成。") return 0 @@ -263,7 +992,7 @@ def main() -> int: list_devices(tsi) return 1 finally: - if role is not None: + if role is not None and sdpg_started: try: role.hdtx.sdpg.stop() except Exception: