Swift [第5單元] 人工智慧程式基礎

課外討論:AI 的隨機性

網友 kouyoumin 提到一個很重要的問題,在人工智慧所用的機器學習或深度學習中,隨機性是必要的嗎?隨機性是人工智慧的本質嗎?

老實說,這個問題筆者雖有點想法,但也不是很清楚,因此我問了 ChatGPT,結果 ChatGPT 回答令我相當意外,以下是問答全文:

深度學習隨機性原因

User
你好,我想詢問有關深度學習的問題,你能幫助我嗎?

ChatGPT
當然!我樂意幫助您解答有關深度學習的問題。請問您想詢問什麼方面的內容?

User
我發現很多人工智慧軟體,就像你一樣,對於同樣的輸入,並不會產生相同的輸出,這是為什麼呢?

ChatGPT
這是因為像我這樣的人工智慧軟體使用的是生成式預訓練模型,這些模型基於龐大的訓練數據訓練而成。當您提供一個輸入時,模型會根據其記憶中的知識和訓練經驗生成回應。然而,由於模型的隨機性和多樣性,同樣的輸入可能導致不同的輸出。

此外,生成式模型在輸入之外還受到其他因素的影響,例如先前的對話內容、模型的初始狀態和隨機性注入等。這些因素也可能導致在相同輸入下產生不同的回應。

因此,雖然人工智慧軟體可以提供有用的回應,但它們的回答可能因多種因素而略有不同。

User
但是其他非人工智慧的軟體,都不會這樣,輸出只會根據輸入而定。為什麼機器學習需要引進隨機性呢?

ChatGPT
非人工智慧的軟體通常是根據預定義的規則和邏輯運算來處理輸入並生成輸出,因此它們的輸出確實會根據輸入而定。然而,機器學習中引入隨機性有幾個原因:

1. 模型訓練:在訓練機器學習模型時,隨機性是一種重要的元素。訓練過程中的隨機初始化、隨機梯度下降等技術有助於模型在參數空間中搜索更好的解。這種隨機性可以增加模型的多樣性和魯棒性,使其能夠應對不同的情況。

2. 不確定性建模:現實世界中的數據和情境往往充滿不確定性。機器學習引入隨機性可以幫助模型更好地處理這種不確定性。例如,蒙地卡羅方法使用隨機抽樣來近似複雜的問題,並獲得結果的概率分布。

3. 多樣性生成:在某些情況下,引入隨機性可以產生多樣性的輸出,這對於生成文本、圖像等創作性任務非常有用。隨機性可以使模型生成不同的變體,提供更多選擇和創造力。

總的來說,引入隨機性可以使機器學習模型更加靈活和適應不確定性的情況。然而,在某些應用場景中,需要確定性和可重複性的結果,可以通過調整模型和訓練參數等方式來減少隨機性的影響。

User
什麼是「魯棒性」?

ChatGPT
魯棒性是指機器學習模型對於噪聲、異常數據或不確定性的抵抗力或穩定性。具有魯棒性的模型能夠在面對未知或不符合預期的情況下仍然保持良好的性能。

一個魯棒的模型不會過度依賴於訓練數據的特定特徵或假設,而是能夠處理各種不完美的數據和環境條件。這包括對噪聲、缺失數據、異常值、分佈變化或攻擊等方面的抵抗力。

魯棒性在機器學習中非常重要,因為現實世界中的數據和應用場景往往存在不確定性和變異性。一個魯棒的模型能夠更好地泛化到新的數據,並且在面對不同情況時能夠提供穩定和可靠的預測或行為。

為了提高模型的魯棒性,可以使用技術如正規化、集成方法、數據增強、異常檢測和對抗訓練等。這些方法旨在減少模型對特定數據的依賴性,增加其泛化能力,並提高在不確定性情況下的性能。

User
魯棒性的英文是什麼?

ChatGPT
魯棒性的英文是 "Robustness"。

User
Robustness 中文應該稱為「強固性」

ChatGPT
是的,您是對的。在機器學習的上下文中,"Robustness" 的中文翻譯應該是「強固性」。謝謝您的指正!

User
感謝你的答覆,讓我對於深度學習有更進一步的瞭解。

ChatGPT
不客氣!我很高興能幫助您對深度學習有更進一步的瞭解。如果您還有其他問題,隨時都可以問我。祝您學習順利!

問答紀錄連結

💡 註解
1. 本節超出課程設定的高中程度,故列為課外討論。
5-9 App (I) 相機鏡頭

在初步了解 class 與 struct 差異之後,我們接下來將5-9a的程式包裝成完整的 class 物件,命名為「相機鏡頭」,將5-9a第1段到第3段程式碼整合為「錄影設置()」物件方法。同時改用 Swift Playgrounds 的 App 模式來撰寫,以便逐步發展成完整的 App。

App 第一個 Swift 檔案如下:
// 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) {
單幅影像 = 影像
}
}
}
}

若與5-9a比較,程式邏輯大致相同,差別只是要做成物件時,首先考量有哪些「物件屬性」(也就是變數)是別的物件會用到的。

從5-9a的測試過程,我們知道「相機鏡頭」最重要的任務就是抓出錄影的每一幀畫面,每當擷取到一幀畫面放入記憶體暫存區時,captureOut() 就會被呼叫一次,CGImage類型的「單幅影像」就會更新一次。

所以「單幅影像」不但要設為屬性,而且必須設定為可發布更新(更新時會通知外界)的變數,在 class 中要使用 @Published var 宣告可更新的變數。這時候,整個 class 必須符合 ObservableObject 規範,讓作業系統監看物件屬性是否有變化。這兩行程式碼如下:
class 相機鏡頭: NSObject, ObservableObject {
@Published var 單幅影像: CGImage? = nil
...
}


另外一行關鍵的程式碼如下,現在「相機鏡頭」本身已符合 AVCaptureVideoDataOutputSampleBufferDelegate 規範,因此第一個參數改為 “self”,由自己處理記憶體暫存區的影像:
// 5-9 App(1) 修改後的程式碼
錄影輸出.setSampleBufferDelegate(self, queue: DispatchQueue(label: "影像取樣"))

這行程式碼原本在5-9a是用另外一個物件實例「影像接收者」來處理記憶體暫存區的影像:
// 5-9a 原本的程式碼
錄影輸出.setSampleBufferDelegate(影像接收者, queue: DispatchQueue(label: "影像取樣"))

準備好 class 物件後,如何在 struct 中使用呢?其實兩種物件的用法幾乎一樣,只有一個小地方不同,若我們要在 struct 視圖物件中使用「相機鏡頭」,並且在「單幅影像」有變化時更新視圖,語法就要類似狀態變數,只不過對於 class 變數,須改用 @StateObject var。

以下 App 第2個Swift檔案,是一個 SwiftUI 典型的視圖,利用 class 物件「相機鏡頭」來獲取每幀畫面,並且用 Image 加以顯示,經過 @StateObject var 自動更新,執行結果會產生動態畫面,就如同錄影一樣:
// 5-9 App(2) 鏡頭測試
// Created by Heman, 2023/07/07
import SwiftUI

