Files
photochk/Tests/PhotochkTests/TestImages.swift
T
yfoxjpandClaude Opus 5 c7f68b6cca 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>
2026-07-28 08:02:14 +09:00

130 lines
4.1 KiB
Swift

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)
}