Visionで輪郭抽出して遊ぶ


これはなに

Visionを使って画像の輪郭抽出して遊んだのでその記録
撮影した画像(キャプチャ)に対してVisionの輪郭抽出をして遊んでみました。

とある勉強会でLTした内容です。
くるるんが可愛すぎるのでくるるんを題材にしていますが、個人的な利用であり、所属する会社、組織とは全く関係ありません。(念の為)

まずは成果物をどうぞ

撮影した画像を切り抜いて、そのあと動かしてます。

なにをしているか

1.画像をCIImageに変換
1.CIFilterで切り出しやすい画像に変換
1.VNDetectContuerで輪郭抽出
1.抽出したパスを加工。外側の輪郭だけ取得
1.元の画像に輪郭を描画
1.VNContournormalizedPathで内側を塗りつぶした画像と元画像をマスク処理
1.切り抜き完成
1.切り抜いた画像をSKTextureにしてSpriteKitSkSpriteNodeで使えるようにする

輪郭抽出

VNDetectContoursRequestで画像の輪郭をnormalizedPath(CGPath)で取得できるのでそれを使います

CIFilterで切り出しやすい画像に変換

輪郭抽出するまえの画像データを加工する必要があります。エッジが際立つので抽出しやすくなります。
画像データを加工ずるためにCIFilterを使うためCIImageに変換します。

今回はCIFilterでグレースケール化、したものを二値化してさらに黒い領域が多い部分は黒く塗りつぶす処理などをかけています。

CIImageにすると画像の回転情報をもたないので画像が横になったりするのでこちらはあとで回転情報を保持して再度渡してあげる必要があります。

CIFilterの使い方は例えば以下のようになります。

// 撮影画像を二値化
let thresholdFilter = CIFilter.colorThreshold()
thresholdFilter.inputImage = grayscaleImage
thresholdFilter.threshold = 0.3
guard let thresholdImage = thresholdFilter.outputImage else { return nil }

VNDetectContuerで輪郭抽出

切り出しやすい画像に変換したらVNDetectContuerのrequestHandlerに渡します。Visionのリクエスト処理のイメージは前回の記事(貼る!)とほぼ同じです

 self.preProcessImage = preprocessForDetectContour(screenImage: inputImage)

let contourRequest = VNDetectContoursRequest.init()
contourRequest.revision = VNDetectContourRequestRevision1
contourRequest.contrastAdjustment = 1.0
contourRequest.detectsDarkOnLight = true
contourRequest.maximumImageDimension = 524

guard let preProcessImage = self.preProcessImage else { return }
        let requestHandler = VNImageRequestHandler.init(ciImage: preProcessImage, options: [:])

do {
    try requestHandler.perform([contourRequest])
} catch {
    // error
}

CGPathを使って輪郭を描画します。

VNDetectContoursRequestで取得したVNContoursObservationのtopLevelContoursVNContourのnormalizedPath: CGPath を使って輪郭を描画します。

public func drawContours(contours: VNContour, sourceImage: CGImage)
        -> UIImage
{
    let size = CGSize(width: sourceImage.width, height: sourceImage.height)
    let renderer = UIGraphicsImageRenderer(size: size)

    let renderedImage = renderer.image { (context) in
        let renderingContext = context.cgContext

        let flipVertical = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: size.height)
        renderingContext.concatenate(flipVertical)

        renderingContext.draw(
            sourceImage,
            in: CGRect(x: 0, y: 0, width: size.width, height: size.height)
        )
        renderingContext.scaleBy(x: size.width, y: size.height)
        renderingContext.setLineWidth(100.0 / CGFloat(size.width))

        let yellowUIColor = UIColor.yellow

        renderingContext.addPath(contours.normalizedPath)
        renderingContext.setStrokeColor(yellowUIColor.cgColor)
        renderingContext.setFillColor(UIColor.clear.cgColor)

        renderingContext.strokePath()

        self.path = contours.normalizedPath
        self.croppingImage = makeShapeNode(
            from: contours.normalizedPath,
            captureImage: CIImage(cgImage: sourceImage)
        )
    }
    return renderedImage
}

CGPathを使ってマスク処理