struct 鏡頭測試: View {
@StateObject var 相機 = 相機鏡頭()
@State var 提示訊息 = "等候授權及鏡頭開啟..."

var body: some View {
if let 畫面 = 相機.單幅影像 {
ZStack(alignment: .trailing) {
Image(畫面, scale: 1.0, label: Text("錄影畫面"))
.resizable()
.scaledToFit()
Image(systemName: "heart.fill")
.font(.largeTitle)
.foregroundStyle(Color.red)
.padding()
}
} else {
Text(提示訊息)
.task {
do {
try await 相機.錄影設置()
if 相機.鏡頭列表 == [] {
提示訊息 = "找不到任何鏡頭!"
} else {
相機.開始錄影()
}
} catch {
print("無法設定鏡頭參數: \(error)")
}
}
}
}
}

struct 鏡頭預覽: PreviewProvider {
static var previews: some View {
鏡頭測試()
}
}

以下影片示範在Swift Playgrounds中新增 App 模式,加入 Swift 程式檔案,還要記得賦予App使用「相機」權限。


💡 註解
  1. 在筆者 Macbook Air (2020/M1) 預設鏡頭為「FaceTime HD相機」,也就是前鏡頭。
  2. 針對 ObservableObject 規範,在新版Swift 5.9語言做了一個重要的更新,可以簡化語法,改寫成:
        import Observation

    @Observable
    class 相機鏡頭: NSObject {
    var 單幅影像: CGImage? = nil
    ...
    }

    只要在整個 class 物件加上 @Observable,裡面所有屬性自動會成為可發布更新的變數(@Published var),這將會在下個版本 Swift Playgrounds 4.4 生效。
5-9 App (II) AI即時辨識

現在我們已經學會在SwiftUI中取得相機鏡頭的每一幀畫面,接下來就可以做更多有趣的事情,例如第一課(5-1a)曾用過 Core Image框架的 CIFilter 物件,裡面包含了200多種基本濾鏡,可以套用在每一幀畫面上,輕易就能做出各種特殊效果。

不過,本單元主題是「人工智慧」,本節我們對影片的每一幀畫面套上「AI影像辨識」,例如透過臉部辨識找出特徵點,未來可進一步做成影片特效。

過去30年,科幻電影經常用到「動作捕捉」(Motion Capture)來製作逼真的特效,由真人演員穿上特製的動作捕捉服,服裝上有明顯的特徵點標示,幫助電腦處理,以替換成虛擬角色的動作,如下圖:

到如今,就可改用成熟的AI影像辨識來取得特徵點,不需要再穿特別的服裝。

在本單元第3課(5-3a, 5-3b, 5-3c)我們學過臉部、全身、手掌的特徵點辨識,這三種AI辨識都同樣傳回正規化的點座標陣列,正好可以包裝在同一個物件中:
// 5-9 App (3) 人體特徵點辨識
// Created by Heman, 2023/07/11
import Vision

struct 人體辨識器 {
var 臉部 = false
var 手掌 = false
var 全身 = false

func 取得特徵點(_ 圖片參數: CGImage) async throws -> [CGPoint] {
var 結果: [CGPoint] = []
var 請求: [VNImageBasedRequest] = []

let 臉部辨識請求 = VNDetectFaceLandmarksRequest()
let 手掌辨識請求 = VNDetectHumanHandPoseRequest()
let 姿態辨識請求 = VNDetectHumanBodyPoseRequest()
if 臉部 { 請求.append(臉部辨識請求) }
if 手掌 { 請求.append(手掌辨識請求) }
if 全身 { 請求.append(姿態辨識請求) }
let 處理者 = VNImageRequestHandler(cgImage: 圖片參數)
try 處理者.perform(請求)

if 臉部, let 處理結果 = 臉部辨識請求.results { // 不需要再寫 as? [VNFaceObservation]
for 臉部 in 處理結果 {
let 外框 = 臉部.boundingBox // 以圖片寬高比例的正規化座標
if let 五官位置 = 臉部.landmarks { // 以外框寬高比例的正規化座標
let 所有特徵點 = 五官位置.allPoints!.normalizedPoints.map { 各點 in
CGPoint( // 改成圖片寬高比例的正規化座標
x: 外框.origin.x + 各點.x * 外框.width,
y: 外框.origin.y + 各點.y * 外框.height)
}
結果 = 結果 + 所有特徵點
}
}
}

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) // 正規化座標
}
}
}
}
}

if 全身, let 處理結果 = 姿態辨識請求.results { // as? [VNHumanBodyPoseObservation]
for 軀體 in 處理結果 {
let 所有特徵點 = try 軀體.recognizedPoints(.all)
let 全身關節: [VNHumanBodyPoseObservation.JointName] = [
.leftEye, .leftEar, .leftShoulder, .leftElbow,
.leftWrist, .leftHip, .leftKnee, .leftAnkle,
.nose, .neck, .root,
.rightEye, .rightEar, .rightShoulder, .rightElbow,
.rightWrist, .rightHip, .rightKnee, .rightAnkle
]
for i in 全身關節 {
if let 特徵點 = 所有特徵點[i] {
if 特徵點.confidence > 0 {
結果.append(特徵點.location) // 正規化座標
}
}
}
}
}
if 結果.isEmpty { 結果.append(CGPoint.zero) }
return 結果
}
}

在5-3a寫過的「描繪特徵點」也同樣適用,直接拿來放入 App 中:
// 5-9 App (4) 描繪特徵點
// Revised by Heman, 2023/07/11
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: 3.0,
startAngle: .zero,
endAngle: .degrees(360),
clockwise: false)
}
圖層.fill(畫筆, with: .color(.red))
}
}
}


所以本節只需要重新寫一個視圖,將相機鏡頭以及AI影像辨識整合進來即可:
// 5-9 App (5) 錄影即時辨識
// Created by Heman, 2023/07/11
import SwiftUI

struct 錄影即時辨識: View {
@StateObject var 鏡頭 = 相機鏡頭()
@State var 點座標陣列: [CGPoint] = []
@State var 暫停辨識 = false
@State var 提示訊息 = "等候相機授權..."
let 辨識器 = 人體辨識器(臉部: true, 手掌: true)

var body: some View {
if let 畫面 = 鏡頭.單幅影像 {
VStack {
Image(畫面, scale: 1.0, label: Text("錄影畫面1"))
.resizable()
.scaledToFit()
.onTapGesture {
暫停辨識.toggle()
暫停辨識 ? 鏡頭.停止錄影() : 鏡頭.開始錄影()
}
Image(畫面, scale: 1.0, label: Text("錄影畫面2"))
.resizable()
.scaledToFit()
.opacity(0.05) // 將圖片淡化作為底圖
.overlay(描繪特徵點(正規化點陣列: 點座標陣列)) // 第4段
.border(Color.red)
.onChange(of: 鏡頭.單幅影像) { 新影像 in
// 點座標陣列 = []
Task {
do {
if let 影像 = 新影像 {
點座標陣列 = try await 辨識器.取得特徵點(影像) // 第2段
}
} catch {
print("無法辨識圖片:\(error)")
}
}
}
.onTapGesture {
暫停辨識.toggle()
暫停辨識 ? 鏡頭.停止錄影() : 鏡頭.開始錄影()
}
}
} else {
Text(提示訊息)
.task {
do {
try await 鏡頭.錄影設置()
if 鏡頭.鏡頭列表 == [] {
提示訊息 = "找不到任何鏡頭"
} else {
鏡頭.開始錄影()
}
} catch {
print("無法設定鏡頭參數: \(error)")
}
}
}
}
}

