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

5-3b 身體姿態辨識

上一節我們學會辨識臉部細節,取得76個特徵點,據此可做出身分識別或臉部表情相關應用。本節我們將學習辨識全身肢體動作,需要多少特徵點呢?其實只要19個特徵點,就能判別一個人舉手投足或站坐蹲臥等姿態,甚至進一步分析運動員或健身教練的動作。

全身19個特徵點如下圖所示,我們身體是左右對稱,左右兩側分別有眼、耳、肩、肘、腕、臀、膝、踝等8個部位,身體中線只取鼻、頸、臍等3個位置,合計19個特徵點,如下圖。

圖片來源:Apple原廠文件

辨識身體姿態的第1段程式如同前一節,將工作請求換成 VNDetectHumanBodyPoseRequest,關鍵字Pose 是姿勢、姿態的意思。程式碼如下,函式將回傳特徵點的正規化座標 [CGPoint]:
import Vision

// 第1段
func 身體姿態辨識(_ 圖片參數: UIImage) async throws -> [CGPoint] {
var 結果: [CGPoint] = []
let 工作請求 = VNDetectHumanBodyPoseRequest()
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results { // as? [VNHumanBodyPoseObservation]
print("處理結果:\(處理結果)")
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) }
print("回傳結果:\(結果)")
return 結果
}

注意這裡如何取得所有特徵點的座標,與上一節稍有不同,當我們執行:
let 所有特徵點 = try 軀體.recognizedPoints(.all)

並不是直接取得所有特徵點的座標陣列,而是一個特殊的資料結構,稱為字典(dictionary),字典和陣列類似,差別在於字典可用任何資料類型當索引,若不了解沒關係,可參考下一節語法說明。

在此段函式中,辨識結果的字典是以19個特徵點的名稱當索引,例如,用「所有辨識點[.leftEye]」可取得左眼特徵點的資料(裡面包含 confidence 信心度與 location 點座標)。

因此,我們將19個特徵點名稱全部列出來,然後逐一當做索引,即可取得身體所有特徵點資料,如果該點信心度 confidence > 0,表示資料有效,就將其正規化座標 location 加入結果陣列中回傳,這段程式碼如下:
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) // 正規化座標
}
}
}

實際執行發現,有些特徵點被擋住或在畫面之外,信心度就會下降,若完全看不到也無法推測,信心度就變成0,這些點就不必回傳。

寫好第1段之後,基本就算完工了,後面4段均可沿用上一節,執行結果如下圖(圖片來源網址請參考附註),相當有趣:



完整程式碼如下:
// 5-3b Human Body Pose Detection
// Created by Heman, 2023/03/06
import SwiftUI
import PhotosUI
import Vision

// 第1段
func 身體姿態辨識(_ 圖片參數: UIImage) async throws -> [CGPoint] {
var 結果: [CGPoint] = []
let 工作請求 = VNDetectHumanBodyPoseRequest()
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results { // as? [VNHumanBodyPoseObservation]
print("處理結果:\(處理結果)")
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) // 正規化座標
} else {
print(i, 特徵點.confidence, 特徵點.location)
}
}
}
}
}
}
if 結果.isEmpty { 結果.append(CGPoint.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 點座標陣列: [CGPoint] = []
@State var 相簿圖片: UIImage? = nil
var body: some View {
網址抓圖(圖片: $相簿圖片) // 第5段
.onChange(of: 相簿圖片) { 新圖片 in
點座標陣列 = []
Task {
do {
點座標陣列 = try await 身體姿態辨識(新圖片 ?? UIImage()) // 第1段
} catch {
print("無法辨識圖片:\(error)")
}
}
}
Spacer()
if 相簿圖片 == nil {
相簿單選(圖片: $相簿圖片) // 第2段
} else {
ZStack() {
Image(uiImage: 相簿圖片!)
.resizable()
.scaledToFit()
.border(Color.secondary)
.opacity(0.5) // 將圖片淡化作為底圖
.overlay(描繪特徵點(正規化點陣列: 點座標陣列)) // 第4段
.onTapGesture {
相簿圖片 = nil
點座標陣列 = []
}
if 點座標陣列.isEmpty {
ProgressView()
.scaleEffect(2.5)
}
}
}
Spacer()
}
}

// 第4段
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))
}
}
}

// 第5段
struct 網址抓圖: View {
@Binding var 圖片: UIImage?
@State var 網址: String = ""
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray.opacity(0.5))
.frame(height: 50)
HStack {
Image(systemName: "photo.fill")
.font(.system(size: 24))
TextField("輸入圖片網址", text: $網址, prompt: Text("https://"))
.font(.system(size: 20))
.background(Color.white)
.textFieldStyle(.roundedBorder)
.cornerRadius(5.0)
.onChange(of: 網址) { 新網址 in
Task {
if let myURL = URL(string: 新網址) {
let (原始資料, _) = try await URLSession.shared.data(from: myURL)
if let 格式轉換 = UIImage(data: 原始資料) {
圖片 = 格式轉換
} else {
print("非圖片網址,請重新輸入。")
}
} else {
print("網址格式錯誤,請重新輸入。")
}
}
}
}
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(照片掃描())


💡 註解
  1. 本節範例圖片網址:
    • https://images.unsplash.com/photo-1561688862-18158b0df99f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=776&q=80

    • https://images.unsplash.com/photo-1515886657613-9f3515b0c78f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1080&q=80

  2. 此處回傳的特徵點,左右方向與人物左右一致,不像上一節與人物相反。
  3. 電影拍攝常用一種稱為「動作捕捉(Motion Capture)」技術,來達到辨認姿態動作的效果,讓動畫特效更逼真,演員常須穿上特製的「動作捕捉服」,或是在臉上塗上黑點(標示特徵點)。
語法說明:字典(Dictionary)資料類型

Swift 程式語言中,有三種資料合輯的類型:陣列(Array)、集合(Set)、字典(Dictionary),所謂資料合輯(Collection)是指許多同類型的資料放在一起,當做一個變數或常數來處理。陣列是其中最基本也最重要的,相信在過去課程中,已看過陣列與for迴圈結合所產生的強大威力。

陣列、集合、字典在語法上非常類似,都用方括號 [ ] 來表示資料合輯,個別資料稱為「元素(Element)」,元素之間以逗號(,)分隔。三者異同比較如下表:
- 陣列(Array) 集合(Set) 字典(Dictionary)
與for迴圈配合使用
元素須同類型資料
元素屬性 一元(unary) 一元(unary) 二元(binary) key: value
元素次序
以索引存取元素
索引方式 整數(如 0, 1, 2,…) 可自訂 鍵(key)
元素能否重複 不可 鍵(key)不可,值(value)可
範例 [95, 34, 95, 22] [”Aa”, “BB”] [”膚色”: ”Black”, “血型”: “O”]
類型定義方式 [類型], 如 [String] Set<類型> [類型1: 類型2], 如 [String: Int]
空值 [] 空陣列 [] 空集合 [:] 空字典

陣列是有次序的資料合輯,應用範圍最廣;字典(dictionary)最常用在資料查詢;集合用途較少。

陣列與字典最大區別如下:一般陣列通常以整數當索引,如「陣列[0]」是第1個元素、「陣列[1]」是第2個元素…;字典則是任何資料類型都可當索引,例如「字典[”膚色”]」取得字典中與 “膚色” 對應的值、「字典[”血型”]」取得與 “血型” 對應的值,有點像在查字典的意思。

我們先來看一小段字典的範例程式:
// Swift dictionary sample code
// Created by Heman, 2023/03/06

let 價格表: [String: Int] = [
"可樂": 25,
"煎餅": 30,
"花生": 15
]
print(價格表["煎餅"]) // (1)會輸出什麼值?
print(價格表["蚵仔煎"]) // (2)會產生錯誤嗎?
print(價格表.count) // (3)多少個元素?

字典的元素語法是不是跟之前學過的JSON很像?沒錯,兩者是有關聯的。

字典就像一個兩欄資料表,第一欄稱為資料鍵,簡稱「鍵(key)」,第二欄稱為資料值,簡稱「值(value)」,兩者成對以冒號(:)隔開,用鍵可以立刻找到對應的值,對查詢搜索很方便。

JSON 內容同樣是鍵與值的配對(key-value pair),常當做網路資料庫的交換格式。可不要小看鍵-值配對的結構,任何複雜的資料庫其實都可以轉化成鍵-值配對的結構!

以上範例執行結果,第一行會列印出 Optional(30),而不是 30 — 特別注意,用字典查詢的值一定都是 Optional,因為隨時都可能查詢不到。若查詢不到,並不會產生錯誤,而是傳回 nil(如第二行print()),表示無此資料 — 只要鍵的內容符合資料類型,語法上就會被接受。第三行輸出3,表示有3個元素。

我們再舉第2個範例,在第4單元第12課學習圖表(Chart)時,曾用一位高中生的成績單當資料來繪製圖表:
struct 科目 {
var 名稱: String
var 成績: Double
}

let 第一次平時試驗: [科目] = [
科目(名稱: "修身", 成績: 95),
科目(名稱: "體育", 成績: 65),
科目(名稱: "國文", 成績: 90),
科目(名稱: "日文", 成績: 80),
科目(名稱: "英文", 成績: 95),
科目(名稱: "解析幾何", 成績: 60),
科目(名稱: "物理", 成績: 55),
科目(名稱: "歷史", 成績: 87),
科目(名稱: "地理", 成績: 90),
科目(名稱: "勞作", 成績: 75),
科目(名稱: "圖畫", 成績: 60),
科目(名稱: "音樂", 成績: 90),
科目(名稱: "習字", 成績: 75)
]

