• 5

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

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

為什麼中學生要學AI程式?

科技已成為當代人類社會最強生產力,尤其過去50年以指數成長的半導體與數位科技,帶動其他科學與經濟發展,如今數位科技以 AI 最令人矚目,影響力將超過網際網路與智慧型手機,是帶動未來30年全球發展的火車頭。

網際網路與智慧型手機在過去20年已改變全世界所有人的生活習慣,也顛覆各行各業的規則,影響力不可謂不大,人工智慧有可能超越他們嗎?人工智慧對人類的影響能有多大?著名作家哈拉瑞的答案相當令人吃驚,在《21世紀的21堂課》書中指出,未來威脅人類生存的三大危機,包括核戰、氣候變遷以及科技顛覆,其中顛覆性科技主要指人工智慧(AI)與生物科技(Biotech)。

也就是說,人工智慧的潛力完全能夠與核能相提並論,用得好,能改變世界、創造幸福;用不好,極可能毀滅人類文明。人工智慧與核能、氣候變遷將是未來30年最重要的議題,受影響最大的,莫過於現在的中學生,他們未來的命運應該掌握在自己手裡。

簡單地說,人工智慧的力量非常巨大,不論對個人或國家,能否善用這股力量,將決定未來的命運,越早開始學習越好,中學生已具備學習人工智慧的部分基礎知識,應儘快有系統的學習。

當然,現有人工智慧(機器學習)背後的原理,牽涉到高等數學,包括線性代數、微分方程、機率統計等,必須等到大學或研究所再修。對高中程度而言,可以先學習基礎的AI程式設計,也就是利用Apple原廠框架裡面的物件,寫出基本的人工智慧應用程式。

人工智慧是如何變厲害的?

人工智慧(Artificial Intelligence, AI)最近的進展,可說風起雲湧,稱得上是當今數位科技最大的突破與成就之一,現在的AI不但各國語言聽說讀寫翻譯樣樣通,就連下棋、作曲、寫詩、繪畫、開車、送貨…也都不在話下。生活中已可看到各類AI產品,包括:

- 語音助手及智慧喇叭:如 Apple Siri, Google Assistant, Amazon Alexa, …
- 自動駕駛汽車:如特斯拉(Tesla)、Google Waymo…
- AI機器人:如第一位獲得中東國家公民權的AI索菲亞(Sophia)、日本軟體銀行的Pepper…
- 聊天機器人:ChatGPT 號稱是目前最強聊天機器人
- AI換臉/修臉:DeepFake, Toonify, 美顏相機…
- AI繪圖:Midjourney, Stable Diffusion, DALL-E, …
- 無人商店/自動化餐廳/無人機送貨/停車場車牌辨識…

AI不但藏身於每個人的手機,也出現在各式各樣的智能機器中,雖然尚未有單一AI融合所有技能,大多只擅長其中一項,且精確度未必都令人滿意,但已顛覆大家對機器的認知,令人不禁要問:機器真能具備人類智慧嗎?

要回答這個問題,就不得不提到兩位被尊稱為「人工智慧之父」的先驅人物。第一位是有現代電腦之父與人工智慧之父雙重頭銜的英國人艾倫.圖靈(Alan Turning),在1950年代研究「計算機器」能否具備人類思考與推理的能力,並且制定一個判斷機器能否模擬人類智慧的測試,稱為「圖靈測試」,如今若有機器或電腦程式能通過圖靈測試,基本上就認定能模擬人類的推理能力或智慧。

圖靈也是最早的程式設計師之一,在1948年設計過一個西洋棋遊戲(的演算法),但當時電子計算機剛發明,沒有任何程式語言,實際上並沒有任何機器可以執行這個遊戲。類似於此,1950年圖靈純粹從理論設想了一個「模仿遊戲」:如果有機器與真人對談一段時間而不被發現,就算模仿成功,通過測試。

“Artificial Intelligence” 一詞的創造與相關研究,則由另一位人工智慧之父 — 美國人約翰.麥卡錫(John McCarthy)1955年左右開始推動,他從人類如何推理、理解語意開始,設法將推理邏輯與語意規則寫成程式,這是一個非常困難且無比複雜的挑戰,他甚至創出一個程式語言 LISP 來開發人工智慧程式。

很可惜,麥卡錫花了40年都無法通過圖靈測試,不過卻在人工智慧領域開拓出廣大空間。1980年之後,其他科學家改用其他不同方法模擬人類智慧,其中包括「神經網路」、「機器學習」等,以數學統計為基礎的方法,並不直接找出準確的答案,而是從大量數據學習中,趨近可接受的答案。

神經網路與機器學習的方法,初期還是遭遇許多困難,經過30年的發展,在網際網路普及之後,有了網路上大量數據為基礎,終於在2010年左右開始有程式通過圖靈測試。如今若與 ChatGPT 對談,不但會以為跟真人無異,還會覺得比人更聰明,已然超越圖靈測試。

人工智慧與一般程式有何不同?

隨著人工智慧的新聞熱度越來越高,人工智慧似乎無所不在、無所不能,好像很厲害的樣子,有什麼是人工智慧做不到的嗎?

其實,人工智慧只是電腦軟體的一種,所能做的事情並不會超出軟體的範圍,只不過隨著軟體應用越來越廣,生活中能承載軟體的數位設備越來越多,讓人工智慧影響力也逐漸擴大。

那麼,同樣都是軟體,人工智慧程式與一般程式又有何不同呢?如果真要比較的話,兩者倒有個非常明顯的區別,就是「因果關係」。

在我們前面課程中,所有範例程式都有一個特點,就是不管在誰的設備上執行,都會得到相同結果,也就是結果並不隨著時間、地點、環境而變化,只要程式與輸入條件沒變,輸出就會相同。種什麼因,得什麼果,因果關係非常明確,寫程式的人完全可以預期程式執行的結果。所有程式不應該就是這樣嗎?

人工智慧程式並非如此,同樣的程式與輸入條件,在不同時間可能產生不同的結果,而且連寫程式的作者,也無法預期執行的準確結果。為什麼會這樣?下一節我們會以最近非常熱門的 ChatGPT 人工智慧程式來當例子,實際觀察一下這個現象。

但為什麼人工智慧程式不像一般程式那樣,完全根據輸入產生唯一的結果,這樣既容易預期,也好判斷對錯呢?簡單的說,因為人工智慧程式所針對的問題,大多沒有明確答案,就像下棋,在任一時間,問下一步下在哪個位置贏面最大?這就產生很多選擇,甚至可能沒有一個正確答案。

為了達到圖靈與麥卡錫所期望,讓電腦也能夠理解人類的智慧,目前人工智慧程式大多採用數學上的統計模型來「趨近」答案,也就是說,AI 每一次回答,其實都帶有隨機性,即使相對較明確的答案可能是代表99.9%機率(有0.1%機率會出現其他結果),不像一般程式那樣,100%一定都是同樣結果。

人工智慧包含哪些領域?

想想看人類的智慧從何而來。當過父母的都知道,嬰兒一歲之前就能透過「聽覺」學習詞彙,兩歲前已會用「視覺」協調肢體動作,三歲就開始有自我意識,能分辨你我他,並用「語言」與人交流。接下來開啟後天學習,透過書本、影片或實物學會辨識各種物體與知識:這是大象走很慢、這是牛奶可以喝、大海裡面有很多魚會游泳…,人類智慧就像這樣,一點一滴累積而來。

由此可知,人類的智慧大都透過視覺與聽覺所獲得,再加上以文字紀錄、用語言交流。因此,人工智慧程式基本上有個目標範圍,就是針對「視覺、聽覺、語言」等三方面的應用。所以,若程式能夠分辨各種物品,聽懂日常對話,或能運用語言與人類雙向交流,我們就認為是合格的人工智慧。

本單元內容大綱

