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
# "掃討完了" SKIP/OK result screen, reuse SWEEP_CONFIRM_BUTTON/
# SWEEP_CONFIRM_CANCEL_BUTTON/SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD/
# SWEEP_RESULT_BUTTON_REGION directly (defined above under story_sweep) --
# confirmed live pixel-identical position and color, since it's the same
# shared dialog component both tasks reach after their own 掃討開始 click.
# SWEEP_CONFIRM_CANCEL_BUTTON/SWEEP_CONFIRM_CYAN/SWEEP_CONFIRM_GOLD directly
# (defined above under story_sweep) -- confirmed live pixel-identical
# position and color, since it's the same shared dialog component both
# 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
# 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
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):
"""OCR a small dark-navy digit rendered on lesson.py's pink/magenta
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
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.
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 os
@ -206,7 +254,7 @@ def _is_ap_purchase_prompt(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):
@ -258,7 +306,7 @@ def _row_number_rect(config, row_y):
def _scan_stage_rows_once(driver, config, stage):
saw_any_valid_row = False
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}'")
if label is not None:
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 |
| 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 |
| 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 |
| 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 |
@ -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.
#### 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
### OCR