上面「科目」資料結構只有兩欄,正適合改寫成字典,不但顯得更簡潔,也不必定義新的資料類型:
// --- 用 dictionary 的寫法
let 我的成績單: [String: Int] = [
"修身": 95,
"體育": 65,
"國文": 90,
"日文": 80,
"英文": 95,
"解析幾何": 60,
"物理": 55,
"歷史": 87,
"地理": 90,
"勞作": 75,
"圖畫": 60,
"音樂": 90,
"習字": 75
]
print(我的成績單["英文"])

用此例來比較陣列與字典,會發現陣列有一個弱點,若要用程式找出”英文”成績,陣列需要用for迴圈一個一個找,相當麻煩,但是用字典就方便多了,特別是當內容很大(如100萬筆)時,字典查詢的強處就更顯突出。

相對的,字典也有一個弱點,就是「鍵(key)」的內容不能重複,如果有100萬筆資料,就必須有100萬個不同的鍵,這時「鍵」就必須有一定的編碼規則。例如,我們寫一個 Unicode 的字典 — [Int: String],鍵(key)是整數編碼(可用16進位表示),確保不重複;值(value)則是對應的Unicode字元。

字典同樣可以用for迴圈將鍵與值全部抓出來,有兩種方法:
// 方法一:
for (科目, 成績) in 我的成績單 {
print(科目, 成績) // 成績值不是 Optional
}

// 方法二:
for 科目 in 我的成績單.keys {
print(科目, 我的成績單[科目]) // 成績值是 Optional
}

如果不需要科目(鍵),只要成績(值),可以寫成:
for 成績 in 我的成績單.values {
print(成績) // 成績值不是 Optional
}

其實就是透過「字典.keys」或「字典.values」,將字典(兩欄)轉成鍵或值的資料陣列(單欄)。

字典的鍵(key)不一定是字串,任何資料類型或物件都可以當鍵;字典的值(value)也不一定是數值,任何資料類型或物件都可以當值。只要在定義時 [類型1: 類型2] 指定好即可。

在上一節5-3b範例程式中,VNDetectHumanBodyPoseRequest 辨識結果是一個字典,鍵的類型是 VNHumanBodyPoseObservation.JointName(關節名稱)。值的類型是 VNRecognizedPoint,裡面包含 confidence 與 location 兩個屬性。

第1段辨識函式只需字典的值(以回傳正規化座標),所以最內層for迴圈可簡化改寫如下:
// 5-3b 第1段
let 所有特徵點 = try 軀體.recognizedPoints(.all)
for 特徵點 in 所有特徵點.values {
if 特徵點.confidence > 0 {
結果.append(特徵點.location)
}
}

💡 註解
  1. 想像你走進銀行的保險庫,有許多保險箱,只有拿到正確鑰匙(key)才能打開保險箱取出裡面的珍寶(value),當然,每個保險箱的鑰匙必須不一樣,否則就不保險了。字典像不像一個保險庫?
  2. 陣列、集合、字典都可指定為空(empty)值,但必須將元素的類型定義清楚,如下:
    // Empty array/set/dictionary
    let 空陣列: [String] = []
    let 空集合: Set<String> = []
    let 空字典: [Int: String] = [:]

  3. 陣列索引預設是從0開始的正整數,但也允許別的類型,只要有前後次序、能找出下一個元素即可。
5-3c 手掌手勢辨識

雙手是人類最靈巧的部位,也是與其他哺乳動物最大區別之一,即使在現代社會,雙手仍舊扮演非常重要的角色。在電影「鋼鐵人」中,相信大家都看過主角東尼·史塔克與人工智慧 Jarvis 互動,並以手勢操作電腦的畫面,令人印象深刻。在學過本課之後會發現,用手勢操作機器的未來,似乎離我們並不是很遠。

人的雙手有多少特徵點呢?我們每根指頭有3個關節,十指共有30個關節,再加上指尖與腕關節,雙手共計42個特徵點。

要辨識手掌的手勢,工作請求只須改用 VNDetectHumanHandPoseRequest,傳回的特徵點與上一節類似,同樣是字典類型,字典的「鍵」要用到手指關節名稱,如下圖:


手掌所有特徵點的索引鍵如下,每隻手掌有21個特徵點:
let 手掌關節: [VNHumanHandPoseObservation.JointName] = [
.thumbTip, .thumbIP, .thumbMP, .thumbCMC, // 拇指
.indexTip, .indexDIP, .indexPIP, .indexMCP, // 食指
.middleTip, .middleDIP, .middlePIP, .middleMCP, // 中指
.ringTip, .ringDIP, .ringPIP, .ringMCP, // 無名指
.littleTip, .littleDIP, .littlePIP, .littleMCP, // 小指
.wrist // 腕關節
]

因此,第1段程式可改寫如下,同樣會回傳正規化的點座標陣列:
import Vision

// 第1段
func 手掌細部辨識(_ 圖片參數: UIImage) async throws -> [CGPoint] {
var 結果: [CGPoint] = []
let 工作請求 = VNDetectHumanHandPoseRequest()
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results { // as? [VNHumanHandPoseObservation]
print("處理結果:\(處理結果)")
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 結果.isEmpty { 結果.append(CGPoint.zero) }
print("回傳結果:\(結果)")
return 結果
}

工作請求VNDetectHumanHandPoseRequest預設最多辨識兩隻手,若超過兩手,須修改工作請求的屬性,加一行程式:
工作請求.maximumHandCount = 5

參考下圖執行結果:


完整程式碼如下:
// 5-3c Human Hand Pose Detection
// Created by Heman, 2023/03/10
import SwiftUI
import PhotosUI
import Vision

// 第1段
func 手掌細部辨識(_ 圖片參數: UIImage) async throws -> [CGPoint] {
var 結果: [CGPoint] = []
let 工作請求 = VNDetectHumanHandPoseRequest()
// 工作請求.maximumHandCount = 5
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results { // as? [VNHumanHandPoseObservation]
print("處理結果:\(處理結果)")
for 手掌 in 處理結果 {
let 所有特徵點 = try 手掌.recognizedPoints(.all) // 字典(Dictionary)類型
// for 特徵點 in 所有特徵點.values {
// if 特徵點.confidence > 0 {
// 結果.append(特徵點.location)
// }
// }
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 結果.isEmpty { 結果.append(CGPoint.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 點座標陣列: [CGPoint] = []
@State var 相簿圖片: UIImage? = nil
var body: some View {
網址抓圖(圖片: $相簿圖片) // 第5段
.onChange(of: 相簿圖片) { 新圖片 in
點座標陣列 = []
Task {
do {
點座標陣列 = try await 手掌細部辨識(新圖片 ?? UIImage()) // 第1段
} catch {
print("無法辨識圖片:\(error)")
}
}
}
Spacer()
if 相簿圖片 == nil {
相簿單選(圖片: $相簿圖片) // 第2段
} else {
ZStack() {
Image(uiImage: 相簿圖片!)
.resizable()
.scaledToFit()
.border(Color.secondary)
.opacity(0.5) // 將圖片淡化作為底圖
.overlay(描繪特徵點(正規化點陣列: 點座標陣列)) // 第4段
.onTapGesture {
相簿圖片 = nil
點座標陣列 = []
}
if 點座標陣列.isEmpty {
ProgressView()
.scaleEffect(2.5)
}
}
}
Spacer()
}
}

// 第4段
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))
}
}
}

// 第5段
struct 網址抓圖: View {
@Binding var 圖片: UIImage?
@State var 網址: String = ""
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray.opacity(0.5))
.frame(height: 50)
HStack {
Image(systemName: "photo.fill")
.font(.system(size: 24))
TextField("輸入圖片網址", text: $網址, prompt: Text("https://"))
.font(.system(size: 20))
.background(Color.white)
.textFieldStyle(.roundedBorder)
.cornerRadius(5.0)
.onChange(of: 網址) { 新網址 in
Task {
if let myURL = URL(string: 新網址) {
let (原始資料, _) = try await URLSession.shared.data(from: myURL)
if let 格式轉換 = UIImage(data: 原始資料) {
圖片 = 格式轉換
} else {
print("非圖片網址,請重新輸入。")
}
} else {
print("網址格式錯誤,請重新輸入。")
}
}
}
}
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(照片掃描())

💡 註解
  1. 本節範例圖片網址如下:
    • https://images.unsplash.com/photo-1556848527-f7c548b972b2?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2070&q=80
    • https://images.unsplash.com/photo-1556484687-30636164638b?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1548&q=80

  2. 手勢控制最著名的設備廠商是 Leap Motion (現更名為Ultraleap),若以手勢控制配合 擴增實境或虛擬實境(AR/VR)效果非常好玩,例如這個應用 Cat Explorer
  3. 手指特徵點的名稱中,Tip 不是縮寫,而是細長物體末端(指尖、筆尖)的意思,小費的英文也是 tip。
第4課 影像分類

本單元一開始提過,人工智慧軟體的目標是希望賦予機器「人的智慧」,而人的智慧從視覺獲得的最多,因此,AI視覺除了要辨識最重要的人臉與手勢之外,也要認識世間萬物,才能妥善為人類服務 — 這就是影像分類的目的。

