静态图片的姿态识别

1,333 阅读8分钟

我们首先尝试从静态图片中去识别出33个人体特征点,并将它们绘制出来,这样读者便能直观的观察出33个特征点在人体中的位置。从静态图片中识别相对于摄像头实时识别也相对简单,是一个不错的入门途径。


工程的创建

  1. 引入ML Kit SDK 我们创建一个全新的XCODE工程(ML Kit工程需要12.5.1版本以上的XCODE版本),这个工程是iOS平台下的App工程,命名为MLKit-iOS,这个工程最低的目标版本设置为iOS10,如上一小节结尾所述,在工程目录下,使用终端命令pod init创建Podfile文件,在Podfile目录中引入MLKit SDK
# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'MLKit-iOS' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for MLKit-iOS
  # If you want to use the base implementation:
    pod 'GoogleMLKit/PoseDetection', '2.5.0'

  # If you want to use the accurate implementation:
    pod 'GoogleMLKit/PoseDetectionAccurate', '2.5.0'

  target 'MLKit-iOSTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'MLKit-iOSUITests' do
    # Pods for testing
  end
end

  1. 工程创建 Podfile创建完成后,在工程目录中,使用终端命令pod install创建工程,生成了.xcworkspace文件,之后我们双击这个文件来打开工程,而不是打开.xcodeproj

image.pngAssets中放入两张人体全身图,根据ML Kit的要求,图中必须包含人像的脸部。

image.png

image.png


核心代码编写

  1. 全身图的展示 这一步比较简单,只是创建一个ImageView来展示上方Assets中的一张全身图,再放置一个识别按钮,来触发人体特征点识别的代码。ViewController是App的第一个页面,在其中构建一个ImageView,并放入一张詹姆斯的全身照,safeAreaFrame()函数是获取屏幕安全区域SafeArea的位置大小。
override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        view.backgroundColor = .white
        
        //ImageView
        imageView = UIImageView(frame: safeAreaFrame(self))
        imageView!.contentMode = .scaleAspectFit
        view.addSubview(imageView!)
   
        imageView!.image = UIImage.init(named: "James1")
        
        //识别按钮
        let detectButton = UIButton(type: .custom)
        detectButton.frame = CGRect(x:0, y:80, width: 200, height: 50)
        detectButton.center.x = self.view.center.x
        detectButton.setTitle("从静态图片中识别", for: UIControl.State.normal)
        detectButton.setTitleColor(.white, for: UIControl.State.normal)
        detectButton.backgroundColor = UIColor.orange
        detectButton.addTarget(self, action:#selector(detectButton(_:)), for: .touchUpInside)
        self.view.addSubview(detectButton)
    }
  1. PoseDetector对象的创建 PoseDetectorML Kit SDK中的核心识别类。由于本项目是Swift编写的项目,而ML Kit SDK是由Objective-C编写的,PoseDetectorML Kit SDK中真实的类名是MLKPoseDetector
    PoseDetector的构造方法很简单:
+ (instancetype)poseDetectorWithOptions:(MLKCommonPoseDetectorOptions *)options
    NS_SWIFT_NAME(poseDetector(options:));

我们在上一小节中讲过:ML Kit SDK有两种,一种是基础SDK,另一种是准确SDK,那么在这里对应着MLKCommonPoseDetectorOptions也有两种:PoseDetectorOptionsAccuratePoseDetectorOptions,它们都是MLKCommonPoseDetectorOptions的子类。MLKCommonPoseDetectorOptions有一个MLKPoseDetectorMode参数,它有两个值.singleImage.stream

  • singleImage 单张图片模式 这种模式下,PoseDetector姿势检测器首先会识别图片中的人体,然后再进行姿态检测。每一张图片都会进行人体检测,所以延迟会高一些。并且没有人体追踪。通常在静态图片或者而不需要追踪人体的情况下使用这种模式。
  • stream 流模式 在这种模式下,PoseDetector姿势检测器首先会检测图片中最突出的人体,然后再进行姿态检测。在随后的图片帧中,不会再进行人体检测,除非人体变得模糊或者可信度降低。姿势检测器将尝试跟踪最突出的人体并在每次推理中返回他们的姿势。这减少了延迟并平滑了检测。当您想要检测视频流中的姿势时,请使用此模式。 那么该小节,我们讲的是从静态图片中去识别人体特征点,很显然要使用singleImage模式。 PoseDetector初始化,这里我们使用准确SDK
private var poseDetector: PoseDetector?
let options = AccuratePoseDetectorOptions()                     //使用准确`SDK`
options.detectorMode = .singleImage                             //`singleImage`模式
self.poseDetector = PoseDetector.poseDetector(options: options) //初始化poseDetector
  1. 特征点识别 特征点识别之前,需要将UIImage转换成ML Kit SDK需要的类对象,既MLImage类对象。
