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.
- screenshot_vision_algorithm/__init__.py +48 -0
- screenshot_vision_algorithm/_config.py +61 -0
- screenshot_vision_algorithm/android/__init__.py +1 -0
- screenshot_vision_algorithm/android/wechat/__init__.py +1 -0
- screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
- screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +209 -0
- screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +275 -0
- screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +1000 -0
- screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +267 -0
- screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +290 -0
- screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +2163 -0
- screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +143 -0
- screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
- screenshot_vision_algorithm/android/wechat/merge/multipage.py +157 -0
- screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
- screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +434 -0
- screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +232 -0
- screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +1888 -0
- screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py +625 -0
- screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
- screenshot_vision_algorithm/android/wechat/profiles/android.py +53 -0
- screenshot_vision_algorithm/android/wechat/profiles/harmony.py +10 -0
- screenshot_vision_algorithm/android/wechat/profiles/ios.py +53 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_back_chevron.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_emoji_smile.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_plus.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_voice.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_title_more_dots.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/favorite_label.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/new_messages_hint_suffix.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint_v2_textonly.png +0 -0
- screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_note_header.png +0 -0
- screenshot_vision_algorithm/android/xhs/__init__.py +4 -0
- screenshot_vision_algorithm/android/zhihu/__init__.py +4 -0
- screenshot_vision_algorithm/png_utils.py +86 -0
- screenshot_vision_algorithm-0.3.0.dist-info/METADATA +425 -0
- screenshot_vision_algorithm-0.3.0.dist-info/RECORD +40 -0
- screenshot_vision_algorithm-0.3.0.dist-info/WHEEL +5 -0
- 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
|
+
]
|