影像分類(Image Classification)在電腦視覺或整個AI中,可能是應用最廣泛的技術,在實際生活中影像分類比人臉辨識更常用,不管是停車場的車牌辨識、工廠裏的瑕疵檢測、醫療影像協助診斷、農業病蟲害防治、無人車自動駕駛…等,在各行各業幾乎都能找到影像分類的應用場景。

但世界上萬事萬物那麼多,不管是天然的,還是人造的,AI 不可能一下子全部認得,要從哪些東西學起呢?我們先來看看 Apple 原廠教 AI 認識哪些東西。

5-4a 內建的基本分類(VNClassifyImageRequest)

用 Swift 寫AI影像分類的程式相當簡單,如前兩課一樣,只要將工作請求改用 VNClassifyImageRequest 即可,這會根據 Apple 內建的分類去判讀影像。不過,影像分類較難理解之處並非程式語法,而是執行結果。怎麼說呢?

我們先來看一個範例,才能進一步理解影像分類在做什麼。下圖取自Unsplash網站(來源網址請參考註解),你看到什麼呢?在這張照片中,AI 影像分類共找出15類物品:


程式實際執行結果是這樣(類別名稱只有英文):


這些分類跟你心裡所想的一樣嗎?一般人看到這張圖片,大概會說「在倒茶」或「茶壺」,不過 AI 影像分類並不是解釋圖片內容或動作,而是像一個初生嬰兒,試圖去認識周遭世界。

影像分類會與已知分類相比較,設法辨認影像中所有物品,標示可能有哪些物品類別(及機率)。在此例中,影像分類結果包括廚具(84.8%)、炊具(80.4%)、茶壺(80.4%)、餐具(64.3%)…等15類,我們心裡所想的「茶壺」,只排名第3。

原來,用VNClassifyImageRequest工作請求所得到的分類,是比對 Apple 原廠預先訓練好的物品集(稱為資料模型 Data Model),目前共包含1300多種分類,以樹狀結構安排大類、小類,如廚具(大類)包含炊具、餐具、茶壺…等。

所以,影像分類就像自動幫照片加上「#標籤」,替我們解讀照片內容,依照預先訓練的物品種類加以分門別類,這樣以後要找出某些內容的照片,就方便多了。

再看另外兩個執行結果,以下這張相當溫馨的室內照片分類結果有20個標籤(類別):


注意影像分類與視覺焦點無關,上圖我最喜歡的,是那張給人溫暖感覺的橘色沙發椅(排名第9,信心度才31.1%),而不是信心度99.4%的植物,也就是說,影像分類並非以重要性排序,而是正確率,這與人看東西的角度不一樣。

再看一張傍晚的城市街道(美國舊金山),分類結果如下:


戶外、道路、燈光…這些分類客觀上都非常正確,但這張照片是經典的舊金山街景,其中最吸引人的是遠方舊金山連接奧克蘭的海灣大橋,反而沒有辨識出來(或信心度太低)。

從這些執行範例可以看出,VNClassifyImageRequest 能夠辨識大部分常見的物品,例如室內家居用品、戶外城市街道、食衣住行、大自然景色…等,基本上Apple 的策略是以人為中心,先教AI認識人的周邊常出現的物品類別,這個策略相當好。

但要如何應用在實際場景呢?老實說,這個內建的影像分類並不容易應用,因為辨識出來的類別雖多,不確定的機率也高,想想看我們使用搜尋引擎的經驗,若給出太多不確定的結果,反而要花時間一一過濾,並非好事。

儘管如此,這是最簡單的影像分類方式(有現成的物品分類),程式寫起來非常容易,將第1段改寫如下,類似寫法已重複多次,應該駕輕就熟了:
import Vision

struct 物品信心度 {
let 名稱: String
let 信心度: Float
}

// 第1段
func 影像分類(_ 圖片參數: UIImage) async throws -> [物品信心度] {
var 回傳結果: [物品信心度] = []
let 工作請求 = VNClassifyImageRequest()
// let 物品清單 = try 工作請求.supportedIdentifiers()
// print(物品清單.count)
if let 圖像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 圖像)
try 處理者.perform([工作請求])
if let 分類結果 = 工作請求.results { // as? [VNClassificationObservation]
for i in 分類結果 {
if i.confidence > 0.1 {
let 物品 = 物品信心度(名稱: i.identifier, 信心度: i.confidence)
回傳結果.append(物品)
}
}
}
}
if 回傳結果.isEmpty { 回傳結果.append(物品信心度(名稱: "", 信心度: 0.0)) }
print("回傳結果:\(回傳結果)")
return 回傳結果
}

由於影像分類的處理結果,有兩個屬性需要回傳:(1) identifier 物品類別名稱 (2) confidence 信心度,因此,我們先定義一個資料類型「物品信心度」,容納這兩欄屬性(名稱、信心度)。

其實不管輸入任何圖片,VNClassifyImageRequest 的處理結果都會產生同樣1300多個標籤(分類名稱),只是每個標籤的信心度不同而已(大部分會等於0),所以我們必須篩選信心度大於一定機率(如0.1)者,組成 [物品信心度] 陣列回傳。

接下來,第4段原本畫出特徵點,在這裡改為輸出字串陣列,其中信心度用一個函式改成 % 百分比格式(如原始資料 0.914320222 轉換為 “91.4%”):
// 第4段
struct 列出物品: View {
let 陣列: [物品信心度]
var body: some View {
VStack(alignment: .leading) {
ForEach(陣列.indices, id: \.self) { i in
Text("\(i + 1). \(陣列[i].名稱) (\(百分比(陣列[i].信心度)))")
}
}
.padding()
}
func 百分比(_ f: Float) -> String {
let f2 = f * 100.0
let p = String(format: "%.1f", f2)
return p + "%"
}
}

最後合併成完整程式即可:
// 5-4a Image Classification
// Created by Heman, 2023/03/12
// Ref. WWDC 2019 "Understanding Images in Vision Framework"
import SwiftUI
import PhotosUI
import Vision

struct 物品信心度 {
let 名稱: String
let 信心度: Float
}

// 第1段
func 影像分類(_ 圖片參數: UIImage) async throws -> [物品信心度] {
var 回傳結果: [物品信心度] = []
let 工作請求 = VNClassifyImageRequest()
// let 物品清單 = try 工作請求.supportedIdentifiers()
// print(物品清單.count)
if let 圖像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 圖像)
try 處理者.perform([工作請求])
if let 分類結果 = 工作請求.results { // as? [VNClassificationObservation]
for i in 分類結果 {
if i.confidence > 0.1 {
let 物品 = 物品信心度(名稱: i.identifier, 信心度: i.confidence)
回傳結果.append(物品)
}
}
}
}
if 回傳結果.isEmpty { 回傳結果.append(物品信心度(名稱: "", 信心度: 0.0)) }
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 相簿圖片: UIImage? = nil
var body: some View {
網址抓圖(圖片: $相簿圖片) // 第5段
.onChange(of: 相簿圖片) { 新圖片 in
物品列表 = []
Task {
do {
物品列表 = try await 影像分類(新圖片 ?? UIImage()) // 第1段
} catch {
print("無法辨識圖片:\(error)")
}
}
}
Spacer()
if 相簿圖片 == nil {
相簿單選(圖片: $相簿圖片) // 第2段
} else {
ZStack() {
Image(uiImage: 相簿圖片!)
.resizable()
.scaledToFit()
.border(Color.secondary)
.opacity(0.5) // 將圖片淡化作為底圖
.overlay(列出物品(陣列: 物品列表), alignment: .topLeading) // 第4段
.onTapGesture {
相簿圖片 = nil
物品列表 = []
}
if 物品列表.isEmpty {
ProgressView()
.scaleEffect(2.5)
}
}
}
Spacer()
}
}

// 第4段
struct 列出物品: View {
let 陣列: [物品信心度]
var 項次: Int = 0
var body: some View {
VStack(alignment: .leading) {
ForEach(陣列.indices, id: \.self) { i in
Text("\(i + 1). \(陣列[i].名稱) (\(百分比(陣列[i].信心度)))")
}
}
.padding()
}
func 百分比(_ f: Float) -> String {
let f2 = f * 100.0
let p = String(format: "%.1f", f2)
return p + "%"
}
}

// 第5段
struct 網址抓圖: View {
@Binding var 圖片: UIImage?
@State var 網址: String = ""
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray.opacity(0.5))
.frame(height: 50)
HStack {
Image(systemName: "photo.fill")
.font(.system(size: 24))
TextField("輸入圖片網址", text: $網址, prompt: Text("https://"))
.font(.system(size: 20))
.background(Color.white)
.textFieldStyle(.roundedBorder)
.cornerRadius(5.0)
.onChange(of: 網址) { 新網址 in
Task {
if let myURL = URL(string: 新網址) {
let (原始資料, _) = try await URLSession.shared.data(from: myURL)
if let 格式轉換 = UIImage(data: 原始資料) {
圖片 = 格式轉換
} else {
print("非圖片網址,請重新輸入。")
}
} else {
print("網址格式錯誤,請重新輸入。")
}
}
}
}
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(照片掃描())

執行結果會將標籤字串加註在照片左上角(以 .overlay() 對齊 .topLeading):


