フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す1枚を選んでほかをゴミ箱へ送れるようにする。 検出は安い判定から順に4段: - ファイルサイズが同じものだけ SHA-256 でバイト一致を確定 - dHash(知覚ハッシュ)のハミング距離で候補ペアを抽出 - 候補に出てくる画像だけ Vision の feature print で精査 - Union-Find で連結成分をグループ化 判定のしきい値は実写画像で距離を実測して決めた(docs/calibration.md)。 tools/release-update.sh で Gitea のリリースを更新する。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
75 lines
2.4 KiB
Swift
75 lines
2.4 KiB
Swift
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)」の中身を読み取れませんでした。"
|
|
}
|
|
}
|
|
}
|