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.
Files changed (48) hide show
  1. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/PKG-INFO +1 -1
  2. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/pyproject.toml +1 -1
  3. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/badge_detection.py +1 -1
  4. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/card_bbox.py +64 -83
  5. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/template_matching.py +110 -26
  6. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/PKG-INFO +1 -1
  7. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/README.md +0 -0
  8. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/setup.cfg +0 -0
  9. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/__init__.py +0 -0
  10. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/_config.py +0 -0
  11. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/__init__.py +0 -0
  12. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/__init__.py +0 -0
  13. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/__init__.py +0 -0
  14. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/avatar_column.py +0 -0
  15. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/phash_utils.py +0 -0
  16. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/speaker_band.py +0 -0
  17. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/algorithms/title_ocr.py +0 -0
  18. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/merge/__init__.py +0 -0
  19. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/merge/multipage.py +0 -0
  20. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/__init__.py +0 -0
  21. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/avatar_guard.py +0 -0
  22. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/badge_ocr.py +0 -0
  23. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/ocr/nickname_binding.py +0 -0
  24. {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
  25. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/__init__.py +0 -0
  26. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/android.py +0 -0
  27. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/harmony.py +0 -0
  28. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/wechat/profiles/ios.py +0 -0
  29. {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
  30. {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
  31. {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
  32. {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
  33. {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
  34. {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
  35. {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
  36. {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
  37. {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
  38. {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
  39. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/xhs/__init__.py +0 -0
  40. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/android/zhihu/__init__.py +0 -0
  41. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/logging.py +0 -0
  42. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm/png_utils.py +0 -0
  43. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/SOURCES.txt +0 -0
  44. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/dependency_links.txt +0 -0
  45. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/requires.txt +0 -0
  46. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/src/screenshot_vision_algorithm.egg-info/top_level.txt +0 -0
  47. {screenshot_vision_algorithm-0.3.2 → screenshot_vision_algorithm-0.3.5}/tests/test_card_bbox.py +0 -0
  48. {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.2
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.2"
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 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",
@@ -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.2
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