フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す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>
82 lines
3.2 KiB
Swift
82 lines
3.2 KiB
Swift
import Foundation
|
|
import Testing
|
|
import Vision
|
|
|
|
@testable import Photochk
|
|
|
|
@Suite("キャッシュ")
|
|
struct FingerprintCacheTests {
|
|
private func makeFile(at directory: URL) throws -> ScannedFile {
|
|
let url = directory.appendingPathComponent("cached.jpg")
|
|
try TestImages.writeJPEG(TestImages.pattern(seed: 3), to: url, quality: 0.9)
|
|
|
|
let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey])
|
|
return ScannedFile(
|
|
url: url,
|
|
fileSize: Int64(values.fileSize ?? 0),
|
|
modificationDate: values.contentModificationDate ?? Date(),
|
|
creationDate: nil
|
|
)
|
|
}
|
|
|
|
@Test("dHash と feature print がディスクを経由して復元できる")
|
|
func roundTrip() async throws {
|
|
let directory = try TestImages.makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let file = try makeFile(at: directory)
|
|
let cacheURL = directory.appendingPathComponent("cache.plist")
|
|
|
|
let basics = try #require(DHash.compute(for: file.url))
|
|
let print = try await FeaturePrinter.compute(for: file.url)
|
|
|
|
let writer = FingerprintCache(fileURL: cacheURL)
|
|
await writer.store(basics: [file.url: basics], for: [file])
|
|
await writer.store(featurePrints: [file.url: print], for: [file])
|
|
await writer.save()
|
|
|
|
let reader = FingerprintCache(fileURL: cacheURL)
|
|
let restoredBasics = await reader.basics(for: [file])
|
|
let restoredPrints = await reader.featurePrints(for: [file])
|
|
|
|
#expect(restoredBasics[file.url]?.dHash == basics.dHash)
|
|
let restoredPrint = try #require(restoredPrints[file.url])
|
|
// 同一の特徴量なので距離は 0
|
|
#expect(try restoredPrint.distance(to: print) == 0)
|
|
}
|
|
|
|
@Test("ファイルが更新されていたらキャッシュは使われない")
|
|
func invalidatesOnChange() async throws {
|
|
let directory = try TestImages.makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let file = try makeFile(at: directory)
|
|
let cache = FingerprintCache(fileURL: nil)
|
|
let basics = try #require(DHash.compute(for: file.url))
|
|
await cache.store(basics: [file.url: basics], for: [file])
|
|
|
|
let changed = ScannedFile(
|
|
url: file.url,
|
|
fileSize: file.fileSize + 100,
|
|
modificationDate: file.modificationDate,
|
|
creationDate: nil
|
|
)
|
|
#expect(await cache.basics(for: [changed]).isEmpty)
|
|
#expect(await cache.basics(for: [file]).count == 1)
|
|
}
|
|
|
|
@Test("消えたファイルのエントリは prune で捨てられる")
|
|
func prunesMissingFiles() async throws {
|
|
let directory = try TestImages.makeTemporaryDirectory()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let file = try makeFile(at: directory)
|
|
let cache = FingerprintCache(fileURL: nil)
|
|
let basics = try #require(DHash.compute(for: file.url))
|
|
await cache.store(basics: [file.url: basics], for: [file])
|
|
|
|
await cache.prune(keeping: [])
|
|
#expect(await cache.basics(for: [file]).isEmpty)
|
|
}
|
|
}
|