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 プレビュー(細部出る) |
|---|---|
![]() | ![]() |
実際に印刷比較すると 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) | 完走、画像も正常 |


サーマルプリンタは 印字速度に律速されたフロー制御 が必要で、 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+ペーシングで、 公式アプリの出力と肉眼ではほぼ区別つかないレベルまで出るようになった。
途中、設定を変えながら千切らずに連続で出した試行紙:


