Files
FabledCurator/backend/app/models/image_record.py
T
bvandeusenandClaude Opus 5 a05cb66635
CI / lint (push) Failing after 3s
CI / extension-version (push) Successful in 3s
Build images / sign-extension (push) Successful in 4s
Build images / build-agent (push) Successful in 7s
CI / frontend-build (push) Successful in 30s
CI / backend-lint-and-test (push) Successful in 1m8s
Build images / build-web (push) Successful in 1m28s
Build images / smoke-web (push) Skipped
CI / integration (push) Successful in 2m51s
Build images / build-ml (push) Successful in 2m59s
Build images / promote (push) Skipped
fix: variant artwork was dropped as a near-duplicate even at threshold 0 (4223)
The operator reported a 15-image variant pack landing as 3 records, then
reported variants STILL being dropped with phash_threshold at 0 — the floor
of the dial. No setting could have fixed it: at hash_size=8 a pHash is 64
bits of coarse light/dark layout, so two variants sharing a composition
produce the SAME bits. Distance 0 meant "identical hash", not "identical
image", and the dial was simultaneously too coarse to keep variants and too
tight to catch a re-encoded rescale.

The hash no longer decides a merge on its own. find_similar now runs three
gates, cheapest first: the threshold proposes candidates, aspect ratio
(ASPECT_TOL, matching the tier-1 video path) rejects crops and re-canvases,
and a pixel-level confirm on the two files accepts. Every gate fails closed —
unknown dimensions, an unreadable candidate, a hash of the wrong width all
mean "not a duplicate", because too strict keeps a redundant copy the
operator can see while too loose deletes artwork only a source re-walk
returns.

- utils/phash.py: HASH_SIZE 8 -> 16 (256-bit, what ImageRepo always used);
  aspect_matches, fingerprint/fingerprint_path/fingerprints_match (PIL-only,
  mean drift + changed-pixel fraction), find_similar gains `confirm`.
- importer: _pixel_confirmer supplies gate 3 on both dedup sites, lazily and
  cached, so a non-matching import costs no extra I/O.
- 0098: widens image_record.phash to 64 chars and NULLs every value — a
  stored 64-bit hash cannot be compared to a 256-bit one, and backfill_phash
  is NULL-only, keyset-paginated and now on the daily beat, so the library
  re-hashes itself. Dedup degrades to sha256 until it finishes.
- phash_threshold counts bits and the denominator went 64 -> 256, so the
  setting is reset to the new default of 24 (there is no honest carry-over)
  and the slider is rescaled to 0-64.
- gallery_service dup_threshold 8 -> 32: the same fraction of the hash, so
  the Explore rail keeps the variance the operator tuned in on 2026-07-01.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LVjrnpQjRgHdvq95rASoiR
2026-09-21 00:16:14 -04:00

