β 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
A bin/ocrA bin/ocr.swiftM data/build.jsonM public/index.htmlM server.js
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 β