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,243 @@
import Foundation
import Observation
/// 各グループで「どの1枚を残すか」の基準。
enum KeepRule: String, CaseIterable, Identifiable {
case highestResolution
case newest
case oldest
var id: String { rawValue }
var label: String {
switch self {
case .highestResolution: return "解像度が最も高い1枚を残す"
case .newest: return "最も新しい1枚を残す"
case .oldest: return "最も古い1枚を残す"
}
}
}
@MainActor
@Observable
final class ScanViewModel {
private(set) var folder: URL?
private(set) var groups: [SimilarityGroup] = []
private(set) var progress: ScanProgress?
private(set) var hasScanned = false
var settings = ScanSettings() {
didSet { if oldValue != settings, folder != nil { rescan() } }
}
var selectedGroupID: SimilarityGroup.ID?
var markedForDeletion: Set<URL> = []
var errorMessage: String?
private var scanTask: Task<Void, Never>?
private let cache = FingerprintCache()
private let folderAccess = FolderAccess()
var isScanning: Bool { progress != nil }
var selectedGroup: SimilarityGroup? {
guard let selectedGroupID else { return nil }
return groups.first { $0.id == selectedGroupID }
}
// MARK: - フォルダ
func restorePreviousFolder() {
guard folder == nil, let url = folderAccess.restorePrevious() else { return }
folder = url
rescan()
}
func chooseFolder() {
guard let url = folderAccess.choose(startingAt: folder) else { return }
folder = url
rescan()
}
// MARK: - スキャン
func rescan() {
guard let folder else { return }
scanTask?.cancel()
groups = []
selectedGroupID = nil
markedForDeletion = []
errorMessage = nil
progress = ScanProgress(phase: .scanning)
let settings = settings
let cache = cache
scanTask = Task { [weak self] in
do {
let result = try await SimilarityGrouper.run(
root: folder,
settings: settings,
cache: cache,
progress: { [weak self] update in
Task { @MainActor in
// キャンセル後に遅れて届いた更新は捨てる
guard let self, self.isScanning else { return }
self.progress = update
}
}
)
try Task.checkCancellation()
self?.finishScan(with: result)
} catch is CancellationError {
self?.progress = nil
} catch {
self?.errorMessage = error.localizedDescription
self?.progress = nil
}
}
}
func cancelScan() {
scanTask?.cancel()
scanTask = nil
progress = nil
}
private func finishScan(with result: [SimilarityGroup]) {
groups = result
selectedGroupID = result.first?.id
progress = nil
hasScanned = true
}
#if DEBUG
/// テスト専用。実際にスキャンせずに結果を差し込む。
func setGroupsForTesting(_ groups: [SimilarityGroup]) {
self.groups = groups
self.selectedGroupID = groups.first?.id
self.hasScanned = true
}
#endif
// MARK: - 選択
/// そのグループで最後の1枚を消すことになる場合は選べない。
func canMark(_ url: URL, in group: SimilarityGroup) -> Bool {
if markedForDeletion.contains(url) { return true }
let remaining = group.members.filter { !markedForDeletion.contains($0.id) }
return remaining.count > 1
}
func toggleMark(_ url: URL, in group: SimilarityGroup) {
if markedForDeletion.contains(url) {
markedForDeletion.remove(url)
} else if canMark(url, in: group) {
markedForDeletion.insert(url)
}
}
func applyKeepRule(_ rule: KeepRule) {
var marks: Set<URL> = []
for group in groups {
guard let keeper = keeper(for: rule, in: group) else { continue }
for member in group.members where member.id != keeper {
marks.insert(member.id)
}
}
markedForDeletion = marks
}
func clearMarks() {
markedForDeletion = []
}
private func keeper(for rule: KeepRule, in group: SimilarityGroup) -> URL? {
switch rule {
case .highestResolution:
return group.members.max {
($0.asset.pixelCount, $0.asset.fileSize) < ($1.asset.pixelCount, $1.asset.fileSize)
}?.id
case .newest:
return group.members.max {
sortDate($0.asset) < sortDate($1.asset)
}?.id
case .oldest:
return group.members.min {
sortDate($0.asset) < sortDate($1.asset)
}?.id
}
}
private func sortDate(_ asset: PhotoAsset) -> Date {
asset.captureDate ?? asset.modificationDate
}
// MARK: - 集計
var markedCount: Int { markedForDeletion.count }
var markedBytes: Int64 {
groups
.flatMap(\.members)
.filter { markedForDeletion.contains($0.id) }
.reduce(0) { $0 + $1.asset.fileSize }
}
var markedBytesText: String {
ByteCountFormatter.string(fromByteCount: markedBytes, countStyle: .file)
}
var totalReclaimableText: String {
let bytes = groups.reduce(0) { $0 + $1.reclaimableBytes }
return ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file)
}
var totalDuplicateCount: Int {
groups.reduce(0) { $0 + $1.members.count - 1 }
}
// MARK: - ゴミ箱へ
func moveMarkedToTrash() async {
let targets = Array(markedForDeletion)
guard !targets.isEmpty else { return }
let outcome = await TrashService.moveToTrash(targets)
let trashed = Set(outcome.trashed)
guard !trashed.isEmpty || !outcome.failed.isEmpty else { return }
markedForDeletion.subtract(trashed)
removeFromGroups(trashed)
if !outcome.failed.isEmpty {
let names = outcome.failed.prefix(5).map(\.lastPathComponent).joined(separator: "\n")
let more = outcome.failed.count > 5 ? "\nほか \(outcome.failed.count - 5) 件" : ""
errorMessage = """
\(outcome.failed.count) 件をゴミ箱へ移動できませんでした。
\(names)\(more)
\(outcome.errorDescription ?? "")
"""
}
}
/// ゴミ箱に入れたファイルを一覧から取り除き、1枚だけになったグループは畳む。
private func removeFromGroups(_ removed: Set<URL>) {
var rebuilt: [SimilarityGroup] = []
for group in groups {
let survivors = group.members.filter { !removed.contains($0.id) }
guard survivors.count > 1 else { continue }
let representativeID = survivors.contains { $0.id == group.representativeID }
? group.representativeID
: survivors[0].id
rebuilt.append(
SimilarityGroup(members: survivors, representativeID: representativeID)
)
}
groups = rebuilt
if let selectedGroupID, !rebuilt.contains(where: { $0.id == selectedGroupID }) {
self.selectedGroupID = rebuilt.first?.id
}
}
}