0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Vision Framework で顔から口元だけを匿名化して切り出す (歯列矯正記録アプリ実装)

0
Posted at

Vision Framework で顔から口元だけを匿名化して切り出す

歯列矯正の経過を、顔を出さずに口元だけで記録するアプリを個人開発しました。この記事では、Apple 純正の Vision Framework を使って「顔写真から口元だけを自動で正方形に切り出す + EXIF/GPS を丸ごと落とす」パイプラインを組んだときの技術メモを共有します。

サーバーには一切送らない、iPhone の中だけで完結する匿名化を目指しました。個人開発でサーバー代を払わずに済むメリットもありますが、それ以上に、矯正記録という繊細な画像は端末外に出したくない、という設計判断が先にありました。

Vision Framework で顔から口元だけを切り出した結果

この記事で分かること

  • VNDetectFaceLandmarksRequest で顔から口元 (outerLips) の座標を取得する方法
  • outerLips.normalizedPoints の座標系 (顔 bounding box 相対の 0..1) の扱い
  • 口元の bbox から「アプリで使いやすい正方形」を計算するときのマージン設計
  • 口を大きく開けたときに outerLips が飛ぶ問題への対処
  • ImageIO で EXIF / GPS / TIFF を丸ごと落として書き出す実装
  • Vision で検出失敗する口元アップ写真への 2 段構えパイプライン設計
  • CoreImage で正方形にクロップして UIImage に戻すときの orientation の扱い

技術スタック概要

今回使った Apple 純正フレームワークは主に 2 つです。

Vision (顔検出 + ランドマーク検出)

VNDetectFaceLandmarksRequest で顔の中の各部位の座標が取れます。目、鼻、口、顎、瞳など。今回使うのは口の外側の輪郭 outerLips の点群です。

iOS 12 から利用可能ですが、iOS 15 以降の revision = VNDetectFaceLandmarksRequestRevision3 で精度が大幅に上がっています。個人開発で「そこそこ」から「実用レベル」に押し上げるには、この revision 指定は必須です。

ImageIO (EXIF/GPS の除去 + 画像リサイズ)

匿名化の要はメタデータの除去です。iPhone で撮影した写真には撮影日時だけでなく GPS 座標 (自宅の位置)、機種情報、カメラ設定などが丸ごと入っています。UIImage で JPEG 書き出しするだけだと EXIF が残るので、CGImageDestination を使って明示的にメタデータを空にして書き出します。

Vision で口元の座標を取る

基本の呼び出し

VNDetectFaceLandmarksRequestVNImageRequestHandler に画像を渡して実行します。

import Vision

func detectMouth(in image: UIImage) async -> [CGPoint]? {
    guard let cgImage = image.cgImage else { return nil }

    return await withCheckedContinuation { continuation in
        let request = VNDetectFaceLandmarksRequest()
        request.revision = VNDetectFaceLandmarksRequestRevision3

        let handler = VNImageRequestHandler(
            cgImage: cgImage,
            orientation: image.cgImageOrientation
        )

        do {
            try handler.perform([request])
            guard let face = request.results?.first,
                  let outerLips = face.landmarks?.outerLips else {
                continuation.resume(returning: nil)
                return
            }
            continuation.resume(returning: outerLips.normalizedPoints)
        } catch {
            continuation.resume(returning: nil)
        }
    }
}

これで outerLips.normalizedPoints に唇の外側の点群が入ります。ただし、この座標系にはちょっとした罠があります。

normalizedPoints の座標系

outerLips.normalizedPoints は「検出された顔の bounding box に対する相対座標 (0..1)」です。画像全体の座標ではありません。画像座標に戻すには、顔の bbox を掛けて足す必要があります。

let faceBBox = face.boundingBox  // 画像全体に対する正規化座標 (0..1)
let imgWidth = CGFloat(cgImage.width)
let imgHeight = CGFloat(cgImage.height)

// Vision は左下原点、UIImage は左上原点なので Y を反転する
func denormalize(_ p: CGPoint) -> CGPoint {
    let x = (faceBBox.origin.x + p.x * faceBBox.width) * imgWidth
    let y = (1.0 - (faceBBox.origin.y + p.y * faceBBox.height)) * imgHeight
    return CGPoint(x: x, y: y)
}

let lipPointsInImage = outerLips.normalizedPoints.map(denormalize)

