温度の数字認識への挑戦
紙に印刷した数字の認識
業務用の冷蔵庫や冷凍庫の庫内温度は、-20.0度から20度程度の間で扉の上にデジタル表示されている。これをiPhoneで撮影し、数値の認識を行わせる。次にそのデータをGoogle Spreadsheetに記録させる。ここまでをCHatGPTを用いながらCodePen上で開発したい。
数字認識のプログラム
ChatGPTには以下の指示を与えた。
再度、以下条件でお願いします。iPhoneのカメラで、数字を認識してデータを読み取り、iPhoneの中に記録して動くアプリをCodePenで作成します。数字は―20.0度から100.0度までの、小数点以下1桁までが表示されます。PWAを使います。CodePen用のコードを書きなさい。
その回答が以下である。コード類は長いので折りたたんである。
了解。条件に合わせて、-20.0 ~ 100.0(小数1桁)だけを抽出・記録する iPhone向けPWA(CodePen用)を用意しました。
オフラインでも使えるよう manifest / service worker をJS内で動的登録し、初回オンライン時にOCR資材をキャッシュします(以後はオフラインで動作)。
HTMLのコード
<!-- 外枠の <!doctype> / <html> / <head> / <body> は書かないでください -->
<!-- UI(Tailwind) -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- OCR(Tesseract.js) -->
<script src="https://unpkg.com/tesseract.js@4.0.2/dist/tesseract.min.js"></script>
<header class="p-4 bg-sky-500 text-white">
<h1 class="text-xl font-semibold">温度OCR PWA</h1>
<p class="text-sm opacity-90">
iPhoneカメラで「-20.0〜100.0」の数値(小数点以下1桁まで)を認識 → 端末内に保存
</p>
</header>
<main class="p-4 space-y-4 max-w-screen-md mx-auto">
<!-- カメラ -->
<section class="space-y-2">
<div class="flex items-center gap-2">
<button id="btnStart" class="px-3 py-2 rounded-lg bg-sky-600 text-white hover:bg-sky-700">カメラ起動</button>
<button id="btnStop" class="px-3 py-2 rounded-lg bg-slate-300 hover:bg-slate-400">停止</button>
<button id="btnCapture" class="px-3 py-2 rounded-lg bg-emerald-600 text-white hover:bg-emerald-700">撮影→OCR</button>
<span class="ml-auto text-xs text-slate-600">抽出:-20.0〜100.0(小数点以下1桁まで)</span>
</div>
<div class="relative rounded-xl overflow-hidden shadow bg-black">
<video id="video" class="w-full h-[45vh] object-contain bg-black" autoplay muted playsinline></video>
<!-- ROI -->
<div class="pointer-events-none absolute inset-0">
<div class="absolute inset-0 border-2 border-white/40 rounded-xl"></div>
<div class="absolute top-1/2 -translate-y-1/2 left-6 right-6 h-24 border-2 border-emerald-400/80 rounded-lg"></div>
</div>
</div>
<div class="text-sm text-slate-600">
カメラ不可時:<input id="filePick" type="file" accept="image/*" capture="environment" class="text-sm">
</div>
<div id="status" class="text-sm text-slate-700">準備中…</div>
<div id="net" class="text-xs"></div>
<div id="result" class="p-3 rounded bg-white shadow font-mono text-lg"></div>
<canvas id="preview" class="rounded-xl shadow bg-white w-full"></canvas>
</section>
<!-- 履歴 -->
<section class="space-y-2">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold">履歴</h2>
<button id="btnExport" class="ml-auto px-3 py-2 rounded-lg bg-amber-600 text-white hover:bg-amber-700">CSVエクスポート</button>
<button id="btnClear" class="px-3 py-2 rounded-lg bg-rose-600 text-white hover:bg-rose-700">全削除</button>
</div>
<div class="overflow-x-auto rounded-xl shadow">
<table class="min-w-full bg-white text-sm">
<thead class="bg-slate-100">
<tr>
<th class="p-2 text-left">日時</th>
<th class="p-2 text-left">温度(℃)</th>
<th class="p-2 text-left">画像</th>
<th class="p-2">操作</th>
</tr>
</thead>
<tbody id="history" class="divide-y"></tbody>
</table>
</div>
</section>
<canvas id="work" class="hidden"></canvas>
</main>
<footer class="p-4 text-center text-xs text-slate-500">
初回はオンラインで開き資材をキャッシュ → 以後オフラインで動作(PWA/ホーム画面追加 可)
</footer>
CSSは「空でOK」と出ました。
続いてJavaScriptのコードです。
JavaScriptのコード
/** =======================================================
* 温度OCR PWA (-20.0〜100.0℃, 小数点以下1桁まで)
* - 整数 or 小数1桁のみ許可(2桁以上の小数は除外)
* - 端末内(localStorage)に保存/CSVエクスポート
* - PWA(Manifest/SW)はJS内で動的登録:初回オンラインで資材キャッシュ
* - iPhoneのFull Page表示推奨(CodePenエディタ内は権限が弾かれる場合あり)
* ======================================================= */
/* ====== 診断パッチ(JS最上部に置く)====== */
(function setupDiagnostics() {
const st = document.getElementById('status') || (() => {
const d = document.createElement('div'); d.id = 'status'; document.body.prepend(d); return d;
})();
const net = document.getElementById('net') || (() => {
const d = document.createElement('div'); d.id = 'net'; document.body.prepend(d); return d;
})();
window.addEventListener('error', e => { st.textContent = 'JSエラー: ' + e.message; });
window.addEventListener('unhandledrejection', e => {
const msg = (e.reason && e.reason.message) || e.reason || '';
st.textContent = 'Promiseエラー: ' + msg;
});
st.textContent = '準備OK。「カメラ起動」をタップして権限を許可してください。';
const camOK = !!(navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
const httpsOK = location.protocol === 'https:';
net.innerHTML = [
`<span class="inline-block px-2 py-1 rounded ${navigator.onLine ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-200 text-slate-700'}">${navigator.onLine ? 'オンライン' : 'オフライン'}</span>`,
`HTTPS:${httpsOK ? 'OK' : 'NG'}`,
`getUserMedia:${camOK ? 'OK' : 'NG'}`
].join(' / ');
if (!httpsOK) st.textContent = 'HTTPSで開いてください(カメラ要件)。';
if (!camOK) st.textContent = 'エディタではなくFull Pageで開いてください。';
})();
/* ====== 診断パッチ ここまで ====== */
const RANGE_MIN = -20.0;
const RANGE_MAX = 100.0;
const els = {
video: document.getElementById('video'),
work: document.getElementById('work'),
preview: document.getElementById('preview'),
status: document.getElementById('status'),
net: document.getElementById('net'),
result: document.getElementById('result'),
histBody: document.getElementById('history'),
btnStart: document.getElementById('btnStart'),
btnStop: document.getElementById('btnStop'),
btnCapture: document.getElementById('btnCapture'),
btnExport: document.getElementById('btnExport'),
btnClear: document.getElementById('btnClear'),
filePick: document.getElementById('filePick'),
};
const STORE_KEY = 'temp_ocr_records_v3';
let mediaStream = null;
/* ====== ネット状態表示 ====== */
function updateNetBadge() {
els.net.innerHTML = navigator.onLine
? `<span class="inline-block px-2 py-1 rounded bg-emerald-100 text-emerald-700">オンライン</span>`
: `<span class="inline-block px-2 py-1 rounded bg-slate-200 text-slate-700">オフライン</span>`;
}
window.addEventListener('online', updateNetBadge);
window.addEventListener('offline', updateNetBadge);
updateNetBadge();
/* ====== Manifest 動的生成 ====== */
(function registerManifest() {
const svgIcon = encodeURIComponent(
`<svg xmlns='http://www.w3.org/2000/svg' width='512' height='512'>
<rect width='100%' height='100%' fill='#0ea5e9'/>
<text x='50%' y='56%' text-anchor='middle' font-size='280' font-family='system-ui,-apple-system,Segoe UI,Roboto' fill='white'>℃</text>
</svg>`
);
const iconUrl = `data:image/svg+xml;charset=utf-8,${svgIcon}`;
const manifest = {
name: "温度OCR PWA",
short_name: "TempOCR",
start_url: ".",
display: "standalone",
background_color: "#0ea5e9",
theme_color: "#0ea5e9",
icons: [
{ src: iconUrl, sizes: "512x512", type: "image/svg+xml" },
{ src: iconUrl, sizes: "192x192", type: "image/svg+xml" }
]
};
const blob = new Blob([JSON.stringify(manifest)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('link');
link.rel = 'manifest';
link.href = url;
document.head.appendChild(link);
const apple = document.createElement('link');
apple.rel = 'apple-touch-icon';
apple.href = iconUrl;
document.head.appendChild(apple);
})();
/* ====== Service Worker(OCR資材を事前キャッシュ) ====== */
(async function registerSW() {
if (!('serviceWorker' in navigator)) return;
const PRECACHE_URLS = [
location.href.split('#')[0],
'https://cdn.tailwindcss.com',
'https://unpkg.com/tesseract.js@4.0.2/dist/tesseract.min.js',
'https://unpkg.com/tesseract.js@4.0.2/dist/worker.min.js',
'https://unpkg.com/tesseract.js-core@4.0.2/tesseract-core-simd.wasm',
'https://tessdata.projectnaptha.com/4.0.0/eng.traineddata.gz'
];
const swCode = `
const CACHE = 'temp-ocr-precache-v5';
const PRECACHE_URLS = ${JSON.stringify(PRECACHE_URLS)};
self.addEventListener('install', (e) => {
e.waitUntil((async () => {
const c = await caches.open(CACHE);
try { await c.addAll(PRECACHE_URLS); } catch(_) {}
self.skipWaiting();
})());
});
self.addEventListener('activate', (e) => {
e.waitUntil(clients.claim());
});
// キャッシュ優先(オフライン重視)
self.addEventListener('fetch', (e) => {
e.respondWith((async () => {
const hit = await caches.match(e.request);
if (hit) return hit;
try {
const net = await fetch(e.request);
const c = await caches.open(CACHE);
c.put(e.request, net.clone()).catch(()=>{});
return net;
} catch (err) { throw err; }
})());
});
`;
const swBlob = new Blob([swCode], { type: 'text/javascript' });
const swUrl = URL.createObjectURL(swBlob);
try { await navigator.serviceWorker.register(swUrl); }
catch(e){ console.warn('SW registration failed:', e); }
})();
/* ====== ストレージ ====== */
function loadRecords() {
try { return JSON.parse(localStorage.getItem(STORE_KEY) || '[]'); } catch { return []; }
}
function saveRecords(arr) { localStorage.setItem(STORE_KEY, JSON.stringify(arr)); }
function addRecord(value, dataUrl) {
const recs = loadRecords();
const item = { id: crypto.randomUUID(), ts: Date.now(), value, image: dataUrl || null };
recs.unshift(item);
saveRecords(recs);
renderHistory();
}
/* 表示は「小数点以下1桁まで」:整数は .0 を付けない */
function fmt1dpMax(v) {
return Math.abs(v % 1) < 1e-9 ? Number(v).toFixed(0) : Number(v).toFixed(1);
}
/* ====== 履歴描画 ====== */
function renderHistory() {
const recs = loadRecords();
els.histBody.innerHTML = '';
for (const r of recs) {
const tr = document.createElement('tr');
tr.innerHTML = `
<td class="p-2 whitespace-nowrap">${new Date(r.ts).toLocaleString()}</td>
<td class="p-2 font-mono">${fmt1dpMax(r.value)}</td>
<td class="p-2">${r.image ? `<img src="${r.image}" class="h-12 rounded shadow" />` : ''}</td>
<td class="p-2 text-center">
<button data-id="${r.id}" class="del px-2 py-1 rounded bg-slate-200 hover:bg-slate-300 text-xs">削除</button>
</td>
`;
els.histBody.appendChild(tr);
}
els.histBody.querySelectorAll('.del').forEach(btn=>{
btn.addEventListener('click', (e)=>{
const id = e.currentTarget.getAttribute('data-id');
const recs = loadRecords().filter(x => x.id !== id);
saveRecords(recs); renderHistory();
});
});
}
renderHistory();
/* ====== カメラ ====== */
async function startCamera() {
const st = document.getElementById('status');
try {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
st.textContent = 'CodePenのFull Pageで開いてください(getUserMedia不可)。';
return;
}
if (window.__mediaStream) {
window.__mediaStream.getTracks().forEach(t => t.stop());
window.__mediaStream = null;
}
const constraints = {
audio: false,
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 }
}
};
st.textContent = 'カメラ権限を要求中…(許可してください)';
const stream = await navigator.mediaDevices.getUserMedia(constraints);
window.__mediaStream = stream;
const v = document.getElementById('video');
v.srcObject = stream;
await v.play().catch(()=>{});
st.textContent = 'カメラ起動。中央の緑枠に数値を合わせてください。';
} catch (e) {
const msg = (e && e.message) || String(e);
document.getElementById('status').textContent =
'カメラ起動に失敗:' + msg + '(Full Pageで再実行・権限許可を確認)';
console.error(e);
}
}
function stopCamera() {
if (mediaStream) {
mediaStream.getTracks().forEach(t => t.stop());
mediaStream = null;
els.video.srcObject = null;
}
}
/* ====== ROI切り出し + 前処理 ====== */
function grabROIFromVideo() {
const video = els.video;
const w = video.videoWidth, h = video.videoHeight;
if (!w || !h) return null;
const work = els.work;
const ctx = work.getContext('2d', { willReadFrequently: true });
// 中央帯(高さ24%)
const roiH = Math.floor(h * 0.24);
const roiY = Math.floor(h * 0.38);
const roiX = Math.floor(w * 0.08);
const roiW = Math.floor(w * 0.84);
work.width = roiW;
work.height = roiH;
ctx.drawImage(video, roiX, roiY, roiW, roiH, 0, 0, roiW, roiH);
// グレースケール+軽いコントラスト
const img = ctx.getImageData(0, 0, roiW, roiH);
const data = img.data;
for (let i = 0; i < data.length; i += 4) {
const y = data[i]*0.2126 + data[i+1]*0.7152 + data[i+2]*0.0722;
let v = Math.pow(y/255, 0.8) * 255;
data[i] = data[i+1] = data[i+2] = v;
}
ctx.putImageData(img, 0, 0);
// プレビュー
const pctx = els.preview.getContext('2d');
els.preview.width = roiW;
els.preview.height = roiH;
pctx.drawImage(work, 0, 0);
return work;
}
/* ====== OCR(Tesseract.js) ====== */
async function ocrCanvas(canvas) {
if (!window.Tesseract) throw new Error('Tesseract.jsが読み込まれていません。');
els.status.textContent = 'OCR実行中…';
// v4互換パターンで確実に動かす
const worker = await Tesseract.createWorker({ logger: m => {
if (m.status && typeof m.progress === 'number') {
els.status.textContent = `${m.status} ${(m.progress*100).toFixed(0)}%`;
}
}});
await worker.load();
await worker.loadLanguage('eng');
await worker.initialize('eng');
// 許可文字:数字・小数点・各種マイナス記号
await worker.setParameters({
tessedit_char_whitelist: '0123456789.-−—–―',
preserve_interword_spaces: '1',
tessedit_pageseg_mode: '7' // 1行想定
});
const { data: { text } } = await worker.recognize(canvas);
await worker.terminate();
els.status.textContent = 'OCR完了';
return text;
}
/* ====== 数値抽出(-20.0〜100.0、整数 or 小数1桁) ====== */
function normalizeMinus(s) {
// 全角/ダッシュを半角-に統一
return s.replace(/[\u2212\u2010-\u2015\uFE58\uFE63\uFF0D]/g, '-');
}
function extractTemps(rawText) {
let t = normalizeMinus(rawText)
.replace(/[℃°C度]/g, ' ')
.replace(/[A-Za-z]/g, ' '); // Cなどの残渣
// 1) 小数1桁: [-+]?\d{1,3}\.\d(?!\d)
// 2) 整数: [-+]?\d{1,3}(?![\d.]) ← 後続に数字/ドットがあれば整数扱いしない
const re = /[-+]?\d{1,3}\.\d(?!\d)|[-+]?\d{1,3}(?![\d.])/g;
const out = [];
for (const m of t.matchAll(re)) {
const s = m[0];
const v = parseFloat(s);
if (!Number.isFinite(v)) continue;
if (v < RANGE_MIN || v > RANGE_MAX) continue;
// 保存は1桁に丸め(整数はそのまま)
const rounded = Math.round(v * 10) / 10;
out.push(rounded);
}
return [...new Set(out)]; // 重複排除
}
/* ====== CSV出力 ====== */
function exportCSV() {
const recs = loadRecords();
const rows = [['timestamp','localTime','valueC']];
for (const r of recs) {
rows.push([r.ts, new Date(r.ts).toLocaleString(), fmt1dpMax(r.value)]);
}
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\r\n');
const blob = new Blob([csv], { type: 'text/csv' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = 'temp_records.csv'; a.click();
URL.revokeObjectURL(url);
}
/* ====== イベント ====== */
els.btnStart.addEventListener('click', startCamera);
// iOSでのタップ取りこぼし対策(任意)
els.btnStart.addEventListener('touchend', (e)=>{ e.preventDefault(); startCamera(); }, { passive:false });
els.btnStop.addEventListener('click', stopCamera);
els.btnCapture.addEventListener('click', async () => {
try {
const roiCanvas = grabROIFromVideo();
if (!roiCanvas) { els.status.textContent = '映像が未取得です。'; return; }
const text = await ocrCanvas(roiCanvas);
const temps = extractTemps(text);
if (!temps.length) {
els.result.textContent = '該当値(-20.0〜100.0、整数または小数1桁)が見つかりません。';
return;
}
els.result.textContent = temps.map(fmt1dpMax).join(' / ');
const dataUrl = roiCanvas.toDataURL('image/png', 0.9);
temps.forEach(v => addRecord(v, dataUrl));
} catch (e) {
els.status.textContent = 'OCR失敗:' + e.message;
console.error(e);
}
});
els.filePick.addEventListener('change', async (e) => {
const f = e.target.files?.[0];
if (!f) return;
try {
const bmp = await createImageBitmap(f);
const work = els.work;
const ctx = work.getContext('2d', { willReadFrequently: true });
const w = bmp.width, h = bmp.height;
work.width = w; work.height = h;
ctx.drawImage(bmp, 0, 0);
const roiH = Math.floor(h * 0.24);
const roiY = Math.floor(h * 0.38);
const roiX = Math.floor(w * 0.08);
const roiW = Math.floor(w * 0.84);
const roi = document.createElement('canvas');
roi.width = roiW; roi.height = roiH;
roi.getContext('2d').drawImage(work, roiX, roiY, roiW, roiH, 0, 0, roiW, roiH);
els.preview.width = roiW; els.preview.height = roiH;
els.preview.getContext('2d').drawImage(roi, 0, 0);
const text = await ocrCanvas(roi);
const temps = extractTemps(text);
if (!temps.length) {
els.result.textContent = '該当値(-20.0〜100.0、整数または小数1桁)が見つかりません。';
} else {
els.result.textContent = temps.map(fmt1dpMax).join(' / ');
temps.forEach(v => addRecord(v, els.preview.toDataURL('image/png', 0.9)));
}
} catch (e2) {
els.status.textContent = 'OCR失敗:' + e2.message;
} finally {
e.target.value = '';
}
});
els.btnExport.addEventListener('click', exportCSV);
els.btnClear.addEventListener('click', () => {
if (!confirm('履歴をすべて削除しますか?')) return;
saveRecords([]); renderHistory();
});
// iOSのページ離脱時はカメラ停止
window.addEventListener('pagehide', stopCamera);
// 初期メッセージ
els.status.textContent = '「カメラ起動」→ 緑枠に数値を合わせ →「撮影→OCR」。-20.0〜100.0の整数または小数1桁だけ保存。';
その後、以下の回答が続いて終わりです。
使い方(要点)
1.CodePenにHTML/JSを貼って保存→実行。
2.初回はオンラインで開き、OCR資材をキャッシュ。以後はオフラインでも撮影→OCR→保存が可能。
3.認識値は**-20.0 〜 100.0の小数1桁だけを抽出・保存。履歴は端末内(localStorage)**に保持。
4.CSVエクスポートで取り出せます。
結果
うまくいきました。
そのデータもGoogle DriveにCSVで取り込めました。

めでたし。めでたし。
