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:
parent
0b794a4c32
commit
2ac63718a9
@ -124,7 +124,14 @@ Wolf Chat 是一個基於 MCP (Modular Capability Provider) 框架的聊天機
|
|||||||
* **計算頭像座標**:根據**新**找到的氣泡左上角座標,應用特定偏移量 (`AVATAR_OFFSET_X_REPLY`, `AVATAR_OFFSET_Y_REPLY`) 計算頭像點擊位置。
|
* **計算頭像座標**:根據**新**找到的氣泡左上角座標,應用特定偏移量 (`AVATAR_OFFSET_X_REPLY`, `AVATAR_OFFSET_Y_REPLY`) 計算頭像點擊位置。
|
||||||
* **互動(含重試)**:點擊計算出的頭像位置,檢查是否成功進入個人資料頁面 (`Profile_page.png`)。若失敗,最多重試 3 次(每次重試前會再次重新定位氣泡)。若成功,則繼續導航菜單複製用戶名稱。
|
* **互動(含重試)**:點擊計算出的頭像位置,檢查是否成功進入個人資料頁面 (`Profile_page.png`)。若失敗,最多重試 3 次(每次重試前會再次重新定位氣泡)。若成功,則繼續導航菜單複製用戶名稱。
|
||||||
* **原始偏移量**:原始的 `-55` 像素水平偏移量 (`AVATAR_OFFSET_X`) 仍保留,用於 `remove_user_position` 等其他功能。
|
* **原始偏移量**:原始的 `-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 整合
|
#### LLM 整合
|
||||||
|
|
||||||
|
|||||||
18
simple_bubble_dedup.json
Normal file
18
simple_bubble_dedup.json
Normal 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
155
simple_bubble_dedup.py
Normal 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}")
|
||||||
@ -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 threading # Import threading for Lock if needed, or just use a simple flag
|
||||||
import math # Added for distance calculation in dual method
|
import math # Added for distance calculation in dual method
|
||||||
import time # Ensure time is imported for MessageDeduplication
|
import time # Ensure time is imported for MessageDeduplication
|
||||||
|
from simple_bubble_dedup import SimpleBubbleDeduplication
|
||||||
|
|
||||||
class MessageDeduplication:
|
class MessageDeduplication:
|
||||||
def __init__(self, expiry_seconds=3600): # 1 hour expiry time
|
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) ---")
|
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) ---
|
# --- Initialization (Instantiate modules within the thread) ---
|
||||||
# --- Template Dictionary Setup (Refactored) ---
|
# --- Template Dictionary Setup (Refactored) ---
|
||||||
essential_templates = {
|
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.")
|
print("UI Thread: Processing clear_history command.")
|
||||||
recent_texts.clear()
|
recent_texts.clear()
|
||||||
deduplicator.clear_all() # Simultaneously clear deduplication records
|
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.")
|
print("UI Thread: recent_texts and deduplicator records cleared.")
|
||||||
|
|
||||||
elif action == 'reset_state': # Added for F8 resume
|
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()
|
recent_texts.clear()
|
||||||
last_processed_bubble_info = None
|
last_processed_bubble_info = None
|
||||||
deduplicator.clear_all() # Simultaneously clear deduplication records
|
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.")
|
print("UI Thread: recent_texts, last_processed_bubble_info, and deduplicator records reset.")
|
||||||
|
|
||||||
else:
|
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.")
|
print("Warning: Failed to capture bubble snapshot. Skipping this bubble.")
|
||||||
continue # Skip to next 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 ---
|
# --- Save Snapshot for Debugging ---
|
||||||
try:
|
try:
|
||||||
screenshot_index = (screenshot_counter % MAX_DEBUG_SCREENSHOTS) + 1
|
screenshot_index = (screenshot_counter % MAX_DEBUG_SCREENSHOTS) + 1
|
||||||
@ -2186,14 +2215,14 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu
|
|||||||
continue # Skip this bubble
|
continue # Skip this bubble
|
||||||
|
|
||||||
# If not a duplicate by deduplicator, then check recent_texts (original safeguard)
|
# If not a duplicate by deduplicator, then check recent_texts (original safeguard)
|
||||||
if bubble_text in recent_texts:
|
# if bubble_text in recent_texts:
|
||||||
print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.")
|
# print(f"UI Thread: Content '{bubble_text[:30]}...' in recent_texts history, skipping.")
|
||||||
perform_state_cleanup(detector, interactor) # Cleanup as we are skipping
|
# perform_state_cleanup(detector, interactor) # Cleanup as we are skipping
|
||||||
continue
|
# continue
|
||||||
|
|
||||||
# If not a duplicate by any means, add to recent_texts and proceed
|
# If not a duplicate by any means, add to recent_texts and proceed
|
||||||
print(">>> New trigger event (passed deduplication) <<<")
|
print(">>> New trigger event (passed deduplication) <<<")
|
||||||
recent_texts.append(bubble_text)
|
# recent_texts.append(bubble_text) # No longer needed with image deduplication
|
||||||
else:
|
else:
|
||||||
# This case implies sender_name or bubble_text was None/empty,
|
# This case implies sender_name or bubble_text was None/empty,
|
||||||
# which should have been caught by earlier checks.
|
# which should have been caught by earlier checks.
|
||||||
@ -2278,6 +2307,16 @@ def run_ui_monitoring_loop(trigger_queue: queue.Queue, command_queue: queue.Queu
|
|||||||
trigger_queue.put(data_to_send)
|
trigger_queue.put(data_to_send)
|
||||||
print("Trigger info (with region, reply flag, snapshot, search_area) placed in Queue.")
|
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 ---
|
# --- CRITICAL: Break loop after successfully processing one trigger ---
|
||||||
print("--- Single bubble processing complete. Breaking scan cycle. ---")
|
print("--- Single bubble processing complete. Breaking scan cycle. ---")
|
||||||
break # Exit the 'for target_bubble_info in sorted_bubbles' loop
|
break # Exit the 'for target_bubble_info in sorted_bubbles' loop
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user