Photochk 1.0.0 — 類似写真を探して整理する macOS アプリ
フォルダ内の写真から、中身が同じものと見た目が似ているものを検出して グループにまとめ、残す1枚を選んでほかをゴミ箱へ送れるようにする。 検出は安い判定から順に4段: - ファイルサイズが同じものだけ SHA-256 でバイト一致を確定 - dHash(知覚ハッシュ)のハミング距離で候補ペアを抽出 - 候補に出てくる画像だけ Vision の feature print で精査 - Union-Find で連結成分をグループ化 判定のしきい値は実写画像で距離を実測して決めた(docs/calibration.md)。 tools/release-update.sh で Gitea のリリースを更新する。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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..<rows {
|
||||
for column in 0..<columns {
|
||||
context.setFillColor(
|
||||
red: generator.unit(),
|
||||
green: generator.unit(),
|
||||
blue: generator.unit(),
|
||||
alpha: 1
|
||||
)
|
||||
context.fill(
|
||||
CGRect(
|
||||
x: CGFloat(column) * cellWidth,
|
||||
y: CGFloat(row) * cellHeight,
|
||||
width: cellWidth,
|
||||
height: cellHeight
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..<12 {
|
||||
context.setFillColor(
|
||||
red: generator.unit(),
|
||||
green: generator.unit(),
|
||||
blue: generator.unit(),
|
||||
alpha: 0.85
|
||||
)
|
||||
let radius = 20 + generator.unit() * 60
|
||||
context.fillEllipse(
|
||||
in: CGRect(
|
||||
x: generator.unit() * Double(width),
|
||||
y: generator.unit() * Double(height),
|
||||
width: radius,
|
||||
height: radius
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return context.makeImage()!
|
||||
}
|
||||
|
||||
static func scaled(_ image: CGImage, factor: Double) -> 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)
|
||||
}
|
||||
Reference in New Issue
Block a user