diff --git a/alembic/versions/0098_phash_256_bit_rehash.py b/alembic/versions/0098_phash_256_bit_rehash.py new file mode 100644 index 0000000..569547b --- /dev/null +++ b/alembic/versions/0098_phash_256_bit_rehash.py @@ -0,0 +1,90 @@ +"""Widen image_record.phash to 256-bit and re-hash the library (issue #4223). + +The operator reported a 15-image variant pack landing as 3 records, and then +that variants were STILL being dropped with `phash_threshold` at 0. Zero was +already the floor of the dial, so no setting could have fixed it: at +`hash_size=8` a pHash is 64 bits of coarse light/dark layout, and variant +artwork sharing a composition produces the SAME 64 bits. Distance 0 meant +"identical hash", never "identical image". + +`utils/phash.py` moves to `hash_size=16` (256 bits, what ImageRepo always +used) and adds an aspect-ratio gate plus a pixel-level confirm, so a merge is +accepted on the files rather than on the hash. + +## Why this NULLs every phash + +Widening the column does not correct the values already in it. Every stored +hash is a 64-bit hash of an image the app will now hash at 256 bits, and the +two cannot be compared — `find_similar` skips a mismatched-length candidate +rather than guessing, so leaving them would silently mean "no dedup, forever, +for everything imported before today". NULL is the state `backfill_phash` +already knows how to repair: it is NULL-only, keyset-paginated and +restart-safe, and the beat schedule runs it daily. + +Until that backfill finishes, image dedup degrades to sha256 only — +duplicates may be kept. That is the safe direction, and the only one +available: the alternative is comparing hashes of different widths, which +would drop artwork. NOTHING here deletes or supersedes a file. + +## Why the threshold is reset rather than carried over + +`phash_threshold` counts bits, and the denominator went from 64 to 256. The +stored number would keep its value while meaning something four times +tighter. There is no honest carry-over, so every row goes to the new default +of 24 — including the operator's 0, which was a workaround for the bug this +revision fixes. + +Revision ID: 0098 +Revises: 0097 +Create Date: 2026-09-21 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "0098" +down_revision: Union[str, None] = "0097" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # varchar(32) -> varchar(64): widening a length limit is a catalog-only + # change in Postgres, so this does not rewrite the table or its index. + op.alter_column( + "image_record", "phash", + existing_type=sa.String(32), + type_=sa.String(64), + existing_nullable=True, + ) + op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL") + op.alter_column( + "import_settings", "phash_threshold", + existing_type=sa.Integer(), + server_default="24", + existing_nullable=False, + ) + op.execute("UPDATE import_settings SET phash_threshold = 24") + + +def downgrade() -> None: + # The 64-bit hashes this replaced are gone, and a 64-char value does not + # fit back into varchar(32) — so the column is cleared again on the way + # down and left for backfill_phash to refill at whatever HASH_SIZE the + # code is running. Rule #22: no legacy to preserve. + op.execute("UPDATE image_record SET phash = NULL WHERE phash IS NOT NULL") + op.alter_column( + "image_record", "phash", + existing_type=sa.String(64), + type_=sa.String(32), + existing_nullable=True, + ) + op.alter_column( + "import_settings", "phash_threshold", + existing_type=sa.Integer(), + server_default="10", + existing_nullable=False, + ) + op.execute("UPDATE import_settings SET phash_threshold = 10") diff --git a/backend/app/celery_app.py b/backend/app/celery_app.py index b702fc6..d49c8ef 100644 --- a/backend/app/celery_app.py +++ b/backend/app/celery_app.py @@ -120,6 +120,14 @@ def make_celery() -> Celery: "schedule": 86400.0, # daily — sweep .part/.partial left by a # download/import killed mid-write (graceful-shutdown fallout) }, + "backfill-phash-daily": { + "task": "backend.app.tasks.maintenance.backfill_phash", + "schedule": 86400.0, # daily — NULL-only, so a no-op once the + # library is hashed. This is what makes migration 0098's + # re-hash happen on its own: 0098 NULLs every phash, and + # without a scheduled refill the library would sit + # dedup-disabled until someone ran a deep scan (#4223). + }, "train-heads-nightly": { "task": "backend.app.tasks.ml.scheduled_train_heads", "schedule": 86400.0, # passive cadence; manual retrain stays available diff --git a/backend/app/models/image_record.py b/backend/app/models/image_record.py index f5f4050..223bb8d 100644 --- a/backend/app/models/image_record.py +++ b/backend/app/models/image_record.py @@ -64,7 +64,11 @@ class ImageRecord(Base): # that 0001 also built was an exact duplicate of it — dropped in 0089 # (#3301). Lookups by sha256 use the constraint's index. sha256: Mapped[str] = mapped_column(String(64), nullable=False) - phash: Mapped[str | None] = mapped_column(String(32), nullable=True, index=True) + # 64 hex chars = the 256-bit hash utils.phash emits at hash_size=16. Was + # String(32) (64-bit) until migration 0098; the narrow column was the + # reason for the undersized hash, and the undersized hash was collapsing + # variant artwork into one record (#4223). + phash: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True) size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False) mime: Mapped[str] = mapped_column(String(64), nullable=False) width: Mapped[int | None] = mapped_column(Integer, nullable=True) diff --git a/backend/app/models/import_settings.py b/backend/app/models/import_settings.py index 0ed2e8b..3227724 100644 --- a/backend/app/models/import_settings.py +++ b/backend/app/models/import_settings.py @@ -42,7 +42,12 @@ class ImportSettings(Base): single_color_threshold: Mapped[float] = mapped_column(Float, nullable=False, default=0.95, server_default="0.95") single_color_tolerance: Mapped[int] = mapped_column(Integer, nullable=False, default=30, server_default="30") - phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=10, server_default="10") + # Hamming distance over a 256-bit pHash (utils.phash, hash_size=16). The + # unit CHANGED in migration 0098 — it used to be bits out of 64 — so the + # old default of 10 is not this scale's 10, and 0098 resets every row. + # This is now the cheap PRE-FILTER: the aspect + pixel gates decide, which + # is what lets it be generous enough to catch a re-encoded rescale. + phash_threshold: Mapped[int] = mapped_column(Integer, nullable=False, default=24, server_default="24") # FC-3c downloader knobs download_rate_limit_seconds: Mapped[float] = mapped_column( diff --git a/backend/app/services/gallery_service.py b/backend/app/services/gallery_service.py index 3f124db..87224ad 100644 --- a/backend/app/services/gallery_service.py +++ b/backend/app/services/gallery_service.py @@ -322,7 +322,7 @@ def _gallery_images(rows, artists: dict[int, dict]) -> list[GalleryImage]: ] -def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40): +def _diversify_similar(src, rows, limit, *, dup_threshold=32, lam=0.40): """Trim a nearest-cosine candidate pool down to `limit` diverse picks. 1. pHash collapse: drop any candidate whose perceptual hash is within @@ -338,6 +338,11 @@ def _diversify_similar(src, rows, limit, *, dup_threshold=8, lam=0.40): 2026-07-01 — dropped 0.55→0.40, dup 6→8, paired with a wider pool in `similar()`). + `dup_threshold` counts Hamming bits, so it moved 8→32 when the pHash went + from 64 to 256 bits (#4223, migration 0098) — the same fraction of the + hash, i.e. the tuning the operator chose, unchanged. This collapse is + DISPLAY-only: it hides a near-dup from one rail, it never drops a record. + Falls back to nearest-order (`rows[:limit]`) on any failure or a small pool. """ if len(rows) <= 1: diff --git a/backend/app/services/importer.py b/backend/app/services/importer.py index db8cf0c..f4c0142 100644 --- a/backend/app/services/importer.py +++ b/backend/app/services/importer.py @@ -39,7 +39,7 @@ from ..utils.paths import ( hash_suffixed_name, safe_ext, ) -from ..utils.phash import compute_phash, find_similar +from ..utils.phash import compute_phash, find_similar, fingerprint_path, fingerprints_match from ..utils.sidecar import find_sidecar, parse_sidecar from ..utils.slug import slugify from .archive_extractor import extract_archive, is_archive @@ -234,6 +234,38 @@ class Importer: (phash, width or 0, height or 0, image_id) ) + def _pixel_confirmer(self, source: Path): + """Build `find_similar`'s gate-3 callback for an incoming file. + + pHash proposes; this accepts. A candidate is a duplicate only if its + file really is the same picture as `source` at a different size — + which is the only merge the operator asked for (#4223). Everything + else (a missing file, an unreadable one, a deleted row) returns + False: the destructive outcomes here are dropping a download and + overwriting a kept file, so an unanswerable question must not read + as "yes". + + Both sides' fingerprints are computed lazily and cached, so an + import that matches nothing costs no I/O at all and an archive + member that keeps hitting the same candidate pays for it once. + """ + new_fp: list = [] + cand_fps: dict[int, object] = {} + + def confirm(candidate_id: int) -> bool: + if not new_fp: + new_fp.append(fingerprint_path(source)) + if new_fp[0] is None: + return False + if candidate_id not in cand_fps: + rec = self.session.get(ImageRecord, candidate_id) + cand_fps[candidate_id] = ( + fingerprint_path(Path(rec.path)) if rec and rec.path else None + ) + return fingerprints_match(new_fp[0], cand_fps[candidate_id]) + + return confirm + def _get_or_create(self, stmt, factory): """Race-safe find-or-create. Run `stmt` (scalar_one_or_none); if a row exists, return it. Otherwise open a savepoint and INSERT @@ -862,6 +894,7 @@ class Importer: rel, match_id = find_similar( phash, width or 0, height or 0, candidates, self.settings.phash_threshold, + confirm=self._pixel_confirmer(source), ) if rel == "larger_exists": # Enrich-on-duplicate (parity with attach_in_place). @@ -1241,6 +1274,7 @@ class Importer: rel, match_id = find_similar( phash, width or 0, height or 0, candidates, self.settings.phash_threshold, + confirm=self._pixel_confirmer(path), ) if rel == "larger_exists": # Enrich-on-duplicate: link the near-dup's post to the diff --git a/backend/app/tasks/maintenance.py b/backend/app/tasks/maintenance.py index 9b8cd98..3a87924 100644 --- a/backend/app/tasks/maintenance.py +++ b/backend/app/tasks/maintenance.py @@ -502,10 +502,16 @@ def prune_task_runs() -> dict: soft_time_limit=1800, time_limit=2100, ) def backfill_phash() -> int: - """Recompute phash for stored images that have none (imported before - FC-2d-i+ii). Keyset-paginated by id (restart-safe), NULL-only fill, - idempotent. Videos legitimately keep phash NULL. A missing/unreadable - file is logged and left NULL — never fails the task.""" + """Recompute phash for stored images that have none. Keyset-paginated by + id (restart-safe), NULL-only fill, idempotent. Videos legitimately keep + phash NULL. A missing/unreadable file is logged and left NULL — never + fails the task. + + Two sources of NULLs: images imported before FC-2d-i+ii, and migration + 0098, which cleared every phash so the library could be re-hashed at + hash_size=16 (#4223). The daily beat entry exists for the second — until + a row is refilled it takes no part in dedup, which is why this runs on a + schedule rather than waiting for a deep scan.""" SessionLocal = _sync_session_factory() updated = 0 last_id = 0 diff --git a/backend/app/utils/phash.py b/backend/app/utils/phash.py index a610e96..f2d2ba0 100644 --- a/backend/app/utils/phash.py +++ b/backend/app/utils/phash.py @@ -1,57 +1,206 @@ """Perceptual-hash dedup helpers (ported from ImageRepo). -hash_size=8 -> 64-bit hash -> 16-hex-char string, which fits the existing -ImageRecord.phash String(32) column (no image_record migration). IR uses -hash_size=16; the deliberate FC deviation keeps the schema unchanged. The -Hamming threshold is the operator-exposed dial (ImportSettings). +hash_size=16 -> 256-bit hash -> 64-hex-char string, which is why +`ImageRecord.phash` is String(64) (widened in migration 0098). + +## Why 16, after running at 8 from FC-2d until 2026-09-21 + +`hash_size=8` keeps only the top-left 8x8 block of the DCT — 64 bits +describing an image's coarse light/dark layout and nothing else. Variant +artwork that shares a composition (same pose and framing, a different +outfit / expression / overlay) collided OUTRIGHT at that size, so the +operator's near-duplicate dial could not separate variants from rescales +even at its floor: `phash_threshold=0` means "the same 64 bits", not "the +same image", and packs of 15 variants were landing as 3 records +(issue #4223). IR always used 16; FC's deviation to 8 was made to fit the +old String(32) column without a migration — a schema convenience that cost +the operator artwork. + +## The hash no longer decides a merge on its own + +Dropping a file or superseding one is destructive, so `find_similar` now +runs three gates, cheapest first: + + 1. Hamming distance within the operator's `phash_threshold` — the cheap + indexed pre-filter that PROPOSES candidates. + 2. Aspect ratio within ASPECT_TOL. A crop or a re-canvas is not a + rescale, and this is the same identity test the tier-1 video near-dup + path already uses (`_VIDEO_DUP_ASPECT_TOL`). + 3. A pixel-level confirm on the candidate's actual file, supplied by the + caller (the importer opens the files; this module stays I/O-free apart + from `fingerprint_path`). + +Because the confirm is what ACCEPTS a match, the threshold can stay +generous enough to tolerate a re-encode without putting variants at risk. + +Every gate fails CLOSED: unknown dimensions, an unreadable candidate, a +hash that won't parse — all mean "not a duplicate". The two failure +directions are not symmetrical. Too strict keeps a redundant lower-res copy, +which the operator can see and delete; too loose deletes artwork that only +a re-walk of the source can bring back. """ import imagehash +from PIL import Image, ImageChops, ImageStat -HASH_SIZE = 8 +HASH_SIZE = 16 + +# Gate 2. Matching `importer._VIDEO_DUP_ASPECT_TOL` — a rescale preserves +# aspect ratio to within rounding, so this only has to absorb off-by-one +# pixel dimensions. +ASPECT_TOL = 0.02 + +# Gate 3. Both images are reduced to one FINGERPRINT_SIZE-square grayscale +# thumbnail and compared directly, which is the question actually being +# asked: "are these the same picture at different resolutions?" +# +# Two criteria, because they catch different things. MEAN absolute +# difference catches a global change (a recolour, a filter, a different +# shading pass) that leaves the composition intact. The CHANGED-PIXEL +# fraction catches a LOCAL one — an added overlay, a different expression, +# an alternate outfit on part of the figure — which a mean over 4096 pixels +# would otherwise dilute into noise. +# +# Tuned to fail closed (see the module docstring). A true rescale lands near +# zero on both; these ceilings sit well above that and well below a variant. +FINGERPRINT_SIZE = 64 +FINGERPRINT_MAX_MEAN_DIFF = 6.0 +FINGERPRINT_CHANGED_LEVEL = 64 +FINGERPRINT_MAX_CHANGED_FRACTION = 0.02 + + +def _seek_first_frame(pil_image) -> None: + """Animated images (multi-frame WebP/GIF/APNG) are hashed and + fingerprinted on frame 0 — the conventional choice for animated + content, and the one that keeps PIL from iterating every frame.""" + if getattr(pil_image, "is_animated", False): + try: + pil_image.seek(0) + except Exception: + pass def compute_phash(pil_image) -> str | None: """Perceptual hash of an opened PIL image, as a hex string. None on any failure (videos/unreadable/non-image). - For animated images (multi-frame WebP/GIF/APNG), explicitly seek to - frame 0 first. Without this, some PIL operations downstream of - imagehash.phash (convert("L"), resize) can iterate all frames and - blow past Celery's hard time limit on large animations - (operator-flagged 2026-05-26 against animated WebPs). The pHash of - frame 0 is the conventional choice for animated content. + Frame 0 for animated images: without the seek, PIL operations + downstream of imagehash.phash (convert("L"), resize) can iterate all + frames and blow past Celery's hard time limit on large animations + (operator-flagged 2026-05-26 against animated WebPs). """ try: - if getattr(pil_image, "is_animated", False): - try: - pil_image.seek(0) - except Exception: - pass + _seek_first_frame(pil_image) return str(imagehash.phash(pil_image, hash_size=HASH_SIZE)) except Exception: return None +def fingerprint(pil_image): + """A small grayscale thumbnail of an opened PIL image, for gate 3. + + Returns a detached PIL image (so the caller may close the original) or + None on any failure. PIL-only on purpose: numpy is an imagehash + transitive dependency, not a declared one for this path. + """ + try: + _seek_first_frame(pil_image) + return pil_image.convert("L").resize( + (FINGERPRINT_SIZE, FINGERPRINT_SIZE), Image.LANCZOS + ) + except Exception: + return None + + +def fingerprint_path(path) -> "Image.Image | None": + """`fingerprint` for a file on disk. None if it cannot be read — which + the gate treats as "not a duplicate".""" + try: + with Image.open(path) as im: + return fingerprint(im) + except Exception: + return None + + +def fingerprints_match( + a, b, + *, + max_mean_diff: float = FINGERPRINT_MAX_MEAN_DIFF, + changed_level: int = FINGERPRINT_CHANGED_LEVEL, + max_changed_fraction: float = FINGERPRINT_MAX_CHANGED_FRACTION, +) -> bool: + """True when two fingerprints are the same picture: no large global + drift AND no meaningful local region that differs. False on any + failure.""" + if a is None or b is None: + return False + try: + diff = ImageChops.difference(a, b) + if ImageStat.Stat(diff).mean[0] > max_mean_diff: + return False + hist = diff.histogram() + total = sum(hist) + if not total: + return False + changed = sum(hist[changed_level:]) + return (changed / total) <= max_changed_fraction + except Exception: + return False + + +def aspect_matches( + width: int | None, height: int | None, + cand_width: int | None, cand_height: int | None, + *, tol: float = ASPECT_TOL, +) -> bool: + """Gate 2. False when either side's dimensions are unknown — an + unmeasurable candidate is not a proven duplicate.""" + if not width or not height or not cand_width or not cand_height: + return False + a, b = width / height, cand_width / cand_height + if a <= 0 or b <= 0: + return False + return abs(a - b) / max(a, b) <= tol + + def find_similar( phash_hex: str, width: int, height: int, candidates: list[tuple[str, int, int, int]], threshold: int, + *, + confirm=None, ) -> tuple[str, int | None]: """candidates: (phash_hex, width, height, image_id). Returns one of ("none", None) / ("larger_exists", id) / ("smaller_exists", id). - First qualifying candidate wins (IR loop order).""" + First candidate to pass EVERY gate wins (IR loop order). + + `confirm` is gate 3: an optional callable(image_id) -> bool, called only + for a candidate that already passed the hash and aspect gates, and + expected to compare the two files' pixels. A rejected candidate does not + end the search — the loop moves on, so a false pre-filter hit cannot + mask a real duplicate further down the list. Omitting it leaves the + hash+aspect behaviour, which is what the unit tests exercise. + """ new_h = imagehash.hex_to_hash(phash_hex) for cand_hex, cw, ch, cid in candidates: try: dist = new_h - imagehash.hex_to_hash(cand_hex) except Exception: + # Includes the mismatched-length case while a library re-hash + # (migration 0098) is still in flight: an old 64-bit hash cannot + # be compared to a new 256-bit one, and skipping it degrades to + # "no dedup yet" rather than to a wrong merge. continue - if dist <= threshold: - if cw >= width and ch >= height: - return ("larger_exists", cid) - if width > cw or height > ch: - return ("smaller_exists", cid) + if dist > threshold: + continue + if not aspect_matches(width, height, cw, ch): + continue + if confirm is not None and not confirm(cid): + continue + if cw >= width and ch >= height: + return ("larger_exists", cid) + if width > cw or height > ch: + return ("smaller_exists", cid) return ("none", None) diff --git a/frontend/src/components/settings/ImportFiltersForm.vue b/frontend/src/components/settings/ImportFiltersForm.vue index 0ffb849..903342f 100644 --- a/frontend/src/components/settings/ImportFiltersForm.vue +++ b/frontend/src/components/settings/ImportFiltersForm.vue @@ -10,16 +10,20 @@