//struct 錄影即時辨識_預覽: PreviewProvider {
// static var previews: some View {
// 錄影即時辨識()
// }
//}

此範例會同時辨識影片人物的臉部與手掌特徵點,不過,辨識越多,對設備的性能要求也越高,畢竟影片可能每秒30幀畫面,也就是說,理想上必須在0.033秒內辨識完成,否則就會造成影片lag,看起來不流暢。

在筆者的Macbook Air (2020, M1)與 Mac mini (2023, M2) 上,同時執行兩種辨識還算順暢,但是在第6代 iPad (2018)或 Mac mini (2018) 上,辨識一種(如臉部)就已相當吃力。

此視圖「錄影即時辨識」會從「相機鏡頭」取得每一幀畫面,顯示到兩個 Image 圖框,上面是正常圖框,下面圖框則經過AI影像辨識畫出特徵點,背景畫面用.opacity(0.05)加以淡化。這樣就可以看到兩個畫面連動,做出相同動作。

先用預覽測試無誤,就可以替換成 App 入口:
// MyApp
import SwiftUI

@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
錄影即時辨識()
}
}
}


執行結果影片如下:


💡 註解
  1. 參考善科教育基金會「電影特效的演進」
  2. 第6代iPad尚未配備Apple研發專用於AI運算的神經引擎(Neural Engine),並不適合執行AI即時辨識。第8代(2020)以後的iPad才開始配備神經引擎,較適合執行本節範例程式。
第10課 手勢遙控 5-10 App (I)

上一課學會在SwiftUI中錄影+AI即時辨識,接下來我們就可進一步實做手勢遙控的人機互動,怎麼做呢?參考上個月剛發表的 Apple Vision Pro 互動方式,除了眼球追蹤之外,主要就是靠手勢遙控,目前已發布以下6種遙控手勢:


這6種手勢有個共同之處,就是以拇指尖與食指尖碰觸在一起,表示手勢開始,拇指食指分開則表示手勢結束。這個準備動作稱為 “Pinch”,中文意為「捏」或「拈」(通「捻」,如佛祖拈花微笑)。

上圖中,6種遙控手勢分別代表的動作如下:
(1)捏一下放開代表輕觸(Tap)
(2)捏兩下代表輕觸兩次
(3)一直捏著代表拿住(hold)
(4)捏著移動代表拖曳
(5)雙手捏著往外拉代表放大
(6)雙手捏著順時針(或逆時針)移動代表旋轉

因此,若我們要仿照這樣的手勢,第一步是偵測拇指尖與食指尖是否碰觸在一起,如何偵測呢?其實很簡單,就是計算兩個指尖特徵點的距離,小於一定門檻(範例程式中設為40點螢幕座標),就判定為「捏」。

以下修改上一節5-9(II)的特徵點辨識,只取其中的手掌辨識,不過,除了傳回特徵點座標陣列(以便繪出特徵點)之外,還需傳回拇指尖與食指尖的距離。但是,如何同時傳回兩種數值呢?

至少有兩種方法。第一種方法將兩種傳回值當作物件屬性,經過AI手掌辨識之後,更新「特徵值座標陣列」與「拇指食指距離」兩個屬性,讓視圖物件來讀取。這個方法會遇到資料衝突(data race)問題,需要用 actor 才能解決,留到下一節說明。

第二種方法比較直觀,利用第8課5-8c後面「語法說明」提到的多元組(Tuple)來當作回傳值,多元組其實是 struct 結構資料的簡化版,不需要宣告就能直接使用,例如:
func 手掌辨識() -> (點陣列: [CGPoint], 距離陣列: [Double])

傳回值 (點陣列: [CGPoint], 距離陣列: [Double]) 是個二元組,表示可以同時傳回兩種陣列,第一個「點陣列」是特徵點的座標陣列,第二個「距離陣列」是拇指尖與食指尖的距離。為什麼距離也是陣列?因為一幅影像中可能不只一隻手掌,因此就會有多個距離值,若沒有任何手掌,就傳回空陣列。

以下 5-10 App 第一個Swift檔案除了辨識手掌特徵點之外,還需要計算拇指尖(.thumbTip)與食指尖(.indexTip)的螢幕座標距離。由於AI辨識取得的特徵點座標是介於0~1.0之間的正規化座標,而我們希望回傳螢幕座標距離(較容易判別「捏」的手勢),需要將正規化座標乘以圖片的寬、高。

5-10 App (1) 程式碼列表如下:
// 5-10 App (1) 隔空手勢辨識
// Created by Heman, 2023/07/17
import Vision

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 距離
}

func 手掌辨識(_ 圖片參數: CGImage) async throws -> (點陣列: [CGPoint], 距離陣列: [Double]) {
var 辨識結果: (點陣列: [CGPoint], 距離陣列: [Double]) = ([], [])
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 拇指尖 = 所有特徵點[.thumbTip]?.location,
let 食指尖 = 所有特徵點[.indexTip]?.location {
let 拇指尖螢幕座標 = CGPoint(
x: 拇指尖.x * 圖寬,
y: 拇指尖.y * 圖高)
let 食指尖螢幕座標 = CGPoint(
x: 食指尖.x * 圖寬,
y: 食指尖.y * 圖高)
let 距離 = 兩點距離(拇指尖螢幕座標, 食指尖螢幕座標)
辨識結果.距離陣列.append(距離)
}
}
}
return 辨識結果
}

接下來,我們修改上一節「錄影即時辨識」視圖,除了顯示特徵點之外,也判斷拇指食指距離是否小於門檻(40點)。經過測試,不能一次距離小於門檻就判定為「捏」,這樣容易導致誤判,因為當手掌揮動或經過圖片邊緣時,拇指食指尖兩個特徵點會有瞬間接近重合。

因此,要確認「捏」的手勢,也必須有次數的門檻(範例中設為7次,大約0.3秒左右),也就是拇指尖與食指尖靠近(距離小於40點),連續7次(錄影的連續7幀畫面)以上才判定為「捏」。

在以下範例中,一旦偵測到「捏」的手勢,在錄影畫面右側就顯示一個紅色愛心❤️圖示,另外,為了方便觀察,上面圖框也將拇指尖與食指尖距離小於40點的確認次數顯示出來。以下是5-10 App 第2個Swift檔案的程式碼列表:
// 5-10 App (2) 手勢遙控
// Created by Heman, 2023/07/17
// Revised by Heman, 2023/07/19
import SwiftUI