guard let inputImage = MLImage(image: image) else {
      // 转换错误处理
      print("Failed to create MLImage from UIImage.")
      return
    }

inputImage.orientation = image.imageOrientation //这里需要把`UIImage`的方向值赋给`MLImage`

使用- (void)processImage:(id<MLKCompatibleImage>)image completion:(MLKPoseDetectionCallback)completion方法进行特征点识别:

poseDetector.process(inputImage) { poses, error in
    guard error == nil, let poses = poses, !poses.isEmpty else {
          //识别错误
          ...
          return
     }
     ...
 }

可以看到将inputImage参数输入到process方法中,便可进行特征点识别。识别的回调是一个MLKPoseDetectionCallback闭包,此闭包中包含了识别错误的error值,或者识别成功的poses值。error读者可以尝试自行处理,或者在源码中查看。我们需要重点关注的是poses值,它一个NSArray数组对象:

NSArray<MLKPose *> *_Nullable poses

数组中包含的是MLKPose对象,对于单张静态图片来说,我们只需要取poses的第1个元素:

let pose = poses[0]

我们再来看MLKPose类,它有一个核心属性landmarks,这个landmarks便是这张静态图片中识别出来的所有33个特征点对象MLKPoseLandmark

@property(nonatomic, readonly) NSArray<MLKPoseLandmark *> *landmarks;

MLKPoseLandmark类,很显然包含了这个特征点的x, y, z坐标值、这个特征点的类型(眼睛、鼻子、手、脚等)以及这个特征点识别结果的可信度,可信度的范围是[0, 1],可信度我们在上一小节讲过。

@interface MLKPoseLandmark : NSObject
    @property(nonatomic, readonly) MLKPoseLandmarkType type;    // 特征点类型
    @property(nonatomic, readonly) MLKVision3DPoint *position;  // x, y, z轴坐标值
    @property(nonatomic, readonly) float inFrameLikelihood;     // 可信度

为了更为直观的看到pose值,我们运行程序,对一张图片进行识别,打上断点,看一下XCODE中的具体属性值,这里由于截图窗口长度限制,我只截取了20个特征点的属性值,总共应该是33个:

image.png

经过这一步的特征点识别,我们很开心已经学会了如何用ML Kit SDK识别静态图片中人体特征点。接下来的工作便是将识别出来的特征点绘制到静态图片上,实现如下的效果,特征点用蓝色小圆圈表示,而特征点之间用颜色线段进行连接:

image.png

  1. 坐标矩阵变换 我们首先来给UIImageView设置一个橙色,然后来解释这个问题:

image.png
橙色区域便是我们的UIImageView,而詹姆斯的全身照等比例缩放放入了UIImageView中,这是通过设置contentMode.scaleAspectFit实现的。

imageView!.contentMode = .scaleAspectFit

我们给UIImageView增加一个蒙版透明annotationOverlayView,我们所有的绘制过程都是这个透明蒙版annotationOverlayView上进行,这个annotationOverlayView必须覆盖在UIImageView上,并且frame一致。

private lazy var annotationOverlayView: UIView = {
    precondition(isViewLoaded)
    // 这个annotationOverlayView必须覆盖在UIImageView上,并且frame一致。
    let annotationOverlayView = UIView(frame: self.imageView.frame)
    annotationOverlayView.translatesAutoresizingMaskIntoConstraints = false
    annotationOverlayView.clipsToBounds = true
    return annotationOverlayView
  }()

我们会发现,我们需要绘制的场所是annotationOverlayView,而我们识别的特征点是从UIImage(詹姆斯)这张图片中识别出来的,很显然詹姆斯这张图片是进行等比例缩放后放入UIImageView中的,它俩的frame是不一致的,这就涉及到一个坐标矩阵问题,将UIImage图片本身的坐标系转换成UIImageView的坐标系,核心代码:

private func transformMatrix() -> CGAffineTransform {
    guard let image = imageView.image else { return CGAffineTransform() }

    //图片框宽 高
    let imageViewWidth = imageView.frame.size.width
    let imageViewHeight = imageView.frame.size.height

    //图片宽高(这个是图片放入ImageView后的宽高,scaleAspectFit)
    let imageWidth = image.size.width
    let imageHeight = image.size.height

    let imageViewAspectRatio = imageViewWidth / imageViewHeight
    let imageAspectRatio = imageWidth / imageHeight

    
    //ImageView宽高比和Image宽高比 比较
    let scale =
      (imageViewAspectRatio > imageAspectRatio)
      ? imageViewHeight / imageHeight : imageViewWidth / imageWidth

    //图片原始大小
    let scaledImageWidth = imageWidth * scale
    let scaledImageHeight = imageHeight * scale
      
    let xValue = (imageViewWidth - scaledImageWidth) / CGFloat(2.0)
    let yValue = (imageViewHeight - scaledImageHeight) / CGFloat(2.0)

    // 坐标系矩阵变换
    var transform = CGAffineTransform.identity.translatedBy(x: xValue, y: yValue)
    transform = transform.scaledBy(x: scale, y: scale)

    return transform
  }

