Poooli L3 感熱紙プリンタでの grayscale 印刷

# 要旨

Poooli L3 プリンタで 8 階調 grayscale 印刷する Python プログラムを改良した。RGB 三色に分解しそれぞれでディザリングした後再び合成し、その合成画像を 8 階調白黒画像とする方法をとった。この方法は chatGPT に案を出させてあみ出した。

# 前置き

Poooli 社の L3 サーマル・プリンタは、グレイスケール印刷が可能になっている。以前、Poooli L3 プリンタの Bluetooth 出力の解析を行い、grayscale 出力の命令を見出した。しかしながら、一般のカラー画像を適切な grayscale 画像に変換することが出来ず、グラデーションが縞模様になるなど課題を残した。

https://qiita.com/cure_honey/items/e82a85ef8ca8ed26210a

最近 OpenAI 社の chatGPT が流行っているので、これに頼ってカラー画像を grayscale 画像に変換する方法を得ることを試みた。chatGPT はそのまま使える結果は出してくれなかったものの、複数回質問を重ねるうち画像を RRGB 三色に分解した上で画像処理をするというアイデアを出してきたので、そのアイデアを拝借することにして、残りを適宜作った。

# 8 階調 grayscale への変換法

処理は画像データを RGB 三色へ分解したのち、それぞれを 2 階調画像にディザリングしたうえで、再びカラー画像として合成する。そうしてこの合成したカラー画像を 8 階調の grayscale 画像に変換する。元々のカラー画像を直接 8 階調の grayscale 画像とすると、グラデーションなどが量子化の閾値で縞模様を作ってしまうが、この方法では縞は目立たない。またディザリング特有の点々感も軽減される。

## アルゴリズム中核

```python
img_r, img_g, img_b = image.split()
img_r = img_r.convert(mode="1").convert(mode="L")
img_g = img_g.convert(mode="1").convert(mode="L")
img_b = img_b.convert(mode="1").convert(mode="L")
# black&white dithering
image = Image.merge("RGB", (img_r, img_g, img_b))
image = image.convert(mode="L")  # 8bit grayscale
depth = 8
image = image.quantize(depth)
```

なおプリンタの出力としては、6 階調程度の分解能しか持たないが、全体に色が薄いため色が濃くなうように階調を増やしている。

## 出力例

- 今回の改良された出力
![grayscale.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44522/34c7116e-8697-c246-0a33-c0489f9e70d3.png)

- 前回の出力
上 2 階調ディザリング
下 6 階調 grayscale　
![https___qiita-image-store.s3.ap-northeast-1.amazonaws.com_0_44522_e220d813-626f-1250-38af-95a8947e45cd.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/44522/e03e2915-e842-0b65-65f1-7e5a909bf78e.png)

下の画像との比較から背景の放射状の光のグラデーションが改善されていることが分かる。(写真のピントが合っていないのでこの画像からは判然としないが)上の画像との比較からディザリング特有の点々の模様も改善されていることも分かる。

## スクリプトプログラム
chatGPT に講評を求めたところ、bluetooth 接続時の例外処理が無いとか文句を垂れつつ改善例を示してくれたので、一部拝借して改良した。

- 利用法
```
python -m スクリプトプログラム名  画像ファイル名
```
なおスクリプトプログラム名の拡張子の .py は付けなくていい。 

