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.
This commit is contained in:
HappyOnigiri
2026-01-17 21:47:18 +09:00
parent dbf07f4d87
commit 955b4f0e13
6 changed files with 100 additions and 33 deletions
+29 -2
View File
@@ -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:
@@ -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":
+32 -2
View File
@@ -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:
+26 -26
View File
@@ -37,14 +37,14 @@ export const SettingsModal: React.FC = () => {
useState<string>(state.settings["viewer.thumbnail_window_size"].toString());
const [trashRetentionDaysInput, setTrashRetentionDaysInput] =
useState<string>(state.settings["gallery.trash_retention_days"].toString());
const [autoLinkPhashThresholdInput, setAutoLinkPhashThresholdInput] =
useState<string>(
state.settings["gallery.auto_link_phash_threshold"].toString(),
);
const [suggestPhashThresholdInput, setSuggestPhashThresholdInput] =
useState<string>(
state.settings["gallery.suggest_phash_threshold"].toString(),
);
const [relatedPhashThresholdInput, setRelatedPhashThresholdInput] =
useState<string>(
state.settings["gallery.related_phash_threshold"].toString(),
);
const [maxPositivePromptLinesInput, setMaxPositivePromptLinesInput] =
useState<string>(
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"
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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;