ebayDeepScan/test_OCR.js

735 lines
19 KiB
JavaScript

const express = require("express");
const OpenAI = require("openai");
const cors = require("cors");
require("dotenv").config();
const app = express();
app.use(cors());
app.use(express.json({ limit: "10mb" }));
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
// ---- API: analyze image ----
app.post("/analyze", async (req, res) => {
try {
const { imageUrl } = req.body;
if (!imageUrl) return res.status(400).json({ error: "imageUrl is required" });
const aiRes = await client.chat.completions.create({
model: "gpt-4o-mini", // upgraded: better vision accuracy
temperature: 0,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: "You are a RAM hardware OCR expert. Extract text from RAM module labels and return only valid JSON, no markdown."
},
{
role: "user",
content: [
{
type: "text",
text: `Read all text on this RAM module label and return JSON:
{
"modules": [
{
"brand": "<manufacturer name>",
"part_number": "<exact part number>",
"specs": "<capacity, rank, speed spec line>",
"raw_label": "<all visible text on label, space-separated>"
}
],
"total_modules": <integer>
}`
},
{
type: "image_url",
image_url: { url: imageUrl, detail: "high" } // request high detail
}
]
}
]
});
const raw = aiRes.choices[0].message.content;
const data = JSON.parse(raw);
res.json(data);
} catch (err) {
console.error(err);
res.status(500).json({ error: err.message });
}
});
// ---- serve UI ----
app.get("/", (_req, res) => {
res.send(`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>RAM OCR Inspector</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;600;700&family=Syne:wght@400;600;800&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0a0c10;
--surface: #111318;
--surface2: #181c24;
--border: #252a35;
--border-glow: #2a7fff44;
--text: #e8eaf0;
--text-muted: #6b7280;
--accent: #2a7fff;
--accent-dim: #1a4f99;
--green: #00e5a0;
--yellow: #f5c518;
--red: #ff4d6a;
--mono: 'JetBrains Mono', monospace;
--sans: 'Syne', sans-serif;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--sans);
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
header {
padding: 18px 32px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
}
.logo-icon {
width: 34px; height: 34px;
background: var(--accent);
border-radius: 8px;
display: grid; place-items: center;
font-size: 16px;
}
header h1 {
font-size: 18px;
font-weight: 800;
letter-spacing: -0.3px;
}
header span {
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
background: var(--surface2);
border: 1px solid var(--border);
padding: 2px 8px;
border-radius: 4px;
margin-left: 6px;
}
/* ── Main layout ── */
main {
flex: 1;
display: grid;
grid-template-columns: 1fr 380px;
gap: 0;
overflow: hidden;
}
/* ── Left panel ── */
.left {
display: flex;
flex-direction: column;
padding: 24px;
gap: 20px;
overflow-y: auto;
border-right: 1px solid var(--border);
}
.input-row {
display: flex;
gap: 10px;
align-items: center;
}
.input-row input {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--mono);
font-size: 13px;
padding: 10px 14px;
border-radius: 8px;
outline: none;
transition: border-color .2s;
}
.input-row input:focus { border-color: var(--accent); }
.input-row input::placeholder { color: var(--text-muted); }
button#analyzeBtn {
background: var(--accent);
color: #fff;
border: none;
font-family: var(--sans);
font-weight: 700;
font-size: 13px;
padding: 10px 22px;
border-radius: 8px;
cursor: pointer;
transition: background .2s, transform .1s;
white-space: nowrap;
}
button#analyzeBtn:hover { background: #1a6fe8; }
button#analyzeBtn:active { transform: scale(.97); }
button#analyzeBtn:disabled { background: var(--accent-dim); cursor: not-allowed; }
/* ── Canvas wrapper ── */
.canvas-wrap {
position: relative;
display: inline-block;
line-height: 0;
border-radius: 10px;
overflow: hidden;
border: 1px solid var(--border);
align-self: flex-start;
max-width: 100%;
}
.canvas-wrap img {
display: block;
max-width: 100%;
height: auto;
}
.canvas-wrap canvas {
position: absolute;
top: 0; left: 0;
width: 100%;
height: 100%;
cursor: crosshair;
}
.placeholder {
border: 2px dashed var(--border);
border-radius: 10px;
height: 280px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
color: var(--text-muted);
font-family: var(--mono);
font-size: 13px;
}
.placeholder .icon { font-size: 36px; opacity: .4; }
/* ── Word list ── */
.word-list-wrap {
display: flex;
flex-direction: column;
gap: 6px;
}
.section-label {
font-family: var(--mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.5px;
color: var(--text-muted);
padding-bottom: 4px;
border-bottom: 1px solid var(--border);
}
.word-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
font-family: var(--mono);
font-size: 12px;
padding: 4px 10px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
transition: background .15s, border-color .15s, color .15s;
user-select: none;
}
.chip:hover { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip.low-conf { border-color: var(--yellow); color: var(--yellow); }
.chip.dim { opacity: .45; }
/* ── Tooltip ── */
.tooltip {
position: fixed;
background: #fff;
color: #000;
font-family: var(--mono);
font-size: 12px;
padding: 4px 10px;
border-radius: 5px;
pointer-events: none;
opacity: 0;
transition: opacity .15s;
z-index: 999;
white-space: nowrap;
}
.tooltip.show { opacity: 1; }
/* ── Right panel ── */
.right {
display: flex;
flex-direction: column;
overflow: hidden;
}
.tabs {
display: flex;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.tab {
padding: 12px 20px;
font-family: var(--mono);
font-size: 12px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s;
user-select: none;
}
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab:hover:not(.active) { color: var(--text); }
.tab-content {
flex: 1;
overflow-y: auto;
padding: 20px;
display: none;
}
.tab-content.active { display: block; }
/* ── Module cards ── */
.module-card {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
margin-bottom: 14px;
}
.module-card .brand {
font-size: 16px;
font-weight: 800;
color: var(--accent);
margin-bottom: 10px;
display: flex;
align-items: center;
gap: 8px;
}
.badge {
font-family: var(--mono);
font-size: 10px;
padding: 2px 7px;
border-radius: 4px;
background: var(--accent-dim);
color: var(--accent);
}
.kv-grid {
display: grid;
grid-template-columns: 110px 1fr;
gap: 6px 12px;
font-family: var(--mono);
font-size: 12px;
}
.kv-grid .k { color: var(--text-muted); }
.kv-grid .v { color: var(--text); word-break: break-all; }
.kv-grid .v.good { color: var(--green); }
/* ── JSON panel ── */
pre#jsonOut {
font-family: var(--mono);
font-size: 11.5px;
line-height: 1.7;
color: #a8b4cc;
white-space: pre-wrap;
word-break: break-all;
}
/* ── Status bar ── */
.status {
padding: 8px 24px;
background: var(--surface);
border-top: 1px solid var(--border);
font-family: var(--mono);
font-size: 11px;
color: var(--text-muted);
display: flex;
gap: 20px;
align-items: center;
}
.status .dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--border);
display: inline-block;
}
.status .dot.ready { background: var(--green); box-shadow: 0 0 6px var(--green); }
.status .dot.loading { background: var(--yellow); animation: blink 1s infinite; }
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: .3; }
}
/* ── Copy toast ── */
.toast {
position: fixed;
bottom: 30px; right: 30px;
background: var(--green);
color: #000;
font-family: var(--mono);
font-size: 13px;
font-weight: 600;
padding: 10px 18px;
border-radius: 8px;
opacity: 0;
transform: translateY(8px);
transition: opacity .25s, transform .25s;
pointer-events: none;
z-index: 1000;
}
.toast.show { opacity: 1; transform: translateY(0); }
/* scrollbar */
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<header>
<div class="logo-icon">🔍</div>
<h1>RAM OCR Inspector</h1>
<span>gpt-4o · high detail</span>
</header>
<main>
<!-- LEFT -->
<div class="left">
<div class="input-row">
<input id="url" type="url" placeholder="Paste image URL of RAM module…"/>
<button id="analyzeBtn" onclick="run()">Analyze</button>
</div>
<div id="imgArea">
<div class="placeholder">
<span class="icon">🖥️</span>
<span>Image will appear here</span>
</div>
</div>
<div id="wordListArea"></div>
</div>
<!-- RIGHT -->
<div class="right">
<div class="tabs">
<div class="tab active" onclick="switchTab('modules')">Modules</div>
<div class="tab" onclick="switchTab('json')">Raw JSON</div>
</div>
<div id="tab-modules" class="tab-content active">
<p style="color:var(--text-muted); font-family:var(--mono); font-size:13px;">
Module info will appear after analysis.
</p>
</div>
<div id="tab-json" class="tab-content">
<pre id="jsonOut">// No data yet</pre>
</div>
</div>
</main>
<div class="status">
<span class="dot" id="statusDot"></span>
<span id="statusText">Ready</span>
<span id="wordCount"></span>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="toast" id="toast"></div>
<script>
let currentWords = [];
let hoveredIdx = -1;
let imgEl = null;
let canvasEl = null;
function switchTab(name) {
document.querySelectorAll('.tab').forEach((t,i) => {
t.classList.toggle('active', ['modules','json'][i] === name);
});
document.querySelectorAll('.tab-content').forEach(c => {
c.classList.toggle('active', c.id === 'tab-' + name);
});
}
function setStatus(state, msg) {
const dot = document.getElementById('statusDot');
dot.className = 'dot ' + (state === 'loading' ? 'loading' : state === 'ready' ? 'ready' : '');
document.getElementById('statusText').textContent = msg;
}
function showToast(msg) {
const t = document.getElementById('toast');
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 1800);
}
async function run() {
const url = document.getElementById('url').value.trim();
if (!url) return;
const btn = document.getElementById('analyzeBtn');
btn.disabled = true;
btn.textContent = 'Analyzing…';
setStatus('loading', 'Sending to GPT-4o…');
document.getElementById('tab-modules').innerHTML = \`<p style="color:var(--text-muted);font-family:var(--mono);font-size:13px">Analyzing…</p>\`;
document.getElementById('jsonOut').textContent = '// Waiting…';
try {
const res = await fetch('/analyze', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ imageUrl: url })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
currentWords = data.words || [];
document.getElementById('jsonOut').textContent = JSON.stringify(data, null, 2);
renderModules(data);
renderWordChips(currentWords);
drawImage(url, currentWords);
document.getElementById('wordCount').textContent =
\`\${currentWords.length} words · \${data.total_modules || 0} module(s)\`;
setStatus('ready', 'Done');
} catch (err) {
setStatus('', 'Error: ' + err.message);
document.getElementById('tab-modules').innerHTML =
\`<p style="color:var(--red);font-family:var(--mono);font-size:13px">\${err.message}</p>\`;
} finally {
btn.disabled = false;
btn.textContent = 'Analyze';
}
}
function renderModules(data) {
const panel = document.getElementById('tab-modules');
if (!data.modules || !data.modules.length) {
panel.innerHTML = '<p style="color:var(--text-muted);font-family:var(--mono);font-size:13px">No modules detected.</p>';
return;
}
panel.innerHTML = data.modules.map((m, i) => \`
<div class="module-card">
<div class="brand">
\${m.brand || 'Unknown'}
<span class="badge">Module \${i + 1}</span>
</div>
<div class="kv-grid">
\${row('Part №', m.part_number)}
\${row('Type', m.type, 'good')}
\${row('Capacity', m.capacity, 'good')}
\${row('Speed', m.speed)}
\${row('Voltage', m.voltage)}
\${row('Specs', m.specs)}
\${row('Label', m.raw_label)}
</div>
</div>
\`).join('');
}
function row(k, v, cls = '') {
if (!v) return '';
return \`<span class="k">\${k}</span><span class="v \${cls}">\${v}</span>\`;
}
function renderWordChips(words) {
const area = document.getElementById('wordListArea');
if (!words.length) { area.innerHTML = ''; return; }
area.innerHTML = \`
<div class="word-list-wrap">
<div class="section-label">Detected tokens — click to highlight · click chip to copy</div>
<div class="word-chips">
\${words.map((w, i) => {
const conf = w.confidence ?? 1;
const cls = conf < 0.7 ? 'chip low-conf' : 'chip';
return \`<div class="\${cls}" id="chip-\${i}" onmouseenter="highlightWord(\${i})" onmouseleave="clearHighlight()" onclick="copyWord('\${w.text.replace(/'/g, "\\\\'")}', \${i})">\${w.text}</div>\`;
}).join('')}
</div>
</div>
\`;
}
function drawImage(url, words) {
const area = document.getElementById('imgArea');
area.innerHTML = \`<div class="canvas-wrap"><img id="theImg" crossorigin="anonymous"/><canvas id="theCanvas"></canvas></div>\`;
imgEl = document.getElementById('theImg');
canvasEl = document.getElementById('theCanvas');
imgEl.onload = () => {
const W = imgEl.naturalWidth;
const H = imgEl.naturalHeight;
canvasEl.width = W;
canvasEl.height = H;
drawBoxes(words, -1);
setupCanvasEvents(words);
};
imgEl.src = url;
}
function drawBoxes(words, activeIdx) {
if (!canvasEl || !imgEl) return;
const ctx = canvasEl.getContext('2d');
const W = canvasEl.width;
const H = canvasEl.height;
ctx.clearRect(0, 0, W, H);
words.forEach((w, i) => {
const [nx, ny, nw, nh] = w.bbox;
const x = nx * W, y = ny * H, bw = nw * W, bh = nh * H;
const conf = w.confidence ?? 1;
const isActive = i === activeIdx;
if (isActive) {
// Glow highlight
ctx.shadowColor = '#2a7fff';
ctx.shadowBlur = 14;
ctx.fillStyle = 'rgba(42,127,255,0.20)';
ctx.fillRect(x, y, bw, bh);
ctx.strokeStyle = '#2a7fff';
ctx.lineWidth = 2;
} else {
ctx.shadowBlur = 0;
if (conf < 0.7) {
ctx.strokeStyle = 'rgba(245,197,24,0.7)';
} else {
ctx.strokeStyle = 'rgba(0,229,160,0.65)';
}
ctx.lineWidth = 1;
}
ctx.strokeRect(x + .5, y + .5, bw, bh);
ctx.shadowBlur = 0;
// Label above box
if (isActive || (nw * W > 20 && nh * H > 10)) {
const label = w.text;
const fs = Math.max(9, Math.min(13, bh * 0.55));
ctx.font = \`600 \${fs}px JetBrains Mono, monospace\`;
const tw = ctx.measureText(label).width;
const lx = Math.min(x, W - tw - 4);
const ly = y > fs + 4 ? y - 3 : y + bh + fs + 2;
ctx.fillStyle = isActive ? '#2a7fff' : (conf < 0.7 ? '#f5c518' : '#00e5a0');
ctx.globalAlpha = isActive ? 1 : 0.75;
ctx.fillText(label, lx, ly);
ctx.globalAlpha = 1;
}
});
}
function setupCanvasEvents(words) {
const tooltip = document.getElementById('tooltip');
canvasEl.addEventListener('mousemove', (e) => {
const rect = canvasEl.getBoundingClientRect();
const scaleX = canvasEl.width / rect.width;
const scaleY = canvasEl.height / rect.height;
const cx = (e.clientX - rect.left) * scaleX;
const cy = (e.clientY - rect.top) * scaleY;
const W = canvasEl.width, H = canvasEl.height;
let found = -1;
words.forEach((w, i) => {
const [nx, ny, nw, nh] = w.bbox;
if (cx >= nx*W && cx <= (nx+nw)*W && cy >= ny*H && cy <= (ny+nh)*H) found = i;
});
if (found !== hoveredIdx) {
hoveredIdx = found;
drawBoxes(words, hoveredIdx);
}
if (found >= 0) {
const w = words[found];
const conf = ((w.confidence ?? 1) * 100).toFixed(0);
tooltip.textContent = \`\${w.text} · conf \${conf}%\`;
tooltip.style.left = (e.clientX + 14) + 'px';
tooltip.style.top = (e.clientY - 24) + 'px';
tooltip.classList.add('show');
} else {
tooltip.classList.remove('show');
}
});
canvasEl.addEventListener('mouseleave', () => {
hoveredIdx = -1;
drawBoxes(words, -1);
tooltip.classList.remove('show');
});
canvasEl.addEventListener('click', (e) => {
const rect = canvasEl.getBoundingClientRect();
const scaleX = canvasEl.width / rect.width;
const scaleY = canvasEl.height / rect.height;
const cx = (e.clientX - rect.left) * scaleX;
const cy = (e.clientY - rect.top) * scaleY;
const W = canvasEl.width, H = canvasEl.height;
words.forEach((w) => {
const [nx, ny, nw, nh] = w.bbox;
if (cx >= nx*W && cx <= (nx+nw)*W && cy >= ny*H && cy <= (ny+nh)*H) {
navigator.clipboard.writeText(w.text).then(() => showToast('Copied: ' + w.text));
}
});
});
}
function highlightWord(i) {
hoveredIdx = i;
drawBoxes(currentWords, i);
}
function clearHighlight() {
hoveredIdx = -1;
drawBoxes(currentWords, -1);
}
function copyWord(text, i) {
navigator.clipboard.writeText(text).then(() => showToast('Copied: ' + text));
}
</script>
</body>
</html>`);
});
app.listen(3000, () => console.log("Server running at http://localhost:3000"));