struct 手勢遙控: View {
let 距離門檻: Double = 40.0 // 指尖距離(螢幕座標/點)
let 確認次數: Int = 7 // 手勢確認n次以上
@StateObject var 鏡頭 = 相機鏡頭() //5-9(1)相機鏡頭
@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 {
暫停辨識.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(alignment: .trailing) {
Image(systemName: "heart.fill")
.font(.title)
.padding()
.foregroundStyle(手勢狀態.次數 > 確認次數 ? Color.red : Color.clear)
}
.border(Color.red)
.onChange(of: 鏡頭.單幅影像) { 新影像 in
Task {
do {
if let 影像 = 新影像 {
let 辨識結果 = try await 手掌辨識(影像)
點座標陣列 = 辨識結果.點陣列
if 辨識結果.距離陣列.isEmpty {
手勢狀態 = (顯示次數: false, 次數: 0)
}
if let 較小距離 = 辨識結果.距離陣列.min(), 較小距離 < 距離門檻 {
手勢狀態.顯示次數 = true
手勢狀態.次數 += 1
} else {
手勢狀態 = (顯示次數: false, 次數: 0)
}
}
} catch {
print("無法辨識圖片:\(error)")
}
}
}
.onTapGesture {
暫停辨識.toggle()
暫停辨識 ? 鏡頭.停止錄影() : 鏡頭.開始錄影()
}
}
} else {
Text(提示訊息)
.task {
do {
try await 鏡頭.錄影設置() // 僅需執行一次
if 鏡頭.鏡頭列表 == [] {
提示訊息 = "找不到任何鏡頭"
} else {
鏡頭.開始錄影()
}
} catch {
print("無法設定鏡頭參數: \(error)")
}
}
}
}
}

struct 手勢遙控_預覽: PreviewProvider {
static var previews: some View {
手勢遙控()
}
}

這部分會用到上一節的5-9(1)相機鏡頭與5-9(4)描繪特徵點,因此本節 App 共有4個Swift檔案:

1. 5-9(1) 相機鏡頭
2. 5-9(4) 描繪特徵點
3. 5-10(1) 隔空手勢辨識
4. 5-10(2) 手勢遙控

以下影片為實際執行結果:


💡 註解
  1. 本課並不打算做出全部6種遙控手勢,限於篇幅,僅仿製「捏」的手勢即可。不過即便這樣,應該也能了解其他遙控手勢的原理,以及AI在其中所扮演的角色,這才是本課的目的。
  2. 食指拇指尖的距離門檻40點,乃參考 Apple WWDC 2020 “Detect Body and Hand Pose with Vision” 範例程式而訂。經過實測,門檻40點對於手掌距離鏡頭1米以內很合適,若超過1米應該縮小門檻,以減少誤判。
語法說明:actor
⚠️ 注意:本節為進階語法,初學者可以略過,對後面課程沒有影響。

上一節提到我們需要「特徵點辨識」函式傳回兩個陣列,方法之一是將函式建構成一個物件,物件屬性包含AI辨識後要傳回的數值。不過,這個方法會出現錯誤,需要用 actor 解決,所以本節就來了解一下什麼是 actor。

現代電腦的CPU大多具備多核心,例如2020年令筆者非常驚艷的 Apple M1 處理器,包含了8核CPU、8核GPU、16核神經引擎,非常高效又省電,價格還更便宜。可以說,多核處理器已是電腦與智慧型手機的基本配備,但軟體如何善用這些多核處理器呢?

最常用的方法就是程式設計時,儘量用「多執行緒」(multi-thread)執行,讓運算工作分散到不同CPU核心,在同一時脈下,不同核心的工作可同時運算,故又稱為「並行計算」(Concurrent Computing)。

如何設計多執行緒的程式呢?在Swift語言中,最簡單的方式就是用第3單元第7課(3-7a)介紹過的 async/await。async/await 語法是在 Swift 5.5 (2021年)引進,同時間也引進了 actor 語法。在這之前,Swift 多執行緒主要依賴 GCD (Grand Central Dispatch) 機制,就是在上一課(5-9a)用過(但沒有說明)的 DispatchQueue 物件。

actor 用來解決一個特定問題,這個問題是並行計算先天致命之疾,稱為「資料衝突」(data race),要做並行計算遲早會遇到這個問題。

資料衝突是如何發生的呢?我們借用第3單元第7課3-7b的圖稍作修改來解釋「資料衝突」如何發生,如下圖:


若不同執行緒的工作都需要使用(讀、寫)同一筆資料時,就有機會(即使機率很低)發生一個在讀取時,另一個正在寫(更新資料),甚至兩個同時要寫,這時候就會發生無法預期的結果,嚴重時甚至導致軟體閃退,這就稱為 data race(race 是賽跑、競賽,data race 是大家爭搶同一筆資料的意思)。

如何解決呢?在過去,通常需要程式設計師寫額外程式碼來解決這個問題,方法有很多種,但都有點麻煩,牽涉到一些理論,例如用獨佔鎖(lock)或互斥(mutex)等等,不管哪一種解決方案,都必須確保在同一時間只能有一個執行緒可以寫入資料。

actor 背後也有一套理論,但好處是語法簡單,背後理論及處理細節都已隱藏起來,程式設計師只要照語法用就好。

如何使用呢?我們以一個範例程式來說明,會發現非常簡單。以下有兩個物件,一個用 struct 定義,一個用 actor 定義,兩物件的功能一模一樣,裡面的非同步函式「異動」會更新兩個屬性「整數列」與「實數列」:
// Tested by Heman, 2023/07/22
struct 非同步物件 {
var 整數列: [Int] = []
var 實數列: [Double] = []

mutating func 異動(_ i: Int, _ d: Double) async {
整數列.append(i)
實數列.append(d)
}
}

actor 非同步演員 {
var 整數列: [Int] = []
var 實數列: [Double] = []

func 異動(_ i: Int, _ d: Double) async {
整數列.append(i)
實數列.append(d)
}
}

以上 struct 與 actor 兩種物件的程式碼幾乎相同,唯一的差別是 actor 的「異動」函式不需要 mutating 關鍵字,因為 actor 跟 class 一樣是參照類型(reference type),對屬性的變更沒有約束。

為什麼「異動」函式要寫成非同步(async)?因為我們希望將「異動」丟到其他執行緒(不要佔用主線),用其他核心來執行,以實現並行運算。

注意在執行異動函式時,「整數列.append(i)」牽涉到三個動作,依次為讀取(整數列)、運算(加入i)、寫入(整數列),當有兩個執行緒要同時進行異動時,就可能發生以下幾種情況:
同一時間 對「整數列」 對「整數列」 對「整數列」 對「整數列」
執行緒1 讀取 讀取 寫入 寫入
執行緒2 讀取 寫入 讀取 寫入
用 struct 結果 OK 沒事 讀取值無法預期 讀取值無法預期 App 閃退(記憶體衝突)
用 actor 結果 OK 沒事 排隊(先寫後讀 或 先寫後讀 不一定) 排隊(先寫後讀 或 先寫後讀 不一定) 排隊(一前一後)