```python
import sys
import binascii
import socket
import ctypes
from PIL import Image  # , ImageOps

Poooli_L3 = ("00:15:82:93:3C:F9", 6)  # MAC address, channel

INITIALIZE = b"\x1b\x1cset mm\x05\x08"
PAPER_TYPE = b"\x10\x7e\x68\x79\x7d\x0d"
# width    ed09h 1248dots, 9d0eh 912dots, 850fh 648
PAPER_WIDTH = b"\x10\x7e\x68\x79\x7a" + b"\xed\x09"
# thickness  3Ah 55, 46h 75, 52h 95
DENSITY = b"\x10\x7e\x68\x79\x6e" + b"\x52"

args = sys.argv
fn = args[1]
width = 1248  # default 107mm paper

print("Connecting...")
s = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM,
                  socket.BTPROTO_RFCOMM)

try:
    s.connect(Poooli_L3)
except socket.error as e:
    print(f"Connection failed: {e}")
    sys.exit(1)

if len(args) > 2:
    width = int(args[2])

#    s.connect((args[3], 1))


# printer initialization
s.send(INITIALIZE)
s.recv(2)
s.send(PAPER_TYPE)
s.recv(2)
s.send(PAPER_WIDTH)
s.recv(2)
# set concentration
s.send(DENSITY)
s.recv(2)
# self test
# s.send(b"\x16\x16\x0D")


# PIL
image = Image.open(fn)
if image.width > image.height:
    image = image.transpose(Image.ROTATE_90)
#
# width 1248/912/576 dots
IMAGE_WIDTH_BITS = width  # must be multiple of 8
IMAGE_WIDTH_BYTES = IMAGE_WIDTH_BITS // 8
image = image.resize(size=(IMAGE_WIDTH_BITS, int(
    image.height * IMAGE_WIDTH_BITS / image.width)))
#
img_r, img_g, img_b = image.split()
img_r = img_r.convert(mode="1").convert(mode="L")
img_g = img_g.convert(mode="1").convert(mode="L")
img_b = img_b.convert(mode="1").convert(mode="L")
# black&white dithering
image = Image.merge("RGB", (img_r, img_g, img_b))
image = image.convert(mode="L")  # 8bit grayscale
depth = 8
image = image.quantize(depth)
# ImageOps.posterize(image, 2)
# image.show()

## print ##
lzo = ctypes.cdll.LoadLibrary('./minilzo.so')
wk = ctypes.create_string_buffer(16384 * 8)  # 64bit pointer
#
mx = image.width // 8
ny = image.height
print("Data sending...")
for iy in range(ny):
    if iy % 100 == 0:
        print(100 * iy // ny, "%")
    image_bytes = b""
    for ik in range(depth - 0):
        for ix in range(mx):
            byte = 0
            for bit in range(8):
                indx = ix * 8 + bit, iy
                bw = image.getpixel(indx)
                if bw > ik:
                    byte |= 1 << (7 - bit)
            image_bytes += byte.to_bytes(1, "little")

    # miniLZO compress
    buff = ctypes.create_string_buffer(image_bytes)
    ni = ctypes.c_int(len(buff) - 1)
    no_max = len(buff) + len(buff) // 16 + 64 + 3
    out = ctypes.create_string_buffer(no_max)
    no = ctypes.c_int(len(out))
    iret = lzo.lzo1x_1_compress(ctypes.byref(buff), ni, ctypes.byref(
        out), ctypes.byref(no), ctypes.byref(wk))
    lzno = no.value
    image_lzo = b"\x12\x78\x07" + \
        iy.to_bytes(2, byteorder="little") + \
        lzno.to_bytes(4, byteorder="little")
    image_lzo += out[:lzno]
    #
    # crc32
    icrc32 = binascii.crc32(image_lzo, -1 ^ 489490)
    icrc32_xor = icrc32 ^ (((13 * 256 + 13) * 256 + 13) * 256 + 13)

    # send to printer;  all data must be XORed with 13 (\x0d)
    tmp = b""                                             # compressed data
    for ib in range(no.value + 9):
        tmp += (image_lzo[ib] ^ 13).to_bytes(1, "little")
    s.send(tmp)
    s.send(icrc32_xor.to_bytes(4, byteorder="little"))
# end of grayscale data
print("Data sent...")
s.send(b"\x1F\x75\x04")
ny_xor = (ny - 1) ^ (((13 * 256 + 13) * 256 + 13) * 256 + 13)
#
s.send(ny_xor.to_bytes(4, byteorder="little"))
a = s.recv(10)
print(a[:4])
a = s.recv(12)
print(a[5:11])
# wait till print ends
# s.send(b"\x16\x1f\x69") # get info
s.send(b"\x16\x1f\x7e")  # get status
a = s.recv(16)
print("Energy ", int(a[3]), "%")

# close bluetooth connection
s.close()

```

# まとめ

Poooli 社 L3 プリンタの grayscale 出力用 python プログラムを chatGPT の助太刀を得て改良した。chatGPT は求める結果を直接には与えてくれなかったものの、試行錯誤を通じて思いがけないアイデアを得ることが出来た。