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,91 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
struct PhotoTileView: View {
|
||||
let member: SimilarityGroup.Member
|
||||
let isRepresentative: Bool
|
||||
let isMarked: Bool
|
||||
let canMark: Bool
|
||||
let onToggle: () -> Void
|
||||
|
||||
private let thumbnailSize: CGFloat = 168
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
ZStack(alignment: .topLeading) {
|
||||
ThumbnailView(url: member.id, size: thumbnailSize)
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 6, style: .continuous)
|
||||
.strokeBorder(borderColor, lineWidth: isMarked ? 3 : 1)
|
||||
}
|
||||
.opacity(isMarked ? 0.55 : 1)
|
||||
|
||||
Toggle(isOn: Binding(get: { isMarked }, set: { _ in onToggle() })) {
|
||||
EmptyView()
|
||||
}
|
||||
.toggleStyle(.checkbox)
|
||||
.labelsHidden()
|
||||
.disabled(!canMark)
|
||||
// 明るい写真の上でもチェックボックスが埋もれないように下敷きを敷く
|
||||
.padding(4)
|
||||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 5, style: .continuous))
|
||||
.padding(6)
|
||||
.help(canMark ? "ゴミ箱へ移動する対象にする" : "グループから最低1枚は残す必要があります")
|
||||
|
||||
if isRepresentative {
|
||||
Text("残す候補")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color.accentColor, in: Capsule())
|
||||
.foregroundStyle(.white)
|
||||
.padding(6)
|
||||
.frame(maxWidth: thumbnailSize, alignment: .topTrailing)
|
||||
}
|
||||
}
|
||||
|
||||
Text(member.asset.fileName)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
Text("\(member.asset.dimensionText)・\(member.asset.fileSizeText)")
|
||||
Text(member.asset.captureDateText)
|
||||
|
||||
if isRepresentative {
|
||||
Text("この写真が基準")
|
||||
} else if let tag = member.exactSetTag {
|
||||
// 代表とは別だが、同じ印どうしは中身が完全に一致している
|
||||
Text("\(member.relation.distanceText)・同一 \(tag)")
|
||||
.foregroundStyle(.orange)
|
||||
} else {
|
||||
Text(member.relation.distanceText)
|
||||
.foregroundStyle(member.relation.isExact ? .orange : .secondary)
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(width: thumbnailSize)
|
||||
.contentShape(.rect)
|
||||
// クリックは選択に一本化する。ダブルクリックを併置すると単クリックと取り合いになり、
|
||||
// チェックが二度切り替わってしまうため、プレビューは右クリックメニューから開く。
|
||||
.onTapGesture { if canMark { onToggle() } }
|
||||
.contextMenu {
|
||||
Button("プレビューで開く") { NSWorkspace.shared.open(member.id) }
|
||||
Button("Finder で表示") {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([member.id])
|
||||
}
|
||||
Button("パスをコピー") {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(member.id.path, forType: .string)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
if isMarked { return .red }
|
||||
return isRepresentative ? .accentColor : Color.primary.opacity(0.12)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user