0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

「読めるアートQR」を個人開発で作る - ControlNet×Stable Diffusionのスキャン率をコードで詰めた話

0
Posted at

はじめに

QRコードをAIでイラスト化する系のサービスはいくつもありますが、実際に作ってみると一番厄介なのは「絵は綺麗だけどスキャンできない」問題です。見た目のクオリティとスキャン成功率はトレードオフの関係にあり、この綱引きをどう設計するかがほぼ全てと言っていいくらい重要でした。

個人開発で わたしのQR(watashi no QR) というQRコード作成アプリ(Flutter / iOS・Android・Web)を作り、実際に運用しています。

この記事では、アプリ紹介はそこそこにして、「AI生成画像をどうやって"スキャンできるQR"として成立させているか」 を、残しておきます。ControlNetでQRっぽい画像を作ったことがある人なら、「あの読み取れない問題、こう潰したのか」という参考になるはずです。

アプリ自体の機能や料金プランなど詳しい紹介はランディングページにまとめています。

全体アーキテクチャ

Flutter クライアント
  ├─ 1. URLを自前の短縮URLサービスに変換(createShortUrl)
  ├─ 2. 短縮後のURLで「QRっぽくない」QRベース画像を生成(グレー背景 + 誤り訂正H)
  └─ 3. base64化してCloud Functionsへ送信
       ↓
Firebase Cloud Functions(generateAiQr)
  ├─ 権限チェック
  ├─ 日本語プロンプトを英語へ自動翻訳
  └─ Modal(GPU推論)を呼び出し
       ↓
Modal上のPythonアプリ(diffusers + ControlNet)
  ├─ テンプレートごとに異なるSD1.5ベースモデルをロード
  ├─ conditioning_scaleを段階的に上げながら最大3回リトライ生成
  ├─ 生成のたびにpyzbarで複数手法によるスキャン判定
  └─ 全滅時はQRを半透明ブレンドして強制的に読める状態に補正
       ↓
Cloud Run(Python + pyzbar)
  └─ 最終画像を独立環境で再検証(Cloud FunctionsのNode.js環境ではpyzbarが動かないため)

余談ですが、設計当初は Fal.ai + ComfyUI ワークフローで構築する方針でした最終的に実運用では Modal + diffusers(Python)直叩き に落ち着いています。ComfyUIのワークフローJSONをNode.js側で組み立てて外部実行させるより、Python側でパイプラインを直接制御した方がリトライやフォールバックのロジックを書きやすかった、というのが個人開発的な実感です。

1. QRのペイロードを先に短縮する

QRに埋め込む文字列が長いほどモジュール数(QRのバージョン)が増え、1マスが小さくなります。マスが小さいと誤り訂正の冗長ビットも細かくなり、ControlNetが「絵柄に変えていい余白」が減ってスキャン成功率が下がります。そのため、URLを渡された場合はまず自前の短縮URLサービスを経由させています。

// functions/src/createShortUrl.js
const BASE62 = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const CODE_LENGTH = 5;

exports.createShortUrl = onCall({ region: REGION }, async (request) => {
  // ...
  const existing = await db('urls').where({ original_url }).first();
  if (existing) {
    return { short_url: `https://${domain}/${existing.short_code}`, short_code: existing.short_code };
  }
  // 5文字のbase62コードを衝突チェックしながら発行
  // ...
});
// lib/features/ai_qr/providers/generation_job_provider.dart
// URLの場合は短縮URLに変換してQR精度を向上
if (qrContent.startsWith('http://') || qrContent.startsWith('https://')) {
  shortUrl = await ApiService.instance.createShortUrl(qrContent);
}

同一URLなら同じ短縮コードを返す(urlsテーブルをoriginal_urlでルックアップ)ようにしているので、DB肥大化も抑えられます。地味な機能ですが、生成品質に直結する前処理としてかなり効いています。

2. あえて「QRっぽくない」QR画像を先に作る

ControlNetに渡す元QR画像も、Flutter側で以下のように整形しています。

// lib/features/ai_qr/providers/generation_job_provider.dart
static Future<Uint8List> _renderQrToPng(String content) async {
  final qrCode = QrCode.fromData(
    data: content,
    errorCorrectLevel: QrErrorCorrectLevel.H, // 誤り訂正は最大レベル
  );
  // ...
  canvas.drawRect(fullRect, Paint()..color = const Color(0xFF808080)); // 背景はグレー
  canvas.drawRect(quietZoneRect, Paint()..color = const Color(0xFFFFFFFF)); // クワイエットゾーンは白
  // モジュールを黒で描画
}

