feat: optimize image handling by adding optional image parameter for screenshot functions

This commit is contained in:
Nik Afiq 2026-08-21 23:53:57 +09:00
parent d266b47e58
commit b10824d978
6 changed files with 53 additions and 15 deletions

View File

@ -126,7 +126,7 @@ CAFE_RANK_UP_DISMISS_RETRIES = 5
CAFE_PAN_DRAG_Y = 600 CAFE_PAN_DRAG_Y = 600
CAFE_PAN_RIGHT_X = 1500 CAFE_PAN_RIGHT_X = 1500
CAFE_PAN_LEFT_X = 400 CAFE_PAN_LEFT_X = 400
CAFE_PAN_DRAG_REPEATS = 3 CAFE_PAN_DRAG_REPEATS = 2
CAFE_PAN_DRAG_DURATION = 0.8 CAFE_PAN_DRAG_DURATION = 0.8
# Cafe student invitation (招待券, module/cafe_reward.py's invite_girl/ # Cafe student invitation (招待券, module/cafe_reward.py's invite_girl/

View File

@ -60,9 +60,13 @@ def _masked_template(template):
return cv2.merge([mask_plane, mask_plane, mask_plane]) return cv2.merge([mask_plane, mask_plane, mask_plane])
def find_cafe_sparkle(): def find_cafe_sparkle(image=None):
"""`image` optionally supplies an already-decoded frame instead of
taking a fresh screenshot -- for cafe.py's per-iteration capture, shared
with that same iteration's navigation.is_header_bar_visible rank-up
check (see cafe.py's _pat_current_view)."""
template_full = cv2.imread(config.CAFE_SPARKLE_TEMPLATE) template_full = cv2.imread(config.CAFE_SPARKLE_TEMPLATE)
img = driver.read_screenshot(SPARKLE_SHOT_PATH) img = image if image is not None else driver.read_screenshot(SPARKLE_SHOT_PATH)
th0, tw0 = template_full.shape[:2] th0, tw0 = template_full.shape[:2]
best = None best = None

View File

@ -133,7 +133,7 @@ def color_at(x, y):
return int(r), int(g), int(b) return int(r), int(g), int(b)
def colors_at(points): def colors_at(points, image=None):
"""Sample multiple (x, y) points from a single screenshot, instead of one """Sample multiple (x, y) points from a single screenshot, instead of one
scrot capture per point -- added for navigation.is_header_bar_visible's scrot capture per point -- added for navigation.is_header_bar_visible's
multi-point header check, which otherwise called color_at() 8 times (8 multi-point header check, which otherwise called color_at() 8 times (8
@ -142,12 +142,17 @@ def colors_at(points):
than points sampled sequentially across several hundred ms of separate than points sampled sequentially across several hundred ms of separate
captures, which could straddle a screen transition. captures, which could straddle a screen transition.
`image` optionally supplies an already-decoded frame instead of taking a
fresh screenshot -- for callers reading several independent things (e.g.
cafe.py's rank-up header check + sparkle template match) off one shared
capture rather than one apiece.
Returns a list of (r, g, b) tuples in the same order as `points`. Returns a list of (r, g, b) tuples in the same order as `points`.
""" """
image = read_screenshot(PROBE_SHOT_PATH) img = image if image is not None else read_screenshot(PROBE_SHOT_PATH)
colors = [] colors = []
for x, y in points: for x, y in points:
b, g, r = image[y, x] b, g, r = img[y, x]
colors.append((int(r), int(g), int(b))) colors.append((int(r), int(g), int(b)))
return colors return colors

View File

@ -84,12 +84,16 @@ HEADER_ROW_Y = 10
HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700) HEADER_ROW_X_OFFSETS = (300, 500, 700, 900, 1100, 1300, 1500, 1700)
def is_header_bar_visible(driver): def is_header_bar_visible(driver, image=None):
# One screenshot for all 8 points (driver.colors_at) rather than 8 # One screenshot for all 8 points (driver.colors_at) rather than 8
# separate color_at() calls -- cheaper, and atomic (every point comes # separate color_at() calls -- cheaper, and atomic (every point comes
# from the same frame instead of drifting across ~8 sequential captures). # from the same frame instead of drifting across ~8 sequential captures).
#
# `image` optionally supplies an already-decoded frame (e.g. cafe.py's
# per-iteration capture, shared with that same iteration's
# find_cafe_sparkle call) instead of taking a fresh screenshot here.
points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS] points = [(x, HEADER_ROW_Y) for x in HEADER_ROW_X_OFFSETS]
for r, g, b in driver.colors_at(points): for r, g, b in driver.colors_at(points, image=image):
if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL): if not (r > SUBSCREEN_HEADER_MIN_CHANNEL and g > SUBSCREEN_HEADER_MIN_CHANNEL and b > SUBSCREEN_HEADER_MIN_CHANNEL):
return False return False
return True return True

View File

