MedSpeakを初めて作った時は、手で短縮形を入力する必要がありました。デモとしては問題ありませんが、ラベルの本来の目的は「すでに書かれている」ことなので、次の自然なステップは、人々がラベルの写真を撮って入力を完全にスキップできるようにすることでした。

今回のアップデートでは、Tesseract.jsを使ったブラウザ内OCRを追加し、ページをフォーム投稿ではなくJSON APIに切り替え、その他はすべてそのままにしています — ルックアップテーブルやデコードロジックは元のバージョンと全く同じです。

1. 内部で何が変わったか

翻訳ロジック(decode()、およびFREQUENCY/TIMING/ROUTE/FORMテーブル)は元のバージョンから変更されていません。変更されたのは、テキストがその関数に入る方法と、結果がページに戻る方法です:

  • フォームはもはやフルページPOSTを行わず、小さなfetch()コールが代わりにJSON APIを呼び出します
  • ファイル入力で写真を選択または撮影できます
  • Tesseract.jsはその写真に対して完全にブラウザ内でOCRを実行します — 画像がサーバーにアップロードされることはありません
  • Tesseractが見つけたテキストは入力ボックスに挿入され、自動的にデコードされます
def do_POST(self):
    if self.path == "/api/decode":
        length = int(self.headers.get("Content-Length", 0))
        data = json.loads(self.rfile.read(length) or "{}")
        text = data.get("text", "")
        result = decode(text)
        self._send(200, json.dumps({"result": result}))
    else:
        self._send(404, "{}")

Enter fullscreen mode Exit fullscreen mode

サーバーは今、フォームエンコードされたボディを解析して完全なHTMLページを再レンダリングするのではなく、JSONで入出力を行います。

2. ページからAPIを呼び出す

<form>の送信ではなく、通常のfetch()コールが現在の入力値を送信し、結果のdivを返ってきた内容で更新します。

async function go(){
  const text = document.getElementById('rx').value;
  const out = document.getElementById('out');
  if(!text.trim()){ return; }
  const res = await fetch('/api/decode', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({text: text})
  });
  const data = await res.json();
  out.classList.remove('empty');
  out.innerHTML = '<span class="tag">Instructions</span>' + data.result;
}

Enter fullscreen mode Exit fullscreen mode

これによりページが即座に感じられるようになります — フルページのリロードはなく、ラベルカードがその場で更新されるだけです。

3. 写真からテキストを読み取る

これが新しい部分です。Tesseract.jsはCDNから読み込まれ、完全にクライアントサイドでOCRを実行します:

<script src="https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/5.0.4/tesseract.min.js"></script>

Enter fullscreen mode Exit fullscreen mode

ファイルが選択されると、すぐにプレビューされ、OCR関数に渡されます:

function handleImage(event){
  const file = event.target.files[0];
  if (!file) return;
  const preview = document.getElementById('preview');
  preview.src = URL.createObjectURL(file);
  preview.style.display = 'block';
  scanImage(file);
}

Enter fullscreen mode Exit fullscreen mode

URL.createObjectURL(file)は画像の一時的なローカルURLを作成し、ブラウザから出ることなく<img>タグで表示できるようにします。

4. OCRの実行と結果のフィードバック

async function scanImage(file){
  const status = document.getElementById('scanStatus');
  status.innerText = "Reading text from image...";
  try {
    const result = await Tesseract.recognize(file, 'eng');
    const rawText = result.data.text.trim();
    status.innerText = rawText ? ("Detected: " + rawText) : "No text found — try a clearer photo.";
    if (rawText){
      document.getElementById('rx').value = rawText.replace(/\n/g, ' ');
      go();
    }
  } catch(e){
    status.innerText = "Scan failed — type it manually instead.";
  }
}

Enter fullscreen mode Exit fullscreen mode

Tesseract.recognize(file, 'eng')はOCRパイプライン全体を実行し、検出されたテキストを返します。改行はスペースに変換され(処方短縮形は通常1行のため)、入力ボックスに値が設定され、手動入力に使われる同じ関数go()が即座にデコードします。

5. スクリプト全体(最初から最後まで)

import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

FREQUENCY = {
    "qd": "once a day", "od": "once a day",
    "bid": "twice a day", "tid": "three times a day", "qid": "four times a day", "qod": "every other day", "hs": "at bedtime", "stat": "immediately", "prn": "as needed", "am": "in the morning", "qam": "every morning", "pm": "in the evening", "qpm": "every evening",
}

TIMING = {
    "ac": "before meals", "pc": "after meals", "cc": "with meals",
}

ROUTE = {
    "po": "by mouth", "sl": "under the tongue", "iv": "by IV", "im": "as an injection into the muscle", "sc": "as an injection under the skin", "sq": "as an injection under the skin", "pr": "rectally", "top": "applied to the skin",
}

FORM = {
    "tab": "tablet", "tabs": "tablets", "cap": "capsule", "caps": "capsules", "ml": "ml", "mg": "mg", "gtt": "drop", "gtts": "drops",
}

