フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す1枚を選んでほかをゴミ箱へ送れるようにする。 検出は安い判定から順に4段: - ファイルサイズが同じものだけ SHA-256 でバイト一致を確定 - dHash(知覚ハッシュ)のハミング距離で候補ペアを抽出 - 候補に出てくる画像だけ Vision の feature print で精査 - Union-Find で連結成分をグループ化 判定のしきい値は実写画像で距離を実測して決めた(docs/calibration.md)。 tools/release-update.sh で Gitea のリリースを更新する。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
352 lines
14 KiB
Swift
352 lines
14 KiB
Swift
import Foundation
|
|
import Vision
|
|
|
|
/// 走査からグループ生成までの一連の処理をまとめる。
|
|
///
|
|
/// 安いフィルタから順にかけて候補を絞る:
|
|
/// 1. ファイルサイズが同じものだけ SHA-256 → 完全同一を確定
|
|
/// 2. dHash(知覚ハッシュ)で「見た目が近そうな」ペアを抽出
|
|
/// 3. そのペアに登場する画像だけ Vision の feature print で精査
|
|
/// 4. Union-Find で連結成分をグループ化
|
|
enum SimilarityGrouper {
|
|
private struct Pair: Sendable {
|
|
let a: Int
|
|
let b: Int
|
|
}
|
|
|
|
private struct HashEntry: Sendable {
|
|
let index: Int
|
|
let hash: UInt64
|
|
}
|
|
|
|
/// feature print を計算できなかったときの保険。dHash がここまで一致していれば同一とみなす。
|
|
private static let fallbackHammingThreshold = 4
|
|
|
|
static func run(
|
|
root: URL,
|
|
settings: ScanSettings,
|
|
cache: FingerprintCache,
|
|
progress: @escaping @Sendable (ScanProgress) -> Void
|
|
) async throws -> [SimilarityGroup] {
|
|
progress(ScanProgress(phase: .scanning))
|
|
let files = try PhotoScanner.scan(root: root, includesSubfolders: settings.includesSubfolders)
|
|
try Task.checkCancellation()
|
|
guard files.count > 1 else { return [] }
|
|
|
|
// --- Stage 1: 完全同一をまとめ、以降は各セットの代表1枚だけを扱う ---
|
|
let leaderOf = try await exactLeaders(files: files, progress: progress)
|
|
let leaderIndices = files.indices.filter { leaderOf[$0] == $0 }
|
|
let leaderFiles = leaderIndices.map { files[$0] }
|
|
|
|
// --- Stage 2: dHash ---
|
|
let basicsByURL = try await loadBasics(for: leaderFiles, cache: cache, progress: progress)
|
|
|
|
// --- Stage 3: 候補ペア → feature print ---
|
|
let entries = leaderIndices.compactMap { index -> HashEntry? in
|
|
guard let basics = basicsByURL[files[index].url] else { return nil }
|
|
return HashEntry(index: index, hash: basics.dHash)
|
|
}
|
|
progress(ScanProgress(phase: .comparing))
|
|
let pairs = try await candidatePairs(
|
|
entries,
|
|
threshold: settings.sensitivity.hammingThreshold
|
|
)
|
|
|
|
var involved = Set<Int>()
|
|
for pair in pairs {
|
|
involved.insert(pair.a)
|
|
involved.insert(pair.b)
|
|
}
|
|
let involvedFiles = involved.sorted().map { files[$0] }
|
|
let printsByURL = try await loadFeaturePrints(
|
|
for: involvedFiles, cache: cache, progress: progress
|
|
)
|
|
|
|
// --- Stage 4: グループ化 ---
|
|
progress(ScanProgress(phase: .grouping))
|
|
var unionFind = UnionFind(count: files.count)
|
|
for index in files.indices where leaderOf[index] != index {
|
|
unionFind.union(index, leaderOf[index])
|
|
}
|
|
|
|
let threshold = settings.sensitivity.featurePrintThreshold
|
|
for pair in pairs {
|
|
let printA = printsByURL[files[pair.a].url]
|
|
let printB = printsByURL[files[pair.b].url]
|
|
|
|
if let distance = FeaturePrinter.distance(printA, printB) {
|
|
if distance < threshold { unionFind.union(pair.a, pair.b) }
|
|
} else if let basicsA = basicsByURL[files[pair.a].url],
|
|
let basicsB = basicsByURL[files[pair.b].url],
|
|
DHash.hammingDistance(basicsA.dHash, basicsB.dHash) <= fallbackHammingThreshold {
|
|
unionFind.union(pair.a, pair.b)
|
|
}
|
|
}
|
|
try Task.checkCancellation()
|
|
|
|
let groups = buildGroups(
|
|
components: unionFind.components(),
|
|
files: files,
|
|
leaderOf: leaderOf,
|
|
basicsByURL: basicsByURL,
|
|
printsByURL: printsByURL
|
|
)
|
|
|
|
await cache.prune(keeping: files)
|
|
await cache.save()
|
|
return groups
|
|
}
|
|
|
|
// MARK: - Stage 1
|
|
|
|
/// 各インデックスに「完全同一セットの代表インデックス」を割り当てる。単独なら自分自身。
|
|
private static func exactLeaders(
|
|
files: [ScannedFile],
|
|
progress: @escaping @Sendable (ScanProgress) -> Void
|
|
) async throws -> [Int] {
|
|
var leaderOf = Array(files.indices)
|
|
|
|
// サイズが違えば中身も違う。同サイズのものだけハッシュすればよい。
|
|
var bySize: [Int64: [Int]] = [:]
|
|
for (index, file) in files.enumerated() {
|
|
bySize[file.fileSize, default: []].append(index)
|
|
}
|
|
let candidates = bySize.values.filter { $0.count > 1 }.flatMap { $0 }.sorted()
|
|
guard !candidates.isEmpty else { return leaderOf }
|
|
|
|
progress(ScanProgress(phase: .hashing, completed: 0, total: candidates.count))
|
|
let hashes = try await mapConcurrently(
|
|
candidates,
|
|
onProgress: { done in
|
|
progress(ScanProgress(phase: .hashing, completed: done, total: candidates.count))
|
|
}
|
|
) { index -> String? in
|
|
try? ContentHasher.sha256(of: files[index].url)
|
|
}
|
|
|
|
var byDigest: [String: [Int]] = [:]
|
|
for (index, hash) in zip(candidates, hashes) {
|
|
guard let hash else { continue }
|
|
byDigest["\(files[index].fileSize):\(hash)", default: []].append(index)
|
|
}
|
|
for indices in byDigest.values where indices.count > 1 {
|
|
guard let leader = indices.min(by: { files[$0].url.path < files[$1].url.path }) else {
|
|
continue
|
|
}
|
|
for index in indices { leaderOf[index] = leader }
|
|
}
|
|
return leaderOf
|
|
}
|
|
|
|
// MARK: - Stage 2 / 3
|
|
|
|
private static func loadBasics(
|
|
for files: [ScannedFile],
|
|
cache: FingerprintCache,
|
|
progress: @escaping @Sendable (ScanProgress) -> Void
|
|
) async throws -> [URL: ImageBasics] {
|
|
var result = await cache.basics(for: files)
|
|
let missing = files.filter { result[$0.url] == nil }
|
|
guard !missing.isEmpty else { return result }
|
|
|
|
let cached = files.count - missing.count
|
|
progress(ScanProgress(phase: .fingerprinting, completed: cached, total: files.count))
|
|
let computed = try await mapConcurrently(
|
|
missing,
|
|
onProgress: { done in
|
|
progress(
|
|
ScanProgress(phase: .fingerprinting, completed: cached + done, total: files.count)
|
|
)
|
|
}
|
|
) { file -> ImageBasics? in
|
|
DHash.compute(for: file.url)
|
|
}
|
|
|
|
var fresh: [URL: ImageBasics] = [:]
|
|
for (file, basics) in zip(missing, computed) {
|
|
guard let basics else { continue }
|
|
fresh[file.url] = basics
|
|
result[file.url] = basics
|
|
}
|
|
await cache.store(basics: fresh, for: missing)
|
|
return result
|
|
}
|
|
|
|
private static func loadFeaturePrints(
|
|
for files: [ScannedFile],
|
|
cache: FingerprintCache,
|
|
progress: @escaping @Sendable (ScanProgress) -> Void
|
|
) async throws -> [URL: FeaturePrintObservation] {
|
|
guard !files.isEmpty else { return [:] }
|
|
var result = await cache.featurePrints(for: files)
|
|
let missing = files.filter { result[$0.url] == nil }
|
|
guard !missing.isEmpty else { return result }
|
|
|
|
let cached = files.count - missing.count
|
|
progress(ScanProgress(phase: .comparing, completed: cached, total: files.count))
|
|
let computed = try await mapConcurrently(
|
|
missing,
|
|
onProgress: { done in
|
|
progress(
|
|
ScanProgress(phase: .comparing, completed: cached + done, total: files.count)
|
|
)
|
|
}
|
|
) { file -> FeaturePrintObservation? in
|
|
try? await FeaturePrinter.compute(for: file.url)
|
|
}
|
|
|
|
var fresh: [URL: FeaturePrintObservation] = [:]
|
|
for (file, print) in zip(missing, computed) {
|
|
guard let print else { continue }
|
|
fresh[file.url] = print
|
|
result[file.url] = print
|
|
}
|
|
await cache.store(featurePrints: fresh, for: missing)
|
|
return result
|
|
}
|
|
|
|
/// dHash のハミング距離がしきい値以下のペアを総当たりで探す。
|
|
///
|
|
/// 64bit の XOR + popcount なので1ペアあたりの実費はごく小さい。LSH などで
|
|
/// 絞ると取りこぼしが出るため、区間をコアに分けて総当たりする方針にしている。
|
|
private static func candidatePairs(
|
|
_ entries: [HashEntry],
|
|
threshold: Int
|
|
) async throws -> [Pair] {
|
|
guard entries.count > 1 else { return [] }
|
|
|
|
let cores = max(2, ProcessInfo.processInfo.activeProcessorCount)
|
|
let chunkSize = max(64, entries.count / (cores * 4))
|
|
let starts = stride(from: 0, to: entries.count, by: chunkSize).map { $0 }
|
|
|
|
let chunks = try await mapConcurrently(starts) { start -> [Pair] in
|
|
var found: [Pair] = []
|
|
let end = min(start + chunkSize, entries.count)
|
|
for i in start..<end {
|
|
let hash = entries[i].hash
|
|
for j in (i + 1)..<entries.count
|
|
where DHash.hammingDistance(hash, entries[j].hash) <= threshold {
|
|
found.append(Pair(a: entries[i].index, b: entries[j].index))
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
return chunks.flatMap { $0 }
|
|
}
|
|
|
|
// MARK: - Stage 4
|
|
|
|
/// グループの中に「代表とは別だが、互いにバイト単位で同じ」ファイルの組があれば印を付ける。
|
|
///
|
|
/// 代表と同一のものは相手が「完全同一」と表示されるので印は要らない。ここで拾うのは
|
|
/// たとえば縮小版が2つあってその2つだけが同一、といったケース。
|
|
private static func exactSetTags(
|
|
in ranked: [Int],
|
|
leaderOf: [Int],
|
|
excluding representativeLeader: Int
|
|
) -> [Int: String] {
|
|
var membersPerLeader: [Int: Int] = [:]
|
|
for index in ranked where leaderOf[index] != representativeLeader {
|
|
membersPerLeader[leaderOf[index], default: 0] += 1
|
|
}
|
|
|
|
let labels = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
|
|
var tags: [Int: String] = [:]
|
|
// ranked の順に印を振るので、同じ結果からは常に同じ印になる。
|
|
for index in ranked {
|
|
let leader = leaderOf[index]
|
|
guard membersPerLeader[leader, default: 0] > 1, tags[leader] == nil else { continue }
|
|
tags[leader] = tags.count < labels.count
|
|
? String(labels[tags.count])
|
|
: "\(tags.count + 1)"
|
|
}
|
|
return tags
|
|
}
|
|
|
|
private static func buildGroups(
|
|
components: [[Int]],
|
|
files: [ScannedFile],
|
|
leaderOf: [Int],
|
|
basicsByURL: [URL: ImageBasics],
|
|
printsByURL: [URL: FeaturePrintObservation]
|
|
) -> [SimilarityGroup] {
|
|
func makeAsset(_ index: Int) -> PhotoAsset {
|
|
let file = files[index]
|
|
// 完全同一なら中身は同じなので、代表の解像度・撮影日をそのまま使える。
|
|
let basics = basicsByURL[files[leaderOf[index]].url]
|
|
return PhotoAsset(
|
|
url: file.url,
|
|
fileSize: file.fileSize,
|
|
modificationDate: file.modificationDate,
|
|
captureDate: basics?.captureDate ?? file.creationDate,
|
|
pixelWidth: basics?.pixelWidth ?? 0,
|
|
pixelHeight: basics?.pixelHeight ?? 0
|
|
)
|
|
}
|
|
|
|
var groups: [SimilarityGroup] = []
|
|
for component in components where component.count > 1 {
|
|
let assets = Dictionary(uniqueKeysWithValues: component.map { ($0, makeAsset($0)) })
|
|
|
|
// 代表は「解像度が最大 → ファイルサイズが最大 → 撮影が最古」で選ぶ。
|
|
let ranked = component.sorted { lhs, rhs in
|
|
guard let a = assets[lhs], let b = assets[rhs] else { return lhs < rhs }
|
|
if a.pixelCount != b.pixelCount { return a.pixelCount > b.pixelCount }
|
|
if a.fileSize != b.fileSize { return a.fileSize > b.fileSize }
|
|
let dateA = a.captureDate ?? .distantFuture
|
|
let dateB = b.captureDate ?? .distantFuture
|
|
if dateA != dateB { return dateA < dateB }
|
|
return a.url.path < b.url.path
|
|
}
|
|
guard let representativeIndex = ranked.first,
|
|
let representative = assets[representativeIndex]
|
|
else { continue }
|
|
let representativeLeader = leaderOf[representativeIndex]
|
|
let exactSetTags = exactSetTags(
|
|
in: ranked, leaderOf: leaderOf, excluding: representativeLeader
|
|
)
|
|
|
|
let members = component.compactMap { index -> SimilarityGroup.Member? in
|
|
guard let asset = assets[index] else { return nil }
|
|
let relation: SimilarityGroup.Relation
|
|
if leaderOf[index] == representativeLeader {
|
|
relation = .exact
|
|
} else {
|
|
relation = .similar(
|
|
distance: FeaturePrinter.distance(
|
|
printsByURL[files[leaderOf[index]].url],
|
|
printsByURL[files[representativeLeader].url]
|
|
)
|
|
)
|
|
}
|
|
return SimilarityGroup.Member(
|
|
asset: asset,
|
|
relation: relation,
|
|
exactSetTag: exactSetTags[leaderOf[index]]
|
|
)
|
|
}
|
|
|
|
let ordered = members.sorted { lhs, rhs in
|
|
if lhs.id == representative.url { return true }
|
|
if rhs.id == representative.url { return false }
|
|
if lhs.relation.sortKey != rhs.relation.sortKey {
|
|
return lhs.relation.sortKey < rhs.relation.sortKey
|
|
}
|
|
return lhs.asset.url.path < rhs.asset.url.path
|
|
}
|
|
|
|
groups.append(
|
|
SimilarityGroup(members: ordered, representativeID: representative.url)
|
|
)
|
|
}
|
|
|
|
// 減らせる容量が大きいグループから見せる。
|
|
return groups.sorted { lhs, rhs in
|
|
if lhs.reclaimableBytes != rhs.reclaimableBytes {
|
|
return lhs.reclaimableBytes > rhs.reclaimableBytes
|
|
}
|
|
return lhs.representativeID.path < rhs.representativeID.path
|
|
}
|
|
}
|
|
}
|