不可諱言,目前人工智慧程式的電腦語言,使用最多的是 Python,不過,從語言特性與應用層面來看,Python 有許多地方比不上 Swift。在2017-2020年期間,Google 曾挖角 Swift 之父 Chris Lattner,加入研發AI的“Google Brain”團隊,嘗試以 Swift 改寫其人工智慧程式,結果創作出 Swift for TensorFlow,可惜這個計畫在2021年終止。

幸好,在Apple原廠積極投入人工智慧的努力之下,不但從硬體上優化,每顆Apple 設計的核心晶片都帶有「神經網路引擎」(Neural Engine),可加速人工智慧程式;在軟體上,也不斷擴增人工智慧相關框架與物件,令 Swift 也成為人工智慧相當合適的程式語言,並可充分享受硬體加速的優勢。

因此,第5單元將介紹如何用 Swift 設計人工智慧程式,課程理念仍與前四個單元一致:以高中程度為對象、採用 Swift Playgrounds 為實作開發環境、中文化命名、完整範例程式、盡量使用基礎語法。

具體來說,第5單元將學習 Apple 的人工智慧框架,包括 CoreML, Vision, Speech, NaturalLanguage 等,涵蓋人工智慧三個核心領域:「視覺、聽覺、語言」,課程大致包含以下應用:

- QR Code
- 電腦視覺
- 人臉辨識
- 物品辨識
- 語音辨識
- 自然語言

希望學習之後,能夠了解:

1. 如何動手實作人工智慧程式
2. 知道人工智慧擅長解決什麼問題
3. 日常生活哪些情境能運用人工智慧

學習路線

下載Swift Playgrounds App https://www.apple.com/tw/swift/playgrounds/
第1單元 Swift 程式語言基礎 https://www.mobile01.com/topicdetail.php?f=482&t=6402999
第2單元 SwiftUI 圖形介面基礎 https://www.mobile01.com/topicdetail.php?f=482&t=6424982
第3單元 Swift 網路程式基礎 https://www.mobile01.com/topicdetail.php?f=482&t=6453587
第4單元 SwiftUI 動畫與繪圖 https://www.mobile01.com/topicdetail.php?f=482&t=6555364

後續課程
第6單元 AR擴增實境與空間運算

💡 註解
  1. 艾倫.圖靈(Alan Turing)對現代電腦基本理論的貢獻非常大,全球電腦科學領域最高榮譽就命名為「圖靈獎(Turing Award)」,約翰.麥卡錫曾獲得1971年圖靈獎。艾倫.圖靈也是電影「模仿遊戲(The Imitation Game)」的故事主角。
  2. 圖靈的模仿遊戲-《創新者們》 - PanSci 泛科學
  3. 電腦首次通過圖靈測試,人工智慧大突破?艾倫.圖靈在1950年文章中曾預測「可能50年後才有程式能通過測試」,如今看來確實如此。
  4. 有趣的是,Swift 之父 Chris Lattner在2022年1月與一位Google Brain同事共同創辦一家人工智慧公司 Modular AI,並擔任執行長(CEO),全力投入人工智慧領域。
  5. 以iPhone 14 Pro所用的 Apple A16 晶片為例,裡面包含了6核心CPU、5核心GPU 以及高達16核心的神經網路引擎,可見Apple對人工智慧的重視。

© 2023 Heman Lu <heman@sancode.org.tw>
2023-02-01 11:41 發佈
序曲:ChatGPT -- AI 寫程式?

ChatGPT 是 OpenAI 在2022年11月發表的 AI 程式,OpenAI 原是一家非營利組織,由特斯拉創辦人Elon Musk及矽谷企業家Samuel Altman於2015年創立,不過後來Elon Musk退出,在 Sam Altman 主導下已改制為營利單位,並獲得微軟大力投資。目前發布的AI服務包括GPT語言模型、DALL-E (AI繪圖)、Codex (AI寫程式)、Gym (AI開發環境)等等。

以下對話,猜猜看A或B哪個是真人、哪個是AI?

A: 你好,我叫Angela,是一個中學生。

B: 你好,Angela,很高興遇見你。有什麼可以為你服務的嗎?

A: 我對電腦有興趣,但不確定該不該學程式設計,你覺得呢?

B: 學習程式設計是一個很好的選擇,因為它是一個非常多元化且廣泛應用的技能。它可以幫助你在未來的職涯中建立基礎,無論是在軟體開發、人工智能、數據科學、電腦科學等領域都可以幫上很大的忙。如果你實在不確定是否學習程式設計,你可以嘗試先學習一些基礎的程式語言,如Python,看看是否喜歡程式設計這個領域。

A: 我喜歡Apple的產品,像是iPhone或iPad

B: 學習 iOS 開發是一個很好的選擇。iOS 是 Apple 所開發的移動作業系統,用於 iPhone 和 iPad 裝置。iOS 開發需要使用 Swift 或 Objective-C 編寫程式碼。通過學習 iOS 開發,你可以開發出自己的 iPhone 和 iPad 應用程式,並且可以在 App Store 上發布你的應用程式給全世界的用戶下載。

A: 你覺得Swift適合中學生學習嗎?

B: 我認為 Swift 是一種適合中學生學習的程式語言。Swift 是一種相對新的程式語言,具有簡單易學的特點,它的語法與其他主流程式語言類似,因此容易上手。而且 Swift 是蘋果公司所開發的,可以用於 iOS 和 macOS 的開發,這意味著,如果你喜歡 Apple 的產品,學習 Swift 將是一個很好的選擇。

就算你不打算專攻移動開發或 iOS 開發,學習 Swift 也是個很好的選擇,因為它是一種流行且功能強大的程式語言,可以用於開發許多不同類型的應用程式。

A: 你知道Swift Playgrounds嗎?它跟Xcode比較起來,哪個比較適合初學者?

B: 是的,我知道 Swift Playgrounds。Swift Playgrounds 是一個蘋果公司開發的用於學習 Swift 程式語言的工具。它是一個互動式環境,允許用戶使用拖放方式將程式碼放入工作區並立即看到結果。它還提供了許多互動式課程和挑戰,可以幫助用戶學習程式設計。

Xcode 是一個蘋果公司開發的集成開發環境 (Integrated Development Environment, IDE),用於開發 iOS、macOS 和 watchOS 應用程式。Xcode 是一個功能非常強大的工具,提供了許多功能,如代碼結構、版本控制、調試和優化等。

如果你是初學者,我建議你先使用 Swift Playgrounds,因為它是一個簡單易學的環境,可以讓你快速上手程式設計。Xcode 是一個專業級的工具,需要更多的時間和經驗才能熟練掌握。


以上B是由ChatGPT自動回答,可以看出,中文語法相當流暢,完全沒有過去聊天機器人語法生硬、內容制式、語氣缺乏變化的缺點,更可怕的是,回答的前後文邏輯清楚,建議或判斷相當有深度,猶如深思熟慮過。

很明顯,這已經超越「圖靈測試」,不但讓人分不出是AI與真人,還具有相當「智慧」。

ChatGPT不但對日常會話對答如流,在專業領域如「程式設計」也不輸真人,甚至會寫SwiftUI程式。例如,輸入以下中文敘述:「用 SwiftUI 畫一個正六邊形」,得到答案如下:


ChatGPT 回覆不但解釋如何用 SwiftUI 畫出六邊形,還寫了兩段範例程式,第一段是符合 Shape 規範的正六邊形,還加了中文註解:
struct Hexagon: Shape {
func path(in rect: CGRect) -> Path {
// 計算正六邊形的各個點的坐標
let center = CGPoint(x: rect.midX, y: rect.midY)
let points = (0..<6).map {
let x = rect.width / 2 * cos(CGFloat($0) * .pi / 3)
let y = rect.height / 2 * sin(CGFloat($0) * .pi / 3)
return CGPoint(x: x + center.x, y: y + center.y)
}

// 將這些點連接起來並返回
return Path { path in
path.addLines(points)
path.closeSubpath()
}
}
}

