Skip to content

Commit 7a26b4e

Browse files
fix(ui): normalize ship selection matching and level filtering (copilot addressed v5) (#442)
* fix(ui): normalize ship selection and level filtering * chore(release): bump version to 2.1.9.post6 * fix(ui): address Copilot review for level OCR parsing * fix(ui): address Copilot review for level parsing and probing * fix(ui): address Copilot review for level binding and OCR noise * fix(ui): address Copilot review for OCR noise i and docs * fix(ui): add bounded OCR fallback and retry threshold * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * fix(ui): map uppercase L as OCR noise digit --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 770eaac commit 7a26b4e

3 files changed

Lines changed: 243 additions & 35 deletions

File tree

autowsgr/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""AutoWSGR - 战舰少女R 自动化框架(v2)"""
22

3-
__version__ = '2.1.9.post5'
3+
__version__ = '2.1.9.post6'

autowsgr/ui/choose_ship_page.py

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from __future__ import annotations
1515

16+
import re
1617
import time
1718
from typing import TYPE_CHECKING
1819

@@ -25,7 +26,7 @@
2526
)
2627

2728
from .utils import wait_for_page, wait_leave_page
28-
from .utils.ship_list import locate_ship_rows, read_ship_levels
29+
from .utils.ship_list import LevelOCRRetryNeededError, locate_ship_rows, read_ship_levels
2930

3031

3132
if TYPE_CHECKING:
@@ -56,6 +57,7 @@
5657
_SCROLL_FROM_Y: float = 0.55
5758
_SCROLL_TO_Y: float = 0.30
5859
_OCR_MAX_ATTEMPTS: int = 3
60+
_SHIP_ALIAS_SUFFIX_RE = re.compile(r'\s*[((][^()()]*[))]\s*$')
5961

6062
PAGE_SIGNATURE = PixelSignature(
6163
name='choose_ship_page',
@@ -329,14 +331,16 @@ def _click_ship_in_list(
329331
Parameters
330332
----------
331333
name:
332-
目标舰船名 (精确名称)。
334+
目标舰船名。
335+
匹配时会先做舰名归一化(如去除“·改”与尾部括号别名)后再比较。
333336
334337
Returns
335338
-------
336339
str | None
337340
匹配并点击成功时返回舰船名;失败返回 ``None``。
338341
"""
339342
assert self._ctx.ocr is not None
343+
normalized_target = self._normalize_ship_name(name)
340344

341345
for attempt in range(_OCR_MAX_ATTEMPTS):
342346
screen = self._ctrl.screenshot()
@@ -348,31 +352,47 @@ def _click_ship_in_list(
348352
deduplicate_by_name=False,
349353
include_row_key=True,
350354
)
351-
raw_levels = read_ship_levels(
352-
self._ctx.ocr,
353-
screen,
354-
deduplicate_by_name=False,
355-
include_row_key=True,
356-
)
355+
try:
356+
raw_levels = read_ship_levels(
357+
self._ctx.ocr,
358+
screen,
359+
deduplicate_by_name=False,
360+
include_row_key=True,
361+
)
362+
except LevelOCRRetryNeededError as exc:
363+
_log.warning(
364+
'[UI] 等级 OCR 噪声过高,触发重新识别 (第 {}/{} 次)',
365+
attempt + 1,
366+
_OCR_MAX_ATTEMPTS,
367+
)
368+
if attempt >= _OCR_MAX_ATTEMPTS - 1:
369+
raise RuntimeError('等级 OCR 噪声过高,重试后仍失败') from exc
370+
time.sleep(0.3)
371+
continue
357372
else:
358373
raw_hits = locate_ship_rows(self._ctx.ocr, screen)
359374
raw_levels = []
360375

361376
hits = [self._normalize_hit_entry(hit) for hit in raw_hits]
362-
level_map: dict[float, list[int | None]] = {}
377+
level_map: dict[float, dict[str, list[int | None]]] = {}
363378
for entry in raw_levels:
364-
_, level, row_key = self._normalize_level_entry(entry)
365-
level_map.setdefault(row_key, []).append(level)
379+
level_name, level, row_key = self._normalize_level_entry(entry)
380+
normalized_level_name = self._normalize_ship_name(level_name)
381+
row_levels = level_map.setdefault(row_key, {})
382+
row_levels.setdefault(normalized_level_name, []).append(level)
366383

367384
for matched, cx, cy, row_key in hits:
368-
if matched != name:
385+
normalized_matched = self._normalize_ship_name(matched)
386+
if normalized_matched != normalized_target:
369387
continue
370388

371389
level = None
372390
if use_level_filter:
373391
row_levels = level_map.get(row_key)
374392
if row_levels:
375-
level = row_levels.pop(0)
393+
name_levels = row_levels.get(normalized_matched)
394+
if name_levels:
395+
level = name_levels.pop(0)
376396
if not self._is_level_in_range(level, min_level, max_level):
377397
_log.warning(
378398
"[UI] 命中 '{}', 但等级 {} 不满足范围 [{}, {}]",
@@ -406,3 +426,10 @@ def _click_ship_in_list(
406426
time.sleep(0.5)
407427

408428
return None
429+
430+
@staticmethod
431+
def _normalize_ship_name(name: str) -> str:
432+
normalized = name.strip()
433+
normalized = normalized.removesuffix('·改')
434+
normalized = _SHIP_ALIAS_SUFFIX_RE.sub('', normalized)
435+
return normalized.strip()

autowsgr/ui/utils/ship_list.py

Lines changed: 202 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,15 @@
3131
#: Legacy 选船列表左侧裁剪宽度 (px@1280)
3232
LEGACY_LIST_WIDTH: int = 1048
3333

34-
_LEVEL_PATTERN = re.compile(r'[Ll][Vv]\.?\s*(\d+)')
34+
_LEVEL_PATTERN = re.compile(r'[Ll][Vv]\.?\s*([0-9ILilOo]{1,6})')
35+
_LEVEL_NOISY_PATTERN = re.compile(r'(?:[LlIi1O0][VvYy])[\.:]?\s*([0-9ILilOo]{1,6})')
36+
_MAX_LEVEL_VALUE = 200
37+
_MAX_LEVEL_NOISE_CHARS = 1
38+
_MAX_NOISY_LEVEL_HITS_BEFORE_RETRY = 1
39+
40+
41+
class LevelOCRRetryNeededError(RuntimeError):
42+
"""等级 OCR 噪声过高,需要重新截图识别。"""
3543

3644

3745
def to_legacy_format(screen: np.ndarray) -> tuple[np.ndarray, float, float]:
@@ -169,15 +177,151 @@ def recognize_ships_in_list(
169177

170178
def _parse_level(text: str) -> int | None:
171179
"""从 OCR 文本中提取 ``Lv.XX`` 格式等级数字。"""
172-
m = _LEVEL_PATTERN.search(text)
180+
level, _need_retry = _parse_level_with_status(text)
181+
return level
182+
183+
184+
def _parse_level_with_status(text: str) -> tuple[int | None, bool]:
185+
"""解析等级并返回是否应触发重识别。"""
186+
compact = text.strip().replace(' ', '')
187+
188+
m = _LEVEL_PATTERN.search(compact)
173189
if m:
174-
try:
175-
return int(m.group(1))
176-
except ValueError:
177-
return None
190+
raw_digits = m.group(1)
191+
if _noise_char_count(raw_digits) > _MAX_LEVEL_NOISE_CHARS:
192+
return None, True
193+
level = _coerce_level_digits(raw_digits)
194+
if level is not None:
195+
return level, False
196+
197+
m2 = _LEVEL_NOISY_PATTERN.search(compact)
198+
if m2:
199+
raw_digits = m2.group(1)
200+
if _noise_char_count(raw_digits) > _MAX_LEVEL_NOISE_CHARS:
201+
return None, True
202+
level = _coerce_level_digits(raw_digits)
203+
if level is not None:
204+
return level, False
205+
206+
return None, False
207+
208+
209+
def _noise_char_count(raw_digits: str) -> int:
210+
return sum(1 for ch in raw_digits if ch in 'ILilOo')
211+
212+
213+
def _coerce_level_digits(raw_digits: str) -> int | None:
214+
"""将 OCR 提取出的数字串映射为合法等级值。"""
215+
trans = str.maketrans(
216+
{
217+
'I': '1',
218+
'i': '1',
219+
'l': '1',
220+
'L': '1',
221+
'O': '0',
222+
'o': '0',
223+
}
224+
)
225+
normalized = raw_digits.translate(trans)
226+
digits = ''.join(ch for ch in normalized if ch.isdigit())
227+
if not digits:
228+
return None
229+
230+
candidates: list[int] = []
231+
232+
# 先尝试前 3 位(常见误读: 1046 -> 104, 110544 -> 110)
233+
if len(digits) >= 3:
234+
candidates.append(int(digits[:3]))
235+
if len(digits) >= 2:
236+
candidates.append(int(digits[:2]))
237+
candidates.append(int(digits[:1]))
238+
239+
# 兼容前导 0 的场景(如 051 -> 51)
240+
if digits.startswith('0') and len(digits) >= 3:
241+
candidates.insert(0, int(digits[1:3]))
242+
243+
seen_vals: set[int] = set()
244+
for value in candidates:
245+
if value in seen_vals:
246+
continue
247+
seen_vals.add(value)
248+
if 1 <= value <= _MAX_LEVEL_VALUE:
249+
return value
250+
178251
return None
179252

180253

254+
def _center_x(bbox: tuple[int, int, int, int] | None, width: int) -> float:
255+
if bbox is None:
256+
return width / 2
257+
x1, _, x2, _ = bbox
258+
return (x1 + x2) / 2
259+
260+
261+
def _probe_level_near_name(
262+
ocr: OCREngine,
263+
screen: np.ndarray,
264+
*,
265+
y_start: int,
266+
y_end: int,
267+
name_x: float,
268+
max_x: int,
269+
) -> int | None:
270+
"""在同一 y 行按舰名 x 位置裁剪区域,二次识别等级。"""
271+
h, w = screen.shape[:2]
272+
row_h = max(1, y_end - y_start)
273+
274+
x_pad = max(70, int(w * 0.045))
275+
x0 = max(0, int(name_x - x_pad))
276+
x1 = min(max_x, int(name_x + x_pad))
277+
278+
y0 = max(0, y_start - int(row_h * 1.6))
279+
y1 = min(h, y_end + int(row_h * 0.4))
280+
281+
if x1 <= x0 or y1 <= y0:
282+
return None
283+
284+
roi = screen[y0:y1, x0:x1]
285+
if roi.size == 0:
286+
return None
287+
288+
parsed_levels: list[int] = []
289+
noisy_level_hits = 0
290+
291+
def collect_levels(img: np.ndarray) -> None:
292+
nonlocal noisy_level_hits
293+
results = ocr.recognize(img, allowlist='LlVvIiYy0Oo1.:-/0123456789')
294+
for r in results:
295+
text = r.text.strip()
296+
if not text:
297+
continue
298+
level, need_retry = _parse_level_with_status(text)
299+
if need_retry:
300+
noisy_level_hits += 1
301+
continue
302+
if level is not None:
303+
parsed_levels.append(level)
304+
305+
collect_levels(roi)
306+
307+
gray = cv2.cvtColor(roi, cv2.COLOR_RGB2GRAY)
308+
up = cv2.resize(gray, None, fx=3, fy=3, interpolation=cv2.INTER_CUBIC)
309+
norm = cv2.normalize(up, None, 0, 255, cv2.NORM_MINMAX)
310+
binary = cv2.threshold(norm, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)[1]
311+
binary_rgb = cv2.cvtColor(binary, cv2.COLOR_GRAY2RGB)
312+
collect_levels(binary_rgb)
313+
314+
if not parsed_levels and noisy_level_hits > _MAX_NOISY_LEVEL_HITS_BEFORE_RETRY:
315+
raise LevelOCRRetryNeededError(
316+
f'等级 OCR 噪声过高: {noisy_level_hits} 条异常等级文本 (阈值 {_MAX_NOISY_LEVEL_HITS_BEFORE_RETRY})',
317+
)
318+
319+
if not parsed_levels:
320+
return None
321+
322+
return max(parsed_levels)
323+
324+
181325
def read_ship_levels(
182326
ocr: OCREngine,
183327
screen: np.ndarray,
@@ -230,43 +374,80 @@ def read_ship_levels(
230374
for y_start_720, y_end_720 in rows:
231375
y_start = max(0, int((y_start_720 - 1) * scale_y))
232376
y_end = min(h, int((y_end_720 + 1) * scale_y))
377+
row_key = round((y_start + y_end) / 2 / h, 4)
233378

234379
row_img = list_area_native[y_start:y_end]
235380
results = ocr.recognize(row_img)
236381

237-
row_name: str | None = None
238-
row_level: int | None = None
382+
name_hits: list[tuple[str, float]] = []
383+
local_level_hits: list[tuple[int, float]] = []
239384

240385
for r in results:
241386
text = r.text.strip()
242387
if not text:
243388
continue
244389

245-
# 尝试匹配等级
246-
if row_level is None:
247-
level = _parse_level(text)
248-
if level is not None:
249-
row_level = level
390+
x_center = _center_x(r.bbox, row_img.shape[1])
250391

251-
# 尝试匹配舰船名
252-
if row_name is None:
253-
name = _fuzzy_match(text, SHIPNAMES)
254-
if name is not None and name not in seen:
255-
row_name = name
392+
level = _parse_level(text)
393+
if level is not None:
394+
local_level_hits.append((level, x_center))
256395

257-
if row_name is not None:
396+
name = _fuzzy_match(text, SHIPNAMES)
397+
if name is not None:
398+
name_hits.append((name, x_center))
399+
400+
if not name_hits:
401+
continue
402+
403+
name_hits.sort(key=lambda item: item[1])
404+
local_level_hits.sort(key=lambda item: item[1])
405+
max_pair_dist = max(80.0, row_img.shape[1] * 0.12)
406+
407+
for row_name, name_x in name_hits:
258408
if deduplicate_by_name and row_name in seen:
259409
continue
410+
411+
row_level: int | None = None
412+
413+
best_level: int | None = None
414+
best_dist = float('inf')
415+
for candidate_level, candidate_x in local_level_hits:
416+
dist = abs(candidate_x - name_x)
417+
if dist < best_dist:
418+
best_dist = dist
419+
best_level = candidate_level
420+
421+
if best_level is not None and best_dist <= max_pair_dist:
422+
row_level = best_level
423+
424+
if row_level is None:
425+
probe_level = _probe_level_near_name(
426+
ocr,
427+
screen,
428+
y_start=y_start,
429+
y_end=y_end,
430+
name_x=name_x,
431+
max_x=list_w_native,
432+
)
433+
if probe_level is not None:
434+
row_level = probe_level
435+
260436
if deduplicate_by_name:
261437
seen.add(row_name)
262-
row_key = round((y_start + y_end) / 2 / h, 4)
438+
_log.debug(
439+
'[选船列表] 等级识别命中: name={} level={} row_key={}',
440+
row_name,
441+
row_level if row_level is not None else 'None',
442+
row_key,
443+
)
263444
if include_row_key:
264445
found.append((row_name, row_level, row_key))
265446
else:
266447
found.append((row_name, row_level))
267448

268449
_log.debug(
269450
'[选船列表] 等级识别: {}',
270-
[(n, lv) for n, lv in found],
451+
[(entry[0], entry[1]) for entry in found],
271452
)
272453
return found

0 commit comments

Comments
 (0)