screenshot-vision-algorithm 0.3.2__tar.gz → 0.3.8__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/PKG-INFO +1 -1
  2. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/pyproject.toml +1 -1
  3. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +1 -1
  4. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +64 -83
  5. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +10 -3
  6. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +110 -26
  7. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +1 -1
  8. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +26 -11
  9. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +1 -1
  10. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/README.md +0 -0
  11. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/setup.cfg +0 -0
  12. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/__init__.py +0 -0
  13. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/_config.py +0 -0
  14. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
  15. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
  16. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
  17. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +0 -0
  18. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
  19. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
  20. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
  21. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
  22. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
  23. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
  24. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py +0 -0
  25. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
  26. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
  27. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
  28. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
  29. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_back_chevron.png +0 -0
  30. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_emoji_smile.png +0 -0
  31. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_plus.png +0 -0
  32. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_voice.png +0 -0
  33. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_title_more_dots.png +0 -0
  34. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/favorite_label.png +0 -0
  35. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/new_messages_hint_suffix.png +0 -0
  36. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint.png +0 -0
  37. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint_v2_textonly.png +0 -0
  38. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_note_header.png +0 -0
  39. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
  40. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
  41. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/logging.py +0 -0
  42. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm/png_utils.py +0 -0
  43. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm.egg-info/SOURCES.txt +0 -0
  44. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
  45. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
  46. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
  47. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/tests/test_card_bbox.py +0 -0
  48. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.8}/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.2
