Spaces:
Sleeping
Sleeping
Binayak Panigrahi commited on
Commit Β·
969a8a9
1
Parent(s): 7179f5d
Add application file
Browse files- Dockerfile +16 -0
- index.html +639 -0
- requirements.txt +5 -0
- 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 & Letterhead<br><span>Extractor</span></h1>
|
| 383 |
+
<p>Upload or paste a visiting card / letterhead image to extract company name, contacts, address, PIN & 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 | Max ~130 KB per image | <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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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)
|