feat(phash): importer near-dup dispatch + in-place supersession (clear ML)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ from sqlalchemy.orm import Session
|
||||
from ..models import Artist, ImageRecord, ImportSettings, Tag, TagKind
|
||||
from ..models.tag import image_tag
|
||||
from ..utils.paths import derive_subdir, derive_top_level_artist, hash_suffixed_name
|
||||
from ..utils.phash import compute_phash, find_similar
|
||||
from ..utils.slug import slugify
|
||||
from .thumbnailer import Thumbnailer
|
||||
|
||||
@@ -30,12 +31,13 @@ class SkipReason(StrEnum):
|
||||
too_transparent = "too_transparent"
|
||||
single_color = "single_color"
|
||||
duplicate_hash = "duplicate_hash"
|
||||
duplicate_phash = "duplicate_phash"
|
||||
invalid_image = "invalid_image"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportResult:
|
||||
status: str # 'imported' | 'skipped' | 'failed'
|
||||
status: str # 'imported' | 'skipped' | 'failed' | 'superseded'
|
||||
image_id: int | None = None
|
||||
skip_reason: SkipReason | None = None
|
||||
error: str | None = None
|
||||
@@ -152,7 +154,7 @@ class Importer:
|
||||
error=f"{pct:.2%} transparent",
|
||||
)
|
||||
|
||||
# Hash dedup.
|
||||
# Hash dedup (exact).
|
||||
sha = _sha256_of(source)
|
||||
existing_stmt = select(ImageRecord).where(ImageRecord.sha256 == sha)
|
||||
existing = self.session.execute(existing_stmt).scalar_one_or_none()
|
||||
@@ -162,6 +164,42 @@ class Importer:
|
||||
image_id=existing.id, error="sha256 already present",
|
||||
)
|
||||
|
||||
# Perceptual near-dup (images only; videos keep phash NULL).
|
||||
phash = None
|
||||
if not is_video(source):
|
||||
with Image.open(source) as im:
|
||||
phash = compute_phash(im)
|
||||
if phash is not None:
|
||||
cand_rows = self.session.execute(
|
||||
select(
|
||||
ImageRecord.phash,
|
||||
ImageRecord.width,
|
||||
ImageRecord.height,
|
||||
ImageRecord.id,
|
||||
).where(ImageRecord.phash.is_not(None))
|
||||
).all()
|
||||
candidates = [
|
||||
(c.phash, c.width or 0, c.height or 0, c.id)
|
||||
for c in cand_rows
|
||||
]
|
||||
rel, match_id = find_similar(
|
||||
phash, width or 0, height or 0,
|
||||
candidates, self.settings.phash_threshold,
|
||||
)
|
||||
if rel == "larger_exists":
|
||||
return ImportResult(
|
||||
status="skipped",
|
||||
skip_reason=SkipReason.duplicate_phash,
|
||||
image_id=match_id,
|
||||
error="perceptual near-duplicate of larger existing image",
|
||||
)
|
||||
if rel == "smaller_exists":
|
||||
target = self.session.get(ImageRecord, match_id)
|
||||
self._supersede(target, source, sha, phash, width, height)
|
||||
return ImportResult(
|
||||
status="superseded", image_id=match_id
|
||||
)
|
||||
|
||||
# Destination path.
|
||||
subdir = derive_subdir(source, self.import_root)
|
||||
dest_dir = self.images_root / subdir if subdir else self.images_root
|
||||
@@ -177,6 +215,7 @@ class Importer:
|
||||
record = ImageRecord(
|
||||
path=str(dest),
|
||||
sha256=sha,
|
||||
phash=phash,
|
||||
size_bytes=dest.stat().st_size,
|
||||
mime=_mime_for(source),
|
||||
width=width,
|
||||
@@ -232,6 +271,53 @@ class Importer:
|
||||
)
|
||||
)
|
||||
|
||||
def _supersede(
|
||||
self, existing: ImageRecord, source: Path, sha: str,
|
||||
phash: str, width: int | None, height: int | None,
|
||||
) -> None:
|
||||
"""Replace `existing`'s file with the larger `source`, keeping the
|
||||
row id (so tags/series/curation stay attached). ML is cleared so
|
||||
the import task re-derives it on the new pixels."""
|
||||
subdir = derive_subdir(source, self.import_root)
|
||||
dest_dir = self.images_root / subdir if subdir else self.images_root
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
dest = dest_dir / hash_suffixed_name(source.stem, sha, source.suffix)
|
||||
partial = dest.with_suffix(dest.suffix + ".partial")
|
||||
shutil.copy2(source, partial)
|
||||
partial.rename(dest)
|
||||
|
||||
old_path = existing.path
|
||||
old_thumb = existing.thumbnail_path
|
||||
|
||||
existing.path = str(dest)
|
||||
existing.sha256 = sha
|
||||
existing.phash = phash
|
||||
existing.size_bytes = dest.stat().st_size
|
||||
existing.mime = _mime_for(source)
|
||||
existing.width = width
|
||||
existing.height = height
|
||||
existing.thumbnail_path = None
|
||||
existing.integrity_status = "unknown"
|
||||
existing.tagger_predictions = None
|
||||
existing.tagger_model_version = None
|
||||
existing.siglip_embedding = None
|
||||
existing.siglip_model_version = None
|
||||
existing.centroid_scores = None
|
||||
# created_at intentionally preserved; updated_at auto-bumps.
|
||||
self.session.flush()
|
||||
self.session.commit()
|
||||
|
||||
for stale in (old_path, old_thumb):
|
||||
if not stale:
|
||||
continue
|
||||
try:
|
||||
p = Path(stale)
|
||||
if p.exists():
|
||||
p.unlink()
|
||||
except Exception:
|
||||
# Benign orphan; the DB swap already committed. Don't undo it.
|
||||
pass
|
||||
|
||||
def _transparency_pct(self, source: Path) -> float:
|
||||
"""Fraction of fully-transparent pixels in the image. 0.0 if no alpha."""
|
||||
with Image.open(source) as im:
|
||||
|
||||
Reference in New Issue
Block a user