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:
yfoxjp
2026-07-28 08:02:14 +09:00
co-authored by Claude Opus 5
commit c7f68b6cca
38 changed files with 3102 additions and 0 deletions
+58
View File
@@ -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)
}
}
@@ -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)
}
}
+166
View File
@@ -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)
}
}
+102
View File
@@ -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)
}
}
+149
View File
@@ -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)")
}
}
+129
View File
@@ -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)
}
+33
View File
@@ -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))
}
}