@ -126,7 +126,7 @@ def _enter_room(driver, coords):
return False return False
def _dismiss_rank_up_if_shown(driver, config): def _dismiss_rank_up_if_shown(driver, config, image=None):
# The reference's own to_cafe() navigation (module/cafe_reward.py) treats # The reference's own to_cafe() navigation (module/cafe_reward.py) treats
# 'relationship_rank_up' as a recognized, reactively-dismissed popup # 'relationship_rank_up' as a recognized, reactively-dismissed popup
# after every pat round -- this loop's original port had no equivalent, # after every pat round -- this loop's original port had no equivalent,
@ -147,9 +147,14 @@ def _dismiss_rank_up_if_shown(driver, config):
# spread-out header-row x positions to all read bright -- much less # spread-out header-row x positions to all read bright -- much less
# likely to coincidentally match a full-screen character composition than # likely to coincidentally match a full-screen character composition than
# a single point. See navigation.py's own comment for the full reasoning. # a single point. See navigation.py's own comment for the full reasoning.
# `image`, if given, is used only for the very first check -- everything
# after that point may have pressed Enter (real state change), so every
# later check must re-capture rather than keep reusing a now-stale frame.
first_check_image = image
for _ in range(config.CAFE_RANK_UP_DISMISS_RETRIES): for _ in range(config.CAFE_RANK_UP_DISMISS_RETRIES):
if navigation.is_header_bar_visible(driver): if navigation.is_header_bar_visible(driver, image=first_check_image):
return True return True
first_check_image = None
driver.keypress("Return") driver.keypress("Return")
driver.wait(1.5) driver.wait(1.5)
return navigation.is_header_bar_visible(driver) return navigation.is_header_bar_visible(driver)
@ -179,12 +184,22 @@ def _pat_current_view(driver, config):
# iteration, not just right after a pat, so a delayed appearance is # iteration, not just right after a pat, so a delayed appearance is
# caught (and dismissed, via _dismiss_rank_up_if_shown's own Enter-press # caught (and dismissed, via _dismiss_rank_up_if_shown's own Enter-press
# loop) on the next iteration, roughly a second later, instead of never. # loop) on the next iteration, roughly a second later, instead of never.
#
# One capture per iteration shared between the rank-up check and the
# sparkle match (previously two separate scrot calls back-to-back with
# nothing in between): the header-bar check only samples fixed chrome
# pixels unrelated to the room scene, and the sparkle match already
# reports "wherever it is in this one frame" regardless of whether that
# frame is shared -- the room animating (a student walking around)
# doesn't change that, since neither read depends on the other lagging
# or leading it. See plan.md's "Performance improvement plan".
patted = 0 patted = 0
for _ in range(config.CAFE_MAX_CLICKS_PER_ROOM): for _ in range(config.CAFE_MAX_CLICKS_PER_ROOM):
if not _dismiss_rank_up_if_shown(driver, config): image = detector.capture_screen()
if not _dismiss_rank_up_if_shown(driver, config, image=image):
print("[cafe] warning: cafe screen not confirmed (rank-up cutscene stuck?) -- stopping this view's pat loop rather than clicking blindly") print("[cafe] warning: cafe screen not confirmed (rank-up cutscene stuck?) -- stopping this view's pat loop rather than clicking blindly")
break break
match = detector.find_cafe_sparkle() match = detector.find_cafe_sparkle(image=image)
if match is None: if match is None:
driver.wait(1) driver.wait(1)
continue continue
@ -195,6 +210,8 @@ def _pat_current_view(driver, config):
# park the cursor away from the sparkle area so it can't occlude the # park the cursor away from the sparkle area so it can't occlude the
# next detection screenshot (see screenshots/cafe/sparkle/02_*_cursor_on_head.png) # next detection screenshot (see screenshots/cafe/sparkle/02_*_cursor_on_head.png)
driver.move_mouse(10, 1190) driver.move_mouse(10, 1190)
# No image= here -- the click/Return/mouse-move above are real state
# changes since this iteration's capture, so this check must be fresh.
if not _dismiss_rank_up_if_shown(driver, config): if not _dismiss_rank_up_if_shown(driver, config):
print("[cafe] warning: cafe screen not confirmed after a pat (rank-up cutscene stuck?) -- stopping this view's pat loop rather than clicking blindly") print("[cafe] warning: cafe screen not confirmed after a pat (rank-up cutscene stuck?) -- stopping this view's pat loop rather than clicking blindly")
break break

14
plan.md
View File

