screenshot-vision-algorithm 0.3.1__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.
Files changed (48) hide show
  1. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/PKG-INFO +1 -1
  2. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/pyproject.toml +1 -1
  3. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/__init__.py +0 -1
  4. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +1 -1
  5. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +62 -84
  6. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +110 -26
  7. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +1 -1
  8. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/README.md +0 -0
  9. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/setup.cfg +0 -0
  10. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/_config.py +0 -0
  11. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
  12. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
  13. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
  14. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +0 -0
  15. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
  16. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +0 -0
  17. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
  18. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
  19. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
  20. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
  21. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
  22. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
  23. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +0 -0
  24. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/text_ocr_adapter.py +0 -0
  25. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
  26. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
  27. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
  28. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
  29. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_back_chevron.png +0 -0
  30. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_emoji_smile.png +0 -0
  31. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_plus.png +0 -0
  32. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_input_voice.png +0 -0
  33. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/chat_title_more_dots.png +0 -0
  34. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/favorite_label.png +0 -0
  35. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/new_messages_hint_suffix.png +0 -0
  36. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/unread_divider_hint.png +0 -0
  37. {screenshot_vision_algorithm-0.3.1 → 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
  38. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/templates/android/8.0.69/wechat_note_header.png +0 -0
  39. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
  40. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
  41. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/logging.py +0 -0
  42. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/png_utils.py +0 -0
  43. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/SOURCES.txt +0 -0
  44. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
  45. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
  46. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
  47. {screenshot_vision_algorithm-0.3.1 → screenshot_vision_algorithm-0.3.5}/tests/test_card_bbox.py +0 -0
  48. {screenshot_vision_algorithm-0.3.1 → 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.1
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.1"
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" }
@@ -3,7 +3,6 @@
3
3
  from screenshot_vision_algorithm._config import Platform, WeChatVersion, Profile
4
4
 
5
5
  from screenshot_vision_algorithm.android.wechat.algorithms.card_bbox import (
6
- TAP_ANTI_DETECT_X_JITTER_PX,
7
6
  _compute_exact_card_and_bubble_bboxes,
8
7
  _compute_exact_card_bboxes,
9
8
  BubbleBbox,
@@ -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
@@ -83,11 +72,6 @@ REFERENCE_RESUME_CARD_TOP_GAP_BASELINE: int = 542
83
72
  #: **相对 screencap + OpenCV**,性能可忽略。
84
73
  PROCESSED_CARD_MATCH_MIN_OVERLAP_RATIO: float = 0.70
85
74
 
86
- #: Anti-detection horizontal jitter (px @ 1080 baseline) applied by
87
- #: ``enter_note`` on every tap attempt: ``tap_x = click_x ± random(0..15)``.
88
- #: Y is never jittered (speaker attribution depends on exact Y).
89
- TAP_ANTI_DETECT_X_JITTER_PX: int = 15
90
-
91
75
  #: 3b gap 合并/延长判别距离(px @ 1080 baseline,运行时 ×scale_w):
92
76
  #: 收藏锚点落在同一竖线相邻两 zone 的间隙时,取锚点下方第一条横线,
93
77
  #: 横线与下方 zone 起点距离 ≤ 本值 → 两 zone 同卡(被收藏标签行打断),合并;
@@ -171,7 +155,10 @@ def _compute_exact_card_and_bubble_bboxes(
171
155
  bgr_img: np.ndarray,
172
156
  screen_w: int,
173
157
  screen_h: int,
174
- ) -> 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)."""
175
162
  """PRD S6: vline-segment + hline-boundary card + bubble bbox detection.
176
163
 
177
164
  Steps:
@@ -253,7 +240,7 @@ def _compute_exact_card_and_bubble_bboxes(
253
240
  vd_lines = [vx for vx in vd_lines if vx - AR >= min_card_x_gap]
254
241
 
255
242
  if not vd_lines:
256
- return [], []
243
+ return [], [], False
257
244
 
258
245
  # Pick best vline: max total mid pixels + longest continuous segment
259
246
  vline_scores: dict[int, float] = {}
@@ -279,7 +266,7 @@ def _compute_exact_card_and_bubble_bboxes(
279
266
  best_vx = vx
280
267
 
281
268
  if best_vx is None:
282
- return [], []
269
+ return [], [], False
283
270
 
284
271
  # Step 3 — vline segments, computed per candidate vline. A fav-anchored
285
272
  # card may sit on a different right-edge vline than the frame-global best
@@ -311,7 +298,7 @@ def _compute_exact_card_and_bubble_bboxes(
311
298
  )
312
299
  zones_by_vx = {vx: _zones_for(vx) for vx in vline_order}
313
300
  if not any(zones_by_vx.values()):
314
- return [], []
301
+ return [], [], False
315
302
 
316
303
  # Step 4 — card boundary hlines (span > 50% card_w, X-near vline,
317
304
  # max gray gradient >= 30 = white→gray boundary)
@@ -410,6 +397,7 @@ def _compute_exact_card_and_bubble_bboxes(
410
397
  gap_tol = max(1, int(round(GAP_HLINE_TO_NEXT_ZONE_MAX_BASELINE * sw)))
411
398
  cards: list[ThumbnailCard] = []
412
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
413
401
  for idx, hit in enumerate(ordered_hits):
414
402
  fav_cy = hit.y + hit.h // 2
415
403
 
@@ -418,6 +406,7 @@ def _compute_exact_card_and_bubble_bboxes(
418
406
  zones = zones_by_vx[vx]
419
407
  if not zones:
420
408
  continue
409
+ any_anchor_seen = True # anchor entered vline loop with non-empty zones
421
410
  card_hlines = hlines_by_vx[vx]
422
411
 
423
412
  def _snap(y1_: int, y2_: int) -> tuple[int, int]:
@@ -502,7 +491,7 @@ def _compute_exact_card_and_bubble_bboxes(
502
491
  if exact_y2 <= exact_y1 or exact_y2 - exact_y1 < 80:
503
492
  continue
504
493
 
505
- click_x = min(screen_w - 1, max(0, hit.x + hit.w // 2))
494
+ click_x = screen_w // 2
506
495
  click_y = min(screen_h - 1, max(0, hit.y + hit.h // 2))
507
496
  midline = screen_w / 2.0
508
497
  click_side = "left" if click_x < midline else "right"
@@ -558,7 +547,7 @@ def _compute_exact_card_and_bubble_bboxes(
558
547
  continue
559
548
  bubbles.append(BubbleBbox(top=by1, bottom=by2, left=AR, right=int(vx)))
560
549
 
561
- return cards, bubbles
550
+ return cards, bubbles, any_anchor_seen
562
551
 
563
552
 
564
553
  def _compute_exact_card_bboxes(
@@ -566,13 +555,22 @@ def _compute_exact_card_bboxes(
566
555
  bgr_img: np.ndarray,
567
556
  screen_w: int,
568
557
  screen_h: int,
569
- ) -> list[ThumbnailCard]:
558
+ ) -> tuple[list[ThumbnailCard], bool]:
570
559
  """Backward-compat wrapper — cards only (see
571
- :func:`_compute_exact_card_and_bubble_bboxes`)."""
572
- 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(
573
571
  ordered_hits, bgr_img, screen_w, screen_h,
574
572
  )
575
- return cards
573
+ return cards, pipeline_active
576
574
 
577
575
 
578
576
  def derive_cards_and_bubbles(
@@ -596,7 +594,7 @@ def derive_cards_and_bubbles(
596
594
  return [], []
597
595
  ordered = sorted(hits, key=lambda h: h.y)
598
596
  try:
599
- cards, bubbles = _compute_exact_card_and_bubble_bboxes(
597
+ cards, bubbles, _pipeline_active = _compute_exact_card_and_bubble_bboxes(
600
598
  ordered, chat_img, screen_w, screen_h,
601
599
  )
602
600
  except Exception:
@@ -627,15 +625,15 @@ def derive_cards(
627
625
  (we sort by y ourselves).
628
626
  screen_w / screen_h: the source screenshot's raw-image dimensions
629
627
  (usually equal to ``device_info.screen_resolution``).
630
- scale_w: ``screen_w / baseline_w`` (baseline = 1080). Used to scale
631
- ``FAVORITE_TO_CARD_TOP_OFFSET`` + ``FAVORITE_TAIL_OFFSET``
632
- so the logic survives across the 720 / 1080 / 1220 device
633
- whitelist.
634
- chat_img: optional BGR image (as returned by ``cv2.imdecode``).
635
- When provided, uses the vline-segment + hline-boundary
636
- variance-based algorithm (PRD S6) for precise card bbox
637
- derivation. Falls back to legacy fixed-offset logic
638
- 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.
639
637
 
640
638
  Returns:
641
639
  One :class:`ThumbnailCard` per hit, sorted top -> bottom. Empty list
@@ -647,58 +645,22 @@ def derive_cards(
647
645
 
648
646
  ordered = sorted(hits, key=lambda h: h.y)
649
647
 
650
- # New algorithm: vline-segment + hline-boundary (PRD S6). When the
651
- # pipeline ran successfully its verdict is final an anchor it dropped
652
- # (no containing zone → "点对卡、记错框" guard) must NOT be resurrected
653
- # by the legacy fixed-offset fallback. Legacy only serves chat_img=None
654
- # callers and pipeline exceptions.
655
- 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:
656
652
  try:
657
- exact_cards = _compute_exact_card_bboxes(
653
+ exact_cards, _pipeline_active = _compute_exact_card_bboxes(
658
654
  ordered, chat_img, screen_w, screen_h,
659
655
  )
660
656
  except Exception:
661
- pass
657
+ return []
662
658
  else:
663
659
  return drop_top_clamped_false_positive_cards(
664
660
  exact_cards, scale_w=scale_w,
665
661
  )
666
662
 
667
- # Legacy: fixed-offset algorithm
668
- top_offset = int(round(FAVORITE_TO_CARD_TOP_OFFSET_BASELINE * scale_w))
669
- tail_offset = int(round(FAVORITE_TAIL_OFFSET_BASELINE * scale_w))
670
-
671
- cards: list[ThumbnailCard] = []
672
- for idx, hit in enumerate(ordered):
673
- if idx == 0:
674
- card_top = max(0, hit.y1 - top_offset)
675
- else:
676
- prev_bottom = ordered[idx - 1].y2 + tail_offset
677
- card_top = max(prev_bottom + 1, 0)
678
-
679
- card_bottom = min(screen_h - 1, hit.y2 + tail_offset)
680
-
681
- if card_bottom <= card_top:
682
- continue
683
-
684
- click_x = min(screen_w - 1, max(0, hit.x + hit.w // 2))
685
- click_y = min(screen_h - 1, max(0, hit.y + hit.h // 2))
686
- midline = screen_w / 2
687
- click_side = "left" if click_x < midline else "right"
688
-
689
- cards.append(ThumbnailCard(
690
- index=idx,
691
- top=card_top,
692
- bottom=card_bottom,
693
- left=0,
694
- right=screen_w - 1,
695
- click_x=click_x,
696
- click_y=click_y,
697
- click_side=click_side,
698
- favorite_hit=hit,
699
- ))
700
-
701
- return drop_top_clamped_false_positive_cards(cards, scale_w=scale_w)
663
+ return []
702
664
 
703
665
 
704
666
  def drop_top_clamped_false_positive_cards(
@@ -876,12 +838,32 @@ def _card_height(card: ThumbnailCard) -> int:
876
838
  return card.bottom - card.top + 1
877
839
 
878
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
+
879
859
  def _longest_subsequence_match(
880
860
  a_heights: list[int],
881
861
  b_heights: list[int],
882
862
  ) -> tuple[int, int, int] | None:
883
863
  """Find the longest contiguous substring of a_heights that appears in b_heights.
884
864
 
865
+ Matching uses ± ``_CARD_HEIGHT_TOLERANCE`` px per element (PRD §10 第二步 容差)。
866
+
885
867
  Returns ``(a_start, b_start, length)`` or ``None``.
886
868
  If multiple matches share the same maximum length, picks the one with
887
869
  the smallest ``b_start`` (earliest in B).
@@ -896,9 +878,8 @@ def _longest_subsequence_match(
896
878
  for length in range(max_len, 0, -1):
897
879
  if best is not None and length < best[2]:
898
880
  break
899
- sub = a_heights[a_start:a_start + length]
900
881
  for b_start in range(len(b_heights) - length + 1):
901
- if b_heights[b_start:b_start + length] == sub:
882
+ if _heights_match(a_heights, a_start, b_heights, b_start, length):
902
883
  if (
903
884
  best is None
904
885
  or length > best[2]
@@ -973,13 +954,10 @@ def pick_next_unclicked_card(
973
954
  __all__ = [
974
955
  "FAVORITE_LABEL_TEMPLATE_W_BASELINE",
975
956
  "FAVORITE_LABEL_TEMPLATE_H_BASELINE",
976
- "FAVORITE_TO_CARD_TOP_OFFSET_BASELINE",
977
- "FAVORITE_TAIL_OFFSET_BASELINE",
978
957
  "FAV_LABEL_HLINE_EXCLUDE_PAD_BASELINE",
979
958
  "GAP_HLINE_TO_NEXT_ZONE_MAX_BASELINE",
980
959
  "PROCESSED_CARD_MATCH_MIN_OVERLAP_RATIO",
981
960
  "REFERENCE_RESUME_CARD_TOP_GAP_BASELINE",
982
- "TAP_ANTI_DETECT_X_JITTER_PX",
983
961
  "FavoriteLabelHit",
984
962
  "ThumbnailCard",
985
963
  "TrackedCard",
@@ -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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: screenshot_vision_algorithm
3
- Version: 0.3.1
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