本節是常用物品的基本分類,採用 Apple 制定的1300種分類,好處是已在所有 Apple 產品內建這些分類,省去很多麻煩,辨識正確率也相當高,缺點則是無法客製化(如增加新類別或中文化)。

若想將影像分類應用到實際場景,通常須先確定影像類型以及想要什麼分類結果,例如停車場要辨認車牌的英文及數字、工廠想確認製品是否有裂紋、醫療影像(如X光片、超音波或MRI)想診斷有沒有疾病癥狀、海洋生物學家想標示每隻海豚…等,一旦確定分類結果,再去收集各類影像樣本,最後經由機器學習軟體產出資料模型,這樣才能讓影像分類的結果,符合實際需求。

不過,壞消息是製作資料模型有點麻煩,但好消息是有現成的第三方資料模型可用。下一節待續。

💡 註解
  1. 本節範例圖片網址
    • https://images.unsplash.com/photo-1675232348914-95f62960f4f9?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80
    • https://images.unsplash.com/photo-1618220179428-22790b461013?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=654&q=80
    • https://images.unsplash.com/photo-1494257610566-28280a243b22?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80

  2. 影像分類和影像辨識有何區別呢?舉例來說,有天你跟朋友在大安森林公園散步,看到一隻顏色豔麗的小鳥飛過,你說:「哇,那是五色鳥」,這是影像分類;你朋友說:「這隻我看過,尾羽受了傷」,這是影像辨識。
  3. 在日常應用中,影像分類和影像辨識並沒有嚴格區別,兩個名詞經常混用,影像分類只是辨識到較大的類別,而影像辨識也可說是分類到每個個體。
  4. 在AI專業領域中,電腦視覺相關技術大致分類如下:
    • 影像分類(Classification):比對各類已知物品的機率
    • 影像偵測(Detection):偵測是否有某種物品(框出 Bounding Box)
    • 影像區隔(Segmentation):找出物品的輪廓(不定形)
    • 影像辨識(Recognition):找出特定實例(特徵)

  5. 若想動手玩機器學習,試試自己做影像分類(或聲音分類),可參考 Google 的 Teachable Machine (網頁版),相當好玩。
5-4b 什麼是AI模型?

從上一節了解到,影像分類若要符合各行各業的需求,必須想辦法訓練自己的的資料模型,資料模型相當於 AI 背後的知識庫,若模型有欠缺,目前的 AI 是無法自行推理補足的。

所謂訓練自己的資料模型,以影像分類為例,是指自己去蒐集相關的圖片資料合輯(稱為 data set),人工加註標籤或分類,再使用機器學習(machine learning)軟體,萃取出資料的類型特徵,最後的產出就稱為AI的資料模型(data model)。

是否一定得自己訓練才能獲得資料模型呢?也未必,最近幾年機器學習的發展非常迅速,網路已有不少前人訓練好的模型開放出來,可以從 Model Zoo 或類似的網站下載。

不過還有個問題,網路上 AI 模型的檔案格式並不一致,Apple 目前使用的格式稱為 Core ML (副檔名為 .mlmodel,ml 是 machine learning 的縮寫),而網路上很多的是 Google 的 TensorFlow 或用於 Python 的 PyTorch 格式,模型下載後須轉換為 .mlmodel 才能使用。

Apple 官網已有一些轉換為 .mlmodel 格式的第三方模型,其中 MobileNetV2 是 Google 開放出來的影像分類模型,輕薄短小只有24.7MB,很適合手機使用,本節就利用這個來練習一下。


在 Swift Playgrounds 裡面如何使用 .mlmodel ?網路上似乎缺乏這種案例,經筆者一番嘗試終於成功,須經過兩個前置處理:(1) 下載模型 (2) 編譯模型,才能用於影像分類的工作請求。

我們先寫兩個函式進行前置處理,第一個「下載模型」使用檔案管理員(FileManager)物件,將下載的 .mlmodel 模型暫存到檔案裡面。
import Foundation

// 第6段
func 下載模型(_ 網址: String) async throws -> URL? {
if let myURL = URL(string: 網址) {
let 檔案名稱 = myURL.lastPathComponent
let 檔案管理員 = FileManager()
if let 目錄 = 檔案管理員.urls(
for: .cachesDirectory,
in: .userDomainMask).first {
let 存檔路徑 = 目錄.path + "/" + 檔案名稱
print("下載目標:\(myURL)\n存檔標的:\(存檔路徑)")
if 檔案管理員.fileExists(atPath: 存檔路徑) {
print("檔案已下載過")
} else {
let (原始資料, 錯誤碼) = try await URLSession.shared.data(from: myURL)
print("下載成功:\(原始資料)", 錯誤碼)
_ = 檔案管理員.createFile(atPath: 存檔路徑, contents: 原始資料)
}
let 回傳值 = 目錄.appendingPathComponent(檔案名稱)
print("回傳模型位址:\(回傳值)")
return 回傳值
}
}
return nil
}

這個函式的輸入參數為模型網址,如:
https://ml-assets.apple.com/coreml/models/Image/ImageClassification/MobileNetV2/MobileNetV2.mlmodel

將此網址最後一部分(.lastPathComponent)抓出來指定為「檔案名稱」,此時檔案名稱為 “MobileNetV2.mlmodel”。

接下來取得檔案管理員(FileManager)物件,指定「目錄」為使用者的 .cacheDirectory,這是每個 App 的暫存目錄(App 關閉後一段時間會自動清除),用來存放模型檔案:
let 檔案管理員 = FileManager()
if let 目錄 = 檔案管理員.urls(
for: .cachesDirectory,
in: .userDomainMask).first {...}

這是我們課程中第一次用到檔案管理員(FileManager)物件,其實使用上非常簡單,就跟平常的檔案與目錄操作類似,只是滑鼠操作改為程式呼叫。

接下來「目錄 + 檔案名稱」就是「存檔路徑」,如果檔案不存在(之前未下載過),就用 URLSession 下載檔案,再用檔案管理員.createFile() 儲存起來,並回傳存檔路徑的 URL:
let 存檔路徑 = 目錄.path + "/" + 檔案名稱
if 檔案管理員.fileExists(atPath: 存檔路徑) {
print("檔案已下載過")
} else {
let (原始資料, 錯誤碼) = try await URLSession.shared.data(from: myURL)
print("下載成功:\(原始資料)", 錯誤碼)
_ = 檔案管理員.createFile(atPath: 存檔路徑, contents: 原始資料)
}
let 回傳值 = 目錄.appendingPathComponent(檔案名稱)

第二個函式「編譯模型」也是類似的過程,只是將下載(URLSession)的動作改成編譯(MLModel.compileModel),模型編譯需要先導入 CoreML 框架,裡面的物件名稱大多以 ML 開頭,如 MLModel。
import Foundation
import CoreML

// 第7段
func 編譯模型(_ 模型位址: URL) async throws -> URL? {
let 檔名 = 模型位址.lastPathComponent + "c"
let 編譯目錄 = 模型位址.deletingLastPathComponent()
let 存檔位址 = 編譯目錄.appendingPathComponent(檔名)
print("編譯目錄:\(編譯目錄)\n存檔位址:\(存檔位址)")
let 檔案管理員 = FileManager()
if 檔案管理員.fileExists(atPath: 存檔位址.path) {
print("模型已編譯過")
} else {
let 編譯檔 = try await MLModel.compileModel(at: 模型位址)
print("編譯成功:\(編譯檔)")
try 檔案管理員.moveItem(at: 編譯檔, to: 存檔位址)
}
return 存檔位址
}

最後同樣回傳編譯過的存檔位址(URL),注意這個 URL 是在本機的檔案目錄中(才能導入 MLModel)。

到此就可以跟之前一樣使用這個外來的模型,有什麼不一樣呢?先看看第1段修改後的程式:
import Vision
import CoreML

struct 物品信心度 {
let 名稱: String
let 信心度: Float
}

// 第1段
func 影像分類v2(_ 圖片參數: UIImage, 模型參數: URL) async throws -> [物品信心度] {
var 回傳結果: [物品信心度] = []
let 影像模型 = try MLModel(contentsOf: 模型參數)
print(影像模型.modelDescription)
let 工作請求 = VNCoreMLRequest(model: try VNCoreMLModel(for: 影像模型))
print(工作請求)
if let 圖像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 圖像)
try 處理者.perform([工作請求])
if let 分類結果 = 工作請求.results as? [VNClassificationObservation] {
// print(分類結果)
for i in 分類結果 {
if i.confidence > 0.05 {
let 物品 = 物品信心度(名稱: i.identifier, 信心度: i.confidence)
回傳結果.append(物品)
}
}
}
}
if 回傳結果.isEmpty { 回傳結果.append(物品信心度(名稱: "", 信心度: 0.0)) }
print("回傳結果:\(回傳結果)")
return 回傳結果
}

「影像分類v2」多一個 URL 參數,用來承接「編譯模型()」所回傳的結果,將此 URL 參數傳入 MLModel() 轉為模型物件,再將此模型物件傳給 VNCoreMLRequest() 產出工作請求:
let 影像模型 = try MLModel(contentsOf: 模型參數) 
let 工作請求 = VNCoreMLRequest(model: try VNCoreMLModel(for: 影像模型))

也就是說,使用第三方(外來或客製)模型,最大的區別是工作請求須改用 VNCoreMLRequest(),處理者同樣是 VNImageRequestHandler,其他地方幾乎沒有兩樣。

