screenshot-vision-algorithm 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. screenshot_vision_algorithm/__init__.py +48 -0
  2. screenshot_vision_algorithm/_config.py +61 -0
  3. screenshot_vision_algorithm/android/__init__.py +1 -0
  4. screenshot_vision_algorithm/android/wechat/__init__.py +1 -0
  5. screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
  6. screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +209 -0
  7. screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +275 -0
  8. screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +1000 -0
  9. screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +267 -0
  10. screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +290 -0
  11. screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +2163 -0
  12. screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +143 -0
  13. screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
  14. screenshot_vision_algorithm/android/wechat/merge/multipage.py +157 -0
  15. screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
  16. screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +434 -0
  17. screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +232 -0
  18. screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +1888 -0
  19. screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py +625 -0
  20. screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
  21. screenshot_vision_algorithm/android/wechat/profiles/android.py +53 -0
  22. screenshot_vision_algorithm/android/wechat/profiles/harmony.py +10 -0
  23. screenshot_vision_algorithm/android/wechat/profiles/ios.py +53 -0
  24. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_back_chevron.png +0 -0
  25. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_emoji_smile.png +0 -0
  26. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_plus.png +0 -0
  27. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_voice.png +0 -0
  28. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_title_more_dots.png +0 -0
  29. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/favorite_label.png +0 -0
  30. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/new_messages_hint_suffix.png +0 -0
  31. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint.png +0 -0
  32. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint_v2_textonly.png +0 -0
  33. screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_note_header.png +0 -0
  34. screenshot_vision_algorithm/android/xhs/__init__.py +4 -0
  35. screenshot_vision_algorithm/android/zhihu/__init__.py +4 -0
  36. screenshot_vision_algorithm/png_utils.py +86 -0
  37. screenshot_vision_algorithm-0.3.0.dist-info/METADATA +425 -0
  38. screenshot_vision_algorithm-0.3.0.dist-info/RECORD +40 -0
  39. screenshot_vision_algorithm-0.3.0.dist-info/WHEEL +5 -0
  40. screenshot_vision_algorithm-0.3.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2163 @@