接下來,用以下這段程式碼來驗證兩種物件執行結果:
var 資料 = 非同步物件()
var 演員 = 非同步演員()
for i in 1...5 {
Task {
await 資料.異動(i, Double(i)/10.0)
await 演員.異動(i, Double(i)/10.0)
print("資料", 資料.整數列, 資料.實數列)
await print("演員", 演員.整數列, 演員.實數列)
}
}
print("資料(初始還是結果?)", 資料.整數列, 資料.實數列

在上面 for 迴圈中,會產生5個非同步工作(參考上圖的Task-1到Task-5),兩種物件分別更新自己的物件屬性,然後將更新後的結果用 print() 輸出到主控台。

這裡可以看到 actor 與 struct 另一個差異,就是 actor 不但異動時需要等候(await),並且在讀取資料時,也必須用 await,這是萬一此時有其他執行緒正在寫入,就必須等候寫入完成才能讀取。
await print("演員", 演員.整數列, 演員.實數列)

實際執行結果如何呢?如果是一般的同步工作(不用 async/await 的情況),我們會預期輸出的結果應該是這樣:
[1] [0.1]
[1, 2] [0.1, 0.2]
[1, 2, 3] [0.1, 0.2, 0.3]
[1, 2, 3, 4] [0.1, 0.2, 0.3, 0.4]
[1, 2, 3, 4, 5] [0.1, 0.2, 0.3, 0.4, 0.5]

但實際上,非同步的執行結果如下,「資料」(struct)和「演員」(actor)的執行結果跟上面(同步執行)都不一樣:
資料(初始還是結果?) [] []
資料 [3] [0.3]
資料 [4] [0.4]
資料 [4] [0.4]
資料 [4] [0.4]
資料 [4] [0.4]
演員 [2, 1, 5, 3] [0.2, 0.1, 0.5, 0.3, 0.4]
演員 [2, 1, 5, 3, 4] [0.2, 0.1, 0.5, 0.3, 0.4]
演員 [2, 1, 5, 3, 4] [0.2, 0.1, 0.5, 0.3, 0.4]
演員 [2, 1, 5, 3, 4] [0.2, 0.1, 0.5, 0.3, 0.4]
演員 [2, 1, 5, 3, 4] [0.2, 0.1, 0.5, 0.3, 0.4]

注意執行結果的第一行,是由上面程式最後一行所列印的,為什麼會這樣呢?簡單講,是因為這行程式在「主線」上執行(參考上圖的主線),比其他執行緒優先執行。

有趣的是,整個程式每次執行也可能有不同結果,尤其是 struct 資料,表示每個執行緒讀取出來的資料無法預期:


如何解釋呢?從這個執行結果,可以觀察以下幾點:
  1. 非同步的特性是無法預期執行的先後「順序」(誰先執行、誰先結束、會不會同時),不管有沒有用 actor 都是如此(想想看,最上面非同步Task的圖該如何修正?)
  2. actor 有解決 data race 的機制,會排隊等候別人寫入完成,再輪到自己讀取或寫入,因此5筆資料都有順利加入陣列
  3. 只用 struct 並不會自動解決 data race 的問題,輸出結果完全無法預期,甚至有時導致閃退(如下圖)


Data race 是並行計算最大的障礙,而且是屬於執行時期(run time)的問題,不容易透過語法檢查來提早發覺,所以經常成為軟體的致命缺陷。

因此,actor 可說是 data race 的救星,專為並行計算而生,以解決並行計算先天之疾,讓並行計算的程式設計更簡便,不必再去考慮資料衝突的狀況,所以和 async/await 一起發表並非沒有道理。

而且,不論是 Xcode 或 Swift Playgrounds 都已加入檢查 data race 的機制(雖然不是100%能檢查到),以減少我們寫出bug的機會,這對程式設計師來說,實在是莫大福音。

總結來說,actor 可視為一種獨特物件,不像 class 或 struct 是通用物件。至於什麼時候需要用到 actor 呢?簡單地說,就是當 data race 可能發生的時候,例如物件裡面有非同步函式會更新物件屬性,這樣的物件就應該改用 actor。

附錄:5-10 App 用 actor 改寫

第1個Swift檔案修改為actor物件:
// 5-10 App (1) 手勢辨識器(用actor改寫)
// Revised by Heman, 2023/07/22
import Vision

actor 手勢辨識器 {
var 點陣列: [CGPoint] = []
var 距離陣列: [Double] = []

func 手掌辨識(_ 圖片參數: CGImage) async throws {
let 手掌辨識請求 = VNDetectHumanHandPoseRequest()
let 請求: [VNImageBasedRequest] = [手掌辨識請求]
let 處理者 = VNImageRequestHandler(cgImage: 圖片參數)
try 處理者.perform(請求)

if let 處理結果 = 手掌辨識請求.results { // as? [VNHumanHandPoseObservation]
點陣列 = [] // 改成actor後必須歸零
距離陣列 = [] // 改成actor後必須歸零
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 拇指尖 = 所有特徵點[.thumbTip]?.location,
let 食指尖 = 所有特徵點[.indexTip]?.location {
let 拇指尖螢幕座標 = CGPoint(
x: 拇指尖.x * 圖寬,
y: 拇指尖.y * 圖高)
let 食指尖螢幕座標 = CGPoint(
x: 食指尖.x * 圖寬,
y: 食指尖.y * 圖高)
let 距離 = 兩點距離(拇指尖螢幕座標, 食指尖螢幕座標)
距離陣列.append(距離)
}
}
}
}

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 距離
}
}

這種寫法的好處是不管要傳回多少數值都不成問題,通通當作物件屬性來傳遞即可;另一個好處是用物件導向的模式來思考問題、解決問題,以後擴充性比較好,例如,若要增加其他遙控手勢的辨識,用 extension 就可輕易擴展物件的功能。

相對應的,「手勢遙控」視圖中該怎麼用這個 actor 物件呢?需要修改的地方並不多,主要是讀取的地方要加上 await,算一算總共5行而已:
  1. 增加一行「@State var 手勢 = 手勢辨識器()」
  2. 原來「let 辨識結果 = try await 手掌辨識(影像)」改成「try await 手勢.手掌辨識(影像)」
  3. 原來「點座標陣列 = 辨識結果.點陣列」改成「點座標陣列 = await 手勢.點陣列」
  4. 原來「if 辨識結果.距離陣列.isEmpty」改成 「if await 手勢.距離陣列.isEmpty」
  5. 原來「if let 較小距離 = 辨識結果.距離陣列.min()」改成「if let 較小距離 = await 手勢.距離陣列.min()」

第2個Swift 檔案修改後的程式碼如下:
// 5-10 App (2) 手勢遙控
// Created by Heman, 2023/07/17
// Revised by Heman, 2023/07/19
import SwiftUI