Vision の座標が左下原点というのは初見だと戸惑うポイントです。UIImage / CGImage の描画座標は左上原点なので、Y を反転する必要があります。

Vision Framework の口元検出パイプラインの流れ

口元の bbox から正方形を作る

点群の min/max で bbox を出して、それを中心に少しマージンを取った正方形にします。

let xs = lipPointsInImage.map(\.x)
let ys = lipPointsInImage.map(\.y)
guard let minX = xs.min(), let maxX = xs.max(),
      let minY = ys.min(), let maxY = ys.max() else { return nil }

let lipBBox = CGRect(
    x: minX, y: minY,
    width: maxX - minX, height: maxY - minY
)

// bbox の中心を基準に、口幅の 1.6 倍を一辺とする正方形
let center = CGPoint(x: lipBBox.midX, y: lipBBox.midY)
let side = lipBBox.width * 1.6
let squareRect = CGRect(
    x: center.x - side / 2,
    y: center.y - side / 2,
    width: side, height: side
)

マージン 1.6 倍は経験値です。試作段階では 1.0 倍 (ぴったり口だけ) にしていたんですが、実際に自分の写真で試すと「唇の周りの肌が全く写らないのは違和感がある」となり、少し余白を持たせた 1.6 倍に落ち着きました。この余白の中でも、EXIF は落として位置情報は残さないので、匿名性は損なわれません。

口を大きく開けたとき、outerLips が飛ぶ問題

これは実際にハマったポイントです。口を大きく開けて歯を見せる撮影方法では、outerLips の座標が想定外の位置に飛ぶことがあります。原因は Vision の内部モデルが「閉じた口」寄りに学習されているためと推測しています。

対策として、口の左右のコーナー (最も外側の点) から角度を計算して、極端に傾いていたら再検出するロジックを入れました。

// outerLips の x 座標が最小・最大の点を左右のコーナーとして採用
let leftCorner = lipPointsInImage.min(by: { $0.x < $1.x })!
let rightCorner = lipPointsInImage.max(by: { $0.x < $1.x })!
let angle = atan2(rightCorner.y - leftCorner.y, rightCorner.x - leftCorner.x)

// 顔が想定外に傾いていたら (60 度以上ずれていたら) 別経路で再検出
if abs(angle) > .pi / 3 {
    return await fallbackDetection(cgImage)
}

Vision で検出できない口元アップ写真

もう一つ、実装中に遭遇した課題があります。アプリで撮った写真ならまず顔全体が写るんですが、既存のカメラロールから取り込むと「口元だけをアップで撮った写真」も多くて、この場合 VNDetectFaceLandmarksRequest は顔を検出できずに失敗します。

対策として、Vision で失敗したときのフォールバックとしてピクセル解析ベースの検出を追加しました。HSV 色空間で歯 (白 + 少し青みがかった色) と唇 (赤みがかった色) を検出する簡易的な手法です。精度は Vision より落ちますが、口元アップ写真のケースをカバーできます。

static func detectMouth(in image: UIImage) async -> MouthLandmarks? {
    // 1. Vision で試す (顔全体が写っている写真)
    if let landmarks = await detectViaVision(image) {
        return landmarks
    }
    // 2. Vision が失敗したら、ピクセル解析で試す (口元アップ写真)
    return await detectViaColorAnalysis(image)
}

2 段構えにすることで、アプリ内撮影とカメラロール取り込みの両方に対応できます。

EXIF/GPS を丸ごと落として書き出す

匿名化の後半戦は、メタデータの除去です。切り出した画像を JPEG で書き出す時、UIImage の JPEG 変換だと EXIF が丸ごと保持されてしまいます。

CGImageDestination を使って、明示的にメタデータを空にして書き出す必要があります。

import ImageIO
import MobileCoreServices

func writeAnonymizedJPEG(cgImage: CGImage, to url: URL) throws {
    guard let destination = CGImageDestinationCreateWithURL(
        url as CFURL,
        kUTTypeJPEG,
        1,
        nil
    ) else {
        throw AnonymizationError.destinationCreationFailed
    }

    // メタデータを明示的に空 (nil) にする
    let options: [CFString: Any] = [
        kCGImageDestinationLossyCompressionQuality: 0.92
    ]

    CGImageDestinationAddImage(
        destination,
        cgImage,
        options as CFDictionary
    )

    guard CGImageDestinationFinalize(destination) else {
        throw AnonymizationError.finalizeFailed
    }
}

