はじめに
こんにちは!
Swiftの学習を兼ねて、以前執筆した内容を、Swiftで実現してみようと思います。
今回は、ハミング距離による類似画像の検出にチャレンジです。
最初にお伝えしておくと、あまり実用性はありません!
こういうことができるんだ、という程度で見ていただけると幸いです!
要件
- 画像を2枚アップロードできる
- 比較ボタンをタップすると、画像のハミング距離と類似度を表示する
準備
プロジェクトの作成方法は、Swift & SwiftUIでタロットカードアプリを作る を参照してください。
User InterfaceはSwiftUI
を選択しました。
今回のディレクトリ構成
ContentViewのみ修正してます。

実装
import SwiftUI
import PhotosUI
struct ContentView: View {
@State private var image1: UIImage?
@State private var image2: UIImage?
@State private var hammingDistance: Int = 0
@State private var similarity: Double = 0.0
@State private var showImagePicker1 = false
@State private var showImagePicker2 = false
@State private var isLoading = false // ローディング状態
@State private var showResult = false // 類似度表示状態
var body: some View {
NavigationView {
VStack(spacing: 20) {
// タイトル
Text("ImageDiff")
.font(.largeTitle)
.fontWeight(.bold)
.padding()
// 画像選択エリア
HStack {
// 画像1を選択するボタン
Button(action: {
showImagePicker1 = true
// 写真を選択したら類似度の表示をオフにする
showResult = false
}) {
if let image1 = image1 {
Image(uiImage: image1)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
} else {
Image(systemName: "photo")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
}
.sheet(isPresented: $showImagePicker1) {
ImagePickerWrapper(selectedImage: $image1)
}
// 画像2を選択するボタン
Button(action: {
showImagePicker2 = true
// 写真を選択したら類似度の表示をオフにする
showResult = false
}) {
if let image2 = image2 {
Image(uiImage: image2)
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
} else {
Image(systemName: "photo")
.resizable()
.scaledToFit()
.frame(width: 200, height: 200)
}
}
.sheet(isPresented: $showImagePicker2) {
ImagePickerWrapper(selectedImage: $image2)
}
}
// 比較ボタン
Button("比較") {
isLoading = true // ローディング開始
// 非同期で類似度計算を実行
Task {
await calculateDistanceAndSimilarity()
}
}
.padding()
.font(.title2) // フォントサイズを大きく
.frame(maxWidth: .infinity) // 横幅いっぱいに広げる
.background(Color.blue) // 背景色を青に
.foregroundColor(.white) // 文字色を白に
.cornerRadius(10) // 角丸にする
// ローディング表示
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle(tint: .blue))
.scaleEffect(1.5)
}
// 類似度表示
if showResult {
Text("ハミング距離: \(hammingDistance)")
Text("類似度: \(similarity * 100, specifier: "%.2f")%")
}
Spacer() // コンテンツを上部に寄せる
}
.onAppear {
showResult = false
}
.padding() // VStack 全体のパディング
}
}
// MARK: - 画像処理とハミング距離・類似度の計算
func calculateDistanceAndSimilarity() async {
guard let image1 = image1, let image2 = image2 else { return }
// UIImageをCGImageに変換
guard let cgImage1 = image1.cgImage, let cgImage2 = image2.cgImage else { return }
// CGImageからCIImageを作成
let ciImage1 = CIImage(cgImage: cgImage1)
let ciImage2 = CIImage(cgImage: cgImage2)
// CIFilterを使ってグレースケールに変換
guard let grayFilter = CIFilter(name: "CIPhotoEffectMono"),
let grayImage1 = applyFilter(image: ciImage1, filter: grayFilter),
let grayImage2 = applyFilter(image: ciImage2, filter: grayFilter) else { return }
// CIImageをUIImageに変換
let context = CIContext()
if let cgImage1 = context.createCGImage(grayImage1, from: grayImage1.extent),
let cgImage2 = context.createCGImage(grayImage2, from: grayImage2.extent) {
// UIImage を生成する処理を if 文の中に入れる
let grayscaleImage1 = UIImage(cgImage: cgImage1)
let grayscaleImage2 = UIImage(cgImage: cgImage2)
// ここで画像を縮小
let thumbnailSize = CGSize(width: 100, height: 100) // 縮小後のサイズ
guard let thumbnail1 = grayscaleImage1.resized(to: thumbnailSize),
let thumbnail2 = grayscaleImage2.resized(to: thumbnailSize) else { return }
// バイナリ化
let binaryImage1 = thumbnail1.binarize(threshold: 0.5)
let binaryImage2 = thumbnail2.binarize(threshold: 0.5)
guard let binaryImage1 = binaryImage1, let binaryImage2 = binaryImage2 else { return }
let data1 = binaryImage1.pixelData() // リサイズ後の画像からpixelDataを取得
let data2 = binaryImage2.pixelData() // リサイズ後の画像からpixelDataを取得
hammingDistance = calculateHammingDistance(data1, data2)
similarity = 1.0 - Double(hammingDistance) / Double(data1.count)
// 処理が終わってからメインスレッドでUIを更新
await MainActor.run {
isLoading = false // ローディング終了
showResult = true // 類似度表示をオンにする
}
} else {
// CIImage から CGImage への変換に失敗した場合の処理
print("CIImage から CGImage への変換に失敗しました")
// 処理が終わってからメインスレッドでUIを更新
await MainActor.run {
isLoading = false // ローディング終了
}
return
}
}
// MARK: - CIImageにフィルターを適用する関数
private func applyFilter(image: CIImage, filter: CIFilter) -> CIImage? {
filter.setValue(image, forKey: kCIInputImageKey)
return filter.outputImage
}
// MARK: - ハミング距離の計算
func calculateHammingDistance(_ data1: [UInt8], _ data2: [UInt8]) -> Int {
guard data1.count == data2.count else { return 0 }
var distance = 0
for i in 0..<data1.count {
if data1[i] != data2[i] {
distance += 1
}
}
return distance
}
}
// MARK: - UIImageの拡張
extension UIImage {
// バイナリ化
func binarize(threshold: CGFloat) -> UIImage? {
guard let cgImage = self.cgImage else { return nil }
let width = cgImage.width
let height = cgImage.height
let colorSpace = CGColorSpaceCreateDeviceGray()
let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.none.rawValue)
guard let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width, space: colorSpace, bitmapInfo: bitmapInfo.rawValue) else { return nil }
context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height))
guard let pixelData = context.data else { return nil }
let bytesPerPixel = 1
for y in 0..<height {
for x in 0..<width {
let offset = (y * width + x) * bytesPerPixel
let pixelValue = pixelData.load(fromByteOffset: offset, as: UInt8.self)
let newPixelValue = pixelValue > UInt8(threshold * 255) ? UInt8(255) : UInt8(0)
pixelData.storeBytes(of: newPixelValue, toByteOffset: offset, as: UInt8.self)
}
}
guard let newCGImage = context.makeImage() else { return nil }
return UIImage(cgImage: newCGImage)
}
// UIImageのピクセルデータを取得
func pixelData() -> [UInt8] {
guard let cgImage = self.cgImage,
let dataProvider = cgImage.dataProvider,
let pixelData = dataProvider.data else { return [] }
let dataPointer = CFDataGetBytePtr(pixelData)
let width = cgImage.width
let height = cgImage.height
let bytesPerPixel = cgImage.bitsPerPixel / 8
var pixelArray: [UInt8] = []
for y in 0..<height {
for x in 0..<width {
let offset = (y * width + x) * bytesPerPixel
for i in 0..<bytesPerPixel {
pixelArray.append(dataPointer![offset + i])
}
}
}
return pixelArray
}
// UIImage を指定サイズにリサイズする
func resized(to size: CGSize) -> UIImage? {
UIGraphicsBeginImageContextWithOptions(size, false, 0.0)
draw(in: CGRect(origin: .zero, size: size))
let resizedImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return resizedImage
}
}
// MARK: - ImagePicker構造体
struct ImagePickerWrapper: UIViewControllerRepresentable {
@Binding var selectedImage: UIImage?
func makeUIViewController(context: Context) -> PHPickerViewController {
var config = PHPickerConfiguration()
config.filter = .images
let picker = PHPickerViewController(configuration: config)
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {
// 更新処理は不要
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
class Coordinator: NSObject, PHPickerViewControllerDelegate {
let parent: ImagePickerWrapper
init(_ parent: ImagePickerWrapper) {
self.parent = parent
}
func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
picker.dismiss(animated: true)
guard let provider = results.first?.itemProvider else { return }
if provider.canLoadObject(ofClass: UIImage.self) {
provider.loadObject(ofClass: UIImage.self) { image, error in
if let image = image as? UIImage {
self.parent.selectedImage = image
}
}
}
}
}
}
// MARK: - プレビュー用構造体
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
比較ボタンを押した後のロジック解説
比較ボタンを押すと、calculateDistanceAndSimilarity()
関数が非同期で実行されます。
この関数は、2つの画像のハミング距離と類似度を計算する処理を行います。
処理の流れは以下の通りです。
-
画像データの取得:
-
image1
とimage2
に格納されているUIImage
データを取得します。 - 画像データがない場合は、処理を終了します。
-
-
CGImageへの変換:
-
UIImage
からCGImage
を取得します。 -
CGImage
は、Core Graphics で画像を扱うためのデータ形式です。
-
-
CIImageへの変換:
-
CGImage
からCIImage
を作成します。 -
CIImage
は、Core Image で画像を扱うためのデータ形式です。
-
-
グレースケール変換:
-
CIFilter
を使って、CIImage
をグレースケールに変換します。 -
CIPhotoEffectMono
というフィルターを使って、モノクロ画像を作成します。
-
-
UIImageへの変換:
-
CIContext
を使って、グレースケールに変換したCIImage
からCGImage
を作成し、それをUIImage
に変換します。
-
-
画像の縮小
- グレースケール変換後の画像を指定サイズに縮小します。
-
resized(to:)
関数を使って、画像をリサイズします。
-
バイナリ化:
-
binarize(threshold:)
関数を使って、縮小後のグレースケール画像をバイナリ画像に変換します。 - しきい値
threshold
を使って、各ピクセルを白か黒に変換します。
-
-
ピクセルデータの取得:
-
pixelData()
関数を使って、バイナリ画像のピクセルデータを取得します。 - ピクセルデータは、
UInt8
の配列として取得されます。
-
-
ハミング距離の計算:
-
calculateHammingDistance(_:_:)
関数を使って、2つのバイナリ画像のピクセルデータのハミング距離を計算します。 - ハミング距離とは、2つのバイナリデータ列で異なるビットの数を表します。
-
-
類似度の計算:
- ハミング距離と画像のサイズから、類似度を計算します。
- 類似度は、0 から 1 の間の値で表され、1 に近いほど類似度が高いことを意味します。
-
UIの更新:
- 計算が完了したら、
isLoading
をfalse
に設定してローディング表示を非表示にし、showResult
をtrue
に設定して類似度を表示します。 - UIの更新は、メインスレッドで行う必要があるため、
await MainActor.run
を使ってメインスレッドで実行するようにしています。
- 計算が完了したら、
このように、比較ボタンを押すと、画像の読み込み、変換、縮小、計算、UIの更新といった一連の処理が非同期で行われます。
非同期処理を行うことで、UIがフリーズすることなく、スムーズな動作を実現しています。
補足
-
Task
は、非同期処理を実行するためのSwiftの機能です。 -
await
は、非同期処理の完了を待つためのキーワードです。 -
MainActor.run
は、メインスレッドでクロージャを実行するための関数です。
動作確認
起動直後の画面

まずは同じ画像をアップして、比較ボタンをタップ。

ハミング距離が0、類似度は100%でした!

続いて、違う画像で比較してみます。

ハミング距離が32,445、類似度は63.95%でした!

類似度の精度がどのくらい正確なのか分かりませんが、違う画像として認識したようです。
まとめ
今回は、ハミング距離を使った類似度の計算をSwiftで実装しました。
最初にお伝えしたように、あまり実用性がありませんが、みなさんも一緒にがんばりましょう🙂