def decode(text):
    tokens = text.replace(",", " ").split()
    result = []
    for tok in tokens:
        raw = tok.strip(".")
        low = raw.lower()

        if low.isdigit():
            result.append(f"Take {low}")
        elif low in FORM:
            result.append(FORM[low])
        elif low in FREQUENCY:
            result.append(FREQUENCY[low])
        elif low in TIMING:
            result.append(TIMING[low])
        elif low in ROUTE:
            result.append(ROUTE[low])
        elif low.startswith("q") and low.endswith("h") and low[1:-1].isdigit():
            hrs = low[1:-1]
            result.append(f"every {hrs} hours")
        else:
            result.append(raw)

    sentence = " ".join(result)
    return sentence[0].upper() + sentence[1:] if sentence else ""

HTML_PAGE = """
<!DOCTYPE html>
<html><head><meta charset="utf-8">
<title>MedSpeak</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Lora:wght@500;600&family=Inter:wght@400;500;600&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
<script src="https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/5.0.4/tesseract.min.js"></script>
<style>
  :root{
    --paper: #E8EDE6;
    --card: #FBFAF6;
    --ink: #1B2A4A;
    --ink-soft: #4A5A72;
    --amber: #C17817;
    --amber-deep: #9C5F10;
    --clay: #B3492B;
  }
  *{box-sizing:border-box}
  body{
    margin:0; padding:32px 16px 60px;
    background:var(--paper);
    background-image:
      radial-gradient(circle at 15% 8%, rgba(193,120,23,0.06), transparent 40%),
      radial-gradient(circle at 85% 90%, rgba(27,42,74,0.05), transparent 40%);
    font-family:'Inter', sans-serif;
    color:var(--ink);
    display:flex; justify-content:center;
  }
  main{width:100%; max-width:560px}

  .eyebrow{
    font-family:'IBM Plex Mono', monospace;
    font-size:12px; letter-spacing:0.12em; text-transform:uppercase;
    color:var(--amber-deep); font-weight:600;
  }
  h1{
    font-family:'Lora', serif; font-weight:600;
    font-size:clamp(28px, 5vw, 36px);
    margin:6px 0 4px; color:var(--ink);
  }
  .sub{ color:var(--ink-soft); font-size:15px; margin:0 0 28px; max-width:44ch; line-height:1.5}

  .rx-mark{
    display:inline-flex; align-items:center; justify-content:center;
    width:40px; height:40px; border-radius:10px;
    background:var(--ink); color:var(--paper);
    font-family:'Lora', serif; font-weight:600; font-size:20px;
    margin-bottom:14px;
  }

  .card{
    background:var(--card); border-radius:14px; padding:22px;
    box-shadow:0 1px 2px rgba(27,42,74,0.06), 0 8px 24px rgba(27,42,74,0.05);
    border:1px solid rgba(27,42,74,0.08);
  }

  label.field-label{
    display:block; font-size:13px; font-weight:600; color:var(--ink-soft);
    margin-bottom:6px;
  }

  input[type=text]{
    width:100%; padding:12px 14px; font-size:16px;
    border-radius:8px; border:1.5px solid rgba(27,42,74,0.18);
    font-family:'IBM Plex Mono', monospace; background:#fff; color:var(--ink);
  }
  input[type=text]:focus{
    outline:3px solid rgba(193,120,23,0.35); outline-offset:1px;
    border-color:var(--amber);
  }

  .row{ display:flex; gap:10px; margin-top:12px }
  .row input{flex:1}

  button{
    padding:12px 20px; font-size:15px; font-weight:600;
    border:none; border-radius:8px; cursor:pointer;
    background:var(--amber); color:#fff;
    font-family:'Inter', sans-serif;
    transition:background .15s ease;
  }
  button:hover{ background:var(--amber-deep) }
  button:focus-visible{ outline:3px solid var(--ink); outline-offset:2px }

  .examples{ font-size:13px; color:var(--ink-soft); margin-top:10px }
  .examples code{
    font-family:'IBM Plex Mono', monospace; background:rgba(27,42,74,0.06);
    padding:2px 6px; border-radius:4px;
  }

  .label-wrap{ margin-top:26px }
  .rx-label{
    position:relative;
    background:#fff;
    border:1.5px solid rgba(27,42,74,0.15);
    border-radius:2px;
    padding:18px 18px 16px;
    font-family:'IBM Plex Mono', monospace;
    font-size:15px; line-height:1.55; color:var(--ink);
    transform:rotate(-0.6deg);
    box-shadow:0 6px 16px rgba(27,42,74,0.10);
  }
  .rx-label::before{
    content:"";
    position:absolute; top:-1px; left:8px; right:8px; height:1px;
    background-image: repeating-linear-gradient(90deg, rgba(27,42,74,0.35) 0 6px, transparent 6px 12px);
  }
  .rx-label .tag{
    font-family:'Inter', sans-serif; font-weight:600; font-size:11px;
    letter-spacing:0.08em; text-transform:uppercase; color:var(--amber-deep);
    display:block; margin-bottom:6px;
  }
  .rx-label.empty{ color:var(--ink-soft); font-style:normal }

  .scan-section{ margin-top:30px }
  .scan-section .field-label{ margin-bottom:10px }
  .upload-box{
    background:var(--card); border:1.5px dashed rgba(27,42,74,0.25);
    border-radius:12px; padding:18px; text-align:center;
  }
  .upload-box input[type=file]{ font-family:'Inter', sans-serif; font-size:14px }
  #preview{ max-width:100%; margin-top:12px; border-radius:8px; display:none }
  #scanStatus{ font-size:13px; color:var(--ink-soft); margin-top:10px }

  @media (prefers-reduced-motion: reduce){
    * { transition:none !important }
  }
</style>
</head>
<body>
<main>
  <div class="rx-mark">Rx</div>
  <div class="eyebrow">Prescription shorthand, decoded</div>
  <h1>MedSpeak</h1>
  <p class="sub">Type or scan the shorthand exactly as written on the label — get back plain instructions anyone can follow.</p>

  <div class="card">
    <label class="field-label" for="rx">Prescription text</label>
    <div class="row">
      <input type="text" id="rx" placeholder="e.g. 1 tab BID pc" onkeydown="if(event.key==='Enter')go()">
      <button onclick="go()">Decode</button>
    </div>
    <p class="examples">Try <code>1 tab BID pc</code> &middot; <code>PRN q6h</code> &middot; <code>2 cap TID AC PO</code></p>

    <div class="label-wrap">
      <div class="rx-label empty" id="out">
        <span class="tag">Instructions</span>
        Decoded text will appear here.
      </div>
    </div>
  </div>

  <div class="scan-section">
    <label class="field-label">Or scan a photo of the prescription</label>
    <div class="upload-box">
      <input type="file" id="imgInput" accept="image/*" onchange="handleImage(event)">
      <img id="preview">
      <p id="scanStatus"></p>
    </div>
  </div>
</main>

<script>
async function go(){
  const text = document.getElementById('rx').value;
  const out = document.getElementById('out');
  if(!text.trim()){ return; }
  const res = await fetch('/api/decode', {
    method: 'POST',
    headers: {'Content-Type':'application/json'},
    body: JSON.stringify({text: text})
  });
  const data = await res.json();
  out.classList.remove('empty');
  out.innerHTML = '<span class="tag">Instructions</span>' + data.result;
}

function handleImage(event){
  const file = event.target.files[0];
  if (!file) return;
  const preview = document.getElementById('preview');
  preview.src = URL.createObjectURL(file);
  preview.style.display = 'block';
  scanImage(file);
}

async function scanImage(file){
  const status = document.getElementById('scanStatus');
  status.innerText = "Reading text from image...";
  try {
    const result = await Tesseract.recognize(file, 'eng');
    const rawText = result.data.text.trim();
    status.innerText = rawText ? ("Detected: " + rawText) : "No text found — try a clearer photo.";
    if (rawText){
      document.getElementById('rx').value = rawText.replace(/\\n/g, ' ');
      go();
    }
  } catch(e){
    status.innerText = "Scan failed — type it manually instead.";
  }
}
</script>
</body></html>
"""