ポイントは CGImageDestinationAddImage に渡す options に画質だけを含めて、他のメタデータ辞書 (kCGImagePropertyExifDictionary, kCGImagePropertyGPSDictionary, kCGImagePropertyTIFFDictionary) を一切含めないこと。これで書き出し先の JPEG は真っさらな状態になります。

念のため、書き出した JPEG を CGImageSourceCopyPropertiesAtIndex で読み直してメタデータが空かどうか検証するテストも入れておくと安心です。

func assertNoMetadata(url: URL) throws {
    guard let source = CGImageSourceCreateWithURL(url as CFURL, nil),
          let props = CGImageSourceCopyPropertiesAtIndex(source, 0, nil)
            as? [CFString: Any] else {
        throw AnonymizationError.readbackFailed
    }
    assert(props[kCGImagePropertyExifDictionary] == nil)
    assert(props[kCGImagePropertyGPSDictionary] == nil)
    assert(props[kCGImagePropertyTIFFDictionary] == nil)
}

クロップと orientation の扱い

Vision で座標が取れたら、その正方形で元画像をクロップします。ここでもう一つのハマりポイントは orientation です。

iPhone のカメラで撮った写真は、実データは横向きで保存されていて、UIImage の imageOrientation プロパティで縦向きに見せる、という仕組みになっています。この状態のまま cgImage.cropping(to:) で切り出すと、想定と 90 度ずれた領域が切り出されます。

対策として、切り出す前に imageOrientation.up (回転なし) に正規化する処理を挟みます。

extension UIImage {
    func fixedOrientation() -> UIImage {
        guard imageOrientation != .up, let cgImage = cgImage else { return self }
        UIGraphicsBeginImageContextWithOptions(size, false, scale)
        draw(in: CGRect(origin: .zero, size: size))
        let result = UIGraphicsGetImageFromCurrentImageContext() ?? self
        UIGraphicsEndImageContext()
        return result
    }
}

// 使う側
let normalized = image.fixedOrientation()
guard let cropped = normalized.cgImage?.cropping(to: squareRect) else {
    return nil
}

これで座標系と実データが一致するので、クロップ結果が意図通りになります。

全体のパイプライン

まとめると、こんな流れになります。

static func anonymizeAndSave(
    image: UIImage,
    to url: URL
) async throws {
    // 1. orientation を正規化
    let normalized = image.fixedOrientation()

    // 2. Vision で口元の bbox を検出 (フォールバックあり)
    guard let landmarks = await detectMouth(in: normalized) else {
        throw AnonymizationError.mouthNotDetected
    }

    // 3. 口元の bbox を中心に正方形を計算
    let square = squareRect(from: landmarks, margin: 1.6)

    // 4. 正方形にクロップ
    guard let cgImage = normalized.cgImage,
          let cropped = cgImage.cropping(to: square) else {
        throw AnonymizationError.cropFailed
    }

    // 5. EXIF/GPS/TIFF を落として書き出し
    try writeAnonymizedJPEG(cgImage: cropped, to: url)

    // 6. 検証 (書き出し後のメタデータが空か)
    #if DEBUG
    try assertNoMetadata(url: url)
    #endif
}

シンプルに書くとこれだけですが、実際は各ステップにフォールバックとエラーハンドリングを入れています。

パイプラインを通した実出力。切り出した口元だけが日付ごとに整列している

まとめ

Vision Framework は個人開発で使うには十分に強力で、顔から特定パーツを切り出すような処理も Apple 純正だけで完結できます。ImageIO と組み合わせれば、メタデータの完全除去も含めた匿名化パイプラインが iPhone の中だけで組めます。

矯正記録という繊細なドメインでは、写真をサーバーに送らないという設計判断が信頼の土台になります。技術的にも、月額のサーバー代を払わずに済むので個人開発の維持コストが下がるメリットもあります。

この記事の実装が入っているアプリは 矯正ログ (Kyousei Log) / SmileProgress として App Store で配信中です。日本先行で配信していて、期間限定 ¥160 (2026-12-31 まで) の買い切りです。矯正中の方や、顔を出さずに口元だけを記録したい方に、よければ試してもらえたら嬉しいです。

App Store: https://apps.apple.com/jp/app/id6780383136

矯正ログ / SmileProgress のアプリアイコン

感想やフィードバック、コメントでお気軽にどうぞ。個人開発なので全部読みます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?