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
+149
View File
@@ -0,0 +1,149 @@
import AppKit
import Foundation
import SwiftUI
import Testing
@testable import Photochk
/// 画面が実際に描けることを確かめる。
///
/// 描いた PNG は一時フォルダに残すので、目視で確認したいときはテスト出力に出るパスを開く。
@Suite("画面の描画")
@MainActor
struct SnapshotTests {
/// 実物に近い見た目にするため、寸法・容量・撮影日が散らばったグループを作る。
private func makeFixtureModel() async throws -> (ScanViewModel, URL) {
let directory = try TestImages.makeTemporaryDirectory()
let pattern = TestImages.pattern(seed: 42)
let base = directory.appendingPathComponent("IMG_0001.jpg")
try TestImages.writeJPEG(pattern, to: base, quality: 0.95)
try FileManager.default.copyItem(
at: base, to: directory.appendingPathComponent("IMG_0001のコピー.jpg")
)
try TestImages.writeJPEG(
TestImages.scaled(pattern, factor: 0.5),
to: directory.appendingPathComponent("IMG_0001_small.jpg"),
quality: 0.9
)
try TestImages.writeJPEG(
pattern, to: directory.appendingPathComponent("IMG_0001_line.jpg"), quality: 0.3
)
let other = TestImages.pattern(seed: 9_999)
try TestImages.writeJPEG(other, to: directory.appendingPathComponent("IMG_9000.jpg"), quality: 0.95)
try FileManager.default.copyItem(
at: directory.appendingPathComponent("IMG_9000.jpg"),
to: directory.appendingPathComponent("IMG_9000_backup.jpg")
)
let groups = try await SimilarityGrouper.run(
root: directory,
settings: ScanSettings(includesSubfolders: true, sensitivity: .standard),
cache: FingerprintCache(fileURL: nil),
progress: { _ in }
)
let model = ScanViewModel()
model.setGroupsForTesting(groups)
// サムネイルを先に読み込んでおく。描画時はキャッシュから同期的に出る。
for group in groups {
for member in group.members {
_ = await ThumbnailLoader.shared.thumbnail(for: member.id, size: 168)
_ = await ThumbnailLoader.shared.thumbnail(for: member.id, size: 52)
}
}
return (model, directory)
}
private func render(
_ view: some View, width: CGFloat, height: CGFloat, name: String
) throws -> URL {
let renderer = ImageRenderer(
content: view
.frame(width: width, height: height)
.background(Color(nsColor: .windowBackgroundColor))
)
renderer.scale = 2
let image = try #require(renderer.nsImage, "\(name) を描画できなかった")
let tiff = try #require(image.tiffRepresentation)
let bitmap = try #require(NSBitmapImageRep(data: tiff))
let png = try #require(bitmap.representation(using: .png, properties: [:]))
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("photochk-snapshots", isDirectory: true)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = directory.appendingPathComponent("\(name).png")
try png.write(to: url)
return url
}
@Test("写真タイルが状態ごとに描画できる")
func rendersPhotoTiles() async throws {
let (model, directory) = try await makeFixtureModel()
defer { try? FileManager.default.removeItem(at: directory) }
let group = try #require(model.groups.first)
try #require(group.members.count >= 4, "タイルを並べるだけの枚数が必要")
// 代表 / 通常 / 選択済み / 最後の1枚(選択不可)を横に並べる
let tiles = HStack(alignment: .top, spacing: 18) {
PhotoTileView(
member: group.members[0], isRepresentative: true,
isMarked: false, canMark: true, onToggle: {}
)
PhotoTileView(
member: group.members[1], isRepresentative: false,
isMarked: false, canMark: true, onToggle: {}
)
PhotoTileView(
member: group.members[2], isRepresentative: false,
isMarked: true, canMark: true, onToggle: {}
)
PhotoTileView(
member: group.members[3], isRepresentative: false,
isMarked: false, canMark: false, onToggle: {}
)
}
.padding(20)
let url = try render(tiles, width: 800, height: 300, name: "photo-tiles")
print("[snapshot] \(url.path)")
}
@Test("グループ一覧の行が描画できる")
func rendersGroupRows() async throws {
let (model, directory) = try await makeFixtureModel()
defer { try? FileManager.default.removeItem(at: directory) }
model.applyKeepRule(.highestResolution)
let rows = VStack(alignment: .leading, spacing: 8) {
ForEach(model.groups) { group in
GroupRow(
group: group,
markedCount: group.members.filter {
model.markedForDeletion.contains($0.id)
}.count
)
Divider()
}
}
.padding(12)
let url = try render(rows, width: 300, height: 220, name: "group-rows")
print("[snapshot] \(url.path)")
}
@Test("スキャン中の表示が描画できる")
func rendersProgress() throws {
let url = try render(
ScanProgressView(
progress: ScanProgress(phase: .fingerprinting, completed: 128, total: 940),
onCancel: {}
),
width: 420, height: 220, name: "progress"
)
print("[snapshot] \(url.path)")
}
}