US01-04: Detect and Decide Duplicates #49
99
migrations/versions/0002_duplicate_engine.py
Normal file
99
migrations/versions/0002_duplicate_engine.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Duplicate engine: canonical link, phash version, clusters, members, negative links.
|
||||
|
||||
Revision ID: 0002_duplicate_engine
|
||||
Revises: 0001_initial_identity
|
||||
Create Date: 2026-07-15
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0002_duplicate_engine"
|
||||
down_revision = "0001_initial_identity"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Plain ADD COLUMN: SQLite cannot ALTER-ADD a foreign key, and the ORM model
|
||||
# keeps the ForeignKey for relationship metadata. canonical_asset_id is a soft
|
||||
# self-link validated in the service (no cycles), not DB-enforced.
|
||||
op.add_column("assets", sa.Column("phash_version", sa.Integer(), nullable=True))
|
||||
op.add_column("assets", sa.Column("canonical_asset_id", sa.String(), nullable=True))
|
||||
op.create_index("ix_assets_canonical_asset_id", "assets", ["canonical_asset_id"])
|
||||
|
||||
op.create_table(
|
||||
"duplicate_clusters",
|
||||
sa.Column("id", sa.String(), primary_key=True),
|
||||
sa.Column("method", sa.String(), nullable=False),
|
||||
sa.Column("confidence", sa.String(), nullable=False),
|
||||
sa.Column("state", sa.String(), nullable=False),
|
||||
sa.Column("decision", sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
"canonical_asset_id", sa.String(), sa.ForeignKey("assets.id"), nullable=True
|
||||
),
|
||||
sa.Column("version", sa.Integer(), nullable=False, server_default="1"),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
sa.Column("decided_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.create_index("ix_duplicate_clusters_state", "duplicate_clusters", ["state"])
|
||||
|
||||
op.create_table(
|
||||
"duplicate_members",
|
||||
sa.Column(
|
||||
"cluster_id",
|
||||
sa.String(),
|
||||
sa.ForeignKey("duplicate_clusters.id"),
|
||||
primary_key=True,
|
||||
),
|
||||
sa.Column(
|
||||
"asset_id", sa.String(), sa.ForeignKey("assets.id"), primary_key=True
|
||||
),
|
||||
sa.Column("role", sa.String(), nullable=False, server_default="member"),
|
||||
sa.Column("distance", sa.Integer(), nullable=True),
|
||||
sa.Column("evidence", sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
"added_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
op.create_index("ix_duplicate_members_asset_id", "duplicate_members", ["asset_id"])
|
||||
|
||||
op.create_table(
|
||||
"duplicate_negative_links",
|
||||
sa.Column(
|
||||
"asset_a", sa.String(), sa.ForeignKey("assets.id"), primary_key=True
|
||||
),
|
||||
sa.Column(
|
||||
"asset_b", sa.String(), sa.ForeignKey("assets.id"), primary_key=True
|
||||
),
|
||||
sa.Column("reason", sa.String(), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("duplicate_negative_links")
|
||||
op.drop_table("duplicate_members")
|
||||
op.drop_table("duplicate_clusters")
|
||||
op.drop_index("ix_assets_canonical_asset_id", "assets")
|
||||
op.drop_column("assets", "canonical_asset_id")
|
||||
op.drop_column("assets", "phash_version")
|
||||
@@ -5,5 +5,16 @@ Alembic environment relies on.
|
||||
"""
|
||||
|
||||
from photo_pipeline.models.assets import Asset, AssetPath
|
||||
from photo_pipeline.models.duplicates import (
|
||||
DuplicateCluster,
|
||||
DuplicateMember,
|
||||
DuplicateNegativeLink,
|
||||
)
|
||||
|
||||
__all__ = ["Asset", "AssetPath"]
|
||||
__all__ = [
|
||||
"Asset",
|
||||
"AssetPath",
|
||||
"DuplicateCluster",
|
||||
"DuplicateMember",
|
||||
"DuplicateNegativeLink",
|
||||
]
|
||||
|
||||
@@ -30,11 +30,14 @@ class Asset(Base):
|
||||
pixel_sha256: Mapped[str | None] = mapped_column(String)
|
||||
phash: Mapped[str | None] = mapped_column(String)
|
||||
hash_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
phash_version: Mapped[int | None] = mapped_column(Integer)
|
||||
byte_size: Mapped[int | None] = mapped_column(Integer)
|
||||
|
||||
discovered_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
missing_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
availability_state: Mapped[str] = mapped_column(String, nullable=False, default="active")
|
||||
# Duplicate canonical link: NULL when the asset is itself canonical or undecided.
|
||||
canonical_asset_id: Mapped[str | None] = mapped_column(ForeignKey("assets.id"))
|
||||
|
||||
state_version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
|
||||
62
photo_pipeline/models/duplicates.py
Normal file
62
photo_pipeline/models/duplicates.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Duplicate clusters, their members, and not-duplicate negative links.
|
||||
|
||||
A cluster is a reviewable grouping produced by a detection ``method`` (exact,
|
||||
pixel, or perceptual) at a ``confidence`` band. Exact/pixel clusters can be
|
||||
decided automatically; perceptual clusters stay open for human review. Every
|
||||
member carries the evidence behind its inclusion. Negative links record
|
||||
``not_duplicate`` decisions so rescans do not re-suggest a rejected pair.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from photo_pipeline.db import Base
|
||||
|
||||
|
||||
class DuplicateCluster(Base):
|
||||
__tablename__ = "duplicate_clusters"
|
||||
|
||||
id: Mapped[str] = mapped_column(String, primary_key=True)
|
||||
method: Mapped[str] = mapped_column(String, nullable=False) # exact|pixel|perceptual
|
||||
confidence: Mapped[str] = mapped_column(String, nullable=False) # band name
|
||||
state: Mapped[str] = mapped_column(String, nullable=False) # open|decided|dismissed|reopened
|
||||
decision: Mapped[str | None] = mapped_column(String) # canonical|not_duplicate|deferred
|
||||
canonical_asset_id: Mapped[str | None] = mapped_column(ForeignKey("assets.id"))
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
created_at: 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()
|
||||
)
|
||||
decided_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
|
||||
class DuplicateMember(Base):
|
||||
__tablename__ = "duplicate_members"
|
||||
|
||||
cluster_id: Mapped[str] = mapped_column(
|
||||
ForeignKey("duplicate_clusters.id"), primary_key=True
|
||||
)
|
||||
asset_id: Mapped[str] = mapped_column(ForeignKey("assets.id"), primary_key=True)
|
||||
role: Mapped[str] = mapped_column(String, nullable=False, default="member") # member|canonical|variant
|
||||
distance: Mapped[int | None] = mapped_column(Integer) # phash distance to representative
|
||||
evidence: Mapped[str | None] = mapped_column(String) # JSON blob explaining inclusion
|
||||
added_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
|
||||
|
||||
class DuplicateNegativeLink(Base):
|
||||
__tablename__ = "duplicate_negative_links"
|
||||
|
||||
asset_a: Mapped[str] = mapped_column(ForeignKey("assets.id"), primary_key=True)
|
||||
asset_b: Mapped[str] = mapped_column(ForeignKey("assets.id"), primary_key=True)
|
||||
reason: Mapped[str | None] = mapped_column(String)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, server_default=func.now()
|
||||
)
|
||||
568
photo_pipeline/services/duplicates.py
Normal file
568
photo_pipeline/services/duplicates.py
Normal file
@@ -0,0 +1,568 @@
|
||||
"""DuplicateService — explainable clusters and reversible decisions.
|
||||
|
||||
Detection runs in two categories:
|
||||
|
||||
- **Content-identical** clusters join assets that share exact bytes (``sha256``) or
|
||||
normalized pixels (``pixel_sha256``). These are high confidence and are decided
|
||||
automatically (a recommended canonical, the rest variants) — unless a member pair
|
||||
carries a ``not_duplicate`` negative link, which is a contradiction that reopens
|
||||
the cluster for review instead of silently overriding the user.
|
||||
- **Perceptual** clusters join assets whose perceptual hashes are within a distance
|
||||
band (NEAR/SIMILAR). These are review candidates: never decided automatically, and
|
||||
negative-linked pairs are suppressed so a rejected pair is not re-suggested.
|
||||
|
||||
Decisions (``canonical`` / ``not_duplicate`` / ``deferred``) persist with evidence,
|
||||
use optimistic version checks, are reversible, and can never form a canonical cycle.
|
||||
A new content-identical member of an already-decided cluster inherits the established
|
||||
canonical relationship rather than reopening it.
|
||||
|
||||
Extracts photo_analyzer.cluster_duplicates/mark_duplicates (union-find over the
|
||||
Hamming graph); path-keyed identity and automatic largest-file selection are
|
||||
replaced by stable asset IDs and reviewable decisions (donor_ledger.yaml: pa-dupes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from photo_pipeline.models import (
|
||||
Asset,
|
||||
DuplicateCluster,
|
||||
DuplicateMember,
|
||||
DuplicateNegativeLink,
|
||||
)
|
||||
from photo_pipeline.services import hashing
|
||||
|
||||
NEAR_MAX = 5
|
||||
SIMILAR_MAX = 10
|
||||
|
||||
|
||||
class Method(str, Enum):
|
||||
EXACT = "exact"
|
||||
PIXEL = "pixel"
|
||||
PERCEPTUAL = "perceptual"
|
||||
|
||||
|
||||
class Confidence(str, Enum):
|
||||
EXACT = "exact"
|
||||
PIXEL = "pixel"
|
||||
NEAR = "near"
|
||||
SIMILAR = "similar"
|
||||
|
||||
|
||||
class ClusterState(str, Enum):
|
||||
OPEN = "open"
|
||||
DECIDED = "decided"
|
||||
DISMISSED = "dismissed"
|
||||
REOPENED = "reopened"
|
||||
DEFERRED = "deferred"
|
||||
|
||||
|
||||
class Decision(str, Enum):
|
||||
CANONICAL = "canonical"
|
||||
NOT_DUPLICATE = "not_duplicate"
|
||||
DEFERRED = "deferred"
|
||||
|
||||
|
||||
class Role(str, Enum):
|
||||
MEMBER = "member"
|
||||
CANONICAL = "canonical"
|
||||
VARIANT = "variant"
|
||||
|
||||
|
||||
class DuplicateError(RuntimeError):
|
||||
"""Invalid decision (unknown cluster, non-member canonical, would cycle)."""
|
||||
|
||||
|
||||
class ConflictError(RuntimeError):
|
||||
"""Optimistic version check failed; the cluster changed since it was read."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class DetectionReport:
|
||||
clusters: list[dict] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def counts(self) -> dict[str, int]:
|
||||
return dict(Counter(c["state"] for c in self.clusters))
|
||||
|
||||
|
||||
def _pair(a: str, b: str) -> tuple[str, str]:
|
||||
return (a, b) if a <= b else (b, a)
|
||||
|
||||
|
||||
class _UnionFind:
|
||||
def __init__(self, items):
|
||||
self.parent = {x: x for x in items}
|
||||
|
||||
def find(self, x):
|
||||
while self.parent[x] != x:
|
||||
self.parent[x] = self.parent[self.parent[x]]
|
||||
x = self.parent[x]
|
||||
return x
|
||||
|
||||
def union(self, a, b):
|
||||
ra, rb = self.find(a), self.find(b)
|
||||
if ra != rb:
|
||||
self.parent[rb] = ra
|
||||
|
||||
def groups(self) -> list[list[str]]:
|
||||
out: dict[str, list[str]] = {}
|
||||
for x in self.parent:
|
||||
out.setdefault(self.find(x), []).append(x)
|
||||
return [sorted(g) for g in out.values()]
|
||||
|
||||
|
||||
class DuplicateService:
|
||||
def __init__(self, session_factory: sessionmaker) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
# ── perceptual hash backfill ───────────────────────────────────────────
|
||||
def ensure_phashes(self) -> int:
|
||||
updated = 0
|
||||
with self._session_factory() as session:
|
||||
assets = session.execute(
|
||||
select(Asset).where(
|
||||
Asset.availability_state == "active",
|
||||
Asset.current_path.isnot(None),
|
||||
)
|
||||
).scalars()
|
||||
for asset in assets:
|
||||
if asset.phash is not None and asset.phash_version == hashing.PHASH_VERSION:
|
||||
continue
|
||||
value = hashing.safe_phash(asset.current_path)
|
||||
if value is not None:
|
||||
asset.phash = value
|
||||
asset.phash_version = hashing.PHASH_VERSION
|
||||
updated += 1
|
||||
session.commit()
|
||||
return updated
|
||||
|
||||
# ── detection ──────────────────────────────────────────────────────────
|
||||
def detect(self) -> DetectionReport:
|
||||
self.ensure_phashes()
|
||||
now = datetime.now(timezone.utc)
|
||||
report = DetectionReport()
|
||||
with self._session_factory() as session:
|
||||
assets = list(
|
||||
session.execute(
|
||||
select(Asset).where(
|
||||
Asset.availability_state == "active",
|
||||
Asset.current_path.isnot(None),
|
||||
)
|
||||
).scalars()
|
||||
)
|
||||
by_id = {a.id: a for a in assets}
|
||||
negatives = {
|
||||
_pair(link.asset_a, link.asset_b)
|
||||
for link in session.execute(select(DuplicateNegativeLink)).scalars()
|
||||
}
|
||||
clusters = list(session.execute(select(DuplicateCluster)).scalars())
|
||||
members = {
|
||||
c.id: list(
|
||||
session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == c.id)
|
||||
).scalars()
|
||||
)
|
||||
for c in clusters
|
||||
}
|
||||
|
||||
content_groups, content_pairs = self._content_groups(assets)
|
||||
perceptual_groups = self._perceptual_groups(assets, content_pairs, negatives)
|
||||
|
||||
touched: list[DuplicateCluster] = []
|
||||
for method, ids, distances in content_groups:
|
||||
touched.append(
|
||||
self._sync_content_cluster(
|
||||
session, method, ids, by_id, negatives, clusters, members, now
|
||||
)
|
||||
)
|
||||
for ids, distances in perceptual_groups:
|
||||
touched.append(
|
||||
self._sync_perceptual_cluster(
|
||||
session, ids, distances, by_id, clusters, members, now
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
report.clusters = [self._snapshot(session, c.id) for c in touched]
|
||||
return report
|
||||
|
||||
def _content_groups(self, assets):
|
||||
"""Union-find over exact-byte and same-pixel edges. Returns groups of >=2
|
||||
with method EXACT (all same bytes) or PIXEL, plus the set of joined pairs."""
|
||||
uf = _UnionFind([a.id for a in assets])
|
||||
pairs: set[tuple[str, str]] = set()
|
||||
for key in ("current_sha256", "pixel_sha256"):
|
||||
buckets: dict[str, list[str]] = {}
|
||||
for a in assets:
|
||||
value = getattr(a, key)
|
||||
if value:
|
||||
buckets.setdefault(value, []).append(a.id)
|
||||
for group in buckets.values():
|
||||
for other in group[1:]:
|
||||
uf.union(group[0], other)
|
||||
for i in range(len(group)):
|
||||
for j in range(i + 1, len(group)):
|
||||
pairs.add(_pair(group[i], group[j]))
|
||||
by_id = {a.id: a for a in assets}
|
||||
result = []
|
||||
for group in uf.groups():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
shas = {by_id[i].current_sha256 for i in group}
|
||||
method = Method.EXACT if len(shas) == 1 and None not in shas else Method.PIXEL
|
||||
result.append((method, sorted(group), {}))
|
||||
return result, pairs
|
||||
|
||||
def _perceptual_groups(self, assets, content_pairs, negatives):
|
||||
"""Union-find over phash distance edges (<= SIMILAR_MAX), excluding pairs
|
||||
that are already content-identical or negatively linked."""
|
||||
hashed = [a for a in assets if a.phash]
|
||||
uf = _UnionFind([a.id for a in hashed])
|
||||
distances: dict[tuple[str, str], int] = {}
|
||||
for i in range(len(hashed)):
|
||||
for j in range(i + 1, len(hashed)):
|
||||
a, b = hashed[i], hashed[j]
|
||||
pair = _pair(a.id, b.id)
|
||||
if pair in content_pairs or pair in negatives:
|
||||
continue
|
||||
dist = hashing.phash_distance(a.phash, b.phash)
|
||||
if dist <= SIMILAR_MAX:
|
||||
uf.union(a.id, b.id)
|
||||
distances[pair] = dist
|
||||
result = []
|
||||
for group in uf.groups():
|
||||
if len(group) < 2:
|
||||
continue
|
||||
group_dists = {
|
||||
_pair(x, y): distances[_pair(x, y)]
|
||||
for x in group
|
||||
for y in group
|
||||
if x < y and _pair(x, y) in distances
|
||||
}
|
||||
result.append((sorted(group), group_dists))
|
||||
return result
|
||||
|
||||
# ── cluster upserts ──────────────────────────────────────────────────────
|
||||
def _match_existing(self, ids, clusters, members, categories):
|
||||
best = None
|
||||
best_overlap = 0
|
||||
for cluster in clusters:
|
||||
if cluster.method not in categories:
|
||||
continue
|
||||
overlap = len(set(ids) & {m.asset_id for m in members[cluster.id]})
|
||||
if overlap > best_overlap:
|
||||
best, best_overlap = cluster, overlap
|
||||
return best
|
||||
|
||||
def _sync_content_cluster(
|
||||
self, session, method, ids, by_id, negatives, clusters, members, now
|
||||
):
|
||||
contradiction = any(
|
||||
_pair(ids[i], ids[j]) in negatives
|
||||
for i in range(len(ids))
|
||||
for j in range(i + 1, len(ids))
|
||||
)
|
||||
confidence = Confidence.EXACT if method == Method.EXACT else Confidence.PIXEL
|
||||
# Also consider a prior perceptual cluster over the same assets: if a member
|
||||
# pair became byte-identical after a not_duplicate decision, reopen that same
|
||||
# reviewed cluster rather than spawning a parallel one.
|
||||
existing = self._match_existing(
|
||||
ids,
|
||||
clusters,
|
||||
members,
|
||||
{Method.EXACT.value, Method.PIXEL.value, Method.PERCEPTUAL.value},
|
||||
)
|
||||
|
||||
if existing is None:
|
||||
cluster = DuplicateCluster(
|
||||
id=str(uuid.uuid4()),
|
||||
method=method.value,
|
||||
confidence=confidence.value,
|
||||
state=ClusterState.OPEN.value,
|
||||
version=1,
|
||||
)
|
||||
session.add(cluster)
|
||||
session.flush()
|
||||
self._replace_members(session, cluster, ids, by_id, distances={})
|
||||
if contradiction:
|
||||
cluster.state = ClusterState.REOPENED.value
|
||||
else:
|
||||
self._auto_decide(session, cluster, ids, by_id, now)
|
||||
clusters.append(cluster)
|
||||
members[cluster.id] = list(
|
||||
session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars()
|
||||
)
|
||||
return cluster
|
||||
|
||||
cluster = existing
|
||||
cluster.method = method.value
|
||||
cluster.confidence = confidence.value
|
||||
prior_members = {m.asset_id for m in members[cluster.id]}
|
||||
added = set(ids) - prior_members
|
||||
self._replace_members(session, cluster, ids, by_id, distances={})
|
||||
|
||||
if contradiction:
|
||||
cluster.state = ClusterState.REOPENED.value
|
||||
cluster.decision = None
|
||||
cluster.canonical_asset_id = None
|
||||
self._clear_asset_links(session, ids)
|
||||
elif (
|
||||
cluster.decision == Decision.CANONICAL.value
|
||||
and cluster.canonical_asset_id in ids
|
||||
):
|
||||
# Consistent growth: new members inherit the established canonical.
|
||||
self._apply_canonical(session, cluster, ids, cluster.canonical_asset_id)
|
||||
cluster.state = ClusterState.DECIDED.value
|
||||
elif cluster.state in (ClusterState.DISMISSED.value,) and added:
|
||||
cluster.state = ClusterState.REOPENED.value
|
||||
elif cluster.state in (ClusterState.OPEN.value, ClusterState.REOPENED.value):
|
||||
self._auto_decide(session, cluster, ids, by_id, now)
|
||||
cluster.version += 1
|
||||
cluster.updated_at = now
|
||||
members[cluster.id] = list(
|
||||
session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars()
|
||||
)
|
||||
return cluster
|
||||
|
||||
def _sync_perceptual_cluster(self, session, ids, distances, by_id, clusters, members, now):
|
||||
confidence = (
|
||||
Confidence.NEAR
|
||||
if distances and max(distances.values()) <= NEAR_MAX
|
||||
else Confidence.SIMILAR
|
||||
)
|
||||
existing = self._match_existing(ids, clusters, members, {Method.PERCEPTUAL.value})
|
||||
if existing is None:
|
||||
cluster = DuplicateCluster(
|
||||
id=str(uuid.uuid4()),
|
||||
method=Method.PERCEPTUAL.value,
|
||||
confidence=confidence.value,
|
||||
state=ClusterState.OPEN.value,
|
||||
version=1,
|
||||
)
|
||||
session.add(cluster)
|
||||
session.flush()
|
||||
self._replace_members(session, cluster, ids, by_id, distances)
|
||||
clusters.append(cluster)
|
||||
members[cluster.id] = list(
|
||||
session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars()
|
||||
)
|
||||
return cluster
|
||||
|
||||
cluster = existing
|
||||
prior = {m.asset_id for m in members[cluster.id]}
|
||||
added = set(ids) - prior
|
||||
cluster.confidence = confidence.value
|
||||
self._replace_members(session, cluster, ids, by_id, distances)
|
||||
if added and cluster.state in (
|
||||
ClusterState.DECIDED.value,
|
||||
ClusterState.DISMISSED.value,
|
||||
ClusterState.DEFERRED.value,
|
||||
):
|
||||
cluster.state = ClusterState.REOPENED.value
|
||||
cluster.version += 1
|
||||
cluster.updated_at = now
|
||||
members[cluster.id] = list(
|
||||
session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars()
|
||||
)
|
||||
return cluster
|
||||
|
||||
def _replace_members(self, session, cluster, ids, by_id, distances):
|
||||
current = {
|
||||
m.asset_id: m
|
||||
for m in session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars()
|
||||
}
|
||||
wanted = set(ids)
|
||||
for asset_id, member in current.items():
|
||||
if asset_id not in wanted:
|
||||
session.delete(member)
|
||||
for asset_id in ids:
|
||||
asset = by_id[asset_id]
|
||||
evidence = json.dumps(
|
||||
{
|
||||
"sha256": asset.current_sha256,
|
||||
"pixel_sha256": asset.pixel_sha256,
|
||||
"phash": asset.phash,
|
||||
}
|
||||
)
|
||||
distance = next(
|
||||
(d for p, d in distances.items() if asset_id in p), None
|
||||
)
|
||||
if asset_id in current:
|
||||
current[asset_id].evidence = evidence
|
||||
current[asset_id].distance = distance
|
||||
else:
|
||||
session.add(
|
||||
DuplicateMember(
|
||||
cluster_id=cluster.id,
|
||||
asset_id=asset_id,
|
||||
role=Role.MEMBER.value,
|
||||
distance=distance,
|
||||
evidence=evidence,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
|
||||
def _auto_decide(self, session, cluster, ids, by_id, now):
|
||||
canonical = self._recommend_canonical(ids, by_id)
|
||||
self._apply_canonical(session, cluster, ids, canonical)
|
||||
cluster.decision = Decision.CANONICAL.value
|
||||
cluster.canonical_asset_id = canonical
|
||||
cluster.state = ClusterState.DECIDED.value
|
||||
cluster.decided_at = now
|
||||
|
||||
@staticmethod
|
||||
def _recommend_canonical(ids, by_id) -> str:
|
||||
# ponytail: largest file, path as deterministic tie-break. The concept's
|
||||
# richer policy (resolution, least recompression, metadata richness) lands
|
||||
# with the review UI story.
|
||||
return max(ids, key=lambda i: (by_id[i].byte_size or 0, by_id[i].current_path or ""))
|
||||
|
||||
def _apply_canonical(self, session, cluster, ids, canonical_id):
|
||||
for member in session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars():
|
||||
asset = session.get(Asset, member.asset_id)
|
||||
if member.asset_id == canonical_id:
|
||||
member.role = Role.CANONICAL.value
|
||||
asset.canonical_asset_id = None
|
||||
else:
|
||||
member.role = Role.VARIANT.value
|
||||
asset.canonical_asset_id = canonical_id
|
||||
self._assert_acyclic(session, ids)
|
||||
|
||||
@staticmethod
|
||||
def _clear_asset_links(session, ids):
|
||||
for asset_id in ids:
|
||||
session.get(Asset, asset_id).canonical_asset_id = None
|
||||
|
||||
@staticmethod
|
||||
def _assert_acyclic(session, ids):
|
||||
for start in ids:
|
||||
seen = set()
|
||||
cur = start
|
||||
while cur is not None:
|
||||
if cur in seen:
|
||||
raise DuplicateError(f"canonical link cycle at {cur}")
|
||||
seen.add(cur)
|
||||
asset = session.get(Asset, cur)
|
||||
cur = asset.canonical_asset_id if asset else None
|
||||
|
||||
# ── decisions ────────────────────────────────────────────────────────────
|
||||
def decide(
|
||||
self,
|
||||
cluster_id: str,
|
||||
decision: Decision | str,
|
||||
*,
|
||||
canonical_asset_id: str | None = None,
|
||||
expected_version: int | None = None,
|
||||
reason: str | None = None,
|
||||
) -> dict:
|
||||
decision = Decision(decision)
|
||||
now = datetime.now(timezone.utc)
|
||||
with self._session_factory() as session:
|
||||
cluster = session.get(DuplicateCluster, cluster_id)
|
||||
if cluster is None:
|
||||
raise DuplicateError(f"unknown cluster {cluster_id}")
|
||||
if expected_version is not None and cluster.version != expected_version:
|
||||
raise ConflictError(
|
||||
f"cluster {cluster_id} is at version {cluster.version}, "
|
||||
f"expected {expected_version}"
|
||||
)
|
||||
member_ids = [
|
||||
m.asset_id
|
||||
for m in session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster_id)
|
||||
).scalars()
|
||||
]
|
||||
self._reset_members(session, cluster, member_ids)
|
||||
self._remove_negative_links(session, member_ids)
|
||||
|
||||
if decision == Decision.CANONICAL:
|
||||
if canonical_asset_id not in member_ids:
|
||||
raise DuplicateError("canonical_asset_id must be a cluster member")
|
||||
self._apply_canonical(session, cluster, member_ids, canonical_asset_id)
|
||||
cluster.decision = Decision.CANONICAL.value
|
||||
cluster.canonical_asset_id = canonical_asset_id
|
||||
cluster.state = ClusterState.DECIDED.value
|
||||
elif decision == Decision.NOT_DUPLICATE:
|
||||
self._add_negative_links(session, member_ids, reason)
|
||||
cluster.decision = Decision.NOT_DUPLICATE.value
|
||||
cluster.canonical_asset_id = None
|
||||
cluster.state = ClusterState.DISMISSED.value
|
||||
else: # DEFERRED
|
||||
cluster.decision = Decision.DEFERRED.value
|
||||
cluster.canonical_asset_id = None
|
||||
cluster.state = ClusterState.DEFERRED.value
|
||||
|
||||
cluster.decided_at = now
|
||||
cluster.updated_at = now
|
||||
cluster.version += 1
|
||||
session.commit()
|
||||
return self._snapshot(session, cluster_id)
|
||||
|
||||
def _reset_members(self, session, cluster, member_ids):
|
||||
for member in session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster.id)
|
||||
).scalars():
|
||||
member.role = Role.MEMBER.value
|
||||
for asset_id in member_ids:
|
||||
session.get(Asset, asset_id).canonical_asset_id = None
|
||||
|
||||
@staticmethod
|
||||
def _add_negative_links(session, member_ids, reason):
|
||||
existing = {
|
||||
_pair(link.asset_a, link.asset_b)
|
||||
for link in session.execute(select(DuplicateNegativeLink)).scalars()
|
||||
}
|
||||
for i in range(len(member_ids)):
|
||||
for j in range(i + 1, len(member_ids)):
|
||||
a, b = _pair(member_ids[i], member_ids[j])
|
||||
if (a, b) not in existing:
|
||||
session.add(
|
||||
DuplicateNegativeLink(asset_a=a, asset_b=b, reason=reason)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _remove_negative_links(session, member_ids):
|
||||
member_set = set(member_ids)
|
||||
for link in session.execute(select(DuplicateNegativeLink)).scalars():
|
||||
if link.asset_a in member_set and link.asset_b in member_set:
|
||||
session.delete(link)
|
||||
|
||||
def _snapshot(self, session, cluster_id) -> dict:
|
||||
cluster = session.get(DuplicateCluster, cluster_id)
|
||||
members = session.execute(
|
||||
select(DuplicateMember).where(DuplicateMember.cluster_id == cluster_id)
|
||||
).scalars()
|
||||
return {
|
||||
"id": cluster.id,
|
||||
"method": cluster.method,
|
||||
"confidence": cluster.confidence,
|
||||
"state": cluster.state,
|
||||
"decision": cluster.decision,
|
||||
"canonical_asset_id": cluster.canonical_asset_id,
|
||||
"version": cluster.version,
|
||||
"members": sorted(
|
||||
({"asset_id": m.asset_id, "role": m.role, "distance": m.distance} for m in members),
|
||||
key=lambda m: m["asset_id"],
|
||||
),
|
||||
}
|
||||
@@ -19,6 +19,7 @@ from pathlib import Path
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
PIXEL_HASH_VERSION = 1
|
||||
PHASH_VERSION = 1
|
||||
_CHUNK = 1 << 20
|
||||
|
||||
|
||||
@@ -45,3 +46,39 @@ def safe_pixel_sha256(path: Path | str) -> str | None:
|
||||
return pixel_sha256(path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def phash(path: Path | str) -> str:
|
||||
"""DCT perceptual hash → 16 hex chars (64 bits).
|
||||
|
||||
Standard imagehash.phash recipe (extracted verbatim from
|
||||
photo_analyzer._phash_image): grayscale → 32x32 → 2D DCT-II → keep the top-left
|
||||
8x8 low-frequency block → bit = coefficient > median. Resilient to resize and
|
||||
recompression; used only as evidence for review, never for automatic exclusion.
|
||||
"""
|
||||
import numpy as np
|
||||
from scipy.fftpack import dct
|
||||
|
||||
with Image.open(path) as image:
|
||||
small = image.convert("L").resize((32, 32), Image.LANCZOS)
|
||||
matrix = np.asarray(small, dtype=np.float64)
|
||||
transformed = dct(dct(matrix, axis=0), axis=1)
|
||||
low = transformed[:8, :8]
|
||||
bits = (low > np.median(low)).flatten()
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | int(bit)
|
||||
return f"{value:016x}"
|
||||
|
||||
|
||||
def safe_phash(path: Path | str) -> str | None:
|
||||
"""``phash`` but returns None for undecodable images instead of raising."""
|
||||
try:
|
||||
return phash(path)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def phash_distance(a_hex: str, b_hex: str) -> int:
|
||||
"""Hamming distance between two 64-bit hex perceptual hashes."""
|
||||
return bin(int(a_hex, 16) ^ int(b_hex, 16)).count("1")
|
||||
|
||||
291
tests/integration/test_duplicate_engine.py
Normal file
291
tests/integration/test_duplicate_engine.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""Duplicate engine: banded clustering, reversible decisions, negative links,
|
||||
reopening on contradiction, no canonical cycles, and durability across restart.
|
||||
|
||||
Fixtures are inlined (not in a sibling conftest.py) to avoid shadowing the
|
||||
characterization suite's bare ``conftest`` import under pytest's prepend mode.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from sqlalchemy import select
|
||||
|
||||
from photo_pipeline.db import create_db_engine, create_session_factory, run_migrations
|
||||
from photo_pipeline.models import Asset, DuplicateNegativeLink
|
||||
from photo_pipeline.services.duplicates import (
|
||||
ClusterState,
|
||||
ConflictError,
|
||||
Decision,
|
||||
DuplicateError,
|
||||
DuplicateService,
|
||||
Method,
|
||||
)
|
||||
from photo_pipeline.services.inventory import InventoryService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_url(tmp_path):
|
||||
url = f"sqlite:///{tmp_path / 'dup.db'}"
|
||||
run_migrations(url)
|
||||
return url
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_factory():
|
||||
engines = []
|
||||
|
||||
def _make(url):
|
||||
engine = create_db_engine(url)
|
||||
engines.append(engine)
|
||||
return create_session_factory(engine)
|
||||
|
||||
yield _make
|
||||
for engine in engines:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def factory(db_url, make_factory):
|
||||
return make_factory(db_url)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def inventory(factory):
|
||||
return InventoryService(factory)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def duplicates(factory):
|
||||
return DuplicateService(factory)
|
||||
|
||||
|
||||
def structured(path, seed, size=(256, 192)):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
rng = np.random.default_rng(seed)
|
||||
w, h = size
|
||||
base = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
for _ in range(6):
|
||||
x0 = int(rng.integers(0, w - 60))
|
||||
y0 = int(rng.integers(0, h - 60))
|
||||
base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3)
|
||||
grad = np.linspace(0, 120, w, dtype=np.uint8)
|
||||
base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255)
|
||||
Image.fromarray(base).save(path, quality=95)
|
||||
return path
|
||||
|
||||
|
||||
def resized_copy(src, dst, scale=0.5):
|
||||
with Image.open(src) as image:
|
||||
image.resize(
|
||||
(int(image.width * scale), int(image.height * scale)), Image.LANCZOS
|
||||
).save(dst, quality=95)
|
||||
return dst
|
||||
|
||||
|
||||
def only(clusters, method):
|
||||
return [c for c in clusters if c["method"] == method]
|
||||
|
||||
|
||||
def test_exact_copies_form_auto_decided_cluster(tmp_path, inventory, duplicates, factory):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
shutil.copy2(a, lib / "a_copy.jpg")
|
||||
inventory.scan(lib)
|
||||
|
||||
clusters = duplicates.detect().clusters
|
||||
exact = only(clusters, Method.EXACT.value)
|
||||
assert len(exact) == 1
|
||||
cluster = exact[0]
|
||||
assert cluster["state"] == ClusterState.DECIDED.value
|
||||
assert cluster["decision"] == Decision.CANONICAL.value
|
||||
roles = {m["role"] for m in cluster["members"]}
|
||||
assert roles == {"canonical", "variant"}
|
||||
|
||||
# The variant asset points at the canonical; the canonical points at nothing.
|
||||
assets = {a.id: a for a in factory().execute(select(Asset)).scalars()}
|
||||
canonical = cluster["canonical_asset_id"]
|
||||
for m in cluster["members"]:
|
||||
if m["role"] == "variant":
|
||||
assert assets[m["asset_id"]].canonical_asset_id == canonical
|
||||
else:
|
||||
assert assets[m["asset_id"]].canonical_asset_id is None
|
||||
|
||||
|
||||
def test_same_pixels_different_bytes_form_pixel_cluster(tmp_path, inventory, duplicates):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
twin = lib / "a_meta.jpg"
|
||||
shutil.copy2(a, twin)
|
||||
with open(twin, "ab") as handle: # trailing bytes: same pixels, different bytes
|
||||
handle.write(b"\xff\xfe metadata")
|
||||
inventory.scan(lib)
|
||||
|
||||
clusters = duplicates.detect().clusters
|
||||
pixel = only(clusters, Method.PIXEL.value)
|
||||
assert len(pixel) == 1
|
||||
assert pixel[0]["confidence"] == "pixel"
|
||||
assert pixel[0]["state"] == ClusterState.DECIDED.value
|
||||
|
||||
|
||||
def test_perceptual_variant_is_review_only(tmp_path, inventory, duplicates):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
resized_copy(a, lib / "a_small.jpg")
|
||||
inventory.scan(lib)
|
||||
|
||||
clusters = duplicates.detect().clusters
|
||||
perceptual = only(clusters, Method.PERCEPTUAL.value)
|
||||
assert len(perceptual) == 1
|
||||
assert perceptual[0]["state"] == ClusterState.OPEN.value # never auto-decided
|
||||
assert perceptual[0]["decision"] is None
|
||||
assert perceptual[0]["confidence"] in ("near", "similar")
|
||||
|
||||
|
||||
def test_distinct_images_do_not_cluster(tmp_path, inventory, duplicates):
|
||||
lib = tmp_path / "lib"
|
||||
structured(lib / "a.jpg", 1)
|
||||
structured(lib / "b.jpg", 2)
|
||||
inventory.scan(lib)
|
||||
assert duplicates.detect().clusters == []
|
||||
|
||||
|
||||
def test_not_duplicate_decision_persists_negative_link_and_suppresses(
|
||||
tmp_path, inventory, duplicates, factory
|
||||
):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
resized_copy(a, lib / "a_small.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster = only(duplicates.detect().clusters, Method.PERCEPTUAL.value)[0]
|
||||
|
||||
duplicates.decide(cluster["id"], Decision.NOT_DUPLICATE)
|
||||
|
||||
links = list(factory().execute(select(DuplicateNegativeLink)).scalars())
|
||||
assert len(links) == 1
|
||||
|
||||
# Re-detecting must not re-suggest the rejected pair.
|
||||
again = only(duplicates.detect().clusters, Method.PERCEPTUAL.value)
|
||||
assert again == []
|
||||
|
||||
|
||||
def test_decision_is_reversible(tmp_path, inventory, duplicates, factory):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
resized_copy(a, lib / "a_small.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster = only(duplicates.detect().clusters, Method.PERCEPTUAL.value)[0]
|
||||
members = [m["asset_id"] for m in cluster["members"]]
|
||||
|
||||
duplicates.decide(cluster["id"], Decision.NOT_DUPLICATE)
|
||||
assert factory().execute(select(DuplicateNegativeLink)).scalars().all()
|
||||
|
||||
# Reverse: choose a canonical instead.
|
||||
reversed_snapshot = duplicates.decide(
|
||||
cluster["id"], Decision.CANONICAL, canonical_asset_id=members[0]
|
||||
)
|
||||
assert reversed_snapshot["state"] == ClusterState.DECIDED.value
|
||||
assert not factory().execute(select(DuplicateNegativeLink)).scalars().all()
|
||||
|
||||
|
||||
def test_version_conflict_is_rejected(tmp_path, inventory, duplicates):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
resized_copy(a, lib / "a_small.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster = only(duplicates.detect().clusters, Method.PERCEPTUAL.value)[0]
|
||||
|
||||
with pytest.raises(ConflictError):
|
||||
duplicates.decide(
|
||||
cluster["id"], Decision.DEFERRED, expected_version=cluster["version"] + 5
|
||||
)
|
||||
|
||||
|
||||
def test_canonical_must_be_a_member(tmp_path, inventory, duplicates):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
resized_copy(a, lib / "a_small.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster = only(duplicates.detect().clusters, Method.PERCEPTUAL.value)[0]
|
||||
|
||||
with pytest.raises(DuplicateError):
|
||||
duplicates.decide(cluster["id"], Decision.CANONICAL, canonical_asset_id="ghost")
|
||||
|
||||
|
||||
def test_no_canonical_cycles(tmp_path, inventory, duplicates, factory):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
shutil.copy2(a, lib / "a_copy.jpg")
|
||||
inventory.scan(lib)
|
||||
duplicates.detect()
|
||||
|
||||
# Every asset's canonical chain terminates (no loop).
|
||||
assets = {a.id: a for a in factory().execute(select(Asset)).scalars()}
|
||||
for start in assets:
|
||||
seen, cur = set(), start
|
||||
while cur is not None:
|
||||
assert cur not in seen
|
||||
seen.add(cur)
|
||||
cur = assets[cur].canonical_asset_id
|
||||
|
||||
|
||||
def test_new_exact_member_inherits_decision(tmp_path, inventory, duplicates, factory):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
shutil.copy2(a, lib / "a2.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster = only(duplicates.detect().clusters, Method.EXACT.value)[0]
|
||||
canonical = cluster["canonical_asset_id"]
|
||||
|
||||
# A third exact copy appears later.
|
||||
shutil.copy2(a, lib / "a3.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster2 = only(duplicates.detect().clusters, Method.EXACT.value)[0]
|
||||
|
||||
assert cluster2["id"] == cluster["id"]
|
||||
assert cluster2["state"] == ClusterState.DECIDED.value
|
||||
assert len(cluster2["members"]) == 3
|
||||
assets = {a.id: a for a in factory().execute(select(Asset)).scalars()}
|
||||
variants = [m for m in cluster2["members"] if m["role"] == "variant"]
|
||||
assert len(variants) == 2
|
||||
for m in variants:
|
||||
assert assets[m["asset_id"]].canonical_asset_id == canonical
|
||||
|
||||
|
||||
def test_contradictory_member_reopens_reviewed_cluster(
|
||||
tmp_path, inventory, duplicates, db_url, make_factory
|
||||
):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
b = resized_copy(a, lib / "b.jpg")
|
||||
inventory.scan(lib)
|
||||
cluster = only(duplicates.detect().clusters, Method.PERCEPTUAL.value)[0]
|
||||
duplicates.decide(cluster["id"], Decision.NOT_DUPLICATE) # dismissed + negative link
|
||||
|
||||
# b is replaced by an exact copy of a — now byte-identical to a "not duplicate".
|
||||
shutil.copy2(a, b)
|
||||
inventory.scan(lib)
|
||||
clusters = duplicates.detect().clusters
|
||||
|
||||
reopened = [c for c in clusters if c["state"] == ClusterState.REOPENED.value]
|
||||
assert reopened, "byte-identical contradiction of a not_duplicate must reopen review"
|
||||
|
||||
|
||||
def test_decisions_durable_across_restart(tmp_path, db_url, make_factory):
|
||||
lib = tmp_path / "lib"
|
||||
a = structured(lib / "a.jpg", 1)
|
||||
shutil.copy2(a, lib / "a_copy.jpg")
|
||||
|
||||
InventoryService(make_factory(db_url)).scan(lib)
|
||||
cluster = only(
|
||||
DuplicateService(make_factory(db_url)).detect().clusters, Method.EXACT.value
|
||||
)[0]
|
||||
|
||||
# Fresh factory against the same database: the decision persisted.
|
||||
from photo_pipeline.models import DuplicateCluster
|
||||
|
||||
with make_factory(db_url)() as session:
|
||||
persisted = session.get(DuplicateCluster, cluster["id"])
|
||||
assert persisted.state == ClusterState.DECIDED.value
|
||||
assert persisted.decision == Decision.CANONICAL.value
|
||||
@@ -7,6 +7,16 @@ import pytest
|
||||
|
||||
from photo_pipeline.db import run_migrations
|
||||
|
||||
|
||||
def _alembic_head() -> str:
|
||||
from alembic.config import Config as AlembicConfig
|
||||
from alembic.script import ScriptDirectory
|
||||
|
||||
repo = Path(__file__).resolve().parents[2]
|
||||
cfg = AlembicConfig(str(repo / "alembic.ini"))
|
||||
cfg.set_main_option("script_location", str(repo / "migrations"))
|
||||
return ScriptDirectory.from_config(cfg).get_current_head()
|
||||
|
||||
# Faithful snapshot of the donor photo_analyzer.py schema (photos + FTS + triggers).
|
||||
LEGACY_SCHEMA = """
|
||||
CREATE TABLE photos (
|
||||
@@ -50,7 +60,7 @@ def test_migrations_from_empty_database(tmp_path):
|
||||
|
||||
tables = _tables(db)
|
||||
assert {"assets", "asset_paths"} <= tables
|
||||
assert _head_revision(db) == "0001_initial_identity"
|
||||
assert _head_revision(db) == _alembic_head()
|
||||
|
||||
|
||||
def test_migrations_from_legacy_snapshot_preserve_existing_data(tmp_path):
|
||||
@@ -75,7 +85,7 @@ def test_migrations_from_legacy_snapshot_preserve_existing_data(tmp_path):
|
||||
finally:
|
||||
conn.close()
|
||||
assert row == ("album/one.jpg", "analyzed", "a red block")
|
||||
assert _head_revision(db) == "0001_initial_identity"
|
||||
assert _head_revision(db) == _alembic_head()
|
||||
|
||||
|
||||
def test_migrations_are_idempotent(tmp_path):
|
||||
@@ -83,7 +93,7 @@ def test_migrations_are_idempotent(tmp_path):
|
||||
url = f"sqlite:///{db}"
|
||||
run_migrations(url)
|
||||
run_migrations(url) # second run is a no-op at head
|
||||
assert _head_revision(db) == "0001_initial_identity"
|
||||
assert _head_revision(db) == _alembic_head()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expected", ["assets", "asset_paths"])
|
||||
|
||||
52
tests/unit/test_phash.py
Normal file
52
tests/unit/test_phash.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Golden perceptual-hash relationships and confidence bands."""
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from photo_pipeline.services import hashing
|
||||
from photo_pipeline.services.duplicates import NEAR_MAX, SIMILAR_MAX
|
||||
|
||||
|
||||
def _structured(path, seed, size=(256, 192)):
|
||||
rng = np.random.default_rng(seed)
|
||||
w, h = size
|
||||
base = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
for _ in range(6):
|
||||
x0 = int(rng.integers(0, w - 60))
|
||||
y0 = int(rng.integers(0, h - 60))
|
||||
base[y0 : y0 + 60, x0 : x0 + 60] = rng.integers(0, 256, 3)
|
||||
grad = np.linspace(0, 120, w, dtype=np.uint8)
|
||||
base[:, :, 0] = np.clip(base[:, :, 0].astype(int) + grad[None, :], 0, 255)
|
||||
Image.fromarray(base).save(path, quality=95)
|
||||
return path
|
||||
|
||||
|
||||
def test_phash_is_16_hex_chars(tmp_path):
|
||||
value = hashing.phash(_structured(tmp_path / "a.jpg", 1))
|
||||
assert len(value) == 16
|
||||
int(value, 16) # parses as hex
|
||||
|
||||
|
||||
def test_identical_pixels_have_zero_distance(tmp_path):
|
||||
a = _structured(tmp_path / "a.jpg", 1)
|
||||
assert hashing.phash_distance(hashing.phash(a), hashing.phash(a)) == 0
|
||||
|
||||
|
||||
def test_resized_copy_stays_within_near_band(tmp_path):
|
||||
a = _structured(tmp_path / "a.jpg", 1)
|
||||
small = tmp_path / "a_small.jpg"
|
||||
with Image.open(a) as image:
|
||||
image.resize((image.width // 2, image.height // 2), Image.LANCZOS).save(small, quality=95)
|
||||
distance = hashing.phash_distance(hashing.phash(a), hashing.phash(small))
|
||||
assert distance <= NEAR_MAX
|
||||
|
||||
|
||||
def test_distinct_images_exceed_similar_band(tmp_path):
|
||||
a = _structured(tmp_path / "a.jpg", 1)
|
||||
b = _structured(tmp_path / "b.jpg", 2)
|
||||
distance = hashing.phash_distance(hashing.phash(a), hashing.phash(b))
|
||||
assert distance > SIMILAR_MAX
|
||||
|
||||
|
||||
def test_phash_is_versioned():
|
||||
assert hashing.PHASH_VERSION >= 1
|
||||
Reference in New Issue
Block a user