Photochk 1.0.0 — 類似写真を探して整理する macOS アプリ

フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して
グループにまとめ、残す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>
This commit is contained in:
yfoxjp
2026-07-28 08:02:14 +09:00
co-authored by Claude Opus 5
commit c7f68b6cca
38 changed files with 3102 additions and 0 deletions
@@ -0,0 +1,118 @@
import Foundation
import Vision
/// 1ファイル分のキャッシュ。ファイルサイズと更新日時が一致するときだけ再利用する。
struct FingerprintRecord: Codable, Sendable {
var fileSize: Int64
var modificationDate: Date
var basics: ImageBasics?
var featurePrint: FeaturePrintObservation?
func matches(_ file: ScannedFile) -> Bool {
fileSize == file.fileSize
&& abs(modificationDate.timeIntervalSince(file.modificationDate)) < 1
}
}
/// dHash と feature print をディスクに保存し、同じフォルダの再スキャンを速くする。
///
/// feature print は1枚あたり数 KB あるので、JSON ではなくバイナリ plist で持つ。
actor FingerprintCache {
private var records: [String: FingerprintRecord] = [:]
private var isDirty = false
private let fileURL: URL?
init(fileURL: URL? = FingerprintCache.defaultFileURL()) {
self.fileURL = fileURL
self.records = Self.loadRecords(from: fileURL)
}
static func defaultFileURL() -> URL? {
guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
else { return nil }
let directory = caches.appendingPathComponent("Photochk", isDirectory: true)
try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
return directory.appendingPathComponent("fingerprints.plist")
}
// MARK: - 読み出し
func basics(for files: [ScannedFile]) -> [URL: ImageBasics] {
var found: [URL: ImageBasics] = [:]
for file in files {
if let record = records[file.url.path], record.matches(file), let basics = record.basics {
found[file.url] = basics
}
}
return found
}
func featurePrints(for files: [ScannedFile]) -> [URL: FeaturePrintObservation] {
var found: [URL: FeaturePrintObservation] = [:]
for file in files {
if let record = records[file.url.path], record.matches(file),
let print = record.featurePrint {
found[file.url] = print
}
}
return found
}
// MARK: - 書き込み
func store(basics: [URL: ImageBasics], for files: [ScannedFile]) {
for file in files {
guard let value = basics[file.url] else { continue }
update(file) { $0.basics = value }
}
}
func store(featurePrints: [URL: FeaturePrintObservation], for files: [ScannedFile]) {
for file in files {
guard let value = featurePrints[file.url] else { continue }
update(file) { $0.featurePrint = value }
}
}
private func update(_ file: ScannedFile, _ body: (inout FingerprintRecord) -> Void) {
var record = records[file.url.path] ?? FingerprintRecord(
fileSize: file.fileSize,
modificationDate: file.modificationDate
)
if !record.matches(file) {
// ファイルが変わっていたら作り直す
record = FingerprintRecord(
fileSize: file.fileSize,
modificationDate: file.modificationDate
)
}
body(&record)
records[file.url.path] = record
isDirty = true
}
/// 消えたファイルのエントリを捨てる。スキャンのたびに肥大化するのを防ぐ。
func prune(keeping files: [ScannedFile]) {
let alive = Set(files.map(\.url.path))
let before = records.count
records = records.filter { alive.contains($0.key) }
if records.count != before { isDirty = true }
}
// MARK: - 永続化
func save() {
guard isDirty, let fileURL else { return }
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
guard let data = try? encoder.encode(records) else { return }
try? data.write(to: fileURL, options: .atomic)
isDirty = false
}
private static func loadRecords(from fileURL: URL?) -> [String: FingerprintRecord] {
guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return [:] }
// 形式が変わっていたら黙って捨てる。キャッシュなので作り直せばよい。
return (try? PropertyListDecoder().decode([String: FingerprintRecord].self, from: data)) ?? [:]
}
}