Binayak Panigrahi commited on
Commit
969a8a9
Β·
1 Parent(s): 7179f5d

Add application file

Browse files
Files changed (4) hide show
  1. Dockerfile +16 -0
  2. index.html +639 -0
  3. requirements.txt +5 -0
  4. visiting_card_api.py +263 -0
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # Install dependencies
6
+ COPY requirements.txt .
7
+ RUN pip install --no-cache-dir -r requirements.txt
8
+
9
+ # Copy app files
10
+ COPY visiting_card_api.py .
11
+ COPY index.html .
12
+
13
+ # HF Spaces expects the app on port 7860
14
+ EXPOSE 7860
15
+
16
+ CMD ["python", "visiting_card_api.py"]
index.html ADDED
@@ -0,0 +1,639 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Card OCR Tester</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=DM+Mono:wght@300;400;500&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root {
10
+ --ink: #0f0e0d;
11
+ --paper: #f5f0e8;
12
+ --cream: #ede7d9;
13
+ --accent: #d4400a;
14
+ --accent2: #1a6b4a;
15
+ --muted: #8a8070;
16
+ --border: #c8bfae;
17
+ --card-bg: #faf7f2;
18
+ --success: #1a6b4a;
19
+ --error: #c0392b;
20
+ }
21
+
22
+ * { box-sizing: border-box; margin: 0; padding: 0; }
23
+
24
+ body {
25
+ background: var(--paper);
26
+ color: var(--ink);
27
+ font-family: 'DM Mono', monospace;
28
+ min-height: 100vh;
29
+ position: relative;
30
+ overflow-x: hidden;
31
+ }
32
+
33
+ body::before {
34
+ content: '';
35
+ position: fixed;
36
+ inset: 0;
37
+ background-image: repeating-linear-gradient(
38
+ transparent, transparent 31px,
39
+ var(--border) 31px, var(--border) 32px
40
+ );
41
+ opacity: 0.35;
42
+ pointer-events: none;
43
+ z-index: 0;
44
+ }
45
+
46
+ .page {
47
+ position: relative;
48
+ z-index: 1;
49
+ max-width: 860px;
50
+ margin: 0 auto;
51
+ padding: 48px 24px 80px;
52
+ }
53
+
54
+ header {
55
+ border-left: 5px solid var(--accent);
56
+ padding-left: 20px;
57
+ margin-bottom: 48px;
58
+ animation: slideIn 0.5s ease both;
59
+ }
60
+ header .eyebrow {
61
+ font-size: 11px;
62
+ letter-spacing: 0.2em;
63
+ text-transform: uppercase;
64
+ color: var(--muted);
65
+ margin-bottom: 6px;
66
+ }
67
+ header h1 {
68
+ font-family: 'Syne', sans-serif;
69
+ font-size: clamp(28px, 5vw, 44px);
70
+ font-weight: 800;
71
+ line-height: 1.1;
72
+ color: var(--ink);
73
+ }
74
+ header h1 span { color: var(--accent); }
75
+ header p {
76
+ margin-top: 10px;
77
+ font-size: 13px;
78
+ color: var(--muted);
79
+ max-width: 480px;
80
+ line-height: 1.7;
81
+ }
82
+
83
+ /* ── API URL bar ── */
84
+ .api-bar {
85
+ display: flex;
86
+ align-items: center;
87
+ gap: 10px;
88
+ background: var(--cream);
89
+ border: 1.5px solid var(--border);
90
+ border-radius: 4px;
91
+ padding: 10px 14px;
92
+ margin-bottom: 32px;
93
+ animation: slideIn 0.5s 0.1s ease both;
94
+ }
95
+ .api-bar label {
96
+ font-size: 10px;
97
+ letter-spacing: 0.15em;
98
+ text-transform: uppercase;
99
+ color: var(--muted);
100
+ white-space: nowrap;
101
+ }
102
+ .api-bar input {
103
+ flex: 1;
104
+ background: transparent;
105
+ border: none;
106
+ outline: none;
107
+ font-family: 'DM Mono', monospace;
108
+ font-size: 13px;
109
+ color: var(--ink);
110
+ }
111
+ .api-bar .reset-btn {
112
+ font-size: 10px;
113
+ padding: 3px 9px;
114
+ border: 1px solid var(--border);
115
+ border-radius: 3px;
116
+ background: transparent;
117
+ cursor: pointer;
118
+ color: var(--muted);
119
+ white-space: nowrap;
120
+ transition: all 0.15s;
121
+ }
122
+ .api-bar .reset-btn:hover { border-color: var(--ink); color: var(--ink); }
123
+
124
+ /* ── Mode tabs ── */
125
+ .tabs {
126
+ display: flex;
127
+ margin-bottom: 28px;
128
+ border: 1.5px solid var(--border);
129
+ border-radius: 4px;
130
+ overflow: hidden;
131
+ width: fit-content;
132
+ animation: slideIn 0.5s 0.15s ease both;
133
+ }
134
+ .tab-btn {
135
+ padding: 9px 22px;
136
+ font-family: 'Syne', sans-serif;
137
+ font-size: 12px;
138
+ font-weight: 600;
139
+ letter-spacing: 0.08em;
140
+ text-transform: uppercase;
141
+ background: var(--cream);
142
+ border: none;
143
+ cursor: pointer;
144
+ color: var(--muted);
145
+ transition: background 0.2s, color 0.2s;
146
+ }
147
+ .tab-btn + .tab-btn { border-left: 1.5px solid var(--border); }
148
+ .tab-btn.active { background: var(--ink); color: var(--paper); }
149
+
150
+ /* ── Upload zone ── */
151
+ .upload-section {
152
+ animation: slideIn 0.5s 0.2s ease both;
153
+ margin-bottom: 28px;
154
+ }
155
+
156
+ .drop-zone {
157
+ border: 2px dashed var(--border);
158
+ border-radius: 6px;
159
+ background: var(--card-bg);
160
+ padding: 48px 24px;
161
+ text-align: center;
162
+ cursor: pointer;
163
+ transition: border-color 0.2s, background 0.2s;
164
+ position: relative;
165
+ }
166
+ .drop-zone:hover, .drop-zone.dragover {
167
+ border-color: var(--accent);
168
+ background: #fff9f5;
169
+ }
170
+ .drop-zone input[type=file] {
171
+ position: absolute;
172
+ inset: 0;
173
+ opacity: 0;
174
+ cursor: pointer;
175
+ width: 100%;
176
+ height: 100%;
177
+ }
178
+ .drop-icon { font-size: 36px; margin-bottom: 12px; display: block; }
179
+ .drop-zone h3 {
180
+ font-family: 'Syne', sans-serif;
181
+ font-size: 16px;
182
+ font-weight: 700;
183
+ margin-bottom: 6px;
184
+ }
185
+ .drop-zone p { font-size: 11px; color: var(--muted); letter-spacing: 0.05em; }
186
+
187
+ kbd {
188
+ display: inline-block;
189
+ font-family: 'DM Mono', monospace;
190
+ font-size: 10px;
191
+ background: var(--cream);
192
+ border: 1px solid var(--border);
193
+ border-radius: 3px;
194
+ padding: 1px 5px;
195
+ color: var(--muted);
196
+ }
197
+
198
+ /* preview strip */
199
+ #preview-strip { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
200
+ .preview-thumb {
201
+ position: relative;
202
+ width: 90px; height: 68px;
203
+ border-radius: 4px;
204
+ overflow: hidden;
205
+ border: 1.5px solid var(--border);
206
+ animation: popIn 0.25s ease both;
207
+ }
208
+ .preview-thumb img { width: 100%; height: 100%; object-fit: cover; }
209
+ .preview-thumb .rm {
210
+ position: absolute;
211
+ top: 2px; right: 2px;
212
+ width: 18px; height: 18px;
213
+ background: rgba(0,0,0,0.65);
214
+ color: #fff;
215
+ border: none;
216
+ border-radius: 50%;
217
+ font-size: 10px;
218
+ cursor: pointer;
219
+ display: flex; align-items: center; justify-content: center;
220
+ }
221
+ .preview-thumb .fname {
222
+ position: absolute;
223
+ bottom: 0; left: 0; right: 0;
224
+ background: rgba(0,0,0,0.5);
225
+ color: #fff;
226
+ font-size: 8px;
227
+ padding: 2px 4px;
228
+ white-space: nowrap;
229
+ overflow: hidden;
230
+ text-overflow: ellipsis;
231
+ }
232
+
233
+ /* ── Submit button ── */
234
+ .submit-btn {
235
+ display: flex;
236
+ align-items: center;
237
+ gap: 10px;
238
+ padding: 14px 32px;
239
+ background: var(--ink);
240
+ color: var(--paper);
241
+ font-family: 'Syne', sans-serif;
242
+ font-size: 13px;
243
+ font-weight: 700;
244
+ letter-spacing: 0.1em;
245
+ text-transform: uppercase;
246
+ border: none;
247
+ border-radius: 4px;
248
+ cursor: pointer;
249
+ transition: background 0.2s, transform 0.1s;
250
+ animation: slideIn 0.5s 0.25s ease both;
251
+ }
252
+ .submit-btn:hover { background: var(--accent); }
253
+ .submit-btn:active { transform: scale(0.98); }
254
+ .submit-btn:disabled { background: var(--muted); cursor: not-allowed; }
255
+ .submit-btn .spinner {
256
+ width: 14px; height: 14px;
257
+ border: 2px solid rgba(255,255,255,0.3);
258
+ border-top-color: #fff;
259
+ border-radius: 50%;
260
+ animation: spin 0.7s linear infinite;
261
+ display: none;
262
+ }
263
+ .submit-btn.loading .spinner { display: block; }
264
+ .submit-btn.loading .btn-text { opacity: 0.7; }
265
+
266
+ /* ── Status bar ── */
267
+ #status {
268
+ margin-top: 16px;
269
+ font-size: 12px;
270
+ padding: 10px 14px;
271
+ border-radius: 4px;
272
+ display: none;
273
+ }
274
+ #status.info { background: #e8f4ff; color: #1a4a7a; border-left: 3px solid #1a4a7a; }
275
+ #status.ok { background: #e6f5ee; color: var(--success); border-left: 3px solid var(--success); }
276
+ #status.err { background: #fdecea; color: var(--error); border-left: 3px solid var(--error); }
277
+
278
+ /* ── Results ── */
279
+ #results-section { margin-top: 48px; }
280
+ .results-header {
281
+ font-family: 'Syne', sans-serif;
282
+ font-size: 11px;
283
+ font-weight: 700;
284
+ letter-spacing: 0.2em;
285
+ text-transform: uppercase;
286
+ color: var(--muted);
287
+ border-bottom: 1.5px solid var(--border);
288
+ padding-bottom: 8px;
289
+ margin-bottom: 24px;
290
+ }
291
+
292
+ .result-card {
293
+ background: var(--card-bg);
294
+ border: 1.5px solid var(--border);
295
+ border-radius: 6px;
296
+ padding: 24px;
297
+ margin-bottom: 20px;
298
+ position: relative;
299
+ animation: popIn 0.3s ease both;
300
+ }
301
+ .result-card .card-badge {
302
+ position: absolute;
303
+ top: -1px; right: 16px;
304
+ background: var(--accent);
305
+ color: #fff;
306
+ font-size: 10px;
307
+ font-family: 'Syne', sans-serif;
308
+ font-weight: 700;
309
+ letter-spacing: 0.1em;
310
+ text-transform: uppercase;
311
+ padding: 3px 10px;
312
+ border-radius: 0 0 4px 4px;
313
+ }
314
+ .result-card .company-name {
315
+ font-family: 'Syne', sans-serif;
316
+ font-size: 20px;
317
+ font-weight: 800;
318
+ margin-bottom: 4px;
319
+ }
320
+ .result-card .contact-person {
321
+ font-size: 13px;
322
+ color: var(--accent2);
323
+ font-weight: 500;
324
+ margin-bottom: 20px;
325
+ }
326
+ .result-card .contact-person span { color: var(--muted); font-weight: 400; }
327
+
328
+ .fields-grid {
329
+ display: grid;
330
+ grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
331
+ gap: 12px 24px;
332
+ }
333
+ .field { display: flex; flex-direction: column; gap: 3px; }
334
+ .field .lbl { font-size: 9px; letter-spacing: 0.18em; text-transform: uppercase; color: var(--muted); }
335
+ .field .val { font-size: 13px; color: var(--ink); word-break: break-word; line-height: 1.5; }
336
+ .field .val.empty { color: var(--border); font-style: italic; font-size: 11px; }
337
+ .field .val.hi { color: var(--accent); font-weight: 500; }
338
+
339
+ .copy-row {
340
+ margin-top: 18px;
341
+ padding-top: 14px;
342
+ border-top: 1px solid var(--border);
343
+ display: flex;
344
+ gap: 10px;
345
+ flex-wrap: wrap;
346
+ }
347
+ .copy-btn {
348
+ font-family: 'DM Mono', monospace;
349
+ font-size: 10px;
350
+ padding: 5px 12px;
351
+ border: 1.5px solid var(--border);
352
+ border-radius: 3px;
353
+ background: transparent;
354
+ cursor: pointer;
355
+ color: var(--muted);
356
+ transition: all 0.15s;
357
+ }
358
+ .copy-btn:hover { border-color: var(--ink); color: var(--ink); }
359
+ .copy-btn.copied { border-color: var(--success); color: var(--success); }
360
+
361
+ @keyframes slideIn {
362
+ from { opacity: 0; transform: translateY(14px); }
363
+ to { opacity: 1; transform: translateY(0); }
364
+ }
365
+ @keyframes popIn {
366
+ from { opacity: 0; transform: scale(0.97); }
367
+ to { opacity: 1; transform: scale(1); }
368
+ }
369
+ @keyframes spin { to { transform: rotate(360deg); } }
370
+ @keyframes pasteFlash {
371
+ 0% { border-color: var(--accent2); background: #edfaf4; }
372
+ 100% { border-color: var(--border); background: var(--card-bg); }
373
+ }
374
+ .drop-zone.paste-flash { animation: pasteFlash 0.6s ease both; }
375
+ </style>
376
+ </head>
377
+ <body>
378
+ <div class="page">
379
+
380
+ <header>
381
+ <div class="eyebrow">NVIDIA OCR Β· NeMo Retriever + Nemotron</div>
382
+ <h1>Card &amp; Letterhead<br><span>Extractor</span></h1>
383
+ <p>Upload or paste a visiting card / letterhead image to extract company name, contacts, address, PIN &amp; GST in one click.</p>
384
+ </header>
385
+
386
+ <!-- API URL -->
387
+ <div class="api-bar">
388
+ <label>API</label>
389
+ <input type="text" id="api-url" value="/extract-card" spellcheck="false">
390
+ <button class="reset-btn" onclick="resetUrl()">Reset</button>
391
+ </div>
392
+
393
+ <!-- Mode tabs -->
394
+ <div class="tabs">
395
+ <button class="tab-btn active" onclick="setMode('single',this)">Single Card</button>
396
+ <button class="tab-btn" onclick="setMode('batch',this)">Batch (up to 10)</button>
397
+ </div>
398
+
399
+ <!-- Upload zone -->
400
+ <div class="upload-section">
401
+ <div class="drop-zone" id="drop-zone">
402
+ <input type="file" id="file-input" accept="image/jpeg,image/png,image/webp">
403
+ <span class="drop-icon">πŸͺͺ</span>
404
+ <h3>Drop, paste, or click to browse</h3>
405
+ <p>JPG · PNG · WEBP &nbsp;|&nbsp; Max ~130 KB per image &nbsp;|&nbsp; <kbd>Ctrl+V</kbd> / <kbd>⌘V</kbd> to paste</p>
406
+ </div>
407
+ <div id="preview-strip"></div>
408
+ </div>
409
+
410
+ <button class="submit-btn" id="submit-btn" onclick="doSubmit()">
411
+ <div class="spinner"></div>
412
+ <span class="btn-text">Extract Data</span>
413
+ </button>
414
+
415
+ <div id="status"></div>
416
+
417
+ <div id="results-section" style="display:none">
418
+ <div class="results-header">Extracted Results</div>
419
+ <div id="results-container"></div>
420
+ </div>
421
+
422
+ </div>
423
+ <script>
424
+ /* ── State ── */
425
+ let mode = 'single';
426
+ let files = [];
427
+
428
+ /* ── Relative base URL (works on HF Spaces and localhost alike) ── */
429
+ // Default is relative "/extract-card" so it always points to the same origin.
430
+ // User can override in the API bar for cross-origin setups.
431
+ const DEFAULT_SINGLE = '/extract-card';
432
+ const DEFAULT_BATCH = '/extract-card/batch';
433
+
434
+ function resetUrl() {
435
+ document.getElementById('api-url').value = mode === 'single' ? DEFAULT_SINGLE : DEFAULT_BATCH;
436
+ }
437
+
438
+ /* ── Mode ── */
439
+ function setMode(m, el) {
440
+ mode = m;
441
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
442
+ el.classList.add('active');
443
+ document.getElementById('file-input').multiple = (m === 'batch');
444
+ document.getElementById('api-url').value = m === 'single' ? DEFAULT_SINGLE : DEFAULT_BATCH;
445
+ clearFiles();
446
+ }
447
+
448
+ /* ── Drag & drop ── */
449
+ const dz = document.getElementById('drop-zone');
450
+ dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
451
+ dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
452
+ dz.addEventListener('drop', e => {
453
+ e.preventDefault();
454
+ dz.classList.remove('dragover');
455
+ addFiles([...e.dataTransfer.files]);
456
+ });
457
+
458
+ document.getElementById('file-input').addEventListener('change', e => {
459
+ addFiles([...e.target.files]);
460
+ e.target.value = '';
461
+ });
462
+
463
+ /* ── Clipboard paste (Ctrl+V / ⌘V) ── */
464
+ document.addEventListener('paste', e => {
465
+ const items = [...(e.clipboardData?.items || [])];
466
+ const imgs = items.filter(i => i.type.startsWith('image/'));
467
+ if (!imgs.length) return;
468
+ e.preventDefault();
469
+
470
+ const pasted = imgs.map(i => i.getAsFile()).filter(Boolean);
471
+ addFiles(pasted);
472
+
473
+ dz.classList.remove('paste-flash');
474
+ void dz.offsetWidth;
475
+ dz.classList.add('paste-flash');
476
+ dz.addEventListener('animationend', () => dz.classList.remove('paste-flash'), { once: true });
477
+
478
+ setStatus(`βœ“ ${pasted.length} image${pasted.length > 1 ? 's' : ''} pasted from clipboard.`, 'ok');
479
+ });
480
+
481
+ /* ── File management ── */
482
+ function addFiles(newFiles) {
483
+ const imageFiles = newFiles.filter(f => f.type.startsWith('image/'));
484
+ if (!imageFiles.length) { setStatus('No image found in clipboard / selection.', 'err'); return; }
485
+ if (mode === 'single') files = imageFiles.slice(0, 1);
486
+ else files = [...files, ...imageFiles].slice(0, 10);
487
+ renderPreviews();
488
+ }
489
+
490
+ function clearFiles() { files = []; renderPreviews(); }
491
+ function removeFile(i) { files.splice(i, 1); renderPreviews(); }
492
+
493
+ function renderPreviews() {
494
+ const strip = document.getElementById('preview-strip');
495
+ strip.innerHTML = '';
496
+ files.forEach((f, i) => {
497
+ const url = URL.createObjectURL(f);
498
+ const div = document.createElement('div');
499
+ div.className = 'preview-thumb';
500
+ const name = f.name || `image-${i+1}`;
501
+ div.innerHTML = `<img src="${url}" alt="preview">
502
+ <button class="rm" onclick="removeFile(${i})" title="Remove">βœ•</button>
503
+ <div class="fname">${esc(name)}</div>`;
504
+ strip.appendChild(div);
505
+ });
506
+ }
507
+
508
+ /* ── Status ── */
509
+ function setStatus(msg, type) {
510
+ const el = document.getElementById('status');
511
+ el.textContent = msg;
512
+ el.className = type;
513
+ el.style.display = msg ? 'block' : 'none';
514
+ }
515
+
516
+ /* ── Submit ── */
517
+ async function doSubmit() {
518
+ if (!files.length) { setStatus('Please select or paste at least one image.', 'err'); return; }
519
+
520
+ const url = document.getElementById('api-url').value.trim();
521
+ const btn = document.getElementById('submit-btn');
522
+ btn.disabled = true;
523
+ btn.classList.add('loading');
524
+ btn.querySelector('.btn-text').textContent = 'Extracting…';
525
+ setStatus('Sending to OCR pipeline…', 'info');
526
+ document.getElementById('results-section').style.display = 'none';
527
+ document.getElementById('results-container').innerHTML = '';
528
+
529
+ try {
530
+ const fd = new FormData();
531
+ if (mode === 'single') {
532
+ fd.append('file', files[0], files[0].name || 'image.png');
533
+ } else {
534
+ files.forEach((f, i) => fd.append('files', f, f.name || `image-${i+1}.png`));
535
+ }
536
+
537
+ const res = await fetch(url, { method: 'POST', body: fd });
538
+ let data;
539
+ try { data = await res.json(); } catch { throw new Error('Server returned a non-JSON response.'); }
540
+ if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
541
+
542
+ setStatus(`βœ“ Extraction complete${mode === 'batch' ? ` Β· ${files.length} card(s) processed` : ''}.`, 'ok');
543
+ renderResults(Array.isArray(data) ? data : [data]);
544
+ } catch(err) {
545
+ setStatus('Error: ' + err.message, 'err');
546
+ } finally {
547
+ btn.disabled = false;
548
+ btn.classList.remove('loading');
549
+ btn.querySelector('.btn-text').textContent = 'Extract Data';
550
+ }
551
+ }
552
+
553
+ /* ── Render results ── */
554
+ function renderResults(items) {
555
+ const container = document.getElementById('results-container');
556
+ container.innerHTML = '';
557
+ items.forEach((d, idx) => {
558
+ const card = document.createElement('div');
559
+ card.className = 'result-card';
560
+ card.innerHTML = `
561
+ <div class="card-badge">${items.length > 1 ? `Card ${idx+1}` : 'Result'}</div>
562
+ <div class="company-name">${esc(d.company_name) || '<span style="color:var(--muted);font-size:14px">Company name not found</span>'}</div>
563
+ <div class="contact-person">
564
+ ${d.contact_person ? esc(d.contact_person) : '<span style="color:var(--border)">β€”</span>'}
565
+ ${d.designation ? `<span> Β· ${esc(d.designation)}</span>` : ''}
566
+ </div>
567
+ <div class="fields-grid">
568
+ ${fld('Mobile', d.mobile, true)}
569
+ ${fld('Phone / Landline', d.phone)}
570
+ ${fld('Email', d.email, true)}
571
+ ${fld('Website', d.website)}
572
+ ${fldFull('Address', d.address)}
573
+ ${fld('PIN Code', d.pin, true)}
574
+ ${fld('City', d.city)}
575
+ ${fld('State', d.state)}
576
+ ${fld('Country', d.country)}
577
+ ${fld('GST Number', d.gst_number, true)}
578
+ ${fld('Fax', d.fax)}
579
+ </div>
580
+ <div class="copy-row">
581
+ <button class="copy-btn" onclick="copyJSON(this,${idx})">Copy JSON</button>
582
+ <button class="copy-btn" onclick="copyCSV(this,${idx})">Copy CSV row</button>
583
+ </div>`;
584
+ container.appendChild(card);
585
+ });
586
+ window._results = items;
587
+ document.getElementById('results-section').style.display = 'block';
588
+ }
589
+
590
+ function fld(label, val, hi=false) {
591
+ const empty = !val || !val.trim();
592
+ return `<div class="field">
593
+ <span class="lbl">${label}</span>
594
+ <span class="val ${empty?'empty':hi?'hi':''}">${empty?'not found':esc(val)}</span>
595
+ </div>`;
596
+ }
597
+ function fldFull(label, val) {
598
+ const empty = !val || !val.trim();
599
+ return `<div class="field" style="grid-column:1/-1">
600
+ <span class="lbl">${label}</span>
601
+ <span class="val ${empty?'empty':''}">${empty?'not found':esc(val).replace(/\|/g,'<br>')}</span>
602
+ </div>`;
603
+ }
604
+
605
+ function esc(s) {
606
+ return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
607
+ }
608
+
609
+ /* ── Copy helpers ── */
610
+ async function safeCopy(btn, text, label) {
611
+ try {
612
+ await navigator.clipboard.writeText(text);
613
+ } catch {
614
+ // Fallback for browsers that block clipboard API in non-secure contexts
615
+ const ta = document.createElement('textarea');
616
+ ta.value = text;
617
+ ta.style.cssText = 'position:fixed;opacity:0';
618
+ document.body.appendChild(ta);
619
+ ta.select();
620
+ document.execCommand('copy');
621
+ document.body.removeChild(ta);
622
+ }
623
+ btn.textContent = 'βœ“ Copied!';
624
+ btn.classList.add('copied');
625
+ setTimeout(() => { btn.textContent = label; btn.classList.remove('copied'); }, 1800);
626
+ }
627
+
628
+ function copyJSON(btn, idx) {
629
+ safeCopy(btn, JSON.stringify(window._results[idx], null, 2), 'Copy JSON');
630
+ }
631
+ function copyCSV(btn, idx) {
632
+ const d = window._results[idx];
633
+ const keys = ['company_name','contact_person','designation','mobile','phone','email','address','pin','city','state','country','gst_number','website','fax'];
634
+ const row = keys.map(k => `"${String(d[k]||'').replace(/"/g,'""')}"`).join(',');
635
+ safeCopy(btn, keys.join(',') + '\n' + row, 'Copy CSV row');
636
+ }
637
+ </script>
638
+ </body>
639
+ </html>
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.29.0
3
+ requests==2.31.0
4
+ python-multipart==0.0.9
5
+ pydantic==2.7.1
visiting_card_api.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Visiting Card & Letterhead OCR API
3
+ ===================================
4
+ Two-step pipeline: nemoretriever-ocr-v1 β†’ nvidia-nemotron-nano-9b-v2
5
+
6
+ Deploy on Hugging Face Spaces (Docker or Python SDK):
7
+ - Set secret NVIDIA_API_KEY in Space settings β†’ Variables and secrets
8
+ - The app serves the HTML frontend at / and the API at /extract-card
9
+ - HF Spaces exposes port 7860 by default (set via HF_PORT env var)
10
+
11
+ Local usage:
12
+ pip install fastapi uvicorn requests python-multipart
13
+ NVIDIA_API_KEY=nvapi-xxx python visiting_card_api.py
14
+ Open http://localhost:7860
15
+ """
16
+
17
+ import os
18
+ import re
19
+ import json
20
+ import base64
21
+ import requests
22
+ from pathlib import Path
23
+ from typing import List
24
+
25
+ from fastapi import FastAPI, File, UploadFile, HTTPException
26
+ from fastapi.middleware.cors import CORSMiddleware
27
+ from fastapi.responses import HTMLResponse
28
+ from pydantic import BaseModel
29
+
30
+ # ── App ────────────────────────────────────────────────────────────────────────
31
+ app = FastAPI(
32
+ title="Visiting Card & Letterhead OCR API",
33
+ description="Two-step RAG pipeline: nemoretriever-ocr-v1 β†’ nvidia-nemotron-nano-9b-v2",
34
+ )
35
+
36
+ # ── CORS β€” allow all origins (needed for HF Spaces iframe / custom domains) ───
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_credentials=True,
41
+ allow_methods=["*"],
42
+ allow_headers=["*"],
43
+ )
44
+
45
+ # ── Configuration ─────────────────────────────────────────────────────────────
46
+ NVIDIA_API_KEY = os.environ.get("NVIDIA_API_KEY", "nvapi-YOUR_API_KEY_HERE")
47
+
48
+ OCR_URL = "https://ai.api.nvidia.com/v1/cv/nvidia/nemoretriever-ocr-v1"
49
+ LLM_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
50
+ LLM_MODEL = "nvidia/nvidia-nemotron-nano-9b-v2"
51
+
52
+ OCR_HEADERS = {"Authorization": f"Bearer {NVIDIA_API_KEY}", "Accept": "application/json"}
53
+ LLM_HEADERS = {"Authorization": f"Bearer {NVIDIA_API_KEY}", "Content-Type": "application/json"}
54
+
55
+ # ── System prompt ──────────────────────────────────────────────────────────────
56
+ CARD_SYSTEM_PROMPT = """You are a business card and letterhead data extraction assistant.
57
+ You will receive raw OCR text extracted from a visiting card, business card, or the header/footer of a business letter.
58
+ Parse it carefully and return ONLY a valid JSON object.
59
+ No markdown fences, no explanation, no preamble β€” just the raw JSON object.
60
+
61
+ JSON schema (return exactly this structure):
62
+ {
63
+ "company_name": "full name of the company or firm (string)",
64
+ "contact_person": "name of the individual on the card or letter (string)",
65
+ "designation": "job title or designation of the contact person (string)",
66
+ "mobile": "mobile number(s) as a string; if multiple separate with comma (string)",
67
+ "phone": "landline / office phone number(s); if multiple separate with comma (string)",
68
+ "email": "email address(es); if multiple separate with comma (string)",
69
+ "address": "full postal address as printed, preserving line breaks with a pipe | separator (string)",
70
+ "pin": "PIN code / ZIP code / postal code as a string of digits (string)",
71
+ "city": "city name (string)",
72
+ "state": "state or province name (string)",
73
+ "country": "country name (string)",
74
+ "gst_number": "GST / GSTIN number; typically 15 alphanumeric characters (string)",
75
+ "website": "website URL if present (string)",
76
+ "fax": "fax number if present (string)"
77
+ }
78
+
79
+ Rules:
80
+ - company_name: usually the largest text or the text near a logo
81
+ - contact_person: individual's personal name distinct from company name
82
+ - designation: title like CEO, Manager, Director, Proprietor, Sales Executive, etc.
83
+ - mobile: numbers prefixed with M:, Mob:, Cell:, +91, or 10-digit numbers
84
+ - phone: numbers prefixed with Ph:, Tel:, T:, O:, or STD codes like (022), (080)
85
+ - email: look for @ symbol; may be prefixed with E:, Email:, Mail:
86
+ - address: collect all address lines; separate each line with ' | '
87
+ - pin: extract 6-digit Indian PIN code or 5/9-digit ZIP; digits only
88
+ - city: extract city name from address
89
+ - state: extract state name from address
90
+ - country: default to India if address looks Indian and country not stated
91
+ - gst_number: 15-character alphanumeric GSTIN
92
+ - website: any URL starting with www., http://, or https://
93
+ - fax: number prefixed with Fax:, F:, or similar
94
+ - If a field is not found return "" (empty string)
95
+ - Do NOT invent or hallucinate any information not present in the OCR text
96
+ - If multiple phone or mobile numbers are present, join them with ', '"""
97
+
98
+
99
+ # ── Helpers ──────────────────────────────────────────────────────────���─────────
100
+
101
+ async def run_ocr(file: UploadFile) -> str:
102
+ content = await file.read()
103
+ image_b64 = base64.b64encode(content).decode()
104
+
105
+ if len(image_b64) >= 500_000:
106
+ raise HTTPException(413, "Image too large. Resize and retry.")
107
+
108
+ payload = {"input": [{"type": "image_url", "url": f"data:image/png;base64,{image_b64}"}]}
109
+
110
+ try:
111
+ r = requests.post(OCR_URL, headers=OCR_HEADERS, json=payload, timeout=30)
112
+ r.raise_for_status()
113
+ except requests.exceptions.RequestException as e:
114
+ raise HTTPException(502, f"NVIDIA OCR API error: {e}")
115
+
116
+ ocr_json = r.json()
117
+ detections = ocr_json.get("text_detections", [])
118
+ if not detections:
119
+ data = ocr_json.get("data", [])
120
+ if isinstance(data, list) and data:
121
+ detections = data[0].get("text_detections", [])
122
+
123
+ lines = []
124
+ for det in detections:
125
+ text = ""
126
+ if isinstance(det, dict):
127
+ if "text_prediction" in det:
128
+ text = det["text_prediction"].get("text", "").strip()
129
+ else:
130
+ text = det.get("text", "").strip()
131
+ if text:
132
+ lines.append(text)
133
+ return "\n".join(lines)
134
+
135
+
136
+ def call_llm(ocr_text: str) -> dict:
137
+ payload = {
138
+ "model": LLM_MODEL,
139
+ "max_tokens": 2048,
140
+ "temperature": 0.1,
141
+ "top_p": 0.9,
142
+ "messages": [
143
+ {"role": "system", "content": CARD_SYSTEM_PROMPT},
144
+ {"role": "user", "content": (
145
+ f"Here is the OCR text extracted from the business card or letterhead:\n\n"
146
+ f"{ocr_text}\n\nExtract the required data and return ONLY the JSON object."
147
+ )},
148
+ ],
149
+ }
150
+
151
+ try:
152
+ r = requests.post(LLM_URL, headers=LLM_HEADERS, json=payload, timeout=120)
153
+ r.raise_for_status()
154
+ llm_json = r.json()
155
+ except requests.exceptions.RequestException as e:
156
+ raise HTTPException(502, f"NVIDIA LLM API error: {e}")
157
+
158
+ raw: str = llm_json.get("choices", [{}])[0].get("message", {}).get("content", "")
159
+ if not raw:
160
+ raise HTTPException(502, "LLM returned empty response")
161
+
162
+ cleaned = re.sub(r"```json\s*", "", raw, flags=re.IGNORECASE)
163
+ cleaned = re.sub(r"```\s*", "", cleaned).strip()
164
+
165
+ try:
166
+ parsed = json.loads(cleaned)
167
+ except json.JSONDecodeError:
168
+ m = re.search(r"\{[\s\S]*\}", cleaned)
169
+ if not m:
170
+ raise HTTPException(502, f"LLM did not return valid JSON. Preview: {raw[:400]}")
171
+ try:
172
+ parsed = json.loads(m.group(0))
173
+ except json.JSONDecodeError as e:
174
+ raise HTTPException(502, f"JSON parse error: {e}")
175
+
176
+ if not isinstance(parsed, dict):
177
+ raise HTTPException(502, f"LLM response not a JSON object. Got: {type(parsed).__name__}")
178
+ return parsed
179
+
180
+
181
+ # ── Pydantic models ────────────────────────────────────────────────────────────
182
+
183
+ class CardData(BaseModel):
184
+ company_name: str
185
+ contact_person: str
186
+ designation: str
187
+ mobile: str
188
+ phone: str
189
+ email: str
190
+ address: str
191
+ pin: str
192
+ city: str
193
+ state: str
194
+ country: str
195
+ gst_number: str
196
+ website: str
197
+ fax: str
198
+
199
+
200
+ def build_card(parsed: dict) -> CardData:
201
+ def s(k, n=300): return str(parsed.get(k, "")).strip()[:n]
202
+ return CardData(
203
+ company_name=s("company_name", 200), contact_person=s("contact_person", 100),
204
+ designation=s("designation", 100), mobile=s("mobile", 100),
205
+ phone=s("phone", 100), email=s("email", 200),
206
+ address=s("address", 500), pin=s("pin", 10),
207
+ city=s("city", 100), state=s("state", 100),
208
+ country=s("country", 100), gst_number=s("gst_number", 20),
209
+ website=s("website", 200), fax=s("fax", 50),
210
+ )
211
+
212
+
213
+ # ── API endpoints ──────────────────────────────────────────────────────────────
214
+
215
+ @app.post("/extract-card", response_model=CardData)
216
+ async def extract_card(file: UploadFile = File(...)):
217
+ allowed = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
218
+ if file.content_type and file.content_type not in allowed:
219
+ raise HTTPException(415, f"Unsupported type: {file.content_type}.")
220
+ ocr_text = await run_ocr(file)
221
+ if not ocr_text.strip():
222
+ raise HTTPException(422, "OCR produced no text. Check image quality.")
223
+ return build_card(call_llm(ocr_text))
224
+
225
+
226
+ @app.post("/extract-card/batch", response_model=List[CardData])
227
+ async def extract_card_batch(files: List[UploadFile] = File(...)):
228
+ if len(files) > 10:
229
+ raise HTTPException(400, "Maximum 10 files per batch request.")
230
+ empty = CardData(**{f: "" for f in CardData.__fields__})
231
+ results = []
232
+ for idx, file in enumerate(files):
233
+ allowed = {"image/jpeg", "image/jpg", "image/png", "image/webp"}
234
+ if file.content_type and file.content_type not in allowed:
235
+ raise HTTPException(415, f"File {idx+1}: unsupported type.")
236
+ ocr_text = await run_ocr(file)
237
+ results.append(build_card(call_llm(ocr_text)) if ocr_text.strip() else empty)
238
+ return results
239
+
240
+
241
+ @app.get("/health")
242
+ async def health():
243
+ return {"status": "healthy", "model": LLM_MODEL}
244
+
245
+
246
+ # ── Serve index.html at root (must be placed alongside this script) ────────────
247
+ HTML_PATH = Path(__file__).parent / "index.html"
248
+
249
+ @app.get("/", response_class=HTMLResponse)
250
+ async def serve_ui():
251
+ if not HTML_PATH.exists():
252
+ return HTMLResponse(
253
+ "<h2 style='font-family:sans-serif;padding:40px'>"
254
+ "index.html not found β€” place it next to visiting_card_api.py</h2>", 500
255
+ )
256
+ return HTMLResponse(HTML_PATH.read_text(encoding="utf-8"))
257
+
258
+
259
+ # ── Entry point ────────────────────────────────────────────────────────────────
260
+ if __name__ == "__main__":
261
+ import uvicorn
262
+ port = int(os.environ.get("HF_PORT", 7860))
263
+ uvicorn.run("visiting_card_api:app", host="0.0.0.0", port=port, reload=False)