若和第4單元第8課4-8a所教的「正多邊形」範例程式 Shape 寫法加以比較,AI 寫的還更簡潔,並且用進階的 .map() 方法來取代 for 迴圈,是相當成熟的寫法。

接下來第二段用 GeometryReader 畫出正六邊形的視圖,GeometryReader 我們沒有教過,這個視圖物件類似 Canvas,能夠讀取螢幕的寬高尺寸:
GeometryReader { geometry in
Hexagon()
.fill(Color.blue)
.frame(width: geometry.size.width, height: geometry.size.height)
}
這部份要放在視圖主體(View body)裡面。

兩段結合起來,就畫出正六邊形,還加了顏色,結果相當正確!


其實 AI 寫的這個範例程式還是有點小錯誤 — 長寬沒有取等長,所以不算「正」六邊形。而且這裡的 GeometryReader 是不必要的,在視圖主體(View body)裡面直接用 Hexagon() 即可:
struct ContentView: View {
var body: some View {
Hexagon()
.fill(Color.blue)
}
}

值得注意的是,如果重複問ChatGPT同樣的問題,例如再問一次「用 SwiftUI 畫一個正六邊形」,回答內容與程式範例就會不一樣,下面第二次回答的程式範例就沒有上一個用Shape 寫得好,由此可見,ChatGPT 並沒有判斷優劣的能力。
import SwiftUI

struct ContentView: View {
var body: some View {
Path { path in
path.move(to: CGPoint(x: 100, y: 100))
path.addLine(to: CGPoint(x: 200, y: 100))
path.addLine(to: CGPoint(x: 250, y: 150))
path.addLine(to: CGPoint(x: 200, y: 200))
path.addLine(to: CGPoint(x: 100, y: 200))
path.addLine(to: CGPoint(x: 50, y: 150))
path.closeSubpath()
}.stroke(Color.blue, lineWidth: 2)
}
}

無論如何,用 AI 寫程式已經完全可行,甚至接近一個初級工程師的程度,網路上還有其他利用 ChatGPT逐步寫出一個完整App的例子。

當然,這不是AI 的發展極限,甚至說,現在AI 的能力還只算初期階段,未來還有很大發展空間,我們能想像50年後的AI發展嗎?有人說或許 AI 將發展出自我意識 — 這會不會是下一代「圖靈測試」的目標呢?

💡 備註
  1. GPT 全名是 “Generative Pre-trained Transformer”,大意是預先訓練、生成式的轉換器,是 OpenAI 開發的自然語言模型,之前已歷經三代(GPT-1, GPT-2, GPT-3),ChatGPT 算是 GPT-3.5。
  2. 轉換器(Transformer)是機器學習的新技術,主要用於自然語言處理,由 Google Brain 在2017年所發表。
  3. 推薦兩則台大教授介紹 ChatGPT 的影片:台大資工陳縕儂教授台大電機李宏毅教授
  4. 試試看讓 ChatGPT 寫一個 Swift 程式,計算小於1000的質數,將結果與第1單元第5課範例1-5b比較看看。
  5. 想想看,如果未來AI都能自動寫程式,那我們現在還有學程式的必要嗎?
第1課 條碼及文字掃描

5-1a QR Code

日常生活中 QR Code 已隨處可見,與智慧型手機搭配很方便,只要手機鏡頭一掃描,就可直達某個網站,可說是從實體世界進入虛擬世界的捷徑。在正式進入人工智慧程式設計之前,我們先學一點QR Code 背景知識,以及如何用程式製作與辨識QR Code。

QR Code 事實上是一種「二維條碼」,相對的,之前常見於商品包裝背後的條碼(Bar code)都是一維的。所謂「條碼」,是一種將資料數位化以方便輸入電腦的編碼技術,看過便利商店結帳過程就知道,掃描條碼比手敲鍵盤輸入快多了,而且還不會出錯。

一維條碼早在1970年代就已發明,有多種編碼形式,最常見的稱為歐洲商品碼(EAN Code),便利商店所有商品包裝背面一定都有條碼,那就是EAN-13。EAN-13 內容由13個數字組成,前3個數字為國碼、接下來4個數字為廠商代碼,台灣的EAN國碼是471,因此台灣廠商生產的商品,條碼一定是471開頭。EAN-13 還保留一些特殊國碼,例如以977開頭的專用於期刊,稱為國際期刊碼(ISSN),以978開頭的則用於書籍,稱為國際書碼(ISBN)。

到了西元2000年前後,有多種二維條碼被各國發明出來,包括台灣的交通大學也有,但最終日本的QR Code因為開放授權,最早成為國際標準,到2007年iPhone與Android問世之後,附帶相機鏡頭的智慧型手機成為主流,QR Code 才跟著普及起來。

對程式而言,條碼掃描的原始內容都是「字串」。一維條碼只能存放數字或英文(含標點符號),一般不超過15個字元;二維條碼則允許萬國碼(Unicode),可包含中文,而且容量大幅提昇,最多可存數千個字元。

如何用 SwiftUI 製作QR Code

要用 SwiftUI 產出QR Code,目前需借助底層的Core Image框架(物件名稱大多以 CI 開頭),關鍵的物件方法是 CIFilter.qrCodeGenerator(),這是 CIFilter 物件的類型方法,我們先寫一個函式來產生 QR Code 圖片讓 SwiftUI 使用,函式輸入值為任意字串:
import CoreImage

func qrCode(_ 輸入: String) -> UIImage {
let 結果: UIImage
let 圖層 = CIContext()
let 放大 = CGAffineTransform(scaleX: 10, y: 10)
let 濾鏡 = CIFilter.qrCodeGenerator()
濾鏡.message = Data(輸入.utf8)
if let 輸出 = 濾鏡.outputImage?.transformed(by: 放大) {
if let 圖片 = 圖層.createCGImage(輸出, from: 輸出.extent) {
結果 = UIImage(cgImage: 圖片)
} else {
結果 = UIImage(systemName: "xmark.square") ?? UIImage()
}
} else {
結果 = UIImage(systemName: "xmark.square") ?? UIImage()
}
return 結果
}

Core Image 物件的使用方法與第4單元第5課用過的畫布/畫筆(Canvas/Path)類似,都是先產出一個空的物件實例,如 var 畫筆 = Path(),然後再以此物件實例進行操作,逐步增添內容:
let 濾鏡 = CIFilter.qrCodeGenerator()
濾鏡.message = Data(輸入.utf8)
let 輸出 = 濾鏡.outputImage?.transformed(by: 放大)

接下來與 Canvas 類似,要把「輸出」圖案顯示出來,需要先產出一個空白圖層(CIContext),然後將「輸出」轉印到圖層上:
let 圖層 = CIContext()
let 圖片 = 圖層.createCGImage(輸出, from: 輸出.extent)

最後將整個圖層的「圖片」轉成SwiftUI可用的 UIImage 圖片格式,回傳回去:
let 結果: UIImage
結果 = UIImage(cgImage: 圖片)
return 結果

有了這個qrCode()函式之後,我們在SwiftUI產出QR Code圖片就非常簡單,類似這樣:
Image(uiImage: qrCode("https://heman.lu/"))

就可用網址字串 "https://heman.lu/" 為內容產出一個 QR Code 圖片。

接下來用 SwiftUI 產出兩個 QR Code 圖片,一個輸入是筆者申請的專用網址字串 "https://heman.lu/",只有17個英文字元(含標點符號);另一個輸入第4單元用過的蘇東坡「水調歌頭」,有132個中文字元(含換行)。以下為完整範例程式:
// 5-1a QR Code Generator
// Created by Heman, 2023/02/08
import SwiftUI
import CoreImage.CIFilterBuiltins

