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.
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/PKG-INFO +2 -2
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/README.md +1 -1
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/pyproject.toml +1 -1
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +126 -9
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +8 -5
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +79 -28
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +128 -9
- screenshot_vision_algorithm-0.3.13/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_declarative_rules.py +206 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +2 -2
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/SOURCES.txt +2 -0
- screenshot_vision_algorithm-0.3.13/tests/test_chat_left_avatar.py +88 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/setup.cfg +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/_config.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
- {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
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/logging.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/png_utils.py +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
- {screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/tests/test_card_bbox.py +0 -0
- {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.
|
|
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(...)` |
|
|
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(...)` |
|
|
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.
|
|
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
|
|
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
|
|
72
|
-
y_bottom_excl:
|
|
102
|
+
y_top: int,
|
|
103
|
+
y_bottom_excl: int,
|
|
73
104
|
) -> LeftAvatarColumnLayout:
|
|
74
|
-
"""
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
|
1815
|
+
def _avatar_roundrect_centroids_in_left_roi(
|
|
1816
1816
|
screen_bgr: np.ndarray,
|
|
1817
1817
|
scale_w: float,
|
|
1818
1818
|
*,
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
min_rows: int
|
|
1822
|
-
|
|
1823
|
-
|
|
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
|
|
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:
|
|
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(
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
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
|
-
|
|
1937
|
-
|
|
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
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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.
|
|
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(...)` |
|
|
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]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/tests/test_card_bbox.py
RENAMED
|
File without changes
|
{screenshot_vision_algorithm-0.3.10 → screenshot_vision_algorithm-0.3.13}/tests/test_package.py
RENAMED
|
File without changes
|