Spaces:
Sleeping
Sleeping
| {% extends 'base.html' %} | |
| {% load static %} | |
| {% load i18n %} | |
| {% block title %}{% trans "Virtual Body Scan - Virtual Fitting System" %}{% endblock %} | |
| {% block content %} | |
| <div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12"> | |
| <div class="text-center mb-8"> | |
| <h1 class="text-4xl font-bold text-gray-900 mb-4">{% trans "Virtual Body Scan" %}</h1> | |
| <p id="scan-subtitle" class="text-xl text-gray-600">{% trans "Two images needed: full-body pose + face selfie" %}</p> | |
| </div> | |
| <!-- Gender Selector --> | |
| <div class="flex justify-center mb-8"> | |
| <div class="inline-flex bg-gray-100 rounded-xl p-1.5 shadow-inner"> | |
| <button id="gender-men" onclick="switchGender('men')" | |
| class="flex items-center gap-2 px-8 py-3 rounded-lg font-semibold text-sm transition-all duration-300 bg-white text-[#1B3A6B] shadow-md"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> | |
| </svg> | |
| {% trans "Men" %} | |
| </button> | |
| <button id="gender-women" onclick="switchGender('women')" | |
| class="flex items-center gap-2 px-8 py-3 rounded-lg font-semibold text-sm transition-all duration-300 text-gray-500 hover:text-gray-700"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path> | |
| </svg> | |
| {% trans "Women" %} | |
| </button> | |
| </div> | |
| </div> | |
| <!-- ===== MEN FLOW ===== --> | |
| <div id="men-flow"> | |
| <!-- Height Input --> | |
| <div class="max-w-md mx-auto mb-8"> | |
| <div class="bg-white rounded-2xl shadow-lg p-6 border border-[#7DB8D8]/40"> | |
| <label for="user-height" class="block text-sm font-semibold text-gray-700 mb-2"> | |
| {% trans "Your Height" %} <span class="text-red-500">*</span> | |
| <span class="text-gray-400 font-normal">({% trans "required for accurate measurements" %})</span> | |
| </label> | |
| <div class="flex items-center gap-3"> | |
| <div class="relative flex-1"> | |
| <input type="number" id="user-height" min="100" max="250" step="1" placeholder="{% trans 'e.g. 175' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium text-gray-800 placeholder-gray-400" | |
| oninput="validateHeightInput()"> | |
| <span class="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 font-medium">cm</span> | |
| </div> | |
| <div id="height-status" class="hidden"> | |
| <span | |
| class="inline-flex items-center justify-center w-8 h-8 rounded-full bg-green-100 text-green-600"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M5 13l4 4L19 7"> | |
| </path> | |
| </svg> | |
| </span> | |
| </div> | |
| </div> | |
| <p id="height-error" class="hidden text-red-500 text-sm mt-2">{% trans "Please enter a valid height between 100 and 250 cm." %}</p> | |
| <p class="text-gray-400 text-xs mt-2">{% trans "Your height is used to calibrate YOLO body measurements for accuracy." %}</p> | |
| </div> | |
| </div> | |
| <!-- Mode Selector Tabs --> | |
| <div class="flex justify-center mb-8"> | |
| <div class="inline-flex bg-gray-100 rounded-xl p-1.5 shadow-inner"> | |
| <button id="tab-camera" onclick="switchMode('camera')" | |
| class="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-300 bg-white text-[#1B3A6B] shadow-md"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"> | |
| </path> | |
| </svg> | |
| {% trans "Camera Scan" %} | |
| </button> | |
| <button id="tab-upload" onclick="switchMode('upload')" | |
| class="flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-300 text-gray-500 hover:text-gray-700"> | |
| <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"> | |
| </path> | |
| </svg> | |
| {% trans "Upload Images" %} | |
| </button> | |
| </div> | |
| </div> | |
| <!-- ===== CAMERA MODE ===== --> | |
| <div id="camera-mode"> | |
| <!-- Progress Steps: 3 steps now (Body → Face → Process) --> | |
| <div class="mb-12"> | |
| <div class="flex items-center justify-center space-x-2 md:space-x-4 flex-wrap gap-y-4"> | |
| <div id="step1" class="flex items-center"> | |
| <div | |
| class="w-10 h-10 bg-[#1B3A6B] text-white rounded-full flex items-center justify-center font-bold"> | |
| 1</div> | |
| <span class="ml-2 font-medium text-gray-900">{% trans "Body Pose" %}</span> | |
| </div> | |
| <div class="w-8 md:w-16 h-1 bg-gray-300"></div> | |
| <div id="step2" class="flex items-center opacity-50"> | |
| <div | |
| class="w-10 h-10 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center font-bold"> | |
| 2</div> | |
| <span class="ml-2 font-medium text-gray-600">{% trans "Face Selfie" %}</span> | |
| </div> | |
| <div class="w-8 md:w-16 h-1 bg-gray-300"></div> | |
| <div id="step3" class="flex items-center opacity-50"> | |
| <div | |
| class="w-10 h-10 bg-gray-300 text-gray-600 rounded-full flex items-center justify-center font-bold"> | |
| 3</div> | |
| <span class="ml-2 font-medium text-gray-600">{% trans "Processing" %}</span> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Camera Interface --> | |
| <div class="bg-white rounded-2xl shadow-xl p-8"> | |
| <div id="camera-container" class="mb-6 relative"> | |
| <div class="relative bg-gray-900 rounded-xl overflow-hidden" style="aspect-ratio: 4/3;"> | |
| <video id="video" autoplay playsinline | |
| class="w-full h-full object-cover transform scale-x-[-1]"></video> | |
| <canvas id="canvas" class="hidden"></canvas> | |
| <canvas id="overlay-canvas" class="absolute inset-0 pointer-events-none w-full h-full"></canvas> | |
| <!-- Visual Guide – Full Body --> | |
| <div id="visual-guide-body" | |
| class="absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-70 transition-all duration-300"> | |
| <div id="head-guide" | |
| class="w-32 h-40 border-4 border-dashed border-white/70 rounded-full mb-2 transition-colors duration-300"> | |
| </div> | |
| <div id="body-guide" | |
| class="w-80 h-96 border-4 border-dashed border-white/70 rounded-3xl rounded-t-lg transition-colors duration-300"> | |
| </div> | |
| </div> | |
| <!-- Visual Guide – Face Selfie --> | |
| <div id="visual-guide-face" | |
| class="hidden absolute inset-0 flex flex-col items-center justify-center pointer-events-none opacity-70 transition-all duration-300"> | |
| <div id="face-guide" | |
| class="w-64 h-80 border-4 border-dashed border-amber-400/80 rounded-full transition-colors duration-300"> | |
| </div> | |
| <p class="text-amber-400 font-bold mt-4 text-lg bg-black/40 px-4 py-2 rounded-lg"> | |
| {% trans "Come close! Fill the circle with your face" %} | |
| </p> | |
| </div> | |
| <!-- Countdown Overlay --> | |
| <div id="countdown-overlay" | |
| class="hidden absolute inset-0 flex items-center justify-center pointer-events-none z-40"> | |
| <div class="text-center"> | |
| <div id="countdown-number" | |
| class="text-9xl font-black text-white drop-shadow-2xl animate-pulse"></div> | |
| <p class="text-2xl font-bold text-white mt-4 bg-green-600/80 px-6 py-2 rounded-full">{% | |
| trans "Hold still!" %}</p> | |
| </div> | |
| </div> | |
| <!-- Capture Flash --> | |
| <div id="capture-flash" | |
| class="hidden absolute inset-0 bg-white pointer-events-none z-50 animate-flash"></div> | |
| <!-- Status Panel --> | |
| <div id="status-panel" | |
| class="absolute top-4 left-0 right-0 flex justify-center pointer-events-none"> | |
| <div id="status-badge" | |
| class="bg-black/60 backdrop-blur-md text-white px-6 py-2 rounded-full flex items-center gap-2 transition-all duration-300"> | |
| <div id="status-indicator" class="w-3 h-3 rounded-full bg-yellow-400 animate-pulse"> | |
| </div> | |
| <span id="status-text" class="font-medium">{% trans "Initializing camera..." %}</span> | |
| </div> | |
| </div> | |
| <!-- Auto-Capture Progress Bar --> | |
| <div id="auto-capture-bar" class="hidden absolute bottom-4 left-4 right-4 pointer-events-none"> | |
| <div class="bg-black/50 rounded-full p-1"> | |
| <div id="capture-progress" | |
| class="h-2 rounded-full bg-gradient-to-r from-green-400 to-emerald-500 transition-all duration-100" | |
| style="width: 0%"></div> | |
| </div> | |
| <p class="text-center text-white text-sm mt-2 font-medium">{% trans "Auto-capturing when position is correct..." %}</p> | |
| </div> | |
| <!-- Loading spinner --> | |
| <div id="loading" | |
| class="hidden absolute inset-0 bg-black/70 flex items-center justify-center z-50"> | |
| <div class="text-center"> | |
| <div | |
| class="animate-spin rounded-full h-16 w-16 border-t-4 border-b-4 border-white mx-auto mb-4"> | |
| </div> | |
| <p class="text-white text-lg">{% trans "Analysing with YOLO + AI..." %}</p> | |
| <p class="text-gray-300 text-sm mt-2">{% trans "Detecting pose, measuring body, detecting skin tone..." %}</p> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Instructions --> | |
| <div id="instructions" class="bg-[#EAF3F8] rounded-xl p-6 mb-6"> | |
| <h3 class="font-bold text-lg text-[#1B3A6B] mb-3">{% trans "Step 1: Body Pose (Auto-Capture)" %} | |
| </h3> | |
| <ul class="space-y-2 text-[#2E5FA3]"> | |
| <li>✓ {% trans "Stand 2-3 metres away from the camera" %}</li> | |
| <li>✓ {% trans "Align your full body with the guide outline" %}</li> | |
| <li>✓ {% trans "Stand straight with arms slightly away from your body" %}</li> | |
| <li>⚡ <strong>{% trans "Image captures automatically when you're positioned correctly!" %}</strong></li> | |
| </ul> | |
| </div> | |
| <!-- Preview Images --> | |
| <div id="preview-container" class="hidden mb-6"> | |
| <h3 class="font-bold text-lg text-gray-900 mb-3">{% trans "Captured Images:" %}</h3> | |
| <div class="grid grid-cols-2 gap-4"> | |
| <div id="front-preview-container" class="hidden"> | |
| <p class="text-sm text-gray-600 mb-2">{% trans "Body Pose" %} ✓</p> | |
| <img id="front-preview" class="w-full rounded-lg border-2 border-green-400" | |
| alt="{% trans 'Body pose' %}"> | |
| </div> | |
| <div id="face-preview-container" class="hidden"> | |
| <p class="text-sm text-gray-600 mb-2">{% trans "Face Selfie" %} ✓</p> | |
| <img id="face-preview" class="w-full rounded-lg border-2 border-amber-400" | |
| alt="{% trans 'Face selfie' %}"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Error Message --> | |
| <div id="error-message" class="hidden bg-red-50 border-2 border-red-200 rounded-xl p-4 mb-6"> | |
| <p class="text-red-800 font-medium"></p> | |
| </div> | |
| <!-- Action Buttons --> | |
| <div class="flex gap-4 justify-center flex-wrap"> | |
| <button id="process-scan" | |
| class="hidden bg-gradient-to-r from-emerald-600 to-teal-600 text-white px-8 py-4 rounded-lg font-bold text-lg hover:shadow-lg transition transform hover:scale-105"> | |
| {% trans "Process Scan" %} | |
| </button> | |
| <button id="retake" | |
| class="hidden bg-gray-500 text-white px-6 py-3 rounded-lg font-medium hover:bg-gray-600 transition"> | |
| {% trans "Start Over" %} | |
| </button> | |
| </div> | |
| </div> | |
| </div><!-- /camera-mode --> | |
| <!-- ===== UPLOAD MODE ===== --> | |
| <div id="upload-mode" class="hidden"> | |
| <div class="bg-white rounded-2xl shadow-xl p-8"> | |
| <div class="text-center mb-6"> | |
| <h2 class="text-2xl font-bold text-gray-900 mb-2">{% trans "Upload Body Images" %}</h2> | |
| <p class="text-gray-500">{% trans "Upload a full-body photo AND a face selfie for AI analysis" %} | |
| </p> | |
| </div> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8"> | |
| <!-- Front / Body Image Upload --> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-2"> | |
| {% trans "Body Pose" %} <span class="text-red-500">*</span> | |
| <span class="text-gray-400 font-normal">({% trans "required – full body, front view" %})</span> | |
| </label> | |
| <div id="front-dropzone" | |
| class="relative border-2 border-dashed border-[#5B8FC9] rounded-xl p-8 text-center cursor-pointer transition-all duration-300 hover:border-[#1B3A6B] hover:bg-[#EAF3F8]/50 group" | |
| onclick="document.getElementById('front-file-input').click()" | |
| ondragover="handleDragOver(event,'front-dropzone')" | |
| ondragleave="handleDragLeave(event,'front-dropzone')" ondrop="handleDrop(event,'front')"> | |
| <input type="file" id="front-file-input" accept="image/*" class="hidden" | |
| onchange="handleFileSelect(event,'front')"> | |
| <div id="front-upload-placeholder"> | |
| <div | |
| class="w-16 h-16 mx-auto mb-4 bg-[#EAF3F8] rounded-full flex items-center justify-center group-hover:bg-[#7DB8D8]/30 transition-colors"> | |
| <svg class="w-8 h-8 text-[#5B8FC9]" fill="none" stroke="currentColor" | |
| viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"> | |
| </path> | |
| </svg> | |
| </div> | |
| <p class="text-gray-600 font-medium">{% trans "Drop body photo here" %}</p> | |
| <p class="text-gray-400 text-sm mt-1">{% trans "or click to browse" %}</p> | |
| <p class="text-gray-400 text-xs mt-3">{% trans "Stand facing the camera, full body visible" %}</p> | |
| </div> | |
| <div id="front-upload-preview" class="hidden"> | |
| <img id="front-upload-img" class="max-h-64 mx-auto rounded-lg shadow-md" | |
| alt="{% trans 'Body pose' %}"> | |
| <p class="text-green-600 font-medium mt-3">✓ {% trans "Body image ready" %}</p> | |
| <button type="button" onclick="event.stopPropagation(); removeUpload('front')" | |
| class="mt-2 text-sm text-red-500 hover:text-red-700 underline">{% trans "Remove" %}</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Face / Selfie Image Upload --> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-2"> | |
| {% trans "Face Selfie" %} <span class="text-red-500">*</span> | |
| <span class="text-gray-400 font-normal">({% trans "required – close-up face for skin tone" %})</span> | |
| </label> | |
| <div id="face-dropzone" | |
| class="relative border-2 border-dashed border-amber-300 rounded-xl p-8 text-center cursor-pointer transition-all duration-300 hover:border-amber-500 hover:bg-amber-50/50 group" | |
| onclick="document.getElementById('face-file-input').click()" | |
| ondragover="handleDragOver(event,'face-dropzone')" | |
| ondragleave="handleDragLeave(event,'face-dropzone')" ondrop="handleDrop(event,'face')"> | |
| <input type="file" id="face-file-input" accept="image/*" class="hidden" | |
| onchange="handleFileSelect(event,'face')"> | |
| <div id="face-upload-placeholder"> | |
| <div | |
| class="w-16 h-16 mx-auto mb-4 bg-amber-100 rounded-full flex items-center justify-center group-hover:bg-amber-200 transition-colors"> | |
| <svg class="w-8 h-8 text-amber-500" fill="none" stroke="currentColor" | |
| viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"> | |
| </path> | |
| </svg> | |
| </div> | |
| <p class="text-gray-600 font-medium">{% trans "Drop face selfie here" %}</p> | |
| <p class="text-gray-400 text-sm mt-1">{% trans "or click to browse" %}</p> | |
| <p class="text-gray-400 text-xs mt-3">{% trans "Close-up of your face, good lighting" %} | |
| </p> | |
| </div> | |
| <div id="face-upload-preview" class="hidden"> | |
| <img id="face-upload-img" class="max-h-64 mx-auto rounded-lg shadow-md" | |
| alt="{% trans 'Face selfie' %}"> | |
| <p class="text-amber-600 font-medium mt-3">✓ {% trans "Face image ready" %}</p> | |
| <button type="button" onclick="event.stopPropagation(); removeUpload('face')" | |
| class="mt-2 text-sm text-red-500 hover:text-red-700 underline">{% trans "Remove" %}</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Upload Tips --> | |
| <div class="bg-[#EAF3F8] rounded-xl p-5 mb-6"> | |
| <h3 class="font-bold text-[#1B3A6B] mb-2">{% trans "Tips for best results:" %}</h3> | |
| <ul class="space-y-1.5 text-sm text-[#2E5FA3]"> | |
| <li>✓ {% trans "Body photo: full body visible from head to toe, good lighting" %}</li> | |
| <li>✓ {% trans "Body photo: stand straight with arms slightly away from body" %}</li> | |
| <li>✓ {% trans "Face selfie: close-up, face fills most of the frame" %}</li> | |
| <li>✓ {% trans "Face selfie: natural lighting, no heavy filters" %}</li> | |
| </ul> | |
| </div> | |
| <!-- Upload Error --> | |
| <div id="upload-error" class="hidden bg-red-50 border-2 border-red-200 rounded-xl p-4 mb-6"> | |
| <p class="text-red-800 font-medium"></p> | |
| </div> | |
| <!-- Upload Loading --> | |
| <div id="upload-loading" class="hidden bg-gray-900/80 rounded-xl p-8 mb-6"> | |
| <div class="text-center"> | |
| <div | |
| class="animate-spin rounded-full h-12 w-12 border-t-4 border-b-4 border-[#5B8FC9] mx-auto mb-4"> | |
| </div> | |
| <p class="text-white text-lg font-medium">{% trans "Analysing with YOLO + AI..." %}</p> | |
| <p class="text-gray-300 text-sm mt-2">{% trans "Detecting pose, measuring body, detecting skin tone..." %}</p> | |
| </div> | |
| </div> | |
| <!-- Upload Action Button --> | |
| <div class="flex justify-center"> | |
| <button id="upload-process-btn" onclick="processUpload()" disabled | |
| class="bg-gradient-to-r from-[#1B3A6B] to-[#2E5FA3] text-white px-10 py-4 rounded-xl font-bold text-lg shadow-lg transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed hover:shadow-xl hover:scale-105 disabled:hover:scale-100 disabled:hover:shadow-lg"> | |
| {% trans "Process Scan" %} | |
| </button> | |
| </div> | |
| </div> | |
| </div><!-- /upload-mode --> | |
| </div><!-- /men-flow --> | |
| <!-- ===== WOMEN FLOW ===== --> | |
| <div id="women-flow" class="hidden"> | |
| <div class="bg-white rounded-2xl shadow-xl p-8"> | |
| <div class="text-center mb-6"> | |
| <h2 class="text-2xl font-bold text-gray-900 mb-2">{% trans "Enter Your Measurements" %}</h2> | |
| <p class="text-gray-500">{% trans "Enter your body measurements and upload a hand photo for skin tone" %}</p> | |
| </div> | |
| <!-- Required Measurements --> | |
| <div class="mb-6"> | |
| <h3 class="font-bold text-lg text-[#1B3A6B] mb-4 flex items-center gap-2"> | |
| <span | |
| class="w-6 h-6 bg-red-100 text-red-600 rounded-full flex items-center justify-center text-xs font-bold">*</span> | |
| {% trans "Required Measurements" %} | |
| </h3> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Height" %} (cm) <span | |
| class="text-red-500">*</span></label> | |
| <input type="number" id="w-height" min="100" max="250" step="1" placeholder="{% trans 'e.g. 165' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Bust / Chest" %} (cm) | |
| <span class="text-red-500">*</span></label> | |
| <input type="number" id="w-chest" min="50" max="200" step="0.5" placeholder="{% trans 'e.g. 88' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Waist" %} (cm) <span | |
| class="text-red-500">*</span></label> | |
| <input type="number" id="w-waist" min="40" max="200" step="0.5" placeholder="{% trans 'e.g. 68' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Hip" %} (cm) <span | |
| class="text-red-500">*</span></label> | |
| <input type="number" id="w-hip" min="50" max="200" step="0.5" placeholder="{% trans 'e.g. 96' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Optional Measurements --> | |
| <div class="mb-6"> | |
| <h3 class="font-bold text-lg text-gray-600 mb-4 flex items-center gap-2"> | |
| <span | |
| class="w-6 h-6 bg-gray-100 text-gray-500 rounded-full flex items-center justify-center text-xs">+</span> | |
| {% trans "Optional Measurements" %} | |
| <span class="text-sm font-normal text-gray-400">({% trans "better accuracy" %})</span> | |
| </h3> | |
| <div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Shoulder Width" %} | |
| (cm)</label> | |
| <input type="number" id="w-shoulder" min="20" max="70" step="0.5" placeholder="{% trans 'e.g. 38' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Inseam" %} (cm)</label> | |
| <input type="number" id="w-inseam" min="40" max="110" step="0.5" placeholder="{% trans 'e.g. 76' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Arm Length" %} | |
| (cm)</label> | |
| <input type="number" id="w-arm" min="30" max="100" step="0.5" placeholder="{% trans 'e.g. 56' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| <div> | |
| <label class="block text-sm font-semibold text-gray-700 mb-1">{% trans "Torso Length" %} | |
| (cm)</label> | |
| <input type="number" id="w-torso" min="20" max="80" step="0.5" placeholder="{% trans 'e.g. 42' %}" | |
| class="w-full px-4 py-3 rounded-xl border-2 border-gray-200 focus:border-[#5B8FC9] focus:ring-2 focus:ring-[#5B8FC9]/30 outline-none transition-all text-lg font-medium"> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Hand Image Upload --> | |
| <div class="mb-6"> | |
| <h3 class="font-bold text-lg text-[#1B3A6B] mb-4 flex items-center gap-2"> | |
| <span | |
| class="w-6 h-6 bg-red-100 text-red-600 rounded-full flex items-center justify-center text-xs font-bold">*</span> | |
| {% trans "Hand Photo for Skin Tone" %} | |
| </h3> | |
| <div id="hand-dropzone" | |
| class="relative border-2 border-dashed border-amber-300 rounded-xl p-8 text-center cursor-pointer transition-all duration-300 hover:border-amber-500 hover:bg-amber-50/50 group" | |
| onclick="document.getElementById('hand-file-input').click()" | |
| ondragover="handleDragOver(event,'hand-dropzone')" | |
| ondragleave="handleDragLeave(event,'hand-dropzone')" ondrop="handleDropWomen(event)"> | |
| <input type="file" id="hand-file-input" accept="image/*" class="hidden" | |
| onchange="handleHandFileSelect(event)"> | |
| <div id="hand-upload-placeholder"> | |
| <div | |
| class="w-16 h-16 mx-auto mb-4 bg-amber-100 rounded-full flex items-center justify-center group-hover:bg-amber-200 transition-colors"> | |
| <svg class="w-8 h-8 text-amber-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> | |
| <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" | |
| d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"> | |
| </path> | |
| </svg> | |
| </div> | |
| <p class="text-gray-600 font-medium">{% trans "Drop hand photo here" %}</p> | |
| <p class="text-gray-400 text-sm mt-1">{% trans "or click to browse" %}</p> | |
| <p class="text-gray-400 text-xs mt-3">{% trans "Photo of the back of your hand, good lighting, no filters" %}</p> | |
| </div> | |
| <div id="hand-upload-preview" class="hidden"> | |
| <img id="hand-upload-img" class="max-h-64 mx-auto rounded-lg shadow-md" alt="Hand photo"> | |
| <p class="text-amber-600 font-medium mt-3">✓ {% trans "Hand image ready" %}</p> | |
| <button type="button" onclick="event.stopPropagation(); removeHandUpload()" | |
| class="mt-2 text-sm text-red-500 hover:text-red-700 underline">{% trans "Remove" %}</button> | |
| </div> | |
| </div> | |
| </div> | |
| <!-- Tips --> | |
| <div class="bg-[#EAF3F8] rounded-xl p-5 mb-6"> | |
| <h3 class="font-bold text-[#1B3A6B] mb-2">{% trans "How to measure:" %}</h3> | |
| <ul class="space-y-1.5 text-sm text-[#2E5FA3]"> | |
| <li>✓ {% trans "Use a soft measuring tape for best results" %}</li> | |
| <li>✓ {% trans "Bust: measure around the fullest part of your chest" %}</li> | |
| <li>✓ {% trans "Waist: measure around your natural waistline" %}</li> | |
| <li>✓ {% trans "Hip: measure around the widest part of your hips" %}</li> | |
| <li>⚡ {% trans "Hand photo: back of hand, natural light, no makeup or filters" %}</li> | |
| </ul> | |
| </div> | |
| <!-- Women Error --> | |
| <div id="women-error" class="hidden bg-red-50 border-2 border-red-200 rounded-xl p-4 mb-6"> | |
| <p class="text-red-800 font-medium"></p> | |
| </div> | |
| <!-- Women Loading --> | |
| <div id="women-loading" class="hidden bg-gray-900/80 rounded-xl p-8 mb-6"> | |
| <div class="text-center"> | |
| <div | |
| class="animate-spin rounded-full h-12 w-12 border-t-4 border-b-4 border-[#5B8FC9] mx-auto mb-4"> | |
| </div> | |
| <p class="text-white text-lg font-medium">{% trans "Analysing with AI..." %}</p> | |
| <p class="text-gray-400 text-sm mt-2">{% trans "Detecting skin tone, recommending size..." %}</p> | |
| </div> | |
| </div> | |
| <!-- Women Process Button --> | |
| <div class="flex justify-center"> | |
| <button id="women-process-btn" onclick="processWomenScan()" disabled | |
| class="bg-gradient-to-r from-[#1B3A6B] to-[#2E5FA3] text-white px-10 py-4 rounded-xl font-bold text-lg shadow-lg transition-all duration-300 disabled:opacity-40 disabled:cursor-not-allowed hover:shadow-xl hover:scale-105 disabled:hover:scale-100 disabled:hover:shadow-lg"> | |
| {% trans "Get My Size Recommendation" %} | |
| </button> | |
| </div> | |
| </div> | |
| </div><!-- /women-flow --> | |
| </div> | |
| <style> | |
| @keyframes flash { | |
| 0% { | |
| opacity: 1; | |
| } | |
| 100% { | |
| opacity: 0; | |
| } | |
| } | |
| .animate-flash { | |
| animation: flash 0.3s ease-out forwards; | |
| } | |
| @keyframes pulse-border { | |
| 0%, | |
| 100% { | |
| border-color: rgba(34, 197, 94, 0.8); | |
| } | |
| 50% { | |
| border-color: rgba(34, 197, 94, 0.4); | |
| } | |
| } | |
| .pulse-border-green { | |
| animation: pulse-border 1s ease-in-out infinite; | |
| } | |
| </style> | |
| <script src="{% static 'js/camera.js' %}"></script> | |
| {% endblock %} | |
| {% block extra_js %} | |
| <script> | |
| window.LANG_PREFIX = "{% if request.LANGUAGE_CODE == 'ar' %}/ar{% endif %}"; | |
| window.SCAN_I18N = { | |
| statusInit: "{% trans 'Initializing camera...' %}", | |
| statusAlign: "{% trans 'Align your body with the guide' %}", | |
| statusNoPerson: "{% trans 'No person detected – step into frame' %}", | |
| statusReady: "{% trans 'Ready to process your scan!' %}", | |
| statusCameraDenied: "{% trans 'Camera access denied' %}", | |
| errorCamera: "{% trans 'Camera access denied. Please allow camera access.' %}", | |
| errorBodyImage: "{% trans 'Please capture a body pose image.' %}", | |
| errorFaceImage: "{% trans 'Please capture a face selfie.' %}", | |
| errorHeight: "{% trans 'Please enter a valid height (100-250 cm) before processing.' %}", | |
| errorProcess: "{% trans 'Processing failed. Please try again.' %}", | |
| errorNetwork: "{% trans 'Network error. Please check your connection and try again.' %}", | |
| errorUploadBody: "{% trans 'Please upload a body pose image.' %}", | |
| errorUploadFace: "{% trans 'Please upload a face selfie image.' %}", | |
| errorUploadHeight: "{% trans 'Please enter a valid height (100-250 cm) before processing.' %}", | |
| step1Title: "{% trans 'Step 1: Body Pose (Auto-Capture)' %}", | |
| step1_1: "{% trans 'Stand 2-3 metres away from the camera' %}", | |
| step1_2: "{% trans 'Align your full body with the guide outline' %}", | |
| step1_3: "{% trans 'Stand straight with arms slightly away from your body' %}", | |
| step1_4: "{% trans 'Image captures automatically when you\'re positioned correctly!' %}", | |
| step2Title: "{% trans 'Step 2: Face Selfie (Auto-Capture)' %}", | |
| step2_1: "{% trans 'Come close to the camera like a selfie!' %}", | |
| step2_2: "{% trans 'Your face should fill most of the circle' %}", | |
| step2_3: "{% trans 'Position your face in the centre of the guide' %}", | |
| step2_4: "{% trans 'Ensure good, natural lighting on your face' %}", | |
| step2_5: "{% trans 'Image captures automatically when you\'re close enough!' %}", | |
| step3Title: "{% trans 'Step 3: Ready to Process' %}", | |
| step3_1: "{% trans 'Both images captured successfully!' %}", | |
| step3_2: "{% trans 'Click Process Scan to analyse your measurements & skin tone' %}", | |
| step3_3: "{% trans 'You can start over if needed' %}", | |
| subtitleMen: "{% trans 'Two images needed: full-body pose + face selfie' %}", | |
| subtitleWomen: "{% trans 'Enter your measurements and upload a hand photo' %}", | |
| errorWomenHeight: "{% trans 'Please enter a valid height between 100 and 250 cm.' %}", | |
| errorWomenBust: "{% trans 'Please enter your bust/chest measurement.' %}", | |
| errorWomenWaist: "{% trans 'Please enter your waist measurement.' %}", | |
| errorWomenHip: "{% trans 'Please enter your hip measurement.' %}", | |
| errorWomenHand: "{% trans 'Please upload a hand photo for skin tone detection.' %}" | |
| }; | |
| </script> | |
| <script> | |
| // ===== STATE ===== | |
| let video, canvas, context, overlayCanvas, overlayContext; | |
| let frontImageData = null; // body pose image | |
| let faceImageData = null; // face selfie image | |
| let currentStep = 1; | |
| let analysisInterval = null; | |
| let isAnalyzing = false; | |
| let goodPoseStartTime = null; | |
| const AUTO_CAPTURE_DELAY = 2000; | |
| let countdownActive = false; | |
| let countdownValue = 3; | |
| document.addEventListener('DOMContentLoaded', function () { | |
| video = document.getElementById('video'); | |
| canvas = document.getElementById('canvas'); | |
| context = canvas.getContext('2d'); | |
| overlayCanvas = document.getElementById('overlay-canvas'); | |
| overlayContext = overlayCanvas.getContext('2d'); | |
| window.addEventListener('resize', resizeOverlay); | |
| initCamera(); | |
| document.getElementById('process-scan').addEventListener('click', processScan); | |
| document.getElementById('retake').addEventListener('click', retake); | |
| }); | |
| // ===== CAMERA INIT ===== | |
| async function initCamera() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { width: { ideal: 1280 }, height: { ideal: 960 }, facingMode: 'user' } | |
| }); | |
| video.srcObject = stream; | |
| video.onloadedmetadata = () => { | |
| resizeOverlay(); | |
| startRealTimeAnalysis(); | |
| }; | |
| } catch (error) { | |
| showError(window.SCAN_I18N ? window.SCAN_I18N.errorCamera : 'Camera access denied. Please allow camera access.'); | |
| updateStatus('error', window.SCAN_I18N ? window.SCAN_I18N.statusCameraDenied : 'Camera access denied'); | |
| } | |
| } | |
| function resizeOverlay() { | |
| const preview = document.querySelector('#camera-container .relative'); | |
| if (!preview) return; | |
| overlayCanvas.width = Math.max(1, Math.round(preview.clientWidth)); | |
| overlayCanvas.height = Math.max(1, Math.round(preview.clientHeight)); | |
| } | |
| // ===== REAL-TIME ANALYSIS ===== | |
| function startRealTimeAnalysis() { | |
| if (isAnalyzing) return; | |
| isAnalyzing = true; | |
| goodPoseStartTime = null; | |
| updateStatus('warning', window.SCAN_I18N ? window.SCAN_I18N.statusAlign : 'Align your body with the guide'); | |
| document.getElementById('auto-capture-bar').classList.remove('hidden'); | |
| analysisInterval = setInterval(async () => { | |
| if (!isAnalyzing || video.paused || video.ended || countdownActive) return; | |
| const analysisCanvas = document.createElement('canvas'); | |
| analysisCanvas.width = 320; | |
| analysisCanvas.height = 240; | |
| const ctx = analysisCanvas.getContext('2d'); | |
| ctx.drawImage(video, 0, 0, 320, 240); | |
| const imageData = analysisCanvas.toDataURL('image/jpeg', 0.6); | |
| try { | |
| const analysisMode = currentStep === 2 ? 'face' : 'body'; | |
| const response = await fetch('{% url "fitting_system:analyze_frame" %}', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image: imageData, mode: analysisMode }) | |
| }); | |
| const result = await response.json(); | |
| handleAnalysisResult(result); | |
| } catch (error) { | |
| console.error('Analysis frame error:', error); | |
| } | |
| }, 200); | |
| } | |
| function stopAnalysis() { | |
| isAnalyzing = false; | |
| goodPoseStartTime = null; | |
| if (analysisInterval) { clearInterval(analysisInterval); analysisInterval = null; } | |
| overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); | |
| document.getElementById('auto-capture-bar').classList.add('hidden'); | |
| document.getElementById('capture-progress').style.width = '0%'; | |
| } | |
| function handleAnalysisResult(result) { | |
| if (!result.detected) { | |
| updateStatus('error', window.SCAN_I18N ? window.SCAN_I18N.statusNoPerson : 'No person detected – step into frame'); | |
| clearLandmarks(); | |
| goodPoseStartTime = null; | |
| updateCaptureProgress(0); | |
| return; | |
| } | |
| updateStatus(result.status, result.message); | |
| if (result.landmarks && result.landmarks.length > 0) { | |
| drawLandmarks(result.landmarks, result.status); | |
| } | |
| if (result.status === 'good') { | |
| if (!goodPoseStartTime) goodPoseStartTime = Date.now(); | |
| const elapsed = Date.now() - goodPoseStartTime; | |
| const progress = Math.min(100, (elapsed / AUTO_CAPTURE_DELAY) * 100); | |
| updateCaptureProgress(progress); | |
| updateGuideColor('good'); | |
| if (elapsed >= AUTO_CAPTURE_DELAY && !countdownActive) startCountdown(); | |
| } else { | |
| goodPoseStartTime = null; | |
| updateCaptureProgress(0); | |
| updateGuideColor(result.status); | |
| } | |
| } | |
| function updateCaptureProgress(p) { | |
| document.getElementById('capture-progress').style.width = p + '%'; | |
| } | |
| function updateGuideColor(status) { | |
| const headGuide = document.getElementById('head-guide'); | |
| const bodyGuide = document.getElementById('body-guide'); | |
| const faceGuide = document.getElementById('face-guide'); | |
| [headGuide, bodyGuide, faceGuide].forEach(el => { | |
| if (el) el.classList.remove('border-green-400', 'border-yellow-400', 'border-red-400', | |
| 'pulse-border-green', 'border-white/70', 'border-amber-400/80'); | |
| }); | |
| if (status === 'good') { | |
| headGuide && headGuide.classList.add('border-green-400', 'pulse-border-green'); | |
| bodyGuide && bodyGuide.classList.add('border-green-400', 'pulse-border-green'); | |
| faceGuide && faceGuide.classList.add('border-green-400', 'pulse-border-green'); | |
| } else if (status === 'warning') { | |
| headGuide && headGuide.classList.add('border-yellow-400'); | |
| bodyGuide && bodyGuide.classList.add('border-yellow-400'); | |
| faceGuide && faceGuide.classList.add('border-amber-400/80'); | |
| } else { | |
| headGuide && headGuide.classList.add('border-white/70'); | |
| bodyGuide && bodyGuide.classList.add('border-white/70'); | |
| faceGuide && faceGuide.classList.add('border-amber-400/80'); | |
| } | |
| } | |
| function startCountdown() { | |
| countdownActive = true; | |
| countdownValue = 3; | |
| const overlay = document.getElementById('countdown-overlay'); | |
| const num = document.getElementById('countdown-number'); | |
| overlay.classList.remove('hidden'); | |
| num.textContent = countdownValue; | |
| const interval = setInterval(() => { | |
| countdownValue--; | |
| if (countdownValue > 0) { | |
| num.textContent = countdownValue; | |
| } else { | |
| clearInterval(interval); | |
| overlay.classList.add('hidden'); | |
| triggerAutoCapture(); | |
| } | |
| }, 1000); | |
| } | |
| function triggerAutoCapture() { | |
| const flash = document.getElementById('capture-flash'); | |
| flash.classList.remove('hidden'); | |
| setTimeout(() => flash.classList.add('hidden'), 300); | |
| if (currentStep === 1) captureBodyImage(); | |
| else if (currentStep === 2) captureFaceImage(); | |
| countdownActive = false; | |
| } | |
| // ===== CAPTURE FUNCTIONS ===== | |
| function captureImage() { | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| context.save(); | |
| context.scale(-1, 1); | |
| context.drawImage(video, -canvas.width, 0, canvas.width, canvas.height); | |
| context.restore(); | |
| return canvas.toDataURL('image/jpeg', 0.9); | |
| } | |
| function captureBodyImage() { | |
| stopAnalysis(); | |
| frontImageData = captureImage(); | |
| document.getElementById('front-preview').src = frontImageData; | |
| document.getElementById('preview-container').classList.remove('hidden'); | |
| document.getElementById('front-preview-container').classList.remove('hidden'); | |
| // Move to face selfie step | |
| moveToFaceStep(); | |
| } | |
| function moveToFaceStep() { | |
| // Switch visual guides | |
| document.getElementById('visual-guide-body').classList.add('hidden'); | |
| document.getElementById('visual-guide-face').classList.remove('hidden'); | |
| updateStep(2); | |
| var i = window.SCAN_I18N || {}; | |
| document.getElementById('instructions').innerHTML = ` | |
| <h3 class="font-bold text-lg text-amber-700 mb-3">` + (i.step2Title || 'Step 2: Face Selfie (Auto-Capture)') + `</h3> | |
| <ul class="space-y-2 text-amber-800"> | |
| <li>✓ <strong>` + (i.step2_1 || 'Come close to the camera like a selfie!') + `</strong></li> | |
| <li>✓ ` + (i.step2_2 || 'Your face should fill most of the circle') + `</li> | |
| <li>✓ ` + (i.step2_3 || 'Position your face in the centre of the guide') + `</li> | |
| <li>✓ ` + (i.step2_4 || 'Ensure good, natural lighting on your face') + `</li> | |
| <li>⚡ <strong>` + (i.step2_5 || "Image captures automatically when you're close enough!") + `</strong></li> | |
| </ul> | |
| `; | |
| setTimeout(() => startRealTimeAnalysis(), 500); | |
| } | |
| function captureFaceImage() { | |
| stopAnalysis(); | |
| faceImageData = captureImage(); | |
| document.getElementById('face-preview').src = faceImageData; | |
| document.getElementById('face-preview-container').classList.remove('hidden'); | |
| // Hide face guide | |
| document.getElementById('visual-guide-face').classList.add('hidden'); | |
| // Show process button | |
| document.getElementById('process-scan').classList.remove('hidden'); | |
| document.getElementById('retake').classList.remove('hidden'); | |
| updateStep(3); | |
| var i3 = window.SCAN_I18N || {}; | |
| document.getElementById('instructions').innerHTML = ` | |
| <h3 class="font-bold text-lg text-emerald-700 mb-3">` + (i3.step3Title || 'Step 3: Ready to Process') + `</h3> | |
| <ul class="space-y-2 text-emerald-800"> | |
| <li>✓ ` + (i3.step3_1 || 'Both images captured successfully!') + `</li> | |
| <li>✓ ` + (i3.step3_2 || 'Click "Process Scan" to analyse your measurements & skin tone') + `</li> | |
| <li>✓ ` + (i3.step3_3 || 'You can start over if needed') + `</li> | |
| </ul> | |
| `; | |
| updateStatus('good', window.SCAN_I18N ? window.SCAN_I18N.statusReady : 'Ready to process your scan!'); | |
| } | |
| // ===== PROCESS SCAN ===== | |
| function getValidatedHeight() { | |
| const heightInput = document.getElementById('user-height'); | |
| const val = parseFloat(heightInput.value); | |
| if (isNaN(val) || val < 100 || val > 250) { | |
| return null; | |
| } | |
| return val; | |
| } | |
| function validateHeightInput() { | |
| const heightInput = document.getElementById('user-height'); | |
| const val = parseFloat(heightInput.value); | |
| const errorEl = document.getElementById('height-error'); | |
| const statusEl = document.getElementById('height-status'); | |
| if (heightInput.value === '') { | |
| errorEl.classList.add('hidden'); | |
| statusEl.classList.add('hidden'); | |
| return; | |
| } | |
| if (isNaN(val) || val < 100 || val > 250) { | |
| errorEl.classList.remove('hidden'); | |
| statusEl.classList.add('hidden'); | |
| heightInput.classList.add('border-red-400'); | |
| heightInput.classList.remove('border-green-400'); | |
| } else { | |
| errorEl.classList.add('hidden'); | |
| statusEl.classList.remove('hidden'); | |
| heightInput.classList.remove('border-red-400'); | |
| heightInput.classList.add('border-green-400'); | |
| } | |
| } | |
| async function processScan() { | |
| var i = window.SCAN_I18N || {}; | |
| if (!frontImageData) { showError(i.errorBodyImage || 'Please capture a body pose image.'); return; } | |
| if (!faceImageData) { showError(i.errorFaceImage || 'Please capture a face selfie.'); return; } | |
| const userHeight = getValidatedHeight(); | |
| if (!userHeight) { | |
| showError(i.errorHeight || 'Please enter a valid height (100-250 cm) before processing.'); | |
| document.getElementById('user-height').focus(); | |
| return; | |
| } | |
| document.getElementById('loading').classList.remove('hidden'); | |
| document.getElementById('process-scan').disabled = true; | |
| try { | |
| const response = await fetch('{% url "fitting_system:process_scan" %}', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| front_image: frontImageData, | |
| face_image: faceImageData, | |
| user_height_cm: userHeight, | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| window.location.href = `${window.LANG_PREFIX || ''}/recommendations/${data.session_id}/`; | |
| } else { | |
| showError(data.error || (window.SCAN_I18N && window.SCAN_I18N.errorProcess) || 'Processing failed. Please try again.'); | |
| document.getElementById('loading').classList.add('hidden'); | |
| document.getElementById('process-scan').disabled = false; | |
| } | |
| } catch (error) { | |
| showError(window.SCAN_I18N && window.SCAN_I18N.errorNetwork ? window.SCAN_I18N.errorNetwork : 'Network error. Please check your connection and try again.'); | |
| document.getElementById('loading').classList.add('hidden'); | |
| document.getElementById('process-scan').disabled = false; | |
| } | |
| } | |
| // ===== RETAKE ===== | |
| function retake() { | |
| frontImageData = null; | |
| faceImageData = null; | |
| currentStep = 1; | |
| goodPoseStartTime = null; | |
| countdownActive = false; | |
| document.getElementById('preview-container').classList.add('hidden'); | |
| document.getElementById('front-preview-container').classList.add('hidden'); | |
| document.getElementById('face-preview-container').classList.add('hidden'); | |
| document.getElementById('process-scan').classList.add('hidden'); | |
| document.getElementById('retake').classList.add('hidden'); | |
| document.getElementById('error-message').classList.add('hidden'); | |
| document.getElementById('visual-guide-body').classList.remove('hidden'); | |
| document.getElementById('visual-guide-face').classList.add('hidden'); | |
| var i1 = window.SCAN_I18N || {}; | |
| document.getElementById('instructions').innerHTML = ` | |
| <h3 class="font-bold text-lg text-[#1B3A6B] mb-3">` + (i1.step1Title || 'Step 1: Body Pose (Auto-Capture)') + `</h3> | |
| <ul class="space-y-2 text-[#2E5FA3]"> | |
| <li>✓ ` + (i1.step1_1 || 'Stand 2-3 metres away from the camera') + `</li> | |
| <li>✓ ` + (i1.step1_2 || 'Align your full body with the guide outline') + `</li> | |
| <li>✓ ` + (i1.step1_3 || 'Stand straight with arms slightly away from your body') + `</li> | |
| <li>⚡ <strong>` + (i1.step1_4 || "Image captures automatically when you're positioned correctly!") + `</strong></li> | |
| </ul> | |
| `; | |
| updateStep(1); | |
| startRealTimeAnalysis(); | |
| } | |
| // ===== STATUS + STEP HELPERS ===== | |
| function updateStatus(status, message) { | |
| const badge = document.getElementById('status-badge'); | |
| const indicator = document.getElementById('status-indicator'); | |
| const text = document.getElementById('status-text'); | |
| text.textContent = message; | |
| indicator.className = 'w-3 h-3 rounded-full animate-pulse transition-colors duration-300'; | |
| badge.className = 'backdrop-blur-md px-6 py-2 rounded-full flex items-center gap-2 transition-all duration-300 border-2'; | |
| if (status === 'good') { | |
| indicator.classList.add('bg-green-400'); | |
| badge.classList.add('bg-green-900/60', 'border-green-400'); | |
| } else if (status === 'warning') { | |
| indicator.classList.add('bg-yellow-400'); | |
| badge.classList.add('bg-yellow-900/60', 'border-yellow-400'); | |
| } else { | |
| indicator.classList.add('bg-red-500'); | |
| badge.classList.add('bg-red-900/60', 'border-red-500'); | |
| } | |
| } | |
| function updateStep(step) { | |
| currentStep = step; | |
| const totalSteps = 3; | |
| for (let i = 1; i <= totalSteps; i++) { | |
| const stepEl = document.getElementById(`step${i}`); | |
| if (!stepEl) continue; | |
| if (i < step) { | |
| stepEl.classList.remove('opacity-50'); | |
| stepEl.querySelector('div').classList.remove('bg-gray-300', 'text-gray-600', 'bg-[#1B3A6B]'); | |
| stepEl.querySelector('div').classList.add('bg-green-500', 'text-white'); | |
| stepEl.querySelector('span').classList.remove('text-gray-600'); | |
| stepEl.querySelector('span').classList.add('text-gray-900'); | |
| } else if (i === step) { | |
| stepEl.classList.remove('opacity-50'); | |
| stepEl.querySelector('div').classList.remove('bg-gray-300', 'text-gray-600', 'bg-green-500'); | |
| stepEl.querySelector('div').classList.add('bg-[#1B3A6B]', 'text-white'); | |
| stepEl.querySelector('span').classList.remove('text-gray-600'); | |
| stepEl.querySelector('span').classList.add('text-gray-900'); | |
| } else { | |
| stepEl.classList.add('opacity-50'); | |
| stepEl.querySelector('div').classList.remove('bg-[#1B3A6B]', 'bg-green-500', 'text-white'); | |
| stepEl.querySelector('div').classList.add('bg-gray-300', 'text-gray-600'); | |
| stepEl.querySelector('span').classList.remove('text-gray-900'); | |
| stepEl.querySelector('span').classList.add('text-gray-600'); | |
| } | |
| } | |
| } | |
| function showError(message) { | |
| const errorEl = document.getElementById('error-message'); | |
| errorEl.querySelector('p').textContent = message; | |
| errorEl.classList.remove('hidden'); | |
| } | |
| // ===== LANDMARK DRAWING ===== | |
| const POSE_CONNECTIONS = [ | |
| [11, 12], [11, 13], [13, 15], [12, 14], [14, 16], | |
| [11, 23], [12, 24], [23, 24], [23, 25], [25, 27], [24, 26], [26, 28] | |
| ]; | |
| function drawLandmarks(landmarks, status) { | |
| overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); | |
| let color = '#34d399'; | |
| if (status === 'warning') color = '#fbbf24'; | |
| if (status === 'error') color = '#ef4444'; | |
| overlayContext.strokeStyle = color; | |
| overlayContext.lineWidth = 4; | |
| overlayContext.lineCap = 'round'; | |
| overlayContext.lineJoin = 'round'; | |
| overlayContext.shadowBlur = 15; | |
| overlayContext.shadowColor = color; | |
| POSE_CONNECTIONS.forEach(([i, j]) => { | |
| const lm1 = landmarks[i], lm2 = landmarks[j]; | |
| if (lm1 && lm2 && lm1.x !== undefined && lm2.x !== undefined) { | |
| const x1 = (1 - lm1.x) * overlayCanvas.width; | |
| const y1 = lm1.y * overlayCanvas.height; | |
| const x2 = (1 - lm2.x) * overlayCanvas.width; | |
| const y2 = lm2.y * overlayCanvas.height; | |
| overlayContext.beginPath(); | |
| overlayContext.moveTo(x1, y1); | |
| overlayContext.lineTo(x2, y2); | |
| overlayContext.stroke(); | |
| } | |
| }); | |
| const keyJoints = [11, 12, 13, 14, 15, 16, 23, 24, 25, 26, 27, 28, 0]; | |
| landmarks.forEach((lm, index) => { | |
| if (!keyJoints.includes(index)) return; | |
| const x = (1 - lm.x) * overlayCanvas.width; | |
| const y = lm.y * overlayCanvas.height; | |
| overlayContext.beginPath(); | |
| overlayContext.fillStyle = color; | |
| overlayContext.arc(x, y, 6, 0, 2 * Math.PI); | |
| overlayContext.fill(); | |
| overlayContext.beginPath(); | |
| overlayContext.fillStyle = '#FFFFFF'; | |
| overlayContext.arc(x, y, 3, 0, 2 * Math.PI); | |
| overlayContext.fill(); | |
| }); | |
| overlayContext.shadowBlur = 0; | |
| } | |
| function clearLandmarks() { | |
| overlayContext.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); | |
| } | |
| // ===== MODE SWITCH ===== | |
| let currentMode = 'camera'; | |
| function switchMode(mode) { | |
| currentMode = mode; | |
| const cameraMode = document.getElementById('camera-mode'); | |
| const uploadMode = document.getElementById('upload-mode'); | |
| const tabCamera = document.getElementById('tab-camera'); | |
| const tabUpload = document.getElementById('tab-upload'); | |
| if (mode === 'camera') { | |
| cameraMode.classList.remove('hidden'); | |
| uploadMode.classList.add('hidden'); | |
| tabCamera.className = 'flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-300 bg-white text-[#1B3A6B] shadow-md'; | |
| tabUpload.className = 'flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-300 text-gray-500 hover:text-gray-700'; | |
| if (video && video.srcObject) video.play(); | |
| setTimeout(resizeOverlay, 0); | |
| } else { | |
| cameraMode.classList.add('hidden'); | |
| uploadMode.classList.remove('hidden'); | |
| tabCamera.className = 'flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-300 text-gray-500 hover:text-gray-700'; | |
| tabUpload.className = 'flex items-center gap-2 px-6 py-3 rounded-lg font-semibold text-sm transition-all duration-300 bg-white text-[#1B3A6B] shadow-md'; | |
| if (video && video.srcObject) video.pause(); | |
| stopAnalysis(); | |
| } | |
| } | |
| // ===== UPLOAD MODE FUNCTIONS ===== | |
| let uploadFrontData = null; | |
| let uploadFaceData = null; | |
| function handleDragOver(e, dropzoneId) { | |
| e.preventDefault(); e.stopPropagation(); | |
| document.getElementById(dropzoneId).classList.add('border-[#1B3A6B]', 'bg-[#EAF3F8]'); | |
| } | |
| function handleDragLeave(e, dropzoneId) { | |
| e.preventDefault(); e.stopPropagation(); | |
| document.getElementById(dropzoneId).classList.remove('border-[#1B3A6B]', 'bg-[#EAF3F8]'); | |
| } | |
| function handleDrop(e, type) { | |
| e.preventDefault(); e.stopPropagation(); | |
| const dropzoneId = type + '-dropzone'; | |
| document.getElementById(dropzoneId).classList.remove('border-[#1B3A6B]', 'bg-[#EAF3F8]'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0 && files[0].type.startsWith('image/')) readFileAsBase64(files[0], type); | |
| } | |
| function handleFileSelect(e, type) { | |
| const files = e.target.files; | |
| if (files.length > 0) readFileAsBase64(files[0], type); | |
| } | |
| function readFileAsBase64(file, type) { | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| const base64Data = e.target.result; | |
| if (type === 'front') { | |
| uploadFrontData = base64Data; | |
| document.getElementById('front-upload-img').src = base64Data; | |
| document.getElementById('front-upload-placeholder').classList.add('hidden'); | |
| document.getElementById('front-upload-preview').classList.remove('hidden'); | |
| } else if (type === 'face') { | |
| uploadFaceData = base64Data; | |
| document.getElementById('face-upload-img').src = base64Data; | |
| document.getElementById('face-upload-placeholder').classList.add('hidden'); | |
| document.getElementById('face-upload-preview').classList.remove('hidden'); | |
| } | |
| updateUploadButton(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function removeUpload(type) { | |
| if (type === 'front') { | |
| uploadFrontData = null; | |
| document.getElementById('front-upload-placeholder').classList.remove('hidden'); | |
| document.getElementById('front-upload-preview').classList.add('hidden'); | |
| document.getElementById('front-file-input').value = ''; | |
| } else if (type === 'face') { | |
| uploadFaceData = null; | |
| document.getElementById('face-upload-placeholder').classList.remove('hidden'); | |
| document.getElementById('face-upload-preview').classList.add('hidden'); | |
| document.getElementById('face-file-input').value = ''; | |
| } | |
| updateUploadButton(); | |
| } | |
| function updateUploadButton() { | |
| // Both images required | |
| document.getElementById('upload-process-btn').disabled = !(uploadFrontData && uploadFaceData); | |
| } | |
| async function processUpload() { | |
| var i = window.SCAN_I18N || {}; | |
| if (!uploadFrontData) { showUploadError(i.errorUploadBody || 'Please upload a body pose image.'); return; } | |
| if (!uploadFaceData) { showUploadError(i.errorUploadFace || 'Please upload a face selfie image.'); return; } | |
| const userHeight = getValidatedHeight(); | |
| if (!userHeight) { | |
| showUploadError(i.errorUploadHeight || i.errorHeight || 'Please enter a valid height (100-250 cm) before processing.'); | |
| document.getElementById('user-height').focus(); | |
| return; | |
| } | |
| document.getElementById('upload-loading').classList.remove('hidden'); | |
| document.getElementById('upload-process-btn').disabled = true; | |
| document.getElementById('upload-error').classList.add('hidden'); | |
| try { | |
| const response = await fetch('{% url "fitting_system:process_scan" %}', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| front_image: uploadFrontData, | |
| face_image: uploadFaceData, | |
| user_height_cm: userHeight, | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| window.location.href = `${window.LANG_PREFIX || ''}/recommendations/${data.session_id}/`; | |
| } else { | |
| showUploadError(data.error || (window.SCAN_I18N && window.SCAN_I18N.errorProcess) || 'Processing failed. Please try again.'); | |
| document.getElementById('upload-loading').classList.add('hidden'); | |
| document.getElementById('upload-process-btn').disabled = false; | |
| } | |
| } catch (error) { | |
| showUploadError(window.SCAN_I18N && window.SCAN_I18N.errorNetwork ? window.SCAN_I18N.errorNetwork : 'Network error. Please check your connection and try again.'); | |
| document.getElementById('upload-loading').classList.add('hidden'); | |
| document.getElementById('upload-process-btn').disabled = false; | |
| } | |
| } | |
| function showUploadError(message) { | |
| const errorEl = document.getElementById('upload-error'); | |
| errorEl.querySelector('p').textContent = message; | |
| errorEl.classList.remove('hidden'); | |
| } | |
| // ===== GENDER SWITCH ===== | |
| let selectedGender = 'men'; | |
| function switchGender(gender) { | |
| selectedGender = gender; | |
| const menFlow = document.getElementById('men-flow'); | |
| const womenFlow = document.getElementById('women-flow'); | |
| const btnMen = document.getElementById('gender-men'); | |
| const btnWomen = document.getElementById('gender-women'); | |
| const subtitle = document.getElementById('scan-subtitle'); | |
| const activeClass = 'flex items-center gap-2 px-8 py-3 rounded-lg font-semibold text-sm transition-all duration-300 bg-white text-[#1B3A6B] shadow-md'; | |
| const inactiveClass = 'flex items-center gap-2 px-8 py-3 rounded-lg font-semibold text-sm transition-all duration-300 text-gray-500 hover:text-gray-700'; | |
| if (gender === 'men') { | |
| menFlow.classList.remove('hidden'); | |
| womenFlow.classList.add('hidden'); | |
| btnMen.className = activeClass; | |
| btnWomen.className = inactiveClass; | |
| subtitle.textContent = (window.SCAN_I18N && window.SCAN_I18N.subtitleMen) || 'Two images needed: full-body pose + face selfie'; | |
| setTimeout(resizeOverlay, 0); | |
| } else { | |
| menFlow.classList.add('hidden'); | |
| womenFlow.classList.remove('hidden'); | |
| btnMen.className = inactiveClass; | |
| btnWomen.className = activeClass; | |
| subtitle.textContent = (window.SCAN_I18N && window.SCAN_I18N.subtitleWomen) || 'Enter your measurements and upload a hand photo'; | |
| if (video && video.srcObject) video.pause(); | |
| stopAnalysis(); | |
| } | |
| } | |
| // ===== WOMEN FLOW ===== | |
| let uploadHandData = null; | |
| function handleDropWomen(e) { | |
| e.preventDefault(); e.stopPropagation(); | |
| document.getElementById('hand-dropzone').classList.remove('border-[#1B3A6B]', 'bg-[#EAF3F8]'); | |
| const files = e.dataTransfer.files; | |
| if (files.length > 0 && files[0].type.startsWith('image/')) readHandFile(files[0]); | |
| } | |
| function handleHandFileSelect(e) { | |
| const files = e.target.files; | |
| if (files.length > 0) readHandFile(files[0]); | |
| } | |
| function readHandFile(file) { | |
| const reader = new FileReader(); | |
| reader.onload = function (e) { | |
| uploadHandData = e.target.result; | |
| document.getElementById('hand-upload-img').src = uploadHandData; | |
| document.getElementById('hand-upload-placeholder').classList.add('hidden'); | |
| document.getElementById('hand-upload-preview').classList.remove('hidden'); | |
| updateWomenButton(); | |
| }; | |
| reader.readAsDataURL(file); | |
| } | |
| function removeHandUpload() { | |
| uploadHandData = null; | |
| document.getElementById('hand-upload-placeholder').classList.remove('hidden'); | |
| document.getElementById('hand-upload-preview').classList.add('hidden'); | |
| document.getElementById('hand-file-input').value = ''; | |
| updateWomenButton(); | |
| } | |
| function updateWomenButton() { | |
| const height = parseFloat(document.getElementById('w-height').value); | |
| const chest = parseFloat(document.getElementById('w-chest').value); | |
| const waist = parseFloat(document.getElementById('w-waist').value); | |
| const hip = parseFloat(document.getElementById('w-hip').value); | |
| const hasRequired = !isNaN(height) && !isNaN(chest) && !isNaN(waist) && !isNaN(hip) && uploadHandData; | |
| document.getElementById('women-process-btn').disabled = !hasRequired; | |
| } | |
| // Listen for input changes on women's required fields | |
| document.addEventListener('DOMContentLoaded', function () { | |
| ['w-height', 'w-chest', 'w-waist', 'w-hip'].forEach(function (id) { | |
| var el = document.getElementById(id); | |
| if (el) el.addEventListener('input', updateWomenButton); | |
| }); | |
| }); | |
| function showWomenError(message) { | |
| const errorEl = document.getElementById('women-error'); | |
| errorEl.querySelector('p').textContent = message; | |
| errorEl.classList.remove('hidden'); | |
| } | |
| async function processWomenScan() { | |
| document.getElementById('women-error').classList.add('hidden'); | |
| const height = parseFloat(document.getElementById('w-height').value); | |
| const chest = parseFloat(document.getElementById('w-chest').value); | |
| const waist = parseFloat(document.getElementById('w-waist').value); | |
| const hip = parseFloat(document.getElementById('w-hip').value); | |
| var i = window.SCAN_I18N || {}; | |
| if (isNaN(height) || height < 100 || height > 250) { | |
| showWomenError(i.errorWomenHeight || 'Please enter a valid height between 100 and 250 cm.'); | |
| return; | |
| } | |
| if (isNaN(chest)) { showWomenError(i.errorWomenBust || 'Please enter your bust/chest measurement.'); return; } | |
| if (isNaN(waist)) { showWomenError(i.errorWomenWaist || 'Please enter your waist measurement.'); return; } | |
| if (isNaN(hip)) { showWomenError(i.errorWomenHip || 'Please enter your hip measurement.'); return; } | |
| if (!uploadHandData) { showWomenError(i.errorWomenHand || 'Please upload a hand photo for skin tone detection.'); return; } | |
| const measurements = { height, chest, waist, hip }; | |
| // Add optional fields if provided | |
| const shoulder = parseFloat(document.getElementById('w-shoulder').value); | |
| const inseam = parseFloat(document.getElementById('w-inseam').value); | |
| const arm = parseFloat(document.getElementById('w-arm').value); | |
| const torso = parseFloat(document.getElementById('w-torso').value); | |
| if (!isNaN(shoulder)) measurements.shoulder_width = shoulder; | |
| if (!isNaN(inseam)) measurements.inseam = inseam; | |
| if (!isNaN(arm)) measurements.arm_length = arm; | |
| if (!isNaN(torso)) measurements.torso_length = torso; | |
| document.getElementById('women-loading').classList.remove('hidden'); | |
| document.getElementById('women-process-btn').disabled = true; | |
| try { | |
| const response = await fetch('{% url "fitting_system:process_scan_women" %}', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| hand_image: uploadHandData, | |
| measurements: measurements, | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| window.location.href = `${window.LANG_PREFIX || ''}/recommendations/${data.session_id}/`; | |
| } else { | |
| showWomenError(data.error || i.errorProcess || 'Processing failed. Please try again.'); | |
| document.getElementById('women-loading').classList.add('hidden'); | |
| document.getElementById('women-process-btn').disabled = false; | |
| } | |
| } catch (error) { | |
| showWomenError(i.errorNetwork || 'Network error. Please check your connection and try again.'); | |
| document.getElementById('women-loading').classList.add('hidden'); | |
| document.getElementById('women-process-btn').disabled = false; | |
| } | |
| } | |
| </script> | |
| {% endblock %} | |