1
+ """Template-matching primitives for the WeChat OCR collector.
2
+
3
+ Pure cv2 + numpy + stdlib. Shared between:
4
+ - live collector runtime (``scripts/wx_match/collector/driver.py``)
5
+ - cross-device regression tool
6
+ (``scripts/wx_match/tools/verify_templates.py``)
7
+
8
+ so that live capture behaviour is bit-identical to what regression tests
9
+ validate on the 3-device matrix.
10
+
11
+ Design aligned with:
12
+ DD section 2.4.x C / C2 (new_messages_hint + unread_divider_hint)
13
+ DD section 2.7.1 P=B+ single-baseline + scale_w strategy
14
+ DD section 2.7.2 applicable bounds
15
+ chat_profiles/README.md v0.2.2 threshold table
16
+ OCR ADR section 2.7.1 integrity gates
17
+
18
+ Threshold choices:
19
+ CORE_THRESHOLD = 0.80 (favorite_label / new_messages_hint_suffix /
20
+ wechat_note_header; hard gate, FAIL below)
21
+ BONUS_THRESHOLD = 0.80 (unread_divider_hint happy path)
22
+ BONUS_MIN_WARN = 0.70 (unread_divider_hint WARN-only floor; triggers
23
+ the CC1 degrade path — post-tap sleep + one
24
+ extra divider recheck — rather than failing
25
+ the whole capture, see DD section 2.4.x C2)
26
+
27
+ These constants pin the N=3 cross-device calibration from
28
+ ``docs/adr/evidence/wx_match_thin_slice_v1/baseline_devices.md``; do NOT
29
+ change them without refreshing that evidence.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import os
35
+ from dataclasses import dataclass
36
+ from functools import lru_cache
37
+ from pathlib import Path
38
+ from typing import Literal, Optional, Sequence
39
+
40
+ import cv2
41
+ import numpy as np
42
+
43
+ # ---- Template asset resolution ----------------------------------------------
44
+
45
+ _PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent.parent
46
+ _DEFAULT_TEMPLATES_SUBDIR: str = "android/wechat/templates/android/8.0.69"
47
+ _templates_dir_override: Path | None = None
48
+
49
+
50
+ def set_templates_dir(dir_path: Path) -> None:
51
+ """Override the template asset directory (e.g. for iOS / Harmony profiles)."""
52
+ global _templates_dir_override
53
+ _templates_dir_override = dir_path
54
+
55
+
56
+ def _templates_dir() -> Path:
57
+ if _templates_dir_override is not None:
58
+ return _templates_dir_override
59
+ return _PACKAGE_ROOT / _DEFAULT_TEMPLATES_SUBDIR
60
+
61
+
62
+ TEMPLATES_DIR: Path = _templates_dir()
63
+
64
+ # edb1a89f is the baseline device (1080x2248). scale_w = device_width / 1080.
65
+ BASELINE_WIDTH = 1080
66
+
67
+ # ---- Template registry ------------------------------------------------------
68
+
69
+ TEMPLATE_FILE: dict[str, str] = {
70
+ "favorite_label": "favorite_label.png",
71
+ "new_messages_hint_suffix": "new_messages_hint_suffix.png",
72
+ "unread_divider_hint": "unread_divider_hint.png",
73
+ "wechat_note_header": "wechat_note_header.png",
74
+ "chat_back_chevron": "chat_back_chevron.png",
75
+ "chat_title_more_dots": "chat_title_more_dots.png",
76
+ "chat_input_voice": "chat_input_voice.png",
77
+ "chat_input_emoji_smile": "chat_input_emoji_smile.png",
78
+ "chat_input_plus": "chat_input_plus.png",
79
+ }
80
+
81
+ TEMPLATE_ROLE: dict[str, Literal["P0-core", "bonus"]] = {
82
+ "favorite_label": "P0-core",
83
+ "new_messages_hint_suffix": "P0-core",
84
+ "wechat_note_header": "P0-core",
85
+ "chat_back_chevron": "P0-core",
86
+ "chat_title_more_dots": "P0-core",
87
+ "chat_input_voice": "P0-core",
88
+ "chat_input_emoji_smile": "P0-core",
89
+ "chat_input_plus": "P0-core",
90
+ "unread_divider_hint": "bonus",
91
+ }
92
+
93
+ CORE_THRESHOLD = 0.70
94
+ BONUS_THRESHOLD = 0.80
95
+ BONUS_MIN_WARN = 0.60 # was 0.70; lowered to keep < CORE_THRESHOLD (now 0.70)
96
+
97
+
98
+ DEFAULT_NMS_DIST = 50 # pixel distance for non-max suppression of dense hits
99
+
100
+
101
+ # ---- Value types ------------------------------------------------------------
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class Hit:
106
+ """One template match location in raw-image pixel space.
107
+
108
+ ``(x, y)`` is the TOP-LEFT corner of the matched patch, so the center is
109
+ ``(x + w // 2, y + h // 2)`` — use :meth:`center` for the tap target.
110
+ """
111
+
112
+ x: int
113
+ y: int
114
+ w: int
115
+ h: int
116
+ score: float
117
+
118
+ @property
119
+ def center(self) -> tuple[int, int]:
120
+ return (self.x + self.w // 2, self.y + self.h // 2)
121
+
122
+ @property
123
+ def bbox(self) -> tuple[int, int, int, int]:
124
+ """Inclusive-exclusive bbox ``(x1, y1, x2, y2)``."""
125
+ return (self.x, self.y, self.x + self.w, self.y + self.h)
126
+
127
+
128
+ # ---- Primitives -------------------------------------------------------------
129
+
130
+
131
+ @lru_cache(maxsize=len(TEMPLATE_FILE) + 2)
132
+ def load_template(name: str) -> np.ndarray:
133
+ """Load a baseline template (8.0.69) in BGR; cached per process.
134
+
135
+ Raises KeyError for unknown names — keep the dict the source of truth.
136
+ """
137
+ if name not in TEMPLATE_FILE:
138
+ raise KeyError(
139
+ f"unknown template {name!r}; known templates: "
140
+ f"{sorted(TEMPLATE_FILE)}"
141
+ )
142
+ path = TEMPLATES_DIR / TEMPLATE_FILE[name]
143
+ img = cv2.imread(str(path))
144
+ if img is None:
145
+ raise FileNotFoundError(
146
+ f"template file not found or unreadable: {path!s}"
147
+ )
148
+ return img
149
+
150
+
151
+ def scale_template(template: np.ndarray, scale_w: float) -> np.ndarray:
152
+ """Resize template proportionally by ``scale_w`` (``device_width / 1080``).
153
+
154
+ Returns the original template when ``scale_w`` is within 0.001 of 1.0 so
155
+ the baseline device incurs zero resize cost. INTER_CUBIC keeps the edge
156
+ sharpness cv2.matchTemplate relies on.
157
+ """
158
+ if abs(scale_w - 1.0) < 1e-3:
159
+ return template
160
+ return cv2.resize(
161
+ template, None, fx=scale_w, fy=scale_w,
162
+ interpolation=cv2.INTER_CUBIC,
163
+ )
164
+
165
+
166
+ def match_all(
167
+ screen: np.ndarray,
168
+ template: np.ndarray,
169
+ threshold: float,
170
+ nms_dist: int = DEFAULT_NMS_DIST,
171
+ ) -> list[Hit]:
172
+ """Run ``TM_CCOEFF_NORMED`` + greedy NMS; return all surviving hits.
173
+
174
+ Hits are sorted by Y ascending (top-to-bottom reading order) to match
175
+ the convention the verification tool prints and the driver consumes.
176
+ """
177
+ if screen.shape[0] < template.shape[0] or screen.shape[1] < template.shape[1]:
178
+ # Template larger than screen (pathological scale_w) — no hits.
179
+ return []
180
+
181
+ result = cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED)
182
+ ys, xs = np.where(result >= threshold)
183
+ candidates = sorted(
184
+ (
185
+ (int(x), int(y), float(result[y, x]))
186
+ for x, y in zip(xs, ys)
187
+ ),
188
+ key=lambda c: -c[2], # descending score → greedy NMS keeps the best
189
+ )
190
+
191
+ th, tw = template.shape[:2]
192
+ kept: list[Hit] = []
193
+ for x, y, s in candidates:
194
+ if any(abs(x - k.x) < nms_dist and abs(y - k.y) < nms_dist for k in kept):
195
+ continue
196
+ kept.append(Hit(x=x, y=y, w=tw, h=th, score=s))
197
+
198
+ kept.sort(key=lambda h: h.y)
199
+ return kept
200
+
201
+
202
+ def match_best(
203
+ screen: np.ndarray,
204
+ template: np.ndarray,
205
+ threshold: float,
206
+ ) -> Optional[Hit]:
207
+ """Return the single highest-score hit at-or-above ``threshold``, or None."""
208
+ hits = match_all(screen, template, threshold)
209
+ if not hits:
210
+ return None
211
+ return max(hits, key=lambda h: h.score)
212
+
213
+
214
+ # ---- Convenience detectors (collector driver entry points) ------------------
215
+
216
+
217
+ def detect_new_messages_hint(
218
+ screen: np.ndarray,
219
+ scale_w: float,
220
+ threshold: float = CORE_THRESHOLD,
221
+ ) -> Optional[Hit]:
222
+ """Detect the "... new messages" floating hint button.
223
+
224
+ Returns the best-scoring hit (tap target = ``Hit.center``) or None if the
225
+ template does not hit at all at ``threshold``. The caller decides what to
226
+ do with None (usually: the user already scrolled past it, skip the step).
227
+ """
228
+ template = scale_template(load_template("new_messages_hint_suffix"), scale_w)
229
+ return match_best(screen, template, threshold)
230
+
231
+
232
+ def detect_unread_divider(
233
+ screen: np.ndarray,
234
+ scale_w: float,
235
+ threshold: float = BONUS_MIN_WARN,
236
+ ) -> Optional[Hit]:
237
+ """Detect the "以下为新消息" divider (bonus template).
238
+
239
+ Threshold defaults to ``BONUS_MIN_WARN`` (0.70) to accept faint divider
240
+ lines on devices like Honor where ``unread_divider_hint`` scored ~0.755
241
+ at baseline calibration. Callers that want a stricter signal should pass
242
+ ``threshold=BONUS_THRESHOLD`` (0.80).
243
+ """
244
+ template = scale_template(load_template("unread_divider_hint"), scale_w)
245
+ return match_best(screen, template, threshold)
246
+
247
+
248
+ def detect_favorite_labels(
249
+ screen: np.ndarray,
250
+ scale_w: float,
251
+ threshold: float = CORE_THRESHOLD,
252
+ ) -> list[Hit]:
253
+ """Find every ``favorite_label.png`` hit on the chat screenshot.
254
+
255
+ One hit per resume-thumbnail card that is currently visible (DD
256
+ section 2.4.x A stage 1). The list is top-to-bottom sorted
257
+ (:func:`match_all` already sorts by y). Empty list means no cards
258
+ visible — caller typically keeps scrolling instead of entering the
259
+ resume sub-flow.
260
+ """
261
+ template = scale_template(load_template("favorite_label"), scale_w)
262
+ return match_all(screen, template, threshold)
263
+
264
+
265
+ def _chat_title_bar_vertical_band(
266
+ screen_h: int, scale_w: float,
267
+ ) -> tuple[int, int]:
268
+ """Y-extent shared by chat/note top chrome (baseline-calibrated strip)."""
269
+ sw = max(scale_w, 1e-6)
270
+ y1 = int(round(108 * sw))
271
+ y2 = int(round(220 * sw))
272
+ y1 = max(0, min(max(2, screen_h) - 2, y1))
273
+ y2 = max(y1 + 40, min(screen_h, y2))
274
+ return y1, y2
275
+
276
+
277
+ def _patch_nav_color_gate(patch_bgr: np.ndarray) -> bool:
278
+ """Light WeChat navigation bar backdrop + observable dark glyphs (BGR).
279
+
280
+ Complements TM_CCOEFF_NORMED: rejects bogus hits on uniformly flat
281
+ patches while allowing mostly-light strips that still bear a faint dark
282
+ chevron / dot trio (thin strokes keep ``dark_frac`` modest).
283
+ """
284
+ if patch_bgr is None or patch_bgr.size < 240:
285
+ return False
286
+ gray = cv2.cvtColor(patch_bgr, cv2.COLOR_BGR2GRAY).astype(np.float32)
287
+ hi = float(np.percentile(gray, 78))
288
+ v_med = float(np.median(gray))
289
+ dark_frac = float((gray < 92).mean())
290
+ mid_dark_frac = float((gray < 125).mean())
291
+ # Reject very dim overlays / non-chrome slabs.
292
+ if v_med < 88 or hi < 115:
293
+ return False
294
+ # Reject uniformly flat patches (no plausible icon ink at all).
295
+ if dark_frac < 0.010 and mid_dark_frac < 0.022:
296
+ return False
297
+ return True
298
+
299
+
300
+ def _expand_hit_gate_patch(screen_bgr: np.ndarray, hit: Hit, scale_w: float) -> bool:
301
+ pad = max(6, int(round(7 * max(scale_w, 1e-6))))
302
+ h, w_full = screen_bgr.shape[:2]
303
+ px1 = max(0, hit.x - pad)
304
+ py1 = max(0, hit.y - pad)
305
+ px2 = min(w_full, hit.x + hit.w + pad)
306
+ py2 = min(h, hit.y + hit.h + pad)
307
+ return _patch_nav_color_gate(screen_bgr[py1:py2, px1:px2])
308
+
309
+
310
+ def _suppress_conversation_nav_templates_for_session_list_chrome(
311
+ screen_bgr: np.ndarray,
312
+ scale_w: float,
313
+ ) -> bool:
314
+ """Do not expose 「<」「⋯」「底栏话筒/笑脸/+」as *chat-shell* cues on微信主会话列表壳.
315
+
316
+ 仅 **会话列表**:与 :func:`is_wechat_main_conversation_list_chrome`(
317
+ ``require_visible_pinned_row=False``)对齐,避免误判成群聊;
318
+ 「左上 「<」+ 右上 ⋯」成对 TM 的假阳性亦不在这层壳上当聊天锚点上送。
319
+
320
+ **笔记页**:不由此函数抑制——``detect_chat_title_more_dots`` 仍单独用
321
+ ``detect_wechat_note_header`` 门控以避免 Honor/宽屏上对 ``wx_note`` 的假阳性。
322
+ """
323
+ if is_wechat_main_conversation_list_chrome(
324
+ screen_bgr,
325
+ scale_w,
326
+ require_visible_pinned_row=False,
327
+ ):
328
+ return True
329
+ return False
330
+
331
+
332
+ def match_chat_back_chevron_core(
333
+ screen_bgr: np.ndarray,
334
+ scale_w: float,
335
+ threshold: float = CORE_THRESHOLD,
336
+ ) -> Optional[Hit]:
337
+ """Internal: 「<」 match + light-nav ink gate (**no**主会话列表壳抑制逻辑)."""
338
+ tpl = scale_template(load_template("chat_back_chevron"), scale_w)
339
+ tw, th = tpl.shape[1], tpl.shape[0]
340
+ h, w = screen_bgr.shape[:2]
341
+ sw = max(scale_w, 1e-6)
342
+ y1, y2 = _chat_title_bar_vertical_band(h, sw)
343
+ roi_h = y2 - y1
344
+ if roi_h < th + 6:
345
+ return None
346
+ x2 = min(w, max(int(round(145 * sw)), tw + int(round(20 * sw))))
347
+ roi_w = x2
348
+ if roi_w < tw + 4:
349
+ return None
350
+ roi = screen_bgr[y1:y2, 0:x2]
351
+ cand = match_best(roi, tpl, threshold)
352
+ if cand is None:
353
+ return None
354
+ abs_hit = Hit(x=cand.x, y=cand.y + y1, w=cand.w, h=cand.h, score=cand.score)
355
+ if not _expand_hit_gate_patch(screen_bgr, abs_hit, sw):
356
+ return None
357
+ return abs_hit
358
+
359
+
360
+ def match_chat_title_more_dots_core(
361
+ screen_bgr: np.ndarray,
362
+ scale_w: float,
363
+ threshold: float = CORE_THRESHOLD,
364
+ ) -> Optional[Hit]:
365
+ """Internal: 「⋯」 match + light-nav ink gate (**no** note_header 抑制逻辑)."""
366
+ tpl = scale_template(load_template("chat_title_more_dots"), scale_w)
367
+ tw, th = tpl.shape[1], tpl.shape[0]
368
+ h, w = screen_bgr.shape[:2]
369
+ sw = max(scale_w, 1e-6)
370
+ y1, y2 = _chat_title_bar_vertical_band(h, sw)
371
+ if y2 - y1 < th + 6:
372
+ return None
373
+ band_left = max(0, w - max(int(round(278 * sw)), tw + int(round(24 * sw))))
374
+ roi = screen_bgr[y1:y2, band_left:w]
375
+ if roi.shape[1] < tw + 6:
376
+ return None
377
+ cand = match_best(roi, tpl, threshold)
378
+ if cand is None:
379
+ return None
380
+ abs_hit = Hit(
381
+ x=cand.x + band_left,
382
+ y=cand.y + y1,
383
+ w=cand.w,
384
+ h=cand.h,
385
+ score=cand.score,
386
+ )
387
+ if not _expand_hit_gate_patch(screen_bgr, abs_hit, sw):
388
+ return None
389
+ return abs_hit
390
+
391
+
392
+ def detect_chat_back_chevron(
393
+ screen_bgr: np.ndarray,
394
+ scale_w: float,
395
+ threshold: float = CORE_THRESHOLD,
396
+ ) -> Optional[Hit]:
397
+ """Top-left 「<」return control on group/single-chat title bar."""
398
+ back = match_chat_back_chevron_core(screen_bgr, scale_w, threshold)
399
+ if _suppress_conversation_nav_templates_for_session_list_chrome(
400
+ screen_bgr, scale_w,
401
+ ):
402
+ return None
403
+ return back
404
+
405
+
406
+ def detect_chat_title_more_dots(
407
+ screen_bgr: np.ndarray,
408
+ scale_w: float,
409
+ threshold: float = CORE_THRESHOLD,
410
+ ) -> Optional[Hit]:
411
+ """Top-right vertical 「⋯」menu control on chat title bars."""
412
+ if detect_wechat_note_header(screen_bgr, scale_w) is not None:
413
+ return None
414
+ more = match_chat_title_more_dots_core(screen_bgr, scale_w, threshold)
415
+ if _suppress_conversation_nav_templates_for_session_list_chrome(
416
+ screen_bgr, scale_w,
417
+ ):
418
+ return None
419
+ return more
420
+
421
+
422
+ def _chat_bottom_input_roi(screen_bgr: np.ndarray, scale_w: float) -> np.ndarray:
423
+ """Bottom / keyboard-adjacent band for 话筒・表情・「+」(含输入框略抬高时的中区)。"""
424
+ h, w = screen_bgr.shape[:2]
425
+ sw = max(scale_w, 1e-6)
426
+ y0 = max(0, h - int(round(320 * sw)))
427
+ return screen_bgr[y0:h, :]
428
+
429
+
430
+ def _detect_chat_input_bar_icon_best(
431
+ screen_bgr: np.ndarray,
432
+ scale_w: float,
433
+ tmpl_key: Literal[
434
+ "chat_input_voice", "chat_input_emoji_smile", "chat_input_plus",
435
+ ],
436
+ threshold: float = CORE_THRESHOLD,
437
+ ) -> Optional[Hit]:
438
+ sw = max(scale_w, 1e-6)
439
+ tpl = scale_template(load_template(tmpl_key), scale_w)
440
+ th, tw = tpl.shape[:2]
441
+ roi = _chat_bottom_input_roi(screen_bgr, sw)
442
+ if roi.shape[0] < th + 3 or roi.shape[1] < tw + 3:
443
+ return None
444
+ h_full = screen_bgr.shape[0]
445
+ y_off = max(0, h_full - int(round(320 * sw)))
446
+ cand = match_best(roi, tpl, threshold)
447
+ if cand is None:
448
+ return None
449
+ return Hit(
450
+ x=int(cand.x),
451
+ y=int(cand.y + y_off),
452
+ w=int(cand.w),
453
+ h=int(cand.h),
454
+ score=float(cand.score),
455
+ )
456
+
457
+
458
+ def detect_chat_input_voice_button(
459
+ screen_bgr: np.ndarray,
460
+ scale_w: float,
461
+ threshold: float = CORE_THRESHOLD,
462
+ ) -> Optional[Hit]:
463
+ """Left-side 「按住说话」麦克风区抠图锚点。"""
464
+ if _suppress_conversation_nav_templates_for_session_list_chrome(
465
+ screen_bgr, scale_w,
466
+ ):
467
+ return None
468
+ return _detect_chat_input_bar_icon_best(
469
+ screen_bgr, scale_w, "chat_input_voice", threshold,
470
+ )
471
+
472
+
473
+ def detect_chat_input_emoji_smile_button(
474
+ screen_bgr: np.ndarray,
475
+ scale_w: float,
476
+ threshold: float = CORE_THRESHOLD,
477
+ ) -> Optional[Hit]:
478
+ """输入栏表情(笑脸)图标抠图锚点。"""
479
+ if _suppress_conversation_nav_templates_for_session_list_chrome(
480
+ screen_bgr, scale_w,
481
+ ):
482
+ return None
483
+ return _detect_chat_input_bar_icon_best(
484
+ screen_bgr, scale_w, "chat_input_emoji_smile", threshold,
485
+ )
486
+
487
+
488
+ def detect_chat_input_plus_button(
489
+ screen_bgr: np.ndarray,
490
+ scale_w: float,
491
+ threshold: float = CORE_THRESHOLD,
492
+ ) -> Optional[Hit]:
493
+ """输入栏右侧「+」(附件)图标抠图锚点。"""
494
+ if _suppress_conversation_nav_templates_for_session_list_chrome(
495
+ screen_bgr, scale_w,
496
+ ):
497
+ return None
498
+ return _detect_chat_input_bar_icon_best(
499
+ screen_bgr, scale_w, "chat_input_plus", threshold,
500
+ )
501
+
502
+
503
+ def detect_wechat_note_header(
504
+ screen: np.ndarray,
505
+ scale_w: float,
506
+ threshold: float = CORE_THRESHOLD,
507
+ ) -> Optional[Hit]:
508
+ """Verify the ``wechat_note_header.png`` (""笔记"" tab marker) is on screen.
509
+
510
+ Two uses in Day 1b:
511
+ - ``tap_thumbnail`` post-tap: confirms the tap actually landed on a
512
+ resume card and WeChat loaded the note-detail page (vs. e.g.
513
+ launching an image viewer).
514
+ - ``detail_content_scroll_down`` per-screen liveness: guards against having
515
+ accidentally bounced back to the chat page.
516
+
517
+ Returns the best hit (or None at threshold miss). Hit position is
518
+ ignored by callers; they only care about hit / no-hit.
519
+ """
520
+ template = scale_template(load_template("wechat_note_header"), scale_w)
521
+ return match_best(screen, template, threshold)
522
+
523
+
524
+ def classify_top_bottom(hit: Hit, screen_h: int) -> Literal["top", "bottom"]:
525
+ """Assign ``click_position`` based on hit center vs. mid-screen.
526
+
527
+ DD section 2.4.x C: the new-messages hint button floats top-right on the
528
+ first unread screen and re-appears bottom-right after the user scrolls
529
+ down; ``click_position`` captures which side we tapped so the processor
530
+ can reconcile ordering.
531
+ """
532
+ cy = hit.y + hit.h // 2
533
+ return "top" if cy < screen_h / 2 else "bottom"
534
+
535
+
536
+ # ---- Day 4 auto-scan detectors (d4-auto-scan) -------------------------------
537
+ #
538
+ # Design decision (2026-04-29): WeChat "pinned group + red unread badge"
539
+ # detection deliberately uses HSV color thresholding instead of a template
540
+ # file. Rationale:
541
+ # (1) Red badges are tiny (~26x26 @ 1080 baseline) and the color signal is
542
+ # the actual discriminator; template matching adds no signal above a
543
+ # well-tuned HSV filter.
544
+ # (2) Pinned-badge geometric marker differs across WeChat sub-versions in
545
+ # subtle ways (light-gray cell background vs. tiny icon). That
546
+ # template would need per-device recalibration; deferred to Day 6
547
+ # (d6-1 真机多轮跑 + trigger-playbook "pinned_badge_calibration").
548
+ # (4) Session list unread counts are read only from the **avatar
549
+ # top-right** ROI per row in ``TopGroupScanDriver.scan_pinned_groups``.
550
+ #
551
+ # HSV red thresholds chosen from empirical review of 3 real-world WeChat
552
+ # unread badges (see docs/adr/evidence/wx_match_thin_slice_v1/): WeChat's
553
+ # badge red sits roughly at H≈0..10 or H≈170..180, S≥120, V≥120. These
554
+ # constants are exposed so calibration tweaks can land without touching
555
+ # callers.
556
+
557
+ RED_HSV_LOW_LO: tuple[int, int, int] = (0, 120, 120)
558
+ RED_HSV_LOW_HI: tuple[int, int, int] = (10, 255, 255)
559
+ RED_HSV_HIGH_LO: tuple[int, int, int] = (170, 120, 120)
560
+ RED_HSV_HIGH_HI: tuple[int, int, int] = (180, 255, 255)
561
+
562
+ #: List **avatar** unread badges (incl. mute ``...`` style): red can look
563
+ #: slightly desaturated; keep a second, looser saturation/value floor for
564
+ #: :func:`detect_unread_dots` when called from session-list scan only.
565
+ LIST_AVATAR_BADGE_SAT_MIN = 88
566
+ # Slightly below previous 82: last-visible-row digit badges can read V≈79–81
567
+ # under warm backlight (edb1a89f 20260507 probe 007) while staying above noise.
568
+ LIST_AVATAR_BADGE_VAL_MIN = 78
569
+ # Hue coverage for the wrap-around “low reds” (wider than generic 0–10).
570
+ LIST_AVATAR_BADGE_LOW_H_MAX = 15
571
+ # Supplemental orange-red band (H≈16–35) OR'd when ``orange_aux=True``.
572
+ # Warm-tinted list badges can sit in 16–26; primary 0–low_h misses them unless
573
+ # merged with stricter S/V (last-row supplement in list scan driver).
574
+ LIST_AVATAR_BADGE_ORANGE_AUX_HUE_LO = 16
575
+ LIST_AVATAR_BADGE_ORANGE_AUX_HUE_HI = 35
576
+ LIST_AVATAR_BADGE_ORANGE_AUX_SAT_MIN = 65
577
+ LIST_AVATAR_BADGE_ORANGE_AUX_VAL_MIN = 60
578
+ # Min area tuned for list badges; white glyphs can split the red mask —
579
+ # :func:`detect_unread_dots` may merge via ``morph_dilate_iters`` in-driver.
580
+ LIST_AVATAR_BADGE_MIN_AREA_BASELINE = 88
581
+
582
+ #: Minimum bounding-box area (in baseline-pixel units, so scale_w invariant)
583
+ #: for a red region to qualify as an unread badge. WeChat badge
584
+ #: pixel-diameter ≈ 26 → area ≈ 530 px² at the baseline 1080x2248; 120 gives
585
+ #: plenty of headroom for sub-pixel anti-alias edges while rejecting most
586
+ #: red emoji / stickers.
587
+ UNREAD_DOT_MIN_AREA_BASELINE = 120
588
+
589
+ #: Maximum bounding-box area at baseline pixel scale. Anything larger is
590
+ #: almost certainly a red sticker / image preview, not a badge.
591
+ UNREAD_DOT_MAX_AREA_BASELINE = 3000
592
+
593
+ #: Maximum aspect ratio (max(w,h)/min(w,h)) — badges are near-circular, so
594
+ #: elongated red runs (e.g. a red progress bar) are filtered out.
595
+ UNREAD_DOT_MAX_ASPECT = 1.8
596
+
597
+ #: Minimum circularity (``4π·area / perimeter²``) — real unread badges are
598
+ #: near-perfect circles / ellipses and measure ``≈ 0.89`` on 49×49 badges
599
+ #: (Day 6 edb1a89f baseline: 0.898 / 0.890 / 0.889 / 0.897 / 0.892). Avatar-
600
+ #: interior red decorations / red text / red stickers are typically
601
+ #: irregular (``< 0.4``). Default ``0.0`` preserves backward compatibility
602
+ #: with callers that don't want the hard geometric gate; session-list
603
+ #: scanners should pass ``UNREAD_DOT_LIST_MIN_CIRCULARITY`` for Day 6
604
+ #: calibration (2026-04-30 real-device regression: row 1 false positive
605
+ #: entered a "no unread" group because avatar-inside red element passed
606
+ #: HSV but not circularity). Probe 00002 edb1a89f: one digit badge measures
607
+ #: ``≈0.54`` after ``morph_open`` — a hard ``0.55`` floor dropped it before
608
+ #: the avatar-anchor gate (still well above avatar-interior clutter ``<0.4``).
609
+ UNREAD_DOT_LIST_MIN_CIRCULARITY = 0.54
610
+
611
+
612
+ @dataclass(frozen=True)
613
+ class UnreadDotHit:
614
+ """One detected red unread badge in raw-image pixel space.
615
+
616
+ Attributes mirror :class:`Hit` (x/y/w/h are the bounding-box corner +
617
+ extent) but we give it its own name because HSV thresholding doesn't
618
+ emit a correlation score — ``area`` stands in as "how bold the badge
619
+ looked" for QA logging.
620
+ """
621
+
622
+ x: int
623
+ y: int
624
+ w: int
625
+ h: int
626
+ area: int
627
+
628
+ @property
629
+ def center(self) -> tuple[int, int]:
630
+ return (self.x + self.w // 2, self.y + self.h // 2)
631
+
632
+
633
+ def detect_unread_dots(
634
+ screen: np.ndarray,
635
+ scale_w: float,
636
+ *,
637
+ roi_bbox: Optional[tuple[int, int, int, int]] = None,
638
+ min_area_baseline: int = UNREAD_DOT_MIN_AREA_BASELINE,
639
+ max_area_baseline: int = UNREAD_DOT_MAX_AREA_BASELINE,
640
+ max_aspect: float = UNREAD_DOT_MAX_ASPECT,
641
+ min_circularity: float = 0.0,
642
+ red_saturation_min: int = 120,
643
+ red_value_min: int = 120,
644
+ low_red_hue_max: int = 10,
645
+ morph_open_iters: int = 0,
646
+ morph_dilate_iters: int = 0,
647
+ orange_aux: bool = False,
648
+ ) -> list[UnreadDotHit]:
649
+ """Find red unread badges anywhere in ``screen`` (or within ``roi_bbox``).
650
+
651
+ Algorithm:
652
+ 1. Convert ROI to HSV.
653
+ 2. OR two red hue bands (``0..10`` + ``170..180``) into a binary mask.
654
+ 3. Morph close (3x3 kernel) to fill anti-alias gaps.
655
+ 4. ``findContours``; filter by area + aspect ratio to reject
656
+ stickers / emoji / progress bars.
657
+
658
+ Args:
659
+ screen: BGR screenshot (raw-pixel space).
660
+ scale_w: ``device_width / 1080``; used to scale min/max area
661
+ thresholds so detection is resolution-invariant.
662
+ roi_bbox: Optional ``(x1, y1, x2, y2)`` to restrict the search
663
+ (e.g., when you already know conversation rows bounds).
664
+ ``None`` scans the whole frame.
665
+ min_area_baseline: Minimum badge area in baseline-1080 px²
666
+ (default ``UNREAD_DOT_MIN_AREA_BASELINE``).
667
+ max_area_baseline: Maximum badge area in baseline-1080 px²
668
+ (default ``UNREAD_DOT_MAX_AREA_BASELINE``).
669
+ max_aspect: Reject elongated shapes (default ``UNREAD_DOT_MAX_ASPECT``).
670
+ min_circularity: Reject non-circular red blobs (avatar-interior red
671
+ decorations / red text / red stickers). ``0.0`` (default) keeps
672
+ old behavior; session-list scanners pass
673
+ :data:`UNREAD_DOT_LIST_MIN_CIRCULARITY` (``0.54``, Day 6 edb1a89f
674
+ regression). Circularity = ``4π·area / perimeter²``.
675
+ red_saturation_min / red_value_min: HSV floors (default 120; list
676
+ avatar scan uses lower values via :data:`LIST_AVATAR_BADGE_SAT_MIN`).
677
+ low_red_hue_max: upper end of hue wrap ``0°`` band (default ``10``).
678
+ morph_open_iters: pre-processing erosion+dilation pass (default
679
+ ``0``). Use ``1`` to break 1-2 px "bridges" between a
680
+ genuine badge and surrounding avatar-interior red decoration
681
+ (Day 6' edb1a89f r10: "A十月天使" avatar's red background
682
+ touched the top-right badge through a thin neck, fusing both
683
+ into one h=55 irregular blob at the ``morph_close`` stage;
684
+ a single open iteration restores them to independent blobs).
685
+ Applied BEFORE ``morph_close`` so the close pass can still
686
+ heal anti-alias nicks inside the now-isolated badge.
687
+ morph_dilate_iters: extra dilation to bridge gaps from white glyphs
688
+ (e.g. ``...``) splitting the red mask (default ``0``).
689
+ orange_aux: when ``True``, OR a supplemental HSV window
690
+ (:data:`LIST_AVATAR_BADGE_ORANGE_AUX_HUE_LO` …
691
+ :data:`LIST_AVATAR_BADGE_ORANGE_AUX_HUE_HI` at
692
+ :data:`LIST_AVATAR_BADGE_ORANGE_AUX_SAT_MIN` /
693
+ :data:`LIST_AVATAR_BADGE_ORANGE_AUX_VAL_MIN`) into the mask before
694
+ morphology. Used only for narrow follow-up ROIs (e.g. last list row)
695
+ so bottom-row warm/orange unread pills still threshold without
696
+ widening the global column scan.
697
+
698
+ Returns:
699
+ Top-to-bottom sorted list of hits. Empty list when no red passes
700
+ the filters (most common on a calm conversation list).
701
+ """
702
+ # Clamp ROI against frame bounds to survive callers that passed
703
+ # coordinates from a baseline-scaled computation.
704
+ h_full, w_full = screen.shape[:2]
705
+ if roi_bbox is None:
706
+ x1, y1, x2, y2 = 0, 0, w_full, h_full
707
+ else:
708
+ x1, y1, x2, y2 = roi_bbox
709
+ x1 = max(0, min(w_full, x1))
710
+ y1 = max(0, min(h_full, y1))
711
+ x2 = max(x1, min(w_full, x2))
712
+ y2 = max(y1, min(h_full, y2))
713
+ if x2 <= x1 or y2 <= y1:
714
+ return []
715
+
716
+ roi = screen[y1:y2, x1:x2]
717
+ hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
718
+ low_lo = np.array((0, red_saturation_min, red_value_min), dtype=np.uint8)
719
+ low_hi = np.array((low_red_hue_max, 255, 255), dtype=np.uint8)
720
+ high_lo = np.array((170, red_saturation_min, red_value_min), dtype=np.uint8)
721
+ high_hi = np.array((180, 255, 255), dtype=np.uint8)
722
+ mask_low = cv2.inRange(hsv, low_lo, low_hi)
723
+ mask_high = cv2.inRange(hsv, high_lo, high_hi)
724
+ mask = cv2.bitwise_or(mask_low, mask_high)
725
+ if orange_aux:
726
+ aux_lo = np.array(
727
+ (
728
+ LIST_AVATAR_BADGE_ORANGE_AUX_HUE_LO,
729
+ LIST_AVATAR_BADGE_ORANGE_AUX_SAT_MIN,
730
+ LIST_AVATAR_BADGE_ORANGE_AUX_VAL_MIN,
731
+ ),
732
+ dtype=np.uint8,
733
+ )
734
+ aux_hi = np.array(
735
+ (LIST_AVATAR_BADGE_ORANGE_AUX_HUE_HI, 255, 255), dtype=np.uint8
736
+ )
737
+ mask = cv2.bitwise_or(mask, cv2.inRange(hsv, aux_lo, aux_hi))
738
+
739
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
740
+ if morph_open_iters > 0:
741
+ # ``open`` (erode-then-dilate) severs 1-2 px bridges between
742
+ # the badge and surrounding red decoration. We skip the usual
743
+ # ``close`` in this mode because close would re-weld the same
744
+ # bridges we just cut — verified empirically on edb1a89f r10
745
+ # "A十月天使": open alone gives bbox 27×28 circ=0.88; adding
746
+ # close after puts it back to 27×70 circ=0.27.
747
+ mask = cv2.morphologyEx(
748
+ mask, cv2.MORPH_OPEN, kernel, iterations=morph_open_iters,
749
+ )
750
+ else:
751
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
752
+ if morph_dilate_iters > 0:
753
+ mask = cv2.dilate(mask, kernel, iterations=morph_dilate_iters)
754
+
755
+ contours, _ = cv2.findContours(
756
+ mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
757
+ )
758
+
759
+ # Scale area thresholds by the actual surface area ratio (scale_w²)
760
+ # so resolution-invariance survives the device matrix we target
761
+ # (see DD §2.7.2 applicable-bounds table).
762
+ area_scale = max(scale_w, 1e-3) ** 2
763
+ min_area = int(min_area_baseline * area_scale)
764
+ max_area = int(max_area_baseline * area_scale)
765
+
766
+ hits: list[UnreadDotHit] = []
767
+ for c in contours:
768
+ rx, ry, rw, rh = cv2.boundingRect(c)
769
+ area = int(cv2.contourArea(c))
770
+ if area < min_area or area > max_area:
771
+ continue
772
+ # Aspect ratio filter — badges are near-circular.
773
+ long_side = max(rw, rh)
774
+ short_side = max(1, min(rw, rh))
775
+ if long_side / short_side > max_aspect:
776
+ continue
777
+ # Circularity filter (Day 6 edb1a89f real-device regression): red
778
+ # avatar-interior elements pass HSV but have irregular contours.
779
+ # Guarded by a user-opt-in threshold so pre-Day-6 callers (whose
780
+ # fixtures don't expect this gate) keep their behavior.
781
+ if min_circularity > 0.0:
782
+ perim = cv2.arcLength(c, closed=True)
783
+ if perim <= 0:
784
+ continue
785
+ circularity = 4.0 * np.pi * area / (perim * perim)
786
+ if circularity < min_circularity:
787
+ continue
788
+ hits.append(
789
+ UnreadDotHit(
790
+ x=rx + x1,
791
+ y=ry + y1,
792
+ w=rw,
793
+ h=rh,
794
+ area=area,
795
+ )
796
+ )
797
+
798
+ hits.sort(key=lambda h: h.y)
799
+ return hits
800
+
801
+
802
+ def dedupe_unread_dot_hits(
803
+ hits: list[UnreadDotHit],
804
+ *,
805
+ min_center_dist: float = 26.0,
806
+ ) -> tuple[UnreadDotHit, ...]:
807
+ """Merge HSV unread hits from overlapping ROIs (avatar vs timestamp strip).
808
+
809
+ When ``scan_pinned_groups`` scans two regions per row, the same blob can
810
+ be picked up twice if ROIs intersect — greedy keep-by-area + center
811
+ distance suppresses duplicates.
812
+ """
813
+ if not hits:
814
+ return ()
815
+ ordered = sorted(hits, key=lambda h: (-h.area, h.y, h.x))
816
+ kept: list[UnreadDotHit] = []
817
+ for h in ordered:
818
+ cx, cy = h.center
819
+ if any(
820
+ abs(cx - k.center[0]) < min_center_dist
821
+ and abs(cy - k.center[1]) < min_center_dist
822
+ for k in kept
823
+ ):
824
+ continue
825
+ kept.append(h)
826
+ kept.sort(key=lambda h: h.y)
827
+ return tuple(kept)
828
+
829
+
830
+ #: Baseline conversation-list row height @ 1080x2248 (measured from WeChat
831
+ #: 8.0.69 on Mi 8 UD). Each row spans one contact/group with avatar + name +
832
+ #: preview + right-side time/badge stack. Used as a heuristic row-split
833
+ #: primitive when the driver has no independent row-boundary anchor (the
834
+ #: ADR §2.7 "图像模板 + 比例坐标" rule — no poco / Accessibility).
835
+ #: Touch SSOT: adb ``input`` only (OCR ADR §6.1.3.1.1) — no sendevent / /dev/input/event*
836
+ #: / USB-HID keyboard-mouse emulation.
837
+ CONVERSATION_ROW_HEIGHT_BASELINE = 178
838
+
839
+ #: Top inset (below the search bar) where the first row begins at baseline.
840
+ #: Status bar + search bar area is ~280 px — anything above that is not a
841
+ #: conversation row and should be skipped.
842
+ #:
843
+ #: NOTE (Day 6 edb1a89f 2026-04-30): this constant is the FALLBACK when
844
+ #: :func:`detect_wechat_main_title_bottom_y` cannot anchor the top nav
845
+ #: bar. In practice the session-list scanner calls the dynamic anchor
846
+ #: first and passes its result as ``first_row_top_baseline`` override,
847
+ #: which makes row split robust to pull-to-refresh bounce states where
848
+ #: the content region is briefly offset from the static chrome.
849
+ CONVERSATION_FIRST_ROW_TOP_BASELINE = 280
850
+
851
+ #: Avatar column ends ~here @ 1080 baseline; nickname + preview middle band;
852
+ # mirrors :meth:`collector.driver.TopGroupScanDriver.scan_pinned_groups`.
853
+ CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE = 220
854
+ CONVERSATION_RIGHT_SIDE_RESERVED_BASELINE = 220
855
+
856
+ #: Horizontal ROI (1080 baseline) for list-side avatar centroid detection — left
857
+ #: strip only; narrower than ``CONVERSATION_AVATAR_RIGHT_EDGE`` to avoid昵称列误检。
858
+ CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE = 24
859
+ CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE = 200
860
+
861
+ #: Hough 头像列:**median ± half_width×scale**(与同列蓝框对齐略放宽;Hough 偶发右偏需 ≥~56)。
862
+ CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE = 56
863
+
864
+ #: 底栏墨色带 **上沿** 距屏幕底偏移(与 :func:`detect_wechat_main_bottom_tab_bar_four_columns` ROI 同源)。
865
+ _WECHAT_MAIN_NAV_TAB_BAND_TOP_OFFSET_FROM_BOTTOM_BASELINE = 152
866
+
867
+ #: 头像 centroid Hough 的竖直 ROI 下边界须早于底栏再上移,剔除「微信」Tab 矢量圆等非会话头像。
868
+ LIST_AVATAR_HOUGH_CLEAR_ABOVE_NAV_BAND_TOP_BASELINE = 16
869
+
870
+ #: Legacy 占位:整块底栏占位(略高于上条 ``152``);与 ``nav-exclusive``cap 取 **min** 以更严者为准。
871
+ CONVERSATION_LIST_AVATAR_HOUGH_ROI_NAV_FALLBACK_GAP_FROM_BOTTOM_BASELINE = 160
872
+
873
+ #: 头像 Hough 圆心(**裁剪坐标 yi**)须 **≤ roi_h − 本值×scale**;与同列 ±56 median 共存时压住 Dock 误导检。
874
+ LIST_AVATAR_HOUGH_MAX_CROP_Y_FROM_ROI_BOTTOM_BASELINE = 102
875
+
876
+ #: ``ymin_gap`` 叠行:**仅当 Δy≤本阈值×scale(且 < ymin_gap)** 才把两圆收成同一头像去重,
877
+ #: 否则会话行距略小于 ``ymin_gap``(如 Δy≈117.5 对 118)应拆成两行。
878
+ LIST_AVATAR_STACK_SAME_ICON_MAX_CENTER_DY_BASELINE = 92
879
+
880
+ #: 会话列表 centroid 行带与屏缘相交后:**中间行**至少保留约此高度×scale,否则该行丢弃。
881
+ #: (原硬编码 ``90×scale_w``。)
882
+ CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_AFTER_CLIP_BASELINE = 90
883
+
884
+ #: **第一行/最后一行**可与列表顶/底各截一半可视面积 ⇒ 阈值 = 上行 ×本比例后再乘 ``scale_w``。
885
+ CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_EDGE_FRAC = 0.5
886
+
887
+ #: 「圆角方块」近似:灰度上做 **形态学梯度** 的核宽(正方形,baseline px @1080)。
888
+ LIST_AVATAR_ROUNDRECT_MORPH_GRAD_KERNEL_BASELINE = 5
889
+ #: OTSU 二值后对边界带 **闭运算** 闭合圆角外轮廓(过大易把行间糊成整块)。
890
+ LIST_AVATAR_ROUNDRECT_CLOSE_KERNEL_BASELINE = 11
891
+ #: bbox 最短边约在 ``[MIN, MAX]×scale`` 视作头像尺度。
892
+ LIST_AVATAR_ROUNDRECT_BBOX_SIDE_MIN_BASELINE = 44
893
+ LIST_AVATAR_ROUNDRECT_BBOX_SIDE_MAX_BASELINE = 152
894
+ LIST_AVATAR_ROUNDRECT_EXTENT_MIN = 0.10
895
+ LIST_AVATAR_ROUNDRECT_EXTENT_MAX = 0.96
896
+ LIST_AVATAR_ROUNDRECT_SOLIDITY_MIN = 0.48
897
+ LIST_AVATAR_ROUNDRECT_MIN_ASPECT = 0.66
898
+
899
+ #: PRD ``product_requirement_document.md`` §14:校准段内相邻行距 **极差**
900
+ #: ``max(gap) − min(gap)`` 上限(等价于所有两两行距之差均 ≤该值)。
901
+ #: Baseline px,按 ``screen_height / LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE`` 缩放。
902
+ LIST_ROW_GEOMETRY_Y12_GAP_SPREAD_MAX_BASELINE = 30
903
+ LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE = 2248
904
+
905
+
906
+ def conversation_list_avatar_hough_roi_y2_exclusive_px(
907
+ screen_h: int,
908
+ scale_w: float,
909
+ ) -> int:
910
+ """会话列表头像 Hough ROI 的 **不包含**的最小 y。
911
+
912
+ ``y`` 区间为 ``[roi_y1, roi_y2)``;返回值取「legacy 占位底」与「底栏上沿再减净空」
913
+ **更靠上(更小)者**,从而在蓝框同款 X 带内不包含底栏「微信」Dock 图标圆。
914
+ """
915
+ sw = max(float(scale_w), 1e-6)
916
+ sh = int(screen_h)
917
+ legacy_exclusive = sh - int(
918
+ round(float(CONVERSATION_LIST_AVATAR_HOUGH_ROI_NAV_FALLBACK_GAP_FROM_BOTTOM_BASELINE) * sw),
919
+ )
920
+ nav_top_exclusive = sh - int(
921
+ round(float(_WECHAT_MAIN_NAV_TAB_BAND_TOP_OFFSET_FROM_BOTTOM_BASELINE) * sw),
922
+ )
923
+ clearance = int(round(float(LIST_AVATAR_HOUGH_CLEAR_ABOVE_NAV_BAND_TOP_BASELINE) * sw))
924
+ cap_from_nav_icons = nav_top_exclusive - clearance
925
+ return min(legacy_exclusive, cap_from_nav_icons)
926
+
927
+
928
+ #: Empirical distance from the bottom of the "微信" title glyph block to
929
+ #: the TOP of the first conversation row's avatar badge area, at
930
+ #: 1080-baseline scale. Calibrated on edb1a89f 8.0.69 across r5~r8
931
+ #: screencaps with varying bounce states:
932
+ #:
933
+ #: - r5/r6/r7/r8 title bottom_y = 176~177 (stable, glyphs anchored to chrome)
934
+ #: - r8 row-0 badge bbox y = 247 (measured by detect_unread_badges tool)
935
+ #: - offset = 247 − 177 = 70 → we take **61** with 9 px headroom so
936
+ #: bounce-up states (content temporarily shifted ~8 px higher than the
937
+ #: bounce-down resting position) still anchor row 0 above its badge.
938
+ #:
939
+ #: Choosing the floor of the observed range is safer than the mean
940
+ #: because it only expands row 0 upward by a few px into harmless
941
+ #: whitespace (search-bar divider region, no red elements), while a
942
+ #: larger offset would cause row 0 to MISS the badge entirely as seen
943
+ #: in the pre-Day-6 value 103 (=280 baseline − 177 title).
944
+ WECHAT_TITLE_TO_FIRST_ROW_OFFSET_BASELINE = 61
945
+
946
+ #: PRD 「list_row_y12_gap_abs_px」:极差 ``max−mingap`` **缩放前的**基线最大值(baseline px)。
947
+ #: 缩放规则:阈值 = 本基数 × ``screen_height / LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE``。
948
+ WX_COLLECTOR_LIST_ROW_Y12_GAP_SPREAD_MAX_BASELINE_ENV = (
949
+ "WX_COLLECTOR_LIST_ROW_Y12_GAP_SPREAD_MAX_BASELINE"
950
+ )
951
+
952
+
953
+ def resolve_list_row_y12_gap_spread_max_baseline_px(
954
+ *,
955
+ baseline_px: Optional[float] = None,
956
+ ) -> float:
957
+ """解析 PRD §4 极差上限基线数值(缩放前)。
958
+
959
+ ``baseline_px`` 显式为非 ``None`` 时直接 ``max(0, float(..))``;否则读环境变量
960
+ :data:`WX_COLLECTOR_LIST_ROW_Y12_GAP_SPREAD_MAX_BASELINE_ENV`;非法/缺省退回
961
+ :data:`LIST_ROW_GEOMETRY_Y12_GAP_SPREAD_MAX_BASELINE`。
962
+ """
963
+ if baseline_px is not None:
964
+ return max(0.0, float(baseline_px))
965
+ raw = os.environ.get(WX_COLLECTOR_LIST_ROW_Y12_GAP_SPREAD_MAX_BASELINE_ENV, "").strip()
966
+ if raw:
967
+ try:
968
+ return max(0.0, float(raw))
969
+ except ValueError:
970
+ pass
971
+ return float(LIST_ROW_GEOMETRY_Y12_GAP_SPREAD_MAX_BASELINE)
972
+
973
+
974
+ AvatarCentroidSplitStatus = Literal[
975
+ "ok",
976
+ "insufficient_avatar_geometry",
977
+ "prd_y12_gap_spread_over_limit",
978
+ "prd_y12_insufficient_centroids",
979
+ "prd_y12_calibration_subset_too_short",
980
+ "prd_y12_no_inter_gap_on_subset",
981
+ ]
982
+
983
+
984
+ def conversation_list_avatar_prd_y12_row_pitch_calibration(
985
+ cy_full_ordered: Sequence[int],
986
+ *,
987
+ scale_w: float,
988
+ screen_h: int,
989
+ list_row_y12_gap_spread_abs_baseline_px: Optional[float] = None,
990
+ screen_height_calibration_baseline_px: Optional[float] = None,
991
+ ) -> tuple[Optional[int], AvatarCentroidSplitStatus, Optional[float], dict[str, object]]:
992
+ """PRD §4:校准段极差门控;``high = mean(gap)``,半行宽 ``high/2``(再夹到 scale 窗)。"""
993
+ sw = max(float(scale_w), 1e-6)
994
+ mids = sorted(int(y) for y in cy_full_ordered)
995
+ n = len(mids)
996
+ spread_bas = resolve_list_row_y12_gap_spread_max_baseline_px(
997
+ baseline_px=list_row_y12_gap_spread_abs_baseline_px,
998
+ )
999
+ cal_bh = float(
1000
+ LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE
1001
+ if screen_height_calibration_baseline_px is None
1002
+ else screen_height_calibration_baseline_px,
1003
+ )
1004
+ thresh_px = spread_bas * float(screen_h) / max(cal_bh, 1e-6)
1005
+
1006
+ diagnostics: dict[str, object] = {
1007
+ "n_detected_avatar_centroids": int(n),
1008
+ "mid_y_ordered": tuple(int(x) for x in mids),
1009
+ "spread_threshold_px": float(thresh_px),
1010
+ }
1011
+
1012
+ if n < 5:
1013
+ diagnostics["prd_fail"] = "insufficient_centroids"
1014
+ return None, "prd_y12_insufficient_centroids", None, diagnostics
1015
+
1016
+ subset = mids[1 : n - 2]
1017
+ diagnostics["prd_calibration_subset_mid_y"] = tuple(int(x) for x in subset)
1018
+ if len(subset) < 2:
1019
+ diagnostics["prd_fail"] = "calibration_subset_too_short"
1020
+ return None, "prd_y12_calibration_subset_too_short", None, diagnostics
1021
+
1022
+ gaps_f = [float(subset[k + 1] - subset[k]) for k in range(len(subset) - 1)]
1023
+ diagnostics["prd_calibration_neighbor_gaps"] = tuple(round(g, 4) for g in gaps_f)
1024
+ if len(gaps_f) < 1:
1025
+ diagnostics["prd_fail"] = "no_inter_gap"
1026
+ return None, "prd_y12_no_inter_gap_on_subset", None, diagnostics
1027
+
1028
+ # 置顶区/折叠群等会在相邻 centroid 间产生「段落级」大间距(真机 edb1a89f
1029
+ # 常见 ~2× 行距);极差门控应基于 **典型行距** 子集,勿把段落缝当行高漂移。
1030
+ sorted_g = sorted(gaps_f)
1031
+ med_gap = sorted_g[len(sorted_g) // 2]
1032
+ if med_gap > 1e-6:
1033
+ lo_typ = med_gap * 0.55
1034
+ hi_typ = med_gap * 1.65
1035
+ gaps_typical = [g for g in gaps_f if lo_typ <= g <= hi_typ]
1036
+ else:
1037
+ gaps_typical = list(gaps_f)
1038
+ if len(gaps_typical) < 2:
1039
+ gaps_typical = list(gaps_f)
1040
+ diagnostics["prd_calibration_gaps_typical"] = tuple(
1041
+ round(g, 4) for g in gaps_typical
1042
+ )
1043
+
1044
+ gmin = float(min(gaps_typical))
1045
+ gmax = float(max(gaps_typical))
1046
+ spread = gmax - gmin
1047
+ diagnostics["prd_gap_spread_px"] = float(spread)
1048
+
1049
+ avg_high = float(sum(gaps_typical) / float(len(gaps_typical)))
1050
+ diagnostics["prd_avg_row_pitch_px"] = float(avg_high)
1051
+
1052
+ if spread > thresh_px + 1e-5:
1053
+ diagnostics["prd_fail"] = "gap_spread_over_limit"
1054
+ return None, "prd_y12_gap_spread_over_limit", float(spread), diagnostics
1055
+
1056
+ half_raw = avg_high / 2.0
1057
+ half_i = int(round(half_raw))
1058
+ min_h = max(48, int(round(70 * sw)))
1059
+ max_h = int(round(118 * sw))
1060
+ half_px = max(min_h, min(max_h, half_i))
1061
+ diagnostics["prd_row_half_px"] = int(half_px)
1062
+ diagnostics["prd_row_pitch_px_mean"] = float(avg_high)
1063
+ return half_px, "ok", None, diagnostics
1064
+
1065
+
1066
+ def estimate_list_row_background_v95(
1067
+ screen_bgr: np.ndarray,
1068
+ group_name_bbox: tuple[int, int, int, int],
1069
+ ) -> float:
1070
+ """Estimate "list cell background lightness" from the nickname column ROI.
1071
+
1072
+ WeChat main list: pinned rows use a light **grey** cell background; normal
1073
+ rows use **white**. Black text lowers raw means; using the **95th
1074
+ percentile of V** (brightness) in HS**V** approximates background without
1075
+ list-side OCR — dark glyphs stay below the percentile on both themes.
1076
+
1077
+ The ROI already excludes the avatar (see ``CONVERSATION_AVATAR_*`` in the
1078
+ driver). We shrink to the horizontal **left slice** (~55%) and vertical
1079
+ middle 50% to avoid subtitle / timestamp bleed from the preview column.
1080
+
1081
+ Returns:
1082
+ A Value in ``[0, 255]`` — lower values suggest pinned grey; white rows
1083
+ are typically **253–255**.
1084
+ """
1085
+ x1, y1, x2, y2 = group_name_bbox
1086
+ h_full, w_full = screen_bgr.shape[:2]
1087
+ x1 = max(0, min(w_full - 1, x1))
1088
+ x2 = max(x1 + 1, min(w_full, x2))
1089
+ y1 = max(0, min(h_full - 1, y1))
1090
+ y2 = max(y1 + 1, min(h_full, y2))
1091
+ w = x2 - x1
1092
+ h = y2 - y1
1093
+ if w < 12 or h < 12:
1094
+ return 255.0
1095
+
1096
+ # Focus on nickname band (avoid top/bottom text baselines extremes).
1097
+ ix1 = x1 + max(4, int(w * 0.02))
1098
+ ix2 = x1 + int(w * 0.55)
1099
+ iy1 = y1 + int(h * 0.22)
1100
+ iy2 = y1 + int(h * 0.78)
1101
+ ix2 = max(ix1 + 4, ix2)
1102
+ iy2 = max(iy1 + 4, iy2)
1103
+
1104
+ roi = screen_bgr[iy1:iy2, ix1:ix2]
1105
+ if roi.size == 0:
1106
+ return 255.0
1107
+
1108
+ hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
1109
+ flat_v = hsv[:, :, 2].reshape(-1).astype(np.float32)
1110
+ return float(np.percentile(flat_v, 95))
1111
+
1112
+
1113
+ #: If ``estimate_list_row_background_v95(...)`` falls **below** this
1114
+ #: threshold (on the 95th percentile of V), the row is classified as pinned
1115
+ #: (grey list cell). Tune with real devices if ROM themes shift; daylight
1116
+ #: pinned rows observed ~236–246 vs white rows ~253–255.
1117
+ PINNED_BACKGROUND_V95_MAX = 251
1118
+
1119
+
1120
+ def list_row_is_pinned_grey_background(
1121
+ screen_bgr: np.ndarray,
1122
+ group_name_bbox: tuple[int, int, int, int],
1123
+ *,
1124
+ pinned_v95_max: float = PINNED_BACKGROUND_V95_MAX,
1125
+ ) -> bool:
1126
+ """Whether the conversation row **looks like** WeChat pinned (grey cell).
1127
+
1128
+ This is heuristic — dark mode / custom skins may drift; pair with unread
1129
+ HSV badges and post-enter :class:`PrePositionedChatDriver` checks."""
1130
+ v95 = estimate_list_row_background_v95(screen_bgr, group_name_bbox)
1131
+ return bool(v95 < pinned_v95_max)
1132
+
1133
+
1134
+ def detect_wechat_main_title_bottom_y(
1135
+ screen_bgr: np.ndarray, scale_w: float,
1136
+ ) -> Optional[int]:
1137
+ """Locate the bottom y (raw-pixel space) of the "微信" title on the
1138
+ WeChat main conversation list.
1139
+
1140
+ Strategy (no PaddleOCR, per rules.md §12 collector dependency
1141
+ isolation): in the top nav band ``y ∈ [100, 220] × scale_w`` and
1142
+ horizontal mid-band ``x ∈ [center ± 180 × scale_w]``, find the densest
1143
+ horizontal strip of dark (V<80) pixels. The ``微信`` glyph and its
1144
+ ``(N)`` unread-count sibling form a bold, compact dark cluster; tab
1145
+ icons / status-bar icons either fall outside the mid-band (they hug
1146
+ the screen edges) or produce fewer dark pixels per row.
1147
+
1148
+ Returns:
1149
+ Raw y of the title block's bottom edge, suitable for
1150
+ :func:`split_conversation_rows`'s ``first_row_top_baseline``
1151
+ anchor (add :data:`WECHAT_TITLE_TO_FIRST_ROW_OFFSET_BASELINE`).
1152
+ ``None`` when the title signal is too weak — callers fall back
1153
+ to :data:`CONVERSATION_FIRST_ROW_TOP_BASELINE`.
1154
+ """
1155
+ h, w = screen_bgr.shape[:2]
1156
+ y1 = int(round(100 * scale_w))
1157
+ y2 = int(round(220 * scale_w))
1158
+ if y2 > h:
1159
+ return None
1160
+ band_half_w = int(round(180 * scale_w))
1161
+ cx = w // 2
1162
+ x1 = max(0, cx - band_half_w)
1163
+ x2 = min(w, cx + band_half_w)
1164
+ if x2 - x1 < 40:
1165
+ return None
1166
+
1167
+ band = screen_bgr[y1:y2, x1:x2]
1168
+ gray = cv2.cvtColor(band, cv2.COLOR_BGR2GRAY)
1169
+ _, mask = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
1170
+ # Count dark pixels per row; title rows should have ≥ 8 dark pixels
1171
+ # (two fat glyph columns at least).
1172
+ row_dark = (mask.sum(axis=1) // 255).astype(np.int32)
1173
+ has_title_row = row_dark > 8
1174
+ if not has_title_row.any():
1175
+ return None
1176
+ last_title_row = int(np.where(has_title_row)[0].max())
1177
+ return y1 + last_title_row
1178
+
1179
+
1180
+ #: 底栏 **选中** Tab 的品牌绿(与 ``driver._manual_labeling_step1_hold_to_talk_green_hint``
1181
+ #: 同阈),在 N=4 真机截图(微信/通讯录/发现/我 各选中的一帧)上校准:未选栏占比 ≈0,
1182
+ #: 选中栏约 0.06–0.10。
1183
+ _WECHAT_BOTTOM_TAB_SELECTED_GREEN_HSV_LOW = (36, 55, 45)
1184
+ _WECHAT_BOTTOM_TAB_SELECTED_GREEN_HSV_HIGH = (92, 255, 255)
1185
+
1186
+
1187
+ def _wechat_bottom_nav_band_roi(
1188
+ screen_bgr: np.ndarray, scale_w: float,
1189
+ ) -> Optional[np.ndarray]:
1190
+ """Bottom nav band shared by tab-bar heuristics (152×scale_w strip)."""
1191
+ h, _w = screen_bgr.shape[:2]
1192
+ sw = max(scale_w, 1e-6)
1193
+ y0 = max(0, h - int(round(152 * sw)))
1194
+ y1 = h - int(round(6 * sw))
1195
+ if y1 <= y0 + 8:
1196
+ return None
1197
+ return screen_bgr[y0:y1, :]
1198
+
1199
+
1200
+ def _wechat_bottom_tab_quarter_ink_fracs(roi: np.ndarray) -> Optional[list[float]]:
1201
+ """Per-quarter ink share = max(dark glyph, brand-green selected tab).
1202
+
1203
+ Selected tab icons/labels are brand-green and often **miss** the dark-pixel
1204
+ threshold — without the green channel, ``detect_wechat_main_bottom_tab_bar_four_columns``
1205
+ false-negatives on the main session list (BUG-1 reposition).
1206
+ """
1207
+ _h, w = roi.shape[:2]
1208
+ quarter = w // 4
1209
+ if quarter < 40:
1210
+ return None
1211
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
1212
+ _, dark_mask = cv2.threshold(gray, 105, 255, cv2.THRESH_BINARY_INV)
1213
+ hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
1214
+ green_mask = cv2.inRange(
1215
+ hsv,
1216
+ np.array(_WECHAT_BOTTOM_TAB_SELECTED_GREEN_HSV_LOW, dtype=np.uint8),
1217
+ np.array(_WECHAT_BOTTOM_TAB_SELECTED_GREEN_HSV_HIGH, dtype=np.uint8),
1218
+ )
1219
+ fracs: list[float] = []
1220
+ for qi in range(4):
1221
+ x0c = qi * quarter
1222
+ x1c = (qi + 1) * quarter if qi < 3 else w
1223
+ dark_col = dark_mask[:, x0c:x1c]
1224
+ green_col = green_mask[:, x0c:x1c]
1225
+ dark_frac = float(cv2.countNonZero(dark_col)) / float(dark_col.size)
1226
+ green_frac = float(cv2.countNonZero(green_col)) / float(green_col.size)
1227
+ fracs.append(max(dark_frac, green_frac))
1228
+ return fracs
1229
+
1230
+
1231
+ def detect_wechat_main_bottom_tab_bar_four_columns(
1232
+ screen_bgr: np.ndarray, scale_w: float,
1233
+ ) -> bool:
1234
+ """Heuristic: 主界面底栏「微信 / 通讯录 / 发现 / 我」四等分上均有笔墨。
1235
+
1236
+ manual_labeling / 列表扫描:与 :func:`detect_wechat_main_title_bottom_y`
1237
+ 联用可区分「主会话列表」与「仅顶部像标题的群聊」等误判。
1238
+
1239
+ Strategy (no OCR): bottom nav band, per-quarter ink = max(dark glyph,
1240
+ brand-green selected tab); require every quarter to have a minimum ink
1241
+ share and roughly comparable density (tabs are four siblings).
1242
+
1243
+ Dark mode / 极端主题可能漂移 —— 与顶栏启发式同级,需真机矩阵复核。
1244
+ """
1245
+ roi = _wechat_bottom_nav_band_roi(screen_bgr, scale_w)
1246
+ if roi is None:
1247
+ return False
1248
+ fracs = _wechat_bottom_tab_quarter_ink_fracs(roi)
1249
+ if fracs is None:
1250
+ return False
1251
+ fmin, fmax = min(fracs), max(fracs)
1252
+ if fmin < 0.0018 or fmax > 0.42:
1253
+ return False
1254
+ if fmax <= 1e-9:
1255
+ return False
1256
+ # Weaker tab may be 「我」单层笔画;最强一般为「通讯录」—— 拒绝单点极强、其余近空
1257
+ if fmin < fmax * 0.20:
1258
+ return False
1259
+ return True
1260
+
1261
+
1262
+ def detect_wechat_bottom_tab_selected_green_index(
1263
+ screen_bgr: np.ndarray,
1264
+ scale_w: float,
1265
+ *,
1266
+ min_winner_frac: float = 0.032,
1267
+ min_lead_ratio: float = 1.5,
1268
+ ambiguous_runner_frac: float = 0.014,
1269
+ ) -> Optional[int]:
1270
+ """Infer which bottom nav tab is **selected** (brand-green icon+label).
1271
+
1272
+ WeChat paints the active tab in #07C160-class green; unselected tabs stay
1273
+ neutral grey outlines. 底栏几何与
1274
+ :func:`detect_wechat_main_bottom_tab_bar_four_columns` 一致:整宽四等分。
1275
+
1276
+ Returns:
1277
+ ``0`` 微信, ``1`` 通讯录, ``2`` 发现, ``3`` 我;无法明确判定时 ``None``
1278
+ (例如暗色主题、强压缩截图、或无明显绿区)——调用方应降级为其它特征。
1279
+
1280
+ Calibration: four full-screen captures with each tabselected; at
1281
+ ``1080``-wide baseline the winning quadrant green-pixel share was
1282
+ ~0.058–0.10 and all others ~0.0 with the HSV band above.
1283
+ """
1284
+ roi = _wechat_bottom_nav_band_roi(screen_bgr, scale_w)
1285
+ if roi is None:
1286
+ return None
1287
+ _h, w = roi.shape[:2]
1288
+ hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
1289
+ mask = cv2.inRange(
1290
+ hsv,
1291
+ np.array(_WECHAT_BOTTOM_TAB_SELECTED_GREEN_HSV_LOW, dtype=np.uint8),
1292
+ np.array(_WECHAT_BOTTOM_TAB_SELECTED_GREEN_HSV_HIGH, dtype=np.uint8),
1293
+ )
1294
+ quarter = w // 4
1295
+ if quarter < 40:
1296
+ return None
1297
+ fracs: list[float] = []
1298
+ for qi in range(4):
1299
+ x0c = qi * quarter
1300
+ x1c = (qi + 1) * quarter if qi < 3 else w
1301
+ col = mask[:, x0c:x1c]
1302
+ fracs.append(float(cv2.countNonZero(col)) / float(col.size))
1303
+ winner_i = int(np.argmax(fracs))
1304
+ winner = fracs[winner_i]
1305
+ if winner < float(min_winner_frac):
1306
+ return None
1307
+ others = sorted((fracs[j] for j in range(4) if j != winner_i), reverse=True)
1308
+ runner = float(others[0]) if others else 0.0
1309
+ if (
1310
+ runner > float(ambiguous_runner_frac)
1311
+ and winner < runner * float(min_lead_ratio)
1312
+ ):
1313
+ return None
1314
+ return winner_i
1315
+
1316
+
1317
+ def conversation_list_visible_pinned_row_count(
1318
+ screen_bgr: np.ndarray,
1319
+ scale_w: float,
1320
+ *,
1321
+ title_bottom_y_raw_px: Optional[int],
1322
+ pinned_v95_max: float = PINNED_BACKGROUND_V95_MAX,
1323
+ max_rows_scan: int = 14,
1324
+ ) -> int:
1325
+ """当前屏会话列表中带 **置顶灰底** 会话行的可见数量估算。
1326
+
1327
+ ROI 与同文件 :func:`list_row_is_pinned_grey_background`、群扫
1328
+ :meth:`collector.driver.TopGroupScanDriver.scan_pinned_groups`
1329
+ —— 仅用昵称列背景的 V95 分位区分灰底置顶 vs 白底普行,
1330
+ **不读 OCR**。行带划分与 Driver 对齐:能用 ``微信`` 标题底边锚时
1331
+ 优先,否则退回 :data:`CONVERSATION_FIRST_ROW_TOP_BASELINE`。
1332
+
1333
+ Args:
1334
+ title_bottom_y_raw_px: :func:`detect_wechat_main_title_bottom_y`
1335
+ 的像素 ``y``;``None`` 时走兜底基线。
1336
+ """
1337
+ h, w_full = screen_bgr.shape[:2]
1338
+ sw = max(scale_w, 1e-6)
1339
+ avatar_right_edge = int(round(CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE * sw))
1340
+ right_reserved = int(round(CONVERSATION_RIGHT_SIDE_RESERVED_BASELINE * sw))
1341
+
1342
+ if title_bottom_y_raw_px is not None and detect_wechat_main_bottom_tab_bar_four_columns(
1343
+ screen_bgr, scale_w,
1344
+ ):
1345
+ first_row_top_bt = int(round(
1346
+ title_bottom_y_raw_px / sw
1347
+ + WECHAT_TITLE_TO_FIRST_ROW_OFFSET_BASELINE,
1348
+ ))
1349
+ row_rects, _split_mode, _gap = conversation_list_row_rects(
1350
+ screen_bgr,
1351
+ scale_w,
1352
+ screen_h=h,
1353
+ list_first_row_top_baseline=first_row_top_bt,
1354
+ strict_pitch_calibration_reject=False,
1355
+ )
1356
+ else:
1357
+ row_rects, _split_mode, _gap = conversation_list_row_rects(
1358
+ screen_bgr,
1359
+ scale_w,
1360
+ screen_h=h,
1361
+ list_first_row_top_baseline=CONVERSATION_FIRST_ROW_TOP_BASELINE,
1362
+ strict_pitch_calibration_reject=False,
1363
+ )
1364
+
1365
+ n_pinned = 0
1366
+ for y1, y2 in row_rects[: max(1, max_rows_scan)]:
1367
+ name_bbox = (
1368
+ min(w_full, avatar_right_edge),
1369
+ y1,
1370
+ max(0, w_full - right_reserved),
1371
+ y2,
1372
+ )
1373
+ if list_row_is_pinned_grey_background(
1374
+ screen_bgr, name_bbox, pinned_v95_max=pinned_v95_max,
1375
+ ):
1376
+ n_pinned += 1
1377
+ return n_pinned
1378
+
1379
+
1380
+ def is_wechat_main_conversation_list_chrome(
1381
+ screen_bgr: np.ndarray,
1382
+ scale_w: float,
1383
+ *,
1384
+ require_visible_pinned_row: bool = True,
1385
+ pinned_v95_max: Optional[float] = None,
1386
+ ) -> bool:
1387
+ """主界面 **微信 Tab 下的会话列表**(含本条业务约定时能识别出的置顶灰行)。
1388
+
1389
+ 顶区标题带 + 底栏四 Tab 墨色均衡;若可读到底栏 **品牌绿选中态**,
1390
+ 则必须为第 1 栏(微信);绿区不可信(``None``)时不凭绿否决。
1391
+
1392
+ ``require_visible_pinned_row=True``(默认)时还要求
1393
+ :func:`conversation_list_visible_pinned_row_count` 返回值 ``>= 1``。
1394
+ 采集 Runner 一般由
1395
+ ``collector.driver.session_list_chrome_require_visible_pinned_row()`` 读出
1396
+ **``WX_COLLECTOR_SESSION_LIST_REQUIRE_PINNED_ROW``**(缺省等价开启)传入本参数;
1397
+ 设 ``0|false|no|off`` 可跳过灰置顶行约束(无置顶检测设备 / 暗色主题调参)。
1398
+ """
1399
+ tb = detect_wechat_main_title_bottom_y(screen_bgr, scale_w)
1400
+ if tb is None:
1401
+ return False
1402
+ if not detect_wechat_main_bottom_tab_bar_four_columns(screen_bgr, scale_w):
1403
+ return False
1404
+ sel = detect_wechat_bottom_tab_selected_green_index(screen_bgr, scale_w)
1405
+ if sel is not None and sel != 0:
1406
+ return False
1407
+ pv = (
1408
+ PINNED_BACKGROUND_V95_MAX
1409
+ if pinned_v95_max is None
1410
+ else float(pinned_v95_max)
1411
+ )
1412
+ if require_visible_pinned_row:
1413
+ n_pin = conversation_list_visible_pinned_row_count(
1414
+ screen_bgr,
1415
+ scale_w,
1416
+ title_bottom_y_raw_px=tb,
1417
+ pinned_v95_max=pv,
1418
+ )
1419
+ if n_pin < 1:
1420
+ return False
1421
+ return True
1422
+
1423
+
1424
+ def reposition_anchor_bottom_wechat_tap_lane_plausible(
1425
+ screen_bgr: np.ndarray,
1426
+ scale_w: float,
1427
+ *,
1428
+ tab_x: int,
1429
+ tab_y: int,
1430
+ ) -> bool:
1431
+ """与 ``driver._session_list_tab_coords`` 即将点击的 **同一点** 邻域,判断底栏是否像「可点的微信条」。
1432
+
1433
+ 归位链 **从不** 对底栏做字形/OCR 识别,仅用几何坐标点 ``(tab_x, tab_y)``;
1434
+ 早停与此对齐:在 ``tab_x±~90×scale_w``、``tab_y`` 上方约 ``44×scale_w`` 至下方
1435
+ ``24×scale_w`` 的矩形内看灰度方差 + Canny 边密度(主列表四 Tab 与「最近」单胶囊
1436
+ 左沿均在相近横坐标落点)。
1437
+ """
1438
+ h, w = screen_bgr.shape[:2]
1439
+ sw = max(scale_w, 1e-6)
1440
+ half_w = int(round(90 * sw))
1441
+ x1 = max(0, int(tab_x) - half_w)
1442
+ x2 = min(w, int(tab_x) + half_w)
1443
+ y1 = max(0, int(tab_y) - int(round(44 * sw)))
1444
+ y2 = min(h, int(tab_y) + int(round(24 * sw)))
1445
+ if x2 <= x1 + 8 or y2 <= y1 + 4:
1446
+ return False
1447
+ roi = screen_bgr[y1:y2, x1:x2]
1448
+ gray = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
1449
+ if float(np.std(gray)) < 4.5:
1450
+ return False
1451
+ edges = cv2.Canny(gray, 50, 150)
1452
+ ef = float(cv2.countNonZero(edges)) / float(edges.size)
1453
+ if ef < 0.012 or ef > 0.55:
1454
+ return False
1455
+ return True
1456
+
1457
+
1458
+ def _longest_true_run(mask: np.ndarray) -> int:
1459
+ """最大连续 True 块的行数。"""
1460
+ if not mask.any():
1461
+ return 0
1462
+ padded = np.concatenate(([False], mask, [False]))
1463
+ edges = np.where(padded[:-1] != padded[1:])[0]
1464
+ if len(edges) == 0:
1465
+ return 0
1466
+ runs = edges[1::2] - edges[::2]
1467
+ return int(runs.max())
1468
+
1469
+
1470
+ def _recent_pull_top_heading_likely(
1471
+ screen_bgr: np.ndarray,
1472
+ scale_w: float,
1473
+ ) -> bool:
1474
+ """顶区「最近」类标题:亮底深字 **或** 暗底浅字,竖向窄中带。"""
1475
+ h, w = screen_bgr.shape[:2]
1476
+ sw = max(scale_w, 1e-6)
1477
+ y1 = int(round(222 * sw))
1478
+ y2 = int(round(540 * sw))
1479
+ y2 = min(y2, int(0.36 * h))
1480
+ if y2 <= y1 + 24 or y1 >= h:
1481
+ return False
1482
+ cx = w // 2
1483
+ band_half_w = int(round(140 * sw))
1484
+ x1 = max(0, cx - band_half_w)
1485
+ x2 = min(w, cx + band_half_w)
1486
+ if x2 - x1 < 36:
1487
+ return False
1488
+
1489
+ band = screen_bgr[y1:y2, x1:x2]
1490
+ gray = cv2.cvtColor(band, cv2.COLOR_BGR2GRAY)
1491
+ area = float(max(band.shape[0] * band.shape[1], 1))
1492
+
1493
+ _, mask_dark = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
1494
+ ink_d = float(cv2.countNonZero(mask_dark)) / area
1495
+ if 0.014 <= ink_d <= 0.48:
1496
+ row_dark = (mask_dark.sum(axis=1) // 255).astype(np.int32)
1497
+ has_row = row_dark > 9
1498
+ if has_row.any():
1499
+ # 最大连续块跨度:低分辨率下散点不会拉长 span(2026-06-13)
1500
+ block_span = _longest_true_run(has_row)
1501
+ if block_span <= int(round(70 * sw)):
1502
+ return True
1503
+
1504
+ light = (gray > 96).astype(np.uint8)
1505
+ ink_l = float(np.sum(light)) / area
1506
+ if 0.006 <= ink_l <= 0.32:
1507
+ row_lit = light.sum(axis=1).astype(np.int32)
1508
+ has_row = row_lit > 5
1509
+ if has_row.any():
1510
+ block_span = _longest_true_run(has_row)
1511
+ if block_span <= int(round(72 * sw)):
1512
+ return True
1513
+ return False
1514
+
1515
+
1516
+ def is_wechat_recent_pull_anchor_early_stop_frame(
1517
+ screen_bgr: np.ndarray,
1518
+ scale_w: float,
1519
+ *,
1520
+ tab_x: int,
1521
+ tab_y: int,
1522
+ ) -> bool:
1523
+ """§步骤1(2) 早停:顶区「最近」类标题 **且** 与点击同标的底栏邻域 ``(tab_x,tab_y)`` 可点条带。
1524
+
1525
+ 底栏侧与归位 ``adb.tap(tab_x, tab_y)`` 共用 :func:`reposition_anchor_bottom_wechat_tap_lane_plausible`,
1526
+ **不**再单独用四 Tab / 胶囊中区视检。"""
1527
+ if not reposition_anchor_bottom_wechat_tap_lane_plausible(
1528
+ screen_bgr, scale_w, tab_x=tab_x, tab_y=tab_y,
1529
+ ):
1530
+ return False
1531
+ return _recent_pull_top_heading_likely(screen_bgr, scale_w)
1532
+
1533
+
1534
+ def split_conversation_rows(
1535
+ screen_h: int,
1536
+ scale_w: float,
1537
+ *,
1538
+ row_height_baseline: int = CONVERSATION_ROW_HEIGHT_BASELINE,
1539
+ first_row_top_baseline: int = CONVERSATION_FIRST_ROW_TOP_BASELINE,
1540
+ bottom_nav_height_baseline: int = 160,
1541
+ ) -> list[tuple[int, int]]:
1542
+ """Heuristic row splitter for the WeChat main conversation list.
1543
+
1544
+ Returns a list of ``(y_top, y_bottom)`` tuples covering the expected
1545
+ conversation rows between the search bar and the bottom nav (weixin /
1546
+ 通讯录 / 发现 / 我 tab strip). Deliberately coarse — subpixel accuracy
1547
+ is not needed because badges are detected by HSV in the avatar ROI; the
1548
+ row boundary is only used to attribute "which row carries an unread
1549
+ badge" for ordering.
1550
+
1551
+ Args:
1552
+ screen_h: raw screen height in pixels.
1553
+ scale_w: ``device_width / 1080``.
1554
+ row_height_baseline: per-row height at baseline (default 178px).
1555
+ first_row_top_baseline: y-coordinate where row 0 starts at baseline
1556
+ (default 280px).
1557
+ bottom_nav_height_baseline: reserved pixels below the last row for
1558
+ WeChat's bottom tab bar (default 160px).
1559
+ """
1560
+ row_height = int(round(row_height_baseline * scale_w))
1561
+ first_top = int(round(first_row_top_baseline * scale_w))
1562
+ nav_reserve = int(round(bottom_nav_height_baseline * scale_w))
1563
+
1564
+ if row_height <= 0:
1565
+ return []
1566
+
1567
+ rows: list[tuple[int, int]] = []
1568
+ y = first_top
1569
+ visible_bottom = screen_h - nav_reserve
1570
+ while y + row_height <= visible_bottom:
1571
+ rows.append((y, y + row_height))
1572
+ y += row_height
1573
+ return rows
1574
+
1575
+
1576
+ def split_conversation_rows_by_avatar_centroids(
1577
+ screen_bgr: np.ndarray,
1578
+ scale_w: float,
1579
+ *,
1580
+ screen_h: int,
1581
+ list_first_row_top_px: int,
1582
+ min_rows: int = 4,
1583
+ half_extend_baseline: Optional[int] = None,
1584
+ list_row_y12_gap_spread_abs_baseline_px: Optional[float] = None,
1585
+ list_row_screen_height_calibration_baseline_px: Optional[float] = None,
1586
+ ) -> tuple[
1587
+ Optional[list[tuple[int, int]]],
1588
+ AvatarCentroidSplitStatus,
1589
+ Optional[float],
1590
+ ]:
1591
+ """Derive list row bands from left-column avatar centroids.
1592
+
1593
+ After Hough detection + x-median + y-NMS we obtain a **top-to-bottom** list
1594
+ of avatar centre ``cy`` values. Row **半高**遵循 PRD
1595
+ ``product_requirement_document.md`` §4:取第 **2~(n−2)** 个头像 ``mid_y`` 的
1596
+ 相邻间距,极差 ``max(gap)−min(gap)`` 不得超过
1597
+ :func:`resolve_list_row_y12_gap_spread_max_baseline_px` 按屏高校准后的阈值;
1598
+ 通过后 ``high = mean(gap)``,行带 ``mid_y ± high/2``(再按比例窗钳制)。
1599
+
1600
+ 门控失败返回 ``prd_y12_*``;Hough/sparse/stack 不满足 ``min_rows`` 等仍返回
1601
+ ``insufficient_avatar_geometry``。
1602
+
1603
+ ``half_extend_baseline`` 暂无用途,仅为旧调用兼容保留。
1604
+
1605
+ Other UIs (e.g. in-group member pickers) must only call when the left
1606
+ column matches the same stacked-avatar layout.
1607
+
1608
+ Bottom strip: centroid Hough **never scans** pixels at/under the Dock
1609
+ “微信/通讯录/…” 墨色带上沿(与同文件 ``detect_wechat_main_bottom_tab_bar_four_columns``
1610
+ 的 ``152×scale_w`` 底边锚一致),再上移净空,避免左侧第一个 Tab 矢量圆误判为会话头像。
1611
+ Horizontal ROI remains ``CONVERSATION_LIST_AVATAR_ROI_*``.
1612
+
1613
+ First and last row bands may clip at the **top/bottom list edge** with only
1614
+ about **half the visible strip height** (see
1615
+ ``CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_*``); interior rows stay
1616
+ on the full threshold.
1617
+ """
1618
+
1619
+ sw = max(float(scale_w), 1e-6)
1620
+ h_full, w_full = screen_bgr.shape[:2]
1621
+ slack_top = int(round(22 * sw))
1622
+ roi_y1 = max(0, int(list_first_row_top_px) - slack_top)
1623
+ roi_y2_excl_pt = conversation_list_avatar_hough_roi_y2_exclusive_px(int(screen_h), sw)
1624
+ roi_y2 = max(roi_y1 + 8, roi_y2_excl_pt)
1625
+ x1_roi = max(0, int(round(CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE * sw)))
1626
+ x2_roi = min(w_full, int(round(CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE * sw)))
1627
+ ins = "insufficient_avatar_geometry"
1628
+ if roi_y2 <= roi_y1 + 40 or x2_roi <= x1_roi + 24:
1629
+ return None, ins, None
1630
+
1631
+ crop = screen_bgr[roi_y1:roi_y2, x1_roi:x2_roi]
1632
+ gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
1633
+ gray_blur = cv2.GaussianBlur(gray, (5, 5), 0)
1634
+ circles = cv2.HoughCircles(
1635
+ gray_blur,
1636
+ cv2.HOUGH_GRADIENT,
1637
+ dp=1.25,
1638
+ minDist=int(round(117 * sw)),
1639
+ param1=100,
1640
+ param2=22,
1641
+ minRadius=max(34, int(round(36 * sw))),
1642
+ maxRadius=int(round(80 * sw)),
1643
+ )
1644
+ if circles is None:
1645
+ return None, ins, None
1646
+
1647
+ cand: list[tuple[float, float, float]] = []
1648
+ for xi, yi, ri in circles[0]:
1649
+ cand.append((float(ri), float(xi), float(yi)))
1650
+ if not cand:
1651
+ return None, ins, None
1652
+
1653
+ xs_crop = [c[1] for c in cand]
1654
+ median_x = float(np.median(np.array(xs_crop, dtype=np.float64)))
1655
+ xt = int(round(float(CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE) * sw))
1656
+ filt = [c for c in cand if abs(c[1] - median_x) <= xt]
1657
+
1658
+ roi_h = max(0, int(roi_y2) - int(roi_y1))
1659
+ foot = int(round(float(LIST_AVATAR_HOUGH_MAX_CROP_Y_FROM_ROI_BOTTOM_BASELINE) * sw))
1660
+ if roi_h > foot + 24:
1661
+ filt = [c for c in filt if float(c[2]) <= float(roi_h - foot)]
1662
+
1663
+ if len(filt) < min_rows:
1664
+ return None, ins, None
1665
+
1666
+ filt.sort(key=lambda t: t[2])
1667
+ ymin_gap = round(118 * sw)
1668
+ same_icon_dy_max = float(LIST_AVATAR_STACK_SAME_ICON_MAX_CENTER_DY_BASELINE) * sw
1669
+ stacked: list[tuple[float, float, float]] = []
1670
+
1671
+ for r, xc, yc in filt:
1672
+ if not stacked:
1673
+ stacked.append((r, xc, yc))
1674
+ continue
1675
+ pr, _px, py_prev = stacked[-1]
1676
+ dy = float(yc) - float(py_prev)
1677
+ if dy < ymin_gap:
1678
+ # 极小 Δy:同一头像多圆;居中区间:(same_icon_dy_max, ymin_gap):仍算相邻两行会话。
1679
+ if dy <= same_icon_dy_max:
1680
+ if r > pr:
1681
+ stacked[-1] = (r, xc, yc)
1682
+ else:
1683
+ stacked.append((r, xc, yc))
1684
+ else:
1685
+ stacked.append((r, xc, yc))
1686
+
1687
+ if len(stacked) < min_rows:
1688
+ return None, ins, None
1689
+
1690
+ cy_full_ordered: list[int] = [
1691
+ roi_y1 + int(round(yc)) for _r, _xc, yc in stacked
1692
+ ]
1693
+
1694
+ half_px_cal, prd_st, prd_gap_spread, _prd_diag = (
1695
+ conversation_list_avatar_prd_y12_row_pitch_calibration(
1696
+ cy_full_ordered,
1697
+ scale_w=sw,
1698
+ screen_h=int(screen_h),
1699
+ list_row_y12_gap_spread_abs_baseline_px=list_row_y12_gap_spread_abs_baseline_px,
1700
+ screen_height_calibration_baseline_px=(
1701
+ list_row_screen_height_calibration_baseline_px
1702
+ ),
1703
+ )
1704
+ )
1705
+ if half_px_cal is None:
1706
+ return None, prd_st, prd_gap_spread
1707
+
1708
+ half_px = int(half_px_cal)
1709
+
1710
+ min_mid = int(round(float(CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_AFTER_CLIP_BASELINE) * sw))
1711
+ edge_frac = float(CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_EDGE_FRAC)
1712
+ min_edge = max(1, int(round(float(min_mid) * edge_frac)))
1713
+
1714
+ n_cy = len(cy_full_ordered)
1715
+ out: list[tuple[int, int]] = []
1716
+ for idx, cy_full in enumerate(cy_full_ordered):
1717
+ top = cy_full - half_px
1718
+ bot = cy_full + half_px
1719
+ top = max(0, top)
1720
+ bot = min(int(screen_h), bot)
1721
+ need_h = min_edge if (idx == 0 or idx == n_cy - 1) else min_mid
1722
+ if bot - top < need_h:
1723
+ continue
1724
+ out.append((top, bot))
1725
+
1726
+ if len(out) < min_rows:
1727
+ return None, ins, None
1728
+
1729
+ out.sort(key=lambda z: z[0])
1730
+ return out, "ok", None
1731
+
1732
+
1733
+ def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1734
+ screen_bgr: np.ndarray,
1735
+ scale_w: float,
1736
+ *,
1737
+ screen_h: int,
1738
+ list_first_row_top_px: int,
1739
+ min_rows: int = 4,
1740
+ ) -> Optional[list[int]]:
1741
+ """圆角方块路径:自上而下整屏头像 **质心 y(px)**,不写行带。"""
1742
+ sw = max(float(scale_w), 1e-6)
1743
+ _, w_full = screen_bgr.shape[:2]
1744
+ slack_top = int(round(22 * sw))
1745
+ roi_y1 = max(0, int(list_first_row_top_px) - slack_top)
1746
+ roi_y2_excl_pt = conversation_list_avatar_hough_roi_y2_exclusive_px(int(screen_h), sw)
1747
+ roi_y2 = max(roi_y1 + 8, roi_y2_excl_pt)
1748
+ x1_roi = max(0, int(round(CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE * sw)))
1749
+ x2_roi = min(w_full, int(round(CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE * sw)))
1750
+ if roi_y2 <= roi_y1 + 40 or x2_roi <= x1_roi + 24:
1751
+ return None
1752
+
1753
+ crop_gray = cv2.cvtColor(
1754
+ screen_bgr[roi_y1:roi_y2, x1_roi:x2_roi], cv2.COLOR_BGR2GRAY,
1755
+ )
1756
+ blur = cv2.GaussianBlur(crop_gray, (5, 5), 0)
1757
+ mk_base = float(LIST_AVATAR_ROUNDRECT_MORPH_GRAD_KERNEL_BASELINE)
1758
+ mk = max(3, int(round(mk_base * sw)) | 1)
1759
+ kern_gr = cv2.getStructuringElement(cv2.MORPH_RECT, (mk, mk))
1760
+ grad_u8 = cv2.morphologyEx(blur, cv2.MORPH_GRADIENT, kern_gr)
1761
+ _, bw = cv2.threshold(
1762
+ grad_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU,
1763
+ )
1764
+ ck_base = float(LIST_AVATAR_ROUNDRECT_CLOSE_KERNEL_BASELINE)
1765
+ ck = max(7, int(round(ck_base * sw)) | 1)
1766
+ kern_ck = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (ck, ck))
1767
+ bw = cv2.morphologyEx(bw.astype(np.uint8), cv2.MORPH_CLOSE, kern_ck)
1768
+
1769
+ contours, _hier = cv2.findContours(
1770
+ bw,
1771
+ cv2.RETR_EXTERNAL,
1772
+ cv2.CHAIN_APPROX_SIMPLE,
1773
+ )
1774
+
1775
+ cand: list[tuple[float, float, float]] = []
1776
+ s_min = float(LIST_AVATAR_ROUNDRECT_BBOX_SIDE_MIN_BASELINE) * sw
1777
+ s_max = float(LIST_AVATAR_ROUNDRECT_BBOX_SIDE_MAX_BASELINE) * sw
1778
+ asp_min = float(LIST_AVATAR_ROUNDRECT_MIN_ASPECT)
1779
+ ext_lo = float(LIST_AVATAR_ROUNDRECT_EXTENT_MIN)
1780
+ ext_hi = float(LIST_AVATAR_ROUNDRECT_EXTENT_MAX)
1781
+ sol_min = float(LIST_AVATAR_ROUNDRECT_SOLIDITY_MIN)
1782
+
1783
+ for cnt in contours:
1784
+ _x, _y0, bw_i, bh = cv2.boundingRect(cnt)
1785
+ small = float(min(bw_i, bh))
1786
+ big = float(max(bw_i, bh))
1787
+ if big > s_max * 1.2 or small < s_min * 0.92:
1788
+ continue
1789
+ if small / max(big, 1.0) < asp_min:
1790
+ continue
1791
+ area = float(cv2.contourArea(cnt))
1792
+ hull = cv2.convexHull(cnt)
1793
+ hull_a = float(cv2.contourArea(hull))
1794
+ sol = area / hull_a if hull_a > 1e-6 else 0.0
1795
+ if sol < sol_min:
1796
+ continue
1797
+ bb_a = float(bw_i * bh)
1798
+ if bb_a <= 1e-6:
1799
+ continue
1800
+ extent = area / bb_a
1801
+ if extent < ext_lo or extent > ext_hi:
1802
+ continue
1803
+ M = cv2.moments(cnt)
1804
+ if M["m00"] < 1e-6:
1805
+ continue
1806
+ xc = float(M["m10"] / M["m00"])
1807
+ yc = float(M["m01"] / M["m00"])
1808
+ merit = bb_a * extent * sol
1809
+ cand.append((merit, xc, yc))
1810
+
1811
+ if len(cand) < min_rows:
1812
+ return None
1813
+
1814
+ xs_crop = [c[1] for c in cand]
1815
+ median_x = float(np.median(np.array(xs_crop, dtype=np.float64)))
1816
+ xt = int(round(float(CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE) * sw))
1817
+ filt = [
1818
+ trip for trip in cand
1819
+ if abs(trip[1] - median_x) <= float(xt)
1820
+ ]
1821
+
1822
+ roi_h_calc = max(0, int(roi_y2) - int(roi_y1))
1823
+ foot = int(round(float(LIST_AVATAR_HOUGH_MAX_CROP_Y_FROM_ROI_BOTTOM_BASELINE) * sw))
1824
+ if roi_h_calc > foot + 24:
1825
+ filt = [trip for trip in filt if float(trip[2]) <= float(roi_h_calc - foot)]
1826
+
1827
+ if len(filt) < min_rows:
1828
+ return None
1829
+
1830
+ filt.sort(key=lambda t: t[2])
1831
+
1832
+ ymin_gap = round(118 * sw)
1833
+ same_icon_dy_max = float(LIST_AVATAR_STACK_SAME_ICON_MAX_CENTER_DY_BASELINE) * sw
1834
+ stacked_h: list[tuple[float, float, float]] = []
1835
+
1836
+ for mer, xc, yc in filt:
1837
+ if not stacked_h:
1838
+ stacked_h.append((mer, xc, yc))
1839
+ continue
1840
+ pr_mer, _px, py_prev = stacked_h[-1]
1841
+ dy = float(yc) - float(py_prev)
1842
+ if dy < ymin_gap:
1843
+ if dy <= same_icon_dy_max:
1844
+ if mer > pr_mer:
1845
+ stacked_h[-1] = (mer, xc, yc)
1846
+ else:
1847
+ stacked_h.append((mer, xc, yc))
1848
+ else:
1849
+ stacked_h.append((mer, xc, yc))
1850
+
1851
+ if len(stacked_h) < min_rows:
1852
+ return None
1853
+
1854
+ return [
1855
+ roi_y1 + int(round(yc)) for _me, _xc, yc in stacked_h
1856
+ ]
1857
+
1858
+
1859
+ def prd_wechat_main_list_row_count_by_gap_y12(
1860
+ mid_y_ordered: Sequence[int],
1861
+ *,
1862
+ scale_w: float,
1863
+ screen_h: int,
1864
+ list_row_y12_gap_spread_abs_baseline_px: Optional[float] = None,
1865
+ screen_height_calibration_baseline_px: Optional[float] = None,
1866
+ ) -> tuple[int, str, dict[str, object]]:
1867
+ """PRD §4/§14:与头像行带校准同一套极差门控 ⇒ 通过则总行数=n,否则 0。"""
1868
+ half_px, status, gap_spread, diagnostics = conversation_list_avatar_prd_y12_row_pitch_calibration(
1869
+ mid_y_ordered,
1870
+ scale_w=scale_w,
1871
+ screen_h=int(screen_h),
1872
+ list_row_y12_gap_spread_abs_baseline_px=list_row_y12_gap_spread_abs_baseline_px,
1873
+ screen_height_calibration_baseline_px=screen_height_calibration_baseline_px,
1874
+ )
1875
+ diag_out = dict(diagnostics)
1876
+ if gap_spread is not None:
1877
+ diag_out.setdefault("prd_observed_gap_spread_px", float(gap_spread))
1878
+ if half_px is None:
1879
+ return 0, str(status), diag_out
1880
+ n = int(diagnostics["n_detected_avatar_centroids"])
1881
+ diag_out.setdefault("prd_row_band_half_px_clamped", int(half_px))
1882
+ return n, "ok", diag_out
1883
+
1884
+
1885
+ def prd_wechat_main_list_row_count_gap_y12_from_screen_roundrect(
1886
+ screen_bgr: np.ndarray,
1887
+ scale_w: float,
1888
+ *,
1889
+ screen_h: int,
1890
+ list_first_row_top_px: int,
1891
+ list_row_y12_gap_spread_abs_baseline_px: Optional[float] = None,
1892
+ screen_height_calibration_baseline_px: Optional[float] = None,
1893
+ ) -> tuple[int, str, dict[str, object]]:
1894
+ """端到端 PRD §14:**圆角方块** centroid + §14 gate。"""
1895
+ cy = conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1896
+ screen_bgr,
1897
+ scale_w,
1898
+ screen_h=screen_h,
1899
+ list_first_row_top_px=list_first_row_top_px,
1900
+ )
1901
+ if cy is None:
1902
+ return (
1903
+ 0,
1904
+ "roundrect_centroid_extraction_failed",
1905
+ {
1906
+ "n_detected_avatar_centroids": 0,
1907
+ "mid_y_ordered": tuple(),
1908
+ "spread_threshold_px": float(
1909
+ float(LIST_ROW_GEOMETRY_Y12_GAP_SPREAD_MAX_BASELINE)
1910
+ * float(screen_h)
1911
+ / max(float(LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE), 1e-6),
1912
+ ),
1913
+ },
1914
+ )
1915
+ rn, rst, diagnostics = prd_wechat_main_list_row_count_by_gap_y12(
1916
+ cy,
1917
+ scale_w=scale_w,
1918
+ screen_h=screen_h,
1919
+ list_row_y12_gap_spread_abs_baseline_px=list_row_y12_gap_spread_abs_baseline_px,
1920
+ screen_height_calibration_baseline_px=screen_height_calibration_baseline_px,
1921
+ )
1922
+ merged = dict(diagnostics)
1923
+ merged["raw_roundrect_mid_y"] = tuple(int(x) for x in cy)
1924
+ return rn, rst, merged
1925
+
1926
+
1927
+ def split_conversation_rows_by_avatar_roundrect_centroids(
1928
+ screen_bgr: np.ndarray,
1929
+ scale_w: float,
1930
+ *,
1931
+ screen_h: int,
1932
+ list_first_row_top_px: int,
1933
+ min_rows: int = 4,
1934
+ half_extend_baseline: Optional[int] = None,
1935
+ list_row_y12_gap_spread_abs_baseline_px: Optional[float] = None,
1936
+ list_row_screen_height_calibration_baseline_px: Optional[float] = None,
1937
+ ) -> tuple[
1938
+ Optional[list[tuple[int, int]]],
1939
+ AvatarCentroidSplitStatus,
1940
+ Optional[float],
1941
+ ]:
1942
+ """Derive list row bands by **rounded-square proxies** via morphological gradient contours.
1943
+
1944
+ WeChat list avatars are visually **rounded rects** — this path uses grayscale
1945
+ **MORPH_GRADIENT → OTSU threshold → morphology close**, then selects **near-square**
1946
+ external contours (`solidity`, `extent`, aspect). Centroid ``(xc,yc)`` substitutes
1947
+ for Hough ``(r,xc,yc)`` in the **same median-x / dock foot / vertical stack** ladder
1948
+ as :func:`split_conversation_rows_by_avatar_centroids`, then reuse identical
1949
+ PRD §4 calibration (极差门控 → ``mean(gap)/2`` 半带) on the stacked centroid ``cy`` list.
1950
+
1951
+ Experimental / alternating geometry path; callers may compare counts vs Hough.
1952
+
1953
+ Horizontal ROI stays ``CONVERSATION_LIST_AVATAR_ROI_*``; ROI bottom aligns with Hough via
1954
+ :func:`conversation_list_avatar_hough_roi_y2_exclusive_px`.
1955
+ """
1956
+
1957
+ sw = max(float(scale_w), 1e-6)
1958
+ ins = "insufficient_avatar_geometry"
1959
+ cy_full_ordered = conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1960
+ screen_bgr,
1961
+ scale_w,
1962
+ screen_h=screen_h,
1963
+ list_first_row_top_px=list_first_row_top_px,
1964
+ min_rows=min_rows,
1965
+ )
1966
+ if cy_full_ordered is None:
1967
+ return None, ins, None
1968
+
1969
+ half_px_cal, prd_st, prd_gap_spread, _prd_diag = (
1970
+ conversation_list_avatar_prd_y12_row_pitch_calibration(
1971
+ cy_full_ordered,
1972
+ scale_w=sw,
1973
+ screen_h=int(screen_h),
1974
+ list_row_y12_gap_spread_abs_baseline_px=(
1975
+ list_row_y12_gap_spread_abs_baseline_px
1976
+ ),
1977
+ screen_height_calibration_baseline_px=(
1978
+ list_row_screen_height_calibration_baseline_px
1979
+ ),
1980
+ )
1981
+ )
1982
+ if half_px_cal is None:
1983
+ return None, prd_st, prd_gap_spread
1984
+
1985
+ half_px = int(half_px_cal)
1986
+
1987
+ min_mid = int(round(float(CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_AFTER_CLIP_BASELINE) * sw))
1988
+ edge_frac = float(CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_EDGE_FRAC)
1989
+ min_edge = max(1, int(round(float(min_mid) * edge_frac)))
1990
+
1991
+ n_cy = len(cy_full_ordered)
1992
+ out: list[tuple[int, int]] = []
1993
+ for idx, cy_full in enumerate(cy_full_ordered):
1994
+ top = cy_full - half_px
1995
+ bot = cy_full + half_px
1996
+ top = max(0, top)
1997
+ bot = min(int(screen_h), bot)
1998
+ need_h = min_edge if (idx == 0 or idx == n_cy - 1) else min_mid
1999
+ if bot - top < need_h:
2000
+ continue
2001
+ out.append((top, bot))
2002
+
2003
+ if len(out) < min_rows:
2004
+ return None, ins, None
2005
+
2006
+ out.sort(key=lambda z: z[0])
2007
+ return out, "ok", None
2008
+
2009
+
2010
+ def _avatar_row_fallback_half_px(
2011
+ sw: float,
2012
+ half_extend_baseline: Optional[int],
2013
+ ) -> int:
2014
+ """Fixed half-row when fewer than four avatars or invalid X1/X2."""
2015
+
2016
+ half_bb = half_extend_baseline
2017
+ if half_bb is None:
2018
+ half_bb = CONVERSATION_ROW_HEIGHT_BASELINE // 2
2019
+ return max(56, int(round(half_bb * sw)))
2020
+
2021
+
2022
+ ListRowRectsMode = Literal[
2023
+ "avatar_centroid",
2024
+ "stripe_heuristic",
2025
+ "list_row_pitch_calibration_reject",
2026
+ ]
2027
+
2028
+
2029
+ def conversation_list_row_rects(
2030
+ screen_bgr: np.ndarray,
2031
+ scale_w: float,
2032
+ *,
2033
+ screen_h: int,
2034
+ list_first_row_top_baseline: int,
2035
+ min_avatar_rows: int = 4,
2036
+ half_extend_baseline: Optional[int] = None,
2037
+ strict_pitch_calibration_reject: bool = True,
2038
+ list_row_y12_gap_spread_abs_baseline_px: Optional[float] = None,
2039
+ list_row_screen_height_calibration_baseline_px: Optional[float] = None,
2040
+ ) -> tuple[list[tuple[int, int]], ListRowRectsMode, Optional[float]]:
2041
+ """Prefer avatar-derived strips(PRD §4 ``high=mean gap``);否则退回条纹启发式。
2042
+
2043
+ 当极差门控失败且 ``strict_pitch_calibration_reject=True``(API 默认)时返回
2044
+ **空**行列表,模式 ``list_row_pitch_calibration_reject``,附带观测极差(px)。
2045
+ 外扫描与 :func:`conversation_list_visible_pinned_row_count` 传入
2046
+ ``strict_pitch_calibration_reject=False``,以便在 centroid 不可靠时降级为
2047
+ :func:`split_conversation_rows` 条纹带。
2048
+
2049
+ ``list_row_y12_gap_spread_abs_baseline_px`` / ``screen_height_calibration_baseline_px``
2050
+ 显式覆盖 PRD 基线与屏高校准分母;``None`` 时走环境与模块默认。
2051
+ """
2052
+
2053
+ sw = max(float(scale_w), 1e-6)
2054
+
2055
+ rows_out, st, gap_px = split_conversation_rows_by_avatar_centroids(
2056
+ screen_bgr,
2057
+ scale_w,
2058
+ screen_h=screen_h,
2059
+ list_first_row_top_px=int(round(list_first_row_top_baseline * sw)),
2060
+ min_rows=min_avatar_rows,
2061
+ half_extend_baseline=half_extend_baseline,
2062
+ list_row_y12_gap_spread_abs_baseline_px=list_row_y12_gap_spread_abs_baseline_px,
2063
+ list_row_screen_height_calibration_baseline_px=(
2064
+ list_row_screen_height_calibration_baseline_px
2065
+ ),
2066
+ )
2067
+ if st == "prd_y12_gap_spread_over_limit" and strict_pitch_calibration_reject:
2068
+ return [], "list_row_pitch_calibration_reject", gap_px
2069
+ if st == "ok" and rows_out is not None:
2070
+ return rows_out, "avatar_centroid", None
2071
+
2072
+ rows = split_conversation_rows(
2073
+ screen_h,
2074
+ scale_w,
2075
+ first_row_top_baseline=list_first_row_top_baseline,
2076
+ )
2077
+ return rows, "stripe_heuristic", None
2078
+
2079
+
2080
+ __all__ = [
2081
+ "TEMPLATE_FILE",
2082
+ "TEMPLATE_ROLE",
2083
+ "BASELINE_WIDTH",
2084
+ # Thresholds
2085
+ "CORE_THRESHOLD",
2086
+ "BONUS_THRESHOLD",
2087
+ "BONUS_MIN_WARN",
2088
+ "DEFAULT_NMS_DIST",
2089
+ # Types
2090
+ "Hit",
2091
+ "UnreadDotHit",
2092
+ # Primitives
2093
+ "load_template",
2094
+ "scale_template",
2095
+ "match_all",
2096
+ "match_best",
2097
+ # Detectors (Day 1b)
2098
+ "detect_new_messages_hint",
2099
+ "detect_unread_divider",
2100
+ "detect_favorite_labels",
2101
+ "detect_wechat_note_header",
2102
+ "detect_chat_back_chevron",
2103
+ "detect_chat_title_more_dots",
2104
+ "match_chat_back_chevron_core",
2105
+ "match_chat_title_more_dots_core",
2106
+ "detect_chat_input_voice_button",
2107
+ "detect_chat_input_emoji_smile_button",
2108
+ "detect_chat_input_plus_button",
2109
+ "classify_top_bottom",
2110
+ # Detectors (Day 4 auto-scan)
2111
+ "detect_unread_dots",
2112
+ "dedupe_unread_dot_hits",
2113
+ "split_conversation_rows",
2114
+ "split_conversation_rows_by_avatar_centroids",
2115
+ "split_conversation_rows_by_avatar_roundrect_centroids",
2116
+ "conversation_list_row_rects",
2117
+ # Day 4 auto-scan constants
2118
+ "RED_HSV_LOW_LO",
2119
+ "RED_HSV_LOW_HI",
2120
+ "RED_HSV_HIGH_LO",
2121
+ "RED_HSV_HIGH_HI",
2122
+ "UNREAD_DOT_MIN_AREA_BASELINE",
2123
+ "UNREAD_DOT_MAX_AREA_BASELINE",
2124
+ "UNREAD_DOT_MAX_ASPECT",
2125
+ "UNREAD_DOT_LIST_MIN_CIRCULARITY",
2126
+ "LIST_AVATAR_BADGE_LOW_H_MAX",
2127
+ "LIST_AVATAR_BADGE_MIN_AREA_BASELINE",
2128
+ "LIST_AVATAR_BADGE_SAT_MIN",
2129
+ "LIST_AVATAR_BADGE_VAL_MIN",
2130
+ "LIST_AVATAR_BADGE_ORANGE_AUX_HUE_LO",
2131
+ "LIST_AVATAR_BADGE_ORANGE_AUX_HUE_HI",
2132
+ "LIST_AVATAR_BADGE_ORANGE_AUX_SAT_MIN",
2133
+ "LIST_AVATAR_BADGE_ORANGE_AUX_VAL_MIN",
2134
+ "CONVERSATION_ROW_HEIGHT_BASELINE",
2135
+ "CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE",
2136
+ "CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE",
2137
+ "CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE",
2138
+ "CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE",
2139
+ "CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_AFTER_CLIP_BASELINE",
2140
+ "CONVERSATION_LIST_AVATAR_ROW_MIN_VISIBLE_HEIGHT_EDGE_FRAC",
2141
+ "conversation_list_avatar_hough_roi_y2_exclusive_px",
2142
+ "LIST_ROW_GEOMETRY_Y12_GAP_SPREAD_MAX_BASELINE",
2143
+ "LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE",
2144
+ "conversation_list_avatar_roundrect_centroid_mid_y_ordered",
2145
+ "prd_wechat_main_list_row_count_by_gap_y12",
2146
+ "prd_wechat_main_list_row_count_gap_y12_from_screen_roundrect",
2147
+ "CONVERSATION_RIGHT_SIDE_RESERVED_BASELINE",
2148
+ "CONVERSATION_FIRST_ROW_TOP_BASELINE",
2149
+ "WECHAT_TITLE_TO_FIRST_ROW_OFFSET_BASELINE",
2150
+ "WX_COLLECTOR_LIST_ROW_Y12_GAP_SPREAD_MAX_BASELINE_ENV",
2151
+ "resolve_list_row_y12_gap_spread_max_baseline_px",
2152
+ "conversation_list_avatar_prd_y12_row_pitch_calibration",
2153
+ "detect_wechat_main_title_bottom_y",
2154
+ "detect_wechat_main_bottom_tab_bar_four_columns",
2155
+ "detect_wechat_bottom_tab_selected_green_index",
2156
+ "conversation_list_visible_pinned_row_count",
2157
+ "is_wechat_main_conversation_list_chrome",
2158
+ "reposition_anchor_bottom_wechat_tap_lane_plausible",
2159
+ "is_wechat_recent_pull_anchor_early_stop_frame",
2160
+ "estimate_list_row_background_v95",
2161
+ "PINNED_BACKGROUND_V95_MAX",
2162
+ "list_row_is_pinned_grey_background",
2163
+ ]