到此,工作請求與處理者的關係,我們又進一步了解,第三方模型是送入工作請求(而不是處理者),所以之前的工作請求背後都是有預設模型,影像分類有影像分類的預設模型、臉部辨識有臉部辨識的預設模型、文字辨識有文字辨識的模型…等等,如下圖:


最後,只需要在第3段稍加修改,將新寫的兩個函式(第6段、第7段)整合進來即可,因為下載與編譯只須做一次,所以放在 .task 裡面,而影像分類則在每次圖片更新後都要用到,故放在 .onChange 裡面:
// 第3段
struct 照片掃描: View {
@State var 相簿圖片: UIImage?
@State var 物品列表: [物品信心度] = []
@State var 已編譯模型: URL?
let 模型網址 = "https://ml-assets.apple.com/coreml/models/Image/ImageClassification/MobileNetV2/MobileNetV2.mlmodel"

var body: some View {
網址抓圖(圖片: $相簿圖片) // 第5段
.task {
do {
if let 模型檔 = try await 下載模型(模型網址) { // 第6段
if let 編譯檔 = try await 編譯模型(模型檔) { // 第7段
已編譯模型 = 編譯檔
}
}
} catch {
print("無法辨識圖片:\(error)")
}
}
.onChange(of: 相簿圖片) { 新圖片 in
物品列表 = []
Task {
do {
if 已編譯模型 != nil && 新圖片 != nil {
物品列表 = try await 影像分類v2(新圖片!, 模型參數: 已編譯模型!) // 第1段
} else {
print("模型尚未編譯完成")
}
} catch {
print("無法辨識圖片:\(error)")
}
}
}
...
}

最後也將第3段主視圖與標籤文字的外觀稍加修改,執行結果如下:


圖片中的食物辨識出甜椒(bell pepper)、小黃瓜(cucumber)、櫛瓜(zucchini)、橡果南瓜(acorn squash),只有甜椒和櫛瓜正確,另外馬鈴薯、紅蘿蔔、甜菜、番茄沒有辨認出來。

最後完整的程式碼已超過200行,共分為7段:
// 5-4b AI資料模型(MobileNetV2 + CoreML)
// Created by Heman, 2023/03/17
import SwiftUI
import PhotosUI
import Vision
import CoreML

struct 物品信心度 {
let 名稱: String
let 信心度: Float
}

// 第1段
func 影像分類v2(_ 圖片參數: UIImage, 模型參數: URL) async throws -> [物品信心度] {
var 回傳結果: [物品信心度] = []
let 影像模型 = try MLModel(contentsOf: 模型參數)
print(影像模型.modelDescription)
let 工作請求 = VNCoreMLRequest(model: try VNCoreMLModel(for: 影像模型))
print(工作請求)
if let 圖像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 圖像)
try 處理者.perform([工作請求])
if let 分類結果 = 工作請求.results as? [VNClassificationObservation] {
// print(分類結果)
for i in 分類結果 {
if i.confidence > 0.05 {
let 物品 = 物品信心度(名稱: i.identifier, 信心度: i.confidence)
回傳結果.append(物品)
}
}
}
}
if 回傳結果.isEmpty { 回傳結果.append(物品信心度(名稱: "", 信心度: 0.0)) }
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 相簿圖片: UIImage?
@State var 物品列表: [物品信心度] = []
@State var 已編譯模型: URL?
let 模型網址 = "https://ml-assets.apple.com/coreml/models/Image/ImageClassification/MobileNetV2/MobileNetV2.mlmodel"

var body: some View {
網址抓圖(圖片: $相簿圖片) // 第5段
.task {
do {
if let 模型檔 = try await 下載模型(模型網址) { // 第6段
if let 編譯檔 = try await 編譯模型(模型檔) { // 第7段
已編譯模型 = 編譯檔
}
}
} catch {
print("無法辨識圖片:\(error)")
}
}
.onChange(of: 相簿圖片) { 新圖片 in
物品列表 = []
Task {
do {
if 已編譯模型 != nil && 新圖片 != nil {
物品列表 = try await 影像分類v2(新圖片!, 模型參數: 已編譯模型!) // 第1段
} else {
print("模型尚未編譯完成")
}
} catch {
print("無法辨識圖片:\(error)")
}
}
}
Spacer()
if 相簿圖片 == nil {
相簿單選(圖片: $相簿圖片) // 第2段
} else {
ZStack {
Image(uiImage: 相簿圖片!)
.resizable()
.scaledToFit()
// .border(Color.secondary)
// .opacity(0.5) // 將圖片淡化作為底圖
.overlay(列出物品(陣列: 物品列表), alignment: .bottomLeading) // 第4段
.onTapGesture {
相簿圖片 = nil
物品列表 = []
}
if 物品列表.isEmpty || 已編譯模型 == nil {
ProgressView()
.scaleEffect(2.5)
}
}
}
Spacer()
}
}

// 第4段
struct 列出物品: View {
let 陣列: [物品信心度]
var 項次: Int = 0
var body: some View {
VStack(alignment: .leading) {
ForEach(陣列.indices, id: \.self) { i in
Text("\(i + 1). \(陣列[i].名稱) (\(百分比(陣列[i].信心度)))")
.foregroundColor(.white)
.shadow(color: .indigo, radius: 3, x: .zero, y: .zero)
}
}
.padding()
}
func 百分比(_ f: Float) -> String {
let f2 = f * 100.0
let p = String(format: "%.1f", f2)
return p + "%"
}
}

// 第5段
struct 網址抓圖: View {
@Binding var 圖片: UIImage?
@State var 網址: String = ""
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray.opacity(0.5))
.frame(height: 50)
HStack {
Image(systemName: "photo.fill")
.font(.system(size: 24))
TextField("輸入圖片網址", text: $網址, prompt: Text("https://"))
.font(.system(size: 20))
.background(Color.white)
.textFieldStyle(.roundedBorder)
.cornerRadius(5.0)
.onChange(of: 網址) { 新網址 in
Task {
if let myURL = URL(string: 新網址) {
let (原始資料, _) = try await URLSession.shared.data(from: myURL)
if let 格式轉換 = UIImage(data: 原始資料) {
圖片 = 格式轉換
} else {
print("非圖片網址,請重新輸入。")
}
} else {
print("網址格式錯誤,請重新輸入。")
}
}
}
}
.padding()
}
}
}

// 第6段
func 下載模型(_ 網址: String) async throws -> URL? {
if let myURL = URL(string: 網址) {
let 檔案名稱 = myURL.lastPathComponent
let 檔案管理員 = FileManager()
if let 目錄 = 檔案管理員.urls(
for: .cachesDirectory,
in: .userDomainMask).first {
let 存檔路徑 = 目錄.path + "/" + 檔案名稱
print("下載目標:\(myURL)\n存檔標的:\(存檔路徑)")
if 檔案管理員.fileExists(atPath: 存檔路徑) {
print("檔案已下載過")
} else {
let (原始資料, 錯誤碼) = try await URLSession.shared.data(from: myURL)
print("下載成功:\(原始資料)", 錯誤碼)
_ = 檔案管理員.createFile(atPath: 存檔路徑, contents: 原始資料)
}
let 回傳值 = 目錄.appendingPathComponent(檔案名稱)
print("回傳模型位址:\(回傳值)")
return 回傳值
}
}
return nil
}

