附錄 5-10(II) App 程式碼(共5個Swift檔案)
App 要記得設定「相機」權限(參考5-9 App (I) 影片)
1. 相機鏡頭
// 5-9 App(1) 啟動相機鏡頭 version 2
// Revised by Heman, 2023/07/07
import AVFoundation
class 相機鏡頭: NSObject, ObservableObject {
@Published var 單幅影像: CGImage? = nil
var 鏡頭列表: [AVCaptureDevice] = []
var 已授權 = false
let 錄影任務 = AVCaptureSession()
func 錄影設置() async throws {
// 第一段:列出攝影鏡頭
let 鏡頭列表 = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .unspecified)
self.鏡頭列表 = 鏡頭列表.devices
print(鏡頭列表.devices)
// 第二段:取得錄影授權
已授權 = await AVCaptureDevice.requestAccess(for: .video)
print("授權了嗎?\(已授權)")
// 第三段:開啟鏡頭
if 已授權 {
guard let 預設鏡頭 = AVCaptureDevice.default(for: .video) else { return }
// guard let 預設鏡頭 = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .front) else { print("找不到攝影鏡頭"); return }
guard let 錄影輸入 = try? AVCaptureDeviceInput(device: 預設鏡頭) else { return }
print(預設鏡頭, 錄影輸入)
let 錄影輸出 = AVCaptureVideoDataOutput()
if 錄影任務.canAddInput(錄影輸入) {
print("開始設置鏡頭參數")
錄影任務.beginConfiguration()
錄影任務.addInput(錄影輸入)
錄影輸出.setSampleBufferDelegate(self, queue: DispatchQueue(label: "影像取樣"))
錄影輸出.alwaysDiscardsLateVideoFrames = true
錄影任務.addOutput(錄影輸出)
錄影任務.commitConfiguration()
print("設置完畢")
// 錄影任務.startRunning()
} else {
throw AVError(.deviceNotConnected)
}
} else {
throw AVError(.operationNotAllowed)
}
}
func 開始錄影() {
if 已授權 {
錄影任務.startRunning()
}
}
func 停止錄影() {
錄影任務.stopRunning()
}
}
import CoreImage
// 第四段:影像接收者
extension 相機鏡頭: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection)
{
let 圖層 = CIContext()
if let 暫存區影像 = CMSampleBufferGetImageBuffer(sampleBuffer) {
let 轉換格式 = CIImage(cvPixelBuffer: 暫存區影像)
if let 影像 = 圖層.createCGImage(轉換格式, from: 轉換格式.extent) {
單幅影像 = 影像
}
}
}
}
2. 描繪特徵點v2
// 5-9 App (4) 描繪特徵點v2
// Revised by Heman, 2023/07/25
// Based on 5-3a Face Landmarks Detection
// Created by Heman, 2023/03/04
import SwiftUI
struct 描繪特徵點: View {
let 正規化點陣列: [CGPoint]
var body: some View {
Canvas { 圖層, 尺寸 in
// print(尺寸)
let 圖寬 = 尺寸.width
let 圖高 = 尺寸.height
var 畫筆 = Path()
for 單點 in 正規化點陣列 {
let 點座標 = CGPoint(
x: 圖寬 * 單點.x,
y: 圖高 - 圖高 * 單點.y)
畫筆.move(to: 點座標)
畫筆.addArc(
center: 點座標,
radius: 5.0,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.red))
}
}
}
struct 描繪線條: View {
let 正規化線段: [[CGPoint]]
var body: some View {
Canvas { 圖層, 尺寸 in
// print(尺寸)
let 圖寬 = 尺寸.width
let 圖高 = 尺寸.height
var 畫筆 = Path()
for 線段 in 正規化線段 {
var 螢幕點陣列: [CGPoint] = []
for 單點 in 線段 {
let 點座標 = CGPoint(
x: 圖寬 * 單點.x,
y: 圖高 - 圖高 * 單點.y)
螢幕點陣列.append(點座標)
}
畫筆.move(to: 螢幕點陣列.first ?? .zero)
畫筆.addLines(螢幕點陣列)
圖層.stroke(畫筆, with: .color(.red),lineWidth: 2.0)
}
}
}
}
3. 手勢辨識器v2
// 5-10 App (1) 手勢辨識器v2
// Revised by Heman, 2023/08/01
import Vision
struct 手掌辨識結果 {
var 點陣列: [CGPoint] = [] // 手掌21個特徵點
var 食中指尖距離: [Double] = [] // 劍訣:食指、中指併攏(距離 < 40)
var 線段陣列: [[CGPoint]] = [] // 手掌骨架(5組線段)
var 筆尖: CGPoint = .zero // 虛空寫字
}
func 手掌辨識(_ 圖片參數: CGImage) async throws -> 手掌辨識結果 {
var 辨識結果 = 手掌辨識結果()
let 手掌辨識請求 = VNDetectHumanHandPoseRequest()
let 請求: [VNImageBasedRequest] = [手掌辨識請求]
let 處理者 = VNImageRequestHandler(cgImage: 圖片參數)
try 處理者.perform(請求)
if let 處理結果 = 手掌辨識請求.results { // as? [VNHumanHandPoseObservation]
for 手掌 in 處理結果 { // 可能多隻手掌
let 所有特徵點 = try 手掌.recognizedPoints(.all) // 字典(Dictionary)類型
let 手掌關節: [VNHumanHandPoseObservation.JointName] = [
.thumbTip, .thumbIP, .thumbMP, .thumbCMC, // 拇指
.indexTip, .indexDIP, .indexPIP, .indexMCP, // 食指
.middleTip, .middleDIP, .middlePIP, .middleMCP, // 中指
.ringTip, .ringDIP, .ringPIP, .ringMCP, // 無名指
.littleTip, .littleDIP, .littlePIP, .littleMCP, // 小指
.wrist // 腕關節
]
// 點陣列:包含所有特徵點
for i in 手掌關節 {
if let 特徵點 = 所有特徵點[i] {
if 特徵點.confidence > 0 {
辨識結果.點陣列.append(特徵點.location) // 正規化座標
}
}
}
// 食中指尖距離、筆尖
let 圖寬 = CGFloat(圖片參數.width)
let 圖高 = CGFloat(圖片參數.height)
if let 食指尖 = 所有特徵點[.indexTip]?.location,
let 中指尖 = 所有特徵點[.middleTip]?.location {
let 食指尖螢幕座標 = CGPoint(
x: 食指尖.x * 圖寬,
y: 食指尖.y * 圖高)
let 中指尖螢幕座標 = CGPoint(
x: 中指尖.x * 圖寬,
y: 中指尖.y * 圖高)
辨識結果.筆尖 = CGPoint(
x: (食指尖.x + 中指尖.x) / 2.0,
y: (食指尖.y + 中指尖.y) / 2.0)
let 距離 = 兩點距離(食指尖螢幕座標, 中指尖螢幕座標)
辨識結果.食中指尖距離.append(距離)
}
// 線段陣列:五隻手指
typealias 關節 = VNHumanHandPoseObservation.JointName
let 拇指: [關節] = [.wrist, .thumbCMC, .thumbMP, .thumbIP, .thumbTip]
let 食指: [關節] = [.wrist, .indexMCP, .indexPIP, .indexDIP, .indexTip]
let 中指: [關節] = [.wrist, .middleMCP, .middlePIP, .middleDIP, .middleTip]
let 無名指: [關節] = [.wrist, .ringMCP, .ringPIP, .ringDIP, .ringTip]
let 小指: [關節] = [.wrist, .littleMCP, .littlePIP, .littleDIP, .littleTip]
for 手指 in [拇指, 食指, 中指, 無名指, 小指] {
var 手指線段: [CGPoint] = []
for i in 手指 {
if let 特徵點 = 所有特徵點[i], 特徵點.confidence > 0 {
手指線段.append(特徵點.location) // 正規化座標
}
}
if 手指線段.isEmpty == false {
辨識結果.線段陣列.append(手指線段)
}
}
}
}
return 辨識結果
}
func 兩點距離(_ a: CGPoint, _ b: CGPoint) -> Double {
let 平方和 = (b.x - a.x) * (b.x - a.x) + (b.y - a.y) * (b.y - a.y)
let 距離 = sqrt(平方和)
return 距離
}
4. 虛空寫字
// 5-10 App (2) 虛空寫字
// Revised by Heman, 2023/08/01
import SwiftUI
struct 虛空寫字: View {
let 距離門檻: Double = 40.0 // 指尖距離(螢幕座標/點)
let 確認次數: Int = 7 // 手勢確認n次以上
@StateObject var 鏡頭 = 相機鏡頭() // 5-9(1)相機鏡頭
@State var 辨識結果 = 手掌辨識結果()
@State var 手指軌跡: [CGPoint] = [] // 用劍訣(食中指併攏)寫字
@State var 暫停辨識 = false
@State var 提示訊息 = "等候相機授權..."
@State var 手勢狀態: (顯示次數: Bool, 次數: Int) = (false, 0)
var body: some View {
if let 畫面 = 鏡頭.單幅影像 {
VStack {
ZStack(alignment: .trailing) {
Image(畫面, scale: 1.0, label: Text("錄影畫面1"))
.resizable()
.scaledToFit()
.onTapGesture(count: 2) {
手指軌跡 = []
}
.onTapGesture {
暫停辨識.toggle()
暫停辨識 ? 鏡頭.停止錄影() : 鏡頭.開始錄影()
}
VStack {
Text("\(手勢狀態.次數)")
.font(.title2)
.padding()
.foregroundStyle(手勢狀態.顯示次數 ? Color.blue : Color.clear)
Image(systemName: "heart.fill")
.font(.title)
.padding()
.foregroundStyle(手勢狀態.次數 > 確認次數 ? Color.red : Color.clear)
}
}
Image(畫面, scale: 1.0, label: Text("錄影畫面2"))
.resizable()
.scaledToFit()
.opacity(0.1) // 將圖片淡化作為底圖
.overlay(描繪特徵點(正規化點陣列: 辨識結果.點陣列)) // 5-9(4)描繪特徵點
.overlay(描繪線條(正規化線段: 辨識結果.線段陣列)) // 描繪手掌骨架
.overlay(描繪特徵點(正規化點陣列: 手指軌跡)) // 虛空寫字
.scaleEffect(x: -1.0, y: 1.0)
.overlay(alignment: .trailing) {
Image(systemName: "heart.fill")
.font(.title)
.padding()
.foregroundStyle(手勢狀態.次數 > 確認次數 ? Color.red : Color.clear)
}
.border(Color.red)
.onChange(of: 鏡頭.單幅影像) { 新影像 in
Task {
do {
if let 影像 = 新影像 {
辨識結果 = try await 手掌辨識(影像)
if 辨識結果.食中指尖距離.isEmpty {
手勢狀態 = (顯示次數: false, 次數: 0)
}
if let 較小距離 = 辨識結果.食中指尖距離.min(), 較小距離 < 距離門檻 {
手勢狀態.顯示次數 = true
手勢狀態.次數 += 1
if 手勢狀態.次數 > 確認次數 {
手指軌跡.append(辨識結果.筆尖)
}
} else {
手勢狀態 = (顯示次數: false, 次數: 0)
}
}
} catch {
print("無法辨識圖片:\(error)")
暫停辨識 = true
鏡頭.停止錄影()
}
}
}
.onTapGesture(count: 2) {
手指軌跡 = []
}
.onTapGesture {
暫停辨識.toggle()
暫停辨識 ? 鏡頭.停止錄影() : 鏡頭.開始錄影()
}
}
} else {
Text(提示訊息)
.task {
do {
try await 鏡頭.錄影設置() // 僅需執行一次
if 鏡頭.鏡頭列表 == [] {
提示訊息 = "找不到任何鏡頭"
} else {
鏡頭.開始錄影()
}
} catch {
print("無法設定鏡頭參數: \(error)")
}
}
}
}
}
struct 虛空寫字_預覽: PreviewProvider {
static var previews: some View {
虛空寫字()
}
}
5. MyApp
import SwiftUI
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
// 虛空寫字()
Text("Hello!")
}
}
}