[object Object]

← back to Dw Photo Capture

Camera SKU scan: πŸ”’ Scan button β†’ macOS Vision OCR (/api/ocr, local/free) reads a printed mfr#/SKU off a sample book, extracts the SKU token, and jumps to that product in Any-SKU search. bin/ocr Swift binary

b34052edabaa735d0ceea0bbdd0a71fe04e8cf5e Β· 2026-06-25 08:47:46 -0700 Β· steve@designerwallcoverings.com

Files touched

Diff

commit b34052edabaa735d0ceea0bbdd0a71fe04e8cf5e
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Thu Jun 25 08:47:46 2026 -0700

    Camera SKU scan: πŸ”’ Scan button β†’ macOS Vision OCR (/api/ocr, local/free) reads a printed mfr#/SKU off a sample book, extracts the SKU token, and jumps to that product in Any-SKU search. bin/ocr Swift binary
---
 bin/ocr           | Bin 0 -> 65232 bytes
 bin/ocr.swift     |  14 ++++++++++++++
 data/build.json   |   6 ++++--
 public/index.html |  19 +++++++++++++++++++
 server.js         |  28 ++++++++++++++++++++++++++++
 5 files changed, 65 insertions(+), 2 deletions(-)

diff --git a/bin/ocr b/bin/ocr
new file mode 100755
index 0000000..25f6a5f
Binary files /dev/null and b/bin/ocr differ
diff --git a/bin/ocr.swift b/bin/ocr.swift
new file mode 100644
index 0000000..d9930f3
--- /dev/null
+++ b/bin/ocr.swift
@@ -0,0 +1,14 @@
+import Foundation
+import Vision
+import AppKit
+let args = CommandLine.arguments
+guard args.count > 1, let img = NSImage(contentsOfFile: args[1]),
+      let cg = img.cgImage(forProposedRect: nil, context: nil, hints: nil) else { print(""); exit(1) }
+let req = VNRecognizeTextRequest()
+req.recognitionLevel = .accurate
+req.usesLanguageCorrection = false
+let h = VNImageRequestHandler(cgImage: cg, options: [:])
+try? h.perform([req])
+var lines: [String] = []
+for obs in (req.results ?? []) { if let t = obs.topCandidates(1).first?.string { lines.append(t) } }
+print(lines.joined(separator: "\n"))
diff --git a/data/build.json b/data/build.json
index 17b1a54..e43dbd2 100644
--- a/data/build.json
+++ b/data/build.json
@@ -1,6 +1,7 @@
 {
-  "next": 26,
+  "next": 28,
   "map": {
+    "63863152": 27,
     "578af86f": 2,
     "bc95fdb0": 3,
     "3a3b2a43": 4,
@@ -24,6 +25,7 @@
     "a455891f": 22,
     "b6ea475b": 23,
     "cf63df2c": 24,
-    "3c666b73": 25
+    "3c666b73": 25,
+    "ce9df522": 26
   }
 }
