私はLEGOが好きです。最近は大人向けのセットも発売されており、LEGOブームの波が来ている気がします。そんな大人向けのシリーズの一つにLEGOアートというものがあります。要はドット絵なんですが、LEGO製だとインテリアとして成り立つものになっています。
さて、Swift愛好家のみなさまはSwiftUIやSwift Package Managerを既に使い倒していると思いますが、私はあまり使いこなせていません。業務でガッツリ使っていないというのが理由の一つですが、シンプルにモチベーションの上がる題材がなく、勉強が億劫になっていました。しかし、先日ふと「写真をレゴアートみたいにしたい」と天啓を得たのでライブラリを作ることにし、ついでにSwiftUIとSwift Package Managerの勉強もすることにしました。
成果物
レゴブロックの色をSwiftで扱うためのライブラリ:LegoColorsと画像をレゴアートに変換するライブラリ:LegoArtFilterを作成しました。レゴブロックの色については、Bricklinkのカラーガイドを参考にしています。
何はともあれ、できたものをご覧いただきましょう。iOSでもmacOSでも動くデモを用意しました!
https://github.com/Kyome22/LegoArtFilter
ユーザが選択した画像を実際にレゴブロックに存在する色のドット絵に描き変えます。
レゴのスタッド(1x1ブロックのポッチのこと)表現も、丸くてポッチあり・丸くてツルツル・四角くてぽっちあり・四角くてツルツルの計4種類から選択でき、最大スタッド幅も変更できます(これで解像度の良し悪し/使用するブロックの量を調節できる)。
ライブラリの紹介
まず、名前にLego
を含めてしまっているので、商用利用は不可でお願いいたします。🙇♂️
LegoColors
CGColorやUIColor、NSColorを渡すとそれに最も近いレゴブロックの色を取得することができるLegoColor
が扱えるようになります。今のところ全部で83色です。
// ブロックの色名からCGColorを取得できます。
let color = LegoColor.darkTurquoise.color
// CGColorから近似したLegoColorを取得
// カラースペースはsRGBを基準にしている
let cgColor = CGColor(srgbRed: 0.12, green: 0.34, blue: 0.56, alpha: 1.0)
let legoColor = LegoColor(cgColor: cgColor)
// iOSではUIColor、 macOSではNSColorからLegoColorを取得可能
let legoColor = LegoColor(uiColor: UIColor.blue)
let legoColor = LegoColor(nsColor: NSColor.yellow)
LegoArtFilter
CIImageやUIImage、NSImageを渡すとレゴアート風の画像を生成することができます。
// CIImageを基にCGImageを生成
let input = CIImage()
if let legoArt = LegoArt(ciImage: input) {
let output = legoArt.exportCGImage()
}
// UIImageをレゴアート風のUIImageに変換
let input = UIImage()
if let legoArt = LegoArt(from: input) {
let output = legoArt.exportUIImage()
}
// NSImageをレゴアート風のNSImageに変換
let input = NSImage()
if let legoArt = LegoArt(from: input) {
let output = legoArt.exportNSImage()
}
// オプションを指定することも可能
// baseColor: PNGのような透過画像の下地色を指定
// StudType: ブロックの見た目を指定可能(丸、ツルツル丸、四角、ツルツル丸)
// maxStud: ブロックの最大幅を指定
// studPixelWidth: ブロック1ポッチの幅のピクセル数を指定
let legoArt = LegoArt(ciImage: CIImage,
baseColor: CGColor,
studType: StudType,
maxStud: Int,
studPixelWidth: Int)
実装面の苦難
iOSとmacOSの両方で動くライブラリ作成
Swift Package Managerでライブラリを作るには、File -> New -> Project -> Multiplatform -> Swift Package を選択すればOKです。
Package.swiftではplatforms
を指定するとOSごとに利用可能なバージョンも指定できます。
let package = Package(
name: "library name",
platforms: [
.iOS(.v14),
.macOS(.v11)
],
...
}
iOSとmacOSで実装を分けるにはプリプロセッサ・ディレクティブを使います。
// OSでimportを分ける
#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif
// import可能なフレームワークで分ける
#if canImport(UIKit)
let color = UIColor.red
#elseif canImport(AppKit)
let color = NSColor.red
#endif
SwiftUI.Color, CGColor, NSColor, CGColorの使い分け
SwiftUI.Colorは初期化の際に渡すRGBの値をそのまま取り出すことが難しいため、扱いづらいです。
// red, green, blueで初期化した場合
let c = Color(red: 0.12, green: 0.34, blue: 0.56)
let cgColor = c.cgColor! // cgColorを取得できる
print(cgColor.colorSpace!.name!) // extendedSRGB
print(cgColor.numberOfComponents) // 4(r,g,b,a)
let components = cgColor.components!
print(components[0], components[1], components[2])
// 0.11999999731779099 0.3399999737739563 0.5600000023841858
// なぜかちょっと誤差出る
// 色名で取得した場合
let c = Color.orange
print(c.cgColor) // nilになる
#if os(iOS)
let uiColor = UIColor(c) // 一旦UIColorにする
let cgColor = uiColor.cgColor
#elseif os(macOS)
let nsColor = NSColor(c) // 一旦NSColorにする
let cgColor = nsColor.cgColor
#endif
print(cgColor.colorSpace!.name!) // extendedSRGB
print(cgColor.numberOfComponents) // 4(r,g,b,a)
let components = cgColor.components!
print(components[0], components[1], components[2])
// iOS: 0.9999999403953552 0.5843137502670288 0.0
// macOS: 0.9999999403953552 0.6235294938087463 0.03921568766236305
// ↑ちょっと誤差が出る
// モノクロカラーで取得した場合
let c = Color(white: 1.0)
let cgColor = c.cgColor! // cgColorを取得できる
print(cgColor.colorSpace!.name!) // extendedSRGB
print(cgColor.numberOfComponents) // 4(r,g,b,a)
UIColorはUIKit、NSColorはAppKitなので各プラットフォームでしか使えません。ので、初期化のインターフェースは用意しつつ、基本は両方のOSで使えるCGColorを使うようにしました。
NSImage, UIImage, CIImage, CGImageの使い分け
色の時と同様に、UIImageはUIKit、NSImageはAppKitなのでインターフェースは用意しつつ、メインの実装は別のものがいいです。CoreImageのCIImageはCIFilterをはじめとして高速な加工処理に長けています。CoreGraphicsのCGImageはUIImageやNSImageへの変換が簡単ですし、CGContextを用いて図形を描画して画像を生成することができます。
今回は、CIImageで画像のリサイズを行い、レゴ色への変換をはさみ、CGImageでレゴアート画像を生成する流れにしました。
画像の各ピクセルのRGB値の取得で罠あり
とりわけ、縦に長い画像において各ピクセルのRGB値を取得しようとしたところ、無効なピクセルが取得されてしまうことがありました。
表にすると上のような感じで、RGBA全ての値が0になっている領域が発生します。
let cgImage = CIImage(data: Data())!.cgImage!
let data = cgImage.dataProvider!.data!
let length = CFDataGetLength(data)
var rgba = [UInt8](repeating: 0, count: length)
CFDataGetBytes(data, CFRange(location: 0, length: length), &rgba)
// ピクセルのRGBAデータの総数は 4x幅x高さとなるはずが、lengthの方が大きくなってしまう
余計なピクセルを削除するには、一旦仕切り直して画像を生成し直すといいです。
extension CGImage {
func correctImage() -> CGImage? {
let size = CGSize(width: self.width, height: self.height)
guard let cgContext = CGContext(data: nil,
width: Int(size.width),
height: Int(size.height),
bitsPerComponent: 8,
bytesPerRow: 4 * Int(size.width),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else { return nil }
cgContext.draw(self, in: CGRect(origin: .zero, size: size))
return cgContext.makeImage()
}
}
このように生成しなおした画像で各ピクセルのデータを取得すると、4x幅x高さ
とlength
が一致するはずです。
画像をリサイズすると画像の端がバグる
リサイズ後画像端のアルファがバグる現象が度々おきました。透過ピクセルのないjpegファイルなどをリサイズしても端の方のピクセルが半透明になりました。
この現象の理由は簡単で、例えば元々のサイズが1800x1080
で幅を48ピクセルにリサイズしようと軽率にすると、高さが整数ではなくなり、画像は整数の幅/高さで出力されるため、足りない分が透過ピクセル扱いになるようです。
let size = CGSize(width: 1800, height: 1080)
let newWidth: Int = 48
let scale = CGFloat(newWidth) / size.width
let newSize = CGSize(width: scale * size.width,
height: scale * size.height)
print(newSize)
// (48.0, 28.8)
// 幅の方が整数ではない!
// 画像になる時は (48.0, 29.0)になり、端のピクセルが半透明に!
対処としては、リサイズするときにceil()
を使って切り上げにした幅と高さを使えばOKです。
色が思った通りに変換できない現象
黒(RGB=0,0,0)を渡すとDark Brown
になる現象がありました。レゴの色のうち、Blackはrgb=(0.129, 0.129, 0.129)
でDark Brownはrgb=(0.199, 0.000, 0.000)
だったため、より近い方の色が黒ではなく焦茶になってしまいました。黒が意外と明るいんですね。こういうパターンはこのペアしかなかったため、焦茶をリストから除外しました。
iOSとmacOS両方向けのテスト実装
基本的には上で説明したプリプロセッサ・ディレクティブを用いる方法で両プラットフォーム向けのテストを書いていきます。画像を用いたテストをしたい場合は、xcassets
とBundle.module
を用います。Asset Catalog(Media.xcassets)をテストのディレクトリ内に置き、テストに使いたい画像を追加しておきます。
func testNativeImage() {
#if os(iOS)
let uiImage = UIImage(named: "Image Name", in: Bundle.module, compatibleWith: nil)
XCTAssertNotNil(uiImage)
#elseif os(macOS)
let nsImage = Bundle.module.image(forResource: NSImage.Name("Image Name"))
XCTAssertNotNil(nsImage)
#endif
}
SwiftUIでmacOSのドロップダウンメニュー(Pop Up Button)どうやって実装するのか?
シンプルに知識がなくてつまづきました。Menu
とかMenuButton
を使って色々やってみていたのですが、Picker
を使えば一発でした。(最終的にはドロップダウンメニューを使うのはやめましたが...)
Picker("ラベル", selection: $selection) {
Text("アイテム1").tag(0)
Text("アイテム2").tag(1)
Text("アイテム3").tag(2)
}
.pickerStyle(SegmentedPickerStyle())
をさらに使うとアイテムが横並びになったUIになっていい感じでした。
課題
生成したレゴアートを画像として保存する機能を実装したかったのですが、SwiftUIのデータバインドの仕方が不勉強でうまくできませんでした。どなたか教えてくださると嬉しいです。
また、現状では色の変換の際、あらかじめ用意してあるレゴの色リスト一覧を総当たりして一番近い色を選択しているのですが、画像の全てのピクセルに対して同じ処理を行うため、maxStud
が大きい値(つまり出来上がるレゴアートの解像度が高い)だと画像生成の処理が非常に重くなります。アルゴリズムを改善して全てのピクセルに対して同じ処理をせずに、同じ色ならショートカットできるようにしたいのですが、いい方法を思いつきません。