フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す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>
167 lines
6.9 KiB
Swift
167 lines
6.9 KiB
Swift
import Foundation
|
|
import Testing
|
|
|
|
@testable import Photochk
|
|
|
|
/// 走査からグループ生成まで、実際に画像ファイルを作って一気通貫で確かめる。
|
|
@Suite("グループ生成")
|
|
struct GroupingTests {
|
|
/// 同じ絵の派生(完全コピー・縮小・低画質)と、無関係な絵を1つ置いたフォルダを作る。
|
|
private func makeFixture() throws -> URL {
|
|
let directory = try TestImages.makeTemporaryDirectory()
|
|
let subdirectory = directory.appendingPathComponent("sub", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: subdirectory, withIntermediateDirectories: true)
|
|
|
|
let pattern = TestImages.pattern(seed: 42)
|
|
let base = directory.appendingPathComponent("base.jpg")
|
|
try TestImages.writeJPEG(pattern, to: base, quality: 0.95)
|
|
|
|
// バイト単位で同じコピー
|
|
try FileManager.default.copyItem(
|
|
at: base, to: directory.appendingPathComponent("base_copy.jpg")
|
|
)
|
|
// 縮小版と、そのバイト単位のコピー(代表以外にできる完全同一の組)
|
|
let small = directory.appendingPathComponent("base_small.jpg")
|
|
try TestImages.writeJPEG(
|
|
TestImages.scaled(pattern, factor: 0.5), to: small, quality: 0.9
|
|
)
|
|
try FileManager.default.copyItem(
|
|
at: small, to: directory.appendingPathComponent("base_small_copy.jpg")
|
|
)
|
|
// 低画質版
|
|
try TestImages.writeJPEG(
|
|
pattern, to: directory.appendingPathComponent("base_low.jpg"), quality: 0.3
|
|
)
|
|
// サブフォルダにもう1つ派生を置く
|
|
try TestImages.writeJPEG(
|
|
TestImages.scaled(pattern, factor: 0.75),
|
|
to: subdirectory.appendingPathComponent("base_sub.jpg"),
|
|
quality: 0.85
|
|
)
|
|
// 無関係な絵
|
|
try TestImages.writeJPEG(
|
|
TestImages.pattern(seed: 9_999),
|
|
to: directory.appendingPathComponent("other.jpg"),
|
|
quality: 0.95
|
|
)
|
|
|
|
return directory
|
|
}
|
|
|
|
@Test("派生画像が1グループにまとまり、無関係な画像は入らない")
|
|
func groupsDerivatives() async throws {
|
|
let directory = try makeFixture()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let groups = try await SimilarityGrouper.run(
|
|
root: directory,
|
|
settings: ScanSettings(includesSubfolders: true, sensitivity: .standard),
|
|
cache: FingerprintCache(fileURL: nil),
|
|
progress: { _ in }
|
|
)
|
|
|
|
#expect(groups.count == 1)
|
|
let group = try #require(groups.first)
|
|
|
|
let names = Set(group.members.map(\.asset.fileName))
|
|
#expect(
|
|
names == [
|
|
"base.jpg", "base_copy.jpg", "base_small.jpg", "base_small_copy.jpg",
|
|
"base_low.jpg", "base_sub.jpg",
|
|
]
|
|
)
|
|
#expect(!names.contains("other.jpg"))
|
|
}
|
|
|
|
@Test("代表以外にできた完全同一の組には同じ印が付く")
|
|
func tagsExactSetsAwayFromRepresentative() async throws {
|
|
let directory = try makeFixture()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let groups = try await SimilarityGrouper.run(
|
|
root: directory,
|
|
settings: ScanSettings(includesSubfolders: true, sensitivity: .standard),
|
|
cache: FingerprintCache(fileURL: nil),
|
|
progress: { _ in }
|
|
)
|
|
let group = try #require(groups.first)
|
|
let tags = Dictionary(
|
|
uniqueKeysWithValues: group.members.map { ($0.asset.fileName, $0.exactSetTag) }
|
|
)
|
|
|
|
// 縮小版の2枚は互いにバイト単位で同じなので、同じ印が付く
|
|
let smallTag = try #require(tags["base_small.jpg"] ?? nil)
|
|
#expect(tags["base_small_copy.jpg"] == smallTag)
|
|
|
|
// 代表と完全同一のものは relation が exact になるので印は要らない
|
|
#expect(tags["base_copy.jpg"] == .some(nil))
|
|
#expect(tags["base_low.jpg"] == .some(nil))
|
|
}
|
|
|
|
@Test("完全同一のコピーは exact、加工された派生は similar になる")
|
|
func relationKinds() async throws {
|
|
let directory = try makeFixture()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let groups = try await SimilarityGrouper.run(
|
|
root: directory,
|
|
settings: ScanSettings(includesSubfolders: true, sensitivity: .standard),
|
|
cache: FingerprintCache(fileURL: nil),
|
|
progress: { _ in }
|
|
)
|
|
let group = try #require(groups.first)
|
|
|
|
// 代表は元画像か、それとバイト単位で同じコピー
|
|
#expect(["base.jpg", "base_copy.jpg"].contains(group.representative.fileName))
|
|
#expect(group.representative.pixelWidth == TestImages.width)
|
|
|
|
let relations = Dictionary(
|
|
uniqueKeysWithValues: group.members.map { ($0.asset.fileName, $0.relation) }
|
|
)
|
|
#expect(relations["base.jpg"]?.isExact == true)
|
|
#expect(relations["base_copy.jpg"]?.isExact == true)
|
|
#expect(relations["base_small.jpg"]?.isExact == false)
|
|
#expect(relations["base_low.jpg"]?.isExact == false)
|
|
|
|
// 加工された派生には feature print 由来の距離が入っている
|
|
if case .similar(let distance) = relations["base_small.jpg"] {
|
|
let value = try #require(distance)
|
|
#expect(value < Sensitivity.standard.featurePrintThreshold)
|
|
} else {
|
|
Issue.record("base_small.jpg は similar であるべき")
|
|
}
|
|
}
|
|
|
|
@Test("サブフォルダを含めないとサブフォルダの画像は対象外になる")
|
|
func respectsRecursionSetting() async throws {
|
|
let directory = try makeFixture()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let groups = try await SimilarityGrouper.run(
|
|
root: directory,
|
|
settings: ScanSettings(includesSubfolders: false, sensitivity: .standard),
|
|
cache: FingerprintCache(fileURL: nil),
|
|
progress: { _ in }
|
|
)
|
|
let group = try #require(groups.first)
|
|
#expect(!group.members.contains { $0.asset.fileName == "base_sub.jpg" })
|
|
#expect(group.members.count == 5)
|
|
}
|
|
|
|
@Test("削減できるバイト数は代表1枚を残した残り")
|
|
func reclaimableBytes() async throws {
|
|
let directory = try makeFixture()
|
|
defer { try? FileManager.default.removeItem(at: directory) }
|
|
|
|
let groups = try await SimilarityGrouper.run(
|
|
root: directory,
|
|
settings: ScanSettings(includesSubfolders: true, sensitivity: .standard),
|
|
cache: FingerprintCache(fileURL: nil),
|
|
progress: { _ in }
|
|
)
|
|
let group = try #require(groups.first)
|
|
#expect(group.reclaimableBytes == group.totalBytes - group.representative.fileSize)
|
|
#expect(group.reclaimableBytes > 0)
|
|
}
|
|
}
|