screenshot-vision-algorithm 0.3.2__tar.gz → 0.3.5__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.2 → screenshot_vision_algorithm-0.3.5}/PKG-INFO +1 -1
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/pyproject.toml +1 -1
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +1 -1
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +64 -83
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +110 -26
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +1 -1
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/README.md +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/setup.cfg +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/_config.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_back_chevron.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_emoji_smile.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_plus.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_voice.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_title_more_dots.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/favorite_label.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/new_messages_hint_suffix.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint_v2_textonly.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_note_header.png +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/logging.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/png_utils.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/SOURCES.txt +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/tests/test_card_bbox.py +0 -0
- {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/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.5
|
|
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
|
|
@@ -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.5"
|
|
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" }
|
|
@@ -218,7 +218,7 @@ def count_badges_per_row_roi(
|
|
|
218
218
|
|
|
219
219
|
del kw # reserved for forwards-compat
|
|
220
220
|
|
|
221
|
-
from
|
|
221
|
+
from screenshot_vision_algorithm.android.wechat.algorithms.template_matching import (
|
|
222
222
|
AVATAR_UNREAD_BADGE_LEFT_INSET_BASELINE,
|
|
223
223
|
AVATAR_UNREAD_BADGE_RIGHT_PAD_BASELINE,
|
|
224
224
|
CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE,
|
|
@@ -46,17 +46,6 @@ from typing import Iterable, Optional, Sequence
|
|
|
46
46
|
import cv2
|
|
47
47
|
import numpy as np
|
|
48
48
|
|
|
49
|
-
#: Distance from the top of the ``favorite_label.png`` hit to the top of the
|
|
50
|
-
#: enclosing note card, measured at 1080x2248 baseline. Used as an
|
|
51
|
-
#: **upper-bound heuristic** for the first hit only (subsequent hits derive
|
|
52
|
-
#: their top from the previous hit's bottom + ``FAVORITE_TAIL_OFFSET``).
|
|
53
|
-
FAVORITE_TO_CARD_TOP_OFFSET_BASELINE = 421
|
|
54
|
-
|
|
55
|
-
#: Gap from the bottom of the ``favorite_label.png`` hit to the card's
|
|
56
|
-
#: actual bottom edge, at 1080x2248 baseline. Used to extend the card bbox
|
|
57
|
-
#: past the label.
|
|
58
|
-
FAVORITE_TAIL_OFFSET_BASELINE = 60
|
|
59
|
-
|
|
60
49
|
#: ``favorite_label.png`` raster size @ 1080-wide baseline (`chat_profiles/README.md`).
|
|
61
50
|
FAVORITE_LABEL_TEMPLATE_W_BASELINE = 70
|
|
62
51
|
FAVORITE_LABEL_TEMPLATE_H_BASELINE = 60
|
|
@@ -166,7 +155,10 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
166
155
|
bgr_img: np.ndarray,
|
|
167
156
|
screen_w: int,
|
|
168
157
|
screen_h: int,
|
|
169
|
-
) -> tuple[list[ThumbnailCard], list[BubbleBbox]]:
|
|
158
|
+
) -> tuple[list[ThumbnailCard], list[BubbleBbox], bool]:
|
|
159
|
+
"""Returns ``(cards, bubbles, pipeline_active)``; ``pipeline_active`` is
|
|
160
|
+
``True`` when the pipeline found vlines / zones and actively decided which
|
|
161
|
+
anchors to keep or drop, ``False`` when it dropped out early (no texture)."""
|
|
170
162
|
"""PRD S6: vline-segment + hline-boundary card + bubble bbox detection.
|
|
171
163
|
|
|
172
164
|
Steps:
|
|
@@ -248,7 +240,7 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
248
240
|
vd_lines = [vx for vx in vd_lines if vx - AR >= min_card_x_gap]
|
|
249
241
|
|
|
250
242
|
if not vd_lines:
|
|
251
|
-
return [], []
|
|
243
|
+
return [], [], False
|
|
252
244
|
|
|
253
245
|
# Pick best vline: max total mid pixels + longest continuous segment
|
|
254
246
|
vline_scores: dict[int, float] = {}
|
|
@@ -274,7 +266,7 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
274
266
|
best_vx = vx
|
|
275
267
|
|
|
276
268
|
if best_vx is None:
|
|
277
|
-
return [], []
|
|
269
|
+
return [], [], False
|
|
278
270
|
|
|
279
271
|
# Step 3 — vline segments, computed per candidate vline. A fav-anchored
|
|
280
272
|
# card may sit on a different right-edge vline than the frame-global best
|
|
@@ -306,7 +298,7 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
306
298
|
)
|
|
307
299
|
zones_by_vx = {vx: _zones_for(vx) for vx in vline_order}
|
|
308
300
|
if not any(zones_by_vx.values()):
|
|
309
|
-
return [], []
|
|
301
|
+
return [], [], False
|
|
310
302
|
|
|
311
303
|
# Step 4 — card boundary hlines (span > 50% card_w, X-near vline,
|
|
312
304
|
# max gray gradient >= 30 = white→gray boundary)
|
|
@@ -405,6 +397,7 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
405
397
|
gap_tol = max(1, int(round(GAP_HLINE_TO_NEXT_ZONE_MAX_BASELINE * sw)))
|
|
406
398
|
cards: list[ThumbnailCard] = []
|
|
407
399
|
prev_bottom: int | None = None # anti-overlap: enforce card boundaries non-overlapping
|
|
400
|
+
any_anchor_seen: bool = False # True if ≥1 anchor entered zone-matching loop
|
|
408
401
|
for idx, hit in enumerate(ordered_hits):
|
|
409
402
|
fav_cy = hit.y + hit.h // 2
|
|
410
403
|
|
|
@@ -413,6 +406,7 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
413
406
|
zones = zones_by_vx[vx]
|
|
414
407
|
if not zones:
|
|
415
408
|
continue
|
|
409
|
+
any_anchor_seen = True # anchor entered vline loop with non-empty zones
|
|
416
410
|
card_hlines = hlines_by_vx[vx]
|
|
417
411
|
|
|
418
412
|
def _snap(y1_: int, y2_: int) -> tuple[int, int]:
|
|
@@ -553,7 +547,7 @@ def _compute_exact_card_and_bubble_bboxes(
|
|
|
553
547
|
continue
|
|
554
548
|
bubbles.append(BubbleBbox(top=by1, bottom=by2, left=AR, right=int(vx)))
|
|
555
549
|
|
|
556
|
-
return cards, bubbles
|
|
550
|
+
return cards, bubbles, any_anchor_seen
|
|
557
551
|
|
|
558
552
|
|
|
559
553
|
def _compute_exact_card_bboxes(
|
|
@@ -561,13 +555,22 @@ def _compute_exact_card_bboxes(
|
|
|
561
555
|
bgr_img: np.ndarray,
|
|
562
556
|
screen_w: int,
|
|
563
557
|
screen_h: int,
|
|
564
|
-
) -> list[ThumbnailCard]:
|
|
558
|
+
) -> tuple[list[ThumbnailCard], bool]:
|
|
565
559
|
"""Backward-compat wrapper — cards only (see
|
|
566
|
-
:func:`_compute_exact_card_and_bubble_bboxes`).
|
|
567
|
-
|
|
560
|
+
:func:`_compute_exact_card_and_bubble_bboxes`).
|
|
561
|
+
|
|
562
|
+
Returns:
|
|
563
|
+
(cards, pipeline_active):
|
|
564
|
+
- ``pipeline_active``: ``True`` when the pipeline found vlines
|
|
565
|
+
and zones (it *actively* decided to drop anchors without a
|
|
566
|
+
containing zone); ``False`` when the image had insufficient
|
|
567
|
+
texture (no vlines / no zones) and a legacy fallback is
|
|
568
|
+
appropriate.
|
|
569
|
+
"""
|
|
570
|
+
cards, _bubbles, pipeline_active = _compute_exact_card_and_bubble_bboxes(
|
|
568
571
|
ordered_hits, bgr_img, screen_w, screen_h,
|
|
569
572
|
)
|
|
570
|
-
return cards
|
|
573
|
+
return cards, pipeline_active
|
|
571
574
|
|
|
572
575
|
|
|
573
576
|
def derive_cards_and_bubbles(
|
|
@@ -591,7 +594,7 @@ def derive_cards_and_bubbles(
|
|
|
591
594
|
return [], []
|
|
592
595
|
ordered = sorted(hits, key=lambda h: h.y)
|
|
593
596
|
try:
|
|
594
|
-
cards, bubbles = _compute_exact_card_and_bubble_bboxes(
|
|
597
|
+
cards, bubbles, _pipeline_active = _compute_exact_card_and_bubble_bboxes(
|
|
595
598
|
ordered, chat_img, screen_w, screen_h,
|
|
596
599
|
)
|
|
597
600
|
except Exception:
|
|
@@ -622,15 +625,15 @@ def derive_cards(
|
|
|
622
625
|
(we sort by y ourselves).
|
|
623
626
|
screen_w / screen_h: the source screenshot's raw-image dimensions
|
|
624
627
|
(usually equal to ``device_info.screen_resolution``).
|
|
625
|
-
scale_w: ``screen_w / baseline_w`` (baseline = 1080).
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
628
|
+
scale_w: ``screen_w / baseline_w`` (baseline = 1080). Passed to
|
|
629
|
+
:func:`drop_top_clamped_false_positive_cards` for
|
|
630
|
+
false-positive filtering.
|
|
631
|
+
chat_img: required BGR image (as returned by ``cv2.imdecode``).
|
|
632
|
+
The vline-segment + hline-boundary variance-based
|
|
633
|
+
algorithm (PRD S6) is used for precise card bbox
|
|
634
|
+
derivation. Returns ``[]`` when ``chat_img`` is
|
|
635
|
+
``None`` or the pipeline encounters an unrecoverable
|
|
636
|
+
error.
|
|
634
637
|
|
|
635
638
|
Returns:
|
|
636
639
|
One :class:`ThumbnailCard` per hit, sorted top -> bottom. Empty list
|
|
@@ -642,61 +645,22 @@ def derive_cards(
|
|
|
642
645
|
|
|
643
646
|
ordered = sorted(hits, key=lambda h: h.y)
|
|
644
647
|
|
|
645
|
-
#
|
|
646
|
-
#
|
|
647
|
-
#
|
|
648
|
-
|
|
649
|
-
# pipeline produces no cards (e.g. variance texture insufficient),
|
|
650
|
-
# legacy fixed-offset serves as a reasonable fallback.
|
|
651
|
-
if chat_img is not None and len(ordered) > 0:
|
|
648
|
+
# vline-segment + hline-boundary algorithm (PRD S6). Pipeline
|
|
649
|
+
# result is final — anchors it dropped (no containing zone → "点对卡、
|
|
650
|
+
# 记错框" guard) must NOT be resurrected by fallback logic.
|
|
651
|
+
if chat_img is not None:
|
|
652
652
|
try:
|
|
653
|
-
exact_cards = _compute_exact_card_bboxes(
|
|
653
|
+
exact_cards, _pipeline_active = _compute_exact_card_bboxes(
|
|
654
654
|
ordered, chat_img, screen_w, screen_h,
|
|
655
655
|
)
|
|
656
656
|
except Exception:
|
|
657
|
-
|
|
657
|
+
return []
|
|
658
658
|
else:
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
)
|
|
663
|
-
# exact_cards empty → fall through to legacy
|
|
664
|
-
|
|
665
|
-
# Legacy: fixed-offset algorithm
|
|
666
|
-
top_offset = int(round(FAVORITE_TO_CARD_TOP_OFFSET_BASELINE * scale_w))
|
|
667
|
-
tail_offset = int(round(FAVORITE_TAIL_OFFSET_BASELINE * scale_w))
|
|
668
|
-
|
|
669
|
-
cards: list[ThumbnailCard] = []
|
|
670
|
-
for idx, hit in enumerate(ordered):
|
|
671
|
-
if idx == 0:
|
|
672
|
-
card_top = max(0, hit.y1 - top_offset)
|
|
673
|
-
else:
|
|
674
|
-
prev_bottom = ordered[idx - 1].y2 + tail_offset
|
|
675
|
-
card_top = max(prev_bottom + 1, 0)
|
|
676
|
-
|
|
677
|
-
card_bottom = min(screen_h - 1, hit.y2 + tail_offset)
|
|
678
|
-
|
|
679
|
-
if card_bottom <= card_top:
|
|
680
|
-
continue
|
|
681
|
-
|
|
682
|
-
click_x = screen_w // 2
|
|
683
|
-
click_y = min(screen_h - 1, max(0, hit.y + hit.h // 2))
|
|
684
|
-
midline = screen_w / 2
|
|
685
|
-
click_side = "left" if click_x < midline else "right"
|
|
686
|
-
|
|
687
|
-
cards.append(ThumbnailCard(
|
|
688
|
-
index=idx,
|
|
689
|
-
top=card_top,
|
|
690
|
-
bottom=card_bottom,
|
|
691
|
-
left=0,
|
|
692
|
-
right=screen_w - 1,
|
|
693
|
-
click_x=click_x,
|
|
694
|
-
click_y=click_y,
|
|
695
|
-
click_side=click_side,
|
|
696
|
-
favorite_hit=hit,
|
|
697
|
-
))
|
|
659
|
+
return drop_top_clamped_false_positive_cards(
|
|
660
|
+
exact_cards, scale_w=scale_w,
|
|
661
|
+
)
|
|
698
662
|
|
|
699
|
-
return
|
|
663
|
+
return []
|
|
700
664
|
|
|
701
665
|
|
|
702
666
|
def drop_top_clamped_false_positive_cards(
|
|
@@ -874,12 +838,32 @@ def _card_height(card: ThumbnailCard) -> int:
|
|
|
874
838
|
return card.bottom - card.top + 1
|
|
875
839
|
|
|
876
840
|
|
|
841
|
+
#: 卡片高度匹配容差(像素)。同一张卡片在相邻两帧中 bbox 高度可能差 1-2px
|
|
842
|
+
#: (back_to_chat 过渡动画导致列表微偏移),±5px 容差可覆盖此误差。
|
|
843
|
+
_CARD_HEIGHT_TOLERANCE: int = 5
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _heights_match(
|
|
847
|
+
a_heights: list[int], a_start: int,
|
|
848
|
+
b_heights: list[int], b_start: int,
|
|
849
|
+
length: int,
|
|
850
|
+
) -> bool:
|
|
851
|
+
"""Return True if a_heights[a_start:a_start+length] equals b_heights[b_start:b_start+length]
|
|
852
|
+
within ``_CARD_HEIGHT_TOLERANCE`` px per element."""
|
|
853
|
+
for k in range(length):
|
|
854
|
+
if abs(a_heights[a_start + k] - b_heights[b_start + k]) > _CARD_HEIGHT_TOLERANCE:
|
|
855
|
+
return False
|
|
856
|
+
return True
|
|
857
|
+
|
|
858
|
+
|
|
877
859
|
def _longest_subsequence_match(
|
|
878
860
|
a_heights: list[int],
|
|
879
861
|
b_heights: list[int],
|
|
880
862
|
) -> tuple[int, int, int] | None:
|
|
881
863
|
"""Find the longest contiguous substring of a_heights that appears in b_heights.
|
|
882
864
|
|
|
865
|
+
Matching uses ± ``_CARD_HEIGHT_TOLERANCE`` px per element (PRD §10 第二步 容差)。
|
|
866
|
+
|
|
883
867
|
Returns ``(a_start, b_start, length)`` or ``None``.
|
|
884
868
|
If multiple matches share the same maximum length, picks the one with
|
|
885
869
|
the smallest ``b_start`` (earliest in B).
|
|
@@ -894,9 +878,8 @@ def _longest_subsequence_match(
|
|
|
894
878
|
for length in range(max_len, 0, -1):
|
|
895
879
|
if best is not None and length < best[2]:
|
|
896
880
|
break
|
|
897
|
-
sub = a_heights[a_start:a_start + length]
|
|
898
881
|
for b_start in range(len(b_heights) - length + 1):
|
|
899
|
-
if b_heights
|
|
882
|
+
if _heights_match(a_heights, a_start, b_heights, b_start, length):
|
|
900
883
|
if (
|
|
901
884
|
best is None
|
|
902
885
|
or length > best[2]
|
|
@@ -971,8 +954,6 @@ def pick_next_unclicked_card(
|
|
|
971
954
|
__all__ = [
|
|
972
955
|
"FAVORITE_LABEL_TEMPLATE_W_BASELINE",
|
|
973
956
|
"FAVORITE_LABEL_TEMPLATE_H_BASELINE",
|
|
974
|
-
"FAVORITE_TO_CARD_TOP_OFFSET_BASELINE",
|
|
975
|
-
"FAVORITE_TAIL_OFFSET_BASELINE",
|
|
976
957
|
"FAV_LABEL_HLINE_EXCLUDE_PAD_BASELINE",
|
|
977
958
|
"GAP_HLINE_TO_NEXT_ZONE_MAX_BASELINE",
|
|
978
959
|
"PROCESSED_CARD_MATCH_MIN_OVERLAP_RATIO",
|
|
@@ -419,6 +419,43 @@ def detect_chat_title_more_dots(
|
|
|
419
419
|
return more
|
|
420
420
|
|
|
421
421
|
|
|
422
|
+
def detect_wechat_layer(
|
|
423
|
+
screen_bgr: np.ndarray,
|
|
424
|
+
scale_w: float,
|
|
425
|
+
) -> str | None:
|
|
426
|
+
"""4-layer classification — layout identical to the 162/162 3-device experiment.
|
|
427
|
+
|
|
428
|
+
Priority order (top to bottom):
|
|
429
|
+
|
|
430
|
+
* **L3**: ``detect_wechat_note_header`` hit
|
|
431
|
+
* **L2**: ``match_chat_back_chevron_core`` or ``match_chat_title_more_dots_core``
|
|
432
|
+
(no chrome / session-list-shell suppressor — the experiment used bare
|
|
433
|
+
``_core`` hits + ``_CONTEXT_SUPPRESS`` post-filtering, and the
|
|
434
|
+
note_header gate is already exercised by the L3-takes-priority
|
|
435
|
+
ordering)
|
|
436
|
+
* **L1**: ``is_wechat_main_conversation_list_chrome`` (without pinned-row
|
|
437
|
+
requirement) or ``detect_wechat_main_bottom_tab_bar_four_columns``
|
|
438
|
+
* **None**: no layer recognised
|
|
439
|
+
|
|
440
|
+
Callers that also need external foreground-package gating (L0)
|
|
441
|
+
are responsible for checking the foreground app before calling
|
|
442
|
+
this function.
|
|
443
|
+
"""
|
|
444
|
+
if detect_wechat_note_header(screen_bgr, scale_w) is not None:
|
|
445
|
+
return "L3"
|
|
446
|
+
if match_chat_back_chevron_core(screen_bgr, scale_w) is not None:
|
|
447
|
+
return "L2"
|
|
448
|
+
if match_chat_title_more_dots_core(screen_bgr, scale_w) is not None:
|
|
449
|
+
return "L2"
|
|
450
|
+
if is_wechat_main_conversation_list_chrome(
|
|
451
|
+
screen_bgr, scale_w, require_visible_pinned_row=False,
|
|
452
|
+
):
|
|
453
|
+
return "L1"
|
|
454
|
+
if detect_wechat_main_bottom_tab_bar_four_columns(screen_bgr, scale_w):
|
|
455
|
+
return "L1"
|
|
456
|
+
return None
|
|
457
|
+
|
|
458
|
+
|
|
422
459
|
def _chat_bottom_input_roi(screen_bgr: np.ndarray, scale_w: float) -> np.ndarray:
|
|
423
460
|
"""Bottom / keyboard-adjacent band for 话筒・表情・「+」(含输入框略抬高时的中区)。"""
|
|
424
461
|
h, w = screen_bgr.shape[:2]
|
|
@@ -851,6 +888,12 @@ CONVERSATION_FIRST_ROW_TOP_BASELINE = 280
|
|
|
851
888
|
#: Avatar column ends ~here @ 1080 baseline; nickname + preview middle band;
|
|
852
889
|
# mirrors :meth:`collector.driver.TopGroupScanDriver.scan_pinned_groups`.
|
|
853
890
|
CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE = 220
|
|
891
|
+
#: Horizontal inset left of ``CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE`` for
|
|
892
|
+
#: unread badge horizontal ROI (1080 baseline).
|
|
893
|
+
AVATAR_UNREAD_BADGE_LEFT_INSET_BASELINE = 90
|
|
894
|
+
#: Horizontal padding right of ``CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE`` for
|
|
895
|
+
#: unread badge horizontal ROI (1080 baseline).
|
|
896
|
+
AVATAR_UNREAD_BADGE_RIGHT_PAD_BASELINE = 36
|
|
854
897
|
CONVERSATION_RIGHT_SIDE_RESERVED_BASELINE = 220
|
|
855
898
|
|
|
856
899
|
#: Horizontal ROI (1080 baseline) for list-side avatar centroid detection — left
|
|
@@ -1256,6 +1299,9 @@ def detect_wechat_main_bottom_tab_bar_four_columns(
|
|
|
1256
1299
|
# Weaker tab may be 「我」单层笔画;最强一般为「通讯录」—— 拒绝单点极强、其余近空
|
|
1257
1300
|
if fmin < fmax * 0.20:
|
|
1258
1301
|
return False
|
|
1302
|
+
# 任一列墨水>0.8%即认可("最近"页面无"微信"栏会匹配)
|
|
1303
|
+
if fmax < 0.008:
|
|
1304
|
+
return False
|
|
1259
1305
|
return True
|
|
1260
1306
|
|
|
1261
1307
|
|
|
@@ -1455,23 +1501,17 @@ def reposition_anchor_bottom_wechat_tap_lane_plausible(
|
|
|
1455
1501
|
return True
|
|
1456
1502
|
|
|
1457
1503
|
|
|
1458
|
-
def _longest_true_run(mask: np.ndarray) -> int:
|
|
1459
|
-
"""最大连续 True 块的行数。"""
|
|
1460
|
-
if not mask.any():
|
|
1461
|
-
return 0
|
|
1462
|
-
padded = np.concatenate(([False], mask, [False]))
|
|
1463
|
-
edges = np.where(padded[:-1] != padded[1:])[0]
|
|
1464
|
-
if len(edges) == 0:
|
|
1465
|
-
return 0
|
|
1466
|
-
runs = edges[1::2] - edges[::2]
|
|
1467
|
-
return int(runs.max())
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
1504
|
def _recent_pull_top_heading_likely(
|
|
1471
1505
|
screen_bgr: np.ndarray,
|
|
1472
1506
|
scale_w: float,
|
|
1473
1507
|
) -> bool:
|
|
1474
|
-
"""顶区「最近」类标题:亮底深字 **或** 暗底浅字,竖向窄中带。
|
|
1508
|
+
"""顶区「最近」类标题:亮底深字 **或** 暗底浅字,竖向窄中带。
|
|
1509
|
+
|
|
1510
|
+
- 优先检测 dark-pixel 墨迹密度 + 竖向跨度 span
|
|
1511
|
+
- 再尝试 gray>96 亮像素(小米/华为 ROM 差异化兼容)
|
|
1512
|
+
- Honor/暗色主题下 ink_d 异常高,走 gray>150 亮字簇路径,
|
|
1513
|
+
用 **最大连续亮行 run** 排除散点噪声 vs 真标题集群。
|
|
1514
|
+
"""
|
|
1475
1515
|
h, w = screen_bgr.shape[:2]
|
|
1476
1516
|
sw = max(scale_w, 1e-6)
|
|
1477
1517
|
y1 = int(round(222 * sw))
|
|
@@ -1492,27 +1532,72 @@ def _recent_pull_top_heading_likely(
|
|
|
1492
1532
|
|
|
1493
1533
|
_, mask_dark = cv2.threshold(gray, 80, 255, cv2.THRESH_BINARY_INV)
|
|
1494
1534
|
ink_d = float(cv2.countNonZero(mask_dark)) / area
|
|
1535
|
+
|
|
1536
|
+
# 路径 1:暗墨标题(D1/D2 浅色主题首选)
|
|
1495
1537
|
if 0.014 <= ink_d <= 0.48:
|
|
1496
1538
|
row_dark = (mask_dark.sum(axis=1) // 255).astype(np.int32)
|
|
1497
1539
|
has_row = row_dark > 9
|
|
1498
1540
|
if has_row.any():
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
if
|
|
1541
|
+
idx = np.where(has_row)[0]
|
|
1542
|
+
span_rows = int(idx.max()) - int(idx.min()) + 1
|
|
1543
|
+
if span_rows <= int(round(70 * sw)):
|
|
1502
1544
|
return True
|
|
1503
1545
|
|
|
1546
|
+
# 路径 2:暗色主题下 ink_d 异常高 → 走亮字簇(D3 Honor 暗色主题)
|
|
1547
|
+
if ink_d > 0.48:
|
|
1548
|
+
bright = (gray > 150).astype(np.uint8)
|
|
1549
|
+
ink_b = float(np.sum(bright)) / area
|
|
1550
|
+
if 0.004 <= ink_b <= 0.10:
|
|
1551
|
+
row_counts = bright.sum(axis=1).astype(np.int32)
|
|
1552
|
+
max_run = 0
|
|
1553
|
+
cur_run = 0
|
|
1554
|
+
for rc in row_counts:
|
|
1555
|
+
if rc > 3:
|
|
1556
|
+
cur_run += 1
|
|
1557
|
+
if cur_run > max_run:
|
|
1558
|
+
max_run = cur_run
|
|
1559
|
+
else:
|
|
1560
|
+
cur_run = 0
|
|
1561
|
+
if 8 <= max_run <= int(round(80 * sw)):
|
|
1562
|
+
return True
|
|
1563
|
+
|
|
1564
|
+
# 路径 3:不依赖字体颜色的通用亮底(小米 / 华为 ROM 兼容)
|
|
1504
1565
|
light = (gray > 96).astype(np.uint8)
|
|
1505
1566
|
ink_l = float(np.sum(light)) / area
|
|
1506
1567
|
if 0.006 <= ink_l <= 0.32:
|
|
1507
1568
|
row_lit = light.sum(axis=1).astype(np.int32)
|
|
1508
1569
|
has_row = row_lit > 5
|
|
1509
1570
|
if has_row.any():
|
|
1510
|
-
|
|
1511
|
-
|
|
1571
|
+
idx = np.where(has_row)[0]
|
|
1572
|
+
span_rows = int(idx.max()) - int(idx.min()) + 1
|
|
1573
|
+
if span_rows <= int(round(72 * sw)):
|
|
1512
1574
|
return True
|
|
1513
1575
|
return False
|
|
1514
1576
|
|
|
1515
1577
|
|
|
1578
|
+
def is_wechat_recent_page_chrome(
|
|
1579
|
+
screen_bgr: np.ndarray,
|
|
1580
|
+
scale_w: float,
|
|
1581
|
+
) -> bool:
|
|
1582
|
+
"""Detect WeChat 「最近」小程序抽屉页。
|
|
1583
|
+
|
|
1584
|
+
Two conditions must BOTH hold:
|
|
1585
|
+
1. Top mid-band contains a compact heading-like dark/bright ink cluster
|
|
1586
|
+
(:func:`_recent_pull_top_heading_likely`).
|
|
1587
|
+
2. The WeChat 4-tab bottom bar is **absent** — the 「最近」drawer
|
|
1588
|
+
replaces it with a dark/empty strip.
|
|
1589
|
+
|
|
1590
|
+
Condition (2) prevents false positives on the normal conversation list
|
|
1591
|
+
where group names and message previews can produce heading-like ink
|
|
1592
|
+
densities in the mid-band.
|
|
1593
|
+
"""
|
|
1594
|
+
if not _recent_pull_top_heading_likely(screen_bgr, scale_w):
|
|
1595
|
+
return False
|
|
1596
|
+
if detect_wechat_main_bottom_tab_bar_four_columns(screen_bgr, scale_w):
|
|
1597
|
+
return False
|
|
1598
|
+
return True
|
|
1599
|
+
|
|
1600
|
+
|
|
1516
1601
|
def is_wechat_recent_pull_anchor_early_stop_frame(
|
|
1517
1602
|
screen_bgr: np.ndarray,
|
|
1518
1603
|
scale_w: float,
|
|
@@ -1520,15 +1605,12 @@ def is_wechat_recent_pull_anchor_early_stop_frame(
|
|
|
1520
1605
|
tab_x: int,
|
|
1521
1606
|
tab_y: int,
|
|
1522
1607
|
) -> bool:
|
|
1523
|
-
"""§步骤1(2)
|
|
1608
|
+
"""§步骤1(2) 早停:heading 命中 + 底栏四列缺失。
|
|
1524
1609
|
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
):
|
|
1530
|
-
return False
|
|
1531
|
-
return _recent_pull_top_heading_likely(screen_bgr, scale_w)
|
|
1610
|
+
通过 :func:`is_wechat_recent_page_chrome` 双条件把关 —
|
|
1611
|
+
heading 启发式 + 四列缺失保证不会在普通会话列表上误触发。
|
|
1612
|
+
``tab_x`` / ``tab_y`` 保留为兼容参数,内部不参与判断。"""
|
|
1613
|
+
return is_wechat_recent_page_chrome(screen_bgr, scale_w)
|
|
1532
1614
|
|
|
1533
1615
|
|
|
1534
1616
|
def split_conversation_rows(
|
|
@@ -2133,6 +2215,8 @@ __all__ = [
|
|
|
2133
2215
|
"LIST_AVATAR_BADGE_ORANGE_AUX_VAL_MIN",
|
|
2134
2216
|
"CONVERSATION_ROW_HEIGHT_BASELINE",
|
|
2135
2217
|
"CONVERSATION_AVATAR_RIGHT_EDGE_BASELINE",
|
|
2218
|
+
"AVATAR_UNREAD_BADGE_LEFT_INSET_BASELINE",
|
|
2219
|
+
"AVATAR_UNREAD_BADGE_RIGHT_PAD_BASELINE",
|
|
2136
2220
|
"CONVERSATION_LIST_AVATAR_ROI_LEFT_BASELINE",
|
|
2137
2221
|
"CONVERSATION_LIST_AVATAR_ROI_RIGHT_BASELINE",
|
|
2138
2222
|
"CONVERSATION_LIST_AVATAR_MEDIAN_X_HALF_WIDTH_BASELINE",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: screenshot_vision_algorithm
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.5
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/tests/test_card_bbox.py
RENAMED
|
File without changes
|
{screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/tests/test_package.py
RENAMED
|
File without changes
|