ポイントは背景をグレー(#808080)にしていることです。QR Code Monster ControlNet はこのグレー背景を前提にチューニングされているモデルで、白黒二値のQRをそのまま渡すよりも、ControlNetが「どこを絵柄に置き換えていいか」を判断しやすくなります(背景=グレー=低コンフィデンス領域、モジュール=黒=高コンフィデンス領域、という扱いに近いイメージです)。誤り訂正レベルもHに固定し、後段の「多少パターンが崩れてもデコードできる」余地を最大化しています。

3. テンプレートごとにベースモデルを切り替える

ControlNet自体は全テンプレート共通で1つ(monster-labs/control_v1p_sd15_qrcode_monster)ですが、絵柄のクオリティを決めるベースモデル(SD1.5のファインチューン)はテンプレートごとに出し分けています。

# modal_app/modal_app.py
TEMPLATE_MODELS: dict[str, str] = {
    "photorealistic": "SG161222/Realistic_Vision_V5.1_noVAE",
    "anime":          "gsdf/Counterfeit-V3.0",
    "japanese":       "gsdf/Counterfeit-V3.0",
    "cyberpunk":      "Lykon/DreamShaper",
    "watercolor":     "Lykon/DreamShaper",
    # ...
}

面倒なのは、HuggingFaceの標準的なfrom_pretrained()形式で配布されていないモデル(Counterfeit-V3.0など、safetensors単体配布のモデル)が混ざっている点です。これらはStableDiffusionPipeline.from_single_file()で読み込んだ上で、VAE・text_encoder・tokenizer・unetだけを取り出してStableDiffusionControlNetPipelineに手動で詰め替えています。

base = StableDiffusionPipeline.from_single_file(local_path, torch_dtype=torch.float16, safety_checker=None)
pipe = StableDiffusionControlNetPipeline(
    vae=base.vae, text_encoder=base.text_encoder, tokenizer=base.tokenizer,
    unet=base.unet, controlnet=self.controlnet, scheduler=base.scheduler,
    safety_checker=None, feature_extractor=None, requires_safety_checker=False,
).to("cuda")

ロードしたパイプラインはテンプレートIDごとにメモリ上へキャッシュ(self._pipelines辞書)しており、Modalのコンテナが生きている間は同じテンプレートの2回目以降のリクエストでロード時間がゼロになります。

また、SD1.5系は学習データが英語キャプション中心のため、ユーザーが日本語で自由記述プロンプトを入力した場合はCloud Functions側でGoogle Cloud Translation APIを使い、生成前に自動で英訳しています。

// functions/src/generateAiQr.js
function containsJapanese(text) {
  return /[]/.test(text);
}
async function translateToEnglish(text) {
  if (!text || !containsJapanese(text)) return text;
  const [translated] = await translator.translate(text, 'en');
  return translated;
}

4. スキャン可否を機械的に判定する

「AIがQRを生成した」だけでは製品として成立しません。生成のたびに 本当にスキャンできるかを機械的に判定 し、ダメなら打つ手を変える必要があります。判定ロジックはpyzbarでのデコードですが、1回試して終わりではなく、前処理を変えながら3段階で試行しています。

def _is_scannable(img) -> bool:
    """複数の前処理でスキャンを試み、1つでも通れば合格"""
    arr = np.array(img)
    gray = cv2.cvtColor(arr, cv2.COLOR_RGB2GRAY)

    if pyzbar_decode(gray):                      # 1. そのままグレースケール
        return True

    clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
    if pyzbar_decode(clahe.apply(gray)):          # 2. CLAHEで局所コントラストを強調
        return True

    _, binary = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
    if pyzbar_decode(binary):                     # 3. Otsuで白黒二値化
        return True

    return False

AIが生成した絵はコントラストや色味が一様ではないため、単純なグレースケール変換だけでは「肉眼では読めるのにpyzbarはデコードできない」ケースが結構ありました。CLAHE(局所ヒストグラム均等化)とOtsu二値化を通してから再度試すことで、判定側の取りこぼしをかなり減らせています。ちなみにスマホカメラのQRリーダーはこの手の前処理をハードウェア・ソフトウェア両面でかなり賢くやっているので、pyzbarより実機の方が寛容な場合が多いです。したがって「Cloud Run側の最終検証をpassした画像しかストレージに保存しない」という強いブロッキングにはせず、あくまで生成結果の可否フラグ(is_scannable)として記録する設計にしています¥

5. スケールを段階的に上げながらリトライする

ControlNetのconditioning_scale(強度)を上げるほどQRパターンは保たれやすくスキャン成功率は上がりますが、絵柄への寄与が弱まりアート感が落ちます。これを1回の生成で決め打ちにせず、最大3回まで、強度を上げながらリトライ する設計にしています。

scale_1 = conditioning_scale          # リクエストされた強度(アート優先)
scale_2 = min(conditioning_scale + 0.3, 1.9)
scale_3 = 1.9                          # 上限固定
scales_to_try = [scale_1, scale_2, scale_3]

for attempt, scale in enumerate(scales_to_try):
    img, scannable = _run(scale)
    if best_art is None:
        best_art = img          # 1回目の生成をアート品質の基準として保持
    if scannable:
        best_scan = img
        break                    # スキャンできた時点で打ち切り
    torch.cuda.empty_cache()

output = best_scan if best_scan is not None else best_art

ここで重要なのは、全部失敗してもエラーにしない という判断です。3回ともスキャン失敗した場合は、最初に生成した(=最もアート優先の)画像をそのまま返します。ユーザー体験として「課金して失敗で終わる」を避けるため、最終手段として次に説明するオーバーレイ処理でスキャン性を補正します。

6. 最後の保険: QRを画像に半透明でブレンドする

3回のリトライでもスキャンできなかった場合に備えて、生成されたアート画像へQRパターンを直接アルファブレンドする補正ロジックを持っています。

def _apply_qr_overlay(self, art_image, qr_image, opacity: float):
    art = np.array(art_image, dtype=np.float32)
    qr  = np.array(qr_image.resize(art_image.size), dtype=np.float32)

    luminance = qr.mean(axis=2)
    dark_mask  = (luminance < 64).astype(np.float32)    # 黒モジュール
    light_mask = (luminance > 100).astype(np.float32)   # QR背景(グレー)

    # 黒モジュール部分はやや黒方向へブレンド
    alpha_dark = dark_mask[:, :, np.newaxis] * opacity
    blended = art * (1 - alpha_dark) + black * alpha_dark

    # 背景部分はコントラスト確保のため、より強めに白方向へブレンド
    alpha_light = light_mask[:, :, np.newaxis] * min(opacity * 2.0, 0.6)
    blended = blended * (1 - alpha_light) + white * alpha_light
    return Image.fromarray(np.clip(blended, 0, 255).astype(np.uint8))

単純に「QR画像とアート画像を50%で重ねる」のではなく、黒モジュールとQR背景(グレー領域)で別々の強さ・別々の方向にブレンドしている のがポイントです。背景側は白方向に通常の2倍の強さでブレンドすることで、モジュールとの明度差(コントラスト)を優先的に確保しています。これにより、opacityを低め(0.15程度)に設定してもスキャン成功率を底上げできつつ、絵柄そのものは大きく損なわれません。実運用では無料/クレジット利用時のみこの補正をかけ、Pro向けの高品質生成では基本的に使わない、といった出し分けもできる設計にしています。

7. うまくいかなかった実験たち

本番採用しなかったせっかくなので供養がてら紹介します。

2パス生成test_two_pass.py): 1パス目をControlNetなしの純粋なtxt2imgでアートを生成し、2パス目でimg2img + ControlNetを使ってQR構造を「後から」埋め込むアプローチ。理屈上はアート表現の自由度が上がりそうですが、2回分のGPU時間がかかる割にスキャン成功率の改善が体感できず、コストに見合わないと判断して不採用にしました。