struct 手勢遙控: View {
let 距離門檻: Double = 40.0 // 指尖距離(螢幕座標/點)
let 確認次數: Int = 7 // 手勢確認n次以上
@StateObject var 鏡頭 = 相機鏡頭() //5-9(1)相機鏡頭
@State var 點座標陣列: [CGPoint] = []
@State var 暫停辨識 = false
@State var 提示訊息 = "等候相機授權..."
@State var 手勢狀態: (顯示次數: Bool, 次數: Int) = (false, 0)
@State var 手勢 = 手勢辨識器()

var body: some View {
if let 畫面 = 鏡頭.單幅影像 {
VStack {
ZStack(alignment: .trailing) {
Image(畫面, scale: 1.0, label: Text("錄影畫面1"))
.resizable()
.scaledToFit()
.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(alignment: .trailing) {
Image(systemName: "heart.fill")
.font(.title)
.padding()
.foregroundStyle(手勢狀態.次數 > 確認次數 ? Color.red : Color.clear)
}
.border(Color.red)
.onChange(of: 鏡頭.單幅影像) { 新影像 in
Task {
do {
if let 影像 = 新影像 {
// let 辨識結果 = try await 手掌辨識(影像)
try await 手勢.手掌辨識(影像)
點座標陣列 = await 手勢.點陣列
if await 手勢.距離陣列.isEmpty {
手勢狀態 = (顯示次數: false, 次數: 0)
}
if let 較小距離 = await 手勢.距離陣列.min(), 較小距離 < 距離門檻 {
手勢狀態.顯示次數 = true
手勢狀態.次數 += 1
} else {
手勢狀態 = (顯示次數: false, 次數: 0)
}
}
} catch {
print("無法辨識圖片:\(error)")
}
}
}
.onTapGesture {
暫停辨識.toggle()
暫停辨識 ? 鏡頭.停止錄影() : 鏡頭.開始錄影()
}
}
} else {
Text(提示訊息)
.task {
do {
try await 鏡頭.錄影設置() // 僅需執行一次
if 鏡頭.鏡頭列表 == [] {
提示訊息 = "找不到任何鏡頭"
} else {
鏡頭.開始錄影()
}
} catch {
print("無法設定鏡頭參數: \(error)")
}
}
}
}
}

struct 手勢遙控_預覽: PreviewProvider {
static var previews: some View {
手勢遙控()
}
}

執行結果與上一節並無差別:
5-10 App (II) 虛空寫字

終於進入本單元尾聲,最後一節延續前面的基礎,決定來寫個「虛空寫字」App。

中文有句成語:「書空咄咄」,典故出於晉書,魏晉南北朝時期晉人殷浩被黜免,終日以手指向空中書寫「咄咄怪事」四字。想像一下殷浩是如何以手指向空中書寫,用什麼手勢呢?

上一節「手勢遙控」用的是捏或拈的手勢,用拈花指寫字似乎太秀氣,不符合殷浩義憤填膺的感受。不過還有另一種手勢,中國古代劍客用單手持劍(不同於西方用雙手握劍),另一手則捏成「劍訣」 — 食指、中指伸直併攏,其餘三指扣住,這個手勢很適合用來虛空寫字。

用AI辨識「劍訣」手勢不成問題,仿照上一節做法,只要計算食指尖、中指尖距離小於一定門檻即可。再進一步考慮寫字時並非都連筆字,所以還要考慮提筆、落筆兩種手勢,將食指中指併攏設為落筆(寫字),食指中指分開(成 Y 或 V 形)則為提筆(移動)。

先看看最後成果,用「劍訣」手勢對著鏡頭在空中寫一個「永」字,如下圖:


有沒有注意到上圖兩個圖框左右相反?因為不這樣的話,對著鏡頭寫出來的字就會左右相反。如何將圖框左右翻轉過來呢?透過SwiftUI非常簡單,只要一行程式碼,在第4單元第7課4-7d「仿射變換」曾介紹過,對x座標乘以”-1”倍即可。
Image(畫面, scale: 1.0, label: Text("錄影畫面2"))
...
.scaleEffect(x: -1.0, y: 1.0)

另外還有一個小改變,手掌除了畫特徵點之外,特徵點之間的線段也連起來,這樣比較容易看出手勢。5隻手指各有4個線段(匯集到腕關節),因此,我們需要在手掌辨識時,取得這些線段,以便用畫布(Canvas)畫出來。

這時候,手掌辨識()函式傳回值若再用多元組(Tuple)就不合適了,改用 struct 組個簡單的資料結構「手掌辨識結果」:
struct 手掌辨識結果 {
var 點陣列: [CGPoint] = [] // 手掌21個特徵點
var 食中指尖距離: [Double] = [] // 劍訣:食指、中指併攏(距離 < 40)
var 線段陣列: [[CGPoint]] = [] // 手掌骨架(5組線段)
var 筆尖: CGPoint = .zero // 虛空寫字
}

最後一欄「筆尖」的座標位置會等於食指尖與中指尖的中間點,當開始寫字時(形成劍訣手勢),將「筆尖」經過的座標記錄下來,直到手指分開才停止紀錄。這樣收集「手指軌跡」的座標陣列,再把軌跡中每一點畫出來,就寫成一個字了。

如果要重寫,就在螢幕點兩下。雖然這個動作應該改成手勢搖控,但為了避免程式碼過於冗長,還是直接用 onTapGesture 比較簡單,輕點一下可暫停錄影,點兩下可清除手寫軌跡。

所以從下面影片可以看到,一開始用右手寫字,但是寫完之後需要用右手點螢幕,所以最後一個畫面反而看到左手。

具體的程式碼,可以參考底下附錄「5-10(II) App 程式碼」。

執行過程影片如下:


💡 註解
  1. 為什麼挑「永」呢?因為永字的筆畫包含書法的八種基本筆法,稱為「永字八法」,相傳是東晉王羲之(與殷浩同時期)所定。
  2. 中國用劍的歷史超過2千年,春秋戰國已使用青銅劍,例如1965年出土的「越王勾踐劍」。李白「俠客行」也寫到劍:「十步殺一人,千里不留行。事了拂衣去,深藏身與名。閒過信陵飲,脫劍膝前橫。將炙啖朱亥,持觴勸侯嬴。」
  3. 因為篇幅限制(希望App程式在300-500行之間),本節尚有許多拓展空間,例如:
    • 暫停錄影(輕點一下)以及清除畫面(輕點兩下)能否改用手勢遙控?
    • 手寫軌跡是否可改用線段(而不是點)連接起來?
    • 如何將手寫出來的字透過第1課5-1c「文字辨識」轉換爲 Text 文字?
  4. 挑戰題:參考上一節 actor 語法,用 actor 改寫手勢辨識器,將「struct 手掌辨識結果」與「func 手掌辨識」結合為一個 actor 物件。
  5. 虛空寫字一開始可能不習慣,需要練習一下手勢運用,寫的時候不可移動過快。手掌與鏡頭的距離也會影響寫字效果,不要太靠近相機。
  6. 熟悉虛空寫字之後,當然也可以畫畫,練習畫個簡單的圖案,並觀察這樣的人機互動有什麼優缺點。
附錄 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!")
}
}
}
第5單元結語

本單元從2月初到現在,足足寫了6個月,是寫得最久的一個單元,不過也符合原先預期,因為人工智慧程式挑戰就是比較大,市面已出版的書或網路文章還沒有用 SwiftUI 寫如此完整的 AI 入門程式,更別說完全用 Swift Playgrounds 來開發,其中有許多需要自行摸索的地方。