func qrCode(_ 輸入: String) -> UIImage {
let 結果: UIImage
let 圖層 = CIContext()
let 放大 = CGAffineTransform(scaleX: 10, y: 10)
let 濾鏡 = CIFilter.qrCodeGenerator()
濾鏡.message = Data(輸入.utf8)
if let 輸出 = 濾鏡.outputImage?.transformed(by: 放大) {
if let 圖片 = 圖層.createCGImage(輸出, from: 輸出.extent) {
結果 = UIImage(cgImage: 圖片)
} else {
結果 = UIImage(systemName: "xmark.square") ?? UIImage()
}
} else {
結果 = UIImage(systemName: "xmark.square") ?? UIImage()
}
return 結果
}

struct QRCode: View {
let 網址 = "https://heman.lu/"
let 水調歌頭 = """
明月幾時有?
把酒問青天。
不知天上宮闕,
今夕是何年。
我欲乘風歸去,
又恐瓊樓玉宇,
高處不勝寒。
起舞弄清影,
何似在人間?
轉朱閣,
低綺戶,
照無眠。
不應有恨,
何事長向別時圓?
人有悲歡離合,
月有陰晴圓缺,
此事古難全。
但願人長久,
千里共嬋娟。
"""
var body: some View {
VStack {
VStack {
Image(uiImage: qrCode(網址))
.resizable()
.scaledToFit()
Link(網址, destination: URL(string: 網址)!)
.font(.caption)
}
.padding()
VStack {
Image(uiImage: qrCode(水調歌頭))
.resizable()
.scaledToFit()
Text("水調歌頭 (宋)蘇軾")
.font(.caption)
}
.padding()
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(QRCode())

執行結果如下圖,可以看出 QR Code 的密度與內容長短有直接關係,內容越多,QR Code 圖案越精細複雜,同學們可以用手機掃描看看。

上圖第一個QR Code內容是網址,所以手機(其實是作業系統的作用)會自動提示用瀏覽器連接;但 QR Code 內容未必都是網址,可以是任意字串,第二個QR Code內容是中文字串,猜猜看手機掃描後會如何提示?

💡 註解
  1. QR Code 能否被用於詐騙?如果QR Code指向一個惡意網站,有什麼方法可以識別?
  2. 作業一:可以找一篇上千字中英混雜的文章,嘗試看看QR Code最多能容納多少字元。
  3. 作業二:請根據範例程式,加上 TextField 或 TextEditor,以便執行時輸入文字,免得將字串直接寫在程式裡不好改。
5-1b QR Code 掃描(AI視覺)

[2024/12/24更新]
  1. 「相簿單選」重新改寫。
  2. 新版macOS 15的PhotosPicker預設行為與最初不同,必須加上 .photosPickerStyle(.inline),否則無法選擇照片,選擇後也不會自己消失。
  3. 測試環境:Mac mini 2023 (M2), macOS 15.2, Swift Playgrounds 4.5.1

本節正式進入人工智慧的程式設計基礎。

如前面序言所說,本單元主要介紹視覺、聽覺、語言三方面的人工智慧,本節就從「視覺」開始,對應 Apple 原廠框架為 Vision,有些廠商也稱之為電腦視覺(Computer Vision, CV)。

上一節我們學會用字串製作 QR Code 圖片,本節則用電腦視覺來辨識圖片中的 QR Code,同樣寫成一個函式,函式的作用與上一節剛好相反,這次是輸入UIImage圖片、輸出辨識後的字串,不過一張圖片中可能有多個條碼,因此辨識後會得到一個字串陣列[String]:
import Vision

func qrDecode(_ 圖片參數: UIImage) -> [String] { ... }

用來辨識QR Code的主要物件是 VNDetectBarcodesRequest,要從 Vision 框架中取用,所以要記得 import Vision。

Vision 內的物件大多以 VN 開頭來命名,VNDetectBarcodesRequest 可以辨識20多種一維及二維條碼,使用方法大致分為三個步驟:

  • 步驟1. 先產出一個「工作請求」物件,請求辨識條碼(本節需要辨識三種條碼,包括 QR Code, EAN-13, Code-39,分別寫在symbologies屬性的陣列中):
    let 工作請求 = VNDetectBarcodesRequest()
    工作請求.symbologies = [.qr, .ean13, .code39]

  • 步驟2. 實際處理圖形辨識的工作,是另外一個「處理者」(Handler)物件負責,這個處理者專門辨識 CGImage 格式,因此要先從函式的UIImage參數中取出 CGImage 圖片,再交給處理者:
    if let 影像 = 圖片參數.cgImage {
    let 處理者 = VNImageRequestHandler(cgImage: 影像)
    ...
    }

  • 步驟3. 將「工作請求」送入「處理者」開始進行辨識,處理者可同時處理多個工作請求,因此參數為 [工作請求] 陣列。辨識結果會存回「工作請求.results」中,條碼辨識的結果(原始內容)需轉成 VNBarcodeObservation (條碼觀測)的陣列類型,裡面就是一個個觀測(解碼)得到的字串。
    try 處理者.perform([工作請求])
    if let 處理結果 = 工作請求.results as? [VNBarcodeObservation] {
    ...
    }

注意這裡用了 try 指令,因為進行辨識 perform() 可能會拋出錯誤,若函式裡面不處理的話(要用 do-try-catch 句型),就須將函式宣告加上 throws,將錯誤傳遞到上層呼叫者。

以上就是用電腦視覺辨識QR Code的過程,初次接觸時會覺得有點複雜,不過後面幾課的視覺辨識過程也都類似,用熟悉之後,就不覺得困難了。整個過程其實就像第3單元第1課網路程式的 Request-Response 通訊方式,物件之間的關係如下圖:


完整的QR Code辨識函式如下,其實並不長:
import Vision

func qrDecode(_ 圖片參數: UIImage) throws -> [String] {
var 結果: [String] = []
let 工作請求 = VNDetectBarcodesRequest()
工作請求.symbologies = [.qr, .ean13, .code39]
print(工作請求)
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
print(處理者)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results as? [VNBarcodeObservation] {
print(處理結果)
for i in 處理結果 {
if let 內容 = i.payloadStringValue {
結果.append(內容)
}
}
}
}
return 結果
}

有了這個 qrDecode() 函式之後,我們就可以試著來辨識常見發票所印的條碼內容。目前台灣使用電子發票,發票證明聯都會印三個條碼:一個一維條碼(Code-39格式),兩個二維條碼(QR Code格式),如下圖。

Code-39與上一節提到的 EAN-13都是一維條碼,看起來都是由黑白條紋構成,但編碼方式並不同。此外還有一個重要差異,EAN-13 因為是國際商品碼,其中國碼、廠商碼都得經過申請,不能隨便印;而 Code-39 則只提供編碼規格,內容可隨便定,長度也不受限,因此廣受歡迎。


一般掃描條碼需開啟相機鏡頭,不過SwiftUI目前無法直接開啟鏡頭,還得借助UIKit,因此我們改用 PhotosPicker 開啟相簿的方式,來掃描已拍照存檔的條碼圖片。

這裡用 PhotosPicker 開啟相簿程式取自第4單元第11課4-11d,其中兩個狀態變數之一要改成 @Binding,以便雙向傳遞「圖片」參數。
import SwiftUI
import PhotosUI

// 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()
}
}
}

有了 qrDecode() 辨識條碼以及「相簿單選」選擇圖片來源,我們就可以寫一個簡單的SwiftUI視圖,來顯示條碼圖片以及條碼的內容字串。三段結合起來,完整範例程式如下:
// 5-1b QR Code Scanner
// Created by Heman, 2023/02/12

import SwiftUI
import PhotosUI
import Vision

func qrDecode(_ 圖片參數: UIImage) throws -> [String] {
var 結果: [String] = []
let 工作請求 = VNDetectBarcodesRequest()
工作請求.symbologies = [.qr, .ean13, .code39]
print(工作請求)
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
print(處理者)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results as? [VNBarcodeObservation] {
print(處理結果)
for i in 處理結果 {
if let 內容 = i.payloadStringValue {
結果.append(內容)
}
}
}
}
return 結果
}

