graphics.hatenablog.com

技術系テクニカルアーティストのあれこれ

QImage の読み書きあれこれ

たしかに GUI 関係に限らずサムネ画像とか直で DB にいれたりするのってたまに便利よね。
QImage でやったことはなかったので軽く試してみた。

前提知識

Qt/PySide における「画像」の種類と使い分け

自分がああだこうだ言うよりもきれいにまとまってる記事があった。
qiita.com

クラス 概要 役割
QImage ハードウェア非依存の画像バッファ 画像をメモリ上で扱う
QPixmap GPU用の画像バッファ 画像を GUI 上で扱う
QIcon アイコン特化型のQPixmap イコン画像を GUI 上で扱う
QBitmap モノクロ特化型の QPixmap モノクロ画像を GUI 上で扱う

QPixmap と QIcon は描画デバイス用のデータとのことで、おそらく何かしらのかたちで VRAM にアップロードされた(あるいはされるための)メモリ領域ということになる。QImage のドキュメント を読むかぎりでは GPU 上で素直に扱えなさそうな形式も含まれているので、アップロードに伴ってなんらかの変換 *1 が行われていることはおそらく間違いない。

使い分けに関してもこれに則って行えばよく、上の記事にもあるとおり、ファイルから読むときや CPU 上でピクセルを舐めるときは QImageGUI 上に表示する(≒GPUで処理する)ときは QPixmap/QIcon/QBitmap でよさそう。QPixmap はコンストラクタにファイルパスを与えるかたちでも初期化できるので、GUI 表示のためにしか使わないときはそれで差し支えない。

あとは QPicture ってのもあるけど、これは QPainter の発行した各種描画コマンドを記録再生するためのものなので、picture って名前だけど「画像」と呼ぶのはちょっと厳しい。

QImage/QPixmap の相互変換

関数 概要
QPixmap.fromImage() QImage → QPixmap
QPixmap.convertFromImage() QPixmap.fromImage() の in-place 版
QPixmap.toImage() QPixmap → QImage

QImage 側に変換メソッドが定義されていないあたり、単純なメモリの塊としての「画像」はあくまで QImage であって、それを GUI で扱うために QPixmap が用意されている……という設計意図を感じたりもする。

あえて注意点を挙げるとすれば QImage と QPixmap が扱うピクセルフォーマットの違いあたりか。QImage と QPixmap はそれぞれ CPU と GPU で扱うためのデータなので、お互いに内部形式が異なるために厳密な色味を再現できない可能性がある。もっとも、そんな再現が必要な状況下にいる人相手は釈迦に念仏ではあるだろうけど……。

QImage で生ピクセル値を読み書き

QByteArray → QImage.loadFromData

冒頭のツイートでも紹介されている Qt 公式おすすめ(?)の方法。
How to Store and Retrieve Image on SQLite - Qt Wiki
loadFromData · GitHub

この方法の特徴は 各種画像フォーマットにエンコードされた状態のバイナリ が手に入ること。つまり、PNG なら PNG 圧縮された状態のピクセル値の並びを扱うことになる。なので、たとえば以下のコードにあるように、PNG として QByteArray に書き込んだデータは、そのまま拡張子 .png でファイルに書き出すことができる。

from PySide2.QtCore import QByteArray, QBuffer, QIODevice, QFile
from PySide2.QtGui import QImage

source = QImage('test.png')

image_format = 'PNG'

# バイナリ読み込み
bits = QByteArray()
buffer = QBuffer(bits)
buffer.open(QIODevice.WriteOnly)
source.save(buffer, image_format)

# 読み込んだバイナリをファイルに書き込み
f = QFile('test111.png')
f.open(QIODevice.WriteOnly)
f.write(bits)
f.close()

# バイナリから QImage を生成
image = QImage()
image.loadFromData(QByteArray(bits.data()), image_format)

この方法は各種画像フォーマットが扱うデータ圧縮の影響を直接受けることになる。I/O を節約したいときに相性が良い。その代わり、QImage.loadFromData() を呼び出した時点で QImage 内に画像が展開される*2ことになるので、CPU 側がこの処理で詰まることになる。QImage.__init__() にファイルパスを渡すと画像を読み込むことができるけど、ファイル読み込みの代わりにバイナリを読んでると思えばいい。

というわけで、loadFromData の処理を適当に 分散 することさえできれば、処理速度的にはこの方法が最善になる可能性が高いように思う。

QImage.constBits() → QImage.__init__()

QImage 内部に展開されたバイナリを直接取得する方法。
constructor · GitHub

