fix(event_sweep): resolve OCR misreads for stages 08/09 and improve result button detection logic

This commit is contained in:
Nik Afiq 2026-07-11 12:07:21 +09:00
parent 634d77d9d2
commit 166a34825a
5 changed files with 118 additions and 8 deletions

View File

@ -319,10 +319,25 @@ EVENT_STAGE_MODAL_CLOSE_BUTTON = (1704, 271)
# The AP-usage confirmation dialog raised by 掃討開始, and the post-sweep # The AP-usage confirmation dialog raised by 掃討開始, and the post-sweep
# "掃討完了" SKIP/OK result screen, reuse SWEEP_CONFIRM_BUTTON/ # "掃討完了" SKIP/OK result screen, reuse SWEEP_CONFIRM_BUTTON/
# SWEEP_CONFIRM_CANCEL_BUTTON/SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD/ # SWEEP_CONFIRM_CANCEL_BUTTON/SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD directly
# SWEEP_RESULT_BUTTON_REGION directly (defined above under story_sweep) -- # (defined above under story_sweep) -- confirmed live pixel-identical
# confirmed live pixel-identical position and color, since it's the same # position and color, since it's the same shared dialog component both
# shared dialog component both tasks reach after their own 掃討開始 click. # tasks reach after their own 掃討開始 click.
#
# NOT shared: SWEEP_RESULT_BUTTON_REGION. Confirmed live (2026-07-11,
# scratchpad/probe_result_button_fp.py) that region's x-range (700-1300)
# reaches into this event's own Quest-list character-art panel on the
# left, which false-positive-matched SWEEP_CONFIRM_CYAN on the plain list
# with NO sweep-result dialog showing at all -- this is why
# _watch_sweep_result kept "finding" a result button and never reached its
# "modal closed, no result button" end condition after a real sweep
# finished, even though the sweep itself succeeded (AP spent, credits
# gained, confirmed by screenshot). Narrower x-range here (1000-1300)
# still comfortably covers the real buttons (SWEEP_CONFIRM_BUTTON's own
# x=1150 sits well inside it) while excluding the character-art panel,
# confirmed against the same saved false-positive screenshot returning
# no match afterward.
EVENT_SWEEP_RESULT_BUTTON_REGION = (1000, 700, 1300, 1050)
# Rotation target: which of stages 9-12 to sweep today, per explicit user # Rotation target: which of stages 9-12 to sweep today, per explicit user
# request (2026-07-10) -- "the current event has up to 12 stages, randomly # request (2026-07-10) -- "the current event has up to 12 stages, randomly

View File

@ -202,6 +202,41 @@ def read_int(region, psm=7):
return int(digits) if digits else None return int(digits) if digits else None
def read_int_bordered(region, psm=7, border=20):
"""read_int, but with a white margin added around the upscaled crop
before OCR.
event_sweep.py's stage-row numbers "08"/"09" (a leading-zero digit
tight against the following one) were misread by plain read_int on
every psm mode tried -- "08" consistently as "2", "09" as empty or "9"
with the leading zero silently dropped -- even though the crop and its
thresholded/upscaled version both look completely clean to the eye.
"10"/"11"/"12" at the same crop dimensions read fine. Confirmed live
(scratchpad/probe_ocr_fix_08_09.py) this is specifically a tesseract
segmentation problem with a text blob that fills the crop edge-to-edge,
with no surrounding whitespace context to anchor character boundaries
-- adding a plain white cv2.copyMakeBorder margin (no other pipeline
change) fixed both "08" and "09" to their exact correct digits at every
psm mode tried, and left the already-working "10" unaffected. Kept as
its own function rather than changed in read_int/_ocr_crop, since only
this one tight edge-to-edge crop shape has ever been confirmed to need
it -- every other OCR read in this project already has enough natural
margin.
"""
x1, y1, x2, y2 = region
driver.screenshot(OCR_SHOT_PATH)
img = cv2.imread(OCR_SHOT_PATH)
crop = img[y1:y2, x1:x2]
gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
_, thresh = cv2.threshold(gray, 150, 255, cv2.THRESH_BINARY)
upscaled = cv2.resize(thresh, None, fx=OCR_UPSCALE, fy=OCR_UPSCALE, interpolation=cv2.INTER_CUBIC)
bordered = cv2.copyMakeBorder(upscaled, border, border, border, border, cv2.BORDER_CONSTANT, value=255)
tess_config = f"--psm {psm} -c tessedit_char_whitelist=0123456789"
text = pytesseract.image_to_string(bordered, lang="eng", config=tess_config).strip()
digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None
def read_int_on_heart_badge(region, psm=7): def read_int_on_heart_badge(region, psm=7):
"""OCR a small dark-navy digit rendered on lesson.py's pink/magenta """OCR a small dark-navy digit rendered on lesson.py's pink/magenta
heart-shaped affection badge. heart-shaped affection badge.

