Skip to content

Commit bde9291

Browse files
committed
fix: bind identity-armed templates to landmark state
1 parent c08df6f commit bde9291

3 files changed

Lines changed: 156 additions & 32 deletions

File tree

benchmark/rdp_multiapp/run_qualification.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1142,6 +1142,9 @@ def acquire_with_fault() -> bytes:
11421142
"resolution_rung": (
11431143
result.resolution.rung if result.resolution is not None else None
11441144
),
1145+
"resolution_point": (
1146+
list(result.resolution.point) if result.resolution is not None else None
1147+
),
11451148
"error": result.error,
11461149
"delivery_uncertainty": (
11471150
result.delivery_uncertainty.model_dump(

openadapt_flow/runtime/resolver.py

Lines changed: 101 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ def visual_resolution_evaluator_contract_sha256() -> str:
151151
"template_ambiguity_suspicion_score": AMBIGUITY_SUSPICION_SCORE,
152152
"template_scales": list(DEFAULT_TEMPLATE_SCALES),
153153
"ambiguity": "unique-or-independent-retained-evidence",
154+
"identity_armed_template_landmark_binding": "corroborate-and-snap",
154155
}
155156
return hashlib.sha256(
156157
json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
@@ -544,6 +545,47 @@ def _select_geometry_estimates(
544545
return (px, py), confidence
545546

546547

548+
def _landmark_target_evidence(
549+
anchor: Anchor,
550+
screen_png: bytes,
551+
vision: Any,
552+
) -> tuple[Optional[tuple[Point, float]], bool]:
553+
"""Return the target point established by retained landmark relations.
554+
555+
A template for an empty field can match at several horizontal offsets
556+
inside the same control. Moving an independently marked identity region
557+
by that arbitrary template offset creates a false identity mismatch. For
558+
an identity-armed target, use unique retained labels to corroborate the
559+
template and to recover the exact demonstrated action point. Ambiguous
560+
labels remain a typed refusal signal; absence remains an abstention.
561+
"""
562+
563+
estimates: list[Point] = []
564+
confidences: list[float] = []
565+
ambiguous = False
566+
for landmark in anchor.landmarks:
567+
try:
568+
match = _find_landmark_text(vision, screen_png, landmark)
569+
except AmbiguousOcrMatchError:
570+
ambiguous = True
571+
continue
572+
if match is None:
573+
continue
574+
estimates.append(
575+
_estimate_from_landmark(
576+
landmark.relation,
577+
(int(match.point[0]), int(match.point[1])),
578+
landmark.distance_px,
579+
landmark.dx_px,
580+
landmark.dy_px,
581+
)
582+
)
583+
confidences.append(float(match.confidence))
584+
if not estimates:
585+
return None, ambiguous
586+
return _select_geometry_estimates(anchor, estimates, confidences), ambiguous
587+
588+
547589
def resolve(
548590
anchor: Anchor,
549591
screen_png: bytes,
@@ -602,6 +644,44 @@ def resolve(
602644
def elapsed_ms() -> float:
603645
return (time.monotonic() - t0) * 1000.0
604646

647+
landmark_evidence_loaded = False
648+
landmark_evidence: Optional[tuple[Point, float]] = None
649+
ambiguous_landmark = False
650+
651+
def identity_landmark_evidence() -> Optional[tuple[Point, float]]:
652+
"""Load landmark evidence once for this exact observed frame."""
653+
654+
nonlocal landmark_evidence_loaded, landmark_evidence, ambiguous_landmark
655+
if not landmark_evidence_loaded:
656+
landmark_evidence, ambiguous_landmark = _landmark_target_evidence(
657+
anchor, screen_png, vision
658+
)
659+
landmark_evidence_loaded = True
660+
return landmark_evidence
661+
662+
def identity_bound_template_point(
663+
candidate: Point, candidate_region: Region
664+
) -> Optional[Point]:
665+
"""Corroborate and stabilize an identity-armed template candidate."""
666+
667+
if anchor.identifier_region is None or not anchor.landmarks:
668+
return candidate
669+
evidence = identity_landmark_evidence()
670+
if evidence is None:
671+
return candidate
672+
landmark_point, _confidence = evidence
673+
if (
674+
math.hypot(
675+
landmark_point[0] - candidate[0],
676+
landmark_point[1] - candidate[1],
677+
)
678+
> GLOBAL_LANDMARK_TOLERANCE_PX
679+
):
680+
return None
681+
if not _point_in_region(landmark_point, candidate_region):
682+
return None
683+
return landmark_point
684+
605685
# Rung 0: structural (DOM / UIA) — the strongest, deterministic evidence.
606686
# Tried FIRST, and only when a structural-capable backend is injected AND
607687
# the anchor carries a recorded structural locator. On a pixel-only
@@ -649,9 +729,17 @@ def elapsed_ms() -> float:
649729
prefer_near=(anchor.region[0], anchor.region[1]),
650730
)
651731
if match is not None:
732+
bound_point = identity_bound_template_point(
733+
_scaled_click_point(anchor, tuple(match.region)),
734+
tuple(match.region),
735+
)
736+
if bound_point is None:
737+
match = None
738+
if match is not None:
739+
assert bound_point is not None
652740
resolution = Resolution(
653741
rung="template",
654-
point=_scaled_click_point(anchor, tuple(match.region)),
742+
point=bound_point,
655743
confidence=float(match.confidence),
656744
elapsed_ms=elapsed_ms(),
657745
)
@@ -676,7 +764,15 @@ def elapsed_ms() -> float:
676764
prefer_near=(anchor.region[0], anchor.region[1]),
677765
)
678766
if match is not None:
679-
point = _scaled_click_point(anchor, tuple(match.region))
767+
bound_point = identity_bound_template_point(
768+
_scaled_click_point(anchor, tuple(match.region)),
769+
tuple(match.region),
770+
)
771+
if bound_point is None:
772+
match = None
773+
if match is not None:
774+
assert bound_point is not None
775+
point = bound_point
680776
contradicted = _landmarks_contradict(anchor, point, screen_png, vision)
681777
if not contradicted:
682778
resolution = Resolution(
@@ -751,36 +847,9 @@ def elapsed_ms() -> float:
751847
return resolution, tuple(match.region)
752848

753849
# Rung 4: geometry from landmarks.
754-
estimates: list[Point] = []
755-
confidences: list[float] = []
756-
ambiguous_landmark = False
757-
for landmark in anchor.landmarks:
758-
try:
759-
lm_match = _find_landmark_text(vision, screen_png, landmark)
760-
except AmbiguousOcrMatchError:
761-
# An ambiguous landmark contributes no coordinate. Other unique
762-
# landmarks remain independently usable under the existing
763-
# geometry contract, regardless of declaration order.
764-
ambiguous_landmark = True
765-
continue
766-
if lm_match is None:
767-
continue
768-
estimates.append(
769-
_estimate_from_landmark(
770-
landmark.relation,
771-
(int(lm_match.point[0]), int(lm_match.point[1])),
772-
landmark.distance_px,
773-
getattr(landmark, "dx_px", None),
774-
getattr(landmark, "dy_px", None),
775-
)
776-
)
777-
confidences.append(float(lm_match.confidence))
778-
if estimates:
779-
(px, py), geometry_confidence = _select_geometry_estimates(
780-
anchor,
781-
estimates,
782-
confidences,
783-
)
850+
evidence = identity_landmark_evidence()
851+
if evidence is not None:
852+
(px, py), geometry_confidence = evidence
784853
region = _clamp_region_of_size(
785854
(px, py), (anchor.region[2], anchor.region[3]), viewport
786855
)

tests/test_resolver.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,58 @@ def test_template_rung_hit_uses_padded_search_region(screen, anchor):
131131
assert vision.template_calls == [(70, 70, 110, 80)]
132132

133133

134+
def test_identity_armed_template_snaps_to_unique_landmark_point(screen):
135+
"""A flat field match cannot move its separate identity window."""
136+
137+
anchor = _icon_anchor().model_copy(update={"identifier_region": (20, 20, 80, 20)})
138+
vision = FakeVision()
139+
# The template slides inside a repeated blank field. The retained
140+
# label still establishes the demonstrated action point at (110, 105).
141+
vision.template_results = [
142+
Match(point=(125, 110), region=(105, 100, 50, 20), confidence=1.0)
143+
]
144+
vision.text_results = {
145+
"Messages": Match(point=(50, 105), region=(20, 95, 60, 20), confidence=0.98)
146+
}
147+
148+
resolution, matched = resolve(
149+
anchor,
150+
screen,
151+
vision,
152+
template_png=b"tpl",
153+
viewport=VIEWPORT,
154+
)
155+
156+
assert resolution.rung == "template"
157+
assert resolution.point == (110, 105)
158+
assert matched == (105, 100, 50, 20)
159+
160+
161+
def test_identity_armed_template_rejects_landmark_contradiction(screen):
162+
"""A nearby look-alike cannot outrank the qualified field relation."""
163+
164+
anchor = _icon_anchor().model_copy(update={"identifier_region": (20, 20, 80, 20)})
165+
vision = FakeVision()
166+
vision.template_results = [
167+
Match(point=(200, 110), region=(175, 100, 50, 20), confidence=1.0),
168+
None,
169+
]
170+
vision.text_results = {
171+
"Messages": Match(point=(50, 105), region=(20, 95, 60, 20), confidence=0.98)
172+
}
173+
174+
resolution, _matched = resolve(
175+
anchor,
176+
screen,
177+
vision,
178+
template_png=b"tpl",
179+
viewport=VIEWPORT,
180+
)
181+
182+
assert resolution.rung == "geometry"
183+
assert resolution.point == (110, 105)
184+
185+
134186
def test_search_region_clamped_to_viewport(screen):
135187
anchor = Anchor(
136188
template="templates/a.png",

0 commit comments

Comments
 (0)