screenshot-vision-algorithm 0.3.10__tar.gz → 0.3.13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/PKG-INFO +2 -2
  2. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/README.md +1 -1
  3. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/pyproject.toml +1 -1
  4. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +126 -9
  5. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +8 -5
  6. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +79 -28
  7. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +128 -9
  8. screenshot_vision_algorithm-0.3.13/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_declarative_rules.py +206 -0
  9. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +2 -2
  10. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/SOURCES.txt +2 -0
  11. screenshot_vision_algorithm-0.3.13/tests/test_chat_left_avatar.py +88 -0
  12. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/setup.cfg +0 -0
  13. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/__init__.py +0 -0
  14. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/_config.py +0 -0
  15. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
  16. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
  17. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
  18. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +0 -0
  19. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +0 -0
  20. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
  21. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
  22. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
  23. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
  24. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
  25. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
  26. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
  27. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py +0 -0
  28. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
  29. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
  30. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
  31. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
  32. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_back_chevron.png +0 -0
  33. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_emoji_smile.png +0 -0
  34. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_plus.png +0 -0
  35. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_voice.png +0 -0
  36. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_title_more_dots.png +0 -0
  37. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/favorite_label.png +0 -0
  38. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/new_messages_hint_suffix.png +0 -0
  39. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint.png +0 -0
  40. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint_v2_textonly.png +0 -0
  41. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_note_header.png +0 -0
  42. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
  43. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
  44. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/logging.py +0 -0
  45. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/png_utils.py +0 -0
  46. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
  47. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
  48. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
  49. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/tests/test_card_bbox.py +0 -0
  50. {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/tests/test_package.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenshot_vision_algorithm
3
- Version: 0.3.10
3
+ Version: 0.3.13
4
4
  Summary: Multi-platform screenshot vision algorithms: WeChat (card bbox, speaker bands, avatar detection, OCR helpers), XHS & Zhihu (planned)
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -209,7 +209,7 @@ profile.templates_dir # Path — 内嵌模板 PNG 目录
209
209
 
210
210
  | 函数 | 说明 |
211
211
  |------|------|
212
- | `detect_left_avatar_column_layout(...)` | Hough 圆检测左侧头像列 |
212
+ | `detect_left_avatar_column_layout(...)` | 群聊左列:圆角方块主路径,左列 `<3` 时 Hough 兜底 |
213
213
  | `find_avatar_anchor_for_nickname_bbox(...)` | 为昵称 bbox 找到对应的发言人头像锚点 |
214
214
  | `nickname_bbox_in_avatar_column(...)` | 判断昵称 bbox 是否在头像列区域内 |
215
215
  | `left_avatar_ytops(layout)` | 提取所有头像的 Y 坐标列表 |
@@ -190,7 +190,7 @@ profile.templates_dir # Path — 内嵌模板 PNG 目录
190
190
 
191
191
  | 函数 | 说明 |
192
192
  |------|------|
193
- | `detect_left_avatar_column_layout(...)` | Hough 圆检测左侧头像列 |
193
+ | `detect_left_avatar_column_layout(...)` | 群聊左列:圆角方块主路径,左列 `<3` 时 Hough 兜底 |
194
194
  | `find_avatar_anchor_for_nickname_bbox(...)` | 为昵称 bbox 找到对应的发言人头像锚点 |
195
195
  | `nickname_bbox_in_avatar_column(...)` | 判断昵称 bbox 是否在头像列区域内 |
196
196
  | `left_avatar_ytops(layout)` | 提取所有头像的 Y 坐标列表 |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "screenshot_vision_algorithm"
7
- version = "0.3.10"
7
+ version = "0.3.13"
8
8
  description = "Multi-platform screenshot vision algorithms: WeChat (card bbox, speaker bands, avatar detection, OCR helpers), XHS & Zhihu (planned)"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -1,6 +1,8 @@
1
- """左列头像圆心检测(列表页 / 群聊页共用几何先验)。
1
+ """左列头像检测(列表页 / 群聊页共用几何先验)。
2
+
3
+ 群聊:**圆角方块**为主路径;左列检出数 ``< CHAT_LEFT_AVATAR_HOUGH_FALLBACK_THRESHOLD`` 时
4
+ 回退 **Hough 圆**。列表会话行仍可在 ``template_matching`` 侧单独走 roundrect + §14 门控。
2
5
 
3
- 列表会话行与群聊发言人归因共用同一套 Hough + median_x 过滤 + y 向去重,
4
6
  产出 ``AvatarCentroid`` 供 ROI 守门与 PRD §5 纵向筒使用。
5
7
  """
6
8
 
@@ -23,6 +25,7 @@ from screenshot_vision_algorithm.android.wechat.algorithms.template_matching imp
23
25
  CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE,
24
26
  CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE,
25
27
  CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE,
28
+ conversation_list_avatar_roundrect_avatar_centroids_ordered,
26
29
  )
27
30
 
28
31
  # 与列表页 Hough 对齐(1080 基准);群聊左列头像物理尺寸与列表一致。
@@ -32,6 +35,10 @@ _LIST_HOUGH_MAX_R_BASELINE = 80
32
35
  _LIST_YMIN_GAP_BASELINE = 118
33
36
  _LIST_SAME_ICON_DY_MAX_BASELINE = 18
34
37
 
38
+ # 群聊:roundrect 主路径;左列 < 阈值时 Hough 兜底。
39
+ CHAT_LEFT_AVATAR_HOUGH_FALLBACK_THRESHOLD = 3
40
+ CHAT_ROUNDRECT_MIN_ROWS = 1
41
+
35
42
  # 绑定:昵称行与头像锚点的最大竖直间距(px @ 1080)。
36
43
  NICKNAME_AVATAR_BIND_MAX_DY_BASELINE = 140
37
44
 
@@ -64,25 +71,48 @@ def _sw(scale_w: float) -> float:
64
71
  return max(float(scale_w), 1e-6)
65
72
 
66
73
 
67
- def detect_left_avatar_column_layout(
74
+ def _empty_layout(scale_w: float) -> LeftAvatarColumnLayout:
75
+ xt = int(round(float(CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE) * scale_w))
76
+ return LeftAvatarColumnLayout(centroids=(), median_cx=0.0, column_x_half_width_px=xt)
77
+
78
+
79
+ def _layout_from_centroids(
80
+ centroids: list[AvatarCentroid],
81
+ *,
82
+ column_x_half_width_px: int,
83
+ ) -> LeftAvatarColumnLayout:
84
+ if not centroids:
85
+ return LeftAvatarColumnLayout(
86
+ centroids=(),
87
+ median_cx=0.0,
88
+ column_x_half_width_px=column_x_half_width_px,
89
+ )
90
+ median_cx = float(np.median(np.array([c.cx for c in centroids], dtype=np.float64)))
91
+ return LeftAvatarColumnLayout(
92
+ centroids=tuple(centroids),
93
+ median_cx=median_cx,
94
+ column_x_half_width_px=column_x_half_width_px,
95
+ )
96
+
97
+
98
+ def _detect_left_avatar_column_layout_hough(
68
99
  screen_bgr: np.ndarray,
69
100
  scale_w: float,
70
101
  *,
71
- y_top: int = 0,
72
- y_bottom_excl: Optional[int] = None,
102
+ y_top: int,
103
+ y_bottom_excl: int,
73
104
  ) -> LeftAvatarColumnLayout:
74
- """在左列 ROI 内 Hough median_x 过滤 → y 向去重(与列表页同参)。"""
105
+ """Hough 圆检测(列表/群聊 legacy 参数)。"""
75
106
  sw = _sw(scale_w)
76
107
  xt = int(round(float(CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE) * sw))
77
- empty = LeftAvatarColumnLayout(centroids=(), median_cx=0.0, column_x_half_width_px=xt)
108
+ empty = _empty_layout(sw)
78
109
 
79
110
  if cv2 is None or screen_bgr is None or getattr(screen_bgr, "size", 0) == 0:
80
111
  return empty
81
112
 
82
113
  h_full, w_full = screen_bgr.shape[:2]
83
114
  y1 = int(max(0, min(y_top, h_full - 1)))
84
- y2 = int(y_bottom_excl if y_bottom_excl is not None else h_full)
85
- y2 = int(max(y1 + 8, min(y2, h_full)))
115
+ y2 = int(max(y1 + 8, min(y_bottom_excl, h_full)))
86
116
  x1_roi = int(max(0, round(CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE * sw)))
87
117
  x2_roi = int(min(w_full, round(CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE * sw)))
88
118
  if y2 <= y1 + 40 or x2_roi <= x1_roi + 24:
@@ -148,6 +178,68 @@ def detect_left_avatar_column_layout(
148
178
  )
149
179
 
150
180
 
181
+ def _detect_left_avatar_column_layout_roundrect(
182
+ screen_bgr: np.ndarray,
183
+ scale_w: float,
184
+ *,
185
+ y_top: int,
186
+ y_bottom_excl: int,
187
+ ) -> LeftAvatarColumnLayout:
188
+ """圆角方块主路径(群聊 content_bounds ROI)。"""
189
+ sw = _sw(scale_w)
190
+ xt = int(round(float(CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE) * sw))
191
+ empty = _empty_layout(sw)
192
+
193
+ if screen_bgr is None or getattr(screen_bgr, "size", 0) == 0:
194
+ return empty
195
+
196
+ pts = conversation_list_avatar_roundrect_avatar_centroids_ordered(
197
+ screen_bgr,
198
+ sw,
199
+ roi_y1=int(y_top),
200
+ roi_y2_excl=int(y_bottom_excl),
201
+ min_rows=CHAT_ROUNDRECT_MIN_ROWS,
202
+ trim_list_footer=False,
203
+ )
204
+ if not pts:
205
+ return empty
206
+
207
+ centroids = [
208
+ AvatarCentroid(cx=int(cx), cy=int(cy), r=max(8, int(r)))
209
+ for cx, cy, r in pts
210
+ ]
211
+ return _layout_from_centroids(centroids, column_x_half_width_px=xt)
212
+
213
+
214
+ def detect_left_avatar_column_layout(
215
+ screen_bgr: np.ndarray,
216
+ scale_w: float,
217
+ *,
218
+ y_top: int = 0,
219
+ y_bottom_excl: Optional[int] = None,
220
+ ) -> LeftAvatarColumnLayout:
221
+ """群聊左列头像:roundrect 主路径;``<3`` 时 Hough 兜底(取检出更多的一侧)。"""
222
+ sw = _sw(scale_w)
223
+ h_full = int(screen_bgr.shape[0]) if screen_bgr is not None else 0
224
+ y1 = int(max(0, min(y_top, max(h_full - 1, 0))))
225
+ y2 = int(y_bottom_excl if y_bottom_excl is not None else h_full)
226
+ y2 = int(max(y1 + 8, min(y2, h_full)))
227
+
228
+ roundrect_layout = _detect_left_avatar_column_layout_roundrect(
229
+ screen_bgr, sw, y_top=y1, y_bottom_excl=y2,
230
+ )
231
+ n_rr = len(roundrect_layout.centroids)
232
+ if n_rr >= CHAT_LEFT_AVATAR_HOUGH_FALLBACK_THRESHOLD:
233
+ return roundrect_layout
234
+
235
+ hough_layout = _detect_left_avatar_column_layout_hough(
236
+ screen_bgr, sw, y_top=y1, y_bottom_excl=y2,
237
+ )
238
+ if len(hough_layout.centroids) > n_rr:
239
+ return hough_layout
240
+ return roundrect_layout
241
+
242
+
151
243
  def find_avatar_anchor_for_nickname_bbox(
152
244
  layout: LeftAvatarColumnLayout,
153
245
  bbox_xyxy: tuple[int, int, int, int],
@@ -208,3 +300,28 @@ def nickname_bbox_in_avatar_column(
208
300
  def left_avatar_ytops(layout: LeftAvatarColumnLayout) -> list[int]:
209
301
  """供 PRD §5:头像顶 y(圆心减半径)。"""
210
302
  return [c.y_top for c in layout.centroids]
303
+
304
+
305
+ def completely_inside_avatar_x_zone(
306
+ bbox_xyxy: tuple[int, int, int, int],
307
+ *,
308
+ cx: int,
309
+ r: int,
310
+ ) -> bool:
311
+ """PRD §8B:OCR 块是否完全被包含在左列头像区 X 范围 ``[0, cx+r]`` 内。"""
312
+ x1, _, x2, _ = bbox_xyxy
313
+ x_right = float(cx) + float(r)
314
+ return float(x1) >= 0.0 and float(x2) <= x_right
315
+
316
+
317
+ def left_avatar_cx_r_for_band(
318
+ layout: LeftAvatarColumnLayout,
319
+ band_y0: float,
320
+ band_y1: float,
321
+ ) -> tuple[int, int] | None:
322
+ """返回与 ``speaker_band`` 对应的左列头像 ``(cx, r)``(圆心 Y 最接近 band 中线)。"""
323
+ if layout.empty:
324
+ return None
325
+ mid = (float(band_y0) + float(band_y1)) / 2.0
326
+ best = min(layout.centroids, key=lambda c: abs(float(c.cy) - mid))
327
+ return int(best.cx), int(best.r)
@@ -191,11 +191,14 @@ def detect_chat_side_avatar_ytops(
191
191
  left_ys = _hough_ytops_in_column(
192
192
  gray, x1=0, x2=left_x2, y1=y1, y2=y2, scale_w=scale_w,
193
193
  )
194
- col_w = int(round(CHAT_SIDE_AVATAR_COLUMN_WIDTH_BASELINE * sw))
195
- right_x1 = max(0, w_full - col_w)
196
- right_ys = _hough_ytops_in_column(
197
- gray, x1=right_x1, x2=w_full, y1=y1, y2=y2, scale_w=scale_w,
198
- )
194
+ # 左列已有检出时禁用右列 timeline,避免标题栏 y≈119 假阳合成单筒。
195
+ right_ys: list[int] = []
196
+ if not left_ys:
197
+ col_w = int(round(CHAT_SIDE_AVATAR_COLUMN_WIDTH_BASELINE * sw))
198
+ right_x1 = max(0, w_full - col_w)
199
+ right_ys = _hough_ytops_in_column(
200
+ gray, x1=right_x1, x2=w_full, y1=y1, y2=y2, scale_w=scale_w,
201
+ )
199
202
  return left_ys, right_ys
200
203
 
201
204
 
@@ -1812,28 +1812,25 @@ def split_conversation_rows_by_avatar_centroids(
1812
1812
  return out, "ok", None
1813
1813
 
1814
1814
 
1815
- def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1815
+ def _avatar_roundrect_centroids_in_left_roi(
1816
1816
  screen_bgr: np.ndarray,
1817
1817
  scale_w: float,
1818
1818
  *,
1819
- screen_h: int,
1820
- list_first_row_top_px: int,
1821
- min_rows: int = 4,
1822
- ) -> Optional[list[int]]:
1823
- """圆角方块路径:自上而下整屏头像 **质心 y(px)**,不写行带。"""
1819
+ roi_y1: int,
1820
+ roi_y2_excl: int,
1821
+ min_rows: int,
1822
+ trim_list_footer: bool = True,
1823
+ ) -> Optional[list[tuple[int, int, int]]]:
1824
+ """圆角方块路径:左列 ROI 内头像 ``(cx_full, cy_full, r)``,自上而下排序。"""
1824
1825
  sw = max(float(scale_w), 1e-6)
1825
1826
  _, w_full = screen_bgr.shape[:2]
1826
- slack_top = int(round(22 * sw))
1827
- roi_y1 = max(0, int(list_first_row_top_px) - slack_top)
1828
- roi_y2_excl_pt = conversation_list_avatar_hough_roi_y2_exclusive_px(int(screen_h), sw)
1829
- roi_y2 = max(roi_y1 + 8, roi_y2_excl_pt)
1830
1827
  x1_roi = max(0, int(round(CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE * sw)))
1831
1828
  x2_roi = min(w_full, int(round(CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE * sw)))
1832
- if roi_y2 <= roi_y1 + 40 or x2_roi <= x1_roi + 24:
1829
+ if roi_y2_excl <= roi_y1 + 40 or x2_roi <= x1_roi + 24:
1833
1830
  return None
1834
1831
 
1835
1832
  crop_gray = cv2.cvtColor(
1836
- screen_bgr[roi_y1:roi_y2, x1_roi:x2_roi], cv2.COLOR_BGR2GRAY,
1833
+ screen_bgr[roi_y1:roi_y2_excl, x1_roi:x2_roi], cv2.COLOR_BGR2GRAY,
1837
1834
  )
1838
1835
  blur = cv2.GaussianBlur(crop_gray, (5, 5), 0)
1839
1836
  mk_base = float(LIST_AVATAR_ROUNDRECT_MORPH_GRAD_KERNEL_BASELINE)
@@ -1854,7 +1851,7 @@ def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1854
1851
  cv2.CHAIN_APPROX_SIMPLE,
1855
1852
  )
1856
1853
 
1857
- cand: list[tuple[float, float, float]] = []
1854
+ cand: list[tuple[float, float, float, float]] = []
1858
1855
  s_min = float(LIST_AVATAR_ROUNDRECT_BBOX_SIDE_MIN_BASELINE) * sw
1859
1856
  s_max = float(LIST_AVATAR_ROUNDRECT_BBOX_SIDE_MAX_BASELINE) * sw
1860
1857
  asp_min = float(LIST_AVATAR_ROUNDRECT_MIN_ASPECT)
@@ -1887,8 +1884,9 @@ def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1887
1884
  continue
1888
1885
  xc = float(M["m10"] / M["m00"])
1889
1886
  yc = float(M["m01"] / M["m00"])
1887
+ half_r = max(8.0, min(bw_i, bh) / 2.0)
1890
1888
  merit = bb_a * extent * sol
1891
- cand.append((merit, xc, yc))
1889
+ cand.append((merit, xc, yc, half_r))
1892
1890
 
1893
1891
  if len(cand) < min_rows:
1894
1892
  return None
@@ -1901,10 +1899,11 @@ def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1901
1899
  if abs(trip[1] - median_x) <= float(xt)
1902
1900
  ]
1903
1901
 
1904
- roi_h_calc = max(0, int(roi_y2) - int(roi_y1))
1905
- foot = int(round(float(LIST_AVATAR_HOUGH_MAX_CROP_Y_FROM_ROI_BOTTOM_BASELINE) * sw))
1906
- if roi_h_calc > foot + 24:
1907
- filt = [trip for trip in filt if float(trip[2]) <= float(roi_h_calc - foot)]
1902
+ roi_h_calc = max(0, int(roi_y2_excl) - int(roi_y1))
1903
+ if trim_list_footer:
1904
+ foot = int(round(float(LIST_AVATAR_HOUGH_MAX_CROP_Y_FROM_ROI_BOTTOM_BASELINE) * sw))
1905
+ if roi_h_calc > foot + 24:
1906
+ filt = [trip for trip in filt if float(trip[2]) <= float(roi_h_calc - foot)]
1908
1907
 
1909
1908
  if len(filt) < min_rows:
1910
1909
  return None
@@ -1913,29 +1912,80 @@ def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1913
1912
 
1914
1913
  ymin_gap = round(118 * sw)
1915
1914
  same_icon_dy_max = float(LIST_AVATAR_STACK_SAME_ICON_MAX_CENTER_DY_BASELINE) * sw
1916
- stacked_h: list[tuple[float, float, float]] = []
1915
+ stacked_h: list[tuple[float, float, float, float]] = []
1917
1916
 
1918
- for mer, xc, yc in filt:
1917
+ for mer, xc, yc, half_r in filt:
1919
1918
  if not stacked_h:
1920
- stacked_h.append((mer, xc, yc))
1919
+ stacked_h.append((mer, xc, yc, half_r))
1921
1920
  continue
1922
- pr_mer, _px, py_prev = stacked_h[-1]
1921
+ pr_mer, _px, py_prev, _pr = stacked_h[-1]
1923
1922
  dy = float(yc) - float(py_prev)
1924
1923
  if dy < ymin_gap:
1925
1924
  if dy <= same_icon_dy_max:
1926
1925
  if mer > pr_mer:
1927
- stacked_h[-1] = (mer, xc, yc)
1926
+ stacked_h[-1] = (mer, xc, yc, half_r)
1928
1927
  else:
1929
- stacked_h.append((mer, xc, yc))
1928
+ stacked_h.append((mer, xc, yc, half_r))
1930
1929
  else:
1931
- stacked_h.append((mer, xc, yc))
1930
+ stacked_h.append((mer, xc, yc, half_r))
1932
1931
 
1933
1932
  if len(stacked_h) < min_rows:
1934
1933
  return None
1935
1934
 
1936
- return [
1937
- roi_y1 + int(round(yc)) for _me, _xc, yc in stacked_h
1938
- ]
1935
+ out: list[tuple[int, int, int]] = []
1936
+ for _me, xc, yc, half_r in stacked_h:
1937
+ cx_full = int(round(x1_roi + xc))
1938
+ cy_full = int(round(roi_y1 + yc))
1939
+ ri = max(8, int(round(half_r)))
1940
+ out.append((cx_full, cy_full, ri))
1941
+ return out
1942
+
1943
+
1944
+ def conversation_list_avatar_roundrect_avatar_centroids_ordered(
1945
+ screen_bgr: np.ndarray,
1946
+ scale_w: float,
1947
+ *,
1948
+ roi_y1: int,
1949
+ roi_y2_excl: int,
1950
+ min_rows: int = 1,
1951
+ trim_list_footer: bool = False,
1952
+ ) -> Optional[list[tuple[int, int, int]]]:
1953
+ """群聊/通用:左列圆角方块头像 ``(cx, cy, r)`` 全图坐标。"""
1954
+ return _avatar_roundrect_centroids_in_left_roi(
1955
+ screen_bgr,
1956
+ scale_w,
1957
+ roi_y1=roi_y1,
1958
+ roi_y2_excl=roi_y2_excl,
1959
+ min_rows=min_rows,
1960
+ trim_list_footer=trim_list_footer,
1961
+ )
1962
+
1963
+
1964
+ def conversation_list_avatar_roundrect_centroid_mid_y_ordered(
1965
+ screen_bgr: np.ndarray,
1966
+ scale_w: float,
1967
+ *,
1968
+ screen_h: int,
1969
+ list_first_row_top_px: int,
1970
+ min_rows: int = 4,
1971
+ ) -> Optional[list[int]]:
1972
+ """圆角方块路径:自上而下整屏头像 **质心 y(px)**,不写行带。"""
1973
+ sw = max(float(scale_w), 1e-6)
1974
+ slack_top = int(round(22 * sw))
1975
+ roi_y1 = max(0, int(list_first_row_top_px) - slack_top)
1976
+ roi_y2_excl_pt = conversation_list_avatar_hough_roi_y2_exclusive_px(int(screen_h), sw)
1977
+ roi_y2 = max(roi_y1 + 8, roi_y2_excl_pt)
1978
+ pts = _avatar_roundrect_centroids_in_left_roi(
1979
+ screen_bgr,
1980
+ sw,
1981
+ roi_y1=roi_y1,
1982
+ roi_y2_excl=roi_y2,
1983
+ min_rows=min_rows,
1984
+ trim_list_footer=True,
1985
+ )
1986
+ if pts is None:
1987
+ return None
1988
+ return [cy for _cx, cy, _r in pts]
1939
1989
 
1940
1990
 
1941
1991
  def prd_wechat_main_list_row_count_by_gap_y12(
@@ -2226,6 +2276,7 @@ __all__ = [
2226
2276
  "LIST_ROW_GEOMETRY_Y12_GAP_SPREAD_MAX_BASELINE",
2227
2277
  "LIST_ROW_SCREEN_HEIGHT_CALIBRATION_Y12_BASELINE",
2228
2278
  "conversation_list_avatar_roundrect_centroid_mid_y_ordered",
2279
+ "conversation_list_avatar_roundrect_avatar_centroids_ordered",
2229
2280
  "prd_wechat_main_list_row_count_by_gap_y12",
2230
2281
  "prd_wechat_main_list_row_count_gap_y12_from_screen_roundrect",
2231
2282
  "CONVERSATION_RIGHT_SIDE_RESERVED_BASELINE",
@@ -113,7 +113,7 @@ References:
113
113
  from __future__ import annotations
114
114
 
115
115
  import re
116
- from dataclasses import dataclass, field
116
+ from dataclasses import dataclass, field, field
117
117
  from dataclasses import replace as dataclass_replace
118
118
  from pathlib import Path
119
119
  from statistics import median
@@ -256,6 +256,18 @@ class NicknameOcrConfig:
256
256
  avatar_merge_min_sep_px: int = 24
257
257
  """头像合并最小间距(px @ 1080 × scale_w)。"""
258
258
 
259
+ rejection_rules: tuple[dict[str, Any], ...] = field(default_factory=tuple)
260
+ """声明式文本/几何拒绝规则(rules_config rejection_rules;geometry 子集亦在此)。"""
261
+
262
+ geometry_rules: tuple[dict[str, Any], ...] = field(default_factory=tuple)
263
+ """附加几何规则(rules_config geometry_rules)。"""
264
+
265
+ group_display_name: str = ""
266
+ """会话顶栏群名 OCR;候选昵称 normalize 后与之 core 匹配则 drop。"""
267
+
268
+ use_declarative_rules: bool = True
269
+ """True 且 ``rejection_rules`` 非空时用声明式 filter 驱动 ``_classify`` / Step B。"""
270
+
259
271
 
260
272
  _ATTRIBUTION_PREFIX_DROPS: tuple[str, ...] = (
261
273
  "撤回了一条消息",
@@ -883,6 +895,7 @@ def _resume_thumb_bindings_and_orphans(
883
895
  original_width: int,
884
896
  config: NicknameOcrConfig,
885
897
  layout_opt: Any = None,
898
+ avatar_layout: Any = None,
886
899
  scale_w: float = 1.0,
887
900
  image_size: tuple[int, int] | None = None,
888
901
  ) -> tuple[tuple[ResumeThumbBinding, ...], int, set[int]]:
@@ -933,6 +946,26 @@ def _resume_thumb_bindings_and_orphans(
933
946
  and by2 <= cy2 + _inside_tol
934
947
  )
935
948
 
949
+ def _completely_inside_avatar_x(block_bbox: tuple, *, cx: int, r: int) -> bool:
950
+ try:
951
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import (
952
+ completely_inside_avatar_x_zone,
953
+ )
954
+ except ImportError:
955
+ return False
956
+ return completely_inside_avatar_x_zone(block_bbox, cx=cx, r=r)
957
+
958
+ def _avatar_cx_r_for_band(band_y0: float, band_y1: float) -> tuple[int, int] | None:
959
+ if avatar_layout is None or getattr(avatar_layout, "empty", True):
960
+ return None
961
+ try:
962
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import (
963
+ left_avatar_cx_r_for_band,
964
+ )
965
+ except ImportError:
966
+ return None
967
+ return left_avatar_cx_r_for_band(avatar_layout, band_y0, band_y1)
968
+
936
969
  def _should_skip_block(b: "TextBlock") -> bool:
937
970
  t = (b.text or "").strip()
938
971
  if not t:
@@ -1056,10 +1089,15 @@ def _resume_thumb_bindings_and_orphans(
1056
1089
 
1057
1090
  matched_nickname: Optional[str] = None
1058
1091
  best_y: float = float('inf')
1092
+ _band_avatar_cx_r = _avatar_cx_r_for_band(_band_y0, _band_y1)
1059
1093
  for i, b in enumerate(blocks):
1060
1094
  bb = b.bbox_xyxy
1061
1095
  _by1, _by2 = float(bb[1]), float(bb[3])
1062
1096
 
1097
+ # Step B 仅采纳 _classify 标为 nickname_candidate 的块(含 rejection_rules)
1098
+ if i < len(classes) and classes[i] != "nickname_candidate":
1099
+ continue
1100
+
1063
1101
  # ── 规则 (0):block 必须与当前 speaker_band 有 Y 交集 ──
1064
1102
  if not (_by2 > _band_y0 and _by1 < _band_y1):
1065
1103
  continue
@@ -1094,6 +1132,12 @@ def _resume_thumb_bindings_and_orphans(
1094
1132
  if is_fully_inside:
1095
1133
  continue
1096
1134
 
1135
+ # PRD §8B:排除完全被包含在头像区 X 范围 [0, cx+r] 内的文字
1136
+ if _band_avatar_cx_r is not None:
1137
+ _acx, _ar = _band_avatar_cx_r
1138
+ if _completely_inside_avatar_x(bb, cx=_acx, r=_ar):
1139
+ continue
1140
+
1097
1141
  # ── 按距离选最优:取 y_bottom 最接近 band 上界的块 ──
1098
1142
  if _by2 < best_y:
1099
1143
  best_y = _by2
@@ -1104,12 +1148,24 @@ def _resume_thumb_bindings_and_orphans(
1104
1148
  best_i: Optional[int] = None
1105
1149
  best_bottom: float = -1.0
1106
1150
  card_top = float(tbb[1])
1151
+ _step_c_avatar_cx_r: tuple[int, int] | None = None
1152
+ if band_idx is not None and band_idx >= 0 and layout_opt is not None:
1153
+ _sb_list_c = getattr(layout_opt, "speaker_bands", ())
1154
+ if band_idx < len(_sb_list_c):
1155
+ _by0_c, _by1_c = float(_sb_list_c[band_idx][0]), float(_sb_list_c[band_idx][1])
1156
+ _step_c_avatar_cx_r = _avatar_cx_r_for_band(_by0_c, _by1_c)
1107
1157
  for i, b in enumerate(blocks):
1108
1158
  bcx = float(b.bbox_xyxy[3]) # block bottom
1109
1159
  if bcx >= card_top:
1110
1160
  continue
1161
+ if i < len(classes) and classes[i] != "nickname_candidate":
1162
+ continue
1111
1163
  if _should_skip_block(b):
1112
1164
  continue
1165
+ if _step_c_avatar_cx_r is not None:
1166
+ _acx, _ar = _step_c_avatar_cx_r
1167
+ if _completely_inside_avatar_x(b.bbox_xyxy, cx=_acx, r=_ar):
1168
+ continue
1113
1169
  if bcx > best_bottom:
1114
1170
  best_bottom = bcx
1115
1171
  best_i = i
@@ -1305,34 +1361,95 @@ def _classify(
1305
1361
  avatar_layout: Any = None,
1306
1362
  ) -> str:
1307
1363
  """Return ``"nickname_candidate"`` / ``"bubble_text"`` / ``"drop"``."""
1364
+ from screenshot_vision_algorithm.android.wechat.ocr.nickname_declarative_rules import (
1365
+ geometry_rules_only,
1366
+ run_geometry_rules_drop,
1367
+ run_rejection_rules_drop,
1368
+ text_matches_group_display_name,
1369
+ text_rejection_rules,
1370
+ )
1371
+
1308
1372
  text = block.text.strip()
1309
1373
  if not text:
1310
1374
  return "drop"
1311
1375
  if block.confidence < config.min_confidence:
1312
1376
  return "drop"
1313
- if (
1314
- _is_pure_time(text)
1315
- or _is_pure_digits(text)
1316
- or _is_chat_timestamp_or_divider(text)
1317
- or _is_favorite_card_label(text)
1318
- ):
1319
- return "drop"
1377
+
1378
+ use_declarative = bool(
1379
+ config.use_declarative_rules and config.rejection_rules
1380
+ )
1381
+
1382
+ if use_declarative:
1383
+ text_rules = text_rejection_rules(config.rejection_rules)
1384
+ if text_rules and run_rejection_rules_drop(text, text_rules):
1385
+ return "drop"
1386
+ if config.group_display_name and text_matches_group_display_name(
1387
+ text, config.group_display_name
1388
+ ):
1389
+ return "drop"
1390
+ geo = geometry_rules_only(config.rejection_rules, config.geometry_rules)
1391
+ if geo and run_geometry_rules_drop(
1392
+ block,
1393
+ geo,
1394
+ screen_width=original_width,
1395
+ median_height=median_height,
1396
+ screen_midline_ratio=config.screen_midline_ratio,
1397
+ ):
1398
+ return "drop"
1399
+ else:
1400
+ if (
1401
+ _is_pure_time(text)
1402
+ or _is_pure_digits(text)
1403
+ or _is_chat_timestamp_or_divider(text)
1404
+ or _is_favorite_card_label(text)
1405
+ ):
1406
+ return "drop"
1407
+
1408
+ if _compiled_skip_patterns_for_config(config):
1409
+ for _pat in _compiled_skip_patterns_for_config(config):
1410
+ try:
1411
+ if _pat.search(text):
1412
+ return "drop"
1413
+ except Exception:
1414
+ continue
1320
1415
 
1321
1416
  is_left = _is_on_left_half(block, original_width, config.screen_midline_ratio)
1322
1417
 
1418
+ if _starts_with_system_prefix(text, config.system_prefix_drops):
1419
+ return "bubble_text"
1420
+
1323
1421
  if (
1324
1422
  len(text) <= config.nickname_max_chars
1325
- and not _starts_with_system_prefix(text, config.system_prefix_drops)
1326
1423
  and is_left
1327
1424
  and _is_avatar_column_nickname_row(
1328
1425
  block, original_width, config, avatar_layout
1329
1426
  )
1330
1427
  ):
1428
+ if use_declarative:
1429
+ geo = geometry_rules_only(config.rejection_rules, config.geometry_rules)
1430
+ if geo and run_geometry_rules_drop(
1431
+ block,
1432
+ geo,
1433
+ screen_width=original_width,
1434
+ median_height=median_height,
1435
+ screen_midline_ratio=config.screen_midline_ratio,
1436
+ ):
1437
+ return "bubble_text"
1331
1438
  return "nickname_candidate"
1332
1439
 
1333
1440
  return "bubble_text"
1334
1441
 
1335
1442
 
1443
+ def _compiled_skip_patterns_for_config(config: NicknameOcrConfig) -> tuple[re.Pattern[str], ...]:
1444
+ out: list[re.Pattern[str]] = []
1445
+ for p in config.skip_block_patterns:
1446
+ try:
1447
+ out.append(re.compile(p))
1448
+ except re.error:
1449
+ pass
1450
+ return tuple(out)
1451
+
1452
+
1336
1453
  def _merge_two_text_blocks(a: TextBlock, b: TextBlock, *, joiner: str) -> TextBlock:
1337
1454
  ax1, ay1, ax2, ay2 = a.bbox_xyxy
1338
1455
  bx1, by1, bx2, by2 = b.bbox_xyxy
@@ -1760,6 +1877,7 @@ def extract_nicknames(
1760
1877
  original_width=original_width,
1761
1878
  config=cfg,
1762
1879
  layout_opt=layout_opt,
1880
+ avatar_layout=avatar_layout,
1763
1881
  scale_w=scale_w,
1764
1882
  image_size=ocr_result.original_size,
1765
1883
  )
@@ -1884,6 +2002,7 @@ def extract_nicknames(
1884
2002
  original_width=original_width,
1885
2003
  config=cfg,
1886
2004
  layout_opt=layout_opt,
2005
+ avatar_layout=avatar_layout,
1887
2006
  scale_w=scale_w,
1888
2007
  image_size=ocr_result.original_size,
1889
2008
  )
@@ -0,0 +1,206 @@
1
+ """Declarative nickname filter rules (rejection_rules + geometry_rules).
2
+
3
+ Mirrors ``skill_self_evolution.rule_runner`` semantics without requiring that
4
+ package inside ``screenshot_vision_algorithm`` (pip 包自洽).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import re
10
+ import unicodedata
11
+ from typing import Any, TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from screenshot_vision_algorithm.android.wechat.ocr.text_ocr_adapter import TextBlock
15
+
16
+ # Strip emoji / symbols for group-name core comparison (PRD 群备注 vs 顶栏).
17
+ _GROUP_NAME_STRIP_RE = re.compile(
18
+ r"[\U0001F300-\U0001FAFF\U00002600-\U000027BF\U0000FE00-\U0000FE0F"
19
+ r"\(\)()\[\]【】\s]+"
20
+ )
21
+
22
+
23
+ def normalize_group_display_core(text: str) -> str:
24
+ """Normalize group title/remark for comparison (drop emoji, spaces, brackets)."""
25
+ t = unicodedata.normalize("NFC", (text or "").strip())
26
+ t = _GROUP_NAME_STRIP_RE.sub("", t)
27
+ # Remove trailing member-count suffix e.g. 阿姨简历群(417)
28
+ t = re.sub(r"\(\d+\)$", "", t)
29
+ t = re.sub(r"(\d+)$", "", t)
30
+ return t.strip()
31
+
32
+
33
+ def text_matches_group_display_name(text: str, group_display_name: str) -> bool:
34
+ """True when OCR text is the same group name as session top-bar title (core match)."""
35
+ gdn = normalize_group_display_core(group_display_name)
36
+ if not gdn or len(gdn) < 2:
37
+ return False
38
+ cand = normalize_group_display_core(text)
39
+ if not cand:
40
+ return False
41
+ if cand == gdn:
42
+ return True
43
+ # Substring only when both sides are long enough (avoid false positives).
44
+ if len(cand) >= 4 and len(gdn) >= 4:
45
+ if cand in gdn or gdn in cand:
46
+ return True
47
+ return False
48
+
49
+
50
+ def _rule_drops_text(text: str, rule: dict[str, Any]) -> bool:
51
+ t = (text or "").strip()
52
+ if not t:
53
+ return True
54
+ rtype = rule.get("type")
55
+ action = rule.get("action", "drop")
56
+ if action != "drop":
57
+ return False
58
+ if rtype == "regex":
59
+ pat = rule.get("pattern")
60
+ if pat and re.search(str(pat), t):
61
+ return True
62
+ elif rtype == "prefix":
63
+ for kw in rule.get("keywords") or []:
64
+ if t.startswith(str(kw)):
65
+ return True
66
+ elif rtype == "length":
67
+ mn = int(rule.get("min", 0))
68
+ mx = int(rule.get("max", 999))
69
+ if not (mn <= len(t) <= mx):
70
+ return True
71
+ return False
72
+
73
+
74
+ def text_rejection_rules(rules: tuple[dict[str, Any], ...] | list[dict[str, Any]]) -> list[dict[str, Any]]:
75
+ return [
76
+ r for r in rules
77
+ if isinstance(r, dict) and r.get("type") in ("regex", "prefix", "length")
78
+ ]
79
+
80
+
81
+ def geometry_rules_only(
82
+ rejection_rules: tuple[dict[str, Any], ...] | list[dict[str, Any]],
83
+ geometry_rules: tuple[dict[str, Any], ...] | list[dict[str, Any]],
84
+ ) -> list[dict[str, Any]]:
85
+ out = [r for r in rejection_rules if isinstance(r, dict) and r.get("type") == "geometry"]
86
+ out.extend(r for r in (geometry_rules or []) if isinstance(r, dict))
87
+ return out
88
+
89
+
90
+ def run_rejection_rules_drop(text: str, rules: list[dict[str, Any]]) -> bool:
91
+ """Return True if any rule drops the text."""
92
+ for rule in rules:
93
+ if _rule_drops_text(text, rule):
94
+ return True
95
+ return False
96
+
97
+
98
+ def _block_width(block: "TextBlock") -> int:
99
+ x1, _, x2, _ = block.bbox_xyxy
100
+ return max(0, int(x2) - int(x1))
101
+
102
+
103
+ def _block_height(block: "TextBlock") -> int:
104
+ _, y1, _, y2 = block.bbox_xyxy
105
+ return max(1, int(y2) - int(y1))
106
+
107
+
108
+ def _block_center_x(block: "TextBlock") -> float:
109
+ x1, _, x2, _ = block.bbox_xyxy
110
+ return (float(x1) + float(x2)) / 2.0
111
+
112
+
113
+ def run_geometry_rules_drop(
114
+ block: "TextBlock",
115
+ rules: list[dict[str, Any]],
116
+ *,
117
+ screen_width: int,
118
+ median_height: float,
119
+ screen_midline_ratio: float = 0.5,
120
+ ) -> bool:
121
+ """Return True if any geometry rule drops the block."""
122
+ for rule in rules:
123
+ if not isinstance(rule, dict) or rule.get("type") != "geometry":
124
+ continue
125
+ if rule.get("action", "drop") != "drop":
126
+ continue
127
+ constraint = str(rule.get("constraint", ""))
128
+ operator = str(rule.get("operator", "lt"))
129
+ value = float(rule.get("value", 0))
130
+ matched = False
131
+
132
+ if constraint == "horizontal_position":
133
+ midline_x = float(screen_width) * float(screen_midline_ratio)
134
+ cx = _block_center_x(block)
135
+ if operator == "lt":
136
+ matched = cx < midline_x
137
+ elif operator == "lte":
138
+ matched = cx <= midline_x
139
+ elif operator == "gt":
140
+ matched = cx > midline_x
141
+ elif operator == "gte":
142
+ matched = cx >= midline_x
143
+ elif constraint == "bbox_width":
144
+ bw = float(_block_width(block))
145
+ if operator == "lt":
146
+ matched = bw < value
147
+ elif operator == "lte":
148
+ matched = bw <= value
149
+ elif operator == "gt":
150
+ matched = bw > value
151
+ elif operator == "gte":
152
+ matched = bw >= value
153
+ elif constraint == "char_height_ratio":
154
+ mh = max(float(median_height), 1.0)
155
+ ratio = float(_block_height(block)) / mh
156
+ if operator == "lt":
157
+ matched = ratio < value
158
+ elif operator == "lte":
159
+ matched = ratio <= value
160
+ elif operator == "gt":
161
+ matched = ratio > value
162
+ elif operator == "gte":
163
+ matched = ratio >= value
164
+
165
+ if matched:
166
+ return True
167
+ return False
168
+
169
+
170
+ def block_passes_nickname_filters(
171
+ block: "TextBlock",
172
+ *,
173
+ rejection_rules: tuple[dict[str, Any], ...],
174
+ geometry_rules: tuple[dict[str, Any], ...],
175
+ group_display_name: str,
176
+ screen_width: int,
177
+ median_height: float,
178
+ screen_midline_ratio: float,
179
+ skip_patterns: tuple[str, ...] = (),
180
+ ) -> bool:
181
+ """Unified filter for Step B/C and declarative classify (True = may be nickname)."""
182
+ text = (block.text or "").strip()
183
+ if not text:
184
+ return False
185
+ text_rules = text_rejection_rules(rejection_rules)
186
+ if text_rules and run_rejection_rules_drop(text, text_rules):
187
+ return False
188
+ if group_display_name and text_matches_group_display_name(text, group_display_name):
189
+ return False
190
+ geo = geometry_rules_only(rejection_rules, geometry_rules)
191
+ if geo and run_geometry_rules_drop(
192
+ block,
193
+ geo,
194
+ screen_width=screen_width,
195
+ median_height=median_height,
196
+ screen_midline_ratio=screen_midline_ratio,
197
+ ):
198
+ return False
199
+ if skip_patterns:
200
+ for pat in skip_patterns:
201
+ try:
202
+ if re.search(pat, text):
203
+ return False
204
+ except re.error:
205
+ continue
206
+ return True
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenshot_vision_algorithm
3
- Version: 0.3.10
3
+ Version: 0.3.13
4
4
  Summary: Multi-platform screenshot vision algorithms: WeChat (card bbox, speaker bands, avatar detection, OCR helpers), XHS & Zhihu (planned)
5
5
  License: MIT
6
6
  Requires-Python: >=3.10
@@ -209,7 +209,7 @@ profile.templates_dir # Path — 内嵌模板 PNG 目录
209
209
 
210
210
  | 函数 | 说明 |
211
211
  |------|------|
212
- | `detect_left_avatar_column_layout(...)` | Hough 圆检测左侧头像列 |
212
+ | `detect_left_avatar_column_layout(...)` | 群聊左列:圆角方块主路径,左列 `<3` 时 Hough 兜底 |
213
213
  | `find_avatar_anchor_for_nickname_bbox(...)` | 为昵称 bbox 找到对应的发言人头像锚点 |
214
214
  | `nickname_bbox_in_avatar_column(...)` | 判断昵称 bbox 是否在头像列区域内 |
215
215
  | `left_avatar_ytops(layout)` | 提取所有头像的 Y 坐标列表 |
@@ -25,6 +25,7 @@ src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py
25
25
  src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py
26
26
  src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py
27
27
  src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py
28
+ src/screenshot_vision_algorithm/android/wechat/ocr/nickname_declarative_rules.py
28
29
  src/screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py
29
30
  src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py
30
31
  src/screenshot_vision_algorithm/android/wechat/profiles/android.py
@@ -43,4 +44,5 @@ src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_n
43
44
  src/screenshot_vision_algorithm/android/xhs/__init__.py
44
45
  src/screenshot_vision_algorithm/android/zhihu/__init__.py
45
46
  tests/test_card_bbox.py
47
+ tests/test_chat_left_avatar.py
46
48
  tests/test_package.py
@@ -0,0 +1,88 @@
1
+ """群聊左列头像:roundrect 主 + Hough 兜底 + 右列禁入。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+ import cv2
9
+ import pytest
10
+
11
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import (
12
+ CHAT_LEFT_AVATAR_HOUGH_FALLBACK_THRESHOLD,
13
+ detect_left_avatar_column_layout,
14
+ left_avatar_ytops,
15
+ )
16
+ from screenshot_vision_algorithm.android.wechat.algorithms.speaker_band import (
17
+ build_prd_speaker_vertical_bands,
18
+ compute_chat_content_vertical_bounds,
19
+ detect_chat_side_avatar_ytops,
20
+ merge_avatar_ytops_time_order,
21
+ )
22
+
23
+ _FIXTURE_ROOT = Path(
24
+ os.environ.get(
25
+ "SVA_CHAT_AVATAR_FIXTURE_ROOT",
26
+ r"E:\projects\housekeeping_ai_match\build\wx_match_sessions\wechat"
27
+ r"\ahxvcp3910405060\20260626\session_20260626093025_ahxvcp3910405060"
28
+ r"\customer_images",
29
+ )
30
+ )
31
+
32
+ _CASES = {
33
+ "scr_002": "002_chat_initial_ahxvcp3910405060_20260626093028_scaling.png",
34
+ "scr_054": "054_chat_resume_rescan_ahxvcp3910405060_20260626093325_scaling.png",
35
+ "scr_075": "075_chat_resume_rescan_ahxvcp3910405060_20260626093428_scaling.png",
36
+ }
37
+
38
+
39
+ def _load_case(name: str):
40
+ path = _FIXTURE_ROOT / _CASES[name]
41
+ if not path.is_file():
42
+ pytest.skip(f"fixture missing: {path}")
43
+ img = cv2.imread(str(path))
44
+ if img is None:
45
+ pytest.skip(f"unreadable fixture: {path}")
46
+ h, w = img.shape[:2]
47
+ sw = w / 1080.0
48
+ bounds = compute_chat_content_vertical_bounds(img, sw, screen_h=h)
49
+ return img, sw, bounds
50
+
51
+
52
+ @pytest.mark.parametrize("name,min_bands", [("scr_002", 3), ("scr_054", 3), ("scr_075", 3)])
53
+ def test_chat_left_avatar_roundrect_produces_multiple_bands(name: str, min_bands: int):
54
+ img, sw, bounds = _load_case(name)
55
+ layout = detect_left_avatar_column_layout(
56
+ img, sw, y_top=bounds.y_top, y_bottom_excl=bounds.y_bottom_excl,
57
+ )
58
+ assert len(layout.centroids) >= min_bands
59
+
60
+ ly, ry = detect_chat_side_avatar_ytops(img, sw, bounds)
61
+ assert ry == []
62
+ assert len(ly) >= min_bands
63
+
64
+ timeline = merge_avatar_ytops_time_order(ly, ry)
65
+ prd = build_prd_speaker_vertical_bands(timeline, bounds, scale_w=sw)
66
+ assert len(prd.speaker_bands) >= min_bands
67
+ assert prd.speaker_bands != ((bounds.y_top, bounds.y_bottom_excl),)
68
+
69
+
70
+ def test_scr_054_not_single_false_right_band():
71
+ img, sw, bounds = _load_case("scr_054")
72
+ ly, ry = detect_chat_side_avatar_ytops(img, sw, bounds)
73
+ assert ry == []
74
+ timeline = merge_avatar_ytops_time_order(ly, ry)
75
+ prd = build_prd_speaker_vertical_bands(timeline, bounds, scale_w=sw)
76
+ assert len(prd.speaker_bands) >= 3
77
+
78
+
79
+ def test_hough_fallback_threshold_constant():
80
+ assert CHAT_LEFT_AVATAR_HOUGH_FALLBACK_THRESHOLD == 3
81
+
82
+
83
+ def test_left_ytops_match_layout():
84
+ img, sw, bounds = _load_case("scr_002")
85
+ layout = detect_left_avatar_column_layout(
86
+ img, sw, y_top=bounds.y_top, y_bottom_excl=bounds.y_bottom_excl,
87
+ )
88
+ assert left_avatar_ytops(layout) == [c.y_top for c in layout.centroids]