AITuberと話した内容を感熱紙シールで印刷してノートに貼る
概要
最近LLMが流行っているがハードウェアとのつなぎ込みの事例が少ない。
そこで、「サーマルプリンタ」というレシートサイズの紙で印刷できるプリンタと繋ぎ込み、LLMの出力内容を印刷する案を思いついたので実践する。
イメージはこんな感じ。
AITuberとは
AIでキャラクターを作り、発話させたり色々させる存在です。
AI+YouTuberでAITuberと言います。
サーマルプリンタとは
熱を加えることで印字できるプリンタです。大体レシートみたいな紙を印刷できるプリンタだと思えば大丈夫です。
事前準備・使用言語
必要なものは「サーマルプリンタ」「Bluetoothレシーバー」の2つです。
Pythonでこれらをつなぎ合わせます。言語選定はいつも筆者が使っているからというだけなので、他言語でも良いです。
プログラム
以下の記事に従って作っていきます。
事前に以下のライブラリは入れておいてください。筆者はuvでやりました。
- bleak
- pillow
- requests
プログラム
サーマルプリンタのUUIDチェック
まずはUUIDのチェックをします。これのUUIDが書き込み指示をするときに必要になります。
import asyncio
from typing import Optional
from bleak import BleakClient, BleakScanner, BLEDevice
DEVICE_NAME = 'M02 Pro'
CONNECTION_RETRY_MAX_COUNT = 3
async def main():
# scan and connect
device = await connect()
if device:
print('connected.')
async with BleakClient(device) as client:
for c in client.services.characteristics.values():
print(f'Property: {c.properties}, UUID: {c.uuid}')
else:
print('device not found.')
return
async def connect() -> Optional[BLEDevice]:
retry_count = 0
device = None
while not device and retry_count < CONNECTION_RETRY_MAX_COUNT:
device = await BleakScanner.find_device_by_name(
name=DEVICE_NAME
)
retry_count += 1
return device
if __name__ == "__main__":
asyncio.run(main())
ここのwriteを控えておきます。
main
main.py
import asyncio
from llm_adapter import LLMAdapter
from printer_adapter import PrinterAdapter
async def main():
# scan and connect
printer_adapter = await PrinterAdapter.create()
llm_adapter = LLMAdapter()
sentence = llm_adapter.encode('仮想世界と物理世界をどう結びつけたら幸せになれると思う?', 3, 128)
await printer_adapter.print_text(sentence)
await printer_adapter.feed(line=3)
await asyncio.sleep(2)
if __name__ == "__main__":
asyncio.run(main())
PrinterAdapterでプリンタ、LLMAdapterでLLMとの繋ぎこみをしています。sentenceにstrを渡して、それをprint_textに渡してるだけの簡易的な仕組みになります。そのため、LLMの部分はadapter作らずにOpenAIと繋ぎこむでも大丈夫です。
プリンタとの繋ぎ込みクラス
printer_adapter.py
from typing import Optional
from bleak import BleakClient, BleakScanner, BLEDevice
from bitmap_converter import BitmapConverter
# 1 line = 576 dots = 72 bytes x 8 bit
DOT_PER_LINE = 576
BYTE_PER_LINE = DOT_PER_LINE // 8
ESC = b'\x1b'
GS = b'\x1d'
DEVICE_NAME = 'M02 Pro'
CONNECTION_RETRY_MAX_COUNT = 3
CHARACTERISTIC_UUID_READ = 'hoge'
CHARACTERISTIC_UUID_WRITE = 'fuga'
CHARACTERISTIC_UUID_NOTIFY = 'piyo'
class PrinterAdapter:
def __init__(self):
self.client = None
@classmethod
async def create(cls):
self = cls()
self.client = await self.__init_client()
return self
async def __init_client(self):
device = await self.connect()
if device:
print('接続しました')
client = BleakClient(device)
# デバイスに接続してサービス検出を実行
await client.connect()
return client
else:
print('デバイスが見つかりません')
raise Exception('デバイスが見つかりません')
async def connect(self) -> Optional[BLEDevice]:
retry_count = 0
device = None
while not device and retry_count < CONNECTION_RETRY_MAX_COUNT:
device = await BleakScanner.find_device_by_name(
name=DEVICE_NAME
)
retry_count += 1
return device
async def feed(self, line: int = 1):
print(f'feed paper: {line} lines')
await self.send_command(data=ESC + b'd' + line.to_bytes(1, 'little'))
async def send_command(self, data: bytes):
await self.client.write_gatt_char(
char_specifier=CHARACTERISTIC_UUID_WRITE,
data=data,
response=True
)
async def print_line(self):
# GS v 0 コマンド
# パラメータの詳細はESC/POSのコマンドリファレンスを参照
# ビットマップのx,yサイズはリトルエンディアンで送信する必要があるので注意
command = GS + b'v0' \
+ int(0).to_bytes(1, byteorder="little") \
+ int(BYTE_PER_LINE).to_bytes(2, byteorder="little") \
+ int(1).to_bytes(2, byteorder="little")
await self.send_command(data=command)
# 上記コマンドで指定したバイト数分のビットマップデータを送信する
line_data = bytearray([0xff] * BYTE_PER_LINE)
await self.send_command(data=line_data)
async def print_text(self, text: str, fontsize: int = 38):
bitmap_data = BitmapConverter.text_to_bitmap(text, fontsize)
# GS v 0 コマンド
command = GS + b'v0' \
+ int(0).to_bytes(1, byteorder="little") \
+ int(BYTE_PER_LINE).to_bytes(2, byteorder="little") \
+ int(bitmap_data.height).to_bytes(2, byteorder="little")
await self.send_command(data=command)
# 上記コマンドで指定したバイト数分のビットマップデータを送信する
await self.send_command(data=bitmap_data.bitmap)
print_textを主に使います。このプリンタには直接テキストを印刷する機能がないので、テキストをbitmap形式に変換し、そのデータをsend_commandで送信します。
bitmap形式に変換するクラス
bitmap_converter.py
from dataclasses import dataclass
from PIL import ImageFont, Image, ImageDraw
@dataclass
class BitmapData:
bitmap: bytes
width: int
height: int
DOT_PER_LINE = 576
class BitmapConverter:
@staticmethod
def text_to_bitmap(text: str, fontsize: int) -> BitmapData:
# 20文字ごとに改行を挿入
text_num = 15
text_lines = []
for i in range(0, len(text), text_num):
text_lines.append(text[i:i+text_num])
text = '\n'.join(text_lines)
font = ImageFont.truetype('./AP.ttf', fontsize)
image_width = DOT_PER_LINE
# 改行を含むテキストの全体サイズを取得
lines = len(text_lines)
image_height = int(fontsize * 1.5 * lines)
img = Image.new('1', (image_width, image_height), 0)
draw = ImageDraw.Draw(img)
# 文字を描画
draw.text((0, 0), text, font=font, fill=1)
return BitmapData(img.tobytes(), image_width, image_height)
テキストからbitmapへの変換はpillowを用います。
また重要なのは「AP.ttf」という日本語フォントを読み込んでいるところです。
あんずもじをダウンロードし、そのファイルを読み込んでください。
LLMとの繋ぎ込み
llm_adapter.py
import requests
class LLMAdapter:
def encode(self, sentence: str, min_length: int, max_length: int) -> str:
url = 'http://localhost:8000/encode'
response = requests.post(
url,
json={
"sentence": sentence,
"min_length": min_length,
"max_length": max_length
}
)
return response.json().get('answer')
裏側でローカルLLMのサーバーを自前で立てており、そこでAITuberの発言生成を行っています。普段は別システムでここと繋ぎ、合成音声と繋いだりOBSとつなぐことでAITuber活動を行わせています。なので、AITuberを作ったことのない人はここの部分を適宜OpenAIやClaudeに紐づけてください。
まとめ・宣伝
最近ではアプリでのLLMのプロダクトは沢山出ていますが、まだまだハードウェアの繋ぎこみは少ないです。そこで趣味でも良いのでこういった遊びツールは勉強になるなと感じました。
Discussion