人工智慧程式基礎-QR Code篇
-
何謂 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 Philip, Heman, Jean 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 掃描(AI視覺)
上一節我們學會用字串製作 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 Philip, Heman, Jean 2023/02/12 // Updated by Heman, 2024/12/24 // Revised by Jean, 2025/05/20 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(掃描條碼())
執行結果如下圖,三個條碼的辨識內容為:
1. 一維條碼(Code-39)內容為”11202JP254166467539”,即(民國)112年02月份、發票號碼 JP25416646、隨機碼7539。
2. 左邊的QR Code內容為”JP254166461120107753900000000000000590000000090620706P0lFl5GBBAbuTAp4Fuotqg==:**********:4:6:1:21Plus香草烤雞腿時蔬餐:1:89:”,包含了發票號碼、隨機碼、購買品名、數量、單價等內容。
3. 右邊的QR Code內容為”*當筆購-好菌對策ABC纖姿飲39元:99:0:當筆購-健達/能多益2件25元:99:0:當筆購-芊柔紙巾買一送一:99:0”,此內容是備註,有些廠商會註明含交易明細,有些則空白,小七這個發票則寫入加購促銷的商品。 -
文字辨識
上一節用 AI 視覺來辨識條碼,若與商店用的光學掃描機比較,AI 視覺可以一次辨識多個不同種類的條碼,不用一個一個掃描,方便許多。
本節就用 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)物件,加入要辨識的圖片,與上一節完全相同:
// 5-1b 寫法: try 處理者.perform([工作請求]) if let 處理結果 = 工作請求.results as? [VNBarcodeObservation] { ... } // ⬇︎⬇︎⬇︎⬇︎⬇︎ // 5-1c 改為: try 處理者.perform([工作請求]) if let 處理結果 = 工作請求.results as? [VNRecognizedTextObservation] { ... }
3.開始進行辨識,將辨識結果轉成 [VNRecognizedTextObservation](文字觀測結果)的陣列類型。
// 5-1b 寫法: try 處理者.perform([工作請求]) if let 處理結果 = 工作請求.results as? [VNBarcodeObservation] { ... } // ⬇︎⬇︎⬇︎⬇︎⬇︎ // 5-1c 改為: try 處理者.perform([工作請求]) if let 處理結果 = 工作請求.results as? [VNRecognizedTextObservation] { ... }
從以上大致可看出規律,Vision 的每一種辨識內容,工作流程都很類似,只是「工作請求」與「觀測結果」(Observation)兩個物件有所不同,這很合理,因為辨識不同內容就該產生不同的資料屬性。故進一步細化物件關係圖如下:
pic
將此函式名稱改為「圖轉文()」,完整函式如下,注意本節所修改的地方:
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%,算是相當不錯了。
-
如何用 SwiftUI 製作QR Code