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:
@@ -0,0 +1,102 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import Photochk
|
||||
|
||||
@Suite("選択ルール")
|
||||
@MainActor
|
||||
struct SelectionTests {
|
||||
private func asset(
|
||||
_ name: String, width: Int, height: Int, size: Int64, days: Double
|
||||
) -> PhotoAsset {
|
||||
PhotoAsset(
|
||||
url: URL(fileURLWithPath: "/tmp/photochk/\(name)"),
|
||||
fileSize: size,
|
||||
modificationDate: Date(timeIntervalSince1970: days * 86_400),
|
||||
captureDate: Date(timeIntervalSince1970: days * 86_400),
|
||||
pixelWidth: width,
|
||||
pixelHeight: height
|
||||
)
|
||||
}
|
||||
|
||||
private func makeGroup() -> SimilarityGroup {
|
||||
let big = asset("big.jpg", width: 4000, height: 3000, size: 5_000_000, days: 10)
|
||||
let mid = asset("mid.jpg", width: 2000, height: 1500, size: 2_000_000, days: 20)
|
||||
let small = asset("small.jpg", width: 1000, height: 750, size: 500_000, days: 1)
|
||||
|
||||
return SimilarityGroup(
|
||||
members: [
|
||||
.init(asset: big, relation: .exact),
|
||||
.init(asset: mid, relation: .similar(distance: 0.1)),
|
||||
.init(asset: small, relation: .similar(distance: 0.2)),
|
||||
],
|
||||
representativeID: big.url
|
||||
)
|
||||
}
|
||||
|
||||
private func makeModel() -> ScanViewModel {
|
||||
let model = ScanViewModel()
|
||||
model.setGroupsForTesting([makeGroup()])
|
||||
return model
|
||||
}
|
||||
|
||||
@Test("最後の1枚は選択できない")
|
||||
func keepsAtLeastOne() {
|
||||
let model = makeModel()
|
||||
let group = model.groups[0]
|
||||
let ids = group.members.map(\.id)
|
||||
|
||||
model.toggleMark(ids[0], in: group)
|
||||
model.toggleMark(ids[1], in: group)
|
||||
#expect(model.markedCount == 2)
|
||||
|
||||
// 3枚目を選ぶと全滅するので拒否される
|
||||
#expect(model.canMark(ids[2], in: group) == false)
|
||||
model.toggleMark(ids[2], in: group)
|
||||
#expect(model.markedCount == 2)
|
||||
|
||||
// すでに選んでいるものは解除できる
|
||||
#expect(model.canMark(ids[0], in: group) == true)
|
||||
}
|
||||
|
||||
@Test("解像度が最も高い1枚を残す")
|
||||
func keepHighestResolution() {
|
||||
let model = makeModel()
|
||||
model.applyKeepRule(.highestResolution)
|
||||
|
||||
let kept = model.groups[0].members.filter { !model.markedForDeletion.contains($0.id) }
|
||||
#expect(kept.count == 1)
|
||||
#expect(kept[0].asset.fileName == "big.jpg")
|
||||
}
|
||||
|
||||
@Test("最も新しい1枚 / 最も古い1枚を残す")
|
||||
func keepNewestAndOldest() {
|
||||
let model = makeModel()
|
||||
|
||||
model.applyKeepRule(.newest)
|
||||
var kept = model.groups[0].members.filter { !model.markedForDeletion.contains($0.id) }
|
||||
#expect(kept.map(\.asset.fileName) == ["mid.jpg"])
|
||||
|
||||
model.applyKeepRule(.oldest)
|
||||
kept = model.groups[0].members.filter { !model.markedForDeletion.contains($0.id) }
|
||||
#expect(kept.map(\.asset.fileName) == ["small.jpg"])
|
||||
}
|
||||
|
||||
@Test("選択解除ですべての印が消える")
|
||||
func clearMarks() {
|
||||
let model = makeModel()
|
||||
model.applyKeepRule(.highestResolution)
|
||||
#expect(model.markedCount == 2)
|
||||
|
||||
model.clearMarks()
|
||||
#expect(model.markedCount == 0)
|
||||
#expect(model.markedBytes == 0)
|
||||
}
|
||||
|
||||
@Test("選択した容量の合計が集計される")
|
||||
func markedBytes() {
|
||||
let model = makeModel()
|
||||
model.applyKeepRule(.highestResolution)
|
||||
#expect(model.markedBytes == 2_500_000)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user