Open1

温度の数字認識への挑戦

zipmanzipman

紙に印刷した数字の認識

業務用の冷蔵庫や冷凍庫の庫内温度は、-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で取り込めました。

めでたし。めでたし。