技術 約3分で読めます

Sugar YMP-01でM1 Macから写真印刷、独自濃度コマンドを特定して公式アプリ並みに出した

いけさん目次

前にSugarをPCから操作する記事を書いた。BLE経由でESC/POSコマンドを叩いて文字や画像は印刷できるところまで持っていったけど、公式アプリ(WalkPrint)で印刷した写真と比べると 画質が全然違う。具体的にはこっちの実装で写真を流すとつぶれて見えるし、なんとなく薄い。
別件で深夜にSprocketのRFCOMM実装をしていたらハーフトーン処理の話になって、Sugarでも応用できるなと思ったのでそのまま勢いで続編に突入した。

最終形として:

  • Floyd-Steinbergディザリングでサーマル可読化(写真→2値画像)
  • BLE write-without-responseで5 KB/s転送
  • 公式アプリWalkPrintの挙動から独自濃度コマンド 1D 49 F0 nn を特定
  • 適切なペーシング+紙送りで完走するPythonクライアント

までいけたので、たどり着くまでに踏んだ落とし穴も含めて書く。

サーマル写真印刷の前処理: ハーフトーン

サーマルプリンタの出力は完全な2値(ドット打つ/打たない)。グレースケール写真をそのまま > 128 ? 0 : 1 で2値化すると、中間調が全部ぶつ切りになって顔が黒い塊になる。
新聞の写真と同じく ハーフトーン処理 で中間調を「黒ドットの密度」に変換するのが王道。

代表的な2手法:

手法特徴サーマル印刷適性
Clustered-dot(集中ドット)中心から外へドットが集まる閾値マトリクス。新聞写真の質感古典的・粒感均一
Floyd-Steinberg(誤差拡散)量子化誤差を隣接ピクセルに伝播。ノイズ風だが細部出る細部表現が高い

Pythonで両方実装してプレビューを比較した。

import numpy as np
from PIL import Image, ImageOps

CLUSTERED = np.array([
    [24,10,12,26,35,47,49,37],
    [ 8, 0, 2,14,45,59,61,51],
    [22, 6, 4,16,43,57,63,53],
    [30,20,18,28,33,41,55,39],
    [34,46,48,38,25,11,13,27],
    [44,58,60,50, 9, 1, 3,15],
    [42,56,62,52,23, 7, 5,17],
    [32,40,54,36,31,21,19,29],
], dtype=np.float32)
CLUSTERED = (CLUSTERED + 0.5) * (256.0 / 64.0)