// 第7段
func 編譯模型(_ 模型位址: URL) async throws -> URL? {
let 檔名 = 模型位址.lastPathComponent + "c"
let 編譯目錄 = 模型位址.deletingLastPathComponent()
let 存檔位址 = 編譯目錄.appendingPathComponent(檔名)
print("編譯目錄:\(編譯目錄)\n存檔位址:\(存檔位址)")
let 檔案管理員 = FileManager()
if 檔案管理員.fileExists(atPath: 存檔位址.path) {
print("模型已編譯過")
} else {
let 編譯檔 = try await MLModel.compileModel(at: 模型位址)
print("編譯成功:\(編譯檔)")
try 檔案管理員.moveItem(at: 編譯檔, to: 存檔位址)
}
return 存檔位址
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(照片掃描())

💡 註解
  1. 開放的 AI 資料模型集中於若干網站,例如:
  2. Apple 提供若干已轉換成 Core ML (.mlmodel) 格式的第三方模型 https://developer.apple.com/machine-learning/models/
  3. 為什麼需要編譯模型?和程式語言的編譯類似,模型編譯的目的也是將模型裡面一些文字敘述轉換成二進位,以加快 App 讀取。
  4. 本節範例圖片網址
    • https://images.unsplash.com/photo-1579113800032-c38bd7635818?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80
    • https://images.unsplash.com/photo-1542696971-bedf437263f6?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=687&q=80
  5. 使用現成的AI模型,正確率似乎也不理想,怎麼才能提高正確率呢?
5-4c 第三方模型(YOLOv3)

Apple 官網上還有另一個著名的 AI 模型 — YOLOv3,同樣可以用於影像分類,相對於 MobileNetV2 有1001種分類,YOLOv3 只有80種分類(參考下表),但比較特別的是,YOLOv3 除了分類(加上標籤)之外,還能同時找出物品的外框位置。


YOLOv3 的影像分類包含以下80種類別,比較偏向都會生活常見物品:
大類 小類
人、動物 1. person 人物, 2. cow 牛, 3. dog 狗, 4. sheep 羊, 5. zebra 斑馬, 20. bird 鳥類, 21. cat 貓, 22. horse 馬, 23. elephant 大象, 24. bear 熊, 25. giraffe 長頸鹿
室內及日用品 6. chair 椅子, 26. backpack 背包, 27. umbrella 雨傘, 28. handbag 手提包, 29. tie 領帶, 30. suitcase 公事包, 58. sofa 沙發, 59. pottedplant 盆栽, 60. bed 床, 61. diningtable 餐桌, 62. toilet 馬桶, 63. tvmonitor 電視機, 64. laptop 筆記型電腦, 65. mouse 滑鼠, 66. remote 遙控器, 67. keyboard 鍵盤, 68. cell phone 手機, 74. book 書, 75. clock 時鐘, 76. vase 花瓶, 77. scissors 剪刀, 78. teddy bear 泰迪熊, 79. hair drier 吹風機, 80. toothbrush 牙刷
交通 7. bicycle 自行車, 8. car 汽車, 9. motorbike 摩托車, 10. aeroplane 飛機, 11. bus 巴士, 12. train 火車, 13. truck 卡車, 14. boat 船, 15. traffic light 交通號誌, 16. fire hydrant 消防栓, 17. stop sign 停止號誌, 18. parking meter 停車計時器, 19. bench 長椅
運動 31. frisbee 飛盤, 32. skis 滑雪板(雙板), 33. snowboard 滑雪板(單板), 34. sports ball 球類運動, 35. kite 風箏, 36. baseball bat 球棒, 37. baseball glove 棒球手套, 38. skateboard 滑板, 39. surfboard 衝浪板, 40. tennis racket 網球拍
廚房餐飲食物 41. bottle 瓶子, 42. wine glass 酒杯, 43. cup 杯子, 44. fork 叉子, 45. knife 刀子, 46. spoon 湯匙, 47. bowl 碗, 48. banana 香蕉, 49. apple 蘋果, 50. sandwich 三明治, 51. orange 橘子, 52. broccoli 花椰菜, 53. carrot 紅蘿蔔, 54. hot dog 熱狗, 55. pizza 披薩, 56. donut 甜甜圈, 57. cake 蛋糕, 69. microwave 微波爐, 70. oven 爐子, 71. toaster 烤麵包機, 72. sink 水槽, 73. refrigerator 冰箱

由於 YOLOv3 擅長分類的物品以室內(客廳、廚房)或街道(建築、交通)為主,所以我們找一張廚房及一張街道照片作為範例,執行結果如下:

廚房辨識出爐子(oven)、微波爐(microwave)、水槽(sink),位置正確。

街道圖辨識出3個人物(person)和近處5輛汽車(car)。

可以看出,YOLOv3 辨識相當準確,感覺很實用,第一張廚房的情境未來可用在居家照護機器人;第二張似乎可應用於自動駕駛無人車。而這些,或許都會出現在 Apple 將推出的 AR 眼鏡上。

當然,如果種類多一點就更理想了,不過 YOLOv3 類別雖只有80種,但模型檔案卻是 MobileNetV2 的10倍大,可見要提高辨識準確率和找出位置,需要的代價也不小,但這些問題在 AI 快速發展之後,很快都會得到解決。

接下來我們動手實作,將YOLOv3加入程式中。以上一節程式為基礎,要修改的地方並不多,除了模型網址修改之外:
let 模型網址 = "https://ml-assets.apple.com/coreml/models/Image/ObjectDetection/YOLOv3/YOLOv3FP16.mlmodel"

只需再修改第1段及第4段。

第1段函式要回傳的內容除了名稱、信心度之外,還須增加外框位置;此外,工作請求處理的結果,要轉換為 [VNRecognizedObjectObservation] 才有物品名稱與外框位置(boundingBox)的資訊:
import Vision
import CoreML

struct 物品信心度 {
let 名稱: String
let 信心度: Float
let 外框: CGRect
}

// 第1段
func 影像分類v2(_ 圖片參數: UIImage, 模型參數: URL) async throws -> [物品信心度] {
var 回傳結果: [物品信心度] = []
let 影像模型 = try MLModel(contentsOf: 模型參數)
print(影像模型.modelDescription)
let 工作請求 = VNCoreMLRequest(model: try VNCoreMLModel(for: 影像模型))
print(工作請求)
if let 圖像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 圖像)
try 處理者.perform([工作請求])
if let 分類結果 = 工作請求.results as? [VNRecognizedObjectObservation] {
// print(分類結果)
for i in 分類結果 {
if i.confidence > 0.05 {
print(i)
if let 標籤 = i.labels.first {
let 物品 = 物品信心度(名稱: 標籤.identifier, 信心度: i.confidence, 外框: i.boundingBox)
回傳結果.append(物品)
}
}
}
}
}
if 回傳結果.isEmpty { 回傳結果.append(物品信心度(名稱: "", 信心度: 0.0, 外框: .zero)) }
print("回傳結果:\(回傳結果)")
return 回傳結果
}

第4段修改的也不多,需要畫外框並且標註文字,外框的畫法可參考第2課5-2a描繪外框,在畫布中標註文字的作法可參考第4單元第7課4-7a貝茲曲線,都不是新學的東西。
// 第4段
struct 列出物品: View {
let 陣列: [物品信心度]
var body: some View {
Canvas { 圖層, 尺寸 in
//print(尺寸)
let 圖寬 = 尺寸.width
let 圖高 = 尺寸.height
var 畫筆 = Path()
for 物品 in 陣列 {
let 畫框 = CGRect(
x: 圖寬 * 物品.外框.minX,
y: 圖高 * (1.0 - 物品.外框.minY - 物品.外框.height),
width: 圖寬 * 物品.外框.width,
height: 圖高 * 物品.外框.height)
畫筆.addRect(畫框)
let 字串 = " \(物品.名稱) (\(百分比(物品.信心度)))"
var 標籤 = 圖層.resolve(Text(字串))
標籤.shading = .color(.red)
圖層.draw(標籤, in: 畫框)
}
圖層.stroke(畫筆, with: .color(.red), lineWidth: 3.0)
}
}
func 百分比(_ f: Float) -> String {
let f2 = f * 100.0
let p = String(format: "%.1f", f2)
return p + "%"
}
}

合併起來,最後完整的程式碼如下,同樣仍是7段:
// 5-4c 第三方資料模型(YOLOv3)
// Created by Heman, 2023/03/20
import SwiftUI
import PhotosUI
import Vision
import CoreML

struct 物品信心度 {
let 名稱: String
let 信心度: Float
let 外框: CGRect
}

