Files
HW-Debug-Tools/HDMI-Tool/hdmi_gui.py
T

1127 lines
48 KiB
Python

#!/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\\<pnp>\\<inst>' → 注册表 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('<H', raw, o)[0] * 10 # 10kHz 单位
t['hact'] = raw[o + 2] | ((raw[o + 4] & 0xF0) << 4)
t['hblank'] = raw[o + 3] | ((raw[o + 4] & 0x0F) << 8)
t['vact'] = raw[o + 5] | ((raw[o + 7] & 0xF0) << 4)
t['vblank'] = raw[o + 6] | ((raw[o + 7] & 0x0F) << 8)
t['hfront'] = raw[o + 8] | ((raw[o + 11] & 0xC0) << 2)
t['hsync'] = raw[o + 9] | ((raw[o + 11] & 0x30) << 4)
t['vfront'] = ((raw[o + 10] & 0xF0) >> 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('<H', raw, 10)[0]
e['serial_num'] = struct.unpack_from('<I', raw, 12)[0]
e['week'] = raw[16]
e['year'] = 1990 + raw[17]
e['ver'] = f'{raw[18]}.{raw[19]}'
if raw[20] & 0x80:
bpc = DEPTHS[(raw[20] >> 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('<Escape>', 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('<Configure>', 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('<Configure>', 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('<<ComboboxSelected>>', 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('<<ComboboxSelected>>', 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()