當然,這些內容只是入門,人工智慧題目太大,範圍很廣,一個單元不可能全部涵蓋,因此在課程設計上主要依循 Apple 原廠現有框架,從視覺 Vision、語音辨識 Speech,到自然語言 NaturalLanguage,對初學者來說,能夠上手是最重要的原則。

相對的,有關人工智慧的理論或背後演算法就較少著墨,例如關於機器學習或訓練模型,所幸這在市面上有大量書籍及網路文章可補充,若有興趣的同學,可以深入研究。

有些媒體提到未來可能有50%的程式設計師會被 ChatGPT 取代,現在要學的不是程式設計,而是運算思維以及 AI 詠唱(所謂的 “Prompt Engineering”)。筆者看法是:「這是一種錯覺」,反而因為 ChatGPT 的出現,才更需要學習程式設計。

這就好比過去 Google 搜尋什麼知識都可以找到,是不是代表就不需要學習或背誦知識了呢?事實上,如果腦袋裡面沒有良好的知識基礎,就無法梳理問題的脈絡,不知道最合適的關鍵字,只會亂搜一通,而且對於搜尋出來的結果也無法判斷好壞對錯。

就同儕比較而言,腦中儲備知識越豐富的人,就越能善用搜尋引擎等網路工具;同樣的,對於程式設計越了解,就越能發揮 ChatGPT 人工智慧的能力。在學習程式設計過程中會發現所謂「魔鬼藏在細節中」,很多時候,一個關鍵字或一個參數就能影響整個程式的品質,而這類細節以及對品質好壞的判斷,人類會比人工智慧表現得更好。

因此,人類對於科技應該掌握主控權,古人說:「役物,而不役於物」,對於越來越強大的網路、手機、人工智慧等科技,更應該如此。
雪白西丘斯
第6單元 AR擴增實境與空間運算 https://www.mobile01.com/topicdetail.php?f=482&t=6924180
AI視覺:修正照片方向

第2課(5-2a)人臉辨識最後面提到一個辨識錯誤的例子(下圖左),原本以為是戴帽子或墨鏡的關係,後來發現未必如此,其他正常照片也有類似情況(下圖右)。經過一番研究與測試,確認是跟拍照時的手機方向有關。

當手機以不同方向拍照時,例如豎拍、橫拍或左、右手持,數位相機的感光晶片會以固定的順序(與手機方向無關),將像素資料寫入照片檔,並同時紀錄手機方向 — 這類非像素資料稱為後設資料 meta data,包含方向、日期、時間、相機參數、GPS定位…等,標準格式為 EXIF。

也就是說,若將手機轉180°拍照時,原始照片應該是上下顛倒,但因為照片檔包含手機方向,大部分軟體會自動旋轉,以顯示正確照片。

至於為什麼第2課的人臉辨識對有些照片會出現錯誤呢?這是因為 Vision 套件的影像辨識用的是較舊的 CGImage 或 CIImage,例如 VNImageRequestHandler(cgImage: 影像),CGImage 和 CIImage 只會讀取原始照片,並不會根據照片方向自動修正;較新的 Image (SwiftUI) 或 UIImage (UIKit) 才會根據照片方向自動修正。

所以嚴格說來,Vision 影像辨識並沒有出錯,只是標示的位置是在原始照片(CGImage)上,我們轉成 UIImage 的時候,必須自行加以修正。

UIImage 有個屬性 .imageOrientation 可以獲知照片方向,共有8種方向(列舉值):
1. .up: 正常方向(右手橫拍,鏡頭在左邊)
2. .down: 手機轉180°(左手橫拍,鏡頭在右邊)
3. .right: 手機右(順時針)轉90° — 豎拍,鏡頭在上
4. .left: 手機左(逆時針)轉90° — 豎拍,鏡頭在下
5. .upMirrored: 左右鏡像(自拍)
6. .downMirrored: 手機轉180°+鏡像(自拍)
7. .rightMirrored: 手機右轉90°+鏡像(自拍)
8. .leftMirrored: 手機左轉90°+鏡像(自拍)

iPhone 的「設定」中,「相機」可以設前鏡頭自拍時,是否要「前置相機鏡像翻轉」,預設是要。利用這個設定,可以8種方向各拍一張自拍照,用來測試,以下是程式修正前後,對8個方向的照片測試結果。
方向 修正前 修正後
.up



.down



.right



.left



.upMirrored



.downMirrored



.rightMirrored



.leftMirrored




從上面對比可以看出,原先的5-2a人臉辨識程式只有對正常(Up)的照片,才能畫出正確的外框,只要方向一轉動,外框的位置就不對。

要修正照片方向的問題,並沒有簡單的方法,筆者費了近一星期時間才完全解決。原先的5-2a程式碼有135行,修正後共250行,多出將近一倍,修正的段落是「描繪外框」,增加了100多行。

修正細節就不多談了,基本原理就是先在原始照片(CGImage)位置上畫出外框,再根據照片方向,用仿射變換(參考第4單元4-7d)旋轉到正確位置。

修正後完整的5-2a程式碼如下:
// 5-2a Face Detection (boundingBox)
// Created by Heman, 2023/02/20
// Updated by Heman, 2024/12/25 修正照片方向
import SwiftUI
import PhotosUI
import Vision

// 第1段
func 人臉偵測(_ 圖片參數: UIImage) async throws -> [CGRect] {
var 結果: [CGRect] = []
let 工作請求 = VNDetectFaceRectanglesRequest()
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results as? [VNFaceObservation] {
print("處理結果:\(處理結果)")
for 矩形 in 處理結果 {
結果.append(矩形.boundingBox)
}
}
}
if 結果.isEmpty { 結果.append(CGRect.zero) }
print("回傳結果:\(結果)")
return 結果
}

// 第2段
// Updated by Heman, 2024/12/24. 重新改寫
struct 相簿單選: View {
@State var 單選: PhotosPickerItem?
@Binding var 圖片: UIImage?
var body: some View {
if 圖片 == nil {
PhotosPicker(selection: $單選) {
VStack {
Image(systemName: "barcode.viewfinder")
.resizable()
.scaledToFit()
Text("請點選條碼照片")
.font(.title)
}
}
.photosPickerStyle(.inline)
.photosPickerAccessoryVisibility(.hidden, edges: .leading)
.onChange(of: 單選) { 選擇結果 in
Task {
do {
if let 原始資料 = try await 選擇結果?.loadTransferable(type: Data.self) {
if let 轉換圖片 = UIImage(data: 原始資料) {
圖片 = 轉換圖片
}
}
} catch {
print("無法取得或轉換照片: \(error)")
圖片 = nil
}
}
}
} else {
Image(uiImage: 圖片!)
.resizable()
.scaledToFit()
}
}
}

