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.
This commit is contained in:
z060142 2025-05-16 02:02:31 +08:00
parent 0b794a4c32
commit 2ac63718a9
4 changed files with 226 additions and 7 deletions

View File

@ -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 整合

18
simple_bubble_dedup.json Normal file
View File

@ -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"
}
}

155
simple_bubble_dedup.py Normal file
View File

@ -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}")

View File

@ -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. ---")