File diff suppressed because one or more lines are too long

View File

@ -167,6 +167,54 @@ natural rotation a real chance to land on the correct item within the
retry window, rather than relying on a fast-but-unreliable dot-click to retry window, rather than relying on a fast-but-unreliable dot-click to
force it. Dot-clicking is kept as a harmless best-effort nudge alongside force it. Dot-clicking is kept as a harmless best-effort nudge alongside
the longer wait, not removed, since it did visibly work at least twice. the longer wait, not removed, since it did visibly work at least twice.
Stage 08/09 OCR misread (2026-07-11, previously flagged in plan.md as a
known-but-non-blocking gap, then hit for real once the daily rotation
picked stage 9): row @366 ("08") read as "2", row @538 ("09") read as
empty/None, on every psm mode tried with plain detector.read_int, even
though the crop and its thresholded/upscaled version both looked perfectly
clean by eye (scratchpad/probe_event_stage_ocr.py, probe_ocr_psm_sweep.py)
-- confirmed live NOT a navigation/timing bug this time, since rows
710/883/1055 ("10"/"11"/"12") read correctly in the same run. Root cause,
isolated via scratchpad/probe_ocr_fix_08_09.py: tesseract's segmentation
struggles with this specific tight edge-to-edge crop (no surrounding
whitespace margin) for a leading-zero digit pair specifically -- adding a
plain white border around the upscaled crop before OCR fixed both "08"
and "09" to their exact correct values at every psm mode tried, without
affecting the already-working "10". Ported as detector.read_int_bordered,
now used for all EVENT_STAGE_ROW_Y reads in _scan_stage_rows_once.
The same live run also confirmed the sweep itself succeeded end-to-end
(AP 233->14, credits +5,892, screenshot-confirmed safe return home) but
still logged "unrecognized_state" instead of "swept" -- the
_watch_sweep_result outcome-logging bug flagged after Phase 14 follow-up
#5 as "not yet re-verified live" was, it turns out, still broken, just for
a DIFFERENT reason than the POST_SWEEP_DISMISS_ROUNDS timing budget that
fix targeted. Root cause this time, isolated with zero AP cost via
scratchpad/probe_result_button_fp.py (checks _find_result_button against
the plain Quest list with no sweep in progress): the shared
SWEEP_RESULT_BUTTON_REGION (700-1300 x-range, borrowed directly from
story_sweep.py) reaches into this event's own Quest-list character-art
panel on the left side of the screen, which false-positive-matched
SWEEP_CONFIRM_CYAN even with no result dialog showing at all -- confirmed
on both a wrong/finished event page and, more importantly, the actual
correct current event's own plain list. This meant _watch_sweep_result's
reaction kept "finding" a result button and clicking it after the real
one had already been fully dismissed, so the "modal closed, no result
button" end condition could never match. Fixed with a new, event_sweep-
only EVENT_SWEEP_RESULT_BUTTON_REGION (x narrowed to 1000-1300, excluding
the character-art panel while still comfortably covering the real
buttons' known x~1150 position) -- confirmed via the same saved
false-positive screenshot returning no match afterward. Deliberately NOT
changed in the shared SWEEP_RESULT_BUTTON_REGION story_sweep.py also uses,
per this file's own established pattern of giving event_sweep its own
constant rather than coupling the two tasks' config together whenever
their actual on-screen content differs (see EVENT_STAGE_MODAL_PROBE's
comment for the earlier instance of the same reasoning). Not yet
re-confirmed against a fresh real bulk sweep (that day's AP was reduced to
14/240 by the sweep that surfaced this, too low to force another MAX
sweep) -- the fix is validated against the actual recorded false-positive
screenshot and the same live code path, just not a full new live sweep.
""" """
import datetime import datetime
import os import os
@ -206,7 +254,7 @@ def _is_ap_purchase_prompt(driver, config):
def _find_result_button(driver, config): def _find_result_button(driver, config):
return detector.find_color_centroid(config.SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN) return detector.find_color_centroid(config.EVENT_SWEEP_RESULT_BUTTON_REGION, *config.SWEEP_CONFIRM_CYAN)
def _count_raised_above_one(driver, config): def _count_raised_above_one(driver, config):
@ -258,7 +306,7 @@ def _row_number_rect(config, row_y):
def _scan_stage_rows_once(driver, config, stage): def _scan_stage_rows_once(driver, config, stage):
saw_any_valid_row = False saw_any_valid_row = False
for row_y in config.EVENT_STAGE_ROW_Y: for row_y in config.EVENT_STAGE_ROW_Y:
label = detector.read_int(_row_number_rect(config, row_y)) label = detector.read_int_bordered(_row_number_rect(config, row_y))
print(f"[event_sweep] row @ {row_y}: read '{label}'") print(f"[event_sweep] row @ {row_y}: read '{label}'")
if label is not None: if label is not None:
saw_any_valid_row = True saw_any_valid_row = True

14
plan.md
View File

@ -159,7 +159,7 @@ Do not implement a feature without filling at least the relevant row.
| Common Shop / Tactical Shop | Done (Phase 11): `ba_auto/tasks/shop_common.py` / `shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`) against config-driven `(row, col, name, expected_price)` targets, price-OCR-verified before each click. Live-tested with real purchases in both shops. Opt-in only (`shop_common`/`shop_tactical` commands), not part of the default daily flow | Done | | Common Shop / Tactical Shop | Done (Phase 11): `ba_auto/tasks/shop_common.py` / `shop_tactical.py` share a checkbox-grid-then-bulk-buy flow (`ba_auto/tasks/shop_utils.py`) against config-driven `(row, col, name, expected_price)` targets, price-OCR-verified before each click. Live-tested with real purchases in both shops. Opt-in only (`shop_common`/`shop_tactical` commands), not part of the default daily flow | Done |
| Lesson/Schedule | Done (Phase 12): `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid, picking the highest-affection available lesson each time via a dedicated heart-badge OCR read (`detector.read_int_on_heart_badge`) until tickets or lessons run out. Live-tested with real tickets spent; two real bugs (checkmark-doesn't-blank-the-number, badge OCR misreads) found and fixed. Opt-in only (`lesson` command), not part of the default daily flow | Done | | Lesson/Schedule | Done (Phase 12): `ba_auto/tasks/lesson.py` sweeps every unlocked region's schedule grid, picking the highest-affection available lesson each time via a dedicated heart-badge OCR read (`detector.read_int_on_heart_badge`) until tickets or lessons run out. Live-tested with real tickets spent; two real bugs (checkmark-doesn't-blank-the-number, badge OCR misreads) found and fixed. Opt-in only (`lesson` command), not part of the default daily flow | Done |
| Arena / Tactical Challenge | Done (Phase 13): `ba_auto/tasks/arena.py` fights exactly one ranked battle per invocation and collects both reward slots. Live-tested across all 5 of the account's daily tickets (2 WIN, 1 LOSE); the post-fight WIN/LOSE result modal proved undetectable by precise button-color search and was fixed via a bounded blind-Enter loop gated by a hard safety check. A later consecutive-invocation navigation bug (Phase 13 follow-up, fixed via `navigation.return_to_home` at start of `run()`) is also confirmed live. Opt-in only (`arena` command), not part of the default daily flow | Done | | Arena / Tactical Challenge | Done (Phase 13): `ba_auto/tasks/arena.py` fights exactly one ranked battle per invocation and collects both reward slots. Live-tested across all 5 of the account's daily tickets (2 WIN, 1 LOSE); the post-fight WIN/LOSE result modal proved undetectable by precise button-color search and was fixed via a bounded blind-Enter loop gated by a hard safety check. A later consecutive-invocation navigation bug (Phase 13 follow-up, fixed via `navigation.return_to_home` at start of `run()`) is also confirmed live. Opt-in only (`arena` command), not part of the default daily flow | Done |
| Event sweep | Done (Phase 14 + follow-ups 1-4): `ba_auto/tasks/event_sweep.py` sweeps one config-rotated stage (9-12) of the currently-running event per invocation. First confirmed real live sweep 2026-07-10 (200 AP spent, 10x MAX sweep, credits gained, confirmed by screenshot) after fixing a badge-carousel navigation bug (pagination dots must be clicked directly, not just the ambiguous rotating badge) and a cold-start OCR timing bug (stage list can take ~20s to populate after a fresh navigation). Opt-in only (`event_sweep` command), not part of the default daily flow | Done, one cosmetic result-outcome-logging fix (`POST_SWEEP_DISMISS_ROUNDS`) not yet re-verified live | | Event sweep | Done (Phase 14 + follow-ups 1-6): `ba_auto/tasks/event_sweep.py` sweeps one config-rotated stage (9-12) of the currently-running event per invocation. First confirmed real live sweep 2026-07-10 (200 AP spent, 10x MAX sweep, credits gained, confirmed by screenshot) after fixing a badge-carousel navigation bug (pagination dots must be clicked directly, not just the ambiguous rotating badge) and a cold-start OCR timing bug (stage list can take ~20s to populate after a fresh navigation). Follow-up #6 (2026-07-11) fixed a stage 08/09 OCR misread (`detector.read_int_bordered`) and the real root cause of the `unrecognized_state` mislabeling (a false-positive color match on the event's own character art, fixed via `EVENT_SWEEP_RESULT_BUTTON_REGION`) -- both confirmed live, the second via the actual false-positive condition rather than a fresh full sweep (AP was too low that day). Opt-in only (`event_sweep` command), not part of the default daily flow | Done, one outcome-logging fix awaiting one more full live sweep to confirm the log itself reads "swept" |
| Shared driver | `ba_auto/driver.py` built (`run_command`, `focus_game`, `click`, `move_mouse`, `scroll`, `keypress`, `screenshot`, `wait`, `color_at`); `click()` now splits `mousemove`/`click` into two xdotool calls (Phase 8 finding — fixes a real source of click flakiness); wired into `mailbox.py`, `cafe.py`, `stamina.py`, `story_sweep.py`, `shop_common.py`, `shop_tactical.py`, `lesson.py` | Extend with new primitives as future tasks need them | | Shared driver | `ba_auto/driver.py` built (`run_command`, `focus_game`, `click`, `move_mouse`, `scroll`, `keypress`, `screenshot`, `wait`, `color_at`); `click()` now splits `mousemove`/`click` into two xdotool calls (Phase 8 finding — fixes a real source of click flakiness); wired into `mailbox.py`, `cafe.py`, `stamina.py`, `story_sweep.py`, `shop_common.py`, `shop_tactical.py`, `lesson.py` | Extend with new primitives as future tasks need them |
| Python CLI | Built: `ba_daily.py` dispatches `mailbox`/`cafe`/`stamina`/`story_sweep`/`shop_common`/`shop_tactical`/`lesson`/default flow | Extend as new tasks are added | | Python CLI | Built: `ba_daily.py` dispatches `mailbox`/`cafe`/`stamina`/`story_sweep`/`shop_common`/`shop_tactical`/`lesson`/default flow | Extend as new tasks are added |
| Reference mapping | Built: `ba_auto/reference_notes/mapping.md` | Fill in reference file/function columns per feature | | Reference mapping | Built: `ba_auto/reference_notes/mapping.md` | Fill in reference file/function columns per feature |
@ -550,6 +550,18 @@ This is the same failure class `CLAUDE.md` already documents for the mailbox/caf
**Status**: the wrong-page-looping bug the user reported twice is now considered definitively fixed, confirmed via two independent real live sweeps in this follow-up alone (three total across the whole Phase 14 investigation). The `_watch_sweep_result` fix is well-reasoned and low-risk (outcome-logging correctness only; the actual sweep behavior was already safe even without it) but awaits live reconfirmation once AP regenerates. **Status**: the wrong-page-looping bug the user reported twice is now considered definitively fixed, confirmed via two independent real live sweeps in this follow-up alone (three total across the whole Phase 14 investigation). The `_watch_sweep_result` fix is well-reasoned and low-risk (outcome-logging correctness only; the actual sweep behavior was already safe even without it) but awaits live reconfirmation once AP regenerates.
#### Phase 14 follow-up #6: stage 08/09 OCR misread (the flagged non-blocking gap, now actually hit) + the real `_watch_sweep_result` root cause
**Reported 2026-07-11**: the daily rotation picked stage 9 for the first time since this task existed — exactly the previously-flagged-but-never-hit "stage-list rows for stages 08/09 consistently misread by OCR" gap. Real log: rows 366/538 ("08"/"09") read `None` on the first (wrong-page) attempt, then `'2'` and `None` respectively on the second (correct-page) attempt, while rows 710/883/1055 ("10"/"11"/"12") read correctly — confirmed NOT a navigation/timing bug this time.
**Root cause** (isolated with `scratchpad/probe_event_stage_ocr.py` and `probe_ocr_psm_sweep.py`, run directly against a live screenshot with no game interaction needed after capture): the crop and its thresholded/upscaled version both looked completely clean by eye, but tesseract's segmentation consistently misreads "08" as "2" and drops/mangles "09" across every psm mode tried. `scratchpad/probe_ocr_fix_08_09.py` isolated why: the crop is edge-to-edge with no whitespace margin around the glyph pair, and adding a plain white border around the upscaled image before OCR fixed both digits to their exact correct values at every psm mode, without affecting the already-working "10". **Fix**: new `detector.read_int_bordered` (same pipeline as `read_int`, plus `cv2.copyMakeBorder`), used for all `EVENT_STAGE_ROW_Y` reads. Kept as its own function rather than changed in the shared `read_int`/`_ocr_crop`, since no other current OCR read in this project has this specific tight-crop shape.
**Confirmed live**: ran `event_sweep` for real. `row @ 366: read '8'`, `row @ 538: read '9'` — stage 9 found, modal opened, sweep confirmed and executed. AP 233/240 → 14/240, credits +5,892, screenshot-confirmed safe return home. The originally reported bug is fixed.
**Bonus finding**: the same run again logged `unrecognized_state` instead of `swept`, despite the sweep genuinely succeeding — proving follow-up #5's `clicked_any`-gate fix, while a real improvement, was not the whole story. Diagnosed at zero AP cost via `scratchpad/probe_result_button_fp.py` (checks `_find_result_button` against the plain Quest list with no sweep in progress, no AP needed to reproduce): the shared `SWEEP_RESULT_BUTTON_REGION` (x: 7001300, borrowed directly from `story_sweep.py`) reaches into this event's own Quest-list character-art panel on the left side of the screen, which false-positive-matches `SWEEP_CONFIRM_CYAN` even with no result dialog showing at all — confirmed on both a wrong/finished event page and, more importantly, the actual correct event's own plain list. This meant `_watch_sweep_result`'s reaction kept "finding" a result button and clicking it after the real one had already been dismissed, so its "modal closed, no result button" end condition could never match. **Fix**: new `config.EVENT_SWEEP_RESULT_BUTTON_REGION` (x narrowed to 10001300, excluding the character-art panel while still comfortably covering the real buttons' known x≈1150 position), used only by `event_sweep.py`'s `_find_result_button` — deliberately not changed in the shared region `story_sweep.py` also uses, matching this task's established pattern of giving event_sweep its own constant whenever the two tasks' actual on-screen content differs. **Confirmed live** (zero AP): re-ran the same false-positive probe against the deployed fix on the real correct-event Quest list — `_find_result_button` now correctly returns `None` where it previously returned a false match. Not yet re-confirmed against a fresh full real sweep end-to-end, since that day's AP was down to 14/240 (too low for another MAX sweep) — the fix is validated against the actual live false-positive scenario and the real code path, just not a brand-new full sweep cycle.
**Status**: both bugs fixed. The originally reported bug (stage 9 unreadable) is fully confirmed via a real successful live sweep. The `_watch_sweep_result` mislabeling's real root cause is now understood and fixed (previous fix attempt addressed a real but secondary issue); confirmed against the actual live false-positive condition, awaiting one more full live sweep to confirm the outcome log itself reads `"swept"` next time AP allows.
## Prerequisites ## Prerequisites
### OCR ### OCR