// 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()
}
}
}
struct 掃描條碼: View {
@State var 條碼內容: [String] = []
@State var 條碼圖片: UIImage? = nil
var body: some View {
VStack {
if 條碼圖片 == nil {
相簿單選(圖片: $條碼圖片)
} else {
Image(uiImage: 條碼圖片!)
.resizable()
.scaledToFit()
.onAppear {
do {
條碼內容 = try qrDecode(條碼圖片!)
} catch {
print("掃描發生錯誤")
}
}
.onTapGesture { // 重新選擇圖片
條碼內容 = []
條碼圖片 = nil
}
}
ForEach(條碼內容.indices, id: \.self) { i in
let 字數 = 條碼內容[i].count
Text("條碼內容(\(字數)字元):\n" + 條碼內容[i])
.padding()
.background(Color.yellow.opacity(0.5))
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(掃描條碼())

執行結果如下圖,三個條碼的辨識內容為:
  • 一維條碼(Code-39)內容為”11202JP254166467539”,即(民國)112年02月份、發票號碼 JP25416646、隨機碼7539。

  • 左邊的QR Code內容為 ”JP254166461120107753900000000000000590000000090620706P0lFl5GBBAbuTAp4Fuotqg==:**********:4:6:1:21Plus香草烤雞腿時蔬餐:1:89:”,包含了發票號碼、隨機碼、購買品名、數量、單價等內容。

  • 右邊的QR Code內容為”*當筆購-好菌對策ABC纖姿飲39元:99:0:當筆購-健達/能多益2件25元:99:0:當筆購-芊柔紙巾買一送一:99:0”,此內容是備註,有些廠商會註明交易明細,有些則空白,小七這個發票則寫入加購促銷的商品。


筆者共測試掃描三家廠商的條碼,包括 7-11、吉福(含交易明細)、時報出版的書籍(ISBN國際書碼),執行結果參考以下影片:


💡 註解
  1. 要測試本節範例程式前,請記得自備發票照片,加入相簿中,以便讓程式讀取。
  2. 所謂「電腦視覺」,並不是電腦加上相機鏡頭即可,而是背後的軟體須具備人工智慧,能夠辨識標的才行,故後面課程會以「AI視覺」來稱呼。
  3. VNDetectBarcodesRequest 工作請求的 symbologies 屬性用來指定辨識的條碼種類,symbology 字面意思是「符號學」,在此指條碼類別。
  4. VNImageRequestHandler 可處理的圖片物件,包括 CGImage, CIImage, CVPixelBuffer (影片), NSData (原始圖檔)等低階類型,本單元一律使用 CGImage 物件,CGImage 可支援 JPG, PNG 等圖片格式。
  5. 對Swift 錯誤處理相關語法不熟悉的同學,記得回頭看第3單元第8課
  6. 對 PhotosPicker 不熟悉者可參考第4單元第11課
  7. @Binding 用法說明請參考第4單元第10課4-10b
  8. 財政部自2010年左右開始推行電子發票,經過十多年努力,目前台灣絕大多數消費都已經採用電子發票,是非常成功的數位政策。
  9. 發票的QR Code可能包含交易明細,雖然不是個人資料,但也和個人隱私有關,有使用這類App的同學要稍加注意。
  10. CGImage, UIImage, Image 圖片物件的關係,就如同 AppKit, UIKit, SwiftUI 傳承歷史,後者(較新的)可包容前者(較舊的)格式,也就是說,Image 可包含 UIImage 物件,而 UIImage 又可將 CGImage 包入其中。
  11. 至於前一節所用的 CoreImage 框架(約2004年發布),其中也有一個 CIImage 物件,用來存放2D繪製的圖形(如條碼)。CoreImage 框架主要用於繪製2D圖形、製作濾鏡、影像處理等功能,可說是第4單元Canvas畫布的內部核心功能。
CUNNING

UIImage要轉換或儲存會比較方便

2023-05-19 9:49
5-1c 文字辨識

上一節用 AI 視覺來辨識條碼,若與商店用的光學掃描機比較,AI 視覺可以一次辨識多個不同種類的條碼,不用一個一個掃描,方便許多。

當然,AI 視覺的能力遠不僅於此,只要是圖片中的物品,AI 就有機會加以辨識。Apple 的視覺(Vision)框架,在未加載額外資料模型的前提下,能辨識以下幾種內容(部分進階內容未列出):
# Vision (工作請求)物件 辨識
內容
支援種類 章節
1 VNDetectBarcodesRequest 條碼 一維及二維共20多種條碼 5-1b
2 VNRecognizeTextRequest 文字 繁中、簡中、日、韓、越、 英、法、德、義、西、俄、 葡等12種,含印刷及手寫 5-1c
3 VNDetectRectanglesRequest 矩形 圖片中的矩形區域(如條碼) -
4 VNDetectFaceRectanglesRequest 人臉 圖片中的臉部(矩形範圍) 5-2a
5 VNDetectHumanRectanglesRequest 人形 圖片中的人形(矩形範圍) 5-2b
6 VNRecognizeAnimalsRequest 動物 目前僅能辨識貓和狗 5-2b
7 VNDetectFaceLandmarksRequest 五官 辨識臉部五官的位置 5-3a
8 VNDetectHumanBodyPoseRequest 姿勢 辨識軀體及手腳關節的位置 5-3b
9 VNDetectHumanHandPoseRequest 手掌 辨識手掌細部關節 5-3c
10 VNDetectContoursRequest 輪廓 辨識物品的輪廓(等高線) -
11 VNDetectHorizonRequest 水平線 偵測物品的水平邊緣 -

若要辨識其他特定物品,例如動植物、車輛、建築、商品…等,通常需導入額外的「資料模型」,在後面課程會有範例。

本節就用 VNRecognizeTextRequest 來辨識發票圖片的中、英文與數字。具體做法與上一節非常類似,以下用同樣三個步驟來比較兩者差異:
  • 步驟1. 產出一個「工作請求」物件,將條碼辨識請求 VNDetectBarcodesRequest 改為文字辨識請求 VNRecognizeTextRequest:
    // 5-1b 寫法:
    let 工作請求 = VNDetectBarcodesRequest()
    工作請求.symbologies = [.qr, .ean13, .code39]
    // ⬇︎⬇︎⬇︎⬇︎⬇︎
    // 5-1c 改為:
    let 工作請求 = VNRecognizeTextRequest()
    print(try 工作請求.supportedRecognitionLanguages())
    工作請求.recognitionLanguages = ["zh-Hant", "en-US"]

    因為這是第一次使用文字辨識,故先以 print() 列出所支援的語言種類,才知道該如何設定。此行 print() 輸出如下:
    ["en-US", "fr-FR", "it-IT", "de-DE", "es-ES", "pt-BR", "zh-Hans", "zh-Hant", "yue-Hans", "yue-Hant", "ko-KR", "ja-JP", "ru-RU", "uk-UA"]

    根據Apple原廠文件,其中 “zh-Hans” 是簡體中文,”zh-Hant” 是繁體中文,預設僅能辨識英文及數字,若要辨識中英文,必須將 “zh-Hant” 寫在陣列最前面,而且後面只能跟隨一個”en-US”。

  • 步驟2. 產出一個「處理者」(Handler)物件,加入要辨識的圖片,與上一節完全相同:
    // 不變:
    if let 影像 = 圖片參數.cgImage {
    let 處理者 = VNImageRequestHandler(cgImage: 影像)
    ...
    }

  • 步驟3. 開始進行辨識,將辨識結果轉成 [VNRecognizedTextObservation](文字觀測結果)的陣列類型。
    // 5-1b 寫法:
    try 處理者.perform([工作請求])
    if let 處理結果 = 工作請求.results as? [VNBarcodeObservation] {
    ...
    }
    // ⬇︎⬇︎⬇︎⬇︎⬇︎
    // 5-1c 改為:
    try 處理者.perform([工作請求])
    if let 處理結果 = 工作請求.results as? [VNRecognizedTextObservation] {
    ...
    }

從以上大致可看出規律,Vision 的每一種辨識內容,工作流程都很類似,只是「工作請求」與「觀測結果」(Observation)兩個物件有所不同,這很合理,因為辨識不同內容就該產生不同的資料屬性。故進一步細化物件關係圖如下:


將此函式名稱改為「圖轉文()」,完整函式如下,注意本節所修改的地方:
import Vision

func 圖轉文(_ 圖片參數: UIImage) throws -> [String] {
var 結果: [String] = []
let 工作請求 = VNRecognizeTextRequest()
print(try 工作請求.supportedRecognitionLanguages())
工作請求.recognitionLanguages = ["zh-Hant", "en-US"]
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
// print(處理者)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results as? [VNRecognizedTextObservation] {
// print(處理結果)
for i in 處理結果 {
if let 字串 = i.topCandidates(1).first?.string {
結果.append(字串)
}
}
}
}
return 結果
}

文字辨識結果類型為[VNRecognizedTextObservation],其中對任何一段文字的辨識,可能產生多筆不同機率(或稱”confidence”信心程度)的結果,因此要取得各段辨識後的字串,有點小麻煩。在函式末的這行程式碼:
if let 字串 = i.topCandidates(1).first?.string

必須先用 topCandidates(1) 物件方法,拿到辨識機率(信心程度)最高的前幾名(參數1表示只取1名),然後選陣列第一個元素(first?),才能取得其字串值(string)。

寫好辨識文字的函式,接下來就容易多了,同樣借用上一節的第二段「相簿單選」與第三段主視圖(改名為「掃描文字」),組合起來就是完整的範例程式:
// 5-1c 文字辨識(Image to Text)
// Created by Heman, 2023/02/15

import SwiftUI
import PhotosUI
import Vision

func 圖轉文(_ 圖片參數: UIImage) throws -> [String] {
var 結果: [String] = []
let 工作請求 = VNRecognizeTextRequest()
print(try 工作請求.supportedRecognitionLanguages())
工作請求.recognitionLanguages = ["zh-Hant", "en-US"]
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
// print(處理者)
try 處理者.perform([工作請求])
if let 處理結果 = 工作請求.results as? [VNRecognizedTextObservation] {
// print(處理結果)
for i in 處理結果 {
if let 字串 = i.topCandidates(1).first?.string {
結果.append(字串)
}
}
}
}
return 結果
}

// 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()
}
}
}