158 lines
7.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ImageRecord — the gallery's primary entity, ported from ImageRepo.
ML fields and thumbnails are declared now (in FC-1) so FC-2 can populate them
without a schema migration. The SigLIP embedding column uses pgvector's Vector
type — pgvector extension is enabled in the initial migration.
"""
from datetime import datetime
from pgvector.sqlalchemy import Vector
from sqlalchemy import (
BigInteger,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import Mapped, mapped_column
from .base import Base
ORIGIN_CHOICES = ("downloaded", "imported_filesystem", "uploaded")
class ImageRecord(Base):
__tablename__ = "image_record"
__table_args__ = (
# alembic 0001. The database enforces sha256 uniqueness with a
# CONSTRAINT and carries a SEPARATE non-unique btree index; the model
# said `unique=True, index=True`, which collapses both into a single
# UNIQUE index under a different name. Same guarantee either way, but
# not the same objects, so autogenerate saw a drop and an add (#3275).
UniqueConstraint("sha256", name="uq_image_record_sha256"),
# alembic 0036, and the last thing in this schema that lived only in a
# migration. SQLAlchemy CAN express an hnsw index with an operator
# class, so there is no reason for it to be invisible to the models —
# and its absence was the quietest failure of the lot: everything
# works, similarity search just silently stops using an index.
Index(
"ix_image_record_siglip_hnsw",
"siglip_embedding",
postgresql_using="hnsw",
postgresql_ops={"siglip_embedding": "vector_cosine_ops"},
),
# alembic 0035/0071: the date-ordered browse indexes (#3275).
Index("ix_image_record_effective_date", text("effective_date DESC"), text("id DESC")),
Index("ix_image_record_earliest_post_date", text("earliest_post_date DESC"), text("id DESC")),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# On-disk identity
path: Mapped[str] = mapped_column(Text, nullable=False, unique=True)
# Neither unique= nor index=: uq_image_record_sha256 in __table_args__
# above creates its own index, and the separate ix_image_record_sha256
# 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)
# 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)
height: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Video container duration (seconds); NULL for images. The Tier-1 video
# near-dup key (#871): two videos of the same artist with matching duration
# (+ aspect) are the same content across re-encodes — dedup like image pHash.
duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True)
# Integrity verification status. FC-2e populates this; FC-2a leaves rows at 'unknown'.
# Values: 'unknown' (default), 'ok', 'corrupt', 'failed_verification'.
integrity_status: Mapped[str] = mapped_column(
String(24), nullable=False, default="unknown", index=True,
server_default="unknown",
)
# Thumbnail (populated by FC-2)
thumbnail_path: Mapped[str | None] = mapped_column(Text, nullable=True)
# Source provenance for downloaded media (#830 Phase 2). `source_url` is the
# CDN/origin URL the file was fetched from (debugging + future re-fetch).
# `source_filehash` is the URL's 32-hex CDN identity segment
# (utils.paths.filehash_from_url) — the JOIN KEY that maps a post body's
# inline `<img src=CDN>` back to this local copy so the rendered body serves
# our stored image instead of hotlinking the public source. Indexed for the
# render-time lookup. NULL for filesystem-imported / pre-Phase-2 rows.
source_url: Mapped[str | None] = mapped_column(Text, nullable=True)
source_filehash: Mapped[str | None] = mapped_column(
String(32), nullable=True, index=True
)
# Origin / provenance pointers
origin: Mapped[str] = mapped_column(Enum(*ORIGIN_CHOICES, name="origin_enum"), nullable=False)
primary_post_id: Mapped[int | None] = mapped_column(
ForeignKey("post.id", ondelete="SET NULL"), nullable=True, index=True
)
# FC-2d-vii-c: canonical per-image artist (the single source of truth
# for attribution; provenance posts remain lineage detail).
# FK named explicitly: the naming convention renders this
# `fk_image_record_artist_id_artist`, but alembic 0008 created it as
# `fk_image_record_artist_id` (#3275).
artist_id: Mapped[int | None] = mapped_column(
ForeignKey(
"artist.id", ondelete="SET NULL", name="fk_image_record_artist_id"
),
nullable=True,
index=True,
)
# ML fields (populated by the ml-worker / GPU agent). 1152 = SigLIP-so400m
# embedding dim; siglip_model_version stamps which model produced it (so an
# operator model swap, #1190, can re-embed the stale rows). A different-dim
# model would need a column-width migration.
siglip_embedding: Mapped[list[float] | None] = mapped_column(Vector(1152), nullable=True)
siglip_model_version: Mapped[str | None] = mapped_column(String(128), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Denormalized gallery sort key = COALESCE(primary post's post_date,
# created_at) (alembic 0035). The gallery used to compute this as a
# COALESCE across the Post outer join on every /scroll, which can't use
# an index and re-sorted a large slice of the library per page (×10 with
# the old serial batching). Materializing it lets the cursor scroll read
# ix_image_record_effective_date directly. Maintained by the importer
# (services/importer.py _apply_sidecar) when a primary post with a date
# is linked; plain inserts keep the created_at-equivalent server default.
effective_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
# Denormalized ORIGINAL-publish sort key (alembic 0071) = MIN(post_date)
# across ALL of the image's provenance posts, else created_at. effective_date
# above keys off the PRIMARY post (often the repost/download the file came
# from); this keys off the earliest publish across EVERY post the image
# appears in, so the gallery can sort by when content was first posted rather
# than when it was downloaded (operator-flagged 2026-07-01). Maintained by
# services/importer.py, recomputed whenever a dated post is linked.
earliest_post_date: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, server_default=func.now()
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
server_default=func.now(),
onupdate=func.now(),
)