diff --git a/autowsgr/combat/fleet.py b/autowsgr/combat/fleet.py index 6ddd80b9..5da60a05 100644 --- a/autowsgr/combat/fleet.py +++ b/autowsgr/combat/fleet.py @@ -247,10 +247,19 @@ def _optional_level(rule: Mapping[str, Any], field: str) -> int | None: return value +def _optional_relaxed(rule: Mapping[str, Any]) -> bool: + """读取宽松校验开关;缺省为 False(严格校验)。""" + value = rule.get('relaxed') + if value is None: + return False + if not isinstance(value, bool): + raise TypeError('relaxed 必须是布尔值') + return value + + def _selector_from_mapping( raw: Mapping[str, Any], *, - relaxed: bool, inherited: Mapping[str, Any] | None = None, ) -> ShipSelector: name = _optional_text(raw.get('name')) @@ -264,7 +273,7 @@ def _selector_from_mapping( ship_types=parse_ship_type_codes(source.get('ship_type')), min_level=_optional_level(source, 'min_level'), max_level=_optional_level(source, 'max_level'), - relaxed_constraints=relaxed, + relaxed_constraints=_optional_relaxed(raw), ) @@ -285,7 +294,7 @@ def fleet_slot_from_api(raw: str | Mapping[str, Any]) -> FleetSlotRule: raise TypeError('舰队槽位必须是字符串或规则对象') name = _optional_text(raw.get('name')) - primary = _selector_from_mapping(raw, relaxed=False) if name is not None else None + primary = _selector_from_mapping(raw) if name is not None else None raw_candidates = raw.get('candidates', []) if not isinstance(raw_candidates, Sequence) or isinstance(raw_candidates, str): raise TypeError('candidates 必须是规则对象列表') @@ -299,7 +308,7 @@ def fleet_slot_from_api(raw: str | Mapping[str, Any]) -> FleetSlotRule: max_level=_optional_level(raw, 'max_level'), ) if isinstance(candidate, str) - else _selector_from_mapping(candidate, relaxed=False) + else _selector_from_mapping(candidate) for candidate in raw_candidates ) ) @@ -320,17 +329,16 @@ def fleet_slot_from_yaml(raw: object) -> FleetSlotRule: primary: ShipSelector | None = None if _optional_text(raw.get('name')) is not None: - primary = _selector_from_mapping(raw, relaxed=False) + primary = _selector_from_mapping(raw) normalized_candidates: list[ShipSelector] = [] for candidate in candidates: if isinstance(candidate, str): selector = _selector_from_mapping( {'name': candidate}, - relaxed=False, inherited=raw, ) elif isinstance(candidate, Mapping): - selector = _selector_from_mapping(candidate, relaxed=False) + selector = _selector_from_mapping(candidate) else: raise TypeError('candidates 只能包含舰名字符串或规则对象') normalized_candidates.append(selector) diff --git a/autowsgr/context/game_context.py b/autowsgr/context/game_context.py index cfe0d906..2e8f1938 100644 --- a/autowsgr/context/game_context.py +++ b/autowsgr/context/game_context.py @@ -19,6 +19,8 @@ if TYPE_CHECKING: + from datetime import date + from autowsgr.combat.history import CombatResult from autowsgr.emulator import AndroidController from autowsgr.infra import UserConfig @@ -70,6 +72,12 @@ class GameContext: ocr: OCREngine """OCR 引擎实例 (章节/阵型识别等)。""" + ship_ocr: OCREngine | None = None + """增强船只识别 OCR 引擎 (RapidOCR, 可选)。 + + 仅在 ``config.ocr.enhanced_ship_ocr`` 开启时由启动器注入; + 船只名称 / 等级 / 舰种识别节点优先使用它,其余节点仍走 ``ocr``。 + """ # ── 游戏运行时状态 ── @@ -97,6 +105,9 @@ class GameContext: quick_repair_used: int = 0 """本次会话已消耗快修数。""" + daily_overlay_date: date | None = None + """最近一次完成主页面浮层处理的日期 (时间戳 gate: 每日 0 点后首次处理, 跨日失效)。""" + active_fight_tasks: int = 0 """当前正在执行的战斗任务数。由 TaskScheduler 维护,用于判断是否可以安全执行浴室维修等会占用舰队的操作。""" diff --git a/autowsgr/infra/config.py b/autowsgr/infra/config.py index 91e9039d..0344d4cd 100644 --- a/autowsgr/infra/config.py +++ b/autowsgr/infra/config.py @@ -91,6 +91,9 @@ class OCRConfig(BaseModel): """是否使用 GPU 加速""" mirror: OcrMirror = OcrMirror.modelscope """EasyOCR 模型下载镜像源""" + enhanced_ship_ocr: bool = False + """是否启用增强船只识别 OCR (RapidOCR)。默认关闭保持原行为;开启后 + 船只名称 / 等级 / 舰种识别节点优先使用 RapidOCR,无需额外下载模型。""" # 舰名匹配 ship_name_match_confidence: float = Field( diff --git a/autowsgr/ops/startup.py b/autowsgr/ops/startup.py index f3da7e19..9db6f468 100644 --- a/autowsgr/ops/startup.py +++ b/autowsgr/ops/startup.py @@ -25,6 +25,7 @@ from __future__ import annotations import time +from datetime import date from typing import TYPE_CHECKING from autowsgr.infra.logger import get_logger @@ -60,6 +61,15 @@ _OVERLAY_DISMISS_DELAY: float = 1.0 """消除浮层后的等待时间 (秒)。""" +_OVERLAY_DISMISS_MAX: int = 5 +"""每日浮层消除的最大尝试次数 (新闻 → 签到 → 确认 → 二次确认 → 兜底)。""" + +_OVERLAY_DISMISS_WAIT: float = 1.5 +"""消除每个浮层后的等待时间 (秒) — 弹窗按顺序逐个出现, 需要间隔等待。""" + +_OVERLAY_CONFIRM_TIMEOUT: float = 3.0 +"""确认弹窗兜底的最大等待时限 (秒) — 用于检测不到浮层签名但存在确认按钮的弹窗。""" + _RECOVERY_TO_MAIN_TIMEOUT: float = 20.0 """页面异常恢复时,尝试回到主界面的超时 (秒)。""" @@ -223,21 +233,72 @@ def restart_game( start_game(ctrl, package, startup_timeout=startup_timeout) -def go_main_page(ctx: GameContext, *, dismiss_overlays: bool = True) -> None: # noqa: ARG001 +def handle_daily_overlays(ctx: GameContext) -> None: + """每日首次主页面浮层处理 (新闻公告 → 每日签到 → 活动预约)。 + + 时间戳 gate: 每天 0 点后第一次调用才真正截图检测并消除浮层, + 当天已处理过则直接返回 (零开销), 避免每次回主页都重复检测。 + + 触发时机 (启动 / 任务执行前 / 挂机等待) 均可安全调用: + - 登录后弹出「新闻公告」→ 勾选「不再显示」+ 点左上角 X + - 接着弹出「每日签到」→ 点领取 + 确认操作 + - 可能再弹出「活动预约」→ 点关闭 + 二次确认 + + 按弹窗出现顺序逐个消除, 最多尝试 :data:`_OVERLAY_DISMISS_MAX` 次, + 直到画面干净或无浮层可关为止。 + """ + today = date.today() # noqa: DTZ011 # 本地墙上时钟 (游戏 0 点刷新) + if ctx.daily_overlay_date == today: + return + # 先标记再执行: 即使本次消除中断, 当天也不会反复重试 + ctx.daily_overlay_date = today + + page = MainPage(ctx) + for attempt in range(_OVERLAY_DISMISS_MAX): + # ① 已知浮层 (新闻 / 签到 / 预约) 直接消除 + if page.dismiss_current_overlay(): + _log.info( + '[Startup] 主页面浮层已消除 ({}/{})', + attempt + 1, + _OVERLAY_DISMISS_MAX, + ) + time.sleep(_OVERLAY_DISMISS_WAIT) + continue + + # ② 确认弹窗兜底: 检测不到浮层签名, 但可能有「确认」按钮 + # (如签到奖励确认、二次确认) — 用确认模板轮子点掉 + from autowsgr.ui.utils import confirm_operation + + if confirm_operation( + ctx.ctrl, + must_confirm=False, + timeout=_OVERLAY_CONFIRM_TIMEOUT, + ): + _log.info('[Startup] 确认弹窗兜底: 点击确认 ({}/{})', attempt + 1, _OVERLAY_DISMISS_MAX) + time.sleep(_OVERLAY_DISMISS_WAIT) + continue + + # ③ 既无浮层又无确认按钮 → 主页干净, 流程完成 + return + + +def go_main_page(ctx: GameContext, *, dismiss_overlays: bool = True) -> None: """确保当前处于游戏主页面。 - 1. 若设置了 ``dismiss_overlays``,先消除登录浮层 - 2. 调用 :func:`~autowsgr.ops.navigate.goto_page` 导航到主页面 + 1. 调用 :func:`~autowsgr.ops.navigate.goto_page` 导航到主页面 + 2. 若设置了 ``dismiss_overlays``,导航后消除登录浮层 Parameters ---------- ctx: 游戏上下文。 dismiss_overlays: - 是否先消除登录浮层,默认 ``True``。 + 是否消除登录浮层,默认 ``True``。 """ _log.info('[Startup] 导航到主页面') goto_page(ctx, PageName.MAIN) + if dismiss_overlays: + handle_daily_overlays(ctx) def recover_to_main_or_restart( @@ -278,7 +339,7 @@ def ensure_game_ready( app: GameAPP | str = GameAPP.official, *, startup_timeout: float = _STARTUP_TIMEOUT, - dismiss_overlays: bool = True, # noqa: ARG001 + dismiss_overlays: bool = True, ) -> None: """确保游戏已启动并处于可识别页面。 @@ -320,3 +381,5 @@ def ensure_game_ready( _log.info('[Startup] 游戏已在运行') _log.info('[Startup] 游戏就绪') + if dismiss_overlays: + handle_daily_overlays(ctx) diff --git a/autowsgr/scheduler/launcher.py b/autowsgr/scheduler/launcher.py index 4a33580c..3fa0e65a 100644 --- a/autowsgr/scheduler/launcher.py +++ b/autowsgr/scheduler/launcher.py @@ -136,9 +136,9 @@ def ctrl(self) -> AndroidController: # ── OCR ── def create_ocr(self) -> OCREngine: - """根据配置创建 EasyOCR 引擎。""" + """根据配置创建通用 OCR 引擎 (EasyOCR)。""" cfg = self.config - _log.info('[Launcher] 创建 EasyOCR 引擎') + _log.info('[Launcher] 创建通用 OCR 引擎: EasyOCR') gpu = cfg.ocr.gpu gpu_override = os.getenv('AUTOWSGR_OCR_GPU_MODE', '').lower() if gpu_override == 'cuda': @@ -167,6 +167,19 @@ def create_ocr(self) -> OCREngine: _log.info('[Launcher] OCR用户舰名别名加载(有效别名:{})', loaded_aliases) return self._ocr + def create_ship_ocr(self) -> OCREngine | None: + """根据配置创建增强船只识别 OCR 引擎 (RapidOCR / PP-OCR)。 + + 仅在 ``cfg.ocr.enhanced_ship_ocr`` 开启时创建; + 默认关闭,返回 ``None``,船只识别节点继续使用默认 EasyOCR。 + """ + cfg = self.config + if not cfg.ocr.enhanced_ship_ocr: + _log.info('[Launcher] 增强船只识别 OCR 未开启,船只识别节点继续使用 EasyOCR') + return None + _log.info('[Launcher] 创建增强船只识别 OCR 引擎: RapidOCR (PP-OCR)') + return OCREngine.create(engine='rapidocr', gpu=cfg.ocr.gpu) + # ── 构造 GameContext ── def build_context(self) -> GameContext: @@ -187,7 +200,10 @@ def build_context(self) -> GameContext: ctrl=self.ctrl, config=self.config, ocr=self._ocr, + ship_ocr=self.create_ship_ocr(), ) + ship_engine = 'RapidOCR (PP-OCR)' if ctx.ship_ocr is not None else '未启用 (沿用 EasyOCR)' + _log.info('[Launcher] OCR 加载完成: 通用引擎=EasyOCR, 船只识别引擎={}', ship_engine) _log.info('[Launcher] GameContext 已构建') return ctx diff --git a/autowsgr/scheduler/scheduler.py b/autowsgr/scheduler/scheduler.py index 91921399..e1af5bb3 100644 --- a/autowsgr/scheduler/scheduler.py +++ b/autowsgr/scheduler/scheduler.py @@ -198,11 +198,30 @@ def run(self) -> list[FightTask]: task.name, task.times, ) + self._ensure_main_page_clean() self._run_task(task) self._print_summary() return list(self._tasks) + def _ensure_main_page_clean(self) -> None: + """常驻弹窗检查: 每日首次消除主页面浮层 (新闻公告 / 每日签到 / 活动预约)。 + + 在任务执行前、挂机等待时调用; 内部由 + :func:`autowsgr.ops.startup.handle_daily_overlays` 的时间戳 gate 短路: + 每天 0 点后第一次调用才真正截图检测并消除, 当天已处理过则直接返回, + 不干扰正常流程。 + """ + try: + from autowsgr.ops.startup import handle_daily_overlays + + handle_daily_overlays(self._ctx) + except Exception as exc: + _log.opt(exception=True).warning( + '[Scheduler] 主页面浮层检查失败: {}', + exc, + ) + def _run_task(self, task: FightTask) -> None: """执行单个任务的全部轮次。""" # 统一用 BatchRunnerAdapter 包装: 完整保留 run()→list[CombatResult], @@ -356,6 +375,8 @@ def run_daily(self) -> None: pass else: # 队列空:所有触发器暂无任务 (常规战打满 / 只等远征定时) → 挂机等待 + # 挂机期间顺带做常驻弹窗检查, 保证回到主页面时浮层不残留 + self._ensure_main_page_clean() time.sleep(self._idle_sleep) _log.info('[Scheduler] 收到停止信号, 调度结束') diff --git a/autowsgr/server/schemas.py b/autowsgr/server/schemas.py index df8c1279..278ed44d 100644 --- a/autowsgr/server/schemas.py +++ b/autowsgr/server/schemas.py @@ -121,6 +121,10 @@ class FleetShipRuleRequest(BaseModel): ship_type: list[str] | None = Field(default=None, description='允许的舰种列表(如 [ss, ssg])') min_level: int | None = Field(default=None, ge=1, description='等级下限(含)') max_level: int | None = Field(default=None, ge=1, description='等级上限(含)') + relaxed: bool = Field( + default=False, + description='宽松校验:舰名必须命中,等级/舰种尽力而为(识别失败或不匹配也放行)', + ) @field_validator('name') @classmethod diff --git a/autowsgr/ui/battle/base.py b/autowsgr/ui/battle/base.py index 36c242e7..cb673534 100644 --- a/autowsgr/ui/battle/base.py +++ b/autowsgr/ui/battle/base.py @@ -123,6 +123,16 @@ def __init__(self, ctx: GameContext, ocr: OCREngine | None = None) -> None: self._ctx = ctx self._ctrl = ctx.ctrl self._ocr = ocr or ctx.ocr + self._ship_ocr = getattr(ctx, 'ship_ocr', None) + + @property + def _preferred_ocr(self) -> OCREngine | None: + """返回船只识别优先使用的 OCR 引擎。 + + 增强船只识别 (RapidOCR) 开启时优先使用它,未开启时回退默认 OCR。 + 只有名称 / 等级 / 舰种等船只信息节点应使用本属性,其余节点仍用 ``_ocr``。 + """ + return self._ship_ocr or self._ocr # ── 页面识别 ────────────────────────────────────────────────────────── diff --git a/autowsgr/ui/battle/constants.py b/autowsgr/ui/battle/constants.py index 07d051b8..4698c933 100644 --- a/autowsgr/ui/battle/constants.py +++ b/autowsgr/ui/battle/constants.py @@ -125,3 +125,22 @@ .. note:: 坐标为估算值,实际使用前应根据截图校准。 """ + + +# ═══════════════════════════════════════════════════════════════════════════════ +# 舰船舰种 OCR 裁切区域 +# ═══════════════════════════════════════════════════════════════════════════════ + +SHIP_TYPE_CROP: dict[int, tuple[float, float, float, float]] = { + 0: (0.0450, 0.5220, 0.1600, 0.5750), + 1: (0.1600, 0.5220, 0.2750, 0.5750), + 2: (0.2750, 0.5220, 0.3900, 0.5750), + 3: (0.3900, 0.5220, 0.5050, 0.5750), + 4: (0.5050, 0.5220, 0.6200, 0.5750), + 5: (0.6200, 0.5220, 0.7350, 0.5750), +} +"""出征准备页 6 个舰船槽位的舰种文本 OCR 裁切区域 (x1, y1, x2, y2)。 + +坐标取自 ``logs/ocr_regions_1280x720.yaml`` 的 ``ship_type`` 槽位 +(如 ``轻巡(J国)`` / ``潜艇(G国)`` 等舰种文字区域)。 +""" diff --git a/autowsgr/ui/battle/detection.py b/autowsgr/ui/battle/detection.py index f8f1e8df..079e6e73 100644 --- a/autowsgr/ui/battle/detection.py +++ b/autowsgr/ui/battle/detection.py @@ -13,14 +13,16 @@ import cv2 from autowsgr.infra.logger import get_logger -from autowsgr.types import ShipDamageState +from autowsgr.types import ShipDamageState, ShipType from autowsgr.ui.battle.base import BaseBattlePreparation +from autowsgr.ui.utils.ship_list import extract_ship_type_from_text from autowsgr.vision import PixelChecker from .blood import classify_blood from .constants import ( BLOOD_BAR_PROBE, SHIP_LEVEL_CROP, + SHIP_TYPE_CROP, ) @@ -137,7 +139,7 @@ def _recognize_fleet_levels( 槽位号 (0-5) → 等级。无法识别或无舰船则为 ``None``。 """ levels: dict[int, int | None] = {} - ocr = self._ocr + ocr = self._preferred_ocr if ocr is None: _log.warning('[UI] 未提供 OCR 引擎,无法识别舰船等级') return dict.fromkeys(range(6)) @@ -182,6 +184,75 @@ def _recognize_fleet_levels( ) return levels + # ── 舰种 OCR ───────────────────────────────────────────────────────── + + def _recognize_fleet_ship_types( + self, + screen: np.ndarray, + ) -> dict[int, ShipType | None]: + """从准备页截图中 OCR 识别每艘舰船的舰种。 + + 读取各舰船卡片上的舰种文本 (如 ``轻巡(J国)``),用于首次换船快照, + 使已就位的目标舰船可以跳过船池二次确认。 + """ + ship_types: dict[int, ShipType | None] = {} + ocr = self._preferred_ocr + if ocr is None: + _log.warning('[UI] 未提供 OCR 引擎,无法识别舰种') + return dict.fromkeys(range(6)) + + # 先检测哪些槽位有舰船 + damage = self.detect_ship_damage(screen) + + for slot in range(6): + if damage.get(slot) == ShipDamageState.NO_SHIP: + ship_types[slot] = None + continue + + crop_region = SHIP_TYPE_CROP.get(slot) + if crop_region is None: + ship_types[slot] = None + continue + + cropped = PixelChecker.crop(screen, *crop_region) + # 4x 上采样提升小字 OCR 准确率 (对齐等级识别) + upscaled = cv2.resize( + cropped, + (cropped.shape[1] * 4, cropped.shape[0] * 4), + ) + ship_type = self._best_ship_type_from_results(ocr.recognize(upscaled)) + ship_types[slot] = ship_type + + if ship_type is not None: + _log.debug('[UI] 槽位{} 舰种: {}', slot, ship_type.value) + else: + _log.debug('[UI] 槽位{} 舰种识别失败', slot) + + _log.info( + '[准备页] 舰种检测: {}', + ' | '.join( + f'槽{i}={ship_types[i].value if ship_types[i] is not None else "未知"}' + for i in range(6) + ), + ) + return ship_types + + @staticmethod + def _best_ship_type_from_results(results: list) -> ShipType | None: + """从多个 OCR 结果中提取唯一舰种;歧义或无法识别返回 None。""" + detected: ShipType | None = None + for r in results: + text = str(getattr(r, 'text', '')).strip() + if not text: + continue + ship_type = extract_ship_type_from_text(text) + if ship_type is None: + continue + if detected is not None and detected != ship_type: + return None + detected = ship_type + return detected + # ── 舰队信息聚合 ───────────────────────────────────────────────────── def detect_fleet_info( diff --git a/autowsgr/ui/battle/fleet_change/_change.py b/autowsgr/ui/battle/fleet_change/_change.py index f2014b79..45c007d1 100644 --- a/autowsgr/ui/battle/fleet_change/_change.py +++ b/autowsgr/ui/battle/fleet_change/_change.py @@ -62,6 +62,8 @@ class FleetChangeMixin(FleetDetectMixin): # True 使用搜索框选船,False 直接通过 OCR 列表选船。 _use_search: bool = True _last_changed_fleet: list[str | None] | None = None + # 首次快照 (含舰种/等级),供重规划备选后重新标记已验证槽位。 + _initial_snapshot: FleetSnapshot | None = None @property def last_changed_fleet(self) -> list[str | None] | None: @@ -114,8 +116,10 @@ def change_fleet( ) # Step 4:首次完整调整,后续最多进行两次局部修正。 - # verified_slots 记录本轮已通过选船页校验舰种和等级的逻辑目标槽位。 + # verified_slots 记录本轮已通过选船页 (或首次快照) 校验舰种和等级的逻辑目标槽位。 verified_slots: set[int] = set() + self._initial_snapshot = snapshot + self._mark_snapshot_verified_slots(snapshot, assigned, verified_slots) unavailable: set[tuple[int, ShipSelector]] = set() locked: dict[int, ShipSelector] = {} for attempt in range(_MAX_SET_RETRIES + 1): @@ -316,12 +320,22 @@ def _target_names( ] def _detect_initial_snapshot(self, expected_pool: Sequence[str]) -> FleetSnapshot: - """初次识别舰队;存在未知占用槽位时再识别一次并保守合并。""" - first = self.detect_fleet_snapshot(expected_pool=expected_pool) + """初次识别舰队 (名称+舰种+等级);存在未知占用槽位时再识别一次并保守合并。 + + 首次快照额外识别各槽位舰种和等级,作为换船流程的上下文, + 供后续跳过已就位目标舰船的二次确认。 + """ + first = self.detect_fleet_snapshot( + expected_pool=expected_pool, + recognize_ship_details=True, + ) if not first.unknown_slots: return first - second = self.detect_fleet_snapshot(expected_pool=expected_pool) + second = self.detect_fleet_snapshot( + expected_pool=expected_pool, + recognize_ship_details=True, + ) names = list(first.names) for slot, second_name in enumerate(second.names): if names[slot] is None and second_name is not None: @@ -340,7 +354,34 @@ def _detect_initial_snapshot(self, expected_pool: Sequence[str]) -> FleetSnapsho strict=True, ) ] - return FleetSnapshot(names=names, occupied=occupied) + ship_types = list(first.ship_types or [None] * 6) + second_types = second.ship_types or [None] * 6 + for slot, second_type in enumerate(second_types): + if ship_types[slot] is None and second_type is not None: + ship_types[slot] = second_type + elif ( + ship_types[slot] is not None + and second_type is not None + and ship_types[slot] != second_type + ): + ship_types[slot] = None + ship_levels = list(first.ship_levels or [None] * 6) + second_levels = second.ship_levels or [None] * 6 + for slot, second_level in enumerate(second_levels): + if ship_levels[slot] is None and second_level is not None: + ship_levels[slot] = second_level + elif ( + ship_levels[slot] is not None + and second_level is not None + and ship_levels[slot] != second_level + ): + ship_levels[slot] = None + return FleetSnapshot( + names=names, + occupied=occupied, + ship_types=ship_types, + ship_levels=ship_levels, + ) @classmethod def _option_matches_name( @@ -528,6 +569,84 @@ def _requires_selection_validation(option: ShipSelector | None) -> bool: and (option.ship_types or option.min_level is not None or option.max_level is not None) ) + @classmethod + def _snapshot_satisfies_option( + cls, + snapshot: FleetSnapshot, + slot: int, + option: ShipSelector, + ) -> bool: + """强校验: 首次快照是否已从舰种/等级确认该槽位满足规则。 + + 名称匹配由调用方保证;这里只做约束校验。relaxed (弱校验) 规则 + 不要求选船校验,无需调用本函数,名称匹配即视为放行。 + """ + ship_type = snapshot.ship_types[slot] if snapshot.ship_types else None + ship_level = snapshot.ship_levels[slot] if snapshot.ship_levels else None + + if option.ship_types and ship_type not in option.ship_types: + return False + if option.min_level is not None or option.max_level is not None: + if ship_level is None: + return False + if option.min_level is not None and ship_level < option.min_level: + return False + if option.max_level is not None and ship_level > option.max_level: + return False + return True + + def _mark_snapshot_verified_slots( + self, + snapshot: FleetSnapshot, + assigned: Sequence[ShipSelector | None], + verified_slots: set[int], + ) -> None: + """用首次快照标记已就位且满足规则的逻辑槽位,跳过选船二次确认。 + + 已确认无需更换的舰船不再进入点对点选船页更换,避免已就位舰船 + 不在船池中导致选不到 → 重选 → 选不到的无限循环。 + 最终舰队 check 仍由流程末尾的验证兜底。 + + assigned 中既包含主选也包含备选;备选同样按自身约束参与校验, + 重规划改派备选后再次调用本函数即可覆盖备选链路。 + + 强校验 (strict): 名称匹配后,舰种/等级必须全部符合 YAML 规定才标记, + 任一约束因 OCR 数据缺失而无法确认时也不标记,落入选船页权威校验; + 弱校验 (relaxed): 规则本就不要求选船校验,由赋值匹配直接放行。 + """ + if snapshot.ship_types is None or snapshot.ship_levels is None: + return + for target_slot, option in enumerate(assigned): + if option is None or not self._requires_selection_validation(option): + continue + if target_slot in verified_slots: + continue + # 位置无关匹配: 与 _assignment_locations 一致,先找已就位位置再校验。 + position = next( + ( + slot + for slot in range(6) + if snapshot.occupied[slot] + and self._option_matches_name(snapshot.names[slot], option) + ), + None, + ) + if position is None: + continue + if not self._snapshot_satisfies_option(snapshot, position, option): + _log.info( + '[准备页] 快照校验未通过: 逻辑槽位 {} ({}), 进入选船二次确认', + target_slot, + snapshot.names[position], + ) + continue + verified_slots.add(target_slot) + _log.info( + '[准备页] 快照确认逻辑槽位 {} 已就位 ({}), 跳过选船二次确认', + target_slot, + snapshot.names[position], + ) + # 从本槽候选中排除队内同名舰,并返回实际可用于选船的规则。 @classmethod def _select_available_candidate( @@ -849,6 +968,14 @@ def _align_member_set( ): if old != new: verified_slots.discard(slot) + # 重规划后新分配的备选若已在队内且满足约束,直接用首次快照 + # 标记为已验证,避免已就位备选不在船池时反复进选船页重选。 + if self._initial_snapshot is not None: + self._mark_snapshot_verified_slots( + self._initial_snapshot, + assigned, + verified_slots, + ) continue _log.info( diff --git a/autowsgr/ui/battle/fleet_change/_detect.py b/autowsgr/ui/battle/fleet_change/_detect.py index 513a7b94..c1346b12 100644 --- a/autowsgr/ui/battle/fleet_change/_detect.py +++ b/autowsgr/ui/battle/fleet_change/_detect.py @@ -20,7 +20,7 @@ from autowsgr.constants import SHIPNAMES, normalize_ship_name from autowsgr.infra.logger import get_logger -from autowsgr.types import ShipDamageState +from autowsgr.types import ShipDamageState, ShipType from autowsgr.ui.battle.base import BaseBattlePreparation from autowsgr.ui.battle.detection import DetectionMixin from autowsgr.vision.ocr import ( @@ -59,6 +59,10 @@ class FleetSnapshot: names: list[str | None] occupied: list[bool] + ship_types: list[ShipType | None] | None = None + """槽位号 (0-5) → 舰种;仅在首次快照识别时填充,否则为 ``None``。""" + ship_levels: list[int | None] | None = None + """槽位号 (0-5) → 等级;仅在首次快照识别时填充,否则为 ``None``。""" @property def unknown_slots(self) -> list[int]: @@ -114,7 +118,7 @@ def detect_fleet( strip = screen[y1:y2, :] results = sorted( - self._ocr.recognize(strip), + self._preferred_ocr.recognize(strip), key=lambda result: ( (result.bbox[0] + result.bbox[2]) / 2 if result.bbox is not None else float('inf') ), @@ -193,8 +197,16 @@ def detect_fleet_snapshot( *, expected_names: Sequence[str | None] | None = None, expected_pool: Sequence[str] | None = None, + recognize_ship_details: bool = False, ) -> FleetSnapshot: - """使用同一截图识别舰名和槽位占用状态。""" + """使用同一截图识别舰名和槽位占用状态。 + + Parameters + ---------- + recognize_ship_details: + 是否额外识别各槽位舰种和等级。仅首次换船快照需要开启, + 避免每次重新截图都做 6 个舰种/等级区域的 OCR。 + """ screen = self._ctrl.screenshot() names = self.detect_fleet( screen, @@ -207,7 +219,14 @@ def detect_fleet_snapshot( or damage.get(slot, ShipDamageState.NO_SHIP) != ShipDamageState.NO_SHIP for slot in range(6) ] - return FleetSnapshot(names=names, occupied=occupied) + ship_types = self._recognize_fleet_ship_types(screen) if recognize_ship_details else None + ship_levels = self._recognize_fleet_levels(screen) if recognize_ship_details else None + return FleetSnapshot( + names=names, + occupied=occupied, + ship_types=ship_types, + ship_levels=ship_levels, + ) @staticmethod def _validate_fleet( diff --git a/autowsgr/ui/choose_ship_page.py b/autowsgr/ui/choose_ship_page.py index 7b607707..fe5b2d1d 100644 --- a/autowsgr/ui/choose_ship_page.py +++ b/autowsgr/ui/choose_ship_page.py @@ -16,9 +16,10 @@ import time from typing import TYPE_CHECKING +import cv2 + from autowsgr.constants import SHIPNAMES, normalize_ship_name from autowsgr.infra.logger import get_logger -from autowsgr.types import ShipType from autowsgr.vision import ( MatchStrategy, PixelChecker, @@ -28,7 +29,12 @@ from autowsgr.vision.ocr import _fuzzy_match from .utils import wait_for_page, wait_leave_page -from .utils.ship_list import LevelOCRRetryNeededError, locate_ship_rows, read_ship_levels +from .utils.ship_list import ( + LevelOCRRetryNeededError, + extract_ship_type_from_text, + locate_ship_rows, + read_ship_levels, +) if TYPE_CHECKING: @@ -36,6 +42,8 @@ from autowsgr.combat.fleet import ShipSelector from autowsgr.context import GameContext + from autowsgr.types import ShipType + from autowsgr.vision import OCREngine _log = get_logger('ui') @@ -61,6 +69,14 @@ _SCROLL_TO_Y: float = 0.30 _OCR_MAX_ATTEMPTS: int = 3 +#: 船池卡片信息区域,以 1280x720 截图为校准基准。 +_CARD_REFERENCE_WIDTH = 1280 +_CARD_REFERENCE_HEIGHT = 720 +_SHIP_TYPE_HALF_WIDTH = 68 +_SHIP_TYPE_TOP_OFFSET = 72 +_SHIP_TYPE_BOTTOM_OFFSET = 24 +_SHIP_TYPE_OCR_SCALES = (2, 3) + PAGE_SIGNATURE = PixelSignature( name='choose_ship_page', strategy=MatchStrategy.ALL, @@ -103,6 +119,24 @@ class ChooseShipPage: def __init__(self, ctx: GameContext) -> None: self._ctx = ctx self._ctrl = ctx.ctrl + self._ship_ocr = getattr(ctx, 'ship_ocr', None) + + @property + def _preferred_ocr(self) -> OCREngine | None: + """返回选船识别优先使用的 OCR 引擎 (增强识别开启时用 RapidOCR)。""" + return self._ship_ocr or self._ctx.ocr + + def _detect_hit_ship_type( + self, + screen: np.ndarray, + cx: float, + cy: float, + row_key: float, + ) -> ShipType | None: + """按链路选择舰种识别入口:新链路用单卡固定坐标,旧链路用命中点探测。""" + if self._ship_ocr is not None: + return self._detect_ship_type_in_single_card(screen, cx, cy, row_key) + return self._detect_ship_type_near_hit(screen, cx, cy, row_key) # ── 页面识别 ────────────────────────────────────────────────────────── @@ -302,20 +336,21 @@ def _click_ship_in_list( # noqa: C901, PLR0912 匹配并点击成功时返回舰船名;失败返回 ``None``。 """ assert self._ctx.ocr is not None + ocr = self._preferred_ocr for attempt in range(_OCR_MAX_ATTEMPTS): screen = self._ctrl.screenshot() use_level_filter = min_level is not None or max_level is not None if use_level_filter: raw_hits = locate_ship_rows( - self._ctx.ocr, + ocr, screen, deduplicate_by_name=False, include_row_key=True, ) try: raw_levels = read_ship_levels( - self._ctx.ocr, + ocr, screen, deduplicate_by_name=False, include_row_key=True, @@ -340,7 +375,7 @@ def _click_ship_in_list( # noqa: C901, PLR0912 time.sleep(0.3) continue else: - raw_hits = locate_ship_rows(self._ctx.ocr, screen) + raw_hits = locate_ship_rows(ocr, screen) raw_levels = [] hits = [self._normalize_hit_entry(hit) for hit in raw_hits] @@ -379,7 +414,7 @@ def _click_ship_in_list( # noqa: C901, PLR0912 continue if ship_type is not None: - detected_ship_type = self._detect_ship_type_near_hit( + detected_ship_type = self._detect_hit_ship_type( screen, cx, cy, @@ -426,7 +461,7 @@ def _detect_ship_type_near_hit( cy: float, row_key: float, ) -> ShipType | None: - """在命中卡片附近 OCR 识别舰种。""" + """使用旧 OCR 流程在命中卡片附近识别舰种。""" assert self._ctx.ocr is not None h, w = screen.shape[:2] @@ -452,16 +487,64 @@ def _detect_ship_type_near_hit( return ship_type return None - @staticmethod - def _extract_ship_type_from_text(text: str) -> ShipType | None: - if not text: + def _detect_ship_type_in_single_card( + self, + screen: np.ndarray, + cx: float, + cy: float, + row_key: float, + ) -> ShipType | None: + """新 OCR 的单卡舰种识别入口,旧 OCR 流程不会调用此方法。""" + ocr = self._preferred_ocr + assert ocr is not None + + h, w = screen.shape[:2] + x_px = max(0, min(w - 1, round(cx * w))) + y_px = max(0, min(h - 1, round(cy * h))) + row_y = max(0, min(h - 1, round(row_key * h))) if row_key >= 0 else y_px + + half_width = max(1, round(_SHIP_TYPE_HALF_WIDTH * w / _CARD_REFERENCE_WIDTH)) + top_offset = max(1, round(_SHIP_TYPE_TOP_OFFSET * h / _CARD_REFERENCE_HEIGHT)) + bottom_offset = max(1, round(_SHIP_TYPE_BOTTOM_OFFSET * h / _CARD_REFERENCE_HEIGHT)) + + x1 = max(0, x_px - half_width) + x2 = min(w, x_px + half_width) + y1 = max(0, row_y - top_offset) + y2 = max(0, min(h, row_y - bottom_offset)) + if x2 - x1 < 16 or y2 - y1 < 16: return None - normalized = text.replace(' ', '') - for ship_type in ShipType: - if ship_type is not ShipType.Other and ship_type.value in normalized: - return ship_type + + crop = screen[y1:y2, x1:x2] + for scale in _SHIP_TYPE_OCR_SCALES: + enlarged = cv2.resize( + crop, + None, + fx=scale, + fy=scale, + interpolation=cv2.INTER_CUBIC, + ) + detected_types: set[ShipType] = set() + results = ocr.recognize(enlarged) + for result in results: + text = str(getattr(result, 'text', '')).strip() + ship_type = self._extract_ship_type_from_text(text) + if ship_type is not None: + detected_types.add(ship_type) + if len(detected_types) == 1: + return next(iter(detected_types)) + if len(detected_types) > 1: + _log.warning( + '[UI] 单卡舰种 OCR 得到多个结果: {}', + sorted(ship_type.value for ship_type in detected_types), + ) + return None return None + @staticmethod + def _extract_ship_type_from_text(text: str) -> ShipType | None: + """从 OCR 文本中提取舰种 (共享实现见 :func:`extract_ship_type_from_text`)。""" + return extract_ship_type_from_text(text) + @staticmethod def _is_ship_type_in_rule( detected: ShipType | None, diff --git a/autowsgr/ui/main_page/constants.py b/autowsgr/ui/main_page/constants.py index 1b1f54fa..f26894b6 100644 --- a/autowsgr/ui/main_page/constants.py +++ b/autowsgr/ui/main_page/constants.py @@ -89,11 +89,11 @@ def xy(self) -> tuple[float, float]: class DismissCoord(enum.Enum): """浮层 / 弹窗消除点击坐标。""" - NEWS_NOT_SHOW = (0.0729, 0.8981) - """新闻「不再显示」复选框 — (70, 485)。""" + NEWS_NOT_SHOW = (0.0664, 0.9000) + """新闻「不再显示」复选框 — 5.6.0 版式 (85, 648)。""" - NEWS_CLOSE = (0.0313, 0.0556) - """新闻关闭按钮 — (30, 30)。""" + NEWS_CLOSE = (0.0352, 0.0625) + """新闻关闭按钮 — 5.6.0 版式 (45, 45)。""" SIGN_CONFIRM = (0.4938, 0.6611) """签到领取/关闭按钮 — (474, 357)。""" @@ -176,18 +176,23 @@ def ps(self) -> PixelSignature: name='news_overlay', strategy=MatchStrategy.ALL, rules=[ - PixelRule.of(0.6523, 0.8292, (148, 88, 86), tolerance=30.0), - PixelRule.of(0.7008, 0.8306, (119, 67, 62), tolerance=30.0), - PixelRule.of(0.7844, 0.8278, (135, 79, 73), tolerance=30.0), - PixelRule.of(0.8516, 0.8278, (126, 72, 65), tolerance=30.0), + # 5.6.0 版式: 左上角关闭按钮 X 笔画 (45, 40) + PixelRule.of(0.0352, 0.0556, (193, 195, 196), tolerance=30.0), + # 5.6.0 版式: 底部米色横条 (结构特征, 与横幅画面无关) + PixelRule.of(0.2375, 0.8194, (209, 202, 191), tolerance=30.0), + PixelRule.of(0.7500, 0.8194, (209, 202, 191), tolerance=30.0), + # 5.6.0 版式: 红色标题栏 + PixelRule.of(0.5188, 0.7500, (180, 126, 119), tolerance=30.0), ], ), Sig.NEWS_NOT_SHOW: PixelSignature( name='news_not_show', strategy=MatchStrategy.ALL, rules=[ - PixelRule.of(0.0714, 0.9065, (49, 130, 211), tolerance=40.0), - PixelRule.of(0.0620, 0.9130, (52, 130, 205), tolerance=40.0), + # 5.6.0 版式: 「不再显示」勾选框已勾选 (蓝色, 86, 649) + PixelRule.of(0.0672, 0.9014, (54, 129, 201), tolerance=40.0), + # 5.6.0 版式: 「不再显示」勾选框已勾选 (蓝色, 89, 649) + PixelRule.of(0.0696, 0.9014, (54, 129, 201), tolerance=40.0), ], ), Sig.SIGN: PixelSignature( diff --git a/autowsgr/ui/main_page/overlays.py b/autowsgr/ui/main_page/overlays.py index 1e02d554..d1d7fdd5 100644 --- a/autowsgr/ui/main_page/overlays.py +++ b/autowsgr/ui/main_page/overlays.py @@ -33,6 +33,15 @@ _log = get_logger('ui') +_SIGN_CONFIRM_MAX: int = 2 +"""每日签到最多处理的确认弹窗段数 (领取确认 + 可能的奖励确认)。""" + +_SIGN_CONFIRM_WAIT: float = 1.0 +"""两段确认之间的等待时间 (秒) — 奖励确认弹窗需几秒才出现。""" + +_SIGN_CONFIRM_TIMEOUT: float = 8.0 +"""等待确认弹窗出现的最大时限 (秒)。""" + # ───────────────────────────────────────────────────────────────────────────── # 检测 @@ -82,12 +91,26 @@ def dismiss_news(ctrl: AndroidController, screen: np.ndarray | None = None) -> N def dismiss_sign(ctrl: AndroidController) -> None: - """关闭每日签到浮层。""" + """关闭每日签到浮层 (领取奖励 + 处理确认弹窗)。 + + 签到奖励流程可能包含连续两段确认弹窗: + 1. 点「领取奖励」后弹出「获得 xx」确认弹窗 → 点确认 + 2. 部分版本点确认后还有「奖励确认」二级弹窗 → 有则再点 + + 第一次确认必须点击 (领取后必有), 第二次确认可选 (等不到就直接收尾)。 + """ from autowsgr.ui.utils import confirm_operation - _log.info('[UI] 每日签到: 关闭') + _log.info('[UI] 每日签到: 领取奖励') ctrl.click(*DismissCoord.SIGN_CONFIRM.xy) - confirm_operation(ctrl, must_confirm=True, timeout=5.0) + # 第一段确认: 领取后必有, 等待出现并点击 (超时未出现则视为异常) + confirm_operation(ctrl, must_confirm=True, timeout=_SIGN_CONFIRM_TIMEOUT) + # 第二段确认: 等待几秒, 若还有「奖励确认」弹窗则继续点, 直到画面干净 + for _ in range(_SIGN_CONFIRM_MAX - 1): + time.sleep(_SIGN_CONFIRM_WAIT) + if not confirm_operation(ctrl, must_confirm=False, timeout=_SIGN_CONFIRM_TIMEOUT): + return + _log.info('[UI] 每日签到: 二次确认已点击') def dismiss_booking(ctrl: AndroidController) -> None: diff --git a/autowsgr/ui/utils/ship_list.py b/autowsgr/ui/utils/ship_list.py index c209379b..a606fc9f 100644 --- a/autowsgr/ui/utils/ship_list.py +++ b/autowsgr/ui/utils/ship_list.py @@ -6,12 +6,14 @@ from __future__ import annotations +import re from typing import TYPE_CHECKING import cv2 from autowsgr.constants import SHIPNAMES from autowsgr.infra.logger import get_logger +from autowsgr.types import ShipType from autowsgr.vision import apply_ship_patches, get_api_dll from autowsgr.vision.ocr import OCRResult, _fuzzy_match from autowsgr.vision.ocr_rules import ( @@ -46,6 +48,22 @@ _MIN_SPLIT_LEVEL_CONFIDENCE = 0.85 +def extract_ship_type_from_text(text: str) -> ShipType | None: + """从 OCR 文本中提取舰种。 + + 游戏内舰种文字常带阵营括号,如 ``轻巡(J国)`` / ``潜艇(G国)``, + 先剔除括号及括号内内容 (国家缩写) 再匹配,避免干扰。 + 准备页快照与选船页单卡识别共用此函数。 + """ + if not text: + return None + normalized = re.sub(r'[((][^(())]*[))]', '', text).replace(' ', '') + for ship_type in ShipType: + if ship_type is not ShipType.Other and ship_type.value in normalized: + return ship_type + return None + + class LevelOCRRetryNeededError(RuntimeError): """等级 OCR 噪声过高,需要重新截图识别。""" diff --git a/autowsgr/vision/__init__.py b/autowsgr/vision/__init__.py index df66ac30..b018135d 100644 --- a/autowsgr/vision/__init__.py +++ b/autowsgr/vision/__init__.py @@ -38,7 +38,14 @@ ImageTemplate, ) from .matcher import PixelChecker -from .ocr import EasyOCREngine, OCREngine, OCRResult, ShipNameMismatchError, apply_ship_patches +from .ocr import ( + EasyOCREngine, + OCREngine, + OCRResult, + RapidOCREngine, + ShipNameMismatchError, + apply_ship_patches, +) from .pixel import ( Color, CompositePixelSignature, @@ -76,6 +83,7 @@ 'PixelMatchResult', 'PixelRule', 'PixelSignature', + 'RapidOCREngine', 'ShipNameMismatchError', 'apply_ship_patches', 'get_api_dll', diff --git a/autowsgr/vision/ocr.py b/autowsgr/vision/ocr.py index f2336ea3..596d6fb9 100644 --- a/autowsgr/vision/ocr.py +++ b/autowsgr/vision/ocr.py @@ -1,6 +1,6 @@ """OCR 引擎抽象层。 -提供统一的文字识别接口,支持 EasyOCR 和 PaddleOCR 后端。 +提供统一的文字识别接口,支持 EasyOCR 和 RapidOCR 后端。 使用方式:: @@ -17,6 +17,7 @@ from dataclasses import dataclass from typing import TYPE_CHECKING, ClassVar +import cv2 import easyocr from autowsgr.constants import SHIPNAMES, normalize_ship_name @@ -370,11 +371,11 @@ def create( Parameters ---------- engine: - 引擎名称: ``"easyocr"`` 或 ``"paddleocr"``。 + 引擎名称: ``"easyocr"`` 或 ``"rapidocr"``。 gpu: 是否使用 GPU 加速。 mirror: - 模型下载镜像源: ``"origin"`` / ``"github"`` / ``"tencent"`` / ``"modelscope"``。 + EasyOCR 模型下载镜像源: ``"origin"`` / ``"github"`` / ``"tencent"`` / ``"modelscope"``。 Returns ------- @@ -386,11 +387,16 @@ def create( return cls._instances[cache_key] if engine == 'easyocr': - _log.info('[OCR] 初始化 EasyOCR(gpu={}, mirror={})', gpu, mirror) + _log.info('[OCR] 初始化 EasyOCR 引擎(gpu={}, mirror={})', gpu, mirror) instance = EasyOCREngine(gpu=gpu, mirror=mirror) cls._instances[cache_key] = instance return instance - raise ValueError(f'不支持的 OCR 引擎: {engine},可选: easyocr, paddleocr') + if engine == 'rapidocr': + _log.info('[OCR] 初始化 RapidOCR 引擎 (PP-OCR, gpu={})', gpu) + instance = RapidOCREngine() + cls._instances[cache_key] = instance + return instance + raise ValueError(f'不支持的 OCR 引擎: {engine},可选: easyocr, rapidocr') # ── 具体实现 ── @@ -429,6 +435,65 @@ def recognize( ] +class RapidOCREngine(OCREngine): + """基于 RapidOCR (PP-OCRv6, onnxruntime) 的识别引擎。 + + 模型内嵌于 pip 包,无需联网下载;采用 lazy import, + 未启用增强识别时不会加载任何额外依赖。 + + .. note:: + onnx 引擎不支持 EasyOCR 式的动态 ``allowlist``, + 这里通过识别后字符过滤近似实现(实测 PP-OCRv6 对数字/字母已很准)。 + """ + + def __init__(self) -> None: + from rapidocr import RapidOCR + + self._reader = RapidOCR() + + def recognize( + self, + image: np.ndarray, + allowlist: str = '', + ) -> list[OCRResult]: + # RapidOCR 遵循 OpenCV 惯例使用 BGR;本项目的截图均为 RGB。 + bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + output = self._reader(bgr) + if output.txts is None: + return [] + + results: list[OCRResult] = [] + for idx, raw_text in enumerate(output.txts): + text = str(raw_text) + if allowlist: + filtered = ''.join(ch for ch in text if ch in allowlist) + if not filtered: + continue + text = filtered + bbox = None + if output.boxes is not None and idx < len(output.boxes): + box = output.boxes[idx] + bbox = ( + int(box[0][0]), + int(box[0][1]), + int(box[2][0]), + int(box[2][1]), + ) + confidence = ( + float(output.scores[idx]) + if output.scores is not None and idx < len(output.scores) + else 0.0 + ) + results.append( + OCRResult( + text=text, + confidence=confidence, + bbox=bbox, + ) + ) + return results + + # ── 辅助函数 ── diff --git a/pyproject.toml b/pyproject.toml index 3f38d3d5..b1c76659 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ dependencies = [ "loguru", "keyboard", "easyocr>=1.7.1", + "rapidocr>=3.0", "adbutils>=2.0,<3.0", "autowsgr_native==0.3.0", "av>=12.0", @@ -180,6 +181,10 @@ extend-safe-fixes = [ "examples/**" = [ "T201", # print ] +"scripts/**" = [ + "T201", # print + "INP001", # implicit-namespace-package (运行时工具脚本, 无需 __init__.py) +] [tool.ruff.lint.mccabe] max-complexity = 17 diff --git a/testing/test_server_schemas.py b/testing/test_server_schemas.py index 4406c780..adc65d84 100644 --- a/testing/test_server_schemas.py +++ b/testing/test_server_schemas.py @@ -55,14 +55,17 @@ def test_new_fleet_rule_keeps_independent_candidates(): 'ship_type': ['ss'], 'min_level': 90, 'max_level': 105, + 'relaxed': False, }, { 'name': 'U-47', 'ship_type': ['ss'], 'min_level': 100, 'max_level': 110, + 'relaxed': False, }, ], + 'relaxed': False, } @@ -78,9 +81,10 @@ def test_candidate_only_fleet_rule_is_valid(): assert rule.model_dump(exclude_none=True) == { 'candidates': [ - {'name': '胡德', 'ship_type': ['bc']}, - {'name': '扶桑', 'min_level': 80, 'max_level': 110}, + {'name': '胡德', 'ship_type': ['bc'], 'relaxed': False}, + {'name': '扶桑', 'min_level': 80, 'max_level': 110, 'relaxed': False}, ], + 'relaxed': False, } slot = fleet_slot_from_api(rule.model_dump(exclude_none=True)) assert slot.primary is None @@ -88,6 +92,26 @@ def test_candidate_only_fleet_rule_is_valid(): assert all(not candidate.relaxed_constraints for candidate in slot.candidates) +def test_api_relaxed_flags_apply_per_rule(): + """API 的 relaxed 开关按规则各自生效,不互相继承。""" + rule = FleetRuleRequest.model_validate( + { + 'name': 'U-47', + 'relaxed': True, + 'min_level': 100, + 'candidates': [ + {'name': 'U-96', 'relaxed': True, 'min_level': 90}, + {'name': 'U-81', 'min_level': 95}, + ], + }, + ) + slot = fleet_slot_from_api(rule.model_dump(exclude_none=True)) + + assert slot.primary is not None + assert slot.primary.relaxed_constraints is True + assert [candidate.relaxed_constraints for candidate in slot.candidates] == [True, False] + + def test_empty_fleet_slot_is_rejected(): with pytest.raises( ValidationError, @@ -307,6 +331,41 @@ def test_legacy_candidate_only_does_not_promote_first_candidate(): assert [candidate.name for candidate in slot.candidates] == ['A', 'B'] +def test_yaml_relaxed_parsing_keeps_strict_default(): + """YAML 的 relaxed 只作用于写它的规则;缺省保持严格,字符串备选不继承槽位开关。""" + plan = CombatPlan.from_dict( + { + 'fleet_presets': [ + { + 'ships': [ + { + 'name': 'U-47', + 'relaxed': True, + 'min_level': 100, + 'candidates': [ + 'U-96', + {'name': 'U-81', 'relaxed': True, 'min_level': 95}, + ], + }, + ], + }, + ], + }, + ) + slot = plan.fleet_presets[0].slots[0] + assert slot.primary is not None + assert slot.primary.relaxed_constraints is True + assert [candidate.relaxed_constraints for candidate in slot.candidates] == [False, True] + + +def test_yaml_relaxed_must_be_boolean(): + """YAML 的 relaxed 必须是布尔值,字符串不静默转换。""" + with pytest.raises(TypeError, match='relaxed'): + CombatPlan.from_dict( + {'fleet_presets': [{'ships': [{'name': 'U-47', 'relaxed': 'yes'}]}]}, + ) + + def test_empty_fleet_preset_is_rejected(): with pytest.raises((TypeError, ValueError), match='ships'): CombatPlan.from_dict({'fleet_presets': [{}]}) diff --git a/testing/ui/battle_preparation/test_unit.py b/testing/ui/battle_preparation/test_unit.py index 4d947621..095a1ce0 100644 --- a/testing/ui/battle_preparation/test_unit.py +++ b/testing/ui/battle_preparation/test_unit.py @@ -541,12 +541,87 @@ def test_initial_snapshot_retries_unknown_occupied_slot(self): ) as detect: snapshot = page._detect_initial_snapshot(['岛风']) - assert snapshot == second + assert snapshot.names == second.names + assert snapshot.occupied == second.occupied assert detect.call_args_list == [ - call(expected_pool=['岛风']), - call(expected_pool=['岛风']), + call(expected_pool=['岛风'], recognize_ship_details=True), + call(expected_pool=['岛风'], recognize_ship_details=True), ] + def test_initial_snapshot_recognizes_ship_types(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + first = FleetSnapshot( + names=['岛风', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ) + + with patch.object( + page, + 'detect_fleet_snapshot', + return_value=first, + ): + snapshot = page._detect_initial_snapshot(['岛风']) + + assert snapshot.ship_types == [ShipType.DD, None, None, None, None, None] + + def test_initial_snapshot_merges_conflicting_ship_types_to_unknown(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + first = FleetSnapshot( + names=[None] * 6, + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ) + second = FleetSnapshot( + names=['岛风', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.CL, None, None, None, None, None], + ) + + with patch.object( + page, + 'detect_fleet_snapshot', + side_effect=[first, second], + ): + snapshot = page._detect_initial_snapshot(['岛风']) + + assert snapshot.names == ['岛风', None, None, None, None, None] + assert snapshot.ship_types == [None, None, None, None, None, None] + + def test_recognize_fleet_ship_types_on_preparation_screen(self): + ctrl = MagicMock(spec=AndroidController) + ocr = MagicMock() + ocr.recognize.return_value = [OCRResult(text='轻巡(J国)', confidence=0.99)] + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + page = BattlePreparationPage(_make_ctx(ctrl, ocr)) + + with patch( + 'autowsgr.ui.battle.fleet_change._detect.DetectionMixin.detect_ship_damage', + return_value=dict.fromkeys(range(6), ShipDamageState.NORMAL), + ): + ship_types = page._recognize_fleet_ship_types(screen) + + assert ship_types[0] is ShipType.CL + assert ocr.recognize.call_count == 6 + + def test_best_ship_type_rejects_conflicting_results(self): + assert ( + BattlePreparationPage._best_ship_type_from_results( + [OCRResult(text='航母', confidence=0.99)], + ) + is ShipType.CV + ) + assert ( + BattlePreparationPage._best_ship_type_from_results( + [ + OCRResult(text='航母', confidence=0.99), + OCRResult(text='轻母', confidence=0.99), + ], + ) + is None + ) + assert BattlePreparationPage._best_ship_type_from_results([]) is None + def test_user_ship_name_alias_is_used_for_final_fleet_detection(self): ctrl = MagicMock(spec=AndroidController) ocr = MagicMock() @@ -870,7 +945,7 @@ def test_input_over_six_slots_is_truncated(self): ) as detect: assert page.change_fleet(None, exact_fleet_rules([*target, 'G'])) - detect.assert_called_once_with(expected_pool=target) + detect.assert_called_once_with(expected_pool=target, recognize_ship_details=True) def test_duplicate_fixed_names_fail_before_ocr(self): page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) @@ -901,7 +976,7 @@ def test_failed_verification_uses_two_local_retries(self): assert local_fix.call_count == 2 expected_names = ['A', None, None, None, None, None] assert detect.call_args_list == [ - call(expected_pool=['A']), + call(expected_pool=['A'], recognize_ship_details=True), call(expected_pool=['A']), call(expected_names=expected_names), call(expected_names=expected_names), @@ -912,6 +987,172 @@ def test_failed_verification_uses_two_local_retries(self): call(expected_names=expected_names), ] + def test_snapshot_marks_in_place_strict_slot_as_verified(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + option = ShipSelector(name='A', ship_types=(ShipType.DD,), min_level=100) + snapshot = FleetSnapshot( + names=['A', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ship_levels=[105, None, None, None, None, None], + ) + verified: set[int] = set() + + page._mark_snapshot_verified_slots( + snapshot, + [option, None, None, None, None, None], + verified, + ) + + assert verified == {0} + + def test_snapshot_rejects_wrong_ship_type(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + option = ShipSelector(name='A', ship_types=(ShipType.DD,), min_level=100) + snapshot = FleetSnapshot( + names=['A', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.CL, None, None, None, None, None], + ship_levels=[105, None, None, None, None, None], + ) + verified: set[int] = set() + + page._mark_snapshot_verified_slots( + snapshot, + [option, None, None, None, None, None], + verified, + ) + + assert verified == set() + + def test_snapshot_rejects_level_below_min(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + option = ShipSelector(name='A', ship_types=(ShipType.DD,), min_level=100) + snapshot = FleetSnapshot( + names=['A', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ship_levels=[95, None, None, None, None, None], + ) + verified: set[int] = set() + + page._mark_snapshot_verified_slots( + snapshot, + [option, None, None, None, None, None], + verified, + ) + + assert verified == set() + + def test_snapshot_rejects_missing_level(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + option = ShipSelector(name='A', ship_types=(ShipType.DD,), min_level=100) + snapshot = FleetSnapshot( + names=['A', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ship_levels=[None, None, None, None, None, None], + ) + verified: set[int] = set() + + page._mark_snapshot_verified_slots( + snapshot, + [option, None, None, None, None, None], + verified, + ) + + assert verified == set() + + def test_snapshot_ignores_relaxed_rules(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + option = ShipSelector( + name='A', + ship_types=(ShipType.DD,), + min_level=100, + relaxed_constraints=True, + ) + snapshot = FleetSnapshot( + names=['A', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ship_levels=[105, None, None, None, None, None], + ) + verified: set[int] = set() + + page._mark_snapshot_verified_slots( + snapshot, + [option, None, None, None, None, None], + verified, + ) + + assert verified == set() + + def test_snapshot_marks_mispositioned_target_as_verified(self): + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + option = ShipSelector(name='A', ship_types=(ShipType.DD,), min_level=100) + snapshot = FleetSnapshot( + names=[None, None, 'A', None, None, None], + occupied=[False, False, True, False, False, False], + ship_types=[None, None, ShipType.DD, None, None, None], + ship_levels=[None, None, 105, None, None, None], + ) + verified: set[int] = set() + + page._mark_snapshot_verified_slots( + snapshot, + [option, None, None, None, None, None], + verified, + ) + + assert verified == {0} + + def test_snapshot_reverifies_in_place_candidate_after_replan(self): + """主选不在船池时重规划到已就位的备选,直接用首次快照标记为已验证。""" + page = BattlePreparationPage(_make_ctx(MagicMock(spec=AndroidController))) + rule = FleetSlotRule( + primary=ShipSelector(name='A', ship_types=(ShipType.DD,), min_level=100), + candidates=(ShipSelector(name='B', ship_types=(ShipType.DD,), min_level=100),), + ) + selectors: list[FleetSlotRule | None] = [rule, None, None, None, None, None] + current = ['B', None, None, None, None, None] + occupied = [True, False, False, False, False, False] + # 首次快照确认槽位 0 的 B 已就位且满足 DD/100 约束。 + page._initial_snapshot = FleetSnapshot( + names=['B', None, None, None, None, None], + occupied=[True, False, False, False, False, False], + ship_types=[ShipType.DD, None, None, None, None, None], + ship_levels=[100, None, None, None, None, None], + ) + assigned = BattlePreparationPage._plan_target_options(selectors) + assert assigned is not None + assert assigned[0].name == 'A' + verified: set[int] = set() + selected: list[str] = [] + + def select_option(_slot: int, option: ShipSelector) -> _ShipSelection: + selected.append(option.name) + # 主选 A 不在船池,选择失败;已就位的备选 B 不应再被尝试。 + return _ShipSelection(None, option) + + with ( + patch.object(page, '_try_select_option', side_effect=select_option), + patch('autowsgr.ui.battle.fleet_change._change.time.sleep'), + ): + page._align_member_set( + current, + occupied, + assigned, + selectors, + verified, + set(), + {}, + ) + + # 主选失败后改派已就位备选 B,且直接用首次快照标记为已验证。 + assert selected == ['A'] + assert assigned[0].name == 'B' + assert 0 in verified + class TestFleetSlotRules: @pytest.mark.parametrize( diff --git a/testing/ui/test_choose_ship_page.py b/testing/ui/test_choose_ship_page.py index 68476f54..e0569952 100644 --- a/testing/ui/test_choose_ship_page.py +++ b/testing/ui/test_choose_ship_page.py @@ -3,10 +3,13 @@ from types import SimpleNamespace from unittest.mock import MagicMock, patch +import numpy as np + from autowsgr.combat.fleet import ShipSelector from autowsgr.types import ShipType from autowsgr.ui.choose_ship_page import ChooseShipPage from autowsgr.ui.utils.ship_list import LevelOCRRetryNeededError +from autowsgr.vision import OCRResult from autowsgr.vision.ocr import set_ship_name_match_confidence from autowsgr.vision.ocr_rules import set_user_ship_name_aliases @@ -42,6 +45,121 @@ def test_user_alias_is_used_for_search_and_matching(self): assert ChooseShipPage._matches_ship_name('85工程', '契卡洛夫') +class TestShipTypeProbeRoutes: + @staticmethod + def _build_page() -> tuple[ChooseShipPage, MagicMock]: + ocr = MagicMock() + ctx = SimpleNamespace(ctrl=MagicMock(), ocr=ocr) + return ChooseShipPage(ctx), ocr + + def test_legacy_entry_keeps_original_probe(self): + page, ocr = self._build_page() + ocr.recognize.return_value = [OCRResult(text='航母', confidence=0.99)] + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + + ship_type = page._detect_ship_type_near_hit( + screen, + cx=403 / 1280, + cy=650 / 720, + row_key=648 / 720, + ) + + assert ship_type is ShipType.CV + assert ocr.recognize.call_count == 1 + assert ocr.recognize.call_args.args[0].shape == (108, 220, 3) + + def test_uses_single_card_roi_at_720p(self): + page, ocr = self._build_page() + ocr.recognize.return_value = [OCRResult(text='航母', confidence=0.99)] + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + + ship_type = page._detect_ship_type_in_single_card( + screen, + cx=403 / 1280, + cy=650 / 720, + row_key=648 / 720, + ) + + assert ship_type is ShipType.CV + assert ocr.recognize.call_count == 1 + assert ocr.recognize.call_args.args[0].shape == (96, 272, 3) + + def test_scales_single_card_roi_with_screen_resolution(self): + page, ocr = self._build_page() + ocr.recognize.return_value = [OCRResult(text='轻母', confidence=0.99)] + screen = np.zeros((1080, 1920, 3), dtype=np.uint8) + + ship_type = page._detect_ship_type_in_single_card( + screen, + cx=403 / 1280, + cy=650 / 720, + row_key=648 / 720, + ) + + assert ship_type is ShipType.CVL + assert ocr.recognize.call_args.args[0].shape == (144, 408, 3) + + def test_retries_by_upscaling_the_same_card_roi(self): + page, ocr = self._build_page() + ocr.recognize.side_effect = [ + [], + [OCRResult(text='轻母', confidence=0.99)], + ] + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + + ship_type = page._detect_ship_type_in_single_card( + screen, + cx=403 / 1280, + cy=650 / 720, + row_key=648 / 720, + ) + + assert ship_type is ShipType.CVL + assert ocr.recognize.call_count == 2 + first_image = ocr.recognize.call_args_list[0].args[0] + retry_image = ocr.recognize.call_args_list[1].args[0] + assert first_image.shape == (96, 272, 3) + assert retry_image.shape == (144, 408, 3) + + def test_rejects_multiple_ship_types_without_guessing(self): + page, ocr = self._build_page() + ocr.recognize.return_value = [ + OCRResult(text='航母', confidence=0.99), + OCRResult(text='轻母', confidence=0.99), + ] + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + + ship_type = page._detect_ship_type_in_single_card( + screen, + cx=403 / 1280, + cy=650 / 720, + row_key=648 / 720, + ) + + assert ship_type is None + assert ocr.recognize.call_count == 1 + + def test_ignores_faction_parens_in_ship_type_text(self): + page, ocr = self._build_page() + ocr.recognize.return_value = [OCRResult(text='轻巡(J国)', confidence=0.99)] + screen = np.zeros((720, 1280, 3), dtype=np.uint8) + + ship_type = page._detect_ship_type_in_single_card( + screen, + cx=403 / 1280, + cy=650 / 720, + row_key=648 / 720, + ) + + assert ship_type is ShipType.CL + + def test_extract_ship_type_strips_faction_parens(self): + assert ChooseShipPage._extract_ship_type_from_text('轻巡(J国)') is ShipType.CL + assert ChooseShipPage._extract_ship_type_from_text('(E国)战列') is ShipType.BB + assert ChooseShipPage._extract_ship_type_from_text('潜艇 G国') is ShipType.SS + assert ChooseShipPage._extract_ship_type_from_text('(J国)') is None + + class TestIndependentShipRules: def test_single_rule_uses_its_own_constraints(self): ctx = SimpleNamespace(ctrl=MagicMock(), ocr=object()) diff --git a/uv.lock b/uv.lock index 929d37a6..5740dd44 100644 --- a/uv.lock +++ b/uv.lock @@ -38,6 +38,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, ] +[[package]] +name = "antlr4-python3-runtime" +version = "4.9.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3e/38/7859ff46355f76f8d19459005ca000b6e7012f2f1ca597746cbcd1fbfe5e/antlr4-python3-runtime-4.9.3.tar.gz", hash = "sha256:f224469b4168294902bb1efa80a8bf7855f24c99aef99cbefc1bcd3cce77881b", size = 117034, upload-time = "2021-11-06T17:52:23.524Z" } + [[package]] name = "anyio" version = "4.14.2" @@ -64,6 +70,7 @@ dependencies = [ { name = "loguru" }, { name = "opencv-python-headless" }, { name = "pydantic" }, + { name = "rapidocr" }, { name = "retry" }, { name = "rich" }, { name = "uvicorn", extra = ["standard"] }, @@ -91,6 +98,7 @@ requires-dist = [ { name = "loguru" }, { name = "opencv-python-headless" }, { name = "pydantic", specifier = ">=2.0,<3.0" }, + { name = "rapidocr", specifier = ">=3.0" }, { name = "retry", specifier = ">=0.9.2" }, { name = "rich" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.23.0" }, @@ -238,6 +246,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "colorlog" +version = "6.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8c/55/ba79756cb90c8d69d599d57785398ac87bba7b19c80e87f4e8a562197c93/colorlog-6.12.0.tar.gz", hash = "sha256:2a7924c1dadf18b22a0eb8b06d1c7b01d5341707ec1641eb6fcc4fde0c3e8e5f", size = 18151, upload-time = "2026-07-23T13:40:40.71Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/19/0b6647bf5e331521e55d2b63bfbdc210bd9cd605189273f03614a05f702d/colorlog-6.12.0-py3-none-any.whl", hash = "sha256:30d392604e9110045a2c2aeefc27d7a017abbab63f3a8aee594eac0801df784e", size = 12239, upload-time = "2026-07-23T13:40:39.562Z" }, +] + [[package]] name = "coverage" version = "7.15.3" @@ -860,6 +880,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, ] +[[package]] +name = "omegaconf" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "antlr4-python3-runtime" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/3d/e4b57b8d9008c6ebe0d5eff901f91d5700cf7bdb8c8863df817463a7fd5e/omegaconf-2.3.1.tar.gz", hash = "sha256:e5e7de64aeebeddaf8e6d3f7a783b32ac2a01c0fbd9c878012caecb891a1f42a", size = 3298472, upload-time = "2026-06-11T05:05:12.885Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/0e/152509871bf30df6fc38569f52a2db9b55dd41aae957adae50a053ac7778/omegaconf-2.3.1-py3-none-any.whl", hash = "sha256:3d701d14e9a8828f1edd28bb70b725908b34277cdd72cf7d6a83f94dadc6b6a0", size = 79502, upload-time = "2026-06-11T05:05:09.954Z" }, +] + [[package]] name = "opencv-python" version = "5.0.0.93" @@ -3698,6 +3731,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, ] +[[package]] +name = "rapidocr" +version = "3.9.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorlog" }, + { name = "numpy" }, + { name = "omegaconf" }, + { name = "opencv-python" }, + { name = "pillow" }, + { name = "pyclipper" }, + { name = "pyyaml" }, + { name = "requests" }, + { name = "shapely" }, + { name = "six" }, + { name = "tqdm" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/ed/0ee9b9281986974be9d2406ae0134c8d7c91d2fc613f16ffda9701eeda6f/rapidocr-3.9.2-py3-none-any.whl", hash = "sha256:04d6b8d151f823d930bd91910555f57bea897c0c44fa6794267b94cf9c1ef9a0", size = 27275208, upload-time = "2026-07-21T10:59:01.599Z" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -3867,6 +3921,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/36/f7/d317eb232352a1f1444d11002d477e54514a4a6045536d49d0c59783c0da/shapely-2.1.2-cp313-cp313t-win_amd64.whl", hash = "sha256:4a44bc62a10d84c11a7a3d7c1c4fe857f7477c3506e24c9062da0db0ae0c449c", size = 1739756, upload-time = "2025-09-24T13:51:12.105Z" }, ] +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + [[package]] name = "soupsieve" version = "2.9.1" @@ -3964,6 +4027,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f3/a6/b4081e2d04e1541abf82785ac9e5178a494c19330391f551356c8c18b7b3/torchvision-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:7e9dd6f60d6e15f8dc27d4f877fdb6002fc70d70272412135f1c2ff9cfa08d3b", size = 4157380, upload-time = "2026-07-08T16:07:40.22Z" }, ] +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220", size = 795438, upload-time = "2026-07-27T11:33:15.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953", size = 80184, upload-time = "2026-07-27T11:33:13.167Z" }, +] + [[package]] name = "triton" version = "3.7.1"