jatinkarma commited on
Commit
6c222bf
·
verified ·
1 Parent(s): d03c574

Upload 15 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ models/human_face_v1.keras filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Humanly – HuggingFace Space entry-point
3
+ ========================================
4
+ Serves the custom index.html frontend and the /predict endpoint
5
+ using FastAPI (mounted inside Gradio) so we get a free Gradio Space
6
+ while keeping our own custom UI.
7
+
8
+ How it works:
9
+ 1. Gradio creates a lightweight wrapper app on port 7860.
10
+ 2. We mount our full FastAPI app at "/" so every route (/predict,
11
+ /health, /ready, /model-info, and the index.html at GET /)
12
+ is served by FastAPI exactly as it works in Docker.
13
+ 3. Hugging Face exposes port 7860 to the public internet for free.
14
+ """
15
+
16
+ import os
17
+ import sys
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # Make sure Python can import from 'src/' (for src.model → CLASS_NAMES, etc.)
21
+ # ---------------------------------------------------------------------------
22
+ sys.path.insert(0, os.path.dirname(__file__))
23
+
24
+ import gradio as gr
25
+
26
+ # Import our existing FastAPI app from src/api/main.py
27
+ from src.api.main import app as fastapi_app
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # Create a minimal Gradio Blocks app (required by HF Spaces to detect the SDK)
31
+ # then mount our full FastAPI app onto it so our custom UI is served at "/".
32
+ # ---------------------------------------------------------------------------
33
+ demo = gr.Blocks()
34
+
35
+ # Mount FastAPI at the root path of the Gradio server
36
+ app = gr.mount_gradio_app(fastapi_app, demo, path="/gradio")
37
+ # Note: Gradio is mounted at /gradio (hidden), FastAPI owns "/" and "/predict"
38
+
39
+ if __name__ == "__main__":
40
+ import uvicorn
41
+ uvicorn.run(app, host="0.0.0.0", port=7860)
42
+
index.html ADDED
@@ -0,0 +1,986 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+
4
+ <head>
5
+ <meta charset="UTF-8" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>Humanly - Human or Not?</title>
8
+ <!-- Google Fonts: Plus Jakarta Sans (body), Outfit (playful titles), Caveat (handwriting note) -->
9
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
10
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
11
+ <link
12
+ href="https://fonts.googleapis.com/css2?family=Caveat:wght@600;700&family=Outfit:wght@600;700;800&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap"
13
+ rel="stylesheet" />
14
+
15
+ <style>
16
+ :root {
17
+ --primary: #5c54e5;
18
+ --primary-hover: #4a41d9;
19
+ --primary-light: #ede9fe;
20
+ --accent-coral: #f43f5e;
21
+ --success: #10b981;
22
+ --success-light: #d1fae5;
23
+ --danger: #ef4444;
24
+ --danger-light: #fee2e2;
25
+ --text-dark: #1e293b;
26
+ --text-muted: #64748b;
27
+ --bg-page: #f8faff;
28
+ --card-bg: #ffffff;
29
+ --border-color: #e2e8f0;
30
+ --dashed-border: #c7d2fe;
31
+ --radius-card: 24px;
32
+ --radius-pill: 9999px;
33
+ }
34
+
35
+ * {
36
+ box-sizing: border-box;
37
+ margin: 0;
38
+ padding: 0;
39
+ }
40
+
41
+ body {
42
+ font-family: 'Plus Jakarta Sans', sans-serif;
43
+ background-color: var(--bg-page);
44
+ color: var(--text-dark);
45
+ min-height: 100vh;
46
+ display: flex;
47
+ flex-direction: column;
48
+ position: relative;
49
+ overflow-x: hidden;
50
+ }
51
+
52
+ /* Ambient Pastel Blobs in Background */
53
+ .blob-bg {
54
+ position: fixed;
55
+ border-radius: 50%;
56
+ filter: blur(80px);
57
+ z-index: -1;
58
+ opacity: 0.6;
59
+ pointer-events: none;
60
+ }
61
+
62
+ .blob-1 {
63
+ width: 450px;
64
+ height: 450px;
65
+ background: #e0e7ff;
66
+ top: -100px;
67
+ left: -150px;
68
+ }
69
+
70
+ .blob-2 {
71
+ width: 400px;
72
+ height: 400px;
73
+ background: #fef3c7;
74
+ top: 50px;
75
+ right: -100px;
76
+ }
77
+
78
+ .blob-3 {
79
+ width: 500px;
80
+ height: 500px;
81
+ background: #fce7f3;
82
+ bottom: -150px;
83
+ left: 20%;
84
+ }
85
+
86
+ /* Top Navbar */
87
+ .navbar {
88
+ max-width: 1200px;
89
+ width: 100%;
90
+ margin: 0 auto;
91
+ padding: 1.25rem 2rem;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ flex-wrap: wrap;
96
+ gap: 1rem;
97
+ }
98
+
99
+ .brand {
100
+ display: flex;
101
+ align-items: center;
102
+ gap: 0.5rem;
103
+ font-family: 'Outfit', sans-serif;
104
+ font-size: 1.4rem;
105
+ font-weight: 800;
106
+ color: var(--text-dark);
107
+ text-decoration: none;
108
+ }
109
+
110
+ .brand-icon {
111
+ color: var(--primary);
112
+ font-size: 1.6rem;
113
+ }
114
+
115
+ .nav-links {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 0.5rem;
119
+ }
120
+
121
+ .nav-pill {
122
+ display: inline-flex;
123
+ align-items: center;
124
+ gap: 0.4rem;
125
+ padding: 0.45rem 1.1rem;
126
+ border-radius: var(--radius-pill);
127
+ font-size: 0.9rem;
128
+ font-weight: 600;
129
+ text-decoration: none;
130
+ color: var(--text-muted);
131
+ transition: all 0.2s ease;
132
+ }
133
+
134
+ .nav-pill:hover {
135
+ color: var(--text-dark);
136
+ background: #f1f5f9;
137
+ }
138
+
139
+ .nav-pill.active {
140
+ background: var(--primary-light);
141
+ color: var(--primary);
142
+ }
143
+
144
+ .status-badge {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ gap: 0.45rem;
148
+ padding: 0.4rem 1rem;
149
+ border-radius: var(--radius-pill);
150
+ font-size: 0.85rem;
151
+ font-weight: 600;
152
+ background: #dcfce7;
153
+ color: #15803d;
154
+ transition: all 0.3s;
155
+ }
156
+
157
+ .status-dot {
158
+ width: 8px;
159
+ height: 8px;
160
+ border-radius: 50%;
161
+ background: #16a34a;
162
+ box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.2);
163
+ animation: pulse 2s infinite;
164
+ }
165
+
166
+ .status-badge.offline {
167
+ background: #fee2e2;
168
+ color: #b91c1c;
169
+ }
170
+
171
+ .status-badge.offline .status-dot {
172
+ background: #dc2626;
173
+ animation: none;
174
+ }
175
+
176
+ @keyframes pulse {
177
+ 0% {
178
+ box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.4);
179
+ }
180
+
181
+ 70% {
182
+ box-shadow: 0 0 0 6px rgba(22, 163, 74, 0);
183
+ }
184
+
185
+ 100% {
186
+ box-shadow: 0 0 0 0 rgba(22, 163, 74, 0);
187
+ }
188
+ }
189
+
190
+ /* Main Container */
191
+ .container {
192
+ max-width: 1080px;
193
+ width: 100%;
194
+ margin: 0 auto;
195
+ padding: 1rem 1.5rem 3rem;
196
+ flex: 1;
197
+ }
198
+
199
+ /* Hero Header */
200
+ .hero {
201
+ display: flex;
202
+ align-items: center;
203
+ justify-content: center;
204
+ gap: 2rem;
205
+ margin: 1.5rem 0 2.5rem;
206
+ flex-wrap: wrap;
207
+ }
208
+
209
+ .mascot-container {
210
+ width: 170px;
211
+ height: 170px;
212
+ flex-shrink: 0;
213
+ animation: float 4s ease-in-out infinite;
214
+ }
215
+
216
+ @keyframes float {
217
+
218
+ 0%,
219
+ 100% {
220
+ transform: translateY(0);
221
+ }
222
+
223
+ 50% {
224
+ transform: translateY(-8px);
225
+ }
226
+ }
227
+
228
+ .hero-text {
229
+ text-align: left;
230
+ }
231
+
232
+ .hero-title {
233
+ font-family: 'Outfit', sans-serif;
234
+ font-size: clamp(2.4rem, 5vw, 3.8rem);
235
+ font-weight: 800;
236
+ line-height: 1.15;
237
+ }
238
+
239
+ .hero-title .human {
240
+ color: var(--primary);
241
+ }
242
+
243
+ .hero-title .or {
244
+ color: var(--text-dark);
245
+ font-weight: 700;
246
+ }
247
+
248
+ .hero-title .not {
249
+ color: var(--accent-coral);
250
+ }
251
+
252
+ .hero-subtitle {
253
+ font-size: clamp(1rem, 2.5vw, 1.25rem);
254
+ color: #475569;
255
+ margin-top: 0.5rem;
256
+ font-weight: 500;
257
+ position: relative;
258
+ display: inline-block;
259
+ }
260
+
261
+ .hero-subtitle::after {
262
+ content: '';
263
+ position: absolute;
264
+ left: 0;
265
+ bottom: -6px;
266
+ width: 100%;
267
+ height: 4px;
268
+ background: #fbbf24;
269
+ border-radius: 4px;
270
+ opacity: 0.8;
271
+ }
272
+
273
+ /* Grid Section: Two Cards */
274
+ .app-grid {
275
+ display: grid;
276
+ grid-template-columns: 1fr 1fr;
277
+ gap: 2rem;
278
+ align-items: stretch;
279
+ margin-top: 1rem;
280
+ }
281
+
282
+ @media (max-width: 868px) {
283
+ .app-grid {
284
+ grid-template-columns: 1fr;
285
+ }
286
+
287
+ .hero-text {
288
+ text-align: center;
289
+ }
290
+
291
+ .hero {
292
+ flex-direction: column;
293
+ gap: 1rem;
294
+ }
295
+ }
296
+
297
+ /* Left Card: Upload Dropzone */
298
+ .upload-card {
299
+ background: var(--card-bg);
300
+ border: 2px dashed var(--dashed-border);
301
+ border-radius: var(--radius-card);
302
+ padding: 2.5rem 1.75rem;
303
+ text-align: center;
304
+ display: flex;
305
+ flex-direction: column;
306
+ align-items: center;
307
+ justify-content: center;
308
+ cursor: pointer;
309
+ box-shadow: 0 10px 25px -5px rgba(99, 102, 241, 0.05);
310
+ transition: all 0.25s ease;
311
+ min-height: 380px;
312
+ position: relative;
313
+ }
314
+
315
+ .upload-card:hover,
316
+ .upload-card.dragover {
317
+ border-color: var(--primary);
318
+ background: #fafbff;
319
+ transform: translateY(-2px);
320
+ box-shadow: 0 14px 30px -5px rgba(99, 102, 241, 0.12);
321
+ }
322
+
323
+ .cloud-icon-wrapper {
324
+ width: 80px;
325
+ height: 80px;
326
+ border-radius: 50%;
327
+ background: linear-gradient(135deg, #ede9fe 0%, #c7d2fe 100%);
328
+ display: flex;
329
+ align-items: center;
330
+ justify-content: center;
331
+ margin-bottom: 1.25rem;
332
+ color: var(--primary);
333
+ box-shadow: 0 8px 16px -4px rgba(99, 102, 241, 0.25);
334
+ }
335
+
336
+ .upload-title {
337
+ font-family: 'Outfit', sans-serif;
338
+ font-size: 1.45rem;
339
+ font-weight: 700;
340
+ color: var(--text-dark);
341
+ margin-bottom: 0.35rem;
342
+ }
343
+
344
+ .upload-desc {
345
+ color: var(--text-muted);
346
+ font-size: 0.95rem;
347
+ margin-bottom: 1.25rem;
348
+ }
349
+
350
+ .badge-pill {
351
+ display: inline-block;
352
+ background: #f1f5f9;
353
+ color: #64748b;
354
+ padding: 0.35rem 0.9rem;
355
+ border-radius: var(--radius-pill);
356
+ font-size: 0.8rem;
357
+ font-weight: 600;
358
+ margin-bottom: 1.5rem;
359
+ }
360
+
361
+ .btn-choose {
362
+ background: var(--primary);
363
+ color: #ffffff;
364
+ border: none;
365
+ padding: 0.85rem 2rem;
366
+ border-radius: var(--radius-pill);
367
+ font-family: 'Outfit', sans-serif;
368
+ font-size: 1.05rem;
369
+ font-weight: 700;
370
+ display: inline-flex;
371
+ align-items: center;
372
+ gap: 0.5rem;
373
+ cursor: pointer;
374
+ box-shadow: 0 6px 20px rgba(92, 84, 229, 0.35);
375
+ transition: all 0.2s ease;
376
+ }
377
+
378
+ .btn-choose:hover {
379
+ background: var(--primary-hover);
380
+ transform: translateY(-1px);
381
+ box-shadow: 0 8px 25px rgba(92, 84, 229, 0.45);
382
+ }
383
+
384
+ /* Right Card: Result Showcase */
385
+ .result-card {
386
+ background: var(--card-bg);
387
+ border: 2px solid var(--border-color);
388
+ border-radius: var(--radius-card);
389
+ padding: 2rem;
390
+ display: flex;
391
+ flex-direction: column;
392
+ justify-content: space-between;
393
+ box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.04);
394
+ min-height: 380px;
395
+ transition: all 0.3s ease;
396
+ position: relative;
397
+ }
398
+
399
+ /* Empty Placeholder State inside Result Card */
400
+ .result-empty {
401
+ display: flex;
402
+ flex-direction: column;
403
+ align-items: center;
404
+ justify-content: center;
405
+ height: 100%;
406
+ text-align: center;
407
+ color: #94a3b8;
408
+ padding: 2rem 1rem;
409
+ }
410
+
411
+ .result-empty-icon {
412
+ font-size: 3rem;
413
+ margin-bottom: 0.75rem;
414
+ opacity: 0.6;
415
+ }
416
+
417
+ .result-empty-text {
418
+ font-size: 1.1rem;
419
+ font-weight: 600;
420
+ }
421
+
422
+ .result-empty-sub {
423
+ font-size: 0.88rem;
424
+ color: #cbd5e1;
425
+ margin-top: 0.25rem;
426
+ }
427
+
428
+ /* Active Result View */
429
+ .result-active {
430
+ display: none;
431
+ height: 100%;
432
+ flex-direction: column;
433
+ justify-content: space-between;
434
+ }
435
+
436
+ .result-header {
437
+ display: flex;
438
+ align-items: center;
439
+ gap: 0.85rem;
440
+ margin-bottom: 0.3rem;
441
+ }
442
+
443
+ .result-check-icon {
444
+ width: 44px;
445
+ height: 44px;
446
+ border-radius: 50%;
447
+ background: var(--success);
448
+ color: white;
449
+ display: flex;
450
+ align-items: center;
451
+ justify-content: center;
452
+ font-size: 1.4rem;
453
+ font-weight: bold;
454
+ flex-shrink: 0;
455
+ }
456
+
457
+ .result-title {
458
+ font-family: 'Outfit', sans-serif;
459
+ font-size: 1.7rem;
460
+ font-weight: 800;
461
+ color: var(--text-dark);
462
+ }
463
+
464
+ .confidence-label {
465
+ font-size: 0.95rem;
466
+ color: var(--text-muted);
467
+ margin-left: 3.5rem;
468
+ margin-bottom: 0.75rem;
469
+ }
470
+
471
+ .confidence-pct {
472
+ font-weight: 700;
473
+ color: var(--success);
474
+ font-size: 1.15rem;
475
+ }
476
+
477
+ /* Progress bar */
478
+ .progress-track {
479
+ width: 100%;
480
+ height: 14px;
481
+ background: #e2e8f0;
482
+ border-radius: var(--radius-pill);
483
+ overflow: hidden;
484
+ margin-bottom: 1.5rem;
485
+ position: relative;
486
+ }
487
+
488
+ .progress-fill {
489
+ height: 100%;
490
+ background: var(--success);
491
+ border-radius: var(--radius-pill);
492
+ width: 0%;
493
+ transition: width 0.8s cubic-bezier(0.4, 0, 0.2, 1);
494
+ }
495
+
496
+ /* Image Preview & Note Row */
497
+ .preview-row {
498
+ display: flex;
499
+ align-items: center;
500
+ gap: 1.5rem;
501
+ margin-top: auto;
502
+ }
503
+
504
+ .preview-img-box {
505
+ width: 140px;
506
+ height: 140px;
507
+ border-radius: 16px;
508
+ overflow: hidden;
509
+ border: 3px solid #f1f5f9;
510
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
511
+ flex-shrink: 0;
512
+ background: #f8fafc;
513
+ }
514
+
515
+ .preview-img-box img {
516
+ width: 100%;
517
+ height: 100%;
518
+ object-fit: cover;
519
+ display: block;
520
+ }
521
+
522
+ .doodle-note {
523
+ font-family: 'Caveat', cursive;
524
+ font-size: 1.85rem;
525
+ font-weight: 700;
526
+ line-height: 1.15;
527
+ color: #334155;
528
+ transform: rotate(-2deg);
529
+ position: relative;
530
+ }
531
+
532
+ .doodle-note .sub-doodle {
533
+ display: block;
534
+ width: 60px;
535
+ height: 4px;
536
+ background: #94a3b8;
537
+ border-radius: 4px;
538
+ margin-top: 4px;
539
+ opacity: 0.5;
540
+ }
541
+
542
+ /* Non-Human Theme adjustments */
543
+ .result-card.is-non-human {
544
+ border-color: #fecdd3;
545
+ }
546
+
547
+ .result-card.is-non-human .result-check-icon {
548
+ background: var(--accent-coral);
549
+ }
550
+
551
+ .result-card.is-non-human .confidence-pct {
552
+ color: var(--accent-coral);
553
+ }
554
+
555
+ .result-card.is-non-human .progress-fill {
556
+ background: var(--accent-coral);
557
+ }
558
+
559
+ /* Loading Spinner Overlay */
560
+ .loading-overlay {
561
+ position: absolute;
562
+ inset: 0;
563
+ background: rgba(255, 255, 255, 0.85);
564
+ backdrop-filter: blur(4px);
565
+ border-radius: var(--radius-card);
566
+ display: none;
567
+ flex-direction: column;
568
+ align-items: center;
569
+ justify-content: center;
570
+ z-index: 10;
571
+ }
572
+
573
+ .spinner {
574
+ width: 48px;
575
+ height: 48px;
576
+ border: 4px solid #ede9fe;
577
+ border-top-color: var(--primary);
578
+ border-radius: 50%;
579
+ animation: spin 0.8s linear infinite;
580
+ margin-bottom: 0.75rem;
581
+ }
582
+
583
+ @keyframes spin {
584
+ to {
585
+ transform: rotate(360deg);
586
+ }
587
+ }
588
+
589
+ .loading-text {
590
+ font-family: 'Outfit', sans-serif;
591
+ font-weight: 700;
592
+ color: var(--primary);
593
+ font-size: 1.1rem;
594
+ }
595
+
596
+ /* Footer */
597
+ footer {
598
+ text-align: center;
599
+ padding: 2rem 1rem;
600
+ font-size: 0.9rem;
601
+ color: #64748b;
602
+ margin-top: auto;
603
+ }
604
+
605
+ footer span {
606
+ color: var(--accent-coral);
607
+ }
608
+
609
+ /* Modal for About */
610
+ .modal {
611
+ display: none;
612
+ position: fixed;
613
+ inset: 0;
614
+ background: rgba(15, 23, 42, 0.6);
615
+ backdrop-filter: blur(6px);
616
+ z-index: 999;
617
+ align-items: center;
618
+ justify-content: center;
619
+ padding: 1rem;
620
+ }
621
+
622
+ .modal-content {
623
+ background: white;
624
+ max-width: 500px;
625
+ width: 100%;
626
+ border-radius: 20px;
627
+ padding: 2rem;
628
+ position: relative;
629
+ box-shadow: 0 20px 35px rgba(0, 0, 0, 0.2);
630
+ }
631
+
632
+ .modal-close {
633
+ position: absolute;
634
+ top: 1.25rem;
635
+ right: 1.25rem;
636
+ background: none;
637
+ border: none;
638
+ font-size: 1.5rem;
639
+ cursor: pointer;
640
+ color: #94a3b8;
641
+ }
642
+
643
+ .modal h2 {
644
+ font-family: 'Outfit', sans-serif;
645
+ margin-bottom: 0.75rem;
646
+ }
647
+
648
+ .modal p {
649
+ color: #475569;
650
+ line-height: 1.6;
651
+ margin-bottom: 0.75rem;
652
+ font-size: 0.95rem;
653
+ }
654
+ </style>
655
+ </head>
656
+
657
+ <body>
658
+
659
+ <!-- Ambient Blobs -->
660
+ <div class="blob-bg blob-1"></div>
661
+ <div class="blob-bg blob-2"></div>
662
+ <div class="blob-bg blob-3"></div>
663
+
664
+ <!-- Top Navbar -->
665
+ <header class="navbar">
666
+ <a href="#" class="brand">
667
+ <span class="brand-icon">✦</span> Humanly
668
+ </a>
669
+
670
+ <nav class="nav-links">
671
+ <a href="#" class="nav-pill active">
672
+ <span>🏠</span> Home
673
+ </a>
674
+ <a href="#about" class="nav-pill" onclick="openModal(event)">
675
+ <span>ℹ️</span> About
676
+ </a>
677
+ <a href="https://github.com" target="_blank" class="nav-pill">
678
+ <span>🐙</span> GitHub
679
+ </a>
680
+ </nav>
681
+
682
+ <div id="statusBadge" class="status-badge">
683
+ <span class="status-dot"></span>
684
+ <span id="statusText">Checking Model...</span>
685
+ </div>
686
+ </header>
687
+
688
+ <!-- Main App Container -->
689
+ <main class="container">
690
+
691
+ <!-- Hero Header with Robot Mascot -->
692
+ <section class="hero">
693
+ <div class="mascot-container">
694
+ <!-- Modern SVG Robot Mascot holding photo frame -->
695
+ <svg viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
696
+ <!-- Antenna -->
697
+ <circle cx="100" cy="24" r="8" fill="#818cf8" />
698
+ <line x1="100" y1="32" x2="100" y2="48" stroke="#6366f1" stroke-width="5" stroke-linecap="round" />
699
+ <circle cx="56" cy="62" r="7" fill="#818cf8" />
700
+ <circle cx="144" cy="62" r="7" fill="#818cf8" />
701
+ <!-- Head / Body -->
702
+ <rect x="42" y="46" width="116" height="92" rx="34" fill="#ffffff" stroke="#6366f1"
703
+ stroke-width="6" />
704
+ <!-- Screen visor -->
705
+ <rect x="54" y="58" width="92" height="68" rx="22" fill="#1e1b4b" />
706
+ <!-- Eyes -->
707
+ <path d="M72 90 C 72 82, 84 82, 84 90" stroke="#38bdf8" stroke-width="5" stroke-linecap="round"
708
+ fill="none" />
709
+ <path d="M116 90 C 116 82, 128 82, 128 90" stroke="#38bdf8" stroke-width="5" stroke-linecap="round"
710
+ fill="none" />
711
+ <!-- Smile -->
712
+ <path d="M96 104 C 98 108, 102 108, 104 104" stroke="#38bdf8" stroke-width="4"
713
+ stroke-linecap="round" fill="none" />
714
+ <!-- Cheeks -->
715
+ <circle cx="68" cy="100" r="4" fill="#f43f5e" opacity="0.8" />
716
+ <circle cx="132" cy="100" r="4" fill="#f43f5e" opacity="0.8" />
717
+ <!-- Holding Photo Frame -->
718
+ <g transform="translate(118, 55) rotate(12)">
719
+ <rect x="0" y="0" width="60" height="52" rx="8" fill="#ffffff" stroke="#312e81"
720
+ stroke-width="4" />
721
+ <rect x="6" y="6" width="48" height="40" rx="4" fill="#e0e7ff" />
722
+ <circle cx="36" cy="18" r="6" fill="#fbbf24" />
723
+ <polygon points="12,42 26,26 48,42" fill="#60a5fa" />
724
+ </g>
725
+ <!-- Hands -->
726
+ <circle cx="48" cy="144" r="12" fill="#818cf8" />
727
+ <circle cx="142" cy="120" r="10" fill="#818cf8" />
728
+ <!-- Chest sparkle -->
729
+ <path d="M100 152 L 102 160 L 110 162 L 102 164 L 100 172 L 98 164 L 90 162 L 98 160 Z"
730
+ fill="#818cf8" />
731
+ </svg>
732
+ </div>
733
+
734
+ <div class="hero-text">
735
+ <h1 class="hero-title">
736
+ <span class="human">Human</span> <span class="or">or</span> <span class="not">Not?</span>
737
+ </h1>
738
+ <p class="hero-subtitle">Upload an image and let AI decide!</p>
739
+ </div>
740
+ </section>
741
+
742
+ <!-- Two-Column Cards Grid -->
743
+ <div class="app-grid">
744
+
745
+ <!-- Left Card: Upload -->
746
+ <div class="upload-card" id="dropZone" onclick="triggerFileInput()">
747
+ <input type="file" id="fileInput" accept="image/jpeg, image/png, image/webp" style="display: none;"
748
+ onchange="handleFileSelect(event)" />
749
+
750
+ <div class="cloud-icon-wrapper">
751
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"
752
+ stroke-linecap="round" stroke-linejoin="round">
753
+ <path d="M17.5 19H9a7 7 0 1 1 6.71-9h1.79a4.5 4.5 0 1 1 0 9Z" />
754
+ <polyline points="12 11 12 16 12 11" />
755
+ <polyline points="9 14 12 11 15 14" />
756
+ </svg>
757
+ </div>
758
+
759
+ <h3 class="upload-title">Upload an Image</h3>
760
+ <p class="upload-desc">Drag & drop your file here, or click to browse</p>
761
+ <span class="badge-pill">Supports: JPG, PNG, WEBP</span>
762
+
763
+ <button type="button" class="btn-choose">
764
+ <span>🖼</span> Choose Image
765
+ </button>
766
+ </div>
767
+
768
+ <!-- Right Card: Live Result View -->
769
+ <div class="result-card" id="resultCard">
770
+ <!-- Loading Overlay -->
771
+ <div class="loading-overlay" id="loadingOverlay">
772
+ <div class="spinner"></div>
773
+ <div class="loading-text">Analyzing Face with AI...</div>
774
+ </div>
775
+
776
+ <!-- Initial Placeholder State (Before upload) -->
777
+ <div class="result-empty" id="resultEmpty">
778
+ <div class="result-empty-icon">✨</div>
779
+ <div class="result-empty-text">No Prediction Yet</div>
780
+ <div class="result-empty-sub">Upload an image on the left to see AI classification</div>
781
+ </div>
782
+
783
+ <!-- Populated Result State (After upload) -->
784
+ <div class="result-active" id="resultActive">
785
+ <div>
786
+ <div class="result-header">
787
+ <div class="result-check-icon" id="resultIcon">✓</div>
788
+ <h2 class="result-title" id="resultTitle">It's a Human!</h2>
789
+ </div>
790
+
791
+ <div class="confidence-label">
792
+ Confidence: <span class="confidence-pct" id="confidencePct">98.7%</span>
793
+ </div>
794
+
795
+ <!-- Animated Bar -->
796
+ <div class="progress-track">
797
+ <div class="progress-fill" id="progressFill"></div>
798
+ </div>
799
+ </div>
800
+
801
+ <!-- Thumbnail & Handwritten Note -->
802
+ <div class="preview-row">
803
+ <div class="preview-img-box">
804
+ <img id="resultImagePreview" src="" alt="Analyzed Face" />
805
+ </div>
806
+
807
+ <div class="doodle-note" id="doodleNote">
808
+ <span id="doodleText">Looks like a human! 😊</span>
809
+ <span class="sub-doodle"></span>
810
+ </div>
811
+ </div>
812
+ </div>
813
+
814
+ </div>
815
+
816
+ </div>
817
+
818
+ </main>
819
+
820
+ <!-- Footer -->
821
+ <footer>
822
+ Made by Jatin Karma ❤️
823
+ </footer>
824
+
825
+ <!-- About Modal -->
826
+ <div class="modal" id="aboutModal" onclick="closeModalOnBackdrop(event)">
827
+ <div class="modal-content">
828
+ <button class="modal-close" onclick="closeModal()">&times;</button>
829
+ <h2>About Humanly</h2>
830
+ <p>
831
+ <strong>Are You Human?</strong> is a real-time computer vision classifier built using
832
+ <strong>MobileNetV2 Transfer Learning</strong> and <strong>FastAPI</strong> in Docker.
833
+ </p>
834
+ <p>
835
+ It distinguishes genuine human faces from non-human face-like objects (statues, digital art, anime, CGI,
836
+ and masks)
837
+ trained on over 8,500 labeled images.
838
+ </p>
839
+ <p>
840
+ <strong>Architecture:</strong> MobileNetV2 + GlobalAveragePooling2D + Dropout(0.2) + Sigmoid Classifier.
841
+ </p>
842
+ </div>
843
+ </div>
844
+
845
+ <script>
846
+ // Configuration: Point to your FastAPI backend
847
+ const API_URL = window.location.origin.includes("http") ? "" : "http://localhost:8000";
848
+
849
+ // 1. Check Model Health on Load
850
+ async function checkHealth() {
851
+ const badge = document.getElementById("statusBadge");
852
+ const text = document.getElementById("statusText");
853
+ try {
854
+ const res = await fetch(`${API_URL}/ready`);
855
+ if (res.ok) {
856
+ badge.className = "status-badge";
857
+ text.innerText = "Model Online";
858
+ } else {
859
+ badge.className = "status-badge offline";
860
+ text.innerText = "Model Offline";
861
+ }
862
+ } catch (err) {
863
+ badge.className = "status-badge offline";
864
+ text.innerText = "Connecting...";
865
+ }
866
+ }
867
+ checkHealth();
868
+ setInterval(checkHealth, 10000); // Check every 10 seconds
869
+
870
+ // 2. Drag & Drop and File Input Handlers
871
+ function triggerFileInput() {
872
+ document.getElementById('fileInput').click();
873
+ }
874
+
875
+ const dropZone = document.getElementById('dropZone');
876
+ ['dragenter', 'dragover'].forEach(eventName => {
877
+ dropZone.addEventListener(eventName, (e) => {
878
+ e.preventDefault();
879
+ dropZone.classList.add('dragover');
880
+ }, false);
881
+ });
882
+ ['dragleave', 'drop'].forEach(eventName => {
883
+ dropZone.addEventListener(eventName, (e) => {
884
+ e.preventDefault();
885
+ dropZone.classList.remove('dragover');
886
+ }, false);
887
+ });
888
+
889
+ dropZone.addEventListener('drop', (e) => {
890
+ const files = e.dataTransfer.files;
891
+ if (files.length > 0) {
892
+ processImage(files[0]);
893
+ }
894
+ });
895
+
896
+ function handleFileSelect(e) {
897
+ if (e.target.files.length > 0) {
898
+ processImage(e.target.files[0]);
899
+ }
900
+ }
901
+
902
+ // 3. Process & Classify Image via API
903
+ async function processImage(file) {
904
+ if (!file.type.startsWith('image/')) {
905
+ alert("Please select a valid image file (JPEG, PNG, or WebP)!");
906
+ return;
907
+ }
908
+
909
+ // Show local thumbnail preview immediately
910
+ const previewImg = document.getElementById('resultImagePreview');
911
+ previewImg.src = URL.createObjectURL(file);
912
+
913
+ // Show Loading Spinner
914
+ const loading = document.getElementById('loadingOverlay');
915
+ loading.style.display = 'flex';
916
+
917
+ const formData = new FormData();
918
+ formData.append('file', file);
919
+
920
+ try {
921
+ const res = await fetch(`${API_URL}/predict`, {
922
+ method: 'POST',
923
+ body: formData
924
+ });
925
+
926
+ if (!res.ok) {
927
+ throw new Error(`Server returned HTTP ${res.status}`);
928
+ }
929
+
930
+ const data = await res.json();
931
+ displayPrediction(data);
932
+ } catch (error) {
933
+ console.error("Prediction Error:", error);
934
+ alert("Failed to reach the AI model! Please try again in a moment.");
935
+ } finally {
936
+ loading.style.display = 'none';
937
+ }
938
+ }
939
+
940
+ // 4. Update UI with AI Classification Results
941
+ function displayPrediction(data) {
942
+ document.getElementById('resultEmpty').style.display = 'none';
943
+ const activeSection = document.getElementById('resultActive');
944
+ activeSection.style.display = 'flex';
945
+
946
+ const card = document.getElementById('resultCard');
947
+ const icon = document.getElementById('resultIcon');
948
+ const title = document.getElementById('resultTitle');
949
+ const confPct = document.getElementById('confidencePct');
950
+ const progressFill = document.getElementById('progressFill');
951
+ const doodleText = document.getElementById('doodleText');
952
+
953
+ if (data.prediction === 'human') {
954
+ card.classList.remove('is-non-human');
955
+ icon.innerText = '✓';
956
+ title.innerText = "It's a Human!";
957
+ const score = (data.human_probability * 100).toFixed(1);
958
+ confPct.innerText = `${score}%`;
959
+ progressFill.style.width = `${score}%`;
960
+ doodleText.innerText = "Looks like a human! 😊";
961
+ } else {
962
+ card.classList.add('is-non-human');
963
+ icon.innerText = '✗';
964
+ title.innerText = "Not a Human!";
965
+ const score = (data.non_human_probability * 100).toFixed(1);
966
+ confPct.innerText = `${score}%`;
967
+ progressFill.style.width = `${score}%`;
968
+ doodleText.innerText = "Looks like an AI or art! 🤖";
969
+ }
970
+ }
971
+
972
+ // 5. Modal handlers
973
+ function openModal(e) {
974
+ e.preventDefault();
975
+ document.getElementById('aboutModal').style.display = 'flex';
976
+ }
977
+ function closeModal() {
978
+ document.getElementById('aboutModal').style.display = 'none';
979
+ }
980
+ function closeModalOnBackdrop(e) {
981
+ if (e.target.id === 'aboutModal') closeModal();
982
+ }
983
+ </script>
984
+ </body>
985
+
986
+ </html>
models/human_face_v1.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_version": "1.0.0",
3
+ "architecture": "MobileNetV2 transfer learning",
4
+ "dataset": "LakoreAI/human-nonhuman-face-classification",
5
+ "class_names": [
6
+ "human",
7
+ "non_human"
8
+ ],
9
+ "positive_class_index": 1,
10
+ "image_size": [
11
+ 224,
12
+ 224
13
+ ],
14
+ "threshold": 0.5,
15
+ "preprocessing": "RGB, resize 224x224, scale 0-255 to -1..1 inside saved model"
16
+ }
models/human_face_v1.keras ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:aef727c4b4ad05e94e5e09d7897710ebaa36d7c0ebd686dcbf5fd026df8fe4e0
3
+ size 9646667
requirements.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ tensorflow-cpu>=2.16,<3
2
+ keras>=3.8,<4
3
+ Pillow>=10,<13
4
+ numpy>=1.26,<3
5
+ scikit-learn>=1.4,<2
6
+ fastapi>=0.115,<1
7
+ uvicorn[standard]>=0.30,<1
8
+ python-multipart>=0.0.9,<1
9
+ gradio>=5.0,<6
10
+
11
+ gradio>=5.0,<6
src/__init__.py ADDED
File without changes
src/__pycache__/train.cpython-311.pyc ADDED
Binary file (4.65 kB). View file
 
src/api/__init__.py ADDED
File without changes
src/api/__pycache__/main.cpython-311.pyc ADDED
Binary file (5.48 kB). View file
 
src/api/main.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ from contextlib import asynccontextmanager
4
+
5
+ import keras
6
+ import numpy as np
7
+ from fastapi import FastAPI, File, HTTPException, UploadFile
8
+ from PIL import Image, UnidentifiedImageError
9
+ from starlette.concurrency import run_in_threadpool
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+ from fastapi.responses import FileResponse
12
+
13
+ from src.model import CLASS_NAMES, IMAGE_SIZE
14
+
15
+ MODEL_PATH = os.getenv("MODEL_PATH", "models/human_face_v1.keras")
16
+ MODEL_VERSION = os.getenv("MODEL_VERSION", "1.0.0")
17
+ MAX_UPLOAD_BYTES = 5 * 1024 * 1024 # 5 MB limit
18
+ THRESHOLD = float(os.getenv("PREDICTION_THRESHOLD", "0.5"))
19
+
20
+
21
+ @asynccontextmanager
22
+ async def lifespan(app: FastAPI):
23
+ if not os.path.exists(MODEL_PATH):
24
+ raise RuntimeError(f"Model file not found: {MODEL_PATH}")
25
+ # Load natively with Keras 3
26
+ app.state.model = keras.models.load_model(MODEL_PATH)
27
+ yield
28
+ app.state.model = None
29
+
30
+
31
+ app = FastAPI(
32
+ title="Humanly - Face Classifier API",
33
+ version=MODEL_VERSION,
34
+ lifespan=lifespan,
35
+ )
36
+
37
+ # Allow any frontend to communicate with this API
38
+ app.add_middleware(
39
+ CORSMiddleware,
40
+ allow_origins=["*"], # In production, replace "*" with your frontend URL
41
+ allow_credentials=True,
42
+ allow_methods=["*"],
43
+ allow_headers=["*"],
44
+ )
45
+
46
+
47
+ @app.get("/", include_in_schema=False)
48
+ def serve_ui():
49
+ if os.path.exists("index.html"):
50
+ return FileResponse("index.html")
51
+ return {"status": "Humanly API is online", "docs": "/docs"}
52
+
53
+
54
+ @app.get("/health")
55
+ def health():
56
+ return {"status": "alive"}
57
+
58
+
59
+ @app.get("/ready")
60
+ def ready():
61
+ if not hasattr(app.state, "model") or app.state.model is None:
62
+ raise HTTPException(status_code=503, detail="Model is not loaded")
63
+ return {"status": "ready", "model_version": MODEL_VERSION}
64
+
65
+
66
+ @app.get("/model-info")
67
+ def model_info():
68
+ return {
69
+ "model_version": MODEL_VERSION,
70
+ "classes": CLASS_NAMES,
71
+ "image_size": list(IMAGE_SIZE),
72
+ "threshold": THRESHOLD,
73
+ }
74
+
75
+
76
+ def run_prediction(model, image_bytes):
77
+ image = Image.open(io.BytesIO(image_bytes)).convert("RGB")
78
+ image = image.resize((IMAGE_SIZE[1], IMAGE_SIZE[0]))
79
+ pixels = np.asarray(image, dtype=np.float32)[None, ...]
80
+
81
+ p_non_human = float(model.predict(pixels, verbose=0)[0][0])
82
+ predicted_index = int(p_non_human >= THRESHOLD)
83
+ return {
84
+ "prediction": CLASS_NAMES[predicted_index],
85
+ "human_probability": round(1.0 - p_non_human, 4),
86
+ "non_human_probability": round(p_non_human, 4),
87
+ "model_version": MODEL_VERSION,
88
+ }
89
+
90
+
91
+ @app.post("/predict")
92
+ async def predict(file: UploadFile = File(...)):
93
+ if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
94
+ raise HTTPException(status_code=415, detail="Upload a JPEG, PNG, or WebP image")
95
+
96
+ image_bytes = await file.read(MAX_UPLOAD_BYTES + 1)
97
+ if len(image_bytes) > MAX_UPLOAD_BYTES:
98
+ raise HTTPException(status_code=413, detail="Image must be 5 MB or smaller")
99
+
100
+ try:
101
+ result = await run_in_threadpool(run_prediction, app.state.model, image_bytes)
102
+ except (UnidentifiedImageError, OSError, ValueError):
103
+ raise HTTPException(status_code=400, detail="The uploaded file is not a readable image")
104
+ finally:
105
+ await file.close()
106
+
107
+ return result
src/evaluate.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import numpy as np
5
+ import tensorflow as tf
6
+ from sklearn.metrics import classification_report, confusion_matrix
7
+
8
+ from src.model import CLASS_NAMES
9
+ from src.train import MODEL_DIR, load_split
10
+
11
+
12
+ def main():
13
+ model_path = MODEL_DIR / "human_face_v1.keras"
14
+ metadata_path = MODEL_DIR / "human_face_v1.json"
15
+
16
+ if not model_path.exists():
17
+ raise SystemExit(f"Model file not found: {model_path}")
18
+
19
+ # 1. Load metadata to get the decision threshold (0.5)
20
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
21
+ threshold = float(metadata["threshold"])
22
+
23
+ print("Loading test dataset...")
24
+ # shuffle=False is critical so order of images matches predictions exactly
25
+ test_ds = load_split("test", shuffle=False)
26
+
27
+ print("Loading saved model...")
28
+ model = tf.keras.models.load_model(model_path)
29
+
30
+ print("Running evaluation on test split...")
31
+ # Extract the true ground-truth labels (0 or 1) from the test dataset
32
+ y_true = np.concatenate([labels.numpy().reshape(-1) for _, labels in test_ds]).astype(int)
33
+
34
+ # Predict probabilities for all test images
35
+ probabilities = model.predict(test_ds, verbose=1).reshape(-1)
36
+
37
+ # Convert probability scores into 0 (human) or 1 (non_human) using threshold
38
+ y_pred = (probabilities >= threshold).astype(int)
39
+
40
+ # 2. Print evaluation metrics
41
+ print("\n" + "=" * 50)
42
+ print("Class order:", CLASS_NAMES, "(0 = human, 1 = non_human)")
43
+ print("Decision threshold:", threshold)
44
+ print("=" * 50)
45
+
46
+ print("\nConfusion Matrix (Rows = True class, Columns = Predicted class):")
47
+ print(confusion_matrix(y_true, y_pred, labels=[0, 1]))
48
+
49
+ print("\nClassification Report:")
50
+ print(
51
+ classification_report(
52
+ y_true,
53
+ y_pred,
54
+ labels=[0, 1],
55
+ target_names=CLASS_NAMES,
56
+ zero_division=0,
57
+ digits=4,
58
+ )
59
+ )
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
src/model.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import tensorflow as tf
2
+ from tensorflow.keras import layers
3
+
4
+ IMAGE_SIZE = (224, 224)
5
+ CLASS_NAMES = ["human", "non_human"]
6
+
7
+
8
+ def build_model():
9
+ inputs = tf.keras.Input(shape=(*IMAGE_SIZE, 3), name="rgb_image")
10
+
11
+
12
+ x = layers.RandomFlip("horizontal", name="augment_flip")(inputs)
13
+
14
+ x = layers.RandomZoom((-0.2, 0.2), name="augment_zoom")(x)
15
+
16
+ x = layers.RandomRotation(0.05, name="augment_rotation")(x)
17
+
18
+ x = layers.Rescaling(1.0 / 127.5, offset=-1.0, name="mobilenetv2_scale")(x)
19
+
20
+ #Pretrained Base Network
21
+ base = tf.keras.applications.MobileNetV2(
22
+ include_top=False,
23
+ weights="imagenet",
24
+ input_shape=(*IMAGE_SIZE, 3),
25
+ )
26
+ base.trainable = False
27
+
28
+ #Classifier Head
29
+ x = base(x, training=False)
30
+ x = layers.GlobalAveragePooling2D(name="global_average_pool")(x)
31
+ x = layers.Dropout(0.2, name="dropout")(x)
32
+ outputs = layers.Dense(1, activation="sigmoid", name="non_human_probability")(x)
33
+
34
+ return tf.keras.Model(inputs, outputs, name="human_face_classifier")
src/predict_one.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import numpy as np
6
+ import tensorflow as tf
7
+ from PIL import Image
8
+
9
+ from src.model import CLASS_NAMES, IMAGE_SIZE
10
+
11
+ ROOT = Path(__file__).resolve().parents[1]
12
+ MODEL_DIR = ROOT / "models"
13
+
14
+
15
+ def main(image_path: str):
16
+ model_path = MODEL_DIR / "human_face_v1.keras"
17
+ metadata_path = MODEL_DIR / "human_face_v1.json"
18
+
19
+ if not model_path.exists():
20
+ raise SystemExit(f"Model not found at {model_path}")
21
+
22
+ model = tf.keras.models.load_model(model_path)
23
+ metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
24
+ threshold = float(metadata["threshold"])
25
+
26
+ # Preprocess image
27
+ image = Image.open(image_path).convert("RGB").resize((IMAGE_SIZE[1], IMAGE_SIZE[0]))
28
+ pixels = np.asarray(image, dtype=np.float32)[None, ...] # Shape: (1, 224, 224, 3)
29
+
30
+ p_non_human = float(model.predict(pixels, verbose=0)[0][0])
31
+ predicted_index = int(p_non_human >= threshold)
32
+
33
+ result = {
34
+ "prediction": CLASS_NAMES[predicted_index],
35
+ "human_probability": round(1.0 - p_non_human, 4),
36
+ "non_human_probability": round(p_non_human, 4),
37
+ "model_version": metadata["model_version"],
38
+ }
39
+ print(json.dumps(result, indent=2))
40
+
41
+
42
+ if __name__ == "__main__":
43
+ if len(sys.argv) != 2:
44
+ raise SystemExit("Usage: python -m src.predict_one path/to/image.jpg")
45
+ main(sys.argv[1])
src/train.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from pathlib import Path
3
+ import tensorflow as tf
4
+
5
+ from src.model import CLASS_NAMES, IMAGE_SIZE, build_model
6
+
7
+ ROOT = Path(__file__).resolve().parents[1]
8
+ DATA_DIR = ROOT / "data"
9
+ MODEL_DIR = ROOT / "models"
10
+ MODEL_PATH = MODEL_DIR / "human_face_v1.keras"
11
+ METADATA_PATH = MODEL_DIR / "human_face_v1.json"
12
+ BATCH_SIZE = 32
13
+ SEED = 42
14
+
15
+
16
+ def load_split(split_name: str, shuffle: bool):
17
+ return tf.keras.utils.image_dataset_from_directory(
18
+ directory=DATA_DIR / split_name,
19
+ labels="inferred",
20
+ label_mode="binary",
21
+ class_names=CLASS_NAMES,
22
+ color_mode="rgb",
23
+ image_size=IMAGE_SIZE,
24
+ batch_size=BATCH_SIZE,
25
+ shuffle=shuffle,
26
+ seed=SEED,
27
+ ).prefetch(tf.data.AUTOTUNE)
28
+
29
+
30
+ def main():
31
+ if not (DATA_DIR / "train").exists() or not (DATA_DIR / "val").exists():
32
+ raise SystemExit("Dataset missing in data/train or data/val")
33
+
34
+ MODEL_DIR.mkdir(parents=True, exist_ok=True)
35
+
36
+ print("Loading datasets...")
37
+ train_ds = load_split("train", shuffle=True)
38
+ val_ds = load_split("val", shuffle=False)
39
+
40
+ print("Building model...")
41
+ model = build_model()
42
+ model.compile(
43
+ optimizer=tf.keras.optimizers.Adam(learning_rate=1e-3),
44
+ loss=tf.keras.losses.BinaryCrossentropy(),
45
+ metrics=[
46
+ tf.keras.metrics.BinaryAccuracy(name="accuracy"),
47
+ tf.keras.metrics.Precision(name="precision"),
48
+ tf.keras.metrics.Recall(name="recall"),
49
+ ],
50
+ )
51
+
52
+ callbacks = [
53
+ tf.keras.callbacks.ModelCheckpoint(
54
+ filepath=MODEL_PATH,
55
+ monitor="val_loss",
56
+ save_best_only=True,
57
+ verbose=1,
58
+ ),
59
+ tf.keras.callbacks.EarlyStopping(
60
+ monitor="val_loss",
61
+ patience=3,
62
+ restore_best_weights=True,
63
+ ),
64
+ tf.keras.callbacks.CSVLogger(MODEL_DIR / "human_face_v1_training.csv"),
65
+ ]
66
+
67
+ print("Starting training...")
68
+ model.fit(
69
+ train_ds,
70
+ validation_data=val_ds,
71
+ epochs=10,
72
+ callbacks=callbacks,
73
+ )
74
+
75
+ metadata = {
76
+ "model_version": "1.0.0",
77
+ "architecture": "MobileNetV2 transfer learning",
78
+ "dataset": "LakoreAI/human-nonhuman-face-classification",
79
+ "class_names": CLASS_NAMES,
80
+ "positive_class_index": 1,
81
+ "image_size": list(IMAGE_SIZE),
82
+ "threshold": 0.5,
83
+ "preprocessing": "RGB, resize 224x224, scale 0-255 to -1..1 inside saved model",
84
+ }
85
+ METADATA_PATH.write_text(json.dumps(metadata, indent=2), encoding="utf-8")
86
+ print(f"\nSaved best model: {MODEL_PATH}")
87
+ print(f"Saved metadata: {METADATA_PATH}")
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()
test_metrics.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_name": "human_face_v1",
3
+ "test_samples": 855,
4
+ "accuracy": 96.96,
5
+ "confusion_matrix": {
6
+ "true_human_pred_human": 415,
7
+ "true_human_pred_non_human": 18,
8
+ "true_non_human_pred_human": 8,
9
+ "true_non_human_pred_non_human": 414
10
+ },
11
+ "metrics": {
12
+ "human": {
13
+ "precision": 0.9811,
14
+ "recall": 0.9584,
15
+ "f1_score": 0.9696,
16
+ "support": 433
17
+ },
18
+ "non_human": {
19
+ "precision": 0.9583,
20
+ "recall": 0.981,
21
+ "f1_score": 0.9696,
22
+ "support": 422
23
+ }
24
+ },
25
+ "classification_report": " precision recall f1-score support\n\n human 0.9811 0.9584 0.9696 433\n non_human 0.9583 0.9810 0.9696 422\n\n accuracy 0.9696 855\n macro avg 0.9697 0.9697 0.9696 855\nweighted avg 0.9699 0.9696 0.9696 855\n"
26
+ }