def clustered_halftone(gray_np):
    h, w = gray_np.shape
    tile = np.tile(CLUSTERED, (h//8+1, w//8+1))[:h, :w]
    return (gray_np > tile).astype(np.uint8) * 255

def floyd_steinberg(gray_np):
    a = gray_np.astype(np.float32).copy()
    h, w = a.shape
    for y in range(h):
        for x in range(w):
            old = a[y, x]
            new = 255.0 if old >= 128 else 0.0
            a[y, x] = new
            err = old - new
            if x+1 < w: a[y, x+1] += err * 7/16
            if y+1 < h:
                if x > 0:   a[y+1, x-1] += err * 3/16
                a[y+1, x] += err * 5/16
                if x+1 < w: a[y+1, x+1] += err * 1/16
    return np.clip(a, 0, 255).astype(np.uint8)
Clustered-dot プレビュー(新聞風)Floyd-Steinberg プレビュー(細部出る)
clustered-dot ハーフトーンプレビューFloyd-Steinberg ハーフトーンプレビュー

実際に印刷比較すると Floyd-Steinbergのほうが顔の影や文字(LIVE PAUNI Tシャツのロゴ)まで読める。clustered-dotはレトロな新聞写真質感で味があるけど細部は潰れがち。今回はFSを採用。

BLEのペーシング

Sugarのサービス/キャラクタリスティクスはISSC Transparent UART:

Service:  49535343-fe7d-4ae5-8fa9-9fafd205e455
Write:    49535343-8841-43f4-a8d4-ecbe34729bb3 (write,write-without-response)
Notify:   49535343-1e4d-4bd9-ba61-23c647249616 (read,notify)

最初は 適当に全データを連続write_without_response で送ってみた:

ペース結果
26 KB/s (無間隔)画像の頭ちょっとだけ印字して停止(バッファ溢れ)
400 B/s (ゆっくり過ぎ)「ぶー、ぶー、ぶー」と断続的に印字、白い横線(ヘッド停止)が画像に入る
5 KB/s (200B/40ms)完走、画像も正常

26 KB/s で送ったときの出力。バッファ溢れで頭の上部分しか印字されてない

400 B/s で送ったときの出力。横方向に白い線が入り、画像が縦に潰れている

サーマルプリンタは 印字速度に律速されたフロー制御 が必要で、 BLE write-without-responseはACK待ちがない分速いけど、その分プリンタ内部バッファ(数KB)を超えると以降が捨てられる。
逆に遅すぎると印字ヘッドが「次のデータ待ち」で停止し、紙送りモーターは動き続けるので白い横線が入る。 印字速度にちょうど合わせる(または若干上回る) ペースが必要。

標準ESC/POSの濃度コマンドが全部効かない

Sugarに写真を印刷すると 薄い。公式アプリWalkPrintで印刷すると濃く出るので、ESC/POSの濃度コマンドを送れば変わるはず…と思って色々試した結果:

試したコマンド出典結果
ESC 7 n1 n2 n3 (heating time)Phomemo系の独自拡張完全に無視
51 78 A4 00 01 00 nn ck FF (Quality)Cat-Printer/iPrintファミリ印字停止(プロトコル破壊)
10 FF 10 00 nn (DLE FF density)DP-L13 style realtime印字されない

ESC/POSにそもそも標準的な「印字濃度」コマンドは存在しなくて、各メーカー独自拡張になっている。YMP-01はどれにも属さなかった。

公式アプリの挙動から濃度コマンドを特定

公式アプリ(WalkPrint)で同じ画像を印刷すると 明らかに濃く出る。 ということは、データ送信前に何かしらの濃度設定コマンドを送っているはず。
GS I 系の独自サブファンクションを中心に候補を絞って実機テストを繰り返したところ、 1D 49 F0 nn (GS I 0xF0 nn、 nn = 濃度値) で印字濃度が変わると判明した。

実測した濃度値の挙動:

nn (10進)16進体感濃さ
0x0A (10)デフォルト相当薄い
0x0C (12)new系 lightやや薄い
0x0F (15)old系 dark / new系 medium中濃度
0x12 (18)new系 dark濃い
0x14 (20)public系 light濃い
0x1E (30)public系 dark / maxかなり濃い

5〜30 の範囲で受け付ける。30 を超える値を送ると無視されるか、 firmware側で内部的にクランプされているっぽい。
ペアになる 速度コマンド 1D 49 F1 nn も存在し、 nn で印字スピード(数値が大きいほど高速 = 薄め)を設定する。 公式アプリは濃度と速度をセットで送って印字品質を組み合わせ的にコントロールしている。

濃度コマンドなしの状態(デフォルト値)で印字した出力。読めるけど薄い

濃度を上げると印字遅くなる

濃度30に上げて5 KB/sで送ったら、 画像の上1/3(首あたり)までしか印字できずに停止 した。
理由は単純で、濃度が高いとサーマルヘッドの加熱時間が長くなって印字レートが落ちる。 5 KB/sでは早すぎて再びバッファ溢れ。

濃度ごとの実測安定ペース:

濃度値推奨ペース完走
10(デフォルト)5 KB/s✓ 薄い
18(new dark)~3 KB/s✓ 中濃度
30(public dark/max)2 KB/s✓ 濃い

濃度を上げたらペースを下げて、 バッファ溢れと印字停止を両方避ける。

末尾の紙送り

サーマルプリンタは印字ヘッドとカット位置(ティアバー)が ~25-35mm離れてる。 印字直後だと画像の下端がまだプリンタ内部にいる。

ESC J FF (1B 4A FF = 255 * 1/8mm ≈ 32mm)を送ってみたが、 firmware側の制限なのか実際には足りず、 画像の胸まで出たところで止まった。
ESC d 30 (90mm feed) + LF×20の保険 で確実にティアバー越えまで送るようにした:

final_feed = bytes([0x1B, 0x64, 0x1E])  # ESC d 30
lfs = bytes([0x0A] * 20)
payload = density + init + header + raster + final_feed + lfs

完成形

import asyncio, time, struct
import numpy as np
from PIL import Image
from bleak import BleakClient

ADDR = "..."  # YMP-01のBLEアドレス
WRITE_CHAR = "49535343-8841-43f4-a8d4-ecbe34729bb3"

# 画像をハーフトーン2値化してESC/POSビットマップに整形
img = Image.open('photo.jpg').convert('L')
img = ImageOps.autocontrast(img, cutoff=2)
img.thumbnail((384, 9999), Image.LANCZOS)
gray = np.asarray(img)
bits = floyd_steinberg(gray)
bits = (bits < 128).astype(np.uint8)
# 1bpp packed
raster = bytearray()
for y in range(bits.shape[0]):
    for x in range(0, bits.shape[1], 8):
        b = 0
        for i in range(8):
            if bits[y, x+i]: b |= (1 << (7-i))
        raster.append(b)

W = bits.shape[1]; H = bits.shape[0]
density = bytes([0x1D, 0x49, 0xF0, 0x1E])         # WalkPrint濃度30 (max)
init    = bytes([0x1B, 0x40])                     # ESC @
header  = bytes([0x1D, 0x76, 0x30, 0x00,           # GS v 0
                 (W//8) & 0xff, ((W//8)>>8) & 0xff,
                 H & 0xff, (H>>8) & 0xff])
feed    = bytes([0x1B, 0x64, 0x1E]) + bytes([0x0A]*20)
payload = density + init + header + bytes(raster) + feed

async def main():
    async with BleakClient(ADDR, timeout=20.0) as c:
        CHUNK = 200; SLEEP = 0.10  # 2 KB/s, max density用
        for i in range(0, len(payload), CHUNK):
            await c.write_gatt_char(WRITE_CHAR, payload[i:i+CHUNK], response=False)
            await asyncio.sleep(SLEEP)
        await asyncio.sleep(3)

asyncio.run(main())

濃度30 + 2 KB/sペースで印字した最終出力。公式アプリ並みの濃度で完走、ティアバーまで紙送りも届いている

ハーフトーン+濃度30+ペーシングで、 公式アプリの出力と肉眼ではほぼ区別つかないレベルまで出るようになった。

途中、設定を変えながら千切らずに連続で出した試行紙:

試行紙: 同じ画像を濃度・ペースを変えて連続で印字、切らずに残してある