用函數式的 Swift 實現圖片轉字符畫的功能
今天整理 Pocket 中待看的文章,看到這篇《Creating ASCII art in functional Swift》,講解如何用 Swift 將圖片轉成 ASCII 字符。具體原理文中講解的很詳細,不再贅述,但是標題中的 in functional Swift 讓我很感興趣,想知道 functional 到底體現在哪里,于是下載 swift-ascii-art 源碼一探究竟。
Pixel
圖片是由各個像素點組成的,在代碼中像素通過 Pixel 這個 struct 實現。每個像素分配了4個字節,這4個字節 (2^8 = 256) 分別用來存儲 RBGA 的值。
createPixelMatrix
可以通過 createPixelMatrix 這個靜態方法創建一個 width * height 像素矩陣:
- static func createPixelMatrix(width: Int, _ height: Int) -> [[Pixel]] {
- return map(0.. map(0.. let offset = (width * row + col) * Pixel.bytesPerPixel
- return Pixel(offset)
- }
- }
- }
和傳統方法中使用 for 循環來創建多維數組有所不同的是,這里是通過 map 函數實現的。在 Swift 2.0 中, map 函數已經被干掉了,只能作為方法調用。
intensityFromPixelPointer
intensityFromPixelPointer 方法計算并返回像素點的亮度值,代碼如下:
- func intensityFromPixelPointer(pointer: PixelPointer) -> Double {
- let
- red = pointer[offset + 0],
- green = pointer[offset + 1],
- blue = pointer[offset + 2]
- return Pixel.calculateIntensity(red, green, blue)
- }
- private static func calculateIntensity(r: UInt8, _ g: UInt8, _ b: UInt8) -> Double {
- let
- redWeight = 0.229,
- greenWeight = 0.587,
- blueWeight = 0.114,
- weightedMax = 255.0 * redWeight +
- 255.0 * greenWeight +
- 255.0 * blueWeight,
- weightedSum = Double(r) * redWeight +
- Double(g) * greenWeight +
- Double(b) * blueWeight
- return weightedSum / weightedMax
- }
calculateIntensity 方法基于 Y’UV 編碼獲取某個像素的亮度 (intensity) :
- Y’ = 0.299 R’ + 0.587 G’ + 0.114 B’
YUV 是一種顏色編碼方法,Y 表示亮度, UV 用來表示色差, U 和 V 是構成彩色的兩個分量。它的優點是可以利用人眼的特性來降低數字彩色圖像所需要的存儲容量。我們通過這個公式獲取到的 Y 就是亮度的值。
Offset
Pixel 中其實只存了一個值: offset 。 Pixel.createPixelMatrix 創建出來的矩陣是這樣的:
- [[0, 4, 8, ...], ...]
并沒有像想象中那樣存儲了每個像素相關數據,而更像是一個轉換工具,計算 PixelPointer 的灰度值。
AsciiArtist
AsciiArtist 里封裝了一些生成字符畫的方法。
createAsciiArt
createAsciiArt 方法就是創建字符畫:
- func createAsciiArt() -> String {
- let
- // 加載圖片數據,獲取指針對象
- dataProvider = CGImageGetDataProvider(image.CGImage),
- pixelData = CGDataProviderCopyData(dataProvider),
- pixelPointer = CFDataGetBytePtr(pixelData),
- // 將圖片轉成亮度值矩陣
- intensities = intensityMatrixFromPixelPointer(pixelPointer),
- // 將亮度值轉成對應字符
- symbolMatrix = symbolMatrixFromIntensityMatrix(intensities)
- return join("\n", symbolMatrix)
- }
其中 CFDataGetBytePtr 函數返回了圖像的字節數組指針,數組里每個元素都是一個字節,即 0~255 的整數。每4個字節組成了一個 Pixel ,分別對應著 RGBA 的值。
intensityMatrixFromPixelPointer
intensityMatrixFromPixelPointer 這個方法是通過 PixelPointer 生成對應的亮度值矩陣:
- private func intensityMatrixFromPixelPointer(pointer: PixelPointer) -> [[Double]]
- {
- let
- width = Int(image.size.width),
- height = Int(image.size.height),
- matrix = Pixel.createPixelMatrix(width, height)
- return matrix.map { pixelRow in
- pixelRow.map { pixel in
- pixel.intensityFromPixelPointer(pointer)
- }
- }
- }
首先通過 Pixel.createPixelMatrix 方法創建了一個空的二維數組,用來存放數值。然后用兩個 map 嵌套遍歷里面的所有元素,將像素 (pixel) 轉換成亮度 (intensity) 的值。
symbolMatrixFromIntensityMatrix
symbolMatrixFromIntensityMatrix 函數將亮度值數組轉換成字符畫數組:
- private func symbolMatrixFromIntensityMatrix(matrix: [[Double]]) -> [String]
- {
- return matrix.map { intensityRow in
- intensityRow.reduce("") {
- $0 + self.symbolFromIntensity($1)
- }
- }
- }
map + reduce 成功實現了字符串的累加,每次 reduce 都是通過 symbolFromIntensity 方法獲取到亮度值對應的字符。 symbolFromIntensity 方法如下:
- private func symbolFromIntensity(intensity: Double) -> String
- {
- assert(0.0 <= intensity && intensity <= 1.0)
- let
- factor = palette.symbols.count - 1,
- value = round(intensity * Double(factor)),
- index = Int(value)
- return palette.symbols[index]
- }
傳入 intensity ,在確保了值的范圍是 0 ~ 1 之后,通過 AsciiPalette 將它轉換成對應的字符,輸出 sumbol 。
AsciiPalette
AsciiPalette 是用來將數值轉換成字符的工具,像是一個字符畫里的調色板一樣,根據不同的顏色生成字符。
loadSymbols
loadSymbols 加載了所有的字符:
- private func loadSymbols() -> [String]
- {
- return symbolsSortedByIntensityForAsciiCodes(32...126) // from ' ' to '~'
- }
可以看到,我們選用的字符范圍是 32 ~ 126 的字符,接下來就是通過 symbolsSortedByIntensityForAsciiCodes 方法將這些字符按照亮度進行排序。比如 & 符號肯定代表著比 . 暗的區域,那么它是如何比較的呢?請看排序方法。
symbolsSortedByIntensityForAsciiCodes
symbolsSortedByIntensityForAsciiCodes 方法實現了字符串的生成和排序:
- private func symbolsSortedByIntensityForAsciiCodes(codes: Range) -> [String]
- {
- let
- // 通過 Ascii 碼生成字符數組備用
- symbols = codes.map { self.symbolFromAsciiCode($0) },
- // 將字符繪制出來,把字符數組轉換成圖片數組,用于比較亮度
- symbolImages = symbols.map { UIImage.imageOfSymbol($0, self.font) },
- // 將圖片數組轉換成亮度值數組,亮度值的表現形式是圖片中白色像素的個數
- whitePixelCounts = symbolImages.map { self.countWhitePixelsInImage($0) },
- // 將字符數組通過亮度值就行排序
- sortedSymbols = sortByIntensity(symbols, whitePixelCounts)
- return sortedSymbols
- }
其中, sortByIntensity 這個排序方法如下:
- private func sortByIntensity(symbols: [String], _ whitePixelCounts: [Int]) -> [String]
- {
- let
- // 用字典建立 白色像素數目 和 字符 之間的關系
- mappings = NSDictionary(objects: symbols, forKeys: whitePixelCounts),
- // 白色像素數目數組去重
- uniqueCounts = Set(whitePixelCounts),
- // 白色像素數目數組排序
- sortedCounts = sorted(uniqueCounts),
- // 利用前面的字典映射,將排序后的白色像素數目轉換成對應的字符,從而輸出有序數組
- sortedSymbols = sortedCounts.map { mappings[$0] as! String }
- return sortedSymbols
- }
小結
簡單了過了一下項目,可以隱約感覺到一些函數式風格的氣息,主要體現在一下幾個方面:
map reduce 等函數的應用恰到好處,自如處理數組的轉換和拼接。
通過 input 和 output 進行數據處理,比如 sortByIntensity 方法和 symbolFromIntensity 方法。
很少有狀態和屬性,更多的是直接的函數轉換,函數邏輯不依賴外部變量,只依賴于傳入的參數
代碼感覺簡單輕快。通過這個簡單的小例子,驗證了前面在 函數式的特性 中學習到的東西。
感覺很贊!