[Swift] ハミング距離の計算アプリ

# はじめに
こんにちは！
Swiftの学習を兼ねて、以前執筆した内容を、Swiftで実現してみようと思います。

今回は、[ハミング距離による類似画像の検出](https://qiita.com/yam_dev/items/e5f69b0c7b670aa498cb)にチャレンジです。

最初にお伝えしておくと、あまり実用性はありません！
こういうことができるんだ、という程度で見ていただけると幸いです！

# 要件
- 　画像を2枚アップロードできる
- 　比較ボタンをタップすると、画像のハミング距離と類似度を表示する

# 準備
プロジェクトの作成方法は、[Swift & SwiftUIでタロットカードアプリを作る](https://qiita.com/yam_dev/items/871b47c1065f570ff17a) を参照してください。

User Interfaceは`SwiftUI`を選択しました。

### 今回のディレクトリ構成
ContentViewのみ修正してます。

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/cec1e81a-7a3b-850f-8f52-a66a0ccb4dc1.png" width="60%" />

# 実装
```swift:ContentView.swift
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つの画像のハミング距離と類似度を計算する処理を行います。
処理の流れは以下の通りです。

1. **画像データの取得:**
   - `image1` と `image2` に格納されている `UIImage` データを取得します。
   - 画像データがない場合は、処理を終了します。

2. **CGImageへの変換:**
   - `UIImage` から `CGImage` を取得します。
   - `CGImage` は、Core Graphics で画像を扱うためのデータ形式です。

3. **CIImageへの変換:**
   - `CGImage` から `CIImage` を作成します。
   - `CIImage` は、Core Image で画像を扱うためのデータ形式です。

4. **グレースケール変換:**
   - `CIFilter` を使って、`CIImage` をグレースケールに変換します。
   - `CIPhotoEffectMono` というフィルターを使って、モノクロ画像を作成します。

5. **UIImageへの変換:**
   - `CIContext` を使って、グレースケールに変換した `CIImage` から `CGImage` を作成し、それを `UIImage` に変換します。

6. **画像の縮小**
    - グレースケール変換後の画像を指定サイズに縮小します。
    - `resized(to:)` 関数を使って、画像をリサイズします。

7. **バイナリ化:**
   - `binarize(threshold:)` 関数を使って、**縮小後のグレースケール画像**をバイナリ画像に変換します。
   - しきい値 `threshold` を使って、各ピクセルを白か黒に変換します。

8. **ピクセルデータの取得:**
   - `pixelData()` 関数を使って、バイナリ画像のピクセルデータを取得します。
   - ピクセルデータは、`UInt8` の配列として取得されます。

9. **ハミング距離の計算:**
   - `calculateHammingDistance(_:_:)` 関数を使って、2つのバイナリ画像のピクセルデータのハミング距離を計算します。
   - ハミング距離とは、2つのバイナリデータ列で異なるビットの数を表します。

10. **類似度の計算:**
    - ハミング距離と画像のサイズから、類似度を計算します。
    - 類似度は、0 から 1 の間の値で表され、1 に近いほど類似度が高いことを意味します。

11. **UIの更新:**
    - 計算が完了したら、`isLoading` を `false` に設定してローディング表示を非表示にし、`showResult` を `true` に設定して類似度を表示します。
    - UIの更新は、メインスレッドで行う必要があるため、`await MainActor.run` を使ってメインスレッドで実行するようにしています。

このように、比較ボタンを押すと、画像の読み込み、変換、縮小、計算、UIの更新といった一連の処理が非同期で行われます。
非同期処理を行うことで、UIがフリーズすることなく、スムーズな動作を実現しています。

### 補足

- `Task` は、非同期処理を実行するためのSwiftの機能です。
- `await` は、非同期処理の完了を待つためのキーワードです。
- `MainActor.run` は、メインスレッドでクロージャを実行するための関数です。

# 動作確認
起動直後の画面

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/ecab6aad-a98e-419c-672e-b2bd683430c2.png" width="65%" />

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/50712cbd-acca-2d3a-30d4-d117aeff1fe1.png" width="65%" />

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/f3072fe4-5d67-4187-bf37-1d8023fbc0b6.png" width="65%" />

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/8aa84c31-29bf-66c6-e008-f2f5abc3ee63.png" width="65%" />

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

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/407770/0a8c4c42-7c0a-2317-c1f2-a517b8af3475.png" width="65%" />


類似度の精度がどのくらい正確なのか分かりませんが、違う画像として認識したようです。

# まとめ
今回は、ハミング距離を使った類似度の計算をSwiftで実装しました。
最初にお伝えしたように、あまり実用性がありませんが、みなさんも一緒にがんばりましょう🙂