struct 掃描文字: View {
@State var 文字內容: [String] = []
@State var 文字圖片: UIImage? = nil
var body: some View {
VStack {
if 文字圖片 == nil {
相簿單選(圖片: $文字圖片)
} else {
Image(uiImage: 文字圖片!)
.resizable()
.scaledToFit()
.onAppear {
do {
文字內容 = try 圖轉文(文字圖片!)
} catch {
print("掃描發生錯誤")
}
}
.onTapGesture { // 重新選擇圖片
文字內容 = []
文字圖片 = nil
}
}
if 文字內容 != [] {
Text("--文字辨識結果--\n" + 文字內容.joined(separator: "\n"))
.padding()
.background(Color.yellow.opacity(0.5))
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(掃描文字())

執行結果如下,辨識出來的文字相當準確,繁體中文、英文、數字幾乎都正確,連小七的商標美術字也辨識出來(最後一個字母是小寫n並沒有錯),不過還是有兩個小錯誤:「賣方90620706」誤為「賣方90620705」,最後一位數字有誤;還有圖片最右下角「機2」誤認為「機,」。
7-ELEVEn
電子發票證明聯
112年01-02月
JP-25416646
2023-01-07 18:03:39
隨機碼:7539 總計:89
賣方90620705
小碧潭 232225 序114910 機,

以總數95個字元(不含空白與換行)計算,只有兩個字元錯誤,正確率約98%,算是相當不錯了。


💡 備註
  1. 本節所用圖片,沿用上一節已放入相簿中的7-11發票圖片。
  2. 若以成本比較的話,一維條碼的掃描器最便宜,其次是二維條碼掃描器,最貴的當然是AI視覺。
  3. 此處繁體中文的代碼”zh-Hant”,其中 “zh” 是由 ISO-639 標準所定語言代碼,表示「中文」;”Han”代表漢字,”t” 代表”traditional”(傳統的),即繁體或正體中文。有些程式也用 “zh-TW” 來表示台灣所用的繁體中文,”TW”是 ISO-3166 標準所定的國家或地區代碼。
  4. 為什麼AI視覺的處理結果要轉成 […Observation] 類型?因為這些「觀測」(Observation)物件裡面,除了有AI視覺辨識出來的字串,還有其他資料,包括辨識的信心程度(confidence)以及文字在圖片的位置等等。
    let 處理結果 = 工作請求.results as? [VNRecognizedTextObservation]

  5. 如果仔細觀察5-1b, 5-1c這兩節的執行結果,再比對第三段主視圖程式碼,會發現一個相當嚴重的問題:在相簿點選完圖片之後,應該先顯示圖片,再呼叫第一段函式「圖轉文()」進行AI辨識,等辨識完才會顯示文字。但實際結果卻是點選相簿後,要等到辨識完成,才一起顯示圖片與文字,感覺中間卡住了,為什麼會這樣?
    Image(uiImage: 文字圖片!)
    .resizable()
    .scaledToFit()
    .onAppear {
    do {
    文字內容 = try 圖轉文(文字圖片!)
    } catch {
    print("掃描發生錯誤")
    }
    }

  6. 第二段「相簿單選」程式碼會重複出現在後面兩課,在 Swift Playgrounds 裡面可放在共享程式碼(SharedCode.swift,如上圖左側欄)中,即可避免重複。不過為了閱讀方便,本單元一律不用共享程式碼,這樣在debug時也會比較容易。
  7. 前面序言曾提過,AI 程式執行結果帶有機率性,在每一台設備執行結果未必相同。本文所用設備為Mac mini 2018,在筆者另一台Macbook Air M1上面,對同一張7-11發票照片,「賣方90620706」與「機2」兩處都正確辨識,但「電子發票」誤為「電子發栗」,正確率約99%。

5-1d [條碼+文字]同時辨識

同學們有沒有發現,前兩節的條碼與文字辨識,是作用在同一張圖片(7-11發票)上,這兩件事(條碼掃描、文字辨識)其實可同時進行,因為 Vision 的工作請求是以陣列型態傳給處理者,意謂能同時傳入多個工作請求,也就是說,我們可以這樣寫:
let 條碼辨識請求 = VNDetectBarcodesRequest()    // 請求1
條碼辨識請求.symbologies = [.qr, .ean13, .code39]

let 文字辨識請求 = VNRecognizeTextRequest() // 請求2
文字辨識請求.recognitionLanguages = ["zh-Hant", "en-US"]

...
try 處理者.perform([條碼辨識請求, 文字辨識請求]) // 同時交給處理者


這樣有什麼好處呢?簡單地說,就是速度較快。多個工作交給同一個處理者的執行速度,會比一個工作交給一個處理者依序執行快得多,可能是利用Apple神經網路晶片的關係,這是Apple原廠建議的寫法。此時物件之間的關係如下:


不過,這並沒有解決上一節註解曾提到的問題:「執行時,在點選好圖片到辨識完成之間,螢幕反應會卡住」。這是因為AI視覺開始執行辨識(處理者.perform())時,會佔掉CPU所有時間,以至於視圖無法更新畫面。

解決辦法很簡單,就是利用第3單元第7課教過的 async/await,將整個辨識函式移到背景(由另一顆CPU核心)執行。修改函式時只要多加一個關鍵字,將原來 throws 改為 async throws 即可,就這麼簡單!

順便將條碼與文字辨識兩個工作請求合在一起處理,處理結果則分別取出辨識後的內容字串,基本上就是整合前兩節寫過的程式碼:
import Vision

func 圖轉文(_ 圖片參數: UIImage) async throws -> [String] {
var 結果: [String] = []
let 條碼辨識請求 = VNDetectBarcodesRequest()
條碼辨識請求.symbologies = [.qr, .ean13, .code39]
let 文字辨識請求 = VNRecognizeTextRequest()
文字辨識請求.recognitionLanguages = ["zh-Hant", "en-US"]
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
// print(處理者)
try 處理者.perform([條碼辨識請求, 文字辨識請求])
if let 條碼處理結果 = 條碼辨識請求.results as? [VNBarcodeObservation] {
// print(條碼處理結果)
for i in 條碼處理結果 {
結果.append("--條碼辨識結果--")
if let 內容 = i.payloadStringValue {
結果.append(內容)
}
}
}
if let 文字處理結果 = 文字辨識請求.results as? [VNRecognizedTextObservation] {
// print(文字處理結果)
結果.append("--文字辨識結果--")
for i in 文字處理結果 {
if let 字串 = i.topCandidates(1).first?.string {
結果.append(字串)
}
}
}
}
return 結果
}

相對應的,呼叫非同步的「圖轉文()」時需加上 try await,且所在的視圖修飾語要由 .onAppear 改為 .task(.task 尾隨的匿名函式會整個移到背景執行):
ZStack {
Image(uiImage: 條碼文字圖!)
.resizable()
.scaledToFit()
.task {
do {
文字內容 = try await 圖轉文(條碼文字圖!)
} catch {
print("掃描發生錯誤")
}
}
.onTapGesture { // 重新選擇圖片
文字內容 = []
條碼文字圖 = nil
}
if 文字內容 == [] {
ProgressView()
.scaleEffect(2.5)
}
}

這時,相簿圖片很快就先顯示出來,但是到辨識完成之間會有幾秒鐘時間,所以在圖片上用 ZStack 加一個 ProgressView() 提示等候,等取得字串後,ProgressView() 自然會消失。若沒有改用 async/await,即使有 ProgressView() 也可能看不到。

這樣改下來,雖然變動不大,但程式執行起來就順暢多了!最後完整的範例程式如下:
// 5-1d 條碼+文字同時辨識
// Created by Heman, 2023/02/16
import SwiftUI
import PhotosUI
import Vision

func 圖轉文(_ 圖片參數: UIImage) async throws -> [String] {
var 結果: [String] = []
let 條碼辨識請求 = VNDetectBarcodesRequest()
條碼辨識請求.symbologies = [.qr, .ean13, .code39]
let 文字辨識請求 = VNRecognizeTextRequest()
文字辨識請求.recognitionLanguages = ["zh-Hant", "en-US"]
if let 影像 = 圖片參數.cgImage {
let 處理者 = VNImageRequestHandler(cgImage: 影像)
// print(處理者)
try 處理者.perform([條碼辨識請求, 文字辨識請求])
if let 條碼處理結果 = 條碼辨識請求.results as? [VNBarcodeObservation] {
// print(條碼處理結果)
for i in 條碼處理結果 {
結果.append("--條碼辨識結果--")
if let 內容 = i.payloadStringValue {
結果.append(內容)
}
}
}
if let 文字處理結果 = 文字辨識請求.results as? [VNRecognizedTextObservation] {
// print(文字處理結果)
結果.append("--文字辨識結果--")
for i in 文字處理結果 {
if let 字串 = i.topCandidates(1).first?.string {
結果.append(字串)
}
}
}
}
return 結果
}

// 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()
}
}
}