normarizePathとUIGraphicsImageRendererを使ってマスク処理用に取得したパスで塗りつぶした画像を作ります。下記が完成イメージ

guard let transPath = normalizedPath.copy(using: &transform) else { return nil }
let pathFillImage = UIGraphicsImageRenderer(
    size: CGSize(width: extent.width, height: extent.height),
    format: format
)
.image { context in
    context.cgContext.setFillColor(UIColor.white.cgColor)
    context.cgContext.addPath(transPath)
    context.cgContext.fillPath()
}

マスク処理もCIFilterを使います
multiplyCompositing()メソッドのinputに撮影した画像(CIImage) を、outputにパスで塗りつぶした画像を使います。

// パスの内側だけキャプチャした画像を切り抜く
let filter = CIFilter.multiplyCompositing()
filter.inputImage = captureCIImage
filter.backgroundImage = maskCIImage
guard let texture = filter.outputImage else { return nil }

SKSpriteNodeに切り出した画像をTextureにして貼る

切り出した画像を SKTexture で渡して SKSpriteNodeに貼る

Sceneの touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) でSpriteを動かす

import SpriteKit

class KururunSprite: SKSpriteNode {

    init(texture: [SKTexture], position: CGPoint) {
        super
            .init(
                texture: texture[0],
                color: .clear,
                size: CGSize(
                    width: texture[0].size().width / 90, // この辺のサイズは適当です
                    height: texture[0].size().height / 90
                )
            )
        physicsBody = SKPhysicsBody(
            rectangleOf: CGSize(
                width: texture[0].size().width / 90,
                height: texture[0].size().height / 90
            )
        )
        setScale(4.0)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func move() {
        // 速度の更新
        physicsBody?.velocity = CGVector(dx: 0, dy: 0)
        // 質量を無視して力を加える
        physicsBody?.applyImpulse(CGVector(dx: 0, dy: 60))

        physicsBody?.allowsRotation = true
        let action = SKAction.rotate(byAngle: Double.pi / 8, duration: 3.0)
        run(action)
    }

}

SwiftUIでSpriteKitを動かすのでその画面の準備

mport SpriteKit
import SwiftUI

struct SpriteScreenView: View {

    var scene: GameScene
    var node: CIImage

    init(node: CIImage) {
        self.node = node
        scene = GameScene()
        scene.node = node
        scene.size = CGSize(width: 828, height: 1792)  // 適当です。geometry使いたい
        scene.scaleMode = .fill
    }

    var body: some View {
        GeometryReader { geometry in
            VStack {
                ZStack {
                    if #available(iOS 14.0, *) {
                        SpriteView(scene: scene)
                            .frame(width: geometry.size.width, height: geometry.size.height)
                            .ignoresSafeArea()
                    } else {
                        // なにも用意してない (^θ^)
                    }
                }
            }
        }
    }
}

物理エンジン(SKPhisycsBody)の設定

didMove(to view: SKView) で物理演算の設定

touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) でSpriteNodeを動かす処理を呼ぶ

import SpriteKit

class GameScene: SKScene, SKPhysicsContactDelegate {

    var node: CIImage!

    lazy var kururun: KururunSprite = {

        let uiimage = self.node.toUIImage(orientation: UIImage.Orientation(rawValue: 90) ?? .up)  // .upは適当
        let cgImage = uiimage?.cgImage
        let texture = SKTexture(cgImage: cgImage!)  // forceunwrapは適当

        let position = CGPoint(x: self.frame.midX, y: self.frame.midY)
        return KururunSprite(texture: [texture], position: position)
    }()

    override func didMove(to view: SKView) {
        // 物理エンジンのグローバル設定
        self.physicsBody = SKPhysicsBody.init(edgeLoopFrom: self.frame)
        self.physicsWorld.gravity = CGVector(dx: 0.0, dy: -5.0)
        self.physicsWorld.contactDelegate = self

        self.kururun.position = CGPoint(
            x: self.size.width / 2.0,
            y: self.size.height / 2.0 + (kururun.size.height + 50.0)
        )

        self.addChild(kururun)
    }

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        kururun.move()
    }
}