Imported from luckFan0519/han-card-tracker (
AGENTS.md). Install upstream withnpx skills add luckFan0519/han-card-tracker. Copyright stays with the author.
AGENTS 指南(han_card_tracker)
重要:每次修改代码/文件结构/配置项后,必须主动检查本文件(AGENTS.md)是否需要同步更新。 若架构、数据流、配置项、文件路径等发生了变化,应及时修正本文件中的对应描述,确保文档始终与代码一致。
0) AI 开发标准(写代码前必读)
项目在 AI开发标准/ 目录下维护了代码风格规范,所有新增/修改代码必须遵守:
AI开发标准/开发总原则.md:效率与优美优先、变更策略、输出要求。AI开发标准/Python 代码注释规范.md:- 所有函数/方法/类必须写 Google 风格 Docstring(中文说明 + 英文关键字:
Args / Returns / Raises / Examples / Attributes / TODO)。 - 类型注解必须完整(函数参数、返回值;类属性在
Attributes写明)。 - 异常必须在
Raises中逐条写清触发条件。 - 示例必须可运行(
>>>形式)。 - 输入校验:类型不对抛
TypeError,取值/业务约束不满足抛ValueError。
- 所有函数/方法/类必须写 Google 风格 Docstring(中文说明 + 英文关键字:
AI开发标准/Python 类型注解规范.md:- 优先使用现代写法(Python 3.10+):
X | Y、list[str]、dict[str, int]。 - 每个函数必须标注参数类型 + 返回值类型;无返回值用
-> None。 - 可选值必须显式写出
None:str | None。 - 不要滥用
Any;能用TypedDict/Protocol/Mapping表达就不用Any。 - 集合/序列参数优先用抽象类型:只读用
Sequence[T]/Mapping[K, V],会修改用list[T]/dict[K, V]。
- 优先使用现代写法(Python 3.10+):
硬性约束:输出的每一个 def 都必须带 Docstring,每一个 class 都必须带类 Docstring 并为 __init__ 写 Docstring。
1) 先看什么(最快进入状态)
- 入口是
main.py:创建QApplication、加载ui/ui.qss、启动CardUI。 - 业务主链路:
ui/main_window.py→core/inference_process.py→core/game_controller.py→core/games/<game>.py→core/base_tracker.py→core/yolo_inferencer.py→core/screen_capture.py。 - 配置集中在
config/settings.py(运行时常量 + 保存函数)与config/config.yaml(可编辑参数)。 - 游戏配置在
config/games/目录下,每个游戏一个 YAML 文件(如doudizhu.yaml)。 - AI 开发规范详见
AI开发标准/目录(开发总原则 + 代码注释规范 + 类型注解规范),已在第 0 节概述。
2) 架构与数据流(改逻辑前必须理解)
多进程架构
- 主进程(UI):
CardUI(ui/main_window.py)+InferenceWorker(core/inference_process.py) - 子进程(推理):
GameController(core/game_controller.py)+ 五个核心组件 - 通信方式:
multiprocessing.Queue(命令队列cmd_queue+ 结果队列result_queue) - UI 不因推理耗时而卡顿;子进程不操作任何 Qt 对象
命令协议(cmd_queue)
| 命令 | 说明 |
|---|---|
"detect" |
执行一次识别 |
"reset" |
重置记牌器状态 |
("switch_layout", name) |
切换布局(重建 LayoutAnalyzer + ScreenCapture + Tracker) |
("switch_model", name) |
切换模型(重建 YoloInferencer + Tracker) |
("switch_device", name) |
切换设备(重建 YoloInferencer) |
("switch_game", name) |
切换游戏(重载游戏配置 + 重建 Tracker) |
("update_settings", dict) |
同步运行时设置到子进程 |
("touch_time",) |
刷新 no_target_time(防暂停后超时重置) |
None |
终止子进程 |
结果协议(result_queue)
- 成功:
("ok", {"remain_cards": dict[str, int], "zone_cards": dict[str, list[list[str]]]}, yolo_ms) - 失败:
("error", str)
其中 zone_cards 的 key 为游戏配置中 played_zones 的 key 字段,value 为该区域的出牌记录。
检测流水线
GameController.detect() 执行以下五步:
- 截图:
ScreenCapture.capture_window()→PIL.Image | None - YOLO 纯视觉推理:
YoloInferencer.detect(img)→(raw_result, yolo_ms) - 几何分区 + 二维排序:
LayoutAnalyzer.parse_and_sort(raw_result, img.size)→dict[str, list[dict]] - 业务映射:
Tracker.translate_boxes_to_cards(region_boxes)→dict[str, list[str]] - 状态机更新:
Tracker.get_cards_number(frame_data, yolo_ms)→(remain_cards, show_cards, yolo_ms)
分区与排序
- 分区依据检测框中心点是否落在
window_layouts[*].layout区域内;区域坐标为归一化(0~1) - 区域键名从游戏配置的
layout_regions动态获取 - 排序不是置信度顺序:
LayoutAnalyzer.sort_cards_by_topright_rowwise()先按行(top-y 聚类)再按列(right-x 排序),避免出牌串顺序错乱
多游戏支持
BaseCardTracker(core/base_tracker.py)为抽象基类- 子类在
core/games/目录下(如DoudizhuTracker) - 工厂函数
create_tracker()根据TRACKER_REGISTRY动态创建 - 新增游戏只需:① 在
config/games/添加 YAML 配置 ② 在core/games/添加 Tracker 子类 ③ 在TRACKER_REGISTRY注册
3) 状态机约定(核心业务规则)
三阶段状态机
状态常量在 config/settings.py:
WAIT_BEGIN (0) → HAS_STARTED (1) → STARTED_RECORD_CARD (2)
↓ (超时无目标)
WAIT_BEGIN (0)
帧有效性门禁
BaseCardTracker._presses_one_frame()以_get_validity_region()返回的区域是否为空作为"本帧是否有效"的门禁- 空则直接丢弃,不更新状态也不重置
no_target_time - 子类实现
_get_validity_region()指定判断区域(斗地主用"landlord_cards")
连续帧确认
- 由
FRAME_LENGTH决定,_check_card()要求缓存帧长度达到阈值且所有帧完全一致才算稳定 - 缓存长度超过
FRAME_LENGTH时丢弃最旧帧
扣牌规则
- 扣牌只在确认时发生:
_delete_played_cards()直接减少remain_cards - 为支持"连续两次出相同牌",使用
has_found_empty字典处理"不出牌空帧"过渡
超时重置
get_cards_number()中若超过RESET_TIME未见有效目标则reset()
子类必须实现的抽象方法
| 方法 | 说明 |
|---|---|
_get_validity_region() |
返回帧有效性判断区域键名 |
should_start_game() |
判断是否满足开始游戏条件 |
should_start_recording() |
判断是否满足开始记牌条件 |
on_game_started() |
游戏开始时的回调 |
on_start_recording() |
开始记牌时的回调 |
process_played_cards(zone_key, cards) |
处理确认的出牌 |
斗地主特有逻辑(DoudizhuTracker)
- 开始游戏条件:地主底牌连续帧稳定
- 开始记牌条件:玩家手牌连续帧稳定;开始记牌时扣减手牌
- 出牌处理:上家/下家出牌扣减剩余牌数;本家出牌仅记录不扣减(已在
on_start_recording中扣减)
4) 配置与持久化模式
配置层级
| 配置来源 | 文件/模块 | 说明 |
|---|---|---|
config/config.yaml |
运行时可编辑参数 | 用户可直接修改 |
config/games/*.yaml |
游戏配置 | 牌组、出牌区域、布局区域 |
config/settings.py |
运行时常量 + 加载/保存函数 | 不直接编辑 YAML |
关键约定
- 所有设置写回都走
config/settings.py的save_*函数,不要在别处直接改 YAML - 写配置使用"临时文件 +
os.replace"原子替换模式(见save_device_choice等函数) TOTAL_CARDS、PLAYED_ZONES、LAYOUT_REGIONS从游戏配置加载;YOLO_TO_CARD_MAPPING从config.yaml加载- 设备切换通过
switch_device命令即时生效,子进程重建YoloInferencer - 游戏切换通过
switch_game命令即时生效,子进程重载游戏配置并重建Tracker - 模型切换通过
switch_model命令即时生效,子进程重建YoloInferencer+Tracker - 布局切换通过
switch_layout命令即时生效,子进程重建LayoutAnalyzer+ScreenCapture+Tracker
资源目录解析
BASE_DIR:可写运行目录(项目根目录或 exe 所在目录)RESOURCE_DIR:资源读取目录(兼容 PyInstaller 的_internal布局)_resolve_resource_dir():智能解析,支持打包后从_internal读取资源_ensure_runtime_config_exists():冻结环境下首次运行时复制配置到可写目录
5) UI 交互与进程边界
进程隔离
- 推理在独立子进程中运行,不与 UI 主进程共享 GIL
InferenceWorker是主进程中的QObject,通过轮询定时器(20ms)从结果队列读取数据,以信号形式通知 UI- 不要在子进程中操作任何 Qt 对象;子进程只能通过
result_queue回传纯 Python 数据
UI 更新机制
- 定时触发:
CardUI.request_one_update()用QTimer+_busy防抖,每轮只允许一个后台任务 - 增量刷新:通过
_last_count_values/_last_depleted_values/_last_played_signature缓存,仅在状态变化时更新文本/样式 - 样式依赖动态属性:
depleted与count,更新后需unpolish/polish触发 QSS 刷新
窗口置顶
- 调整窗口置顶使用
setWindowFlag后,会调用_ensure_widgets_attached()修复可能丢失的控件挂载 - 设置对话框打开期间暂停定时器,关闭后调用
_touch_no_target_time()防止立即超时重置
出牌记录显示
- 由
_show_played_cards控制,通过_update_played_cards_visibility()动态挂载/隐藏布局 - 出牌区域标签从游戏配置的
played_zones动态生成 played_cards_labels为dict[str, QLabel],key 为区域键名
6) 扩展工作流(布局/调试)
可视化布局编辑(推荐)
在主界面"设置"→"基本设置"页中点击"可视化编辑",打开 LayoutEditorDialog:
- 选择目标窗口标题
- 点击"截图"(自动下沉本程序窗口避免遮挡)
- 在画布上框选五个牌区:玩家手牌 / 本家出牌 / 上家出牌 / 下家出牌 / 地主底牌
- 点击"预览校验"确认区域标注正确
- "保存"写入
config.yaml
截图时窗口下沉/恢复通过 _send_app_windows_to_bottom() / _restore_app_windows_after_capture() 保证资源安全释放。
设置页面提供 ↻ 刷新按钮手动刷新布局列表,支持在布局下拉项中直接删除布局。
命令行工具(高级/调试用)
utils/add_layout/list_windows.py:列出窗口标题utils/add_layout/draw_layout.py:手动绘制区域
运行主程序
python main.py
7) 调试截图资产约定
- 统一由
core/image_saver.py管理,不要在YoloInferencer/BaseCardTracker外部手写落盘逻辑 - 保存内容是"成对帧":原始截图到
games_images/row/,YOLO 标注图到games_images/yolo/,同局同帧同名 - 每局目录命名固定为
game_N(game_1,game_2, ...),帧命名固定为1.png递增 - 单局最多
1000张;达到上限后该局后续帧不保存,并且仅打印一次提示 - 全局只保留最近
3局;新局创建后会清理更旧目录 - 保存图片与调试模式分开控制:
SAVE_DEBUG_IMAGES控制是否保存游戏图片,DEBUG_MODE仅控制终端日志输出 ImageSaver由GameController统一管理,传入YoloInferencer和Tracker子类- 运行时热切换:
GameController.update_settings()中通过set_enabled()切换保存开关,不需要重建ImageSaver实例 - 启动时执行
ImageSaver.bootstrap_static(BASE_DIR, SAVE_DEBUG_IMAGES)初始化目录
8) 耗时统计约定
- YOLO 推理耗时:仅包含
self.model(...)调用,不含截图和保存图片时间;在YoloInferencer.detect()中用time.perf_counter()计时 - 整轮耗时:从
request_one_update()发出检测请求到on_result_ready()收到结果,包含截图+推理+队列通信+轮询延迟 - 标题栏显示格式:
Han记牌器 · 推理 XXms · 整轮 XXms,由SHOW_TIMING配置项控制是否显示
9) 环境与集成边界
- 当前实现依赖 Windows 截图链路(
win32gui/win32ui/win32con+ DPI Aware),默认是 Windows 场景 - YOLO 推理在独立子进程中运行(
multiprocessing),主进程(UI)与子进程不共享 GIL;main.py必须调用multiprocessing.freeze_support()以支持 PyInstaller 打包 - YOLO 权重在
yolo/weights/目录下的子目录中(如yolo/weights/yolov11s_imgsz_960/best.pt),不支持直接放在weights/下;通过设置界面切换模型 - 模型加载策略:
- GPU(
device_choice == "cuda"):TensorRT(FP16)> PyTorch CUDA + FP16 - CPU(
device_choice == "cpu"):ONNX Runtime > PyTorch CPU - ONNX / TensorRT 引擎在首次使用时自动从
best.pt导出
- GPU(
- 关键三方:
ultralytics、torch、PySide6、opencv-python、PyYAML、pillow - 本仓库未提供自动化测试;回归验证以真实窗口识别 + UI 行为检查为主