\ No newline at end of file
diff --git a/public/index.html b/public/index.html
index eacf2e8..6f385c0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -26,6 +26,8 @@
   .controls{display:flex;gap:8px;align-items:center;margin-top:10px;flex-wrap:wrap}
   input[type=search],select{background:#16140f;border:1px solid var(--line);color:var(--ink);border-radius:10px;padding:9px 11px;font-size:15px}
   input[type=search]{flex:1;min-width:140px}
+  .scanbtn{position:relative;overflow:hidden;background:#16140f;border:1px solid var(--gold);color:var(--gold);border-radius:10px;padding:9px 12px;font-size:14px;font-weight:600;white-space:nowrap;cursor:pointer}
+  .scanbtn input{position:absolute;inset:0;opacity:0;font-size:120px;cursor:pointer}
   .chips{display:flex;gap:6px}
   .chip{padding:7px 13px;border-radius:99px;border:1px solid var(--line);background:#16140f;color:var(--muted);font-size:13px;cursor:pointer}
   .chip.on{background:var(--gold);color:#1b1407;border-color:var(--gold);font-weight:600}
@@ -125,6 +127,7 @@
   <div class="meta"><span id="count">…</span><span id="remain"></span></div>
   <div class="controls">
     <input type="search" id="q" placeholder="πŸ”Ž Search name or model #…" autocapitalize="characters">
+    <label class="scanbtn" title="Scan a printed SKU/model #">πŸ”’ Scan<input type="file" accept="image/*" capture="environment" id="scanInput"></label>
     <select id="sort">
       <option value="ready">Priced first</option>
       <option value="title">Title A→Z</option>
@@ -658,6 +661,22 @@ document.querySelectorAll('.chip').forEach(c=>c.addEventListener('click',()=>{
   else if(filter==='recent'){ $('#q').placeholder='πŸ”Ž Filter recent…'; render(); loadRecent(); }
   else { $('#q').placeholder='πŸ”Ž Search name or model #…'; render(); }
 }));
+// πŸ”’ Scan a printed mfr#/SKU with the camera β†’ OCR (server Vision) β†’ jump to that product.
+$('#scanInput').addEventListener('change',async e=>{
+  const f=e.target.files[0]; if(!f)return;
+  toast('πŸ“· Reading number…');
+  const dataUrl=await downscale(f,1600);
+  try{
+    const r=await fetch('/api/ocr',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({dataUrl})});
+    const d=await r.json();
+    e.target.value='';
+    if(!d.top){ toast('No number found β€” try a closer, flatter shot'); return; }
+    // jump to Any-SKU mode (it searches the mfr# index, which is what's printed on sample books)
+    if(filter!=='any'){ document.querySelectorAll('.chip').forEach(z=>z.classList.remove('on')); const sc=document.querySelector('.chip[data-f="any"]'); if(sc)sc.classList.add('on'); filter='any'; setLS('filter','any'); $('#q').placeholder='πŸ”Ž SKU, model #, name, or collection…'; }
+    $('#q').value=d.top; toast('πŸ“· Read: '+d.top+(d.candidates.length>1?(' (also '+d.candidates.slice(1,3).join(', ')+')'):''));
+    doLookup();
+  }catch(err){ toast('Scan failed β€” check connection'); }
+});
 load(); loadNew(); loadFav(); loadRecent();
 setInterval(()=>{ if(filter!=='any'&&filter!=='shop'&&filter!=='fav'&&filter!=='recent') load(); }, 60000);
 </script>
diff --git a/server.js b/server.js
index 30db5dc..7c235f5 100644
--- a/server.js
+++ b/server.js
@@ -12,6 +12,7 @@ const http = require('http');
 const https = require('https');
 const fs = require('fs');
 const path = require('path');
+const { execFile } = require('child_process');
 
 const ROOT = __dirname;
 const DATA = path.join(ROOT, 'data');
@@ -500,6 +501,33 @@ const server = http.createServer((req, res) => {
     return;
   }
 
+  // OCR a photo of a printed mfr#/SKU (macOS Vision, local/free) β†’ ranked SKU candidates.
+  if (u.pathname === '/api/ocr' && req.method === 'POST') {
+    let body = '';
+    req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
+    req.on('end', () => {
+      let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
+      if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
+      const tmp = path.join(PHOTOS, `_ocr-${Date.now()}.jpg`);
+      try { fs.writeFileSync(tmp, Buffer.from(p.dataUrl.replace(/^data:image\/\w+;base64,/, ''), 'base64')); }
+      catch (e) { return send(res, 500, { err: 'write fail' }); }
+      execFile(path.join(ROOT, 'bin/ocr'), [tmp], { timeout: 8000 }, (err, stdout) => {
+        try { fs.unlinkSync(tmp); } catch (e) {}
+        const text = (stdout || '').trim();
+        // pull SKU-like tokens: GRS-####, mfr codes (letters+digits), digit-dash codes
+        const toks = (text.toUpperCase().match(/[A-Z0-9][A-Z0-9-]{2,13}/g) || [])
+          .filter(t => /\d/.test(t) && /[A-Z0-9]/.test(t) && t.length >= 4);
+        const seen = new Set(); const cand = [];
+        // rank: GRS- first, then dashed, then longest
+        for (const t of toks.sort((a, b) => (b.startsWith('GRS-') - a.startsWith('GRS-')) || (b.includes('-') - a.includes('-')) || (b.length - a.length))) {
+          if (!seen.has(t)) { seen.add(t); cand.push(t); }
+        }
+        send(res, 200, { ok: true, text, candidates: cand, top: cand[0] || null });
+      });
+    });
+    return;
+  }
+
   if (u.pathname === '/api/photo' && req.method === 'POST') {
     let body = '';
     req.on('data', c => { body += c; if (body.length > 25 * 1024 * 1024) req.destroy(); });

← f61b633 Desktop-controlled remote shutter (DTD-C): /desk control pag  Β·  back to Dw Photo Capture  Β·  auto-save: 2026-06-25T09:03:03 (1 files) β€” data/build.json 54495aa β†’