// 第1段
func 影像分類v2(_ 圖片參數: UIImage, 模型參數: URL) async throws -> [物品信心度] {
var 回傳結果: [物品信心度] = []
let 影像模型 = try MLModel(contentsOf: 模型參數)
print(影像模型.modelDescription)
let 工作請求 = VNCoreMLRequest(model: try VNCoreMLModel(for: 影像模型))
print(工作請求)
if let 圖像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 圖像)
try 處理者.perform([工作請求])
if let 分類結果 = 工作請求.results as? [VNRecognizedObjectObservation] {
// print(分類結果)
for i in 分類結果 {
if i.confidence > 0.05 {
print(i)
if let 標籤 = i.labels.first {
let 物品 = 物品信心度(名稱: 標籤.identifier, 信心度: i.confidence, 外框: i.boundingBox)
回傳結果.append(物品)
}
}
}
}
}
if 回傳結果.isEmpty { 回傳結果.append(物品信心度(名稱: "", 信心度: 0.0, 外框: .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 相簿圖片: UIImage?
@State var 物品列表: [物品信心度] = []
@State var 已編譯模型: URL?
let 模型網址 = "https://ml-assets.apple.com/coreml/models/Image/ObjectDetection/YOLOv3/YOLOv3FP16.mlmodel"
var body: some View {
網址抓圖(圖片: $相簿圖片) // 第5段
.task {
do {
if let 模型檔 = try await 下載模型(模型網址) { // 第6段
if let 編譯檔 = try await 編譯模型(模型檔) { // 第7段
已編譯模型 = 編譯檔
}
}
} catch {
print("無法辨識圖片:\(error)")
}
}
.onChange(of: 相簿圖片) { 新圖片 in
物品列表 = []
Task {
do {
if 已編譯模型 != nil && 新圖片 != nil {
物品列表 = try await 影像分類v2(新圖片!, 模型參數: 已編譯模型!) // 第1段
} else {
print("模型尚未編譯完成")
}
} catch {
print("無法辨識圖片:\(error)")
}
}
}
Spacer()
if 相簿圖片 == nil {
相簿單選(圖片: $相簿圖片) // 第2段
} else {
ZStack {
Image(uiImage: 相簿圖片!)
.resizable()
.scaledToFit()
// .border(Color.secondary)
// .opacity(0.5) // 將圖片淡化作為底圖
.overlay(列出物品(陣列: 物品列表)) // 第4段
.onTapGesture {
相簿圖片 = nil
物品列表 = []
}
if 物品列表.isEmpty || 已編譯模型 == nil {
ProgressView()
.scaleEffect(2.5)
}
}
}
Spacer()
}
}

// 第4段
struct 列出物品: View {
let 陣列: [物品信心度]
var body: some View {
Canvas { 圖層, 尺寸 in
//print(尺寸)
let 圖寬 = 尺寸.width
let 圖高 = 尺寸.height
var 畫筆 = Path()
for 物品 in 陣列 {
let 畫框 = CGRect(
x: 圖寬 * 物品.外框.minX,
y: 圖高 * (1.0 - 物品.外框.minY - 物品.外框.height),
width: 圖寬 * 物品.外框.width,
height: 圖高 * 物品.外框.height)
畫筆.addRect(畫框)
let 字串 = " \(物品.名稱) (\(百分比(物品.信心度)))"
var 標籤 = 圖層.resolve(Text(字串))
標籤.shading = .color(.red)
圖層.draw(標籤, in: 畫框)
}
圖層.stroke(畫筆, with: .color(.red), lineWidth: 3.0)
}
}
func 百分比(_ f: Float) -> String {
let f2 = f * 100.0
let p = String(format: "%.1f", f2)
return p + "%"
}
}

// 第5段
struct 網址抓圖: View {
@Binding var 圖片: UIImage?
@State var 網址: String = ""
var body: some View {
ZStack {
Rectangle()
.foregroundColor(.gray.opacity(0.5))
.frame(height: 50)
HStack {
Image(systemName: "photo.fill")
.font(.system(size: 24))
TextField("輸入圖片網址", text: $網址, prompt: Text("https://"))
.font(.system(size: 20))
.background(Color.white)
.textFieldStyle(.roundedBorder)
.cornerRadius(5.0)
.onChange(of: 網址) { 新網址 in
Task {
if let myURL = URL(string: 新網址) {
let (原始資料, _) = try await URLSession.shared.data(from: myURL)
if let 格式轉換 = UIImage(data: 原始資料) {
圖片 = 格式轉換
} else {
print("非圖片網址,請重新輸入。")
}
} else {
print("網址格式錯誤,請重新輸入。")
}
}
}
}
.padding()
}
}
}

// 第6段
func 下載模型(_ 網址: String) async throws -> URL? {
if let myURL = URL(string: 網址) {
let 檔案名稱 = myURL.lastPathComponent
let 檔案管理員 = FileManager()
if let 目錄 = 檔案管理員.urls(
for: .cachesDirectory,
in: .userDomainMask).first {
let 存檔路徑 = 目錄.path + "/" + 檔案名稱
print("下載目標:\(myURL)\n存檔標的:\(存檔路徑)")
if 檔案管理員.fileExists(atPath: 存檔路徑) {
print("檔案已下載過")
} else {
let (原始資料, 錯誤碼) = try await URLSession.shared.data(from: myURL)
print("下載成功:\(原始資料)", 錯誤碼)
_ = 檔案管理員.createFile(atPath: 存檔路徑, contents: 原始資料)
}
let 回傳值 = 目錄.appendingPathComponent(檔案名稱)
print("回傳模型位址:\(回傳值)")
return 回傳值
}
}
return nil
}

// 第7段
func 編譯模型(_ 模型位址: URL) async throws -> URL? {
let 檔名 = 模型位址.lastPathComponent + "c"
let 編譯目錄 = 模型位址.deletingLastPathComponent()
let 存檔位址 = 編譯目錄.appendingPathComponent(檔名)
print("編譯目錄:\(編譯目錄)\n存檔位址:\(存檔位址)")
let 檔案管理員 = FileManager()
if 檔案管理員.fileExists(atPath: 存檔位址.path) {
print("模型已編譯過")
} else {
let 編譯檔 = try await MLModel.compileModel(at: 模型位址)
print("編譯成功:\(編譯檔)")
try 檔案管理員.moveItem(at: 編譯檔, to: 存檔位址)
}
return 存檔位址
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(照片掃描())


💡 註解
  1. 根據本課第一節(5-4a)註解,影像分類(Image Classification)僅標記物品名稱,要找出外框位置屬於影像偵測(Image Detection)技術。 YOLOv3 整合了兩種技術,並且不是先找出外框位置,再進行細部辨識,而是只需一次掃描,所以 YOLO 在此含意為 “You Only Look Once”,有其獨特的演算法。
  2. YOLO 原本的含意是 “You Only Live Once”「人生只活一次」,是美國常用俗語。Apple 創辦人 Steve Jobs 也曾引用過:”You only live once, make it count.”,”make it count” 約略意為「不要辜負它」,這句話是非常好的人生哲語。
  3. YOLOv3 的檔案較大,下載與編譯的時間也較長,在筆者 iPad (2019年第6代)上就無法編譯成功,但在 Macbook Air (M1) 及 Mac mini (2018)則沒問題。
  4. 本節範例圖片網址
    • https://images.unsplash.com/photo-1539922980492-38f6673af8dd?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2340&q=80
    • https://images.unsplash.com/photo-1511880559356-6c0eefd38ad3?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80
前輩,我想跟您請教,
您之前有將前面的課程申請上架,
我也有下載看到,
我將自己的程式申請上架,
卻一直卡在4.2.2,
不能只有網頁之類的呈現,
我想詢問您之前有遇到這樣的問題嗎?
又是如何克服?

雪白西丘斯
改善的作法,就是多用SwiftUI視圖,如List, NavigationStack, Button, Animation...等,加上View Modifier,如onTapGuesture等。
yang9911
我目前感想是盡量不要直接Link到網頁,尤其是FB的網站
第5課 AI 聽覺

前面4課我們學了 AI 視覺方面的基本功能,讓 AI 能辨識人類生活周遭的物品,包括條碼、文字、臉部表情、手勢、軀體動作、貓狗寵物或日常用品等,這些功能都涵蓋於「電腦視覺」(Computer Vision)之中。電腦視覺讓機器有了聰明的眼睛,能夠自主判別東西,以輔助人類的應用。

接下來,我們要讓機器長出聰明的耳朵,學習AI聽覺方面的功能。AI 聽覺可以做什麼呢?之前有個著名的 App,只要聽一段音樂就能辨識出曲名,正確率非常高,後來被蘋果公司以4億美元收購,就是 AI 聽覺的應用。

當然,AI 聽覺最重要的功能是語音辨識,也就是要聽懂人講的話,不管是國語、英語或哪一國語言,甚至台語、客家話等方言,都希望能聽得懂,正確率越高越好。

不過,要學習 AI 聽覺,最基本的還是從聲音播放與錄音開始,學習如何用 Swift 程式控制設備的喇叭(嘴巴)與麥克風(耳朵)。這裡所謂的聲音,包括語音(voice or speech)、音樂(music)以及一般的聲音(sound)。

5-5a 播放聲音

用 Swift 播放聲音目前有三種方式,從發展的時間先後,分別為 AVAudioPlayer, AVPlayer, 以及 AVAudioEngine 三個物件。三者簡單區別如下表:
- AVAudioPlayer AVPlayer AVAudioEngine
發表年份 2008 2010 2014
播放聲音(在背景)
播放影片
支援 macOS, iOS, iPadOS, tvOS, watchOS
聲音來源 本機 本機、網路、串流 本機、串流、混音
控制功能 簡單 中等 精細

開始寫程式之前,我們先下載一個3秒鐘的聲音檔到本機目錄,注意下載檔案並不一定要註冊或登入Pixabay:

再匯入到 Swift Playgrounds 裡面,以便執行範例程式。

要注意匯入的檔案名稱與範例程式 Bundle.main.url() 內的名稱要一致(大小寫有區分),至於如何匯入檔案到Swift Playgrounds裡面,在第2單元曾經介紹過。

範例程式如下:
// 5-5a 播放聲音(1) AVAudioPlayer
// Created by Heman, 2023/03/29
import AVFoundation

let 播放器1: AVAudioPlayer
if let 音效檔 = Bundle.main.url(forResource: "evil-laugh-49831", withExtension: "mp3") {
do {
播放器1 = try AVAudioPlayer(contentsOf: 音效檔)
if 播放器1.prepareToPlay() { // 預先載入檔案至記憶體緩存區(buffer),此步驟可省略
播放器1.play()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

這個範例程式非常簡單,AVAudioPlayer() 需要一個本機檔案做為參數,然後呼叫 .play() 就能播放聲音。程式雖然簡單,不過,有兩個地方很容易犯錯,下面兩個例子都可以執行,但都無法發出聲音:
// 錯誤範例(1)
import AVFoundation

if let 音效檔 = Bundle.main.url(forResource: "evil-laugh-49831", withExtension: "mp3") {
do {
let 播放器1 = try AVAudioPlayer(contentsOf: 音效檔)
if 播放器1.prepareToPlay() { // 預先載入檔案至記憶體緩存區(buffer),此步驟可省略
播放器1.play()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true


第2例:
// 錯誤範例(2)
import AVFoundation

let 播放器1: AVAudioPlayer
if let 音效檔 = Bundle.main.url(forResource: "evil-laugh-49831", withExtension: "mp3") {
do {
播放器1 = try AVAudioPlayer(contentsOf: 音效檔)
if 播放器1.prepareToPlay() { // 預先載入檔案至記憶體緩存區(buffer),此步驟可省略
播放器1.play()
}
}
}
print("有聲音嗎?")

兩個程式都可以順利執行,沒有錯誤發生,但就是無法發出聲音。知道問題出在哪裏嗎?

💡 註解
  1. 英文 voice 和 sound 有何區別?中文都是聲音的意思,但是英文區別很大,voice 專指人(從口中)發出的聲音,而 sound 則是其他聲音;兩者都可稱為 audio。
  2. 聲音檔案來源:
    https://cdn.pixabay.com/download/audio/2022/03/11/audio_e657e07552.mp3?filename=evil-laugh-49831.mp3

  3. 若無法下載Pixabay檔案,可改用「語音備忘錄」自己錄製一段聲音,將檔案拷貝到桌面,再匯入Swift Playgrounds中即可。
5-5b 播放聲音(2)

上一節播放聲音的過程,先用瀏覽器下載網路的聲音檔,存放在本機硬碟中,再由 AVAudioPlayer 播放出來,整個過程可分解如下:

這個過程可以進一步簡化,因為我們在第3單元學過網路程式,瀏覽器的下載功能可用程式 URLSession 取代,而且不必存入硬碟再讀出來,直接在記憶體中讓 AVAudioPlayer 播放即可。簡化後的過程如下:

相對應的,AVAudioPlayer 的參數有兩種選擇,只要將原來的(contentsOf:)改成(data:)即可,下列程式碼可驗證此想法:
// URLSession + AVAudioPlayer
import AVFoundation

// 注意變數的有效範圍
var 播放器1 = AVAudioPlayer()
let 聲音網址 = "https://cdn.pixabay.com/download/audio/2022/03/11/audio_e657e07552.mp3?filename=evil-laugh-49831.mp3"
if let myURL = URL(string: 聲音網址) {
Task {
do {
let (下載內容, 回應碼) = try await URLSession.shared.data(from: myURL)
print(回應碼)
播放器1 = try AVAudioPlayer(data: 下載內容)
播放器1.play()
} catch {
print("有問題:\(error)")
}
}
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

執行結果,同樣會在背景播出邪惡的笑聲。

不過,還有更簡化的,就是利用第二種播放聲音的方法,可進一步省略 URLSession,只使用 AVPlayer:

使用 AVPlayer 只要幾行就同時搞定網路下載及播放:
// AVPlayer
import AVFoundation

// 注意變數的有效範圍
var 播放器2 = AVPlayer()
let 聲音網址 = "https://cdn.pixabay.com/download/audio/2022/03/11/audio_e657e07552.mp3?filename=evil-laugh-49831.mp3"
if let myURL = URL(string: 聲音網址) {
播放器2 = AVPlayer(url: myURL)
播放器2.play()
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

比起 AVAudioPlayer,AVPlayer 播放網路的音檔方便多了。事實上,AVPlayer 不只可以播放音檔,就連網路上的影片或串流也可以播放,在第3單元我們就曾經用過 AVPlayer 來播放 iTune 的音樂。

不過要注意一點,本課介紹的三種播放聲音的物件(AVAudioPlayer, AVPlayer, AVAudioEngine),都不會有外觀視圖,也就是說,播放聲音的時候,是在背景執行,看不到任何畫面(如下圖)。

以下範例我們將 AVAudioPlayer 與 AVPlayer 合併在一起執行,AVPlayer 播放速度設為 0.5 (正常速度為 1.0),兩者並不會彼此干擾,可以聽得到混音的效果:
// 5-5b 播放聲音(2) URLSession + AVAudioPlayer == AVPlayer
// Created by Heman, 2023/03/30
import AVFoundation

// 注意變數的有效範圍
var 播放器1 = AVAudioPlayer()
if let myURL = URL(string: 聲音網址) {
Task {
do {
let (下載內容, 回應碼) = try await URLSession.shared.data(from: myURL)
print(回應碼)
播放器1 = try AVAudioPlayer(data: 下載內容)
播放器1.play()
} catch {
print("有問題:\(error)")
}
}
}

// 注意變數的有效範圍
var 播放器2 = AVPlayer()
if let myURL = URL(string: 聲音網址) {
播放器2 = AVPlayer(url: myURL)
播放器2.defaultRate = 0.5
播放器2.play()
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

第三種播放聲音的物件是 AVAudioEngine,功能最多,控制最精細,後續的錄音功能以及 AI 語音辨識,將會用 AVAudioEngine 來實現。我們下一節再介紹。

💡 註解
  1. 所謂串流(streaming),例如常見的 Podcast (廣播)、Apple Music 或Netflix 影片,不必下載檔案就可播放(其實是邊下載邊播放),所以可以任意長度的播放,不像檔案有一定大小。stream 是溪流,所以 streaming 就像流水一樣,川流不息。
5-5c 播放聲音(3)

前兩節用 AVAudioPlayer, AVPlayer 播放聲音都相當直觀,只要將聲音來源適當地放入參數即可播放,但 AVAudioEngine 就沒有這麼簡單。

如果用比喻的話,AVAudioPlayer, AVPlayer 就像All-in-one的光碟播放機,只要將光碟放進去,就能播放音樂;而 AVAudioEngine 如同頂級音響設備,有前後級擴大機、音訊轉換器、播放機、喇叭系統等等,不但需要組裝,還得自己接線。

當然,AVAudioEngine 能做的事情,也遠超過 AVAudioPlayer 和 AVPlayer 的能力。除了播放、錄音,還可以串接混音器,加入各種音效,更可配合 AI 語音辨識。

AVAudioEngine 最簡單的形式如下,由2-3個節點構成,包含輸入節點(Input Node)、混音器(Mixer, 可省略)、輸出節點(Output Node),輸入節點可連接麥克風、耳麥、MP3檔、MIDI…等音源,輸出節點可連接喇叭、耳機、藍牙…等硬體。

輸入節點會有一個輸出端連接到下一個節點;混音器(Mixer Node)可接受多個輸入,但只有一個輸出連接到下一個節點;輸出節點則只有一個輸入端。如下圖所示:


為什麼要分解成這些節點呢?因為現在手機的聲音應用越來越多樣,不但用來講電話、聽音樂,還需要支援各種音效,例如杜比環繞、3D空間音效、語音隔離(噪音過濾)…等,這些都可以用AVAudioEngine 組合不同節點來達成。

以下範例以本課(5-5a)匯入的MP3檔為音源,用 AVAudioPlayerNode 播放器做為輸入節點,輸出節點用設備預設的喇叭,完整程式碼如下:
// 5-5c 播放聲音(3) AVAudioEngine
// Created by Heman, 2023/04/01
import AVFoundation

// 注意變數的生命週期,不可放在 { } 裡面
let 音效引擎 = AVAudioEngine()
if let 音檔 = Bundle.main.url(forResource: "evil-laugh-49831", withExtension: "mp3") {
do {
let 播放器節點 = AVAudioPlayerNode()
音效引擎.attach(播放器節點)
音效引擎.connect(
播放器節點,
to: 音效引擎.outputNode,
format: nil)
// 音效引擎.prepare() // 預先載入音效,此步驟可省略
try 音效引擎.start()

let 音源 = try AVAudioFile(forReading: 音檔)
播放器節點.scheduleFile(音源, at: nil) // 立刻從頭播放(at: nil)
播放器節點.play()
print(音源.length, 音源.processingFormat)
print(音效引擎)
} catch {
print("有問題:\(error)")
}
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

AVAudioEngine 兩個主要的物件方法分別為附加 attach() 與連接 connect(),前者是將新節點加入音效引擎中,後者則將新節點的輸出端連接到下一個節點:
let 播放器節點 = AVAudioPlayerNode()
音效引擎.attach(播放器節點)
音效引擎.connect(
播放器節點,
to: 音效引擎.outputNode,
format: nil)

在此例中,新節點為 AVAudioPlayerNode(),相當於一個播放器,加入之後連接到音效引擎預設的輸出節點(outputNode),也就是設備的喇叭。

注意音效引擎連接 connect() 的最後一個參數 format,是指所連接兩端的音訊格式,如聲道數、取樣頻率…等,兩端必須一致。範例中參數值 nil 表示用預設格式。

音效引擎的節點連接好之後,就可以嘗試啟動引擎,要注意,播放器播放音源之前,必須先啟動音效引擎。如果要加入新節點,或重新連接,最好先將音效引擎停止 stop(),如同組合音響要先關電源再更改線路一樣。

接下來將MP3音檔讀入,餵給播放器 scheduleFile() 排程,最後執行 play() 就會開始播放:
try 音效引擎.start()

let 音源 = try AVAudioFile(forReading: 音檔)
播放器節點.scheduleFile(音源, at: nil) // 立刻從頭播放(at: nil)
播放器節點.play()

啟動音效引擎或讀入音檔若有任何問題,都會拋出錯誤,直接跳到 catch 之後(列印錯誤訊息)。

執行結果,除了在背景播放「邪惡的笑聲」之外,主控台也列印出音效引擎的相關資訊,可以看出 MP3檔是單聲道,而輸入節點、輸出節點都是兩聲道:

💡 註解

  1. AVAudioEngine 中,Engine 是引擎 — 給機器提供動力的關鍵部位,在軟體中通常比喻為「功能強大的核心元件」,例如搜尋引擎(Search Engine),AVAudioEngine 可直譯為音效引擎。
  2. 音效引擎就像組合音響的核心動力,附加 attach() 就像接電源線,將播放器的電源接到音效引擎上;連接 connect() 則是接訊號線,將播放器的訊號接到喇叭。
  3. Apple 原廠文件提到,AVAudioEngine 可以動態連接,也就是不必停止 stop() 就可更改連接,但必須在混音器以上(upstream)。不過,最保險還是先停止再變更。
    You can connect, disconnect, and remove audio nodes during runtime with minor limitations.
CUNNING
真的複雜不少
文章分享
評分
評分
複製連結

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