人工智慧程式基礎-人體辨識篇
-
人類嬰兒剛出生的前幾個月,視覺尚未發展成熟,眼睛還無法聚焦或分辨色彩,僅對光線明暗與移動變化有反應,到六個月以後,才逐漸分辨人與物,第一個學會辨識的,當然就是母親的臉。
對高等生物而言,臉部辨識攸關生存,是視覺的首要任務。因此,當我們看一張圖片,最先聚焦的總是人臉,特別是眼睛的部位,這是我們生物遺傳的本能。
電腦沒有生物本能,所有技能都靠後天學習,更精確地說,是靠程式設計師賦予智慧。但問題是,人類是如何辨識人臉呢?其中有明確的邏輯與規則嗎?恐怕我們自己也不清楚,但絕對不是一個像素一個像素去分析。
因此,要讓只懂像素分析的電腦辨識人臉並沒有那麼簡單,經過數十年嘗試與努力才得以突破,目前辨識率已相當高。在技術上,人臉辨識可拆解成幾個步驟,首先要偵測人臉在圖片中的位置,其次偵測五官位置,接下來計算五官特徵,最後才能比對特徵、辨識身分。
-
人臉偵測(Face Detection)
本單元先學習人臉偵測,也就是找出人臉所在,大家一定都看過這種應用,當使用相機拍照時,若偵測到人臉,會自動加上外框當做焦點,類似下圖。
插入一個圖
上圖是本節範例程式執行結果,程式第一段函式「人臉偵測()」,用前一課相同寫法,(1) 以 VNDetectFaceRectanglesRequest() 產出「工作請求」,(2) 將圖片參數指定給「處理者」,(3) 呼叫 perform() 進行處理並將結果轉成 [VNFaceObservation] 觀測物件。
import Vision 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 結果 }
對我們寫程式來說,偵測人臉似乎不難(因為Apple原廠已經做好物件了),比較難的反而是怎麼畫出「外框」(術語稱為 “Bounding Box”,又稱邊界框或邊框)。
上面函式回傳的結果是 [CGRect] 類型,也就是[第4單元第5課](https://www.notion.so/c343b2a903df4b62b363f1cfa3057162?pvs=21)介紹的「畫框」陣列,不過較特別的是,這裡用的是「正規化座標」。
「正規化座標」類似數學座標,以圖片左下角為座標原點(0, 0),圖片寬、高之比例為(x, y)座標,與硬體設備無關;而「螢幕座標」是以圖片左上角為座標原點(0, 0),以硬體設備的螢幕像素為(x, y)座標,如下圖所示。
我們必須將正規化座標轉換為螢幕座標,才能用 Canvas 畫布繪製出來。
在上面範例程式執行結果,傳回人臉外框(boundingBox)的正規化座標為:
boundingBox=[0.333408, 0.372809, 0.339921, 0.339921]
這4個數字對應CGRect物件的minX, minY, width, height4個屬性,表示外框「左下角」座標為(0.333408, 0.372809)、寬(width)為0.339921、高(height)為0.339921。
這個比例是以底圖的寬、高為基礎,因此我們必須得到底圖的寬高尺寸,才能計算外框的螢幕座標,進而描繪外框。如下圖(假設圖寬、圖高各1000):
插入一個圖
整個程式除了人臉偵測()、相簿單選()、臉部掃描()主視圖之外,我們還需要寫第4段程式「描繪外框()」:
struct 描繪外框: View { let 正規化圖框: [CGRect] 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(畫框) } 圖層.stroke(畫筆, with: .color(.red), lineWidth: 3) } } }
底圖尺寸一般可用 Canvas 圖層.resolve() 來取得,但這段程式並沒有用 resolve() 解析圖片,而是巧妙地在第3段套用到 .overlay(),讓底圖與視圖重疊,因此只要在 Canvas 取得視圖尺寸就是底圖尺寸。
四段結合起來就是完整範例程式,不小心超過100行:
// 5-2a Face Detection (boundingBox) // Created by Philip, Heman, Jean, 2023/02/20 // Revised by jean, 2025/05/26 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(描繪外框(正規化圖框: 外框陣列)) .task { do { 外框陣列 = try await 人臉偵測(相簿圖片!) for i in 外框陣列 { let 字串 = """ -----臉部外框(正規化)----- 外框左下角:\(i.origin) 外框寬:\(i.width) 外框高:\(i.height)\n """ 顯示字串.append(字串) } } catch { print("掃描發生錯誤:\(error)") } } .onTapGesture { 相簿圖片 = nil 外框陣列 = [] 顯示字串 = "" } Text(顯示字串) .foregroundColor(.cyan) .background(Color.white.opacity(0.5)) .padding() } } } } // 第4段 struct 描繪外框: View { let 正規化圖框: [CGRect] 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(畫框) } 圖層.stroke(畫筆, with: .color(.red), lineWidth: 3) } } } import PlaygroundSupport PlaygroundPage.current.setLiveView(臉部掃描())
如果一張圖裡面偵測到多張人臉,也會畫出多個外框。
插入一張圖
-
人物貓狗辨識
現在的相機除了具備人臉偵測的功能之外,通常也會在合照時偵測人像(全身或半身)以及貓狗寵物,一張照片若有可愛寵物點綴其中,就容易帶來歡樂氣氛。
要辨識照片中的人像與寵物相當簡單,工作請求分別用 VNDetectHumanRectanglesRequest 以及 VNRecognizeAnimalsRequest 即可,我們仿照前一課範例5-1d,將這兩個工作請求一起交給處理者,再分別取得辨識出來的外框座標:import Vision // 第1段 func 人與貓狗辨識(_ 圖片參數: UIImage) async throws -> [CGRect] { var 結果: [CGRect] = [] let 人體外框 = VNDetectHumanRectanglesRequest() let 貓狗辨識 = VNRecognizeAnimalsRequest() if let 影像 = 圖片參數.cgImage { let 處理者 = VNImageRequestHandler(cgImage: 影像) try 處理者.perform([人體外框, 貓狗辨識]) if let 處理結果 = 人體外框.results as? [VNHumanObservation] { print("人體外框:\(處理結果)") for 人體 in 處理結果 { 結果.append(人體.boundingBox) } } if let 處理結果 = 貓狗辨識.results as? [VNRecognizedObjectObservation] { print("貓狗辨識: \(處理結果)") for 貓狗 in 處理結果 { 結果.append(貓狗.boundingBox) } } } if 結果.isEmpty { 結果.append(CGRect.zero) } print("回傳結果:\(結果)") return 結果 }
這段函式回傳值是正規化的畫框陣列 [CGRect],與上一節相同,因此可沿用上個範例5-2a第4段「描繪外框」視圖,完全不用改。
另外,考慮有些讀者的相簿中,不見得有貓狗照片,為了省掉從網路抓圖匯入相簿的麻煩,我們再寫個第5段「網址抓圖」,在文字框中輸入圖片網址,用程式下載以供辨識之用:
import SwiftUI // 第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() } } }
文字輸入框 TextField() 的寫法,是參考[第3單元第3課3-3b](https://www.mobile01.com/topicdetail.php?f=482&t=6453587#83083772)寫過的搜尋框,輸入網址後,利用 URLSession.shared.data() 下載圖片(這部份可參考[第4單元4-9a範例](https://www.notion.so/e143308f9284453681ffb95e343b89aa?pvs=21)),最後用 @Binding 將圖片傳回(仿照「相簿單選」)。
現在我們圖片來源有兩個,一是從自己的相簿選取(第2段「相簿單選」),二由網路下載(第5段「網址抓圖」)。
如此一來,第3段主視圖「照片掃描」需稍加變更,將第5段「網址抓圖」放在最上面,一直都會顯示;原來的第2段「相簿單選」則只有在「相簿圖片 == nil」時才會顯示。兩者都用「$相簿圖片」雙向傳遞參數:
// 第3段:主視圖 struct 照片掃描: View { @State var 外框陣列: [CGRect] = [] @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() } }
從這第3段「照片掃描」程式碼可以分析視圖階層如下,「網址抓圖」或「相簿單選」都可取得照片,以更新「相簿圖片」狀態變數:
這其中「相簿單選」與ZStack/Image(顯示圖片)是以 if-else 二選一,所以執行畫面主要分成兩部分,上方是「網址抓圖」文字框,下方是「相簿單選」或Image(圖片),圖片上層再加 ProgressView 或外框。執行畫面如下:
(圖片取自 Unsplash
)注意上面視圖階層中,並未標註第1段函式「人與貓狗辨識()」,想想看,應該在哪個視圖呼叫辨識函式比較好?這其實是本節最關鍵的地方。
當然,範例程式裡面已經有答案,但是請務必與上一節範例程式(5-2a 第3段)比較一下差異,為什麼上一節在 Image 用 .task 呼叫辨識函式,本節要改到「網路抓圖」用 .onChange 呢?請想一想,動手修改做實驗,看看結果如何。
現在完整的範例程式共有5段,雖然稍長一些,但每一段都從過去範例修改過來,理解並不難,第2, 4, 5段還可重複使用,幾乎不必再修改:
// 5-2b Human bodies, cats and dogs Detection (boundingBox) // Created by Philip, Heman, Jean, 2023/02/24 // Revised by Jean, 2025/05/26 import SwiftUI import PhotosUI import Vision // 第1段 func 人與貓狗辨識(_ 圖片參數: UIImage) async throws -> [CGRect] { var 結果: [CGRect] = [] let 人體外框 = VNDetectHumanRectanglesRequest() let 貓狗辨識 = VNRecognizeAnimalsRequest() if let 影像 = 圖片參數.cgImage { let 處理者 = VNImageRequestHandler(cgImage: 影像) try 處理者.perform([人體外框, 貓狗辨識]) if let 處理結果 = 人體外框.results as? [VNHumanObservation] { print("人體外框:\(處理結果)") for 人體 in 處理結果 { 結果.append(人體.boundingBox) } } if let 處理結果 = 貓狗辨識.results as? [VNRecognizedObjectObservation] { 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 外框陣列: [CGRect] = [] @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 正規化圖框: [CGRect] 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(畫框) } 圖層.stroke(畫筆, with: .color(.red), lineWidth: 3) } } } // 第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(照片掃描())
-
人體細部辨識
上一課我們利用AI視覺辨識人臉、人體與貓狗,取得外框範圍在圖片中的相對位置,這個外框範圍稱為 Bounding Box,是視覺辨識的第一步,也是最重要的一步,如果Bounding Box沒找出來或找錯位置,後面細部辨識就無用武之地了。
如果人臉範圍正確框出,接下來就可進一步辨識臉部五官,使用的工作請求為 VNDetectFaceLandmarksRequest,Landmark 是「地標」的意思,Face Landmarks 就是指眼、耳、鼻、口等臉部特徵。
依照前一課的程式段落,可將第1段程式改寫成「臉部五官辨識()」函式:
import Vision // 第1段 func 臉部五官辨識(_ 圖片參數: UIImage) async throws -> [CGPoint] { var 結果: [CGPoint] = [] let 工作請求 = VNDetectFaceLandmarksRequest() if let 影像 = 圖片參數.cgImage { let 處理者 = VNImageRequestHandler(cgImage: 影像) try 處理者.perform([工作請求]) if let 處理結果 = 工作請求.results { // 不需要再寫 as? [VNFaceObservation] print("處理結果:\(處理結果)") for 臉部 in 處理結果 { let 外框 = 臉部.boundingBox // 以圖片寬高比例的正規化座標 if let 五官位置 = 臉部.landmarks { // 以外框寬高比例的正規化座標 let 所有特徵點 = 五官位置.allPoints!.normalizedPoints.map { 各點 in CGPoint( // 改成圖片寬高比例的正規化座標 x: 外框.origin.x + 各點.x * 外框.width, y: 外框.origin.y + 各點.y * 外框.height) } 結果 = 結果 + 所有特徵點 } } } } if 結果.isEmpty { 結果.append(CGPoint.zero) } print("回傳結果:\(結果)") return 結果 }
VNDetectFaceLandmarksRequest 傳回的辨識結果,包含76個特徵點,也就是76個點座標,仍採用正規化座標,但不一樣的是,此處的寬高比例是以「外框(boundingBox)」為基準,而不是圖片寬高。轉換成圖片寬高比例的算法如下圖:
為什麼要轉換成圖片寬高的正規化座標?因為這樣就可以沿用上一課「描繪外框」的相同技巧來畫出所有特徵點。要取得並轉換所有特徵點的正規化座標,關鍵程式碼如下:
let 外框 = 臉部.boundingBox // 以圖片寬高比例的正規化座標 if let 五官位置 = 臉部.landmarks { // 以外框寬高比例的正規化座標 let 所有特徵點 = 五官位置.allPoints!.normalizedPoints.map { 各點 in CGPoint( // 改成圖片寬高比例的正規化座標 x: 外框.origin.x + 各點.x * 外框.width, y: 外框.origin.y + 各點.y * 外框.height) } 結果 = 所有特徵點 }
用「五官位置.allPoints」即可取得所有76個特徵點,這些特徵點細分成12組,每組可個別取出,只要將 allPoints 改成其他屬性名稱即可,包括:
.faceContour: 17點,臉頰輪廓
.leftEye: 6點,左眼輪廓
.rightEye: 6點,右眼輪廓
.leftPupil: 1點,左眼瞳
.rightPupil: 1點,右眼瞳
.leftEyebrow: 6點,左眉
.rightEyebrow: 6點,右眉
.nose: 8點,鼻子輪廓
.noseCrest: 6點,鼻樑與鼻翼
.medianLine: 10點,臉部中線(眉心到下巴)
.outerLips: 14點,嘴唇外緣
.innerLips: 6點,嘴唇內緣
注意這裡的左右與我們的習慣相反,通常我們說「左眼」是指照片人物的左眼,但這裡是指靠照片(畫面)左側的眼睛(其實是人物的右眼)。
我們已經熟悉相對於圖片寬高的正規化座標,在此可以仿照上一課範例程式的第4段「描繪外框」,改成「描繪特徵點」,利用 Canvas 畫布,在每一點畫出一個半徑3(點)的紅色實心圓:
// 第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)) } } }
執行結果會在臉部畫出76個特徵點,如下圖:
插入一個圖
5段完整的程式碼如下,其中第2, 3, 5段幾乎都不需要更改:
// 5-3a Face Landmarks Detection (76 Points) // Created by Philip, Heman, Jean, 2023/03/04 // Revised by jean, 2025/05/26 import SwiftUI import PhotosUI import Vision // 第1段 func 臉部五官辨識(_ 圖片參數: UIImage) async throws -> [CGPoint] { var 結果: [CGPoint] = [] let 工作請求 = VNDetectFaceLandmarksRequest() if let 影像 = 圖片參數.cgImage { let 處理者 = VNImageRequestHandler(cgImage: 影像) try 處理者.perform([工作請求]) if let 處理結果 = 工作請求.results { // 不需要再寫 as? [VNFaceObservation] print("處理結果:\(處理結果)") for 臉部 in 處理結果 { let 外框 = 臉部.boundingBox // 以圖片寬高比例的正規化座標 if let 五官位置 = 臉部.landmarks { // 以外框寬高比例的正規化座標 let 所有特徵點 = 五官位置.allPoints!.normalizedPoints.map { 各點 in CGPoint( // 改成圖片寬高比例的正規化座標 x: 外框.origin.x + 各點.x * 外框.width, y: 外框.origin.y + 各點.y * 外框.height) } 結果 = 結果 + 所有特徵點 } } } } 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(照片掃描())
-
身體姿態辨識
上一節我們學會辨識臉部細節,取得76個特徵點,據此可做出身分識別或臉部表情相關應用。本節我們將學習辨識全身肢體動作,需要多少特徵點呢?其實只要19個特徵點,就能判別一個人舉手投足或站坐蹲臥等姿態,甚至進一步分析運動員或健身教練的動作。
全身19個特徵點如下圖所示,我們身體是左右對稱,左右兩側分別有眼、耳、肩、肘、腕、臀、膝、踝等8個部位,身體中線只取鼻、頸、臍等3個位置,合計19個特徵點,如下圖。
圖片來源:Apple原廠文件https://developer.apple.com/documentation/vision/detecting_human_body_poses_in_images 辨識身體姿態的第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 Philip, Heman, Jean, 2023/03/06 // Revised by Jean, 2025/05/26 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) // 正規化座標 } } } } } } 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(照片掃描())
-
人臉偵測(Face Detection)