From c7f68b6cca04ef5cb5ba5661d124c9a50576a0eb Mon Sep 17 00:00:00 2001 From: yfoxjp <36430614+yfoxjp@users.noreply.github.com> Date: Tue, 28 Jul 2026 08:02:14 +0900 Subject: [PATCH] =?UTF-8?q?Photochk=201.0.0=20=E2=80=94=20=E9=A1=9E?= =?UTF-8?q?=E4=BC=BC=E5=86=99=E7=9C=9F=E3=82=92=E6=8E=A2=E3=81=97=E3=81=A6?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=81=99=E3=82=8B=20macOS=20=E3=82=A2?= =?UTF-8?q?=E3=83=97=E3=83=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す1枚を選んでほかをゴミ箱へ送れるようにする。 検出は安い判定から順に4段: - ファイルサイズが同じものだけ SHA-256 でバイト一致を確定 - dHash(知覚ハッシュ)のハミング距離で候補ペアを抽出 - 候補に出てくる画像だけ Vision の feature print で精査 - Union-Find で連結成分をグループ化 判定のしきい値は実写画像で距離を実測して決めた(docs/calibration.md)。 tools/release-update.sh で Gitea のリリースを更新する。 Co-Authored-By: Claude Opus 5 --- .gitignore | 7 + Makefile | 35 ++ README.md | 103 +++++ Sources/Photochk/Core/Concurrency.swift | 42 +++ Sources/Photochk/Core/ContentHasher.swift | 18 + Sources/Photochk/Core/DHash.swift | 127 +++++++ Sources/Photochk/Core/FeaturePrinter.swift | 23 ++ Sources/Photochk/Core/FingerprintCache.swift | 118 ++++++ Sources/Photochk/Core/PhotoScanner.swift | 74 ++++ Sources/Photochk/Core/SimilarityGrouper.swift | 351 ++++++++++++++++++ Sources/Photochk/Core/UnionFind.swift | 50 +++ Sources/Photochk/Info.plist | 32 ++ Sources/Photochk/Models/PhotoAsset.swift | 40 ++ Sources/Photochk/Models/ScanSettings.swift | 90 +++++ Sources/Photochk/Models/SimilarityGroup.swift | 75 ++++ Sources/Photochk/Photochk.entitlements | 12 + Sources/Photochk/PhotochkApp.swift | 25 ++ Sources/Photochk/Services/FolderAccess.swift | 73 ++++ .../Photochk/Services/ThumbnailLoader.swift | 46 +++ Sources/Photochk/Services/TrashService.swift | 30 ++ .../Photochk/ViewModels/ScanViewModel.swift | 243 ++++++++++++ Sources/Photochk/Views/ContentView.swift | 145 ++++++++ Sources/Photochk/Views/GroupDetailView.swift | 57 +++ Sources/Photochk/Views/GroupListView.swift | 114 ++++++ Sources/Photochk/Views/PhotoTileView.swift | 91 +++++ Sources/Photochk/Views/ScanProgressView.swift | 29 ++ Sources/Photochk/Views/ThumbnailView.swift | 35 ++ Tests/PhotochkTests/DHashTests.swift | 58 +++ .../PhotochkTests/FingerprintCacheTests.swift | 81 ++++ Tests/PhotochkTests/GroupingTests.swift | 166 +++++++++ Tests/PhotochkTests/SelectionTests.swift | 102 +++++ Tests/PhotochkTests/SnapshotTests.swift | 149 ++++++++ Tests/PhotochkTests/TestImages.swift | 129 +++++++ Tests/PhotochkTests/UnionFindTests.swift | 33 ++ docs/calibration.md | 72 ++++ project.yml | 61 +++ tools/_release_api.py | 89 +++++ tools/release-update.sh | 77 ++++ 38 files changed, 3102 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 README.md create mode 100644 Sources/Photochk/Core/Concurrency.swift create mode 100644 Sources/Photochk/Core/ContentHasher.swift create mode 100644 Sources/Photochk/Core/DHash.swift create mode 100644 Sources/Photochk/Core/FeaturePrinter.swift create mode 100644 Sources/Photochk/Core/FingerprintCache.swift create mode 100644 Sources/Photochk/Core/PhotoScanner.swift create mode 100644 Sources/Photochk/Core/SimilarityGrouper.swift create mode 100644 Sources/Photochk/Core/UnionFind.swift create mode 100644 Sources/Photochk/Info.plist create mode 100644 Sources/Photochk/Models/PhotoAsset.swift create mode 100644 Sources/Photochk/Models/ScanSettings.swift create mode 100644 Sources/Photochk/Models/SimilarityGroup.swift create mode 100644 Sources/Photochk/Photochk.entitlements create mode 100644 Sources/Photochk/PhotochkApp.swift create mode 100644 Sources/Photochk/Services/FolderAccess.swift create mode 100644 Sources/Photochk/Services/ThumbnailLoader.swift create mode 100644 Sources/Photochk/Services/TrashService.swift create mode 100644 Sources/Photochk/ViewModels/ScanViewModel.swift create mode 100644 Sources/Photochk/Views/ContentView.swift create mode 100644 Sources/Photochk/Views/GroupDetailView.swift create mode 100644 Sources/Photochk/Views/GroupListView.swift create mode 100644 Sources/Photochk/Views/PhotoTileView.swift create mode 100644 Sources/Photochk/Views/ScanProgressView.swift create mode 100644 Sources/Photochk/Views/ThumbnailView.swift create mode 100644 Tests/PhotochkTests/DHashTests.swift create mode 100644 Tests/PhotochkTests/FingerprintCacheTests.swift create mode 100644 Tests/PhotochkTests/GroupingTests.swift create mode 100644 Tests/PhotochkTests/SelectionTests.swift create mode 100644 Tests/PhotochkTests/SnapshotTests.swift create mode 100644 Tests/PhotochkTests/TestImages.swift create mode 100644 Tests/PhotochkTests/UnionFindTests.swift create mode 100644 docs/calibration.md create mode 100644 project.yml create mode 100644 tools/_release_api.py create mode 100644 tools/release-update.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8e0c94c --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.DS_Store +build/ +dist/ +DerivedData/ +*.xcodeproj +*.xcworkspace +xcuserdata/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5f06f12 --- /dev/null +++ b/Makefile @@ -0,0 +1,35 @@ +PROJECT := Photochk.xcodeproj +SCHEME := Photochk +BUILD := $(CURDIR)/build + +VERSION := 1.0.0 + +.PHONY: generate build test run release install clean + +generate: + xcodegen generate + +build: generate + xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration Debug \ + -derivedDataPath $(BUILD) build + +test: generate + xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration Debug \ + -derivedDataPath $(BUILD) test + +run: build + open $(BUILD)/Build/Products/Debug/Photochk.app + +# 配布用の Release ビルドと zip を dist/ に作る +release: generate + xcodebuild -project $(PROJECT) -scheme $(SCHEME) -configuration Release \ + -derivedDataPath $(BUILD) build + mkdir -p dist + ditto -c -k --keepParent $(BUILD)/Build/Products/Release/Photochk.app \ + dist/Photochk-$(VERSION).zip + +install: release + ditto $(BUILD)/Build/Products/Release/Photochk.app /Applications/Photochk.app + +clean: + rm -rf $(BUILD) $(PROJECT) dist diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc37ab1 --- /dev/null +++ b/README.md @@ -0,0 +1,103 @@ +# Photochk + +フォルダの中から、中身が同じ写真と見た目が似ている写真を探して一覧にする macOS アプリ。 + +同じ写真のコピー、リサイズ版、メッセージアプリ経由で再圧縮された版、連写のほぼ同じカット — +ファイル名もサイズも違うので Finder では見つからないものを、グループにまとめて見せる。 +残す1枚を選んで、ほかをまとめてゴミ箱へ送れる(削除ではないので Finder から戻せる)。 + +## 使い方 + +```bash +make run +``` + +`make build` でビルドのみ、`make test` でテスト。生成物は `build/Build/Products/Debug/Photochk.app`。 + +配布用に固めるなら `make release`(Release ビルド+`dist/Photochk-1.0.0.zip`)、 +`/Applications` に入れるなら `make install`。 + +> ビルドは ad-hoc 署名(Developer ID 証明書なし)。自分の Mac でビルドして使う分には問題ないが、 +> zip を他の Mac に配ると Gatekeeper に止められる。受け取った側は右クリック →「開く」で起動できる。 + +1. ツールバーの「フォルダを選ぶ」で対象フォルダを指定する +2. 左のグループ一覧から選ぶと、右にそのグループの全枚数が並ぶ +3. 残す1枚以外にチェックを入れる(「一括選択」メニューで「解像度が最も高い1枚を残す」なども選べる) +4. 「N 枚をゴミ箱へ」で移動する + +グループから全部を選ぶことはできない。必ず1枚は残る。 + +各写真には基準(代表)からの距離が出る。代表以外にもバイト単位で中身が同じファイルがある場合は +「同一 A」のように同じ印が付くので、その中のどれを消しても失われるものがないと分かる。 + +## 検出のしくみ + +安い判定から順にかけて候補を絞り込む。 + +1. **完全同一** — ファイルサイズが同じものだけ SHA-256 を計算して、バイト単位の重複を確定する +2. **知覚ハッシュ (dHash)** — 9×8 のグレースケールに落として隣接ピクセルの明暗を比べ、64bit のハッシュを作る。 + ハミング距離が近いペアだけを次に回す +3. **Vision の feature print** — 候補ペアに出てくる画像だけ `GenerateImageFeaturePrintRequest` で + 特徴量を計算し、距離で最終判定する +4. **Union-Find** — 「同一」「類似」のペアから連結成分を作ってグループにする + +各グループの代表は「解像度が最大 → ファイルサイズが最大 → 撮影が最古」で自動的に選ばれる。 + +判定の厳しさは3段階。しきい値は実写画像で距離を測って決めた([docs/calibration.md](docs/calibration.md))。 + +| 段階 | 何が拾えるか | +|---|---| +| 厳しい | コピー・縮小(25% まで)・再圧縮 | +| 標準 | 上記+連写・同一シーンの近いカット | +| ゆるい | 上記+トリミングされた版 | + +対応形式は UTType が画像に該当するものすべて。JPEG / PNG / HEIC / TIFF に加えて各社の RAW も扱える。 + +### 分かっている制限 + +- **向きが違う写真は別物として扱う。** EXIF の向きを反映した「見た目」で比較しているため、 + 回転が焼き込まれた版と EXIF で回している版は同じ写真として検出できるが、 + 実際に 90 度違う向きの写真どうしは検出しない(4方向ぶんの特徴量が必要でコストが4倍になるため) +- 2回目以降のスキャンは `~/Library/Caches/Photochk/fingerprints.plist` のキャッシュで速くなる。 + ファイルサイズと更新日時が変わっていれば自動で計算し直す + +## 構成 + +``` +Sources/Photochk/ +├── Models/ PhotoAsset, SimilarityGroup, ScanSettings +├── Core/ 走査・ハッシュ・特徴量・グルーピング(UI 非依存) +├── Services/ サムネイル生成、ゴミ箱、フォルダのアクセス権 +├── ViewModels/ ScanViewModel +└── Views/ SwiftUI +``` + +外部依存なし。SwiftUI / Vision / CryptoKit / ImageIO / QuickLookThumbnailing のみ。 +App Sandbox 有効で、アクセスできるのはユーザーが選んだフォルダだけ。 + +## 開発 + +Xcode プロジェクトは XcodeGen で `project.yml` から生成する(リポジトリには含めない)。 + +```bash +make generate && open Photochk.xcodeproj +``` + +必要なもの: macOS 15 以降、Xcode 26 以降、`brew install xcodegen`。 + +### リリースの更新 + +`tools/release-update.sh` が Gitea のリリースを差し替える(yfoxlogsrv と同じ流儀)。 + +```bash +make release && ./tools/release-update.sh +``` + +タグを HEAD へ移動して push し、リリースの添付 zip を最新ビルドに入れ替え、本文の +「ビルド元コミット」を更新する。認証は `git credential`(host=git.yfox.jp)から取るので、 +未登録なら実行時に登録コマンドが表示される。リリース自体が未作成の場合はエラーで止まるので、 +最初の1本だけ web か gitea MCP の `create_release` で作る。 + +`Tests/PhotochkTests/SnapshotTests.swift` は `ImageRenderer` で写真タイルなどを実際に描画し、 +PNG を一時フォルダに書き出す(パスはテスト出力に出る)。レイアウトを変えたら開いて目で確認できる。 +`List` や `NavigationSplitView` は AppKit 由来で `ImageRenderer` では描けないため対象外。 diff --git a/Sources/Photochk/Core/Concurrency.swift b/Sources/Photochk/Core/Concurrency.swift new file mode 100644 index 0000000..b282689 --- /dev/null +++ b/Sources/Photochk/Core/Concurrency.swift @@ -0,0 +1,42 @@ +import Foundation + +/// 同時実行数を抑えつつ並列に変換し、入力と同じ順序で結果を返す。 +/// +/// 画像デコードや Vision の推論は CPU を使い切るので、無制限に投げるより +/// コア数程度に抑えたほうが速く、メモリも安定する。 +func mapConcurrently( + _ items: [Item], + limit: Int = max(2, ProcessInfo.processInfo.activeProcessorCount), + onProgress: (@Sendable (Int) -> Void)? = nil, + _ transform: @escaping @Sendable (Item) async -> Output +) async throws -> [Output] { + guard !items.isEmpty else { return [] } + + var results = [Output?](repeating: nil, count: items.count) + + try await withThrowingTaskGroup(of: (Int, Output).self) { group in + var next = 0 + let initial = min(limit, items.count) + while next < initial { + let index = next + group.addTask { (index, await transform(items[index])) } + next += 1 + } + + var finished = 0 + while let (index, value) = try await group.next() { + results[index] = value + finished += 1 + onProgress?(finished) + try Task.checkCancellation() + + if next < items.count { + let index = next + group.addTask { (index, await transform(items[index])) } + next += 1 + } + } + } + + return results.compactMap { $0 } +} diff --git a/Sources/Photochk/Core/ContentHasher.swift b/Sources/Photochk/Core/ContentHasher.swift new file mode 100644 index 0000000..78c1ebc --- /dev/null +++ b/Sources/Photochk/Core/ContentHasher.swift @@ -0,0 +1,18 @@ +import CryptoKit +import Foundation + +enum ContentHasher { + private static let chunkSize = 1 << 20 // 1 MB + + /// ファイル全体を読み込まずに SHA-256 を求める。 + static func sha256(of url: URL) throws -> String { + let handle = try FileHandle(forReadingFrom: url) + defer { try? handle.close() } + + var hasher = SHA256() + while let chunk = try handle.read(upToCount: chunkSize), !chunk.isEmpty { + hasher.update(data: chunk) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } +} diff --git a/Sources/Photochk/Core/DHash.swift b/Sources/Photochk/Core/DHash.swift new file mode 100644 index 0000000..889d41e --- /dev/null +++ b/Sources/Photochk/Core/DHash.swift @@ -0,0 +1,127 @@ +import CoreGraphics +import Foundation +import ImageIO + +/// 画像を1回デコードして得られる基本情報。dHash と表示用メタデータをまとめて取る。 +struct ImageBasics: Codable, Hashable, Sendable { + let dHash: UInt64 + let pixelWidth: Int + let pixelHeight: Int + let captureDate: Date? +} + +/// 隣接ピクセルの明暗差から 64bit の知覚ハッシュ(dHash)を作る。 +/// +/// リサイズや再圧縮では値がほとんど変わらないので、feature print にかける候補ペアを +/// 安く絞り込むための一次フィルタとして使う。 +enum DHash { + /// 9×8 に落として横隣接を比較するので 8×8 = 64bit になる。 + private static let hashWidth = 9 + private static let hashHeight = 8 + + static func compute(for url: URL) -> ImageBasics? { + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetCount(source) > 0 + else { return nil } + + let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] + let (width, height) = pixelSize(from: properties) + let captureDate = captureDate(from: properties) + + // フルデコードを避けるため、まず小さなサムネイルを取り出す。 + // WithTransform で EXIF の回転を適用しておく。Vision の feature print も同じく EXIF を + // 反映するので、これで「表示したときの見た目」どうしを比較する形に揃う。 + // 結果として、回転が焼き込まれた版と EXIF で回している版は同じ写真として検出できる一方、 + // 実際に向きが違う写真どうしは別物として扱われる。 + let thumbnailOptions: [CFString: Any] = [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: 64, + ] + guard let thumbnail = CGImageSourceCreateThumbnailAtIndex( + source, 0, thumbnailOptions as CFDictionary + ) else { return nil } + + guard let hash = hash(of: thumbnail) else { return nil } + + return ImageBasics( + dHash: hash, + pixelWidth: width, + pixelHeight: height, + captureDate: captureDate + ) + } + + /// 2つのハッシュがどれだけ違うか(異なるビット数)。 + static func hammingDistance(_ a: UInt64, _ b: UInt64) -> Int { + (a ^ b).nonzeroBitCount + } + + // MARK: - 内部 + + private static func hash(of image: CGImage) -> UInt64? { + let bytesPerRow = 16 // hashWidth(9) 以上で 16 バイト境界に揃える + var buffer = [UInt8](repeating: 0, count: bytesPerRow * hashHeight) + + let ok = buffer.withUnsafeMutableBytes { raw -> Bool in + guard let context = CGContext( + data: raw.baseAddress, + width: hashWidth, + height: hashHeight, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: CGColorSpaceCreateDeviceGray(), + bitmapInfo: CGImageAlphaInfo.none.rawValue + ) else { return false } + + context.interpolationQuality = .medium + context.draw( + image, + in: CGRect(x: 0, y: 0, width: hashWidth, height: hashHeight) + ) + return true + } + guard ok else { return nil } + + var hash: UInt64 = 0 + var bit = 0 + for y in 0.. buffer[row + x + 1] { + hash |= (1 << UInt64(bit)) + } + bit += 1 + } + } + return hash + } + + private static func pixelSize(from properties: [CFString: Any]?) -> (Int, Int) { + let width = properties?[kCGImagePropertyPixelWidth] as? Int ?? 0 + let height = properties?[kCGImagePropertyPixelHeight] as? Int ?? 0 + + // EXIF の回転を反映した「見た目の」サイズを返す。 + let orientation = properties?[kCGImagePropertyOrientation] as? Int ?? 1 + return (5...8).contains(orientation) ? (height, width) : (width, height) + } + + private static func captureDate(from properties: [CFString: Any]?) -> Date? { + guard let exif = properties?[kCGImagePropertyExifDictionary] as? [CFString: Any] else { + return nil + } + let raw = exif[kCGImagePropertyExifDateTimeOriginal] as? String + ?? exif[kCGImagePropertyExifDateTimeDigitized] as? String + guard let raw else { return nil } + return exifDateFormatter.date(from: raw) + } + + /// EXIF の日時は "2024:05:01 12:34:56" 形式。 + private static let exifDateFormatter: DateFormatter = { + let f = DateFormatter() + f.locale = Locale(identifier: "en_US_POSIX") + f.dateFormat = "yyyy:MM:dd HH:mm:ss" + return f + }() +} diff --git a/Sources/Photochk/Core/FeaturePrinter.swift b/Sources/Photochk/Core/FeaturePrinter.swift new file mode 100644 index 0000000..66963cb --- /dev/null +++ b/Sources/Photochk/Core/FeaturePrinter.swift @@ -0,0 +1,23 @@ +import Foundation +import Vision + +/// Vision の feature print で「見た目の近さ」を測る。 +/// +/// `perform(on: URL)` は Vision 側が ImageIO でデコードするため、HEIC や RAW もそのまま渡せる。 +enum FeaturePrinter { + static func compute(for url: URL) async throws -> FeaturePrintObservation { + var request = GenerateImageFeaturePrintRequest() + // 縦横比が違う版(トリミング・リサイズ)でも比較できるよう、切り取らずに収める。 + request.cropAndScaleAction = .scaleToFit + return try await request.perform(on: url) + } + + /// 距離は 0 が同一。比較できない組み合わせでは nil を返す。 + static func distance( + _ a: FeaturePrintObservation?, + _ b: FeaturePrintObservation? + ) -> Double? { + guard let a, let b else { return nil } + return try? a.distance(to: b) + } +} diff --git a/Sources/Photochk/Core/FingerprintCache.swift b/Sources/Photochk/Core/FingerprintCache.swift new file mode 100644 index 0000000..25b96d2 --- /dev/null +++ b/Sources/Photochk/Core/FingerprintCache.swift @@ -0,0 +1,118 @@ +import Foundation +import Vision + +/// 1ファイル分のキャッシュ。ファイルサイズと更新日時が一致するときだけ再利用する。 +struct FingerprintRecord: Codable, Sendable { + var fileSize: Int64 + var modificationDate: Date + var basics: ImageBasics? + var featurePrint: FeaturePrintObservation? + + func matches(_ file: ScannedFile) -> Bool { + fileSize == file.fileSize + && abs(modificationDate.timeIntervalSince(file.modificationDate)) < 1 + } +} + +/// dHash と feature print をディスクに保存し、同じフォルダの再スキャンを速くする。 +/// +/// feature print は1枚あたり数 KB あるので、JSON ではなくバイナリ plist で持つ。 +actor FingerprintCache { + private var records: [String: FingerprintRecord] = [:] + private var isDirty = false + private let fileURL: URL? + + init(fileURL: URL? = FingerprintCache.defaultFileURL()) { + self.fileURL = fileURL + self.records = Self.loadRecords(from: fileURL) + } + + static func defaultFileURL() -> URL? { + guard let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + else { return nil } + let directory = caches.appendingPathComponent("Photochk", isDirectory: true) + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("fingerprints.plist") + } + + // MARK: - 読み出し + + func basics(for files: [ScannedFile]) -> [URL: ImageBasics] { + var found: [URL: ImageBasics] = [:] + for file in files { + if let record = records[file.url.path], record.matches(file), let basics = record.basics { + found[file.url] = basics + } + } + return found + } + + func featurePrints(for files: [ScannedFile]) -> [URL: FeaturePrintObservation] { + var found: [URL: FeaturePrintObservation] = [:] + for file in files { + if let record = records[file.url.path], record.matches(file), + let print = record.featurePrint { + found[file.url] = print + } + } + return found + } + + // MARK: - 書き込み + + func store(basics: [URL: ImageBasics], for files: [ScannedFile]) { + for file in files { + guard let value = basics[file.url] else { continue } + update(file) { $0.basics = value } + } + } + + func store(featurePrints: [URL: FeaturePrintObservation], for files: [ScannedFile]) { + for file in files { + guard let value = featurePrints[file.url] else { continue } + update(file) { $0.featurePrint = value } + } + } + + private func update(_ file: ScannedFile, _ body: (inout FingerprintRecord) -> Void) { + var record = records[file.url.path] ?? FingerprintRecord( + fileSize: file.fileSize, + modificationDate: file.modificationDate + ) + if !record.matches(file) { + // ファイルが変わっていたら作り直す + record = FingerprintRecord( + fileSize: file.fileSize, + modificationDate: file.modificationDate + ) + } + body(&record) + records[file.url.path] = record + isDirty = true + } + + /// 消えたファイルのエントリを捨てる。スキャンのたびに肥大化するのを防ぐ。 + func prune(keeping files: [ScannedFile]) { + let alive = Set(files.map(\.url.path)) + let before = records.count + records = records.filter { alive.contains($0.key) } + if records.count != before { isDirty = true } + } + + // MARK: - 永続化 + + func save() { + guard isDirty, let fileURL else { return } + let encoder = PropertyListEncoder() + encoder.outputFormat = .binary + guard let data = try? encoder.encode(records) else { return } + try? data.write(to: fileURL, options: .atomic) + isDirty = false + } + + private static func loadRecords(from fileURL: URL?) -> [String: FingerprintRecord] { + guard let fileURL, let data = try? Data(contentsOf: fileURL) else { return [:] } + // 形式が変わっていたら黙って捨てる。キャッシュなので作り直せばよい。 + return (try? PropertyListDecoder().decode([String: FingerprintRecord].self, from: data)) ?? [:] + } +} diff --git a/Sources/Photochk/Core/PhotoScanner.swift b/Sources/Photochk/Core/PhotoScanner.swift new file mode 100644 index 0000000..f7becb8 --- /dev/null +++ b/Sources/Photochk/Core/PhotoScanner.swift @@ -0,0 +1,74 @@ +import Foundation +import UniformTypeIdentifiers + +/// 走査で見つかった画像ファイル1件。中身はまだ読んでいない。 +struct ScannedFile: Hashable, Sendable { + let url: URL + let fileSize: Int64 + let modificationDate: Date + let creationDate: Date? +} + +enum PhotoScanner { + /// フォルダ配下の画像ファイルを列挙する。 + /// + /// UTType が `.image` に conform するものだけを拾うので、JPEG / PNG / HEIC / TIFF に加えて + /// 各社の RAW も自動的に対象になる。写真ライブラリなどのパッケージは中に入らない。 + static func scan(root: URL, includesSubfolders: Bool) throws -> [ScannedFile] { + let keys: [URLResourceKey] = [ + .isRegularFileKey, + .contentTypeKey, + .fileSizeKey, + .contentModificationDateKey, + .creationDateKey, + ] + + var options: FileManager.DirectoryEnumerationOptions = [ + .skipsHiddenFiles, + .skipsPackageDescendants, + ] + if !includesSubfolders { + options.insert(.skipsSubdirectoryDescendants) + } + + guard let enumerator = FileManager.default.enumerator( + at: root, + includingPropertiesForKeys: keys, + options: options + ) else { + throw ScanError.cannotEnumerate(root) + } + + var results: [ScannedFile] = [] + for case let url as URL in enumerator { + try Task.checkCancellation() + guard let values = try? url.resourceValues(forKeys: Set(keys)), + values.isRegularFile == true, + let type = values.contentType, + type.conforms(to: .image), + let size = values.fileSize, size > 0 + else { continue } + + results.append( + ScannedFile( + url: url, + fileSize: Int64(size), + modificationDate: values.contentModificationDate ?? .distantPast, + creationDate: values.creationDate + ) + ) + } + return results + } +} + +enum ScanError: LocalizedError { + case cannotEnumerate(URL) + + var errorDescription: String? { + switch self { + case .cannotEnumerate(let url): + return "「\(url.lastPathComponent)」の中身を読み取れませんでした。" + } + } +} diff --git a/Sources/Photochk/Core/SimilarityGrouper.swift b/Sources/Photochk/Core/SimilarityGrouper.swift new file mode 100644 index 0000000..f22a725 --- /dev/null +++ b/Sources/Photochk/Core/SimilarityGrouper.swift @@ -0,0 +1,351 @@ +import Foundation +import Vision + +/// 走査からグループ生成までの一連の処理をまとめる。 +/// +/// 安いフィルタから順にかけて候補を絞る: +/// 1. ファイルサイズが同じものだけ SHA-256 → 完全同一を確定 +/// 2. dHash(知覚ハッシュ)で「見た目が近そうな」ペアを抽出 +/// 3. そのペアに登場する画像だけ Vision の feature print で精査 +/// 4. Union-Find で連結成分をグループ化 +enum SimilarityGrouper { + private struct Pair: Sendable { + let a: Int + let b: Int + } + + private struct HashEntry: Sendable { + let index: Int + let hash: UInt64 + } + + /// feature print を計算できなかったときの保険。dHash がここまで一致していれば同一とみなす。 + private static let fallbackHammingThreshold = 4 + + static func run( + root: URL, + settings: ScanSettings, + cache: FingerprintCache, + progress: @escaping @Sendable (ScanProgress) -> Void + ) async throws -> [SimilarityGroup] { + progress(ScanProgress(phase: .scanning)) + let files = try PhotoScanner.scan(root: root, includesSubfolders: settings.includesSubfolders) + try Task.checkCancellation() + guard files.count > 1 else { return [] } + + // --- Stage 1: 完全同一をまとめ、以降は各セットの代表1枚だけを扱う --- + let leaderOf = try await exactLeaders(files: files, progress: progress) + let leaderIndices = files.indices.filter { leaderOf[$0] == $0 } + let leaderFiles = leaderIndices.map { files[$0] } + + // --- Stage 2: dHash --- + let basicsByURL = try await loadBasics(for: leaderFiles, cache: cache, progress: progress) + + // --- Stage 3: 候補ペア → feature print --- + let entries = leaderIndices.compactMap { index -> HashEntry? in + guard let basics = basicsByURL[files[index].url] else { return nil } + return HashEntry(index: index, hash: basics.dHash) + } + progress(ScanProgress(phase: .comparing)) + let pairs = try await candidatePairs( + entries, + threshold: settings.sensitivity.hammingThreshold + ) + + var involved = Set() + for pair in pairs { + involved.insert(pair.a) + involved.insert(pair.b) + } + let involvedFiles = involved.sorted().map { files[$0] } + let printsByURL = try await loadFeaturePrints( + for: involvedFiles, cache: cache, progress: progress + ) + + // --- Stage 4: グループ化 --- + progress(ScanProgress(phase: .grouping)) + var unionFind = UnionFind(count: files.count) + for index in files.indices where leaderOf[index] != index { + unionFind.union(index, leaderOf[index]) + } + + let threshold = settings.sensitivity.featurePrintThreshold + for pair in pairs { + let printA = printsByURL[files[pair.a].url] + let printB = printsByURL[files[pair.b].url] + + if let distance = FeaturePrinter.distance(printA, printB) { + if distance < threshold { unionFind.union(pair.a, pair.b) } + } else if let basicsA = basicsByURL[files[pair.a].url], + let basicsB = basicsByURL[files[pair.b].url], + DHash.hammingDistance(basicsA.dHash, basicsB.dHash) <= fallbackHammingThreshold { + unionFind.union(pair.a, pair.b) + } + } + try Task.checkCancellation() + + let groups = buildGroups( + components: unionFind.components(), + files: files, + leaderOf: leaderOf, + basicsByURL: basicsByURL, + printsByURL: printsByURL + ) + + await cache.prune(keeping: files) + await cache.save() + return groups + } + + // MARK: - Stage 1 + + /// 各インデックスに「完全同一セットの代表インデックス」を割り当てる。単独なら自分自身。 + private static func exactLeaders( + files: [ScannedFile], + progress: @escaping @Sendable (ScanProgress) -> Void + ) async throws -> [Int] { + var leaderOf = Array(files.indices) + + // サイズが違えば中身も違う。同サイズのものだけハッシュすればよい。 + var bySize: [Int64: [Int]] = [:] + for (index, file) in files.enumerated() { + bySize[file.fileSize, default: []].append(index) + } + let candidates = bySize.values.filter { $0.count > 1 }.flatMap { $0 }.sorted() + guard !candidates.isEmpty else { return leaderOf } + + progress(ScanProgress(phase: .hashing, completed: 0, total: candidates.count)) + let hashes = try await mapConcurrently( + candidates, + onProgress: { done in + progress(ScanProgress(phase: .hashing, completed: done, total: candidates.count)) + } + ) { index -> String? in + try? ContentHasher.sha256(of: files[index].url) + } + + var byDigest: [String: [Int]] = [:] + for (index, hash) in zip(candidates, hashes) { + guard let hash else { continue } + byDigest["\(files[index].fileSize):\(hash)", default: []].append(index) + } + for indices in byDigest.values where indices.count > 1 { + guard let leader = indices.min(by: { files[$0].url.path < files[$1].url.path }) else { + continue + } + for index in indices { leaderOf[index] = leader } + } + return leaderOf + } + + // MARK: - Stage 2 / 3 + + private static func loadBasics( + for files: [ScannedFile], + cache: FingerprintCache, + progress: @escaping @Sendable (ScanProgress) -> Void + ) async throws -> [URL: ImageBasics] { + var result = await cache.basics(for: files) + let missing = files.filter { result[$0.url] == nil } + guard !missing.isEmpty else { return result } + + let cached = files.count - missing.count + progress(ScanProgress(phase: .fingerprinting, completed: cached, total: files.count)) + let computed = try await mapConcurrently( + missing, + onProgress: { done in + progress( + ScanProgress(phase: .fingerprinting, completed: cached + done, total: files.count) + ) + } + ) { file -> ImageBasics? in + DHash.compute(for: file.url) + } + + var fresh: [URL: ImageBasics] = [:] + for (file, basics) in zip(missing, computed) { + guard let basics else { continue } + fresh[file.url] = basics + result[file.url] = basics + } + await cache.store(basics: fresh, for: missing) + return result + } + + private static func loadFeaturePrints( + for files: [ScannedFile], + cache: FingerprintCache, + progress: @escaping @Sendable (ScanProgress) -> Void + ) async throws -> [URL: FeaturePrintObservation] { + guard !files.isEmpty else { return [:] } + var result = await cache.featurePrints(for: files) + let missing = files.filter { result[$0.url] == nil } + guard !missing.isEmpty else { return result } + + let cached = files.count - missing.count + progress(ScanProgress(phase: .comparing, completed: cached, total: files.count)) + let computed = try await mapConcurrently( + missing, + onProgress: { done in + progress( + ScanProgress(phase: .comparing, completed: cached + done, total: files.count) + ) + } + ) { file -> FeaturePrintObservation? in + try? await FeaturePrinter.compute(for: file.url) + } + + var fresh: [URL: FeaturePrintObservation] = [:] + for (file, print) in zip(missing, computed) { + guard let print else { continue } + fresh[file.url] = print + result[file.url] = print + } + await cache.store(featurePrints: fresh, for: missing) + return result + } + + /// dHash のハミング距離がしきい値以下のペアを総当たりで探す。 + /// + /// 64bit の XOR + popcount なので1ペアあたりの実費はごく小さい。LSH などで + /// 絞ると取りこぼしが出るため、区間をコアに分けて総当たりする方針にしている。 + private static func candidatePairs( + _ entries: [HashEntry], + threshold: Int + ) async throws -> [Pair] { + guard entries.count > 1 else { return [] } + + let cores = max(2, ProcessInfo.processInfo.activeProcessorCount) + let chunkSize = max(64, entries.count / (cores * 4)) + let starts = stride(from: 0, to: entries.count, by: chunkSize).map { $0 } + + let chunks = try await mapConcurrently(starts) { start -> [Pair] in + var found: [Pair] = [] + let end = min(start + chunkSize, entries.count) + for i in start.. [Int: String] { + var membersPerLeader: [Int: Int] = [:] + for index in ranked where leaderOf[index] != representativeLeader { + membersPerLeader[leaderOf[index], default: 0] += 1 + } + + let labels = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + var tags: [Int: String] = [:] + // ranked の順に印を振るので、同じ結果からは常に同じ印になる。 + for index in ranked { + let leader = leaderOf[index] + guard membersPerLeader[leader, default: 0] > 1, tags[leader] == nil else { continue } + tags[leader] = tags.count < labels.count + ? String(labels[tags.count]) + : "\(tags.count + 1)" + } + return tags + } + + private static func buildGroups( + components: [[Int]], + files: [ScannedFile], + leaderOf: [Int], + basicsByURL: [URL: ImageBasics], + printsByURL: [URL: FeaturePrintObservation] + ) -> [SimilarityGroup] { + func makeAsset(_ index: Int) -> PhotoAsset { + let file = files[index] + // 完全同一なら中身は同じなので、代表の解像度・撮影日をそのまま使える。 + let basics = basicsByURL[files[leaderOf[index]].url] + return PhotoAsset( + url: file.url, + fileSize: file.fileSize, + modificationDate: file.modificationDate, + captureDate: basics?.captureDate ?? file.creationDate, + pixelWidth: basics?.pixelWidth ?? 0, + pixelHeight: basics?.pixelHeight ?? 0 + ) + } + + var groups: [SimilarityGroup] = [] + for component in components where component.count > 1 { + let assets = Dictionary(uniqueKeysWithValues: component.map { ($0, makeAsset($0)) }) + + // 代表は「解像度が最大 → ファイルサイズが最大 → 撮影が最古」で選ぶ。 + let ranked = component.sorted { lhs, rhs in + guard let a = assets[lhs], let b = assets[rhs] else { return lhs < rhs } + if a.pixelCount != b.pixelCount { return a.pixelCount > b.pixelCount } + if a.fileSize != b.fileSize { return a.fileSize > b.fileSize } + let dateA = a.captureDate ?? .distantFuture + let dateB = b.captureDate ?? .distantFuture + if dateA != dateB { return dateA < dateB } + return a.url.path < b.url.path + } + guard let representativeIndex = ranked.first, + let representative = assets[representativeIndex] + else { continue } + let representativeLeader = leaderOf[representativeIndex] + let exactSetTags = exactSetTags( + in: ranked, leaderOf: leaderOf, excluding: representativeLeader + ) + + let members = component.compactMap { index -> SimilarityGroup.Member? in + guard let asset = assets[index] else { return nil } + let relation: SimilarityGroup.Relation + if leaderOf[index] == representativeLeader { + relation = .exact + } else { + relation = .similar( + distance: FeaturePrinter.distance( + printsByURL[files[leaderOf[index]].url], + printsByURL[files[representativeLeader].url] + ) + ) + } + return SimilarityGroup.Member( + asset: asset, + relation: relation, + exactSetTag: exactSetTags[leaderOf[index]] + ) + } + + let ordered = members.sorted { lhs, rhs in + if lhs.id == representative.url { return true } + if rhs.id == representative.url { return false } + if lhs.relation.sortKey != rhs.relation.sortKey { + return lhs.relation.sortKey < rhs.relation.sortKey + } + return lhs.asset.url.path < rhs.asset.url.path + } + + groups.append( + SimilarityGroup(members: ordered, representativeID: representative.url) + ) + } + + // 減らせる容量が大きいグループから見せる。 + return groups.sorted { lhs, rhs in + if lhs.reclaimableBytes != rhs.reclaimableBytes { + return lhs.reclaimableBytes > rhs.reclaimableBytes + } + return lhs.representativeID.path < rhs.representativeID.path + } + } +} diff --git a/Sources/Photochk/Core/UnionFind.swift b/Sources/Photochk/Core/UnionFind.swift new file mode 100644 index 0000000..b7b9812 --- /dev/null +++ b/Sources/Photochk/Core/UnionFind.swift @@ -0,0 +1,50 @@ +import Foundation + +/// 経路圧縮 + ランク併合の Union-Find。類似ペアから連結成分(グループ)を作るのに使う。 +struct UnionFind { + private var parent: [Int] + private var rank: [Int] + + init(count: Int) { + parent = Array(0.. Int { + var root = x + while parent[root] != root { root = parent[root] } + // 経路圧縮 + var node = x + while parent[node] != root { + let next = parent[node] + parent[node] = root + node = next + } + return root + } + + @discardableResult + mutating func union(_ a: Int, _ b: Int) -> Bool { + let ra = find(a) + let rb = find(b) + guard ra != rb else { return false } + if rank[ra] < rank[rb] { + parent[ra] = rb + } else if rank[ra] > rank[rb] { + parent[rb] = ra + } else { + parent[rb] = ra + rank[ra] += 1 + } + return true + } + + /// 連結成分を、根のインデックスをキーにしてまとめて返す。 + mutating func components() -> [[Int]] { + var buckets: [Int: [Int]] = [:] + for i in parent.indices { + buckets[find(i), default: []].append(i) + } + return Array(buckets.values) + } +} diff --git a/Sources/Photochk/Info.plist b/Sources/Photochk/Info.plist new file mode 100644 index 0000000..a7502ca --- /dev/null +++ b/Sources/Photochk/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + ja + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(MARKETING_VERSION) + CFBundleVersion + $(CURRENT_PROJECT_VERSION) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + + NSPrincipalClass + NSApplication + NSSupportsAutomaticTermination + + NSSupportsSuddenTermination + + + diff --git a/Sources/Photochk/Models/PhotoAsset.swift b/Sources/Photochk/Models/PhotoAsset.swift new file mode 100644 index 0000000..6780d98 --- /dev/null +++ b/Sources/Photochk/Models/PhotoAsset.swift @@ -0,0 +1,40 @@ +import Foundation + +/// スキャン対象の写真1枚。ファイル URL を同一性とする。 +struct PhotoAsset: Identifiable, Hashable, Sendable { + var id: URL { url } + + let url: URL + let fileSize: Int64 + let modificationDate: Date + + /// EXIF の撮影日時。無ければファイルの作成日。 + var captureDate: Date? + var pixelWidth: Int + var pixelHeight: Int + + var fileName: String { url.lastPathComponent } + + /// 画素数。代表選出の第一基準。 + var pixelCount: Int { pixelWidth * pixelHeight } + + var dimensionText: String { + pixelWidth > 0 && pixelHeight > 0 ? "\(pixelWidth) × \(pixelHeight)" : "—" + } + + var fileSizeText: String { + ByteCountFormatter.string(fromByteCount: fileSize, countStyle: .file) + } + + var captureDateText: String { + guard let captureDate else { return "—" } + return PhotoAsset.displayDateFormatter.string(from: captureDate) + } + + private static let displayDateFormatter: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() +} diff --git a/Sources/Photochk/Models/ScanSettings.swift b/Sources/Photochk/Models/ScanSettings.swift new file mode 100644 index 0000000..7d44db3 --- /dev/null +++ b/Sources/Photochk/Models/ScanSettings.swift @@ -0,0 +1,90 @@ +import Foundation + +/// 類似判定の厳しさ。UI の3段階と検出パラメータを結び付ける。 +enum Sensitivity: String, CaseIterable, Identifiable, Sendable { + case strict + case standard + case loose + + var id: String { rawValue } + + var label: String { + switch self { + case .strict: return "厳しい" + case .standard: return "標準" + case .loose: return "ゆるい" + } + } + + var help: String { + switch self { + case .strict: return "コピーやリサイズなど、ほぼ同じ写真だけを検出します" + case .standard: return "リサイズ・再圧縮された同じ写真と、連写の近いカットを検出します" + case .loose: return "トリミングされた版や、似た構図の別カットも検出します" + } + } + + /// Vision の feature print 距離のしきい値。これ未満なら「似ている」。 + /// + /// 実写画像で計測した距離の目安(`docs/calibration.md` 参照): + /// 再エンコード 0.00 / 50%縮小 0.05 / 低画質 0.12 / 25%縮小 0.16 / + /// 連写 0.01 / 同一シーンの別カット 0.02〜0.04 / トリミング 0.42〜0.48 / + /// 無関係な写真 1.38 以上。 + var featurePrintThreshold: Double { + switch self { + case .strict: return 0.20 // 25%まで縮小された版までを拾う + case .standard: return 0.30 // 縮小・再圧縮・連写まで。トリミングは拾わない + case .loose: return 0.55 // トリミング版まで拾う。無関係な写真とはなお十分離れている + } + } + + /// dHash のハミング距離のしきい値。ここを通ったペアだけ feature print で精査する。 + /// + /// 同じ計測でのハミング距離: 縮小・低画質 1〜5 / トリミング 9〜17 / 無関係 22〜27。 + var hammingThreshold: Int { + switch self { + case .strict: return 8 + case .standard: return 12 + case .loose: return 20 + } + } +} + +struct ScanSettings: Equatable, Sendable { + var includesSubfolders = true + var sensitivity: Sensitivity = .standard +} + +/// スキャンの進捗。UI にそのまま表示する。 +struct ScanProgress: Equatable, Sendable { + enum Phase: Equatable, Sendable { + case scanning + case hashing + case fingerprinting + case comparing + case grouping + + var label: String { + switch self { + case .scanning: return "フォルダを走査中" + case .hashing: return "重複を判定中" + case .fingerprinting: return "画像の特徴を計算中" + case .comparing: return "類似度を比較中" + case .grouping: return "グループにまとめ中" + } + } + } + + var phase: Phase + var completed: Int = 0 + var total: Int = 0 + + var fractionCompleted: Double? { + guard total > 0 else { return nil } + return min(1, Double(completed) / Double(total)) + } + + var detailText: String { + total > 0 ? "\(completed) / \(total)" : "" + } +} diff --git a/Sources/Photochk/Models/SimilarityGroup.swift b/Sources/Photochk/Models/SimilarityGroup.swift new file mode 100644 index 0000000..c1732da --- /dev/null +++ b/Sources/Photochk/Models/SimilarityGroup.swift @@ -0,0 +1,75 @@ +import Foundation + +/// 似ていると判定された写真のまとまり。必ず2枚以上を含む。 +struct SimilarityGroup: Identifiable, Hashable, Sendable { + /// 代表写真とメンバーの関係。 + enum Relation: Hashable, Sendable { + /// バイト単位で完全に同一。 + case exact + /// 見た目が似ている。距離は Vision の feature print 由来で 0 に近いほど似ている。 + /// 画像をデコードできず距離を出せなかった場合は nil。 + case similar(distance: Double?) + + var isExact: Bool { + if case .exact = self { return true } + return false + } + + /// 並べ替え用。完全同一を最優先、距離不明は最後に回す。 + var sortKey: Double { + switch self { + case .exact: return -1 + case .similar(let d): return d ?? .greatestFiniteMagnitude + } + } + + var distanceText: String { + switch self { + case .exact: return "完全同一" + case .similar(let d?): return String(format: "距離 %.3f", d) + case .similar(nil): return "類似" + } + } + } + + struct Member: Identifiable, Hashable, Sendable { + var id: URL { asset.id } + let asset: PhotoAsset + let relation: Relation + + /// 代表以外にもバイト単位で同じファイルがある場合の目印("A", "B" …)。 + /// 単独なら nil。同じ印どうしは中身が完全に同じなので、どれを消しても失われるものはない。 + var exactSetTag: String? + } + + /// 1回のスキャン結果の中で代表は一意なので、そのまま識別子に使う。 + var id: URL { representativeID } + + /// 代表を先頭とし、以降は代表に近い順。 + let members: [Member] + let representativeID: URL + + var representative: PhotoAsset { + members.first(where: { $0.id == representativeID })?.asset ?? members[0].asset + } + + /// 全メンバーが代表と完全同一なら true。混在していれば false。 + var isAllExact: Bool { + members.allSatisfy { $0.id == representativeID || $0.relation.isExact } + } + + var kindText: String { isAllExact ? "完全同一" : "類似" } + + var totalBytes: Int64 { members.reduce(0) { $0 + $1.asset.fileSize } } + + /// 代表1枚を残した場合に削減できるバイト数。 + var reclaimableBytes: Int64 { totalBytes - representative.fileSize } + + var totalBytesText: String { + ByteCountFormatter.string(fromByteCount: totalBytes, countStyle: .file) + } + + var reclaimableBytesText: String { + ByteCountFormatter.string(fromByteCount: reclaimableBytes, countStyle: .file) + } +} diff --git a/Sources/Photochk/Photochk.entitlements b/Sources/Photochk/Photochk.entitlements new file mode 100644 index 0000000..05cf7fc --- /dev/null +++ b/Sources/Photochk/Photochk.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-write + + com.apple.security.files.bookmarks.app-scope + + + diff --git a/Sources/Photochk/PhotochkApp.swift b/Sources/Photochk/PhotochkApp.swift new file mode 100644 index 0000000..4a162e6 --- /dev/null +++ b/Sources/Photochk/PhotochkApp.swift @@ -0,0 +1,25 @@ +import SwiftUI + +@main +struct PhotochkApp: App { + @State private var model = ScanViewModel() + + var body: some Scene { + WindowGroup { + ContentView(model: model) + .task { model.restorePreviousFolder() } + } + .defaultSize(width: 1120, height: 760) + .commands { + CommandGroup(replacing: .newItem) { + Button("フォルダを選ぶ…") { model.chooseFolder() } + .keyboardShortcut("o", modifiers: .command) + } + CommandGroup(after: .toolbar) { + Button("再スキャン") { model.rescan() } + .keyboardShortcut("r", modifiers: .command) + .disabled(model.folder == nil || model.isScanning) + } + } + } +} diff --git a/Sources/Photochk/Services/FolderAccess.swift b/Sources/Photochk/Services/FolderAccess.swift new file mode 100644 index 0000000..5faecfd --- /dev/null +++ b/Sources/Photochk/Services/FolderAccess.swift @@ -0,0 +1,73 @@ +import AppKit +import Foundation + +/// フォルダの選択と、サンドボックス下でのアクセス権の保持を担う。 +/// +/// `NSOpenPanel` で選んだフォルダは配下ごとアクセスが許可される。security-scoped bookmark を +/// 保存しておくと、次回起動時に同じフォルダをそのまま開き直せる。 +@MainActor +final class FolderAccess { + private static let bookmarkKey = "lastFolderBookmark" + + private var accessedURL: URL? + + /// フォルダ選択パネルを出す。キャンセルされたら nil。 + func choose(startingAt current: URL?) -> URL? { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = "このフォルダを調べる" + panel.message = "似た写真を探すフォルダを選んでください" + if let current { panel.directoryURL = current } + + guard panel.runModal() == .OK, let url = panel.url else { return nil } + beginAccess(to: url) + saveBookmark(for: url) + return url + } + + /// 前回のフォルダを復元する。無ければ nil。 + func restorePrevious() -> URL? { + guard let data = UserDefaults.standard.data(forKey: Self.bookmarkKey) else { return nil } + + var isStale = false + guard let url = try? URL( + resolvingBookmarkData: data, + options: .withSecurityScope, + relativeTo: nil, + bookmarkDataIsStale: &isStale + ) else { + UserDefaults.standard.removeObject(forKey: Self.bookmarkKey) + return nil + } + + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + beginAccess(to: url) + if isStale { saveBookmark(for: url) } + return url + } + + func releaseAccess() { + accessedURL?.stopAccessingSecurityScopedResource() + accessedURL = nil + } + + // MARK: - 内部 + + private func beginAccess(to url: URL) { + releaseAccess() + if url.startAccessingSecurityScopedResource() { + accessedURL = url + } + } + + private func saveBookmark(for url: URL) { + guard let data = try? url.bookmarkData( + options: .withSecurityScope, + includingResourceValuesForKeys: nil, + relativeTo: nil + ) else { return } + UserDefaults.standard.set(data, forKey: Self.bookmarkKey) + } +} diff --git a/Sources/Photochk/Services/ThumbnailLoader.swift b/Sources/Photochk/Services/ThumbnailLoader.swift new file mode 100644 index 0000000..829d6d9 --- /dev/null +++ b/Sources/Photochk/Services/ThumbnailLoader.swift @@ -0,0 +1,46 @@ +import AppKit +import Foundation +import QuickLookThumbnailing + +/// サムネイル生成。QuickLook 任せなので HEIC や RAW もそのまま扱える。 +@MainActor +final class ThumbnailLoader { + static let shared = ThumbnailLoader() + + private let cache = NSCache() + + private init() { + cache.countLimit = 600 + } + + /// すでに読み込み済みなら即座に返す。スクロールで戻ってきたときに + /// 一瞬プレースホルダが出るのを防ぐために使う。 + func cached(for url: URL, size: CGFloat) -> NSImage? { + cache.object(forKey: Self.key(url, size)) + } + + func thumbnail(for url: URL, size: CGFloat) async -> NSImage? { + let key = Self.key(url, size) + if let cached = cache.object(forKey: key) { return cached } + + let scale = NSScreen.main?.backingScaleFactor ?? 2 + let request = QLThumbnailGenerator.Request( + fileAt: url, + size: CGSize(width: size, height: size), + scale: scale, + representationTypes: .thumbnail + ) + + guard let representation = try? await QLThumbnailGenerator.shared + .generateBestRepresentation(for: request) + else { return nil } + + let image = NSImage(cgImage: representation.cgImage, size: representation.contentRect.size) + cache.setObject(image, forKey: key) + return image + } + + private static func key(_ url: URL, _ size: CGFloat) -> NSString { + "\(url.path)|\(Int(size))" as NSString + } +} diff --git a/Sources/Photochk/Services/TrashService.swift b/Sources/Photochk/Services/TrashService.swift new file mode 100644 index 0000000..0a8c757 --- /dev/null +++ b/Sources/Photochk/Services/TrashService.swift @@ -0,0 +1,30 @@ +import AppKit +import Foundation + +/// 選択されたファイルをゴミ箱へ送る。削除ではないので Finder から戻せる。 +enum TrashService { + struct Outcome: Sendable { + var trashed: [URL] = [] + var failed: [URL] = [] + var errorDescription: String? + } + + @MainActor + static func moveToTrash(_ urls: [URL]) async -> Outcome { + guard !urls.isEmpty else { return Outcome() } + + let (moved, error): ([URL: URL], Error?) = await withCheckedContinuation { continuation in + NSWorkspace.shared.recycle(urls) { newURLs, error in + continuation.resume(returning: (newURLs, error)) + } + } + + let trashed = urls.filter { moved[$0] != nil } + let failed = urls.filter { moved[$0] == nil } + return Outcome( + trashed: trashed, + failed: failed, + errorDescription: failed.isEmpty ? nil : error?.localizedDescription + ) + } +} diff --git a/Sources/Photochk/ViewModels/ScanViewModel.swift b/Sources/Photochk/ViewModels/ScanViewModel.swift new file mode 100644 index 0000000..ac158fc --- /dev/null +++ b/Sources/Photochk/ViewModels/ScanViewModel.swift @@ -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 = [] + var errorMessage: String? + + private var scanTask: Task? + 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 = [] + 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) { + 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 + } + } +} diff --git a/Sources/Photochk/Views/ContentView.swift b/Sources/Photochk/Views/ContentView.swift new file mode 100644 index 0000000..3bf9414 --- /dev/null +++ b/Sources/Photochk/Views/ContentView.swift @@ -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) + } + } +} diff --git a/Sources/Photochk/Views/GroupDetailView.swift b/Sources/Photochk/Views/GroupDetailView.swift new file mode 100644 index 0000000..8cc89ee --- /dev/null +++ b/Sources/Photochk/Views/GroupDetailView.swift @@ -0,0 +1,57 @@ +import SwiftUI + +struct GroupDetailView: View { + @Bindable var model: ScanViewModel + let group: SimilarityGroup + + private let columns = [GridItem(.adaptive(minimum: 168, maximum: 168), spacing: 18, alignment: .top)] + + var body: some View { + ScrollView { + LazyVGrid(columns: columns, alignment: .leading, spacing: 20) { + ForEach(group.members) { member in + PhotoTileView( + member: member, + isRepresentative: member.id == group.representativeID, + isMarked: model.markedForDeletion.contains(member.id), + canMark: model.canMark(member.id, in: group), + onToggle: { model.toggleMark(member.id, in: group) } + ) + } + } + .padding(20) + } + .safeAreaInset(edge: .top) { header } + .navigationTitle(group.representative.fileName) + .navigationSubtitle("\(group.members.count) 枚") + } + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(group.kindText) + .font(.caption.weight(.semibold)) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background( + group.isAllExact ? Color.orange.opacity(0.22) : Color.accentColor.opacity(0.18), + in: Capsule() + ) + Text("\(group.members.count) 枚・合計 \(group.totalBytesText)") + Text("1枚残すと \(group.reclaimableBytesText) 削減") + .foregroundStyle(.secondary) + } + .font(.callout) + + Text(group.representative.url.deletingLastPathComponent().path) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + .truncationMode(.head) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 10) + .background(.bar) + } +} diff --git a/Sources/Photochk/Views/GroupListView.swift b/Sources/Photochk/Views/GroupListView.swift new file mode 100644 index 0000000..5cb51aa --- /dev/null +++ b/Sources/Photochk/Views/GroupListView.swift @@ -0,0 +1,114 @@ +import SwiftUI + +struct GroupListView: View { + @Bindable var model: ScanViewModel + + var body: some View { + List(selection: $model.selectedGroupID) { + if !model.groups.isEmpty { + Section { + ForEach(model.groups) { group in + GroupRow(group: group, markedCount: markedCount(in: group)) + .tag(group.id) + } + } header: { + Text("\(model.groups.count) グループ・重複 \(model.totalDuplicateCount) 枚") + } + } + } + .listStyle(.sidebar) + .overlay { + if model.groups.isEmpty && !model.isScanning { + sidebarPlaceholder + } + } + .safeAreaInset(edge: .bottom) { + if !model.groups.isEmpty { + summaryBar + } + } + } + + private func markedCount(in group: SimilarityGroup) -> Int { + group.members.filter { model.markedForDeletion.contains($0.id) }.count + } + + private var sidebarPlaceholder: some View { + VStack(spacing: 6) { + if model.folder == nil { + Text("フォルダが未選択です") + } else if model.hasScanned { + Text("似た写真は見つかりませんでした") + } else { + Text("スキャンしていません") + } + } + .font(.callout) + .foregroundStyle(.secondary) + .padding() + } + + private var summaryBar: some View { + VStack(alignment: .leading, spacing: 2) { + Divider() + Text("最大 \(model.totalReclaimableText) 削減できます") + .font(.callout) + if model.markedCount > 0 { + Text("\(model.markedCount) 枚を選択中(\(model.markedBytesText))") + .font(.caption) + .foregroundStyle(.secondary) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 12) + .padding(.bottom, 8) + .background(.bar) + } +} + +struct GroupRow: View { + let group: SimilarityGroup + let markedCount: Int + + var body: some View { + HStack(spacing: 10) { + ThumbnailView(url: group.representativeID, size: 52) + + VStack(alignment: .leading, spacing: 3) { + Text(group.representative.fileName) + .lineLimit(1) + .truncationMode(.middle) + + HStack(spacing: 6) { + Text(group.kindText) + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + group.isAllExact ? Color.orange.opacity(0.22) : Color.accentColor.opacity(0.18), + in: Capsule() + ) + Text("\(group.members.count) 枚") + .font(.caption) + .foregroundStyle(.secondary) + } + + Text("\(group.reclaimableBytesText) 削減可") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + + if markedCount > 0 { + Text("\(markedCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Color.red, in: Capsule()) + } + } + .padding(.vertical, 3) + } +} diff --git a/Sources/Photochk/Views/PhotoTileView.swift b/Sources/Photochk/Views/PhotoTileView.swift new file mode 100644 index 0000000..c854831 --- /dev/null +++ b/Sources/Photochk/Views/PhotoTileView.swift @@ -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) + } +} diff --git a/Sources/Photochk/Views/ScanProgressView.swift b/Sources/Photochk/Views/ScanProgressView.swift new file mode 100644 index 0000000..f6661a2 --- /dev/null +++ b/Sources/Photochk/Views/ScanProgressView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct ScanProgressView: View { + let progress: ScanProgress + let onCancel: () -> Void + + var body: some View { + VStack(spacing: 14) { + if let fraction = progress.fractionCompleted { + ProgressView(value: fraction) { + Text(progress.phase.label) + } currentValueLabel: { + Text(progress.detailText) + .monospacedDigit() + } + } else { + ProgressView { + Text(progress.phase.label) + } + } + + Button("中止", role: .cancel, action: onCancel) + } + .frame(width: 300) + .padding(24) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(radius: 20) + } +} diff --git a/Sources/Photochk/Views/ThumbnailView.swift b/Sources/Photochk/Views/ThumbnailView.swift new file mode 100644 index 0000000..ff78fac --- /dev/null +++ b/Sources/Photochk/Views/ThumbnailView.swift @@ -0,0 +1,35 @@ +import SwiftUI + +/// QuickLook のサムネイルを非同期で読み込んで表示する。 +struct ThumbnailView: View { + let url: URL + let size: CGFloat + + @State private var image: NSImage? + + /// 読み込み済みならキャッシュから直接描く。プレースホルダの一瞬のちらつきを防ぐ。 + private var displayImage: NSImage? { + image ?? ThumbnailLoader.shared.cached(for: url, size: size) + } + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(.quaternary) + + if let image = displayImage { + Image(nsImage: image) + .resizable() + .aspectRatio(contentMode: .fit) + } else { + ProgressView() + .controlSize(.small) + } + } + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .task(id: url) { + image = await ThumbnailLoader.shared.thumbnail(for: url, size: size) + } + } +} diff --git a/Tests/PhotochkTests/DHashTests.swift b/Tests/PhotochkTests/DHashTests.swift new file mode 100644 index 0000000..006fb98 --- /dev/null +++ b/Tests/PhotochkTests/DHashTests.swift @@ -0,0 +1,58 @@ +import Foundation +import Testing + +@testable import Photochk + +@Suite("dHash") +struct DHashTests { + @Test("同じ値のハミング距離は 0") + func identical() { + #expect(DHash.hammingDistance(0xDEAD_BEEF_1234_5678, 0xDEAD_BEEF_1234_5678) == 0) + } + + @Test("違うビットの数がそのまま距離になる") + func differingBits() { + #expect(DHash.hammingDistance(0, 0b1011) == 3) + #expect(DHash.hammingDistance(.max, 0) == 64) + #expect(DHash.hammingDistance(0b1100, 0b0110) == 2) + } + + @Test("生成した画像から 64bit のハッシュが取れる") + func computesFromImage() throws { + let directory = try TestImages.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let url = directory.appendingPathComponent("base.jpg") + try TestImages.writeJPEG(TestImages.pattern(seed: 1), to: url, quality: 0.9) + + let basics = try #require(DHash.compute(for: url)) + #expect(basics.pixelWidth == TestImages.width) + #expect(basics.pixelHeight == TestImages.height) + + // 同じファイルからは必ず同じ値が出る + let again = try #require(DHash.compute(for: url)) + #expect(basics.dHash == again.dHash) + } + + @Test("リサイズしてもハッシュはほとんど変わらず、別の絵柄とは大きく離れる") + func robustToResize() throws { + let directory = try TestImages.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let base = directory.appendingPathComponent("base.jpg") + let small = directory.appendingPathComponent("small.jpg") + let other = directory.appendingPathComponent("other.jpg") + + let pattern = TestImages.pattern(seed: 7) + try TestImages.writeJPEG(pattern, to: base, quality: 0.9) + try TestImages.writeJPEG(TestImages.scaled(pattern, factor: 0.5), to: small, quality: 0.8) + try TestImages.writeJPEG(TestImages.pattern(seed: 99), to: other, quality: 0.9) + + let baseHash = try #require(DHash.compute(for: base)).dHash + let smallHash = try #require(DHash.compute(for: small)).dHash + let otherHash = try #require(DHash.compute(for: other)).dHash + + #expect(DHash.hammingDistance(baseHash, smallHash) <= 12) + #expect(DHash.hammingDistance(baseHash, otherHash) > 12) + } +} diff --git a/Tests/PhotochkTests/FingerprintCacheTests.swift b/Tests/PhotochkTests/FingerprintCacheTests.swift new file mode 100644 index 0000000..b86ac33 --- /dev/null +++ b/Tests/PhotochkTests/FingerprintCacheTests.swift @@ -0,0 +1,81 @@ +import Foundation +import Testing +import Vision + +@testable import Photochk + +@Suite("キャッシュ") +struct FingerprintCacheTests { + private func makeFile(at directory: URL) throws -> ScannedFile { + let url = directory.appendingPathComponent("cached.jpg") + try TestImages.writeJPEG(TestImages.pattern(seed: 3), to: url, quality: 0.9) + + let values = try url.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]) + return ScannedFile( + url: url, + fileSize: Int64(values.fileSize ?? 0), + modificationDate: values.contentModificationDate ?? Date(), + creationDate: nil + ) + } + + @Test("dHash と feature print がディスクを経由して復元できる") + func roundTrip() async throws { + let directory = try TestImages.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let file = try makeFile(at: directory) + let cacheURL = directory.appendingPathComponent("cache.plist") + + let basics = try #require(DHash.compute(for: file.url)) + let print = try await FeaturePrinter.compute(for: file.url) + + let writer = FingerprintCache(fileURL: cacheURL) + await writer.store(basics: [file.url: basics], for: [file]) + await writer.store(featurePrints: [file.url: print], for: [file]) + await writer.save() + + let reader = FingerprintCache(fileURL: cacheURL) + let restoredBasics = await reader.basics(for: [file]) + let restoredPrints = await reader.featurePrints(for: [file]) + + #expect(restoredBasics[file.url]?.dHash == basics.dHash) + let restoredPrint = try #require(restoredPrints[file.url]) + // 同一の特徴量なので距離は 0 + #expect(try restoredPrint.distance(to: print) == 0) + } + + @Test("ファイルが更新されていたらキャッシュは使われない") + func invalidatesOnChange() async throws { + let directory = try TestImages.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let file = try makeFile(at: directory) + let cache = FingerprintCache(fileURL: nil) + let basics = try #require(DHash.compute(for: file.url)) + await cache.store(basics: [file.url: basics], for: [file]) + + let changed = ScannedFile( + url: file.url, + fileSize: file.fileSize + 100, + modificationDate: file.modificationDate, + creationDate: nil + ) + #expect(await cache.basics(for: [changed]).isEmpty) + #expect(await cache.basics(for: [file]).count == 1) + } + + @Test("消えたファイルのエントリは prune で捨てられる") + func prunesMissingFiles() async throws { + let directory = try TestImages.makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + + let file = try makeFile(at: directory) + let cache = FingerprintCache(fileURL: nil) + let basics = try #require(DHash.compute(for: file.url)) + await cache.store(basics: [file.url: basics], for: [file]) + + await cache.prune(keeping: []) + #expect(await cache.basics(for: [file]).isEmpty) + } +} diff --git a/Tests/PhotochkTests/GroupingTests.swift b/Tests/PhotochkTests/GroupingTests.swift new file mode 100644 index 0000000..66d93cb --- /dev/null +++ b/Tests/PhotochkTests/GroupingTests.swift @@ -0,0 +1,166 @@ +import Foundation +import Testing + +@testable import Photochk + +/// 走査からグループ生成まで、実際に画像ファイルを作って一気通貫で確かめる。 +@Suite("グループ生成") +struct GroupingTests { + /// 同じ絵の派生(完全コピー・縮小・低画質)と、無関係な絵を1つ置いたフォルダを作る。 + private func makeFixture() throws -> URL { + let directory = try TestImages.makeTemporaryDirectory() + let subdirectory = directory.appendingPathComponent("sub", isDirectory: true) + try FileManager.default.createDirectory(at: subdirectory, withIntermediateDirectories: true) + + let pattern = TestImages.pattern(seed: 42) + let base = directory.appendingPathComponent("base.jpg") + try TestImages.writeJPEG(pattern, to: base, quality: 0.95) + + // バイト単位で同じコピー + try FileManager.default.copyItem( + at: base, to: directory.appendingPathComponent("base_copy.jpg") + ) + // 縮小版と、そのバイト単位のコピー(代表以外にできる完全同一の組) + let small = directory.appendingPathComponent("base_small.jpg") + try TestImages.writeJPEG( + TestImages.scaled(pattern, factor: 0.5), to: small, quality: 0.9 + ) + try FileManager.default.copyItem( + at: small, to: directory.appendingPathComponent("base_small_copy.jpg") + ) + // 低画質版 + try TestImages.writeJPEG( + pattern, to: directory.appendingPathComponent("base_low.jpg"), quality: 0.3 + ) + // サブフォルダにもう1つ派生を置く + try TestImages.writeJPEG( + TestImages.scaled(pattern, factor: 0.75), + to: subdirectory.appendingPathComponent("base_sub.jpg"), + quality: 0.85 + ) + // 無関係な絵 + try TestImages.writeJPEG( + TestImages.pattern(seed: 9_999), + to: directory.appendingPathComponent("other.jpg"), + quality: 0.95 + ) + + return directory + } + + @Test("派生画像が1グループにまとまり、無関係な画像は入らない") + func groupsDerivatives() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + + #expect(groups.count == 1) + let group = try #require(groups.first) + + let names = Set(group.members.map(\.asset.fileName)) + #expect( + names == [ + "base.jpg", "base_copy.jpg", "base_small.jpg", "base_small_copy.jpg", + "base_low.jpg", "base_sub.jpg", + ] + ) + #expect(!names.contains("other.jpg")) + } + + @Test("代表以外にできた完全同一の組には同じ印が付く") + func tagsExactSetsAwayFromRepresentative() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + let tags = Dictionary( + uniqueKeysWithValues: group.members.map { ($0.asset.fileName, $0.exactSetTag) } + ) + + // 縮小版の2枚は互いにバイト単位で同じなので、同じ印が付く + let smallTag = try #require(tags["base_small.jpg"] ?? nil) + #expect(tags["base_small_copy.jpg"] == smallTag) + + // 代表と完全同一のものは relation が exact になるので印は要らない + #expect(tags["base_copy.jpg"] == .some(nil)) + #expect(tags["base_low.jpg"] == .some(nil)) + } + + @Test("完全同一のコピーは exact、加工された派生は similar になる") + func relationKinds() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + + // 代表は元画像か、それとバイト単位で同じコピー + #expect(["base.jpg", "base_copy.jpg"].contains(group.representative.fileName)) + #expect(group.representative.pixelWidth == TestImages.width) + + let relations = Dictionary( + uniqueKeysWithValues: group.members.map { ($0.asset.fileName, $0.relation) } + ) + #expect(relations["base.jpg"]?.isExact == true) + #expect(relations["base_copy.jpg"]?.isExact == true) + #expect(relations["base_small.jpg"]?.isExact == false) + #expect(relations["base_low.jpg"]?.isExact == false) + + // 加工された派生には feature print 由来の距離が入っている + if case .similar(let distance) = relations["base_small.jpg"] { + let value = try #require(distance) + #expect(value < Sensitivity.standard.featurePrintThreshold) + } else { + Issue.record("base_small.jpg は similar であるべき") + } + } + + @Test("サブフォルダを含めないとサブフォルダの画像は対象外になる") + func respectsRecursionSetting() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: false, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + #expect(!group.members.contains { $0.asset.fileName == "base_sub.jpg" }) + #expect(group.members.count == 5) + } + + @Test("削減できるバイト数は代表1枚を残した残り") + func reclaimableBytes() async throws { + let directory = try makeFixture() + defer { try? FileManager.default.removeItem(at: directory) } + + let groups = try await SimilarityGrouper.run( + root: directory, + settings: ScanSettings(includesSubfolders: true, sensitivity: .standard), + cache: FingerprintCache(fileURL: nil), + progress: { _ in } + ) + let group = try #require(groups.first) + #expect(group.reclaimableBytes == group.totalBytes - group.representative.fileSize) + #expect(group.reclaimableBytes > 0) + } +} diff --git a/Tests/PhotochkTests/SelectionTests.swift b/Tests/PhotochkTests/SelectionTests.swift new file mode 100644 index 0000000..b4a4fe2 --- /dev/null +++ b/Tests/PhotochkTests/SelectionTests.swift @@ -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) + } +} diff --git a/Tests/PhotochkTests/SnapshotTests.swift b/Tests/PhotochkTests/SnapshotTests.swift new file mode 100644 index 0000000..2e15751 --- /dev/null +++ b/Tests/PhotochkTests/SnapshotTests.swift @@ -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)") + } +} diff --git a/Tests/PhotochkTests/TestImages.swift b/Tests/PhotochkTests/TestImages.swift new file mode 100644 index 0000000..6a13e63 --- /dev/null +++ b/Tests/PhotochkTests/TestImages.swift @@ -0,0 +1,129 @@ +import CoreGraphics +import Foundation +import ImageIO +import UniformTypeIdentifiers + +/// テスト用の画像を決まった内容で作る。乱数は自前の線形合同法なので毎回同じ絵になる。 +enum TestImages { + static let width = 640 + static let height = 480 + + struct Generator { + private var state: UInt64 + + init(seed: UInt64) { state = seed &* 2_862_933_555_777_941_757 &+ 3_037_000_493 } + + mutating func next() -> UInt64 { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return state + } + + mutating func unit() -> Double { + Double(next() >> 11) / Double(1 << 53) + } + } + + /// 色のついたブロックと円を敷き詰めた、構造のある絵を作る。 + static func pattern(seed: UInt64) -> CGImage { + var generator = Generator(seed: seed) + + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + )! + + let columns = 8 + let rows = 6 + let cellWidth = CGFloat(width) / CGFloat(columns) + let cellHeight = CGFloat(height) / CGFloat(rows) + + for row in 0.. CGImage { + let newWidth = max(1, Int(Double(image.width) * factor)) + let newHeight = max(1, Int(Double(image.height) * factor)) + + let context = CGContext( + data: nil, + width: newWidth, + height: newHeight, + bitsPerComponent: 8, + bytesPerRow: 0, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue + )! + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: newWidth, height: newHeight)) + return context.makeImage()! + } + + static func writeJPEG(_ image: CGImage, to url: URL, quality: Double) throws { + guard let destination = CGImageDestinationCreateWithURL( + url as CFURL, UTType.jpeg.identifier as CFString, 1, nil + ) else { + throw TestImageError.cannotWrite(url) + } + CGImageDestinationAddImage( + destination, + image, + [kCGImageDestinationLossyCompressionQuality: quality] as CFDictionary + ) + guard CGImageDestinationFinalize(destination) else { + throw TestImageError.cannotWrite(url) + } + } + + static func makeTemporaryDirectory() throws -> URL { + let url = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("PhotochkTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } +} + +enum TestImageError: Error { + case cannotWrite(URL) +} diff --git a/Tests/PhotochkTests/UnionFindTests.swift b/Tests/PhotochkTests/UnionFindTests.swift new file mode 100644 index 0000000..9cb96be --- /dev/null +++ b/Tests/PhotochkTests/UnionFindTests.swift @@ -0,0 +1,33 @@ +import Testing + +@testable import Photochk + +@Suite("UnionFind") +struct UnionFindTests { + @Test("結合していない要素はそれぞれ単独の成分になる") + func isolatedElements() { + var uf = UnionFind(count: 4) + let components = uf.components().map { $0.sorted() }.sorted { $0[0] < $1[0] } + #expect(components == [[0], [1], [2], [3]]) + } + + @Test("推移的に結合した要素は1つの成分にまとまる") + func transitiveMerge() { + var uf = UnionFind(count: 5) + uf.union(0, 1) + uf.union(1, 2) + uf.union(3, 4) + + let components = uf.components().map { $0.sorted() }.sorted { $0[0] < $1[0] } + #expect(components == [[0, 1, 2], [3, 4]]) + } + + @Test("同じ成分をもう一度 union しても何も変わらない") + func repeatedUnion() { + var uf = UnionFind(count: 3) + #expect(uf.union(0, 1) == true) + #expect(uf.union(1, 0) == false) + #expect(uf.find(0) == uf.find(1)) + #expect(uf.find(0) != uf.find(2)) + } +} diff --git a/docs/calibration.md b/docs/calibration.md new file mode 100644 index 0000000..35d8111 --- /dev/null +++ b/docs/calibration.md @@ -0,0 +1,72 @@ +# しきい値の実測記録 + +`Sensitivity` のしきい値は当てずっぽうではなく、実写画像で距離を測って決めている。 +判定を変えたくなったらこの手順をなぞって測り直すこと。 + +## 測定に使った素材 + +macOS 内蔵の動画壁紙(実写のタイムラプス)からフレームを切り出したものを元画像とした。 + +| ファイル | 内容 | +|---|---| +| `shotA.jpg` | 元画像(1600×900, JPEG) | +| `A_copy.jpg` | `cp` によるバイト単位の完全コピー | +| `A_half.jpg` | 50% に縮小 | +| `A_quarter.jpg` | 25% に縮小 | +| `A_lowq.jpg` | 同じ寸法で JPEG 品質 25 に再圧縮 | +| `A_crop.jpg` | 中央 1400×900 にトリミング | +| `A_rot90.jpg` | ピクセルごと 90 度回転 | +| `shotA_burst.jpg` | 0.5 秒後のフレーム(連写相当) | +| `shotB.jpg` / `shotC.jpg` | 30 秒後 / 90 秒後のフレーム(同一シーンの別カット) | +| `sonoma.jpg` | まったく別の写真 | + +## 測定結果(Vision の feature print 距離 / dHash のハミング距離) + +`shotA.jpg` を基準にした値。 + +| 相手 | feature print | ハミング | 判定したい? | +|---|---|---|---| +| 完全コピー・再エンコード | 0.000 | 0 | ○(SHA-256 で先に確定) | +| 50% 縮小 | 0.049 | 5 | ○ | +| 低画質 再圧縮 | 0.116 | 1 | ○ | +| 25% 縮小 | 0.162 | 4 | ○ | +| 連写(0.5 秒差) | 0.008 | 0 | ○ | +| 同一シーン 30 秒差 | 0.017 | 6 | ○ | +| 同一シーン 90 秒差 | 0.039 | 14 | ○ | +| トリミング | 0.417 | 11 | 「ゆるい」でのみ | +| 90 度回転 | 0.802 | 28 | ✗(後述) | +| 無関係な写真 | 1.378 | 24 | ✗ | + +## 決めたしきい値 + +| 段階 | feature print | ハミング | 何が拾えるか | +|---|---|---|---| +| 厳しい | 0.20 | 8 | コピー・縮小(25% まで)・再圧縮 | +| 標準 | 0.30 | 12 | 上記+連写・同一シーンの近いカット | +| ゆるい | 0.55 | 20 | 上記+トリミングされた版 | + +「ゆるい」の 0.55 でも無関係な写真の 1.38 とは大きく離れているので、誤検出の余地は小さい。 +当初案の「厳しい = 0.15」では 25% 縮小(0.162)を取りこぼしたため 0.20 に上げた。 + +## 回転の扱い(意図的な仕様) + +dHash も Vision も EXIF の向きを反映してから比較している。つまり **「画面に表示したときの見た目」どうしを比べている**。 + +- 回転がピクセルに焼き込まれた版と、EXIF の向きタグで回している版 → **距離 0.002 で検出できる** + (LINE や AirDrop を経由して向きが焼き込まれた、という現実的なケース) +- 実際に向きが違う写真どうし(元画像 vs 90 度回転) → **距離 0.80 で検出しない** + +後者を拾うには4方向ぶんの特徴量を計算する必要があり、コストが4倍になる割に +写真整理の用途では出番が少ないため、対応していない。 + +## 測り直す手順 + +```bash +ffmpeg -ss 0 -i "/System/Library/Desktop Pictures/.wallpapers/Tahoe Day/Tahoe Day.mov" -frames:v 1 -vf scale=1600:-1 shotA.jpg +sips -Z 800 shotA.jpg --out A_half.jpg +sips -s format jpeg -s formatOptions 25 shotA.jpg --out A_lowq.jpg +sips -c 900 1400 shotA.jpg --out A_crop.jpg +``` + +これらを1つのフォルダに置き、`GenerateImageFeaturePrintRequest`(`cropAndScaleAction = .scaleToFit`)で +総当たりの距離行列を出す。アプリ本体の `FeaturePrinter.compute(for:)` と同じ設定であることを確認すること。 diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..53846b9 --- /dev/null +++ b/project.yml @@ -0,0 +1,61 @@ +name: Photochk + +options: + bundleIdPrefix: com.fujisaki + deploymentTarget: + macOS: "15.0" + createIntermediateGroups: true + +settings: + base: + SWIFT_VERSION: "6.0" + SWIFT_STRICT_CONCURRENCY: complete + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + CODE_SIGN_STYLE: Manual + CODE_SIGN_IDENTITY: "-" + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: NO + +targets: + Photochk: + type: application + platform: macOS + sources: + - path: Sources/Photochk + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.fujisaki.Photochk + PRODUCT_NAME: Photochk + INFOPLIST_FILE: Sources/Photochk/Info.plist + CODE_SIGN_ENTITLEMENTS: Sources/Photochk/Photochk.entitlements + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + COMBINE_HIDPI_IMAGES: YES + configs: + Release: + # デバッガ接続を許す get-task-allow が Release に混ざらないようにする + CODE_SIGN_INJECT_BASE_ENTITLEMENTS: NO + + PhotochkTests: + type: bundle.unit-test + platform: macOS + sources: + - path: Tests/PhotochkTests + dependencies: + - target: Photochk + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.fujisaki.PhotochkTests + GENERATE_INFOPLIST_FILE: YES + +schemes: + Photochk: + build: + targets: + Photochk: all + run: + config: Debug + test: + config: Debug + targets: + - PhotochkTests diff --git a/tools/_release_api.py b/tools/_release_api.py new file mode 100644 index 0000000..3bd7a84 --- /dev/null +++ b/tools/_release_api.py @@ -0,0 +1,89 @@ +# Gitea リリース更新の API 部分(release-update.sh から呼ばれる)。 +# 1) タグ名からリリースを特定 +# 2) 同名の旧添付資産を削除し、最新の zip をアップロード +# 3) 本文の「ビルド元コミット: `xxxx`」を現在の短縮ハッシュへ置換 +# 認証は環境変数 GITEA_USER/GITEA_PASS(Basic)。Gitea が自己署名証明書のため TLS 検証は無効化。 +# 引数: +import os +import re +import ssl +import sys +import json +import base64 +from urllib.request import Request, urlopen +from urllib.error import HTTPError + +host, owner, repo, tag, asset, commit = sys.argv[1:7] +user = os.environ["GITEA_USER"] +pw = os.environ["GITEA_PASS"] + +base = f"https://{host}/api/v1/repos/{owner}/{repo}" +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE +auth = "Basic " + base64.b64encode(f"{user}:{pw}".encode()).decode() + + +def api(path, method="GET", data=None, headers=None): + h = {"Authorization": auth} + if headers: + h.update(headers) + req = Request(base + path, data=data, method=method, headers=h) + try: + r = urlopen(req, context=ctx) + except HTTPError as e: + msg = e.read().decode("utf-8", "replace") + sys.exit(f"ERROR: {method} {path} -> HTTP {e.code}: {msg}") + body = r.read() + return json.loads(body) if body else None + + +# 1) タグ名でリリースを特定(無ければ 404 で停止) +rel = api(f"/releases/tags/{tag}") +rid = rel["id"] +print(f"release id={rid} (tag {tag})") + +# 2) 同名の旧資産を削除 +name = os.path.basename(asset) +for a in api(f"/releases/{rid}/assets") or []: + if a["name"] == name: + api(f"/releases/{rid}/assets/{a['id']}", method="DELETE") + print(f"deleted old asset id={a['id']} ({a['size']} bytes)") + +# 3) 最新の zip をアップロード(multipart/form-data, field=attachment) +with open(asset, "rb") as f: + filedata = f.read() +boundary = "----photochkReleaseBoundary7e1f" +mp = b"".join([ + f"--{boundary}\r\n".encode(), + f'Content-Disposition: form-data; name="attachment"; filename="{name}"\r\n'.encode(), + b"Content-Type: application/octet-stream\r\n\r\n", + filedata, + b"\r\n", + f"--{boundary}--\r\n".encode(), +]) +new = api( + f"/releases/{rid}/assets?name={name}", + method="POST", + data=mp, + headers={"Content-Type": f"multipart/form-data; boundary={boundary}"}, +) +print(f"uploaded asset id={new['id']} ({new['size']} bytes)") + +# 4) 本文の「ビルド元コミット: `xxxx`」を現在の短縮ハッシュへ置換 +body = rel.get("body") or "" +new_body, n = re.subn( + r"(ビルド元コミット: `)[0-9a-fA-F]{7,40}(`)", + r"\g<1>" + commit + r"\g<2>", + body, +) +if n == 0: + # まだ行が無いリリースには追記する。次回以降は置換で更新される。 + new_body = body.rstrip() + f"\n\nビルド元コミット: `{commit}`\n" +api( + f"/releases/{rid}", + method="PATCH", + data=json.dumps({"body": new_body}).encode("utf-8"), + headers={"Content-Type": "application/json"}, +) +print(f"body {'updated' if n else 'appended'}: ビルド元コミット -> {commit}") diff --git a/tools/release-update.sh b/tools/release-update.sh new file mode 100644 index 0000000..d42266d --- /dev/null +++ b/tools/release-update.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# Gitea リリース更新 —— 現在の HEAD と最新ビルドでリリースを差し替える。 +# yfoxlogsrv/tools/release-update.sh と同じ流儀。手動実行専用。 +# +# やること: +# 1) タグ (既定 v1.0.0)を HEAD へ強制移動して push。 +# 2) リリースの添付 zip を最新ビルドへ差し替え(同名の旧資産は削除→アップロード)。 +# 3) リリース本文の「ビルド元コミット: `xxxxxxx`」を現在の短縮ハッシュへ更新。 +# +# 前提: +# * zip は事前にビルド済み(make release)。本スクリプトはビルドしない。 +# * リリース は Gitea に既存であること(無ければエラー終了)。最初の1本は web か +# gitea MCP の create_release で作る。以後の更新を本スクリプトが担う。 +# * 認証は git credential(host=git.yfox.jp)。Gitea が自己署名証明書のため TLS 検証は無効化。 +# * 日本語を含む本文の JSON 化のため Python を使う。 +# +# 使い方: +# ./tools/release-update.sh # タグ v1.0.0 を更新 +# ./tools/release-update.sh v1.0.1 # 別タグを指定 +set -eu + +HOST="git.yfox.jp" +OWNER="yoshikazu" +NAME="photochk" +TAG="${1:-v1.0.0}" +ASSET_REL="dist/Photochk-${TAG#v}.zip" +PY="/usr/bin/python3" + +REPO=$(git rev-parse --show-toplevel) +cd "$REPO" +ASSET="$REPO/$ASSET_REL" + +[ -f "$ASSET" ] || { echo "ERROR: zip が無い: $ASSET (先に make release が必要)" >&2; exit 1; } +[ -x "$PY" ] || { echo "ERROR: Python が無い: $PY" >&2; exit 1; } + +HASH=$(git rev-parse --short HEAD) +FULL=$(git rev-parse HEAD) +echo "repo: $REPO" +echo "HEAD: $HASH tag: $TAG" +echo "asset: $ASSET_REL ($(stat -f %z "$ASSET") bytes)" + +# 0) 先に認証情報を取り出す。ここで落ちるなら push もアップロードもできないので、 +# タグを動かす前に止めたほうが状態がきれいなまま済む。 +# 認証情報は argv に載せず環境変数で渡す(プロセス一覧への漏洩回避)。 +# +# プロトコルは origin の URL から決める。http と https で別のエントリが登録されて +# いることがあり、決め打ちすると push は通るのに API だけ 401 になる。 +case "$(git remote get-url origin 2>/dev/null)" in + https://*) PROTO="https" ;; + http://*) PROTO="http" ;; + *) PROTO="https" ;; +esac +creds=$(printf 'protocol=%s\nhost=%s\n\n' "$PROTO" "$HOST" | git credential fill 2>/dev/null || true) +GITEA_USER=$(printf '%s\n' "$creds" | sed -n 's/^username=//p') +GITEA_PASS=$(printf '%s\n' "$creds" | sed -n 's/^password=//p') +[ -n "$GITEA_USER" ] && [ -n "$GITEA_PASS" ] || { + cat >&2 <\\npassword=<トークン>\\n\\n' | git credential approve + + osxkeychain ヘルパが未設定なら先に: git config --global credential.helper osxkeychain +EOF + exit 1; } +export GITEA_USER GITEA_PASS + +# 1) タグを HEAD へ強制移動して push(annotated)。 +git tag -f -a "$TAG" "$FULL" -m "Photochk ${TAG#v}" +git push --force origin "$TAG" +echo "tag $TAG -> $HASH を push しました" + +# 2)+3) 資産差し替えと本文更新を Python に委譲。 +"$PY" "$REPO/tools/_release_api.py" "$HOST" "$OWNER" "$NAME" "$TAG" "$ASSET" "$HASH" + +echo "完了: https://$HOST/$OWNER/$NAME/releases/tag/$TAG"