From 955b4f0e1394bf481838a8a2dfebbcdaa40ea2fd Mon Sep 17 00:00:00 2001 From: HappyOnigiri <253838257+NodeMeld@users.noreply.github.com> Date: Sat, 17 Jan 2026 21:47:18 +0900 Subject: [PATCH] feat: refactor pHash threshold to percentage and separate auto-link vs suggest Switched from Hamming distance (0-64) to similarity percentage (0-100%) for better user understanding. Also introduced separate thresholds for: 1. Auto-linking: Strict threshold (92%) for high-confidence automatic linking. 2. Suggestion: Permissive threshold (82%) for manual linking candidates. --- py/image_manager/repositories/image_repo.py | 31 +++++++++++- py/image_manager/services/image_service.py | 10 ++++ py/image_manager/services/scan_service.py | 34 +++++++++++++- ui/src/components/SettingsModal.tsx | 52 ++++++++++----------- ui/src/store/galleryReducer.ts | 4 +- ui/src/types.ts | 2 +- 6 files changed, 100 insertions(+), 33 deletions(-) diff --git a/py/image_manager/repositories/image_repo.py b/py/image_manager/repositories/image_repo.py index 48687fc..f59c62c 100644 --- a/py/image_manager/repositories/image_repo.py +++ b/py/image_manager/repositories/image_repo.py @@ -22,17 +22,44 @@ def find_closest_parent( before_timestamp: float | None = None, sort_strategy: str = "phash_only", ) -> int | None: + """ + Find the most likely parent image based on pHash distance. + + DOCUMENTATION FOR AI DEVELOPERS: + There are two distinct types of thresholds used in this system: + + 1. Auto-linking Threshold (gallery.auto_link_phash_threshold): + - Used during new image registration and full library scans. + - PURPOSE: To automatically establish a high-confidence lineage without manual intervention. + - POLICY: Only link if there is a VERY high probability of relatedness. If not met, + it's better to leave the image without a parent and let the user link it manually. + - DEFAULT: 92% (pHash distance <= 5) + + 2. Suggestion Threshold (gallery.suggest_phash_threshold): + - Used for manual "Add source image" dialogs. + - PURPOSE: To show a list of potential candidates that the user might want to link. + - POLICY: Be more permissive to help users find relevant ancestors even if significant + changes were made (e.g., changing many prompts or models). + - DEFAULT: 82% (pHash distance <= 11) + + CRITICAL RULE: NEVER use the permissive Suggestion Threshold for automatic linking tasks. + Doing so creates incorrect lineage data that is difficult for users to clean up. + """ if not phash: return None if threshold is None: - # Fetch from settings if not provided + # FALLBACK: Use Suggestion Threshold if no threshold is provided. + # However, callers SHOULD provide the correct threshold based on the task. cursor.execute("SELECT value FROM settings WHERE key = ?", ("gallery.suggest_phash_threshold",)) row = cursor.fetchone() if row: try: val = json.loads(row[0]) - threshold = val if isinstance(val, int) else 8 + if isinstance(val, (int, float)): + threshold = round(64 * (1 - val / 100)) + else: + threshold = 8 except Exception: threshold = 8 else: diff --git a/py/image_manager/services/image_service.py b/py/image_manager/services/image_service.py index 9a2085a..5fadf47 100644 --- a/py/image_manager/services/image_service.py +++ b/py/image_manager/services/image_service.py @@ -73,6 +73,16 @@ def get_parent_suggestions( strategy: str = "phash_created", threshold: int = 12, ) -> list[ParentSuggestion]: + """ + Get a list of candidate parent images for manual selection. + + DOCUMENTATION FOR AI DEVELOPERS: + This function is used for MANUAL linking via the UI dialog. + It uses a more permissive threshold (gallery.suggest_phash_threshold) + to help users find potential ancestors even if they have diverged significantly. + + DO NOT use this permissive threshold for automatic linking tasks. + """ # 1. Find source filename matches (if strategy includes it) source_matches = [] if strategy == "filename_phash": diff --git a/py/image_manager/services/scan_service.py b/py/image_manager/services/scan_service.py index afb6d0f..be36ff7 100644 --- a/py/image_manager/services/scan_service.py +++ b/py/image_manager/services/scan_service.py @@ -1,3 +1,4 @@ +import json import logging import os import sqlite3 @@ -121,12 +122,40 @@ def infer_parent_id( prompt_json: str | dict | None = None, threshold: int | None = None, ) -> int | None: + """ + Infer the parent ID for an image. + This function is used for AUTOMATIC linking during registration or scan. + + DOCUMENTATION FOR AI DEVELOPERS: + This function MUST use a strict threshold (gallery.auto_link_phash_threshold) + by default. Automatic linking should only happen when there is high confidence + that the images are related. + + - Auto-linking is for high-probability relationships. + - If the relationship is ambiguous, DO NOT link automatically. + - Users can always use the 'Add Source' dialog (which uses a more permissive + threshold) to link images manually. + """ from .image_service import extract_source_filenames parent_id = None if created_at is None: created_at = time.time() + # Determine threshold for pHash matching + if threshold is None: + # Fetch auto-linking threshold from settings + cursor.execute("SELECT value FROM settings WHERE key = ?", ("gallery.auto_link_phash_threshold",)) + row = cursor.fetchone() + if row: + try: + val = json.loads(row[0]) + threshold = round(64 * (1 - val / 100)) + except Exception: + threshold = 5 # Default distance for 92% + else: + threshold = 5 + # 1. Try to get source filenames (for filename_phash strategy) source_filenames = [] if strategy == "filename_phash": @@ -421,8 +450,9 @@ def _scan_thread( parent_id = infer_parent_id(cursor, fname, subf, itype, iphash, icreated, strategy=matching_strategy) - if parent_id and parent_id != img_id: - cursor.execute("UPDATE images SET parent_id = ? WHERE id = ?", (parent_id, img_id)) + if link_strategy == "all" or parent_id: + if parent_id != img_id: + cursor.execute("UPDATE images SET parent_id = ? WHERE id = ?", (parent_id, img_id)) processed_linking += 1 if processed_linking % 5 == 0 or processed_linking == total_linking: diff --git a/ui/src/components/SettingsModal.tsx b/ui/src/components/SettingsModal.tsx index 072c462..8f70813 100644 --- a/ui/src/components/SettingsModal.tsx +++ b/ui/src/components/SettingsModal.tsx @@ -37,14 +37,14 @@ export const SettingsModal: React.FC = () => { useState(state.settings["viewer.thumbnail_window_size"].toString()); const [trashRetentionDaysInput, setTrashRetentionDaysInput] = useState(state.settings["gallery.trash_retention_days"].toString()); + const [autoLinkPhashThresholdInput, setAutoLinkPhashThresholdInput] = + useState( + state.settings["gallery.auto_link_phash_threshold"].toString(), + ); const [suggestPhashThresholdInput, setSuggestPhashThresholdInput] = useState( state.settings["gallery.suggest_phash_threshold"].toString(), ); - const [relatedPhashThresholdInput, setRelatedPhashThresholdInput] = - useState( - state.settings["gallery.related_phash_threshold"].toString(), - ); const [maxPositivePromptLinesInput, setMaxPositivePromptLinesInput] = useState( state.settings["viewer.details.max_positive_prompt_lines"].toString(), @@ -148,24 +148,24 @@ export const SettingsModal: React.FC = () => { max: 365, }, { - key: "gallery.suggest_phash_threshold", - label: "Parent Suggestion Threshold", + key: "gallery.auto_link_phash_threshold", + label: "Auto Linking Threshold (%)", description: - "Maximum pHash distance for automatic parent linking (0-64). Default: 12. Recommended: 8-16. Smaller is stricter.", + "Minimum similarity percentage for automatic parent linking during creation or scan (0-100). Default: 92%. Should be strict to avoid false links.", type: "number", category: "General", min: 0, - max: 64, + max: 100, }, { - key: "gallery.related_phash_threshold", - label: "Related Images Threshold", + key: "gallery.suggest_phash_threshold", + label: "Parent Suggestion Threshold (%)", description: - "Maximum pHash distance for 'Similar Images' view (0-64). Default: 8. Recommended: 4-12. Smaller is stricter.", + "Minimum similarity percentage for showing candidates in manual 'Add Source' dialog (0-100). Default: 82%. Permissive enough to find ancestors.", type: "number", category: "General", min: 0, - max: 64, + max: 100, }, { key: "sidebar.show_filename", @@ -667,10 +667,10 @@ export const SettingsModal: React.FC = () => { setThumbnailWindowSizeInput(value); } else if (key === "gallery.trash_retention_days") { setTrashRetentionDaysInput(value); + } else if (key === "gallery.auto_link_phash_threshold") { + setAutoLinkPhashThresholdInput(value); } else if (key === "gallery.suggest_phash_threshold") { setSuggestPhashThresholdInput(value); - } else if (key === "gallery.related_phash_threshold") { - setRelatedPhashThresholdInput(value); } else if (key === "viewer.details.max_positive_prompt_lines") { setMaxPositivePromptLinesInput(value); } else if (key === "viewer.details.max_negative_prompt_lines") { @@ -868,11 +868,11 @@ export const SettingsModal: React.FC = () => { "gallery.trash_retention_days" ? trashRetentionDaysInput : config.key === - "gallery.suggest_phash_threshold" - ? suggestPhashThresholdInput + "gallery.auto_link_phash_threshold" + ? autoLinkPhashThresholdInput : config.key === - "gallery.related_phash_threshold" - ? relatedPhashThresholdInput + "gallery.suggest_phash_threshold" + ? suggestPhashThresholdInput : (localSettings[ config.key ] as unknown as number) @@ -924,6 +924,14 @@ export const SettingsModal: React.FC = () => { "gallery.trash_retention_days" ].toString(), ); + } else if ( + config.key === "gallery.auto_link_phash_threshold" + ) { + setAutoLinkPhashThresholdInput( + localSettings[ + "gallery.auto_link_phash_threshold" + ].toString(), + ); } else if ( config.key === "gallery.suggest_phash_threshold" ) { @@ -932,14 +940,6 @@ export const SettingsModal: React.FC = () => { "gallery.suggest_phash_threshold" ].toString(), ); - } else if ( - config.key === "gallery.related_phash_threshold" - ) { - setRelatedPhashThresholdInput( - localSettings[ - "gallery.related_phash_threshold" - ].toString(), - ); } else if ( config.key === "viewer.details.max_positive_prompt_lines" diff --git a/ui/src/store/galleryReducer.ts b/ui/src/store/galleryReducer.ts index 7f0420d..6694d27 100644 --- a/ui/src/store/galleryReducer.ts +++ b/ui/src/store/galleryReducer.ts @@ -75,8 +75,8 @@ export const initialState: GalleryState = { "gallery.trash_retention_days": 30, "gallery.trash.show_missing": false, "gallery.view_mode": "grid_details", - "gallery.related_phash_threshold": 8, - "gallery.suggest_phash_threshold": 12, + "gallery.suggest_phash_threshold": 82, + "gallery.auto_link_phash_threshold": 92, "viewer.shortcut.1": "tag:keep next", "viewer.shortcut.2": "tag:best next", "viewer.shortcut.3": "tag:fix-needed next", diff --git a/ui/src/types.ts b/ui/src/types.ts index 4368f7b..55f516f 100644 --- a/ui/src/types.ts +++ b/ui/src/types.ts @@ -99,8 +99,8 @@ export interface Settings { "gallery.trash_retention_days": number; "gallery.trash.show_missing": boolean; "gallery.view_mode": "grid_details" | "grid_only"; - "gallery.related_phash_threshold": number; "gallery.suggest_phash_threshold": number; + "gallery.auto_link_phash_threshold": number; "viewer.shortcut.1": string; "viewer.shortcut.2": string; "viewer.shortcut.3": string;