class Handler(BaseHTTPRequestHandler):
    def _send(self, code, body, ctype="application/json"):
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.end_headers()
        self.wfile.write(body.encode())

    def do_GET(self):
        if self.path == "/":
            self._send(200, HTML_PAGE, "text/html")
        else:
            self._send(404, "{}")

    def do_POST(self):
        if self.path == "/api/decode":
            length = int(self.headers.get("Content-Length", 0))
            data = json.loads(self.rfile.read(length) or "{}")
            text = data.get("text", "")
            result = decode(text)
            self._send(200, json.dumps({"result": result}))
        else:
            self._send(404, "{}")

    def log_message(self, *args):
        pass

if __name__ == "__main__":
    print("MedSpeak running at http://localhost:8000")ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()

Enter fullscreen mode Exit fullscreen mode

python medspeak.pyで実行し、http://localhost:8000を開いてください。

6. 試してみる

以前と同じデコード例に加え、新しいスキャン機能も:

入力方法 出力
入力 1 tab BID pc 1錠を1日2回、食後に服用
入力 PRN q6h 必要に応じて6時間ごとに
2 CAP TID AC POと書かれたラベルの写真をスキャン (写真) 2カプセルを1日3回、食前に経口服用

まとめ

Tesseract.jsを使ってクライアントサイドでOCRを行う利点は、誰かの処方箋の写真がサーバーに触れることがないことです — 認識は完全にブラウザタブ内で起こり、認識されたテキストのみが/api/decodeに送信されます。これは医療ラベルを扱うツールにとって意味のあるプライバシー特性であり、サーバーサイドではなくクライアントサイドのOCRライブラリを選ぶことで、ほぼ無料で実現できました。

注: OCRの精度は写真の品質、照明、手書きによって異なります — スキャンした結果は必ず実際のラベルと照合し、曖昧な点は薬剤師に相談してください。