@ -78,15 +78,23 @@ Prompted by a request to review real cron run logs (`scratchpad/ba_logs/{daily,q
| daily #1 (03:30) | 15m32s | | | daily #1 (03:30) | 15m32s | |
| daily #2 (04:30) | **27m20s** | full run, 7 lesson tickets spent | | daily #2 (04:30) | **27m20s** | full run, 7 lesson tickets spent |
### Finding 1 (top priority, not yet implemented): `lesson.py` re-screenshots per cell instead of per region ### Finding 1 (implemented 2026-08-14): `lesson.py` re-screenshots per cell instead of per region
In the 27m20s `daily` run, `lesson` alone took 9m50s (36% of the run), and ~7 minutes of that is the "scanning all regions" phase — 12 regions × ~30-38s each — that runs *before any ticket is spent*, just to build the priority queue. In the 27m20s `daily` run, `lesson` alone took 9m50s (36% of the run), and ~7 minutes of that is the "scanning all regions" phase — 12 regions × ~30-38s each — that runs *before any ticket is spent*, just to build the priority queue.
Root cause, confirmed by reading the code: `_scan_open_grid_cells` (`ba_auto/tasks/lesson.py:196`) loops 3 rows × 3 cols × 3 slots = 27 cells per region, and `_read_slot_affection` (`lesson.py:185`) calls both `detector.region_contains_color` (checkmark check) and, if not done, `detector.read_int_on_heart_badge` (OCR). Both independently call `driver.read_screenshot()`, and `read_screenshot()` fires a **brand-new `scrot` capture every call** (`ba_auto/driver.py:97-127`) — there's no reuse of a prior frame. That means a single region scan can trigger up to ~50 fresh `scrot` processes against a screen that hasn't changed at all between them (no clicks happen mid-scan, per `_scan_all_regions`'s own docstring: "a pure read, spends no tickets"). Root cause, confirmed by reading the code: `_scan_open_grid_cells` (`ba_auto/tasks/lesson.py:196`) loops 3 rows × 3 cols × 3 slots = 27 cells per region, and `_read_slot_affection` (`lesson.py:185`) calls both `detector.region_contains_color` (checkmark check) and, if not done, `detector.read_int_on_heart_badge` (OCR). Both independently call `driver.read_screenshot()`, and `read_screenshot()` fires a **brand-new `scrot` capture every call** (`ba_auto/driver.py:97-127`) — there's no reuse of a prior frame. That means a single region scan can trigger up to ~50 fresh `scrot` processes against a screen that hasn't changed at all between them (no clicks happen mid-scan, per `_scan_all_regions`'s own docstring: "a pure read, spends no tickets").
**Proposed fix:** take one screenshot per region (or even once for the whole scan pass, if nothing changes on-screen until a click happens), decode it once, and pass the decoded image into the per-cell checkmark/OCR reads instead of each one re-capturing. This is a pure efficiency fix — the detection logic itself (color masking thresholds, heart-badge OCR contamination handling) doesn't change at all, so it doesn't touch anything CLAUDE.md's OCR policy cares about. Expected to cut the scan phase from ~7 minutes to well under a minute. **Fix:** `detector.py` gained an optional `image=` parameter on `region_contains_color`/`read_int_on_heart_badge` (default `None`, so every other caller — `shop_utils.py`, `story_sweep.py`, `story_sweep_hard.py`, `cafe.py` — is unaffected) plus a `capture_screen()` helper. `lesson.py`'s `_scan_open_grid_cells` now takes one screenshot per region and threads it through all 27 checkmark/OCR reads instead of each one re-capturing. Pure efficiency fix — the detection logic itself (color masking thresholds, heart-badge OCR contamination handling) is untouched.
**Scope note:** `_run_queue`'s actual ticket-spending clicks (`lesson.py:306`) happen between scans, so only the *scan* phase's screenshots are provably safe to batch this way — anything read after a click still needs a fresh capture. **Scope respected:** `_run_queue`'s actual ticket-spending clicks (`lesson.py:306`+) still take a fresh screenshot per check, exactly as before — batching only applies to the pure-read scan phase.
**Live-verified 2026-08-21** against a full week of real `daily` cron runs (2026-08-15 through 2026-08-21, 14 runs, all exit 0, zero `[lesson]` warnings/errors). Full 12-region scan phase now consistently takes ~3m27s, down from the ~7m2s baseline — a ~51% cut, exactly from removing the redundant per-cell `scrot` calls (per-region time dropped from ~30-38s to ~17-18s).
### Finding 1b (implemented 2026-08-14): stop scanning once triples alone cover the ticket count
Added alongside Finding 1: `_scan_all_regions` now takes `tickets` and tracks a running triple count, breaking out of the region loop once `triple_count >= tickets`. This is provably behavior-preserving, not just a heuristic: `_build_priority_queue` never reorders within a tier (triples/doubles stay in scan order) and `_run_queue` stops the instant tickets hit 0, so once enough triples are found, any cell in an unscanned region could only ever land after the ticket budget is already exhausted — `_run_queue` would never reach it either way. The queue actually executed is identical to a full scan's; only the number of regions looked at changes. When tickets exceed available triples, the condition never fires and behavior is unchanged (full scan, as today). One cosmetic side effect: the "N triple(s), M double(s), K single(s)" summary log line under-reports M/K when this fires, since those regions were never examined — expected, not a bug.
**Live-verified 2026-08-21**, same week of runs: fired correctly on 2 of 7 ticket-available days (2026-08-18: 7 triples found by region 9/12, skipped the remaining 3; 2026-08-21: 7 triples by region 10/12, skipped 2), and correctly stayed a full 12-region scan on the other 5 days where triples never reached the ticket count. Combined with Finding 1, total `lesson` task duration (start to `Done.`) ranged 5m6s-6m41s across the week, down from the 9m50s baseline.
### Finding 2 (secondary, informational — do not touch without care): `event_sweep` is called 2-4x per run by design ### Finding 2 (secondary, informational — do not touch without care): `event_sweep` is called 2-4x per run by design