#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ HDMI EDID 查看器 + 测试图案投屏工具 ================================================ 配合 LT8619C(HDMI 转 RGB888)→ DLPC3421 转接板调试使用。 功能: 1. 枚举各显示输出口接的设备, 从注册表读取其 EDID 原始数据, 按 EDID 1.4 / CEA-861 标准解算: 厂商/产品/名称/尺寸/Gamma/输入类型、 支持的全部模式(详细时序 DTD / 已建立时序 / 标准时序 / CEA VIC)、 首选时序完整参数(像素时钟/消隐/同步极性)、CEA 扩展能力、校验和。 2. 向所选屏幕全屏投测试图案(黑底 1:1 居中, 测试卡区域可选 640x360 / 800x600 / full 铺满): 纯红 / 纯绿 / 纯蓝 / 黑白棋盘格 / 网格线 + 3 个自定义图片栏位, 点击或 ESC 退出。 仅用标准库(ctypes / winreg / tkinter), 无需 pip 安装任何依赖。 命令行模式(自动化测试用): python hdmi_gui.py 启动 GUI python hdmi_gui.py --dump 控制台输出 EDID 解算结果 python hdmi_gui.py --test-pattern Checker --seconds 3 直接出图案, 3 秒自动退出 python hdmi_gui.py --test-pattern Blue --area 800x600 指定测试卡区域(full=铺满所选屏) python hdmi_gui.py --seconds 5 GUI 5 秒自动关闭(冒烟测试) """ import argparse import ctypes import ctypes.wintypes as wt import json import math import os import shutil import struct import sys import winreg # 进程级 DPI 感知: 保证图案窗口按物理像素精确定位/绘制 try: ctypes.windll.shcore.SetProcessDpiAwareness(2) except Exception: pass # 可选 Pillow: 装了则支持 JPEG/BMP/WebP, 没装则用 tkinter 原生(仅 PNG/GIF) try: from PIL import Image, ImageTk HAS_PIL = True except ImportError: HAS_PIL = False # ============================================================ # 自定义图案栏位: 配置存于脚本目录 pattern_slots.json, # 图片文件同样保存在脚本目录下 # 注: PyInstaller onefile 打包后 __file__ 指向临时解压目录, # 配置必须落在 exe 旁边, 故 frozen 时改用 sys.executable 定位 # ============================================================ if getattr(sys, 'frozen', False): BASE_DIR = os.path.dirname(os.path.abspath(sys.executable)) else: BASE_DIR = os.path.dirname(os.path.abspath(__file__)) SLOT_COUNT = 3 SLOT_CONFIG = os.path.join(BASE_DIR, 'pattern_slots.json') IMG_EXTS = ['.png', '.gif'] + (['.jpg', '.jpeg', '.bmp', '.webp'] if HAS_PIL else []) def load_slots(): """读 pattern_slots.json, 返回 (slots, area)。 新格式为 {'area': 'WxH'|'full', 'slots': [...]}; 兼容旧版纯数组格式(无 area)。 """ slots = [{'name': f'自定义{i + 1}', 'file': None} for i in range(SLOT_COUNT)] try: with open(SLOT_CONFIG, encoding='utf-8') as f: data = json.load(f) except Exception: return slots, DEFAULT_AREA if isinstance(data, dict): area = str(data.get('area') or DEFAULT_AREA) items = data.get('slots') or [] else: area, items = DEFAULT_AREA, data for i, item in enumerate(items[:SLOT_COUNT]): if isinstance(item, dict): slots[i]['name'] = str(item.get('name') or slots[i]['name']) slots[i]['file'] = item.get('file') or None return slots, area def save_slots(slots, area): with open(SLOT_CONFIG, 'w', encoding='utf-8') as f: json.dump({'area': area, 'slots': slots}, f, ensure_ascii=False, indent=2) def copy_image_into_script_dir(src_path): """把任意路径的图片复制到脚本目录, 重名自动加序号, 返回文件名""" dst_name = os.path.basename(src_path) dst = os.path.join(BASE_DIR, dst_name) if os.path.normcase(os.path.abspath(src_path)) == os.path.normcase(dst): return dst_name stem, ext = os.path.splitext(dst_name) n = 1 while os.path.exists(dst): dst_name = f'{stem}_{n}{ext}' dst = os.path.join(BASE_DIR, dst_name) n += 1 shutil.copy2(src_path, dst) return dst_name def load_image_raw(path): """按原始像素加载图片(不缩放不裁剪; PIL 仅用于支持 JPG/BMP/WebP 等格式)""" import tkinter as tk if HAS_PIL: return ImageTk.PhotoImage(Image.open(path).convert('RGB')) return tk.PhotoImage(file=path) def get_icon_path(): """工具图标: 源码模式取 logos/ 下, 打包模式取 exe 内嵌资源(_MEIPASS)""" if getattr(sys, 'frozen', False): p = os.path.join(getattr(sys, '_MEIPASS', ''), 'logo_ph_orange.ico') else: p = os.path.join(BASE_DIR, 'logos', 'logo_ph_orange.ico') return p if os.path.exists(p) else None # ============================================================ # Win32 显示 API # ============================================================ user32 = ctypes.windll.user32 ENUM_CURRENT_SETTINGS = 0xFFFFFFFF # (DWORD)-1 class DISPLAY_DEVICEW(ctypes.Structure): _fields_ = [ ('cb', wt.DWORD), ('DeviceName', ctypes.c_wchar * 32), ('DeviceString', ctypes.c_wchar * 128), ('StateFlags', wt.DWORD), ('DeviceID', ctypes.c_wchar * 128), ('DeviceKey', ctypes.c_wchar * 128), ] class DEVMODEW(ctypes.Structure): _fields_ = [ ('dmDeviceName', ctypes.c_wchar * 32), ('dmSpecVersion', wt.WORD), ('dmDriverVersion', wt.WORD), ('dmSize', wt.WORD), ('dmDriverExtra', wt.WORD), ('dmFields', wt.DWORD), ('dmPositionX', wt.LONG), ('dmPositionY', wt.LONG), ('dmDisplayOrientation', wt.DWORD), ('dmDisplayFixedOutput', wt.DWORD), ('dmColor', wt.WORD), ('dmDuplex', wt.WORD), ('dmYResolution', wt.WORD), ('dmTTOption', wt.WORD), ('dmCollate', wt.WORD), ('dmFormName', ctypes.c_wchar * 32), ('dmLogPixels', wt.WORD), ('dmBitsPerPel', wt.DWORD), ('dmPelsWidth', wt.DWORD), ('dmPelsHeight', wt.DWORD), ('dmDisplayFlags', wt.DWORD), ('dmDisplayFrequency', wt.DWORD), ('dmICMMethod', wt.DWORD), ('dmICMIntent', wt.DWORD), ('dmMediaType', wt.DWORD), ('dmDitherType', wt.DWORD), ('dmReserved1', wt.DWORD), ('dmReserved2', wt.DWORD), ('dmPanningWidth', wt.DWORD), ('dmPanningHeight', wt.DWORD), ] user32.EnumDisplayDevicesW.argtypes = [ctypes.c_wchar_p, wt.DWORD, ctypes.POINTER(DISPLAY_DEVICEW), wt.DWORD] user32.EnumDisplayDevicesW.restype = wt.BOOL user32.EnumDisplaySettingsW.argtypes = [ctypes.c_wchar_p, wt.DWORD, ctypes.POINTER(DEVMODEW)] user32.EnumDisplaySettingsW.restype = wt.BOOL user32.ChangeDisplaySettingsExW.argtypes = [ctypes.c_wchar_p, ctypes.POINTER(DEVMODEW), ctypes.c_void_p, wt.DWORD, ctypes.c_void_p] user32.ChangeDisplaySettingsExW.restype = ctypes.c_long DM_BITSPERPEL = 0x00040000 DM_PELSWIDTH = 0x00080000 DM_PELSHEIGHT = 0x00100000 DM_DISPLAYFREQUENCY = 0x00400000 def read_edid_from_registry(device_id): """monitor DeviceID 'MONITOR\\\\' → 注册表 EDID 原始字节 注意: 间接显示器/虚拟显示驱动返回的 DeviceID 实例段是 '{类GUID}\\NNNN' 格式, 注册表中并不存在该路径; 因此精确匹配失败后按 PNP ID 枚举其下 全部实例, 取第一份 EDID(同一物理屏的重复实例 EDID 相同)。 """ if not device_id or not device_id.startswith('MONITOR\\'): return None parts = device_id.split('\\') if len(parts) < 3: return None pnp = parts[1] inst = device_id.split('\\', 2)[2] def _read(path): try: key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, path) try: val, _typ = winreg.QueryValueEx(key, 'EDID') if isinstance(val, (bytes, bytearray)): return bytes(val) if isinstance(val, list): # 某些系统返回 int 列表 return bytes(val) finally: winreg.CloseKey(key) except OSError: pass return None # 1) 精确实例路径(常规真实显示器) raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{inst}\Device Parameters') if raw: return raw # 2) 按 PNP ID 遍历全部实例(间接显示器/实例名不一致) try: pnp_root = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}') except OSError: return None try: for i in range(winreg.QueryInfoKey(pnp_root)[0]): sub = winreg.EnumKey(pnp_root, i) raw = _read(rf'SYSTEM\CurrentControlSet\Enum\DISPLAY\{pnp}\{sub}\Device Parameters') if raw: return raw finally: winreg.CloseKey(pnp_root) return None def get_displays(): """枚举活动显示器: 设备名/显卡/监视器名/当前模式/屏幕矩形/EDID 字节""" out = [] i = 0 while True: dd = DISPLAY_DEVICEW() dd.cb = ctypes.sizeof(dd) if not user32.EnumDisplayDevicesW(None, i, ctypes.byref(dd), 0): break if dd.StateFlags & 0x1: # ATTACHED_TO_DESKTOP # 找该输出口上活动的 monitor 条目 mon = None j = 0 while True: m = DISPLAY_DEVICEW() m.cb = ctypes.sizeof(m) if not user32.EnumDisplayDevicesW(dd.DeviceName, j, ctypes.byref(m), 0): break if m.StateFlags & 0x1: mon = m break j += 1 cur = '' rect = None dm = DEVMODEW() dm.dmSize = ctypes.sizeof(dm) if user32.EnumDisplaySettingsW(dd.DeviceName, ENUM_CURRENT_SETTINGS, ctypes.byref(dm)): cur = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}' rect = (dm.dmPositionX, dm.dmPositionY, dm.dmPelsWidth, dm.dmPelsHeight) out.append({ 'device': dd.DeviceName, # \\.\DISPLAY1 'adapter': dd.DeviceString, # 显卡名 'mon_name': mon.DeviceString if mon else '', 'edid': read_edid_from_registry(mon.DeviceID) if mon else None, 'cur_mode': cur, 'rect': rect, # (x, y, w, h) 物理像素 }) i += 1 return out def enum_modes(device): """枚举 device(\\\\.\\DISPLAYn) 可用模式 —— 显卡按该屏 EDID 提供, 返回 (['WxH@Hz', ...] 按像素数降序, {'WxH@Hz': DEVMODEW})""" seen = {} i = 0 while True: dm = DEVMODEW() dm.dmSize = ctypes.sizeof(dm) if not user32.EnumDisplaySettingsW(device, i, ctypes.byref(dm)): break i += 1 if dm.dmBitsPerPel != 32: continue key = f'{dm.dmPelsWidth}x{dm.dmPelsHeight}@{dm.dmDisplayFrequency}' seen.setdefault(key, dm) keys = sorted(seen, key=lambda k: tuple(int(v) for v in k.replace('@', 'x').split('x')), reverse=True) return keys, seen def apply_mode(device, dm): """把 device 切到 dm 模式(临时切换, 不写注册表, 重启还原), 返回 (成功?, 返回码)""" dm.dmFields = DM_BITSPERPEL | DM_PELSWIDTH | DM_PELSHEIGHT | DM_DISPLAYFREQUENCY r = user32.ChangeDisplaySettingsExW(device, ctypes.byref(dm), None, 0, None) return r == 0, r # DISP_CHANGE_SUCCESSFUL == 0 # ============================================================ # EDID 1.4 / CEA-861 解析 # ============================================================ # CTA-861 VIC 1..64 常用映射(65+ 及扩展 VIC 不展开) VIC_TABLE = [ '640x480@60', '720x480@60 4:3', '720x480@60 16:9', '1280x720@60', '1920x1080i@60', '720x480i@60 4:3', '720x480i@60 16:9', '720x240p@60 4:3', '720x240p@60 16:9', '1440x480i@60 4:3', '1440x480i@60 16:9', '1440x240p@60 4:3', '1440x240p@60 16:9', '1440x480p@60 4:3', '1440x480p@60 16:9', '1920x1080p@60', '720x576p@50 4:3', '720x576p@50 16:9', '1280x720p@50', '1920x1080i@50', '720x576i@50 4:3', '720x576i@50 16:9', '720x288p@50 4:3', '720x288p@50 16:9', '1440x576i@50 4:3', '1440x576i@50 16:9', '1440x288p@50 4:3', '1440x288p@50 16:9', '1440x576p@50 4:3', '1440x576p@50 16:9', '1920x1080p@50', '1920x1080p@24', '1920x1080p@25', '1920x1080p@30', '2880x480p@60 4:3', '2880x480p@60 16:9', '2880x576p@50 4:3', '2880x576p@50 16:9', '1920x1080i@50(1250)', '1920x1080i@100', '1280x720p@100', '720x576p@100 4:3', '720x576p@100 16:9', '720x576i@100 4:3', '720x576i@100 16:9', '1920x1080i@120', '1280x720p@120', '720x480p@120 4:3', '720x480p@120 16:9', '720x480i@120 4:3', '720x480i@120 16:9', '720x576p@200 4:3', '720x576p@200 16:9', '720x576i@200 4:3', '720x576i@200 16:9', '720x480p@240 4:3', '720x480p@240 16:9', '720x480i@240 4:3', '720x480i@240 16:9', '1280x720p@24', '1280x720p@25', '1280x720p@30', '1920x1080p@120', '1920x1080p@240', # VIC 65-88 为 3D/特殊格式, 少见不展开 *[None] * 24, '2560x1080p@50', '2560x1080p@60', '2560x1080p@100', '2560x1080p@120', '3840x2160p@24', '3840x2160p@25', '3840x2160p@30', '3840x2160p@50', '3840x2160p@60', '4096x2160p@24', ] # 已建立时序位域表(bytes 35-37, 共 24 位, '-' 为保留位) EST_TABLE = [ '720x400@70', '720x400@88', '640x480@60', '640x480@67', '640x480@72', '640x480@75', '800x600@56', '800x600@60', '800x600@72', '800x600@75', '832x624@75', '1024x768@87i', '1024x768@60', '1024x768@70', '1024x768@75', '1280x1024@75', '1152x870@75', '-', '-', '-', '-', '-', '-', '-', ] STD_ASPECT = {0: (10 / 16, '16:10'), 1: (3 / 4, '4:3'), 2: (4 / 5, '5:4'), 3: (9 / 16, '16:9')} DEPTHS = [0, 6, 8, 10, 12, 14, 16, 0] INTERFACES = ['未定义', 'DVI', 'HDMI-A', 'HDMI-B', 'MDDI', 'DisplayPort', '保留', '保留'] SYNC_TYPES = ['模拟合成', '双极模拟合成', '数字合成', '数字分离'] # CEA-861 数据块 tag(CTA-861 标准): 1=音频 2=视频 3=厂商专用(VSDB) # 4=扬声器分配 5=VESA DTC 6=视频格式 7=扩展 tag 集合 EXT_TAG_NAMES = { 0x00: '视频能力', 0x01: '厂商扩展', 0x02: 'VESA 显示设备', 0x04: 'HDMI 4K/8K 视频格式', 0x05: '色度学', 0x06: 'HDR 静态元数据', 0x07: 'HDR 动态元数据', 0x0C: 'YCbCr4:2:0 能力', 0x0E: 'YCbCr4:2:0 视频格式', 0x78: 'HDMI 论坛 SCDB', } def parse_dtd(raw, o): """解析 18 字节详细时序描述符, 返回 dict""" t = {} t['clk_khz'] = struct.unpack_from('> 4) | ((raw[o + 11] & 0x0C) << 2) t['vsync'] = (raw[o + 10] & 0x0F) | ((raw[o + 11] & 0x03) << 4) t['him_mm'] = raw[o + 12] | ((raw[o + 14] & 0xF0) << 4) t['vim_mm'] = raw[o + 13] | ((raw[o + 14] & 0x0F) << 8) fl = raw[o + 17] t['interlaced'] = bool(fl & 0x80) t['sync_type'] = SYNC_TYPES[(fl >> 2) & 3] t['polarity'] = ('V+' if fl & 0x02 else 'V-') + ' ' + ('H+' if fl & 0x01 else 'H-') t['htotal'] = t['hact'] + t['hblank'] t['vtotal'] = t['vact'] + t['vblank'] t['refresh'] = round(t['clk_khz'] * 1000.0 / (t['htotal'] * t['vtotal']), 1) return t def _ascii(raw, o, max_len): out = [] for i in range(max_len): c = raw[o + i] if c in (0x0A, 0x00): break if 32 <= c < 127: out.append(chr(c)) return ''.join(out) def parse_edid(raw): """解析 EDID(128 或 256 字节), 返回汇总 dict""" e = {'raw': raw, 'dtds': [], 'est': [], 'std': [], 'cea_video': [], 'cea_blocks': []} n = len(raw) e['cksum_base'] = (sum(raw[0:128]) & 0xFF) == 0 # 厂商 ID: 3 个 5bit 压缩大写字母 b0, b1 = raw[8], raw[9] e['manufacturer'] = ''.join([ chr(((b0 & 0x7C) >> 2) + 64), chr((((b0 & 0x03) << 3) | ((b1 & 0xE0) >> 5)) + 64), chr((b1 & 0x1F) + 64)]) e['product'] = struct.unpack_from('> 4) & 0x7] e['input'] = '数字 / ' + INTERFACES[raw[20] & 0x7] + \ (f' / {bpc}bpc' if bpc else ' / 色深未定义') else: e['input'] = '模拟 (VGA 输入)' e['wcm'], e['hcm'] = raw[21], raw[22] e['gamma'] = 0 if raw[23] == 0xFF else (raw[23] + 100) / 100.0 feats = [] if raw[24] & 0x80: feats.append('DPMS 待机') if raw[24] & 0x40: feats.append('DPMS 挂起') if raw[24] & 0x20: feats.append('DPMS 关闭') if raw[24] & 0x08: feats.append('sRGB 特性') if raw[24] & 0x04: feats.append('首选时序=原生') e['features'] = ', '.join(feats) # 已建立时序位域 for i in range(24): bit = 7 - (i % 8) if ((raw[35 + i // 8] >> bit) & 1) and EST_TABLE[i] != '-': e['est'].append(EST_TABLE[i]) # 标准时序(8 组 2 字节, 0x0101 = 未用) for i in range(38, 54, 2): a, b = raw[i], raw[i + 1] if (a == 1 and b == 1) or (a == 0 and b == 0): continue h = (a + 31) * 8 v = round(h * STD_ASPECT[(a >> 6) & 3][0]) e['std'].append(f'{h}x{v}@{b + 60}') # 基础块 4 个 18 字节描述符区: 前两字节非 0 即 DTD, 否则是监视器描述符 e['name'] = e['serial_text'] = e['range_text'] = '' for d in range(54, 126, 18): if raw[d] == 0 and raw[d + 1] == 0: tag = raw[d + 2] if tag == 0xFC: e['name'] = _ascii(raw, d + 5, 13) elif tag == 0xFF: e['serial_text'] = _ascii(raw, d + 5, 13) elif tag == 0xFD: e['range_text'] = (f"V {raw[d+5]}~{raw[d+6]}Hz, H {raw[d+7]}~{raw[d+8]}kHz, " f"像素时钟上限 {raw[d+9] * 10}MHz") else: e['dtds'].append(parse_dtd(raw, d)) # CEA-861 扩展块 e['has_ext'] = raw[126] > 0 and n >= 256 e['has_cea'] = False e['cea_rev'] = 0 e['cea_caps'] = '' e['cea_phys'] = '' e['cksum_ext'] = False if e['has_ext']: e['cksum_ext'] = (sum(raw[128:256]) & 0xFF) == 0 if raw[128] == 0x02: e['has_cea'] = True e['cea_rev'] = raw[129] caps = [] if raw[131] & 0x80: caps.append('欠扫描') if raw[131] & 0x40: caps.append('基本音频') if raw[131] & 0x20: caps.append('YCbCr4:4:4') if raw[131] & 0x10: caps.append('YCbCr4:2:2') e['cea_caps'] = ', '.join(caps) dtd_off = raw[130] p = 132 while dtd_off > 4 and (p - 128) < dtd_off and p + 1 < 256: tag = (raw[p] >> 5) & 7 length = raw[p] & 0x1F if length == 0: break data = raw[p + 1:p + 1 + length] if tag == 2: # 视频数据块: VIC 列表 for k in range(length): vic = data[k] & 0x7F native = ' (原生)' if data[k] & 0x80 else '' m = (VIC_TABLE[vic - 1] if 1 <= vic <= len(VIC_TABLE) else None) \ or f'VIC#{vic}' e['cea_video'].append(m + native) elif tag == 1: # 音频数据块: N 个 3 字节 SAD e['cea_blocks'].append(f'音频数据块 x{length // 3}') elif tag == 4: e['cea_blocks'].append('扬声器分配块') elif tag == 5: e['cea_blocks'].append('VESA DTC 块') elif tag == 6: e['cea_blocks'].append('视频格式块(VFDB)') elif tag == 3: # 厂商专用块: 前 3 字节 IEEE OUI(小端) oui = bytes(data[0:3]) if oui == b'\x03\x0c\x00': # HDMI 1.x VSDB: OUI 后 4 字节物理地址 if length >= 7: e['cea_phys'] = '.'.join(str(b) for b in data[3:7]) e['cea_blocks'].append('HDMI VSDB') elif oui == b'\xd8\x5d\xc4': # HDMI 2.0 HF-VSDB e['cea_blocks'].append('HDMI 2.0 HF-VSDB') else: e['cea_blocks'].append(f'厂商块 OUI={oui.hex(" ")}') elif tag == 7: ext = data[0] if length >= 1 else -1 name = EXT_TAG_NAMES.get(ext, f'0x{ext:02X}') e['cea_blocks'].append(f'扩展块({name})') else: e['cea_blocks'].append(f'保留块 tag{tag}') p += 1 + length if dtd_off >= 4: # 扩展块 DTD 区 q = 128 + dtd_off while q + 18 <= n: if raw[q] == 0 and raw[q + 1] == 0: break e['dtds'].append(parse_dtd(raw, q)) q += 18 return e # ============================================================ # 文本化 # ============================================================ def edid_info_lines(e): """[(项目, 值), ...] 摘要行""" lines = [] inch = 0 if e['wcm'] > 0 and e['hcm'] > 0: inch = round(math.hypot(e['wcm'], e['hcm']) / 2.54, 1) lines.append(('厂商 / 产品码', f"{e['manufacturer']} / 0x{e['product']:04X}")) lines.append(('显示器名称', e['name'] or '(未声明)')) if e['serial_text']: lines.append(('序列号(文本)', e['serial_text'])) lines.append(('生产周 / 年', f"第 {e['week']} 周 / {e['year']} 年")) lines.append(('EDID 版本', e['ver'])) lines.append(('输入类型', e['input'])) if inch > 0: lines.append(('屏幕尺寸', f"{e['wcm']}cm x {e['hcm']}cm (约 {inch} 英寸)")) if e['gamma'] > 0: lines.append(('Gamma', e['gamma'])) if e['features']: lines.append(('特性', e['features'])) if e['range_text']: lines.append(('范围限制', e['range_text'])) if e['has_ext']: ck = 'OK' if e['cksum_ext'] else '错误!' lines.append(('扩展块', f"CEA-861 rev.{e['cea_rev']} 校验和 {ck}")) if e['cea_caps']: lines.append(('CEA 能力', e['cea_caps'])) if e['cea_phys']: lines.append(('HDMI 物理地址', e['cea_phys'])) else: lines.append(('扩展块', '无')) lines.append(('基础块校验和', 'OK (0x00)' if e['cksum_base'] else '错误!')) # 每个 DTD 的完整时序参数(硬件调试重点) for k, t in enumerate(e['dtds']): tag = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}' lines.append((f'{tag} 模式', f"{t['hact']}x{t['vact']}@{t['refresh']} {t['clk_khz'] / 1000:.3f} MHz")) lines.append((f'{tag} 总时序', f"{t['htotal']}x{t['vtotal']}")) lines.append((f'{tag} 水平', f"有效 {t['hact']} | 前沿 {t['hfront']} | 同步 {t['hsync']} | " f"后沿 {t['htotal'] - t['hact'] - t['hfront'] - t['hsync']}")) lines.append((f'{tag} 垂直', f"有效 {t['vact']} | 前沿 {t['vfront']} | 同步 {t['vsync']} | " f"后沿 {t['vtotal'] - t['vact'] - t['vfront'] - t['vsync']}")) lines.append((f'{tag} 同步', f"{t['sync_type']} {t['polarity']} " f"{'(隔行)' if t['interlaced'] else '(逐行)'}")) if t['him_mm'] > 0: lines.append((f'{tag} 图像尺寸', f"{t['him_mm']}x{t['vim_mm']} mm")) return lines def hex_dump(b): out = [] for row in range((len(b) + 15) // 16): off = row * 16 chunk = b[off:off + 16] hx = ' '.join(f'{v:02X}' for v in chunk) asc = ''.join(chr(v) if 32 <= v < 127 else '.' for v in chunk) out.append(f'{off:04X}: {hx:<48} {asc}') return '\n'.join(out) # ============================================================ # 测试图案窗口(tkinter) — 测试卡区域可选 640x360/800x600/全屏 # ============================================================ PAT_W, PAT_H = 640, 360 AREA_OPTIONS = ('640x360', '800x600', 'full') DEFAULT_AREA = AREA_OPTIONS[0] PATTERN_NAMES = {'Red': '纯红', 'Green': '纯绿', 'Blue': '纯蓝', 'Checker': '黑白棋盘格', 'Grid': '网格线'} # 纯色测试值(位交换测试用, 与 tools/dist/solid_*.png 对应): # 每通道 55/AA 互为镜像对(检测对), FF 全高基准 SOLID_ROWS = ( ('R', (('55', '#550000'), ('AA', '#AA0000'), ('FF', '#FF0000'))), ('G', (('55', '#005500'), ('AA', '#00AA00'), ('FF', '#00FF00'))), ('B', (('55', '#000055'), ('AA', '#0000AA'), ('FF', '#0000FF'))), ) def _area_box(spec, w, h): """区域选项 -> (x0, y0, aw, ah): 'full' 铺满整个屏幕, 'WxH' 黑底居中""" if spec == 'full': return 0, 0, w, h try: aw, ah = (int(v) for v in spec.lower().split('x', 1)) except Exception: aw, ah = PAT_W, PAT_H return (w - aw) // 2, (h - ah) // 2, aw, ah def _open_fullscreen(parent, rect, auto_close=0): """创建覆盖 rect(x,y,w,h) 的无边框全屏黑底窗口, 返回 (top, cv) 只支持 ESC 退出: overrideredirect 窗口必须 focus_force 强取键盘焦点, 否则收不到按键(Windows 上该组合有效)。 """ import tkinter as tk rx, ry, rw, rh = rect top = tk.Toplevel(parent) top.overrideredirect(True) top.geometry(f'{rw}x{rh}+{rx}+{ry}') top.attributes('-topmost', True) top.configure(bg='black') cv = tk.Canvas(top, bg='black', highlightthickness=0, bd=0) cv.pack(fill='both', expand=True) def close(_evt=None): top.destroy() top.bind('', close) if auto_close > 0: top.after(auto_close * 1000, close) top.focus_force() return top, cv def show_pattern(parent, rect, kind, auto_close=0, area=DEFAULT_AREA): """在 rect 指定的屏幕上全屏出居中测试卡(无任何 OSD 修饰), 区域由 area 指定""" top, cv = _open_fullscreen(parent, rect, auto_close) def draw(_evt=None): cv.delete('all') w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3]) x0, y0, aw, ah = _area_box(area, w, h) if kind.startswith('#'): # 纯色测试值: 直接填充指定颜色 cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill=kind, outline='') elif kind == 'Red': cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#FF0000', outline='') elif kind == 'Green': cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#00FF00', outline='') elif kind == 'Blue': cv.create_rectangle(x0, y0, x0 + aw, y0 + ah, fill='#0000FF', outline='') elif kind == 'Checker': # 40px 黑白格, 格数随区域尺寸自适应(640x360 = 16x9 格) for gy in range((ah + 39) // 40): for gx in range((aw + 39) // 40): if (gx + gy) % 2 == 0: cv.create_rectangle(x0 + gx * 40, y0 + gy * 40, x0 + (gx + 1) * 40, y0 + (gy + 1) * 40, fill='#FFFFFF', outline='') elif kind == 'Grid': # 20px 白色网格线, 黑底, 无其它修饰 for k in range(aw // 20 + 1): cv.create_line(x0 + k * 20, y0, x0 + k * 20, y0 + ah, fill='#FFFFFF') for k in range(ah // 20 + 1): cv.create_line(x0, y0 + k * 20, x0 + aw, y0 + k * 20, fill='#FFFFFF') top.bind('', draw) top.after(50, draw) top.wait_window(top) def show_pattern_image(parent, rect, path, auto_close=0, area=DEFAULT_AREA): """全屏黑底, 图片 1:1 居中于 area 指定的测试卡区域: 小于区域 -> 真实像素居中, 四周黑边; 大于区域 -> 等效从中心截取(区域外用黑矩形遮挡)""" top, cv = _open_fullscreen(parent, rect, auto_close) def draw(_evt=None): cv.delete('all') w, h = max(cv.winfo_width(), rect[2]), max(cv.winfo_height(), rect[3]) x0, y0, aw, ah = _area_box(area, w, h) photo = load_image_raw(path) top._photo = photo # 挂在窗口上防止被垃圾回收 iw, ih = photo.width(), photo.height() cv.create_image(x0 + (aw - iw) // 2, y0 + (ah - ih) // 2, image=photo, anchor='nw') # 四块黑矩形盖住测试卡区域之外 -> 大图等效中心裁剪 cv.create_rectangle(-2, -2, w + 2, y0, fill='black', outline='') cv.create_rectangle(-2, y0 + ah, w + 2, h + 2, fill='black', outline='') cv.create_rectangle(-2, y0, x0, y0 + ah, fill='black', outline='') cv.create_rectangle(x0 + aw, y0, w + 2, y0 + ah, fill='black', outline='') top.bind('', draw) top.after(50, draw) top.wait_window(top) # ============================================================ # 主 GUI # ============================================================ def run_gui(auto_close=0): import tkinter as tk from tkinter import ttk # 让任务栏图标独立显示(不归并到 Python/通用组), 须在创建窗口前调用 try: ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('hulk.HDMI-Tool') except Exception: pass class App(tk.Tk): def __init__(self): super().__init__() self.title('HDMI-Tool — EDID 查看器 + 测试图案 (LT8619C 转接板调试)') self.geometry('960x880') ic = get_icon_path() if ic: try: self.iconbitmap(ic) # 窗口标题栏图标 except Exception: pass self.displays = [] self.slots, self.area = load_slots() self._modes = {} # 'WxH@Hz' -> DEVMODEW(当前所选屏) self._orig_modes = {} # device -> 切改前的原始 DEVMODEW self._build() def _build(self): # --- 顶部: 显示器选择 --- top = ttk.Frame(self, padding=(8, 6)) top.pack(fill='x') ttk.Label(top, text='显示器:').pack(side='left') self.cbo = ttk.Combobox(top, state='readonly', width=62) self.cbo.pack(side='left', padx=6) self.cbo.bind('<>', self._on_select) ttk.Button(top, text='刷新', command=self.load_displays, width=8).pack(side='left') self.lbl_cur = ttk.Label(top, text='') self.lbl_cur.pack(side='left', padx=12) # --- 顶部第二行: 分辨率切换 --- top2 = ttk.Frame(self, padding=(8, 2)) top2.pack(fill='x') ttk.Label(top2, text='分辨率:').pack(side='left') self.cbo_mode = ttk.Combobox(top2, state='readonly', width=16) self.cbo_mode.pack(side='left', padx=6) ttk.Button(top2, text='应用', width=8, command=self._apply_mode).pack(side='left') ttk.Button(top2, text='恢复', width=8, command=self._restore_mode).pack(side='left', padx=6) ttk.Label(top2, text='列表为显卡按该屏 EDID 提供的模式; 切换临时生效, 重启还原', foreground='#666666').pack(side='left', padx=10) # --- 中部: 模式列表 + EDID 信息 --- mid = ttk.Frame(self, padding=(8, 0)) mid.pack(fill='both', expand=True) left = ttk.LabelFrame(mid, text='支持的模式 (EDID 解算)', padding=4) left.pack(side='left', fill='both', expand=True) self.tv_modes = ttk.Treeview(left, columns=('mode', 'hz', 'clk', 'src'), show='headings', height=12) for col, txt, w, anchor in (('mode', '模式', 110, 'w'), ('hz', '刷新率', 65, 'e'), ('clk', '像素时钟', 80, 'e'), ('src', '来源', 90, 'w')): self.tv_modes.heading(col, text=txt) self.tv_modes.column(col, width=w, anchor=anchor) sb1 = ttk.Scrollbar(left, orient='vertical', command=self.tv_modes.yview) self.tv_modes.configure(yscrollcommand=sb1.set) self.tv_modes.pack(side='left', fill='both', expand=True) sb1.pack(side='right', fill='y') right = ttk.LabelFrame(mid, text='EDID 信息', padding=4) right.pack(side='left', fill='both', expand=True, padx=(6, 0)) self.tv_info = ttk.Treeview(right, columns=('k', 'v'), show='headings', height=12) self.tv_info.heading('k', text='项目') self.tv_info.heading('v', text='值') self.tv_info.column('k', width=150, anchor='w') self.tv_info.column('v', width=420, anchor='w') sb2 = ttk.Scrollbar(right, orient='vertical', command=self.tv_info.yview) self.tv_info.configure(yscrollcommand=sb2.set) self.tv_info.pack(side='left', fill='both', expand=True) sb2.pack(side='right', fill='y') # --- 测试图案按钮组 --- grp = ttk.LabelFrame(self, text='测试图案 — 全屏投至所选显示器, 黑底 1:1 居中, 按 ESC 退出', padding=(10, 6)) grp.pack(fill='x', padx=8, pady=(6, 0)) row1 = ttk.Frame(grp) row1.pack(fill='x') for key in ('Red', 'Green', 'Blue', 'Checker', 'Grid'): ttk.Button(row1, text=PATTERN_NAMES[key], width=12, command=lambda k=key: self._show_pattern(k)).pack(side='left', padx=5) # 纯色测试值三行(R/G/B 按通道分组, 位交换测试用) for ch, items in SOLID_ROWS: rowf = ttk.Frame(grp) rowf.pack(fill='x', pady=(3, 0)) ttk.Label(rowf, text=f'{ch}: 0x', width=4).pack(side='left') for label, color in items: ttk.Button(rowf, text=label, width=5, command=lambda c=color: self._show_solid(c) ).pack(side='left', padx=3) ttk.Label(row1, text='测试卡区域:').pack(side='left', padx=(16, 2)) self.area_var = tk.StringVar(value=self.area) cbo_area = ttk.Combobox(row1, textvariable=self.area_var, width=8, state='readonly', values=list(AREA_OPTIONS)) cbo_area.pack(side='left') cbo_area.bind('<>', lambda _e: self._save_area()) row2 = ttk.Frame(grp) row2.pack(fill='x', pady=(4, 0)) self.slot_buttons = [] for i in range(SLOT_COUNT): cell = ttk.Frame(row2) cell.pack(side='left', padx=(5, 0)) btn = ttk.Button(cell, text=self.slots[i]['name'], width=12, command=lambda i=i: self._show_custom(i)) btn.pack(side='left') ttk.Button(cell, text='⚙', width=3, command=lambda i=i: self._configure_slot(i)).pack(side='left', padx=(4, 0)) self.slot_buttons.append(btn) ttk.Label(row2, text='自定义: 点击投图, ⚙ 配置名称与图片(图片保存在脚本目录)', foreground='#666666').pack(side='left', padx=10) # --- 底部: EDID hex dump --- bot = ttk.LabelFrame(self, text='EDID 原始数据', padding=4) bot.pack(fill='both', expand=False, padx=8, pady=(6, 8)) self.txt_hex = tk.Text(bot, height=8, font=('Consolas', 9), state='disabled', wrap='none', background='#101010', foreground='#C8C8C8') sb3 = ttk.Scrollbar(bot, orient='vertical', command=self.txt_hex.yview) self.txt_hex.configure(yscrollcommand=sb3.set) self.txt_hex.pack(side='left', fill='both', expand=True) sb3.pack(side='right', fill='y') def load_displays(self): keep = self.cbo.current() self.displays = get_displays() self.cbo['values'] = [f"{d['device']} {d['adapter']} [{d['mon_name']}]" for d in self.displays] if self.displays: self.cbo.current(max(0, min(keep, len(self.displays) - 1))) self._on_select() else: self.lbl_cur.config(text='未找到活动显示器') def _on_select(self, _evt=None): idx = self.cbo.current() if idx < 0 or idx >= len(self.displays): return d = self.displays[idx] self.lbl_cur.config(text=f"当前输出: {d['cur_mode']}") # 分辨率下拉: 该屏可用模式, 默认选中当前模式 keys, modes = enum_modes(d['device']) self._modes = modes self.cbo_mode['values'] = keys if d['cur_mode'] in modes: self.cbo_mode.set(d['cur_mode']) elif keys: self.cbo_mode.set(keys[0]) for tv in (self.tv_modes, self.tv_info): tv.delete(*tv.get_children()) if not d['edid']: self.tv_info.insert('', 'end', values=('EDID', '读取失败(注册表无 EDID 数据)')) return e = parse_edid(d['edid']) for k, v in edid_info_lines(e): self.tv_info.insert('', 'end', values=(k, v)) for k, t in enumerate(e['dtds']): src = 'DTD 首选' if k == 0 else 'DTD' self.tv_modes.insert('', 'end', values=( f"{t['hact']}x{t['vact']}", f"{t['refresh']}Hz", f"{t['clk_khz'] / 1000:.2f}MHz", src)) for m in e['cea_video']: wh, _, hz = m.partition('@') self.tv_modes.insert('', 'end', values=(wh, hz.split(' ')[0] + 'Hz', '', 'CEA-VIC')) for m in e['est'] + e['std']: wh, _, hz = m.partition('@') src = '已建立时序' if m in e['est'] else '标准时序' self.tv_modes.insert('', 'end', values=(wh, hz + 'Hz', '', src)) self.txt_hex.configure(state='normal') self.txt_hex.delete('1.0', 'end') self.txt_hex.insert('1.0', hex_dump(d['edid'])) self.txt_hex.configure(state='disabled') def _save_area(self): self.area = self.area_var.get() save_slots(self.slots, self.area) def _show_solid(self, color): idx = self.cbo.current() if idx < 0 or idx >= len(self.displays): return d = self.displays[idx] if not d['rect']: return show_pattern(self, d['rect'], color, area=self.area_var.get()) def _apply_mode(self): from tkinter import messagebox idx, mi = self.cbo.current(), self.cbo_mode.current() if idx < 0 or idx >= len(self.displays) or mi < 0: return dm = self._modes.get(self.cbo_mode.get()) if dm is None: return dev = self.displays[idx]['device'] if dev not in self._orig_modes: # 记住首次切改前的原始模式, 供"恢复"用 cur = DEVMODEW() cur.dmSize = ctypes.sizeof(cur) if user32.EnumDisplaySettingsW(dev, ENUM_CURRENT_SETTINGS, ctypes.byref(cur)): self._orig_modes[dev] = cur ok, r = apply_mode(dev, dm) if not ok: messagebox.showerror('切换失败', f'ChangeDisplaySettingsEx 返回码 {r}') return self.after(500, self.load_displays) # 等模式生效后刷新矩形/EDID 面板 def _restore_mode(self): from tkinter import messagebox idx = self.cbo.current() if idx < 0 or idx >= len(self.displays): return dev = self.displays[idx]['device'] dm = self._orig_modes.pop(dev, None) if dm is None: messagebox.showinfo('无可恢复', '该显示器没有记录切改前的模式') return apply_mode(dev, dm) self.after(500, self.load_displays) def _show_pattern(self, kind): idx = self.cbo.current() if idx < 0 or idx >= len(self.displays): return d = self.displays[idx] if not d['rect']: return show_pattern(self, d['rect'], kind, area=self.area_var.get()) def _show_custom(self, idx): from tkinter import messagebox slot = self.slots[idx] if not slot['file']: messagebox.showinfo('未配置图片', f'请先点击"⚙"为"{slot["name"]}"配置图片。') return path = os.path.join(BASE_DIR, slot['file']) if not os.path.exists(path): messagebox.showerror('图片不存在', path) return di = self.cbo.current() if di < 0 or di >= len(self.displays) or not self.displays[di]['rect']: return show_pattern_image(self, self.displays[di]['rect'], path, area=self.area_var.get()) def _configure_slot(self, idx): from tkinter import filedialog, messagebox dlg = tk.Toplevel(self) dlg.title(f'配置自定义栏位 {idx + 1}') dlg.resizable(False, False) dlg.grab_set() name_var = tk.StringVar(value=self.slots[idx]['name']) file_var = tk.StringVar(value=self.slots[idx]['file'] or '(未设置)') body = ttk.Frame(dlg, padding=10) body.pack() ttk.Label(body, text='按钮名称:').grid(row=0, column=0, sticky='w', pady=4) ttk.Entry(body, textvariable=name_var, width=30).grid(row=0, column=1, columnspan=2, sticky='w', padx=6) ttk.Label(body, text='图片文件:').grid(row=1, column=0, sticky='w', pady=4) ttk.Label(body, textvariable=file_var, foreground='#0066CC').grid( row=1, column=1, columnspan=2, sticky='w', padx=6) if not HAS_PIL: ttk.Label(body, text='(未安装 Pillow, 仅支持 PNG/GIF; 安装 pillow 后可用 JPG/BMP/WebP)', foreground='#B05000').grid(row=2, column=0, columnspan=3, sticky='w') def choose(): exts = ' '.join(f'*{e}' for e in IMG_EXTS) path = filedialog.askopenfilename(parent=dlg, title='选择图片', filetypes=[('图片文件', exts), ('所有文件', '*.*')]) if not path: return try: file_var.set(copy_image_into_script_dir(path)) except OSError as ex: messagebox.showerror('复制图片失败', str(ex), parent=dlg) def clear(): file_var.set('(未设置)') def save(): name = name_var.get().strip() or f'自定义{idx + 1}' fname = file_var.get() self.slots[idx] = {'name': name, 'file': None if fname.startswith('(') else fname} save_slots(self.slots, self.area_var.get()) self.slot_buttons[idx].configure(text=name) dlg.destroy() row = 3 if not HAS_PIL else 2 ttk.Button(body, text='选择图片...', command=choose).grid( row=row, column=0, sticky='w', pady=(8, 0)) ttk.Button(body, text='清除图片', command=clear).grid( row=row, column=1, sticky='w', padx=6, pady=(8, 0)) ttk.Button(body, text='保存', command=save).grid( row=row + 1, column=0, sticky='w', pady=(8, 0)) ttk.Button(body, text='取消', command=dlg.destroy).grid( row=row + 1, column=1, sticky='w', padx=6, pady=(8, 0)) app = App() if auto_close > 0: app.after(auto_close * 1000, app.destroy) app.mainloop() # ============================================================ # 入口 # ============================================================ def run_dump(): displays = get_displays() if not displays: print('未找到活动显示器') return 1 for i, d in enumerate(displays, 1): print() print(f'== 显示器 {i} {d["device"]} 显卡[{d["adapter"]}] ' f'监视器[{d["mon_name"]}] 当前输出 {d["cur_mode"]}') if not d['edid']: print(' EDID 读取失败(注册表无 EDID 数据)') continue e = parse_edid(d['edid']) for k, v in edid_info_lines(e): print(f' {k:<14}: {v}') print(' ---- 支持的模式 ----') for k, t in enumerate(e['dtds']): src = 'DTD1(首选)' if k == 0 else f'DTD{k + 1}' print(f" [{src}] {t['hact']}x{t['vact']}@{t['refresh']} " f"{t['clk_khz'] / 1000:.3f}MHz 总{t['htotal']}x{t['vtotal']}") for m in e['cea_video']: print(f' [CEA-VIC] {m}') for m in e['cea_blocks']: print(f' [CEA-块] {m}') for m in e['est']: print(f' [已建立] {m}') for m in e['std']: print(f' [标准] {m}') print(' ---- EDID 原始数据 ----') print(hex_dump(d['edid'])) return 0 def main(): ap = argparse.ArgumentParser(description='HDMI EDID 查看器 + 测试图案') ap.add_argument('--dump', action='store_true', help='控制台输出 EDID 解算结果, 不开 GUI') ap.add_argument('--test-pattern', choices=list(PATTERN_NAMES) + [f'Custom{i + 1}' for i in range(SLOT_COUNT)], default=None, help='直接全屏出测试图案(不开主窗口; CustomN 为自定义栏位)') ap.add_argument('--area', choices=list(AREA_OPTIONS), default=DEFAULT_AREA, help='测试卡区域: WxH 黑底居中 或 full 铺满所选显示器(默认 640x360)') ap.add_argument('--monitor', type=int, default=1, help='--test-pattern 用的显示器序号(默认 1)') ap.add_argument('--seconds', type=int, default=0, help='自动退出秒数(GUI/图案冒烟测试)') args = ap.parse_args() if args.dump: if getattr(sys, 'frozen', False): # --noconsole 打包的 exe 没有控制台可写, 引导用户用源码方式 import tkinter as tk from tkinter import messagebox r = tk.Tk() r.withdraw() messagebox.showinfo('不支持', 'exe 版本无控制台输出。\n请用源码方式运行:\npython hdmi_gui.py --dump') sys.exit(1) sys.exit(run_dump()) if args.test_pattern: displays = get_displays() if not displays: print('未找到活动显示器') sys.exit(1) d = displays[min(args.monitor, len(displays)) - 1] if not d['rect']: print('无法获取显示器矩形') sys.exit(1) import tkinter as tk root = tk.Tk() root.withdraw() if args.test_pattern.startswith('Custom'): slots, _ = load_slots() slot = slots[int(args.test_pattern[6:]) - 1] if not slot['file']: print(f"错误: {args.test_pattern}({slot['name']})未配置图片") sys.exit(1) show_pattern_image(root, d['rect'], os.path.join(BASE_DIR, slot['file']), args.seconds, area=args.area) else: show_pattern(root, d['rect'], args.test_pattern, args.seconds, area=args.area) sys.exit(0) run_gui(args.seconds) if __name__ == '__main__': main()