この方法の特徴は QImage.format() で取得できる画像の内部フォーマットに従ったバイナリ が手に入ること。つまり、Format_ARGB32 であれば ARGB 32bit 形式のデータをそのまま使うことになる。なので、QByteArray を使う方法と違ってそのままファイルに書き出すことはできない。

from PySide2.QtCore import QByteArray, QBuffer, QIODevice
from PySide2.QtGui import QImage

source = QImage('test.png')

# バイナリとメタデータを読み込み
bits = source.constBits()
width = source.width()
height = source.height()
bytes_per_line = source.bytesPerLine()
image_format = source.format()

# バイナリとメタデータから QImage を生成
image = QImage(bits, width, height, bytes_per_line, QImage.Format(image_format))

この方法は内部フォーマットに従うバイナリを無圧縮で扱うので、I/O にかかる負荷が高い。その代わり、余計なエンコードやデコードが一切不要なので、CPU にとても優しい。手許で試した限りでは、QImage.loadFromData() で画像を生成するケースと比べて 4 倍くらいの速度が出た。その上、上記の「QByteArray に画像を書き込む処理」と、この方法の「QImage.constBits() でバイナリを取得する処理」を比べると、後者のほうが 10000 倍くらい速い。

というわけで、冒頭のツイートにあるような「外部 DB に保存する」ケースと相性が良いとは言いにくいけど、I/O をいいかんじに逃してやるとか、あるいは各種パッキング等を駆使して I/O 負荷を減らすことができれば、いいかんじに使えるケースもありそう、

余談: Implicit Data Sharing

単純に「画像バイナリを参照したい」だけであれば、QImage.bits() よりも QImage.constBits() を使うように注意したい。Qt には Implicit Sharing | Qt Core 5.14.1 という仕組みがある。

複数のオブジェクト間でメモリ領域を共有することで効率化するための仕組みなのだけど、ただ共有するだけだと、オブジェクトがどれか 1 個でも書き換わったら全体に影響してしまう。それを防ぐために、書き換えが行われるようなケースに備えて内部的にメモリ領域のディープコピーを返すようになってる。

たとえばこんなかんじ。

label = QLabel('aaa')
font = label.font()    # label が参照している "QLabel 用の標準フォントの共有メモリ領域" を font にコピーする
font.setPixelSize(10)  # font を書き換える、この時点では label はまだ共有領域を参照してる
label.setFont(font)    # label 内で共有領域への参照を外して、font の中身をコピーする

QImage.bits() もそれに対応しているようで、ドキュメント には以下の記述がある。

Note that QImage uses implicit data sharing. This function performs a deep copy of the shared pixel data, thus ensuring that this QImage is the only one using the current return value.

一方で QImage.constBits() のほうはディープコピーを作成しない。

Note that QImage uses implicit data sharing, but this function does not perform a deep copy of the shared pixel data, because the returned data is const.

というわけで C++ の Qt であれば、読み取りだけであれば constBits() を使うべき*3なのだけど、Python だとどうなんだろう?

とりあえず site-packages にある PySide2/QtGui.pyi には typing.Char としか書いてないので、正直よくわからない。そもそも Python には C++ の const に相当する概念がない。わからないので、読み取りだけならとりあえず constBits() を使っとけばいいんじゃないかな。

class QImage(PySide2.QtGui.QPaintDevice):
    def bits(self) -> typing.Char: ...
    def constBits(self) -> typing.Char: ...

余談: QImage が読み込んだ画像バイナリのサイズ

試しに 24.5KB の PNG ファイルを「QBuffer 経由で QByteArray に読み込む」「ストリーム IO でバイナリ読み込み」の両方で Sqlite3 に保存してみたら、後者の DB ファイルサイズが 2 割くらい小さくなった。なんでだろう?

ちょっとだけ調べてみたら、QByteArray に読み込んだデータサイズが 33094 bytes だったのに対して、ストリーム IO で読んだほうは 25098 bytes だった。ためしにバイナリを確認したら、本来 1 個しかないはずの Data chunk が QByteArray のほうは 5 個に増殖してた。QImage.__init__() で画像を読み込んだ直後に constBits() のサイズを確認したら、その時点で既に 33094 だったから、たぶん QImage が Format_ARGB32 に変換するときとかに何かしてるんだろうなー。

save binary from stream · GitHub

*1:各種アラインメントの確保など。いずれにせよドライバ依存。

*2:上記のコードの場合、このタイミングで PNG デコードが行われる。

*3:const uchar* bits() でももちろん構わないんだけど、個人的には constBits() のほうがより明示的で安全だと思う。