screenshot-vision-algorithm 0.3.8__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.8 → screenshot_vision_algorithm-0.3.13}/PKG-INFO +2 -2
  2. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/README.md +1 -1
  3. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/pyproject.toml +1 -1
  4. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +126 -9
  5. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +10 -6
  6. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +79 -28
  7. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +180 -13
  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.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +2 -2
  10. {screenshot_vision_algorithm-0.3.8 → 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.8 → screenshot_vision_algorithm-0.3.13}/setup.cfg +0 -0
  13. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/__init__.py +0 -0
  14. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/_config.py +0 -0
  15. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
  16. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
  17. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
  18. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +0 -0
  19. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +0 -0
  20. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
  21. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
  22. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
  23. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
  24. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
  25. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
  26. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
  27. {screenshot_vision_algorithm-0.3.8 → 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.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
  29. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
  30. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
  31. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
  32. {screenshot_vision_algorithm-0.3.8 → 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.8 → 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.8 → 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.8 → 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.8 → 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.8 → 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.8 → 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.8 → 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.8 → 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.8 → 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.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
  43. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
  44. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/logging.py +0 -0
  45. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/png_utils.py +0 -0
  46. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
  47. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
  48. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
  49. {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/tests/test_card_bbox.py +0 -0
  50. {screenshot_vision_algorithm-0.3.8 → 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.8
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.8"
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
 
@@ -221,6 +224,7 @@ def build_prd_speaker_vertical_bands(
221
224
  scale_w: float,
222
225
  first_avatar_gap_reject_baseline: int = CHAT_BAND_FIRST_AVATAR_TOP_GAP_REJECT_BASELINE,
223
226
  first_band_top_extend_baseline: int = CHAT_FIRST_BAND_TOP_EXTEND_BASELINE,
227
+ avatar_merge_min_sep_baseline: int = 24,
224
228
  ) -> PrdChatVerticalLayout:
225
229
  """由时间序头像顶构造 PRD §5 顶隙 orphan + 左闭右开发言人筒。
226
230
 
@@ -248,7 +252,7 @@ def build_prd_speaker_vertical_bands(
248
252
 
249
253
  # 时间轴序去重:过近视为同一人头像重复检出
250
254
  merged_starts: list[int] = []
251
- min_sep = int(round(24 * sw))
255
+ min_sep = int(round(int(avatar_merge_min_sep_baseline) * sw))
252
256
  for e in timeline:
253
257
  y = int(e.y_top)
254
258
  if not merged_starts or y - merged_starts[-1] >= min_sep:
@@ -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
@@ -230,6 +230,44 @@ class NicknameOcrConfig:
230
230
  """Prefix / substring tags that exclude a block from nickname
231
231
  consideration even if all geometric criteria pass."""
232
232
 
233
+ # ── 以下字段可由 rules_config.yaml 覆盖(housekeeping 胶水层读 yaml → 灌参)──
234
+
235
+ skip_block_patterns: tuple[str, ...] = ()
236
+ """附加正则列表,逐条编译后传递给 ``_should_skip_block``。
237
+ 内置正则(时间戳/纯数字/系统提示等)**始终并行生效**,
238
+ config 提供的是追加规则,不替代内置逻辑。"""
239
+
240
+ inside_y_tolerance_px: float = 4.0
241
+ """``_completely_inside`` 的 Y 方向容差(px @ 1080 基准 × scale_w)。
242
+ 默认 4.0 与 ``card_binding.inside_y_tolerance_px`` 对齐。"""
243
+
244
+ cross_band_distance_threshold_px: float = 50.0
245
+ """Step C 跨 band 距离门控基线(px @ 1080 × scale_w)。
246
+ 文字底部到卡片所属 speaker_band 顶部超过此值则不作为昵称。"""
247
+
248
+ # ── speaker_band 参数(同名传给 build_prd_speaker_vertical_bands)──
249
+
250
+ first_avatar_gap_reject_baseline: int = 30
251
+ """orphan 间隙阈值(px @ 1080)。"""
252
+
253
+ first_band_top_extend_baseline: int = 20
254
+ """首个 band 上移量(px @ 1080)。"""
255
+
256
+ avatar_merge_min_sep_px: int = 24
257
+ """头像合并最小间距(px @ 1080 × scale_w)。"""
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
+
233
271
 
234
272
  _ATTRIBUTION_PREFIX_DROPS: tuple[str, ...] = (
235
273
  "撤回了一条消息",
@@ -710,7 +748,12 @@ def _load_prd_layout_optional(
710
748
  bounds = compute_chat_content_vertical_bounds(arr, sw, screen_h=oh)
711
749
  ly, ry = detect_chat_side_avatar_ytops(arr, sw, bounds)
712
750
  timeline = merge_avatar_ytops_time_order(ly, ry)
713
- return build_prd_speaker_vertical_bands(timeline, bounds, scale_w=sw)
751
+ return build_prd_speaker_vertical_bands(
752
+ timeline, bounds, scale_w=sw,
753
+ first_avatar_gap_reject_baseline=config.first_avatar_gap_reject_baseline,
754
+ first_band_top_extend_baseline=config.first_band_top_extend_baseline,
755
+ avatar_merge_min_sep_baseline=config.avatar_merge_min_sep_px,
756
+ )
714
757
 
715
758
 
716
759
  def _finalize_prd_attribution(
@@ -852,6 +895,7 @@ def _resume_thumb_bindings_and_orphans(
852
895
  original_width: int,
853
896
  config: NicknameOcrConfig,
854
897
  layout_opt: Any = None,
898
+ avatar_layout: Any = None,
855
899
  scale_w: float = 1.0,
856
900
  image_size: tuple[int, int] | None = None,
857
901
  ) -> tuple[tuple[ResumeThumbBinding, ...], int, set[int]]:
@@ -887,7 +931,7 @@ def _resume_thumb_bindings_and_orphans(
887
931
  # OCR bbox 与卡片 bbox 各有 ±2~3px 抖动:卡内标题行可能比卡顶高出 1~3px,
888
932
  # 若按严格包含判定会被误认为"卡外文字"而误绑为昵称(2026-06-11 真实案例)。
889
933
  # 真昵称行整体位于卡片上方(y2 < 卡顶),不受此容差影响。
890
- _inside_tol = 4.0 * max(scale_w, 1.0)
934
+ _inside_tol = float(config.inside_y_tolerance_px) * max(scale_w, 1.0)
891
935
 
892
936
  def _completely_inside(block_bbox: tuple, card_bbox: tuple) -> bool:
893
937
  bx1, by1, bx2, by2 = block_bbox
@@ -902,6 +946,26 @@ def _resume_thumb_bindings_and_orphans(
902
946
  and by2 <= cy2 + _inside_tol
903
947
  )
904
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
+
905
969
  def _should_skip_block(b: "TextBlock") -> bool:
906
970
  t = (b.text or "").strip()
907
971
  if not t:
@@ -917,8 +981,25 @@ def _resume_thumb_bindings_and_orphans(
917
981
  return True
918
982
  if _is_favorite_card_label(t):
919
983
  return True
984
+ # ── 附加正则(来自 rules_config.yaml skip_patterns,编译后缓存)──
985
+ if _compiled_skip_patterns:
986
+ for _pat in _compiled_skip_patterns:
987
+ try:
988
+ if _pat.search(t):
989
+ return True
990
+ except Exception:
991
+ continue
920
992
  return False
921
993
 
994
+ # ── 编译 config 追加的正则(函数内缓存,避免每次 block 重新编译)──
995
+ _compiled_skip_patterns: list[re.Pattern] = []
996
+ if config.skip_block_patterns:
997
+ for p in config.skip_block_patterns:
998
+ try:
999
+ _compiled_skip_patterns.append(re.compile(p))
1000
+ except re.error:
1001
+ pass
1002
+
922
1003
  bindings: list[ResumeThumbBinding] = []
923
1004
  orphan = 0
924
1005
  excluded_indices: set[int] = set()
@@ -1008,10 +1089,15 @@ def _resume_thumb_bindings_and_orphans(
1008
1089
 
1009
1090
  matched_nickname: Optional[str] = None
1010
1091
  best_y: float = float('inf')
1092
+ _band_avatar_cx_r = _avatar_cx_r_for_band(_band_y0, _band_y1)
1011
1093
  for i, b in enumerate(blocks):
1012
1094
  bb = b.bbox_xyxy
1013
1095
  _by1, _by2 = float(bb[1]), float(bb[3])
1014
1096
 
1097
+ # Step B 仅采纳 _classify 标为 nickname_candidate 的块(含 rejection_rules)
1098
+ if i < len(classes) and classes[i] != "nickname_candidate":
1099
+ continue
1100
+
1015
1101
  # ── 规则 (0):block 必须与当前 speaker_band 有 Y 交集 ──
1016
1102
  if not (_by2 > _band_y0 and _by1 < _band_y1):
1017
1103
  continue
@@ -1046,6 +1132,12 @@ def _resume_thumb_bindings_and_orphans(
1046
1132
  if is_fully_inside:
1047
1133
  continue
1048
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
+
1049
1141
  # ── 按距离选最优:取 y_bottom 最接近 band 上界的块 ──
1050
1142
  if _by2 < best_y:
1051
1143
  best_y = _by2
@@ -1056,26 +1148,38 @@ def _resume_thumb_bindings_and_orphans(
1056
1148
  best_i: Optional[int] = None
1057
1149
  best_bottom: float = -1.0
1058
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)
1059
1157
  for i, b in enumerate(blocks):
1060
1158
  bcx = float(b.bbox_xyxy[3]) # block bottom
1061
1159
  if bcx >= card_top:
1062
1160
  continue
1161
+ if i < len(classes) and classes[i] != "nickname_candidate":
1162
+ continue
1063
1163
  if _should_skip_block(b):
1064
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
1065
1169
  if bcx > best_bottom:
1066
1170
  best_bottom = bcx
1067
1171
  best_i = i
1068
1172
 
1069
1173
  if best_i is not None:
1070
1174
  # PRD §B.1 C:距离检查——文字底部到卡片所属 speaker_band 顶部
1071
- # 超过阈值(50px baseline × scale_w)则不作为昵称
1175
+ # 超过阈值(config.cross_band_distance_threshold_px × scale_w)则不作为昵称
1072
1176
  _use_as_nickname = True
1073
1177
  if band_idx is not None and band_idx >= 0:
1074
1178
  _sb_list = getattr(layout_opt, "speaker_bands", ())
1075
1179
  if band_idx < len(_sb_list):
1076
1180
  _band_y0 = float(_sb_list[band_idx][0])
1077
1181
  _gap = _band_y0 - best_bottom # positive → block above band
1078
- _threshold = 50.0 * float(scale_w)
1182
+ _threshold = float(config.cross_band_distance_threshold_px) * float(scale_w)
1079
1183
  if _gap > _threshold:
1080
1184
  _use_as_nickname = False
1081
1185
  if _use_as_nickname:
@@ -1257,34 +1361,95 @@ def _classify(
1257
1361
  avatar_layout: Any = None,
1258
1362
  ) -> str:
1259
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
+
1260
1372
  text = block.text.strip()
1261
1373
  if not text:
1262
1374
  return "drop"
1263
1375
  if block.confidence < config.min_confidence:
1264
1376
  return "drop"
1265
- if (
1266
- _is_pure_time(text)
1267
- or _is_pure_digits(text)
1268
- or _is_chat_timestamp_or_divider(text)
1269
- or _is_favorite_card_label(text)
1270
- ):
1271
- 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
1272
1415
 
1273
1416
  is_left = _is_on_left_half(block, original_width, config.screen_midline_ratio)
1274
1417
 
1418
+ if _starts_with_system_prefix(text, config.system_prefix_drops):
1419
+ return "bubble_text"
1420
+
1275
1421
  if (
1276
1422
  len(text) <= config.nickname_max_chars
1277
- and not _starts_with_system_prefix(text, config.system_prefix_drops)
1278
1423
  and is_left
1279
1424
  and _is_avatar_column_nickname_row(
1280
1425
  block, original_width, config, avatar_layout
1281
1426
  )
1282
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"
1283
1438
  return "nickname_candidate"
1284
1439
 
1285
1440
  return "bubble_text"
1286
1441
 
1287
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
+
1288
1453
  def _merge_two_text_blocks(a: TextBlock, b: TextBlock, *, joiner: str) -> TextBlock:
1289
1454
  ax1, ay1, ax2, ay2 = a.bbox_xyxy
1290
1455
  bx1, by1, bx2, by2 = b.bbox_xyxy
@@ -1712,6 +1877,7 @@ def extract_nicknames(
1712
1877
  original_width=original_width,
1713
1878
  config=cfg,
1714
1879
  layout_opt=layout_opt,
1880
+ avatar_layout=avatar_layout,
1715
1881
  scale_w=scale_w,
1716
1882
  image_size=ocr_result.original_size,
1717
1883
  )
@@ -1836,6 +2002,7 @@ def extract_nicknames(
1836
2002
  original_width=original_width,
1837
2003
  config=cfg,
1838
2004
  layout_opt=layout_opt,
2005
+ avatar_layout=avatar_layout,
1839
2006
  scale_w=scale_w,
1840
2007
  image_size=ocr_result.original_size,
1841
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.8
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]