當我第一次建立 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, "{}")
進入全螢幕模式 退出全螢幕模式
伺服器現在使用 JSON 輸入和 JSON 輸出,而非解析表單編碼的主體並重新渲染完整的 HTML 頁面。
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;
}
進入全螢幕模式 退出全螢幕模式
這就是讓頁面感覺即時的原因——無需整頁重新載入,只需更新標籤卡片的位置。
3. 從照片中讀取文字
這是新的部分。Tesseract.js 從 CDN 載入並完全在客戶端執行 OCR:
<script src="https://cdnjs.cloudflare.com/ajax/libs/tesseract.js/5.0.4/tesseract.min.js"></script>
進入全螢幕模式 退出全螢幕模式
當選擇檔案時,它會立即預覽並傳遞給 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);
}
進入全螢幕模式 退出全螢幕模式
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.";
}
}
進入全螢幕模式 退出全螢幕模式
Tesseract.recognize(file, 'eng') 執行整個 OCR 流程並返回檢測到的文字。新行會被折疊成空格(因為處方簡寫通常是單行),輸入框會被填入,而 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> · <code>PRN q6h</code> · <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()
進入全螢幕模式 退出全螢幕模式
使用 python medspeak.py 執行並開啟 http://localhost:8000。
6. 試用
與之前相同的解碼範例,加上新的掃描路徑:
| 輸入方式 | 範例 | 輸出 |
|---|---|---|
| 手動輸入 | 1 tab BID pc |
Take 1 tablet twice a day after meals |
| 手動輸入 | PRN q6h |
As needed every 6 hours |
掃描標籤照片,內容為 2 CAP TID AC PO
|
(photo) | Take 2 capsules three times a day before meals by mouth |
結語
使用 Tesseract.js 在客戶端執行 OCR 的好處是,任何人的處方照片都不會觸及伺服器——辨識完全在瀏覽器分頁中進行,只有辨識出的文字才會發送到 /api/decode。對於處理醫療標籤的工具來說,這是一個有意義的隱私特性,而選擇客戶端 OCR 函式庫而非伺服器端函式庫,基本上可以免費獲得這個特性。
注意:OCR 準確度會因照片品質、照明和手寫而異——請務必根據實際標籤仔細檢查掃描結果,如有任何疑問,請諮詢您的藥師。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.