🖨️

AITuberと話した内容を感熱紙シールで印刷してノートに貼る

2024/12/15に公開

概要

最近LLMが流行っているがハードウェアとのつなぎ込みの事例が少ない。
そこで、「サーマルプリンタ」というレシートサイズの紙で印刷できるプリンタと繋ぎ込み、LLMの出力内容を印刷する案を思いついたので実践する。

イメージはこんな感じ。

AITuberとは

AIでキャラクターを作り、発話させたり色々させる存在です。
AI+YouTuberでAITuberと言います。

サーマルプリンタとは

熱を加えることで印字できるプリンタです。大体レシートみたいな紙を印刷できるプリンタだと思えば大丈夫です。

事前準備・使用言語

必要なものは「サーマルプリンタ」「Bluetoothレシーバー」の2つです。
Pythonでこれらをつなぎ合わせます。言語選定はいつも筆者が使っているからというだけなので、他言語でも良いです。

プログラム

以下の記事に従って作っていきます。
https://qiita.com/ryo-endo/items/2000009d0783b34b7228#hello-world-までのロードマップ

事前に以下のライブラリは入れておいてください。筆者は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」という日本語フォントを読み込んでいるところです。
あんずもじをダウンロードし、そのファイルを読み込んでください。
https://fontdasu.com/141

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