这样,我们就得到这样一个转换后的坐标系矩阵:

let transform = self.transformMatrix()  //转换后的坐标系矩阵

然后将得到33个特征点的坐标值利用新的坐标系矩阵进行转换,得到新的特征点坐标对象,它是一个二维CGPoint对象:

let newPoint: CGPoint = oldPoint.applying(transform)
  1. 特征点绘制 特征点的绘制比较简单,循环遍历转换后的33个坐标点对象CGPoint,将其一个个绘制在annotationOverlayView上。绘制单个CGPoint对象代码:
public static func addCircle(
    atPoint point: CGPoint,
    to view: UIView,
    color: UIColor,
    radius: CGFloat
  ) {
    let divisor: CGFloat = 2.0
    let xCoord = point.x - radius / divisor
    let yCoord = point.y - radius / divisor
    let circleRect = CGRect(x: xCoord, y: yCoord, width: radius, height: radius)
    guard circleRect.isValid() else { return }
    let circleView = UIView(frame: circleRect)
    circleView.layer.cornerRadius = radius / divisor
    circleView.alpha = Constants.circleViewAlpha
    circleView.backgroundColor = color
    view.addSubview(circleView)
  }

调用该方法进行绘制即可:

UIUtilities.addCircle(
        atPoint: newPoint,
        to: annotationOverlayView,
        color: UIColor.blue,       //特征点颜色
        radius: dotRadius          //特征点圆点半径
      )
  1. 特征点间线段绘制 最后便是,将关联的特征点用某种颜色的线段绘制出来,首先需要定义关联特征点字典集合:这里截取了部分关联特征点。PoseConnectionsHolder结构体中定义了关联connections字典集合,我们可以集合上小节的图1进行代码理解。字典键是特征起始点,而字典值是特征终点。
private static func poseConnections() -> [PoseLandmarkType: [PoseLandmarkType]] {
    struct PoseConnectionsHolder {
      static var connections: [PoseLandmarkType: [PoseLandmarkType]] = [        PoseLandmarkType.leftEar: [PoseLandmarkType.leftEyeOuter],
        PoseLandmarkType.leftEyeOuter: [PoseLandmarkType.leftEye],
        PoseLandmarkType.leftEye: [PoseLandmarkType.leftEyeInner],
        PoseLandmarkType.leftEyeInner: [PoseLandmarkType.nose],
        PoseLandmarkType.nose: [PoseLandmarkType.rightEyeInner],
        PoseLandmarkType.rightEyeInner: [PoseLandmarkType.rightEye],
        PoseLandmarkType.rightEye: [PoseLandmarkType.rightEyeOuter],
        PoseLandmarkType.rightEyeOuter: [PoseLandmarkType.rightEar],
        PoseLandmarkType.mouthLeft: [PoseLandmarkType.mouthRight],
        PoseLandmarkType.leftShoulder: [          PoseLandmarkType.rightShoulder,          PoseLandmarkType.leftHip,        ],
        ...
      ]
    }
    return PoseConnectionsHolder.connections
  }

同样的,循环遍历特征起点和终点,绘制线段,绘制一段线段的代码如下,其核心是使用UIBezierPath贝塞尔曲线进行线段绘制。

private static func addLineSegment(
    fromPoint: CGPoint, toPoint: CGPoint, inView: UIView, colors: [UIColor], width: CGFloat
  ) {
    let viewWidth = inView.bounds.width
    let viewHeight = inView.bounds.height
    if viewWidth == 0.0 || viewHeight == 0.0 {
      return
    }

    let path = UIBezierPath()
    path.move(to: fromPoint)
    path.addLine(to: toPoint)

    let lineMaskLayer = CAShapeLayer()
    lineMaskLayer.path = path.cgPath
    lineMaskLayer.strokeColor = UIColor.black.cgColor
    lineMaskLayer.fillColor = nil
    lineMaskLayer.opacity = 1.0
    lineMaskLayer.lineWidth = width

    let gradientLayer = CAGradientLayer()
    gradientLayer.startPoint = CGPoint(x: fromPoint.x / viewWidth, y: fromPoint.y / viewHeight)
    gradientLayer.endPoint = CGPoint(x: toPoint.x / viewWidth, y: toPoint.y / viewHeight)
    gradientLayer.frame = inView.bounds
    var CGColors = [CGColor]()
    for color in colors {
      CGColors.append(color.cgColor)
    }

    if CGColors.count == 1 {
      CGColors.append(colors[0].cgColor)
    }

    gradientLayer.colors = CGColors
    gradientLayer.mask = lineMaskLayer

    let lineView = UIView(frame: inView.bounds)
    lineView.layer.addSublayer(gradientLayer)
    inView.addSubview(lineView)
  }