From 2ac63718a9f20e115d805fe6f18f0735284e6728 Mon Sep 17 00:00:00 2001 From: z060142 Date: Fri, 16 May 2025 02:02:31 +0800 Subject: [PATCH] Implement perceptual image hash deduplication for bubble processing - Added `simple_bubble_dedup.py` module using perceptual hashing (pHash) to detect duplicate chat bubbles based on visual similarity. - System keeps recent N (default 5) hashes and skips bubbles with Hamming distance below threshold (default 5). - Integrated into `run_ui_monitoring_loop()`: - Hash is computed upon bubble snapshot capture. - Duplicate check occurs before message enqueue. - Sender info is optionally attached to matching hash entries after successful processing. - Deduplication state is persisted in `simple_bubble_dedup.json`. - F7 (`clear_history`) and F8 (`reset_state`) now also clear image-based hash history. - Removed or commented out legacy `recent_texts` text-based deduplication logic. This visual deduplication system reduces false negatives caused by slight text variations and ensures higher confidence in skipping repeated bubble interactions. --- ClaudeCode.md | 9 ++- simple_bubble_dedup.json | 18 +++++ simple_bubble_dedup.py | 155 +++++++++++++++++++++++++++++++++++++++ ui_interaction.py | 51 +++++++++++-- 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 simple_bubble_dedup.json create mode 100644 simple_bubble_dedup.py diff --git a/ClaudeCode.md b/ClaudeCode.md index 2b031b7..f99d976 100644 --- a/ClaudeCode.md +++ b/ClaudeCode.md @@ -124,7 +124,14 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機 * **計算頭像座標**:根據**新**找到的氣泡左上角座標,應用特定偏移量 (`AVATAR_OFFSET_X_REPLY`, `AVATAR_OFFSET_Y_REPLY`) 計算頭像點擊位置。 * **互動(含重試)**:點擊計算出的頭像位置,檢查是否成功進入個人資料頁面 (`Profile_page.png`)。若失敗,最多重試 3 次(每次重試前會再次重新定位氣泡)。若成功,則繼續導航菜單複製用戶名稱。 * **原始偏移量**:原始的 `-55` 像素水平偏移量 (`AVATAR_OFFSET_X`) 仍保留,用於 `remove_user_position` 等其他功能。 -5. **防重複處理 (Duplicate Prevention)**:使用最近處理過的文字內容歷史 (`recent_texts`) 防止對相同訊息重複觸發。 +5. **防重複處理 (Duplicate Prevention)**: + * **基於圖像哈希的去重 (Image Hash Deduplication)**: 新增 `simple_bubble_dedup.py` 模塊,實現基於圖像感知哈希 (Perceptual Hash) 的去重系統。 + * **原理**: 系統會計算最近處理過的氣泡圖像的感知哈希值,並保存最近的 N 個 (預設 5 個) 氣泡的哈希。當偵測到新氣泡時,會計算其哈希並與保存的哈希進行比對。如果哈希差異小於設定的閾值 (預設 5),則認為是重複氣泡並跳過處理。 + * **實現**: 在 `ui_interaction.py` 的 `run_ui_monitoring_loop` 函數中初始化 `SimpleBubbleDeduplication` 實例,並在偵測到關鍵字並截取氣泡快照後,調用 `is_duplicate` 方法進行檢查。 + * **狀態管理**: 使用 `simple_bubble_dedup.json` 文件持久化保存最近的氣泡哈希記錄。 + * **清理**: F7 (`clear_history`) 和 F8 (`reset_state`) 功能已擴展,會同時清除圖像去重系統中的記錄。 + * **發送者信息更新**: 在成功處理並將氣泡信息放入隊列後,會嘗試更新去重記錄中對應氣泡的發送者名稱。 + * **文字內容歷史 (已棄用)**: 原有的基於 `recent_texts` 的文字內容重複檢查邏輯已**移除或註解**,圖像哈希去重成為主要的去重機制。 #### LLM 整合 diff --git a/simple_bubble_dedup.json b/simple_bubble_dedup.json new file mode 100644 index 0000000..c655d38 --- /dev/null +++ b/simple_bubble_dedup.json @@ -0,0 +1,18 @@ +{ + "bubble_210_340_236_70": { + "hash": "ae7d90dad1026ffd2e7990b2d10447fd4731389b1856c5a467594f5bd524b0cc", + "sender": "Wolfhartowo" + }, + "bubble_210_628_236_70": { + "hash": "ae7db0dad1026fad2e79b0b2d10446ff4731791b185695a467596e5bc524b0cc", + "sender": "" + }, + "bubble_210_620_464_264": { + "hash": "abfd544b70b87f87ecc8d70ac0870ee4115a2a7d93afd9b4dc022bdfcc03c4a0", + "sender": "" + }, + "bubble_210_852_464_264": { + "hash": "afff6b41c40b64f0d350972fe0a54ae4f4a0978ed0d0ac70d2402bdc2ffffa40", + "sender": "Wolfhartowo" + } +} \ No newline at end of file diff --git a/simple_bubble_dedup.py b/simple_bubble_dedup.py new file mode 100644 index 0000000..8c75c5b --- /dev/null +++ b/simple_bubble_dedup.py @@ -0,0 +1,155 @@ +import os +import json +import collections +import threading +from PIL import Image +import imagehash +import numpy as np +import io + +class SimpleBubbleDeduplication: + def __init__(self, storage_file="simple_bubble_dedup.json", max_bubbles=5, threshold=5, hash_size=16): + self.storage_file = storage_file + self.max_bubbles = max_bubbles # Keep the most recent 5 bubbles + self.threshold = threshold # Hash difference threshold (lower values are more strict) + self.hash_size = hash_size # Hash size + self.lock = threading.Lock() + + # Use OrderedDict to maintain order + self.recent_bubbles = collections.OrderedDict() + # Load stored bubble hashes + self._load_storage() + + def _load_storage(self): + """Load processed bubble hash values from file""" + if os.path.exists(self.storage_file): + try: + with open(self.storage_file, 'r') as f: + data = json.load(f) + + # Convert stored data to OrderedDict and load + self.recent_bubbles.clear() + # Use loaded_count to track loaded items, ensuring we don't exceed max_bubbles + loaded_count = 0 + for bubble_id, bubble_data in data.items(): + if loaded_count >= self.max_bubbles: + break + self.recent_bubbles[bubble_id] = { + 'hash': imagehash.hex_to_hash(bubble_data['hash']), + 'sender': bubble_data.get('sender', 'Unknown') + } + loaded_count += 1 + + print(f"Loaded {len(self.recent_bubbles)} bubble hash records") + except Exception as e: + print(f"Failed to load bubble hash records: {e}") + self.recent_bubbles.clear() + + def _save_storage(self): + """Save bubble hashes to file""" + try: + # Create temporary dictionary for saving + data_to_save = {} + for bubble_id, bubble_data in self.recent_bubbles.items(): + data_to_save[bubble_id] = { + 'hash': str(bubble_data['hash']), + 'sender': bubble_data.get('sender', 'Unknown') + } + + with open(self.storage_file, 'w') as f: + json.dump(data_to_save, f, indent=2) + print(f"Saved {len(data_to_save)} bubble hash records") + except Exception as e: + print(f"Failed to save bubble hash records: {e}") + + def compute_image_hash(self, bubble_snapshot): + """Calculate perceptual hash of bubble image""" + try: + # If bubble_snapshot is a PIL.Image object + if isinstance(bubble_snapshot, Image.Image): + img = bubble_snapshot + # If bubble_snapshot is a PyAutoGUI screenshot + elif hasattr(bubble_snapshot, 'save'): + img = bubble_snapshot + # If it's bytes or BytesIO + elif isinstance(bubble_snapshot, (bytes, io.BytesIO)): + img = Image.open(io.BytesIO(bubble_snapshot) if isinstance(bubble_snapshot, bytes) else bubble_snapshot) + # If it's a numpy array + elif isinstance(bubble_snapshot, np.ndarray): + img = Image.fromarray(bubble_snapshot) + else: + print(f"Unrecognized image format: {type(bubble_snapshot)}") + return None + + # Calculate perceptual hash + phash = imagehash.phash(img, hash_size=self.hash_size) + return phash + except Exception as e: + print(f"Failed to calculate image hash: {e}") + return None + + def generate_bubble_id(self, bubble_region): + """Generate ID based on bubble region""" + return f"bubble_{bubble_region[0]}_{bubble_region[1]}_{bubble_region[2]}_{bubble_region[3]}" + + def is_duplicate(self, bubble_snapshot, bubble_region, sender_name=""): + """Check if bubble is a duplicate""" + with self.lock: + if bubble_snapshot is None: + return False + + # Calculate hash of current bubble + current_hash = self.compute_image_hash(bubble_snapshot) + if current_hash is None: + print("Unable to calculate bubble hash, cannot perform deduplication") + return False + + # Generate ID for current bubble + bubble_id = self.generate_bubble_id(bubble_region) + + # Check if similar to any known bubbles + for stored_id, bubble_data in self.recent_bubbles.items(): + stored_hash = bubble_data['hash'] + hash_diff = current_hash - stored_hash + + if hash_diff <= self.threshold: + print(f"Detected duplicate bubble (ID: {stored_id}, Hash difference: {hash_diff})") + if sender_name: + print(f"Sender: {sender_name}, Recorded sender: {bubble_data.get('sender', 'Unknown')}") + return True + + # Not a duplicate, add to recent bubbles list + self.recent_bubbles[bubble_id] = { + 'hash': current_hash, + 'sender': sender_name + } + + # If exceeding maximum count, remove oldest item + while len(self.recent_bubbles) > self.max_bubbles: + self.recent_bubbles.popitem(last=False) # Remove first item (oldest) + + self._save_storage() + return False + + def clear_all(self): + """Clear all records""" + with self.lock: + count = len(self.recent_bubbles) + self.recent_bubbles.clear() + self._save_storage() + print(f"Cleared all {count} bubble records") + return count + + def save_debug_image(self, bubble_snapshot, bubble_id, hash_value): + """Save debug image (optional feature)""" + try: + debug_dir = "bubble_debug" + if not os.path.exists(debug_dir): + os.makedirs(debug_dir) + + # Save original image + img_path = os.path.join(debug_dir, f"{bubble_id}_{hash_value}.png") + bubble_snapshot.save(img_path) + print(f"Saved debug image: {img_path}") + except Exception as e: + print(f"Failed to save debug image: {e}") \ No newline at end of file diff --git a/ui_interaction.py b/ui_interaction.py index d575cfd..d5a994c 100644 --- a/ui_interaction.py +++ b/ui_interaction.py @@ -19,6 +19,7 @@ from typing import List, Tuple, Optional, Dict, Any import threading # Import threading for Lock if needed, or just use a simple flag import math # Added for distance calculation in dual method import time # Ensure time is imported for MessageDeduplication +from simple_bubble_dedup import SimpleBubbleDeduplication class MessageDeduplication: def __init__(self, expiry_seconds=3600): # 1 hour expiry time @@ -1697,6 +1698,15 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu """ print("\n--- Starting UI Monitoring Loop (Thread) ---") + # --- 初始化氣泡圖像去重系統(新增) --- + bubble_deduplicator = SimpleBubbleDeduplication( + storage_file="simple_bubble_dedup.json", + max_bubbles=4, # 保留最近5個氣泡 + threshold=7, # 哈希差異閾值(值越小越嚴格) + hash_size=16 # 哈希大小 + ) + # --- 初始化氣泡圖像去重系統結束 --- + # --- Initialization (Instantiate modules within the thread) --- # --- Template Dictionary Setup (Refactored) --- essential_templates = { @@ -1837,6 +1847,12 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("UI Thread: Processing clear_history command.") recent_texts.clear() deduplicator.clear_all() # Simultaneously clear deduplication records + + # --- 新增:清理氣泡去重記錄 --- + if 'bubble_deduplicator' in locals(): + bubble_deduplicator.clear_all() + # --- 清理氣泡去重記錄結束 --- + print("UI Thread: recent_texts and deduplicator records cleared.") elif action == 'reset_state': # Added for F8 resume @@ -1844,6 +1860,12 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu recent_texts.clear() last_processed_bubble_info = None deduplicator.clear_all() # Simultaneously clear deduplication records + + # --- 新增:清理氣泡去重記錄 --- + if 'bubble_deduplicator' in locals(): + bubble_deduplicator.clear_all() + # --- 清理氣泡去重記錄結束 --- + print("UI Thread: recent_texts, last_processed_bubble_info, and deduplicator records reset.") else: @@ -2033,6 +2055,13 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu print("Warning: Failed to capture bubble snapshot. Skipping this bubble.") continue # Skip to next bubble + # --- New: Image deduplication check --- + if bubble_deduplicator.is_duplicate(bubble_snapshot, bubble_region_tuple): + print("Detected duplicate bubble, skipping processing") + perform_state_cleanup(detector, interactor) + continue # Skip processing this bubble + # --- End of image deduplication check --- + # --- Save Snapshot for Debugging --- try: screenshot_index = (screenshot_counter % MAX_DEBUG_SCREENSHOTS) + 1 @@ -2044,7 +2073,7 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu screenshot_counter += 1 except Exception as save_err: print(f"Error saving bubble snapshot to {screenshot_path}: {repr(save_err)}") - + except Exception as snapshot_err: print(f"Error taking initial bubble snapshot: {repr(snapshot_err)}") continue # Skip to next bubble @@ -2186,14 +2215,14 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu continue # Skip this bubble # If not a duplicate by deduplicator, then check recent_texts (original safeguard) - if bubble_text in recent_texts: - print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.") - perform_state_cleanup(detector, interactor) # Cleanup as we are skipping - continue + # if bubble_text in recent_texts: + # print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.") + # perform_state_cleanup(detector, interactor) # Cleanup as we are skipping + # continue # If not a duplicate by any means, add to recent_texts and proceed print(">>> New trigger event (passed deduplication) <<<") - recent_texts.append(bubble_text) + # recent_texts.append(bubble_text) # No longer needed with image deduplication else: # This case implies sender_name or bubble_text was None/empty, # which should have been caught by earlier checks. @@ -2277,6 +2306,16 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu } trigger_queue.put(data_to_send) print("Trigger info (with region, reply flag, snapshot, search_area) placed in Queue.") + + # --- 新增:更新氣泡去重記錄中的發送者信息 --- + # 注意:我們在前面已經添加了氣泡到去重系統,但當時還沒獲取發送者名稱 + # 這裡我們嘗試再次更新發送者信息(如果實現允許的話) + if 'bubble_deduplicator' in locals() and bubble_snapshot and sender_name: + bubble_id = bubble_deduplicator.generate_bubble_id(bubble_region_tuple) + if bubble_id in bubble_deduplicator.recent_bubbles: + bubble_deduplicator.recent_bubbles[bubble_id]['sender'] = sender_name + bubble_deduplicator._save_storage() + # --- 更新發送者信息結束 --- # --- CRITICAL: Break loop after successfully processing one trigger --- print("--- Single bubble processing complete. Breaking scan cycle. ---")