3
+ Version: 0.3.8
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.2"
7
+ version = "0.3.8"
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 collector.driver import (
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
- cards, _bubbles = _compute_exact_card_and_bubble_bboxes(
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). Used to scale
626
- ``FAVORITE_TO_CARD_TOP_OFFSET`` + ``FAVORITE_TAIL_OFFSET``
627
- so the logic survives across the 720 / 1080 / 1220 device
628
- whitelist.
629
- chat_img: optional BGR image (as returned by ``cv2.imdecode``).
630
- When provided, uses the vline-segment + hline-boundary
631
- variance-based algorithm (PRD S6) for precise card bbox
632
- derivation. Falls back to legacy fixed-offset logic
633
- when ``chat_img`` is None.
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
- # New algorithm: vline-segment + hline-boundary (PRD S6). When the
646
- # pipeline ran successfully with non-empty cards its verdict is final
647
- # an anchor it dropped (no containing zone "点对卡、记错框" guard)
648
- # must NOT be resurrected by the legacy fixed-offset fallback. When the
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
- pass
657
+ return []
658
658
  else:
659
- if exact_cards:
660
- return drop_top_clamped_false_positive_cards(
661
- exact_cards, scale_w=scale_w,
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 drop_top_clamped_false_positive_cards(cards, scale_w=scale_w)
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[b_start:b_start + length] == sub:
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",
@@ -26,7 +26,7 @@ from screenshot_vision_algorithm.android.wechat.algorithms.template_matching imp
26
26
  CHAT_BAND_FIRST_AVATAR_TOP_GAP_REJECT_BASELINE = 30
27
27
 
28
28
  #: 首个发言人筒上移量(一个头像高度,防止几何误差),见 PRD §5 补充2。
29
- CHAT_FIRST_BAND_TOP_EXTEND_BASELINE = 100
29
+ CHAT_FIRST_BAND_TOP_EXTEND_BASELINE = 20
30
30
 
31
31
  #: 底部输入区预留(与主列表 scanner 一脉,baseline)。
32
32
  CHAT_COMPOSER_RESERVE_BOTTOM_BASELINE = 160
@@ -173,7 +173,7 @@ def detect_chat_side_avatar_ytops(
173
173
  y1 = max(0, bounds.y_top)
174
174
  y2 = min(h, bounds.y_bottom_excl)
175
175
  try:
176
- from processor.left_avatar_column import (
176
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import (
177
177
  detect_left_avatar_column_layout,
178
178
  left_avatar_ytops,
179
179
  )
@@ -254,7 +254,7 @@ def build_prd_speaker_vertical_bands(
254
254
  if not merged_starts or y - merged_starts[-1] >= min_sep:
255
255
  merged_starts.append(y)
256
256
 
257
- # 首个筒上界上移(PRD §5 补充2):上移 100px @1080,防几何误差与系统变化。
257
+ # 首个筒上界上移(PRD §5 补充2):上移 20px @1080,防几何误差与系统变化。
258
258
  first_band_extend = int(round(int(first_band_top_extend_baseline) * sw))
259
259
 
260
260
  bands: list[tuple[int, int]] = []
@@ -275,6 +275,13 @@ def build_prd_speaker_vertical_bands(
275
275
  elif first_band_start < orphan_top[1]:
276
276
  orphan_top = (orphan_top[0], first_band_start)
277
277
 
278
+ # 无 orphan 时,首个 band 上界对齐 y_ct,保证纵向连续全覆盖
279
+ # (extend 较小且头像紧贴 y_ct 时可能产生小间隙)
280
+ if orphan_top is None and bands:
281
+ first_y0, first_y1 = bands[0]
282
+ if first_y0 > y_ct:
283
+ bands[0] = (y_ct, first_y1)
284
+
278
285
  return PrdChatVerticalLayout(
279
286
  content_bounds=bounds,
280
287
  orphan_top_band=orphan_top,
@@ -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
- # 最大连续块跨度:低分辨率下散点不会拉长 span(2026-06-13)
1500
- block_span = _longest_true_run(has_row)
1501
- if block_span <= int(round(70 * sw)):
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
- block_span = _longest_true_run(has_row)
1511
- if block_span <= int(round(72 * sw)):
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) 早停:顶区「最近」类标题 **且** 与点击同标的底栏邻域 ``(tab_x,tab_y)`` 可点条带。
1608
+ """§步骤1(2) 早停:heading 命中 + 底栏四列缺失。
1524
1609
 
1525
- 底栏侧与归位 ``adb.tap(tab_x, tab_y)`` 共用 :func:`reposition_anchor_bottom_wechat_tap_lane_plausible`,
1526
- **不**再单独用四 Tab / 胶囊中区视检。"""
1527
- if not reposition_anchor_bottom_wechat_tap_lane_plausible(
1528
- screen_bgr, scale_w, tab_x=tab_x, tab_y=tab_y,
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",
@@ -19,7 +19,7 @@ except ImportError:
19
19
  cv2 = None # type: ignore[assignment]
20
20
 
21
21
  try:
22
- from processor.left_avatar_column import AvatarCentroid
22
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import AvatarCentroid
23
23
  except ImportError:
24
24
  AvatarCentroid = None # type: ignore[misc, assignment]
25
25
 
@@ -351,6 +351,9 @@ _PURE_TIME_RE = re.compile(r"^\d{1,2}:\d{2}(:\d{2})?$")
351
351
  _WEEKDAY_TIME_RE = re.compile(
352
352
  r"^周[一二三四五六日天].*?(?:上午|下午|晚上|凌晨)?\s*\d{0,2}[::]?\d{0,2}"
353
353
  )
354
+ _YESTERDAY_TODAY_TIME_RE = re.compile(
355
+ r"^(?:昨天|今天)\s*\d{0,2}:?\d{0,2}$"
356
+ )
354
357
  _MOBILE_PHONE_SUFFIX_RE = re.compile(r"1\d{10}\s*$")
355
358
 
356
359
 
@@ -388,6 +391,8 @@ def _is_chat_timestamp_or_divider(text: str) -> bool:
388
391
  return True
389
392
  if _WEEKDAY_TIME_RE.match(t):
390
393
  return True
394
+ if _YESTERDAY_TODAY_TIME_RE.match(t):
395
+ return True
391
396
  return False
392
397
 
393
398
 
@@ -411,7 +416,7 @@ def _is_avatar_column_nickname_row(
411
416
  avatar_layout: Any = None,
412
417
  ) -> bool:
413
418
  try:
414
- from processor.left_avatar_column import nickname_bbox_in_avatar_column
419
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import nickname_bbox_in_avatar_column
415
420
  except ImportError:
416
421
  nickname_bbox_in_avatar_column = None # type: ignore[assignment,misc]
417
422
  if avatar_layout is not None and nickname_bbox_in_avatar_column is not None:
@@ -446,7 +451,7 @@ def _avatar_anchor_for_block(
446
451
  if avatar_layout is None or getattr(avatar_layout, "empty", True):
447
452
  return None
448
453
  try:
449
- from processor.left_avatar_column import find_avatar_anchor_for_nickname_bbox
454
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import find_avatar_anchor_for_nickname_bbox
450
455
  except ImportError:
451
456
  return None
452
457
  return find_avatar_anchor_for_nickname_bbox(
@@ -463,9 +468,9 @@ def _load_left_avatar_layout(
463
468
  if png_path is None:
464
469
  return None
465
470
  try:
466
- from processor.chat_bubble_geometry_prd import compute_chat_content_vertical_bounds
467
- from processor.left_avatar_column import detect_left_avatar_column_layout
468
- from processor.nickname_avatar_guard import load_png_bgr
471
+ from screenshot_vision_algorithm.android.wechat.algorithms.speaker_band import compute_chat_content_vertical_bounds
472
+ from screenshot_vision_algorithm.android.wechat.algorithms.avatar_column import detect_left_avatar_column_layout
473
+ from screenshot_vision_algorithm.android.wechat.ocr.avatar_guard import load_png_bgr
469
474
  except ImportError:
470
475
  return None
471
476
  arr = load_png_bgr(str(png_path))
@@ -595,7 +600,7 @@ def _nickname_passes_avatar_guard(
595
600
  if bgr_guard is None:
596
601
  return not config.require_avatar_guard_for_nickname
597
602
  try:
598
- from processor.nickname_avatar_guard import avatar_roi_pass
603
+ from screenshot_vision_algorithm.android.wechat.ocr.avatar_guard import avatar_roi_pass
599
604
  except ImportError:
600
605
  return not config.require_avatar_guard_for_nickname
601
606
 
@@ -687,13 +692,13 @@ def _load_prd_layout_optional(
687
692
  if png_path is None or not config.use_prd_chat_vertical_bands:
688
693
  return None
689
694
  try:
690
- from processor.chat_bubble_geometry_prd import (
695
+ from screenshot_vision_algorithm.android.wechat.algorithms.speaker_band import (
691
696
  build_prd_speaker_vertical_bands,
692
697
  compute_chat_content_vertical_bounds,
693
698
  detect_chat_side_avatar_ytops,
694
699
  merge_avatar_ytops_time_order,
695
700
  )
696
- from processor.nickname_avatar_guard import load_png_bgr
701
+ from screenshot_vision_algorithm.android.wechat.ocr.avatar_guard import load_png_bgr
697
702
  except ImportError:
698
703
  return None
699
704
  arr = load_png_bgr(str(png_path))
@@ -962,7 +967,17 @@ def _resume_thumb_bindings_and_orphans(
962
967
  _cy1, _cy2 = float(tbb[1]), float(tbb[3])
963
968
  for _bi, (_b0, _b1) in enumerate(_sb_list):
964
969
  if _cy2 > float(_b0) and _cy1 < float(_b1):
965
- band_idx = _bi # 相交 → 候选;持续覆盖 → 最后一个(下方)胜出
970
+ band_idx = _bi # 相交 → 候选;持续覆盖 → 最后一个(下方)胜出
971
+
972
+ # ── 如果卡片与 orphan_top_band 相交 → 直接记为找不到归属 ──
973
+ if layout_opt is not None:
974
+ ot = getattr(layout_opt, "orphan_top_band", None)
975
+ if ot is not None:
976
+ oy0, oy1 = float(ot[0]), float(ot[1])
977
+ _cy1, _cy2 = float(tbb[1]), float(tbb[3])
978
+ if _cy2 > oy0 and _cy1 < oy1:
979
+ orphan += 1
980
+ continue
966
981
 
967
982
  matched_nickname: Optional[str] = None
968
983
 
@@ -1177,7 +1192,7 @@ def first_attrib_verbatim_display_line_and_bbox(
1177
1192
  scale_w = float(original_width) / 1080.0
1178
1193
  if png_path is not None:
1179
1194
  try:
1180
- from processor.nickname_avatar_guard import load_png_bgr
1195
+ from screenshot_vision_algorithm.android.wechat.ocr.avatar_guard import load_png_bgr
1181
1196
 
1182
1197
  bgr_guard = load_png_bgr(str(png_path))
1183
1198
  except ImportError:
@@ -1748,7 +1763,7 @@ def extract_nicknames(
1748
1763
  bgr_guard = None
1749
1764
  if png_path is not None:
1750
1765
  try:
1751
- from processor.nickname_avatar_guard import load_png_bgr
1766
+ from screenshot_vision_algorithm.android.wechat.ocr.avatar_guard import load_png_bgr
1752
1767
 
1753
1768
  bgr_guard = load_png_bgr(str(png_path))
1754
1769
  except ImportError:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenshot_vision_algorithm
3
- Version: 0.3.2
3
+ Version: 0.3.8
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