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
+145
View File
@@ -0,0 +1,145 @@
import SwiftUI
struct ContentView: View {
@Bindable var model: ScanViewModel
@State private var isConfirmingTrash = false
var body: some View {
NavigationSplitView {
GroupListView(model: model)
.navigationSplitViewColumnWidth(min: 270, ideal: 310, max: 420)
} detail: {
if let group = model.selectedGroup {
GroupDetailView(model: model, group: group)
} else {
placeholder
}
}
.toolbar { ScanToolbar(model: model, isConfirmingTrash: $isConfirmingTrash) }
.overlay {
if let progress = model.progress {
Color.black.opacity(0.12).ignoresSafeArea()
ScanProgressView(progress: progress) { model.cancelScan() }
}
}
.confirmationDialog(
"\(model.markedCount) 枚をゴミ箱へ移動します",
isPresented: $isConfirmingTrash,
titleVisibility: .visible
) {
Button("ゴミ箱へ移動(\(model.markedBytesText))", role: .destructive) {
Task { await model.moveMarkedToTrash() }
}
Button("キャンセル", role: .cancel) {}
} message: {
Text("削除ではなくゴミ箱への移動です。Finder のゴミ箱からいつでも元に戻せます。")
}
.alert(
"エラー",
isPresented: Binding(
get: { model.errorMessage != nil },
set: { if !$0 { model.errorMessage = nil } }
)
) {
Button("OK") { model.errorMessage = nil }
} message: {
Text(model.errorMessage ?? "")
}
}
@ViewBuilder
private var placeholder: some View {
if model.folder == nil {
ContentUnavailableView {
Label("フォルダを選んでください", systemImage: "photo.stack")
} description: {
Text("選んだフォルダの中から、内容が同じ写真とよく似た写真を探します。")
} actions: {
Button("フォルダを選ぶ…") { model.chooseFolder() }
.buttonStyle(.borderedProminent)
}
} else if model.hasScanned && model.groups.isEmpty {
ContentUnavailableView {
Label("似た写真は見つかりませんでした", systemImage: "checkmark.circle")
} description: {
Text("判定をゆるくすると、より広く似た写真を拾えます。")
}
} else {
ContentUnavailableView(
"グループを選択してください",
systemImage: "square.grid.2x2"
)
}
}
}
struct ScanToolbar: ToolbarContent {
@Bindable var model: ScanViewModel
@Binding var isConfirmingTrash: Bool
var body: some ToolbarContent {
ToolbarItem(placement: .navigation) {
Button {
model.chooseFolder()
} label: {
Label("フォルダを選ぶ", systemImage: "folder")
}
.help(model.folder?.path ?? "調べるフォルダを選びます")
}
ToolbarItem {
Picker("類似度", selection: $model.settings.sensitivity) {
ForEach(Sensitivity.allCases) { level in
Text(level.label).tag(level)
}
}
.pickerStyle(.segmented)
.frame(width: 220)
.help(model.settings.sensitivity.help)
.disabled(model.isScanning)
}
ToolbarItem {
Toggle(isOn: $model.settings.includesSubfolders) {
Label("サブフォルダも含む", systemImage: "folder.badge.plus")
}
.help("サブフォルダの中まで再帰的に調べます")
.disabled(model.isScanning)
}
ToolbarItem {
Menu {
ForEach(KeepRule.allCases) { rule in
Button(rule.label) { model.applyKeepRule(rule) }
}
Divider()
Button("選択をすべて解除") { model.clearMarks() }
} label: {
Label("一括選択", systemImage: "checklist")
}
.disabled(model.groups.isEmpty)
}
ToolbarItem {
Button {
isConfirmingTrash = true
} label: {
Label(
model.markedCount > 0 ? "\(model.markedCount) 枚をゴミ箱へ" : "ゴミ箱へ",
systemImage: "trash"
)
}
.disabled(model.markedCount == 0)
}
ToolbarItem {
Button {
model.rescan()
} label: {
Label("再スキャン", systemImage: "arrow.clockwise")
}
.disabled(model.folder == nil || model.isScanning)
}
}
}