struct 掃描文字: View {
@State var 文字內容: [String] = []
@State var 條碼文字圖: UIImage? = nil
var body: some View {
VStack {
if 條碼文字圖 == nil {
相簿單選(圖片: $條碼文字圖)
} else {
ZStack {
Image(uiImage: 條碼文字圖!)
.resizable()
.scaledToFit()
.task {
do {
文字內容 = try await 圖轉文(條碼文字圖!)
} catch {
print("掃描發生錯誤")
}
}
.onTapGesture { // 重新選擇圖片
文字內容 = []
條碼文字圖 = nil
}
if 文字內容 == [] {
ProgressView()
.scaleEffect(2.5)
}
}
}
if 文字內容 != [] {
Text(文字內容.joined(separator: "\n"))
.padding()
.background(Color.yellow.opacity(0.5))
}
}
}
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(掃描文字())

執行影片如下,請特別觀察從「點選相簿」到「辨識完成」之間的視圖變化:


第1課小結

第1課開始學習人工智慧的視覺辨識程式設計,似乎沒有想像中困難,或許因為困難之處 Apple 原廠都幫我們做好了,這也是善用「框架」寫程式的好處,可以輕易站在巨人肩膀上。

對AI視覺來說,辨識的「準確率」很重要。若比較條碼與文字兩者辨識的準確度,很明顯文字辨識比不上條碼,不管用光學掃描機或AI掃描條碼,幾乎不會誤判,而文字辨識則不容易做到100%準確。為什麼文字辨識無法像條碼掃描那麼精準?

因為文字主要是給人看的,而條碼則是專為電腦化而設計。要讓電腦模仿人類視覺辨識文字本來就不容易,在本課範例中,辨識印刷字體的正確率達98%到99%,已算相當不錯了,但即便如此,也無法滿足商業應用,如果商店用文字辨識技術去掃描商品,只要看錯一兩個數字,例如把價格600元當成500元,那就虧大了。

然而條碼卻可以做到100%正確,因為條碼就是為電腦而生的數位編碼,常內含校驗與冗餘機制,即使用傳統程式,也可100% 正確讀出內容文字,這也是條碼在過去數十年就已廣泛應用的原因。

另外,從本課範例程式也可看出,人工智慧程式相較於過去4個單元所學,需要更多運算資源(CPU時間),一定要活用 async/await 非同步指令,將工作分派到背景,透過CPU多核心加速運算,才能讓執行過程更順暢。

最後,當今人工智慧程式最重要的特性之一,就是執行結果大多帶有機率性,如上一節5-1c範例,在兩台設備上辨識同一張圖片的文字,會得出稍有差異的結果。這打破過去程式具有可預期的因果關係,這個變化如同古典牛頓力學轉變到量子力學一樣,具有重大意義。
第2課 人臉辨識

人類嬰兒剛出生的前幾個月,視覺尚未發展成熟,眼睛還無法聚焦或分辨色彩,僅對光線明暗與移動變化有反應,到六個月以後,才逐漸分辨人與物,第一個學會辨識的,當然就是母親的臉。

對高等生物而言,臉部辨識攸關生存,是視覺的首要任務。因此,當我們看一張圖片,最先聚焦的總是人臉,特別是眼睛的部位,這是我們生物遺傳的本能。

電腦沒有生物本能,所有技能都靠後天學習,更精確地說,是靠程式設計師賦予智慧。但問題是,人類是如何辨識人臉呢?其中有明確的邏輯與規則嗎?恐怕我們自己也不清楚,但絕對不是一個像素一個像素去分析。

因此,要讓只懂像素分析的電腦辨識人臉並沒有那麼簡單,經過數十年嘗試與努力才得以突破,目前辨識率已相當高。在技術上,人臉辨識可拆解成幾個步驟,首先要偵測人臉在圖片中的位置,其次偵測五官位置,接下來計算五官特徵,最後才能比對特徵、辨識身分。

5-2a 人臉偵測(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課介紹的「畫框」陣列,不過較特別的是,這裡用的是「正規化座標」。

「正規化座標」類似數學座標,以圖片左下角為座標原點(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 Heman, 2023/02/20
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(臉部掃描())

如果一張圖裡面偵測到多張人臉,也會畫出多個外框。

[2024/12/30修正] 某些情況下,有些照片會出現辨識位置錯誤(如下圖),這與照片方向有關,請參考後面的修正


💡 註解
  1. CGPoint, CGSize, CGRect, Path 等物件的用法請參考第4單元第5課畫布Canvas
  2. 有關正規化座標(Normalized Coordinates),請參考第4單元第7課4-7b介紹。
  3. "Bounding Box" (外框、邊框)是人工智慧的術語,指視覺辨識中,包含所有辨識特徵的矩形範圍。bounding 是 bound 的動名詞,有多重字義,在這裡(boudingBox)為束縛、約束、界定的意思。
5-2b 人物貓狗辨識

現在的相機除了具備人臉偵測的功能之外,通常也會在合照時偵測人像(全身或半身)以及貓狗寵物,一張照片若有可愛寵物點綴其中,就容易帶來歡樂氣氛。

要辨識照片中的人像與寵物相當簡單,工作請求分別用 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段「網址抓圖」,在文字框中輸入圖片網址,用程式下載以供辨識之用:
// 第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寫過的搜尋框,輸入網址後,利用 URLSession.shared.data() 下載圖片(這部份可參考第4單元4-9a範例),最後用 @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 Heman, 2023/02/24
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
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(照片掃描())

💡 註解
  1. 第5段輸入的網址必須是圖片本身的網址(通常末尾是 .jpg 或 .png),而不是看到圖片的網頁(通常末尾是 .html),若下載資料無法直接轉成圖片,會在主控台列印錯誤訊息。
  2. 輸入網址的具體操作方式,通常先用瀏覽器找到目標圖片,用瀏覽器本身的功能「複製圖片位址」,然後執行範例程式,點入文字框中,按⌘-V將網址貼上(如果文字框還留著舊網址,記得先按⌘-A全選,再⌘-V貼上取代舊網址)。
  3. 也可以用本節範例的圖片網址
    https://images.unsplash.com/photo-1628071719729-de25b1794632?ixlib=rb-4.0.3&auto=format&fit=crop&w=1080&q=80

  4. VNDetectHumanRectanglesRequest 預設僅找出半身人像(上半身),若需偵測全身,需設定「人體外框.upperBodyOnly = false」。
5-2c 使用App模式

第4單元第9課4-9f曾經介紹過,在 Swift Playgrounds 裡面寫程式,有三種模式可用:(1) 最早的遊樂園模式 Xcode Playground (2)電子書模式 Swift Playground Book (3) App模式 Swift Package Manager。

本系列課程大多採用第二種,也就是電子書(Swift Playground Book)模式,在這種模式之下,每一頁都是獨立完整的程式,不同頁面之間彼此不會互相參考。若有共享的程式碼,可置於側欄下方”UserModule”裡面,讓每一頁都可參考使用,如下圖:


例如上一節5段程式碼中,「相簿單選」、「描繪外框」、「網址抓圖」三段可重複利用,就很適合放入 UserModule 共享,UserModule 是一個目錄,底下所有 .swift 檔案都可共享,所以三段可以寫在同一個檔案,也可以分開。

電子書模式很適合用來練習或測試程式,沒有複雜的功能表或多餘的設定,如同一本空白筆記簿,打開就可直接編寫(或剪貼)程式碼。

每當學到一個新物件或新語法時,最好就是在電子書模式下測試,練習各種語法選項,不斷熟悉。由於每一頁都是彼此獨立的,可以不必考慮之前定義過什麼變數或物件名稱。

不過,如果要寫比較長的程式,較好的方式是改用 App 模式(如下圖)。


在 App 模式下,每段程式碼都可彼此參考,沒有一個程式是獨立的,所有程式共同組成一個App。所以必須注意App模式下,變數、函式、物件等自訂名稱,不可以重複命名。

App模式相較於電子書模式,是更成熟的程式開發環境,功能較豐富,畫面可分成幾個區域(參考上圖):
  1. 在左側欄是程式檔案區,可隨意分不同目錄存放;還可拖拉加入本機資源(如JSON、圖片、影片、音樂…等)或外部套件(輸入網址)
  2. 中間欄是程式碼編輯區
  3. 右側欄是預覽區,可預覽App或本段(在編輯區)程式
  4. 最上方還有SwiftUI物件選單
  5. 檔案區上方可設定App屬性(如更改名稱、加入權限)或上架到App Store

SwiftUI 物件選單將所有 SwiftUI 視圖(約70個)中文化,如果知道大略名稱的話,就可搜尋、點選使用,對初學者相當方便,如下圖:


每個視圖常用到多個視圖修飾語(View Modifiers),用來調整視圖的外觀屬性,這些修飾語對寫SwiftUI程式非常重要,物件選單中列出約280個修飾語,非常齊全:


還包括經常用到的系統圖示(SF Symbols)與顏色名稱,很方便查找:



App模式適合「個人」練習寫程式或開發App(若需多人協作或版本控制,則以Xcode為宜),程式長度從100行到1萬行應該都沒問題,例如筆者整合第4單元「動畫與繪圖App」,總計約3,500行左右,就是改成App模式並上架到App Store。


使用App模式非常簡單,以下影片是將上一節5-2b改成App模式的過程:

💡 備註
  1. 為了保持每節課程內容的完整性,後面課程仍以電子書模式為主,以方便閱讀;但鼓勵大家改寫成App模式,以熟悉進階的功能。
  2. App模式的檔案(.swiftpm)相容性更好,可在Swift Playgrounds 與 Xcode使用,電子書模式(.playgroundbook)則只能用於Swift Playgrounds。
  3. 要熟悉macOS 常用的快捷鍵:⌘-C 複製、⌘-X 剪下、⌘-V 貼上、⌘-Z 還原、⌘-A 全選。
第3課 人體細部辨識

5-3a 臉部五官辨識(Face Landmarks)

上一課我們利用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 Heman, 2023/03/04
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(照片掃描())

💡 註解
  1. AI 視覺辨識的過程,通常會將畫面分區,從大範圍逐步縮小到臉部,找出臉部外框,然後再進一步從外框範圍內找出五官位置,這與AI背後的機器學習訓練過程有關。
  2. 我們的眼睛是怎麼辨識人臉呢?似乎和AI不同,我們通常是先看出眼睛、鼻子、嘴巴等五官,然後才在心中組成臉形。
  3. 如果AI視覺臉部外框找錯位置,辨識五官就會一錯再錯,如下圖:

  4. 此例(上圖)主控台輸出的處理結果如下,外框辨識的信心程度是1.0 (100%),臉部五官信心程度約為0.89 (89%),表示很有信心。
    處理結果:[<vnfaceobservation: 0x15a94ae40=""> 0897808B-AF8E-4E78-AAAC-C5CE3F6AC493 VNDetectFaceRectanglesRequestRevision2 confidence=1.000000 boundingBox=[0.625709, 0.126032, 0.270598, 0.360797] VNFaceLandmarks2D [VNRequestFaceLandmarksConstellation76Points, confidence=0.891891]]</vnfaceobservation:>

  5. 本節使用的VNDetectFaceLandmarksRequest,目前是第3版(revision 3),有76個特徵點;之前第2版有65個特徵點。顯然未來還有機會提升版本,強化功能與精準度。
  6. 本節範例所使用圖片網址如下:
    • https://upload.wikimedia.org/wikipedia/commons/thumb/4/42/Face_Measurements_Lombroso's_method.jpg/1920px-Face_Measurements_Lombroso's_method.jpg

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

  7. 本節AI所辨識的臉部五官,並未包含額頭與耳朵,為什麼會這樣?
  8. 有了臉部五官這76個特徵點,是不是就能判別各種不同表情(高興、憤怒、厭惡、悲傷…)?
  9. 這76個特徵點對每個人都是獨一無二的嗎?也就是說,利用這76點,就能夠辨識出每一個人嗎?
  10. 人臉辨識的有趣應用(Face8 台灣臉霸),想想看背後的原理。https://kikinote.net/160586
  • 5
內文搜尋
X
評分
評分
複製連結
Mobile01提醒您
您目前瀏覽的是行動版網頁
是否切換到電腦版網頁呢?