// 第3段:主視圖
struct 臉部掃描: View {
@State var 顯示字串 = ""
@State var 外框陣列: [CGRect] = []
@State var 相簿圖片: UIImage? = nil
var body: some View {
if 相簿圖片 == nil {
相簿單選(圖片: $相簿圖片)
} else {
ZStack(alignment: .bottom) {
Image(uiImage: 相簿圖片!)
.resizable()
.scaledToFit()
.border(Color.secondary)
.opacity(0.5)
.overlay(描繪外框(正規化圖框: 外框陣列, 圖片方向: 相簿圖片!.imageOrientation))
.task {
do {
外框陣列 = try await 人臉偵測(相簿圖片!)
for i in 外框陣列 {
let 字串 = """
-----臉部外框(正規化)-----
外框左下角:\(i.origin)
外框寬:\(i.width)
外框高:\(i.height)\n
"""
顯示字串.append(字串)
}
print(顯示字串)
} catch {
print("掃描發生錯誤:\(error)")
}
}
.onTapGesture {
相簿圖片 = nil
外框陣列 = []
顯示字串 = ""
}
// Text(顯示字串)
// .foregroundColor(.cyan)
// .background(Color.white.opacity(0.5))
// .padding()
}
}
}
}

// 2024/12/25 增加第6段
func 轉為字串(_ 方向: UIImage.Orientation) -> String {
var 字串: String = ""

switch 方向 {
case .up: 字串 = "Up (正常)"
case .upMirrored: 字串 = "UpMirrored (左右鏡像)"
case .down: 字串 = "Down (轉180°)"
case .downMirrored: 字串 = "DownMirrored (轉180°+鏡像)"
case .left: 字串 = "Left (左轉90°)"
case .leftMirrored: 字串 = "LeftMirrored (左轉90°+鏡像)"
case .right: 字串 = "Right (右轉90°)"
case .rightMirrored: 字串 = "RightMirrored (右轉90°+鏡像)"
default: 字串 = "未知"
}
return 字串
}

// 2024/12/25 增加第7段
func 修正方向(_ 方向: UIImage.Orientation) -> CGAffineTransform {
var 變換矩陣: CGAffineTransform = .identity

switch 方向 {
case .up:
變換矩陣 = .identity
case .upMirrored:
變換矩陣 = CGAffineTransform( // 左右(Y軸)鏡像
a: -1, b: 0,
c: 0, d: 1,
tx: 0, ty: 0)
case .down:
變換矩陣 = CGAffineTransform( // 往右(順時針)轉180°
a: -1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
case .downMirrored:
變換矩陣 = CGAffineTransform( // 水平(X軸)鏡像
a: 1, b: 0,
c: 0, d: -1,
tx: 0, ty: 0)
case .left:
變換矩陣 = CGAffineTransform( // 往左(逆時針)轉90°
a: 0, b: -1,
c: 1, d: 0,
tx: 0, ty: 0)
case .leftMirrored:
變換矩陣 = CGAffineTransform( // 左轉+鏡像
a: -1, b: 0,
c: 0, d: 1,
tx: 0, ty: 0).concatenating(CGAffineTransform(
a: 0, b: -1,
c: 1, d: 0,
tx: 0, ty: 0))
case .right:
變換矩陣 = CGAffineTransform( // 往右(順時針)轉90°
a: 0, b: 1,
c: -1, d: 0,
tx: 0, ty: 0)
case .rightMirrored:
變換矩陣 = CGAffineTransform( // 右轉+鏡像
a: -1, b: 0,
c: 0, d: 1,
tx: 0, ty: 0).concatenating(CGAffineTransform(
a: 0, b: 1,
c: -1, d: 0,
tx: 0, ty: 0))
default: 變換矩陣 = .identity
}

return 變換矩陣
}

// 第4段
// 2024/12/25 修正圖片方向
struct 描繪外框: View {
let 正規化圖框: [CGRect]
let 圖片方向: UIImage.Orientation

var body: some View {
ZStack(alignment: .bottom) {
Canvas { 圖層, 尺寸 in
print(尺寸)
let 圖寬 = 尺寸.width
let 圖高 = 尺寸.height
let 中心 = CGPoint(x: 圖寬 * 0.5, y: 圖高 * 0.5)
let 左右轉向: [UIImage.Orientation] = [.right, .left, .rightMirrored, .leftMirrored]

var 畫筆 = Path()
for 外框 in 正規化圖框 {
if 左右轉向.contains(圖片方向) { // 若轉90°,寬高交換
let 畫框 = CGRect(
x: 圖高 * 外框.minX,
y: 圖寬 * (1.0 - 外框.minY - 外框.height),
width: 圖高 * 外框.width,
height: 圖寬 * 外框.height)
畫筆.addRect(畫框)
} else { // 若轉180°,寬高不變
let 畫框 = CGRect(
x: 圖寬 * 外框.minX,
y: 圖高 * (1.0 - 外框.minY - 外框.height),
width: 圖寬 * 外框.width,
height: 圖高 * 外框.height)
畫筆.addRect(畫框)
}
}
if 左右轉向.contains(圖片方向) { // 若轉90°,寬高交換
let 中心位移 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: 1,
tx: -中心.y, ty: -中心.x)
let 中心復位 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: 1,
tx: 中心.x, ty: 中心.y)
圖層.transform = 中心位移.concatenating(修正方向(圖片方向)).concatenating(中心復位)
} else { // 若轉180°,寬高不變
let 中心位移 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: 1,
tx: -中心.x, ty: -中心.y)
let 中心復位 = CGAffineTransform(
a: 1, b: 0,
c: 0, d: 1,
tx: 中心.x, ty: 中心.y)
圖層.transform = 中心位移.concatenating(修正方向(圖片方向)).concatenating(中心復位)
}
圖層.stroke(畫筆, with: .color(.red), lineWidth: 3)
}
Text("照片方向: \(轉為字串(圖片方向))")
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(臉部掃描())

附帶一提,新增的兩個函式(第6段、第7段):

func 轉為字串(_ 方向: UIImage.Orientation) -> String
func 修正方向(_ 方向: UIImage.Orientation) -> CGAffineTransform

完全是給「描繪外框」用的,其實可以放在 struct 描繪外框: View { } 裡頭,當作物件方法,這樣才符合物件導向程式設計的精神,全域函式或全域變數應該越少越好。不過這樣一來,「描繪外框」會變得很冗長,初學者較不易閱讀。

有興趣的讀者,可以自行將這兩個函式改寫成「描繪外框」的物件方法,這時就不需要參數「方向」,直接讀取屬性「圖片方向」即可,雖然修改幅度不大,但對於初學者可是一大挑戰。

另一種更棒的改法是用 extension UIImage.Orientation { },將「轉為字串()」與「修正方向()」改寫成 UIImage.Orientation 的物件方法(同樣不需要參數),好處是方便用在其他物件(例如第3課5-3a的「描繪特徵點」),顯得更有彈性。

💡註解
  1. 為什麼自拍時預設會產出鏡像(左右翻轉)照片?自拍就像在照鏡子一樣,這樣看螢幕畫面比較自然。但筆者通常將「前置相機鏡像翻轉」設定關閉,產出照片才不會左右翻轉。
  2. 不想用仿射變換的話,用視圖的旋轉 .rotationEffect() 與鏡像 .scaleEffect(),應該也可以達成同樣效果。
  3. 原本的5-2a程式中,「相簿單選」在macOS 15無法選擇(在 iPadOS 18正常),一併在此修正。參考第4單元第11課說明。
文章分享
評分
評分
複製連結

今日熱門文章 網友點擊推薦!