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.
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/PKG-INFO +2 -2
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/README.md +1 -1
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/pyproject.toml +1 -1
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +126 -9
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +10 -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
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +180 -13
- screenshot_vision_algorithm-0.3.13/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_declarative_rules.py +206 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +2 -2
- {screenshot_vision_algorithm-0.3.8 → 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.8 → screenshot_vision_algorithm-0.3.13}/setup.cfg +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/_config.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
- {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
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/logging.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm/png_utils.py +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
- {screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/tests/test_card_bbox.py +0 -0
- {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.
|
|
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
|
|
|
@@ -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(
|
|
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
|
|
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
|
|
@@ -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(
|
|
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 =
|
|
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
|
-
# 超过阈值(
|
|
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 =
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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.
|
|
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.8 → screenshot_vision_algorithm-0.3.13}/tests/test_card_bbox.py
RENAMED
|
File without changes
|
{screenshot_vision_algorithm-0.3.8 → screenshot_vision_algorithm-0.3.13}/tests/test_package.py
RENAMED
|
File without changes
|