fix(perception): reconcile points/pixels scale and stale overlay screenshot
Tests / Test tests.test_device_config.test_device_config_store_settings_get_set_and_defaults failed

Host-agent console showed OCR/UI-tree overlay boxes misaligned with the
displayed screenshot. Two independent causes, both confirmed with real
task data and pixel-level measurement of a user-provided screenshot:

1. perception/ui_parser.py parses XCUITest UI-tree bounds as iOS logical
   points, while scene_builder.py's Scene.width/height (via infer_png_size)
   and OCR bounds are in screenshot pixels, never reconciled (2.0x on
   Retina devices). build_scene() now detects the scale from the first
   x==0,y==0 UI element and rescales OCR bounds down to points-space,
   reporting Scene.width/height in points too. No-op for Android, where
   UiAutomator2 bounds already match pixels 1:1. This also fixes tap()
   landing at the wrong location for OCR-matched text, and lets the IOU
   fusion between UI-tree and OCR elements actually fire on iOS.

2. runtime/task.py captured `scene` (OCR/UI-tree data) before the LLM
   planning call, but re-captured `before_screenshot` for each step
   afterward - a real time gap during which on-screen content (e.g. a
   keyboard) could shift, producing a directional drift between the
   overlay and the displayed image. The first step of each plan batch
   now reuses the screenshot already taken for planning instead of
   capturing a new one; later steps in a multi-step batch still take a
   fresh capture (left unresolved, scoped out by request).

Regression tests added for both the scale reconciliation (using real
828x1792 vs 414x896 numbers) and the screenshot reuse behavior.
This commit is contained in:
2026-07-15 16:12:53 +08:00
parent c50ce1faec
commit 7f439f0db5
4 changed files with 200 additions and 6 deletions
+9 -2
View File
@@ -134,11 +134,18 @@ class TaskRunner:
):
return self._complete_task(task)
for step in steps:
for step_index, step in enumerate(steps):
if should_stop is not None and should_stop():
return self._interrupt_task(task)
executable_step = self._step_for_device(step, task.device_id)
before_screenshot = self._planning_screenshot(task.device_id)
if step_index == 0 and screenshot is not None:
# Reuse the screenshot already captured for planning instead of
# taking a new one, so the "before action" image shown alongside
# `scene`'s OCR/UI-tree overlay matches what was actually planned
# against (avoids drift from the LLM planning round-trip).
before_screenshot = screenshot
else:
before_screenshot = self._planning_screenshot(task.device_id)
result = self.executor.execute(
executable_step,
context=context,