入力QRのぼかしtest_blur_input.py): ControlNetに渡すQR画像自体にガウシアンブラーをかけて、モジュールの境界を曖昧にした状態で読み込ませる実験。狙いは「境界をあえて曖昧にすることでAIがパターンを"溶かし込み"やすくなるのでは」というものでしたが、conditioning_scaleを上げた方が素直に効くことがわかり、こちらも採用を見送りました。

8. クライアント側の見た目調整: バー描画とファインダーパターンの保護

AI生成のQRとは別に、無料で使える通常のデザインQR(Design QR)側にも、QRらしさを和らげる工夫を入れています。横方向に連続する黒モジュールを1本の角丸バーとして描画するスタイルです。

// lib/features/design_qr/painters/dots_painter.dart
void _drawBars(Canvas canvas, QrImage qrImage, int count, double moduleSize, Paint paint) {
  for (var y = 0; y < count; y++) {
    int? startX;
    for (var x = 0; x <= count; x++) {
      final inFinder = x < count && _isFinderPattern(x, y, count);
      final dark = x < count && qrImage.isDark(y, x);

      if (inFinder) {
        // ファインダーパターン内はバー化せず、個別の正方形として維持
        if (startX != null) { _drawBar(canvas, startX, x - 1, y, moduleSize, barRadius, pad, paint); startX = null; }
        // ...
      } else if (dark) {
        startX ??= x;
      } else if (startX != null) {
        _drawBar(canvas, startX, x - 1, y, moduleSize, barRadius, pad, paint);
        startX = null;
      }
    }
  }
}

単純に全モジュールを丸めたり細くしたりすると、QRの角にある3つのファインダーパターン(位置検出用の7×7領域)が壊れてスキャナーがそもそもQRだと認識できなくなります。そのため_isFinderPattern()でこの3領域だけを判定し、そこだけは正方形のまま維持しつつ、それ以外の連続する黒モジュールだけを1本のバーにまとめています。AIを使わない無料機能でも、「デザイン性」と「スキャン確実性」の両立は同じ考え方で貫いています。

まとめ

今回紹介した中で個人的に効果が大きかった順に並べると、①URL短縮によるQRの単純化、②pyzbarの多段前処理、③段階的リトライ、④オーバーレイ補正、という体感です。派手なモデルよりも、こうした「生成後にどう検証し、どう救うか」の設計の方が製品としての完成度に直結する、という学びがありました。

実際に生成したQRはこちらから試せます。

機能や料金プランの詳細はこちらにまとめています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?