1414import logging
1515import math
1616import re
17+ from collections import Counter
1718from datetime import date , datetime
1819from pathlib import Path
1920from typing import TYPE_CHECKING , Iterable , Literal , Optional , Sequence , cast
@@ -983,6 +984,83 @@ def _field_label_from_ocr(
983984 return best [2 ] if best is not None else None
984985
985986
987+ def _exact_left_field_label_landmark (
988+ lines : list [OcrLine ],
989+ target_region : Region ,
990+ click : Point ,
991+ * ,
992+ exclude_texts : tuple [str , ...] = (),
993+ reference_date : Optional [date ] = None ,
994+ ) -> Optional [tuple [str , Landmark ]]:
995+ """Return one unique, row-aligned label left of an opaque field.
996+
997+ An open native select can repeat its current option in the closed field,
998+ popup, and suggestion list. The option text is then not target identity.
999+ Pixel-only recordings need an independent relation that survives the open
1000+ menu. This function mines only a label whose normalized OCR text occurs
1001+ exactly once in the recorded frame, whose box is left of and vertically
1002+ aligned with the qualified field region, and whose text is stable and
1003+ passes the configured exclusions, volatility classification, and identifier
1004+ heuristic. It records an exact normalized OCR match and the exact
1005+ label-center-to-click offset. There is no fuzzy fallback in this contract.
1006+ """
1007+
1008+ rx , ry , rw , rh = (int (value ) for value in target_region )
1009+ if rw <= 0 or rh <= 0 :
1010+ return None
1011+ recognized = [line for line in lines if normalize_text (line .text )]
1012+ counts = Counter (normalize_text (line .text ) for line in recognized )
1013+ candidates : list [tuple [float , float , str , Landmark ]] = []
1014+ for line in recognized :
1015+ if line .confidence < MIN_OCR_CONFIDENCE :
1016+ continue
1017+ text = " " .join (line .text .split ())
1018+ normalized = normalize_text (text )
1019+ if counts [normalized ] != 1 or len (text ) > LABEL_OCR_MAX_CHARS :
1020+ continue
1021+ if _contains_excluded (text , exclude_texts ):
1022+ continue
1023+ if volatility .classify_text (text , reference_date = reference_date ):
1024+ continue
1025+ if _text_carries_phi (text ):
1026+ continue
1027+ lx , ly , lw , lh = (int (value ) for value in line .region )
1028+ if lw <= 0 or lh <= 0 or lx + lw > rx :
1029+ continue
1030+ if not ly <= click [1 ] <= ly + lh :
1031+ continue
1032+ gap = float (rx - (lx + lw ))
1033+ if gap > LABEL_OCR_MAX_LEFT_GAP_PX :
1034+ continue
1035+ center = (lx + lw // 2 , ly + lh // 2 )
1036+ dx = int (click [0 ] - center [0 ])
1037+ dy = int (click [1 ] - center [1 ])
1038+ if dx <= 0 :
1039+ continue
1040+ candidates .append (
1041+ (
1042+ gap ,
1043+ abs (float (dy )),
1044+ text ,
1045+ Landmark (
1046+ relation = "left_of" ,
1047+ ocr_text = text ,
1048+ distance_px = int (round (math .hypot (dx , dy ))),
1049+ match_mode = "exact" ,
1050+ dx_px = dx ,
1051+ dy_px = dy ,
1052+ ),
1053+ )
1054+ )
1055+ if not candidates :
1056+ return None
1057+ _gap , _vertical_delta , text , landmark = min (
1058+ candidates ,
1059+ key = lambda candidate : (candidate [1 ], candidate [0 ], candidate [2 ].casefold ()),
1060+ )
1061+ return text , landmark
1062+
1063+
9861064def _identity_unarmed_reason (
9871065 frame_lines : list [OcrLine ],
9881066 * ,
@@ -1944,6 +2022,35 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
19442022 key_after , demonstrated , selection_region
19452023 )
19462024 ):
2025+ selected_anchor = previous_anchor .model_copy (deep = True )
2026+ selected_field_label = step .field_label
2027+ exact_label = _exact_left_field_label_landmark (
2028+ cached_lines (
2029+ int (type_event ["i" ]),
2030+ "before" ,
2031+ type_before ,
2032+ ),
2033+ selected_anchor .region ,
2034+ selected_anchor .click_point ,
2035+ exclude_texts = exclude_texts ,
2036+ reference_date = reference_date ,
2037+ )
2038+ if exact_label is not None :
2039+ selected_field_label , label_landmark = exact_label
2040+ retained_landmarks = [
2041+ landmark
2042+ for landmark in selected_anchor .landmarks
2043+ if normalize_text (landmark .ocr_text )
2044+ != normalize_text (label_landmark .ocr_text )
2045+ ]
2046+ selected_anchor = selected_anchor .model_copy (
2047+ update = {
2048+ "landmarks" : [
2049+ label_landmark ,
2050+ * retained_landmarks ,
2051+ ]
2052+ }
2053+ )
19472054 merged_event = dict (type_event )
19482055 for name , value in key_event .items ():
19492056 if name .endswith ("_after" ):
@@ -1958,7 +2065,8 @@ def cached_lines(i: int, suffix: str, png: bytes) -> list[OcrLine]:
19582065 ),
19592066 "selection_commit_key" : commit ,
19602067 "selection_region" : selection_region ,
1961- "anchor" : previous_anchor .model_copy (deep = True ),
2068+ "anchor" : selected_anchor ,
2069+ "field_label" : selected_field_label ,
19622070 "identity_armed" : previous .identity_armed ,
19632071 "identity_unarmed_reason" : (
19642072 previous .identity_unarmed_reason
0 commit comments