Upload 49 files
Browse files- .gitattributes +35 -35
- README.md +9 -10
- assets/example_embryo.jpg +0 -0
- assets/ismo.png +0 -0
- assets/ismo7.png +0 -0
- index.html +158 -19
- login.html +414 -0
- models/README.md +54 -0
- models/classifier_model_compressed/config.json +46 -0
- models/classifier_model_compressed/model.onnx +3 -0
- models/classifier_model_compressed/preprocessor_config.json +24 -0
- models/grader_model_compressed/config.json +90 -0
- models/grader_model_compressed/model.onnx +3 -0
- models/grader_model_compressed/preprocessor_config.json +24 -0
- models/poor_good_compressed/config.json +46 -0
- models/poor_good_compressed/model.onnx +3 -0
- models/poor_good_compressed/preprocessor_config.json +24 -0
- models/yolo-cropper/best.onnx +3 -0
- package-lock.json +164 -0
- package.json +39 -0
- server.js +103 -0
- src/README.md +208 -0
- src/config.js +42 -0
- src/main.js +130 -0
- src/models/inference.js +74 -0
- src/models/modelLoader.js +74 -0
- src/models/yoloDetection.js +29 -0
- src/processing/imagePreprocessing.js +60 -0
- src/processing/postprocessing.js +41 -0
- src/processing/yoloPostprocessing.js +113 -0
- src/state.js +111 -0
- src/ui/cropEditor.js +604 -0
- src/ui/eventHandlers.js +78 -0
- src/ui/imageDisplay.js +75 -0
- src/ui/inlineDetection.js +766 -0
- src/ui/loading.js +35 -0
- src/ui/modal.js +148 -0
- src/ui/quickEval.js +49 -0
- src/ui/results.js +249 -0
- src/ui/stepper.js +368 -0
- src/ui/tabs.js +26 -0
- src/ui/toast.js +18 -0
- src/ui/workflow.js +642 -0
- src/ui/zoom.js +54 -0
- src/utils/firebaseService.js +182 -0
- src/utils/imageUtils.js +53 -0
- src/utils/mathUtils.js +27 -0
- styles.css +1611 -0
- test.html +58 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,35 @@
|
|
| 1 |
-
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
-
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
-
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
-
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
-
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
-
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
-
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
-
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
-
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
-
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
-
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
-
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
-
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
-
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
-
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
-
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
-
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
-
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
-
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
-
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
-
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
-
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
-
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
-
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
-
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
-
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
-
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
-
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
-
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
-
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
-
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
-
*.xz 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
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz 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
|
README.md
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Embryo One
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Embryo One With Cropping JS final
|
| 3 |
+
colorFrom: green
|
| 4 |
+
colorTo: purple
|
| 5 |
+
sdk: static
|
| 6 |
+
pinned: false
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
assets/example_embryo.jpg
ADDED
|
assets/ismo.png
ADDED
|
assets/ismo7.png
ADDED
|
index.html
CHANGED
|
@@ -1,19 +1,158 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Embryo Grading System - AI-Powered Analysis</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="stylesheet" href="styles.css">
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div class="container">
|
| 14 |
+
<header>
|
| 15 |
+
<img src="assets/ismo7.png" alt="Logo" class="logo">
|
| 16 |
+
<div class="user-menu" id="userMenu" style="display: none;">
|
| 17 |
+
<span class="user-email" id="userEmail"></span>
|
| 18 |
+
<button class="btn btn-secondary" id="logoutBtn" style="margin-left: 15px;">Logout</button>
|
| 19 |
+
</div>
|
| 20 |
+
</header>
|
| 21 |
+
|
| 22 |
+
<div class="loading-overlay" id="loadingOverlay">
|
| 23 |
+
<div class="loading-content">
|
| 24 |
+
<div class="spinner"></div>
|
| 25 |
+
<p id="loadingText">Initializing AI models...</p>
|
| 26 |
+
<div class="progress-container">
|
| 27 |
+
<div class="progress-bar" id="progressBar"></div>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
</div>
|
| 31 |
+
|
| 32 |
+
<!-- Stepper Navigation -->
|
| 33 |
+
<div class="stepper-container">
|
| 34 |
+
<div class="stepper">
|
| 35 |
+
<div class="step" data-step="1">
|
| 36 |
+
<div class="step-circle">1</div>
|
| 37 |
+
<div class="step-label">Upload & Classify</div>
|
| 38 |
+
</div>
|
| 39 |
+
<div class="step-line"></div>
|
| 40 |
+
<div class="step" data-step="2">
|
| 41 |
+
<div class="step-circle">2</div>
|
| 42 |
+
<div class="step-label">Detect & Select</div>
|
| 43 |
+
</div>
|
| 44 |
+
<div class="step-line"></div>
|
| 45 |
+
<div class="step" data-step="3">
|
| 46 |
+
<div class="step-circle">3</div>
|
| 47 |
+
<div class="step-label">Results</div>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<!-- Main Workflow -->
|
| 53 |
+
<div id="workflow" class="tab-content active">
|
| 54 |
+
<!-- Step 1: Upload & Classify -->
|
| 55 |
+
<div class="card step-content active" id="step1" data-step="1">
|
| 56 |
+
<h2 class="section-title">Step 1: Upload & Classify Image</h2>
|
| 57 |
+
<div class="upload-section">
|
| 58 |
+
<div class="image-preview-container">
|
| 59 |
+
<h3>Image Preview</h3>
|
| 60 |
+
<div class="image-preview" id="mainImagePreview">
|
| 61 |
+
<div class="placeholder">
|
| 62 |
+
<svg width="100" height="100" viewBox="0 0 24 24" fill="none" stroke="currentColor">
|
| 63 |
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
| 64 |
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
|
| 65 |
+
<polyline points="21 15 16 10 5 21"></polyline>
|
| 66 |
+
</svg>
|
| 67 |
+
<p>No image uploaded</p>
|
| 68 |
+
</div>
|
| 69 |
+
<img id="mainImage" alt="Uploaded embryo" style="display:none;">
|
| 70 |
+
<canvas id="annotatedCanvas" style="display:none;"></canvas>
|
| 71 |
+
<div class="zoom-controls">
|
| 72 |
+
<button class="icon-button" id="zoomIn" disabled>
|
| 73 |
+
<span>Zoom In</span>
|
| 74 |
+
</button>
|
| 75 |
+
<button class="icon-button" id="zoomOut" disabled>
|
| 76 |
+
<span>Zoom Out</span>
|
| 77 |
+
</button>
|
| 78 |
+
<button class="icon-button" id="zoomReset" disabled>
|
| 79 |
+
<span>Reset</span>
|
| 80 |
+
</button>
|
| 81 |
+
<span class="zoom-level" id="zoomLevel">Zoom: 100%</span>
|
| 82 |
+
</div>
|
| 83 |
+
</div>
|
| 84 |
+
<input type="file" id="imageInput" accept="image/*" style="display: none;">
|
| 85 |
+
<button class="btn btn-secondary" onclick="document.getElementById('imageInput').click()">
|
| 86 |
+
Choose Image
|
| 87 |
+
</button>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div class="status-container">
|
| 91 |
+
<h3>Classification Status</h3>
|
| 92 |
+
<div id="classificationStatus" class="status-box">
|
| 93 |
+
<p>Upload an image to check if it contains an embryo.</p>
|
| 94 |
+
<p>The system will automatically classify the image upon upload.</p>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
</div>
|
| 98 |
+
<div class="step-navigation">
|
| 99 |
+
<button class="btn btn-secondary" id="startOverBtn">Start Over</button>
|
| 100 |
+
<button class="btn btn-primary" id="nextStep1" disabled>Next: Detect Embryos</button>
|
| 101 |
+
</div>
|
| 102 |
+
</div>
|
| 103 |
+
|
| 104 |
+
<!-- Step 2: Detect & Select -->
|
| 105 |
+
<div class="card step-content" id="step2" data-step="2">
|
| 106 |
+
<h2 class="section-title">Step 2: Detect & Select Embryos</h2>
|
| 107 |
+
|
| 108 |
+
<div class="detection-layout">
|
| 109 |
+
<div class="detection-preview-container">
|
| 110 |
+
<h3>Detected Embryos on Image</h3>
|
| 111 |
+
<p style="font-size: 0.9em; color: var(--text-secondary); margin-bottom: 15px;">
|
| 112 |
+
All detected embryos are shown with bounding boxes. Click on any box to select/edit that embryo.
|
| 113 |
+
</p>
|
| 114 |
+
<div class="image-preview-with-boxes" id="detectionImageContainer">
|
| 115 |
+
<canvas id="detectionCanvas"></canvas>
|
| 116 |
+
</div>
|
| 117 |
+
<div class="detection-info" id="detectionInfo">
|
| 118 |
+
<p>Click on an embryo to adjust its crop area</p>
|
| 119 |
+
<button class="btn btn-secondary" id="addManualEmbryoBtn" style="margin-top: 10px;">
|
| 120 |
+
+ Add Embryo Manually
|
| 121 |
+
</button>
|
| 122 |
+
</div>
|
| 123 |
+
</div>
|
| 124 |
+
|
| 125 |
+
<div class="embryo-list-container">
|
| 126 |
+
<h3>Detected Embryos List</h3>
|
| 127 |
+
<div id="embryoList" class="embryo-list">
|
| 128 |
+
<!-- Embryo cards will be populated here -->
|
| 129 |
+
</div>
|
| 130 |
+
</div>
|
| 131 |
+
</div>
|
| 132 |
+
<div class="step-navigation">
|
| 133 |
+
<button class="btn btn-secondary" id="prevStep2">Previous</button>
|
| 134 |
+
<button class="btn btn-primary" id="nextStep2" disabled>Analyze All Embryos</button>
|
| 135 |
+
</div>
|
| 136 |
+
</div>
|
| 137 |
+
|
| 138 |
+
<!-- Step 3: Results -->
|
| 139 |
+
<div class="card step-content" id="step3" data-step="3">
|
| 140 |
+
<h2 class="section-title">Step 3: Analysis Results</h2>
|
| 141 |
+
<div class="results-container">
|
| 142 |
+
<div id="finalResults" class="results-box">
|
| 143 |
+
<p>Processing all embryos...</p>
|
| 144 |
+
</div>
|
| 145 |
+
</div>
|
| 146 |
+
<div class="step-navigation">
|
| 147 |
+
<button class="btn btn-secondary" id="prevStep3">Back to Selection</button>
|
| 148 |
+
<button class="btn btn-primary" id="analyzeAnother">Analyze Another Image</button>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
</div>
|
| 153 |
+
|
| 154 |
+
<div id="toast" class="toast"></div>
|
| 155 |
+
|
| 156 |
+
<script type="module" src="src/main.js"></script>
|
| 157 |
+
</body>
|
| 158 |
+
</html>
|
login.html
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Login - Embryo Grading System</title>
|
| 7 |
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 8 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
| 9 |
+
<link href="https://fonts.googleapis.com/css2?family=EB+Garamond:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 10 |
+
<link rel="stylesheet" href="styles.css">
|
| 11 |
+
<style>
|
| 12 |
+
body {
|
| 13 |
+
display: flex;
|
| 14 |
+
justify-content: center;
|
| 15 |
+
align-items: center;
|
| 16 |
+
min-height: 100vh;
|
| 17 |
+
background: linear-gradient(135deg, #007B8F 0%, #004E64 100%);
|
| 18 |
+
margin: 0;
|
| 19 |
+
font-family: 'EB Garamond', serif;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
.login-container {
|
| 23 |
+
background: white;
|
| 24 |
+
border-radius: 20px;
|
| 25 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 26 |
+
padding: 50px 40px;
|
| 27 |
+
width: 100%;
|
| 28 |
+
max-width: 400px;
|
| 29 |
+
animation: slideUp 0.5s ease-out;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
@keyframes slideUp {
|
| 33 |
+
from {
|
| 34 |
+
opacity: 0;
|
| 35 |
+
transform: translateY(30px);
|
| 36 |
+
}
|
| 37 |
+
to {
|
| 38 |
+
opacity: 1;
|
| 39 |
+
transform: translateY(0);
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
.login-header {
|
| 44 |
+
text-align: center;
|
| 45 |
+
margin-bottom: 0px;
|
| 46 |
+
padding-bottom: 0px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.login-header img {
|
| 50 |
+
width: 200px;
|
| 51 |
+
height: 100px;
|
| 52 |
+
margin-bottom: 0px;
|
| 53 |
+
object-fit: contain;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.login-header p {
|
| 57 |
+
margin: 5px 0 0 0;
|
| 58 |
+
color: #666;
|
| 59 |
+
font-size: 18px;
|
| 60 |
+
font-weight: 400;
|
| 61 |
+
display: none;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.form-group {
|
| 65 |
+
margin-bottom: 25px;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.form-group label {
|
| 69 |
+
display: block;
|
| 70 |
+
margin-bottom: 8px;
|
| 71 |
+
color: #2c3e50;
|
| 72 |
+
font-weight: 600;
|
| 73 |
+
font-size: 16px;
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
.form-group input {
|
| 77 |
+
width: 100%;
|
| 78 |
+
padding: 14px 16px;
|
| 79 |
+
border: 2px solid #e0e0e0;
|
| 80 |
+
border-radius: 10px;
|
| 81 |
+
font-size: 15px;
|
| 82 |
+
font-family: 'EB Garamond', serif;
|
| 83 |
+
transition: all 0.3s ease;
|
| 84 |
+
box-sizing: border-box;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.form-group input:focus {
|
| 88 |
+
outline: none;
|
| 89 |
+
border-color: #007B8F;
|
| 90 |
+
box-shadow: 0 0 0 3px rgba(0, 123, 143, 0.2);
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
.login-btn {
|
| 94 |
+
width: 100%;
|
| 95 |
+
padding: 16px;
|
| 96 |
+
background: linear-gradient(135deg, #007B8F 0%, #004E64 100%);
|
| 97 |
+
color: white;
|
| 98 |
+
border: none;
|
| 99 |
+
border-radius: 12px;
|
| 100 |
+
font-size: 18px;
|
| 101 |
+
font-weight: 600;
|
| 102 |
+
font-family: 'EB Garamond', serif;
|
| 103 |
+
cursor: pointer;
|
| 104 |
+
transition: all 0.3s ease;
|
| 105 |
+
margin-top: 10px;
|
| 106 |
+
box-shadow: 0 4px 15px rgba(0, 123, 143, 0.3);
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.login-btn:hover {
|
| 110 |
+
background: linear-gradient(135deg, #004E64 0%, #003847 100%);
|
| 111 |
+
transform: translateY(-2px);
|
| 112 |
+
box-shadow: 0 6px 20px rgba(0, 123, 143, 0.4);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
.login-btn:active {
|
| 116 |
+
transform: translateY(0);
|
| 117 |
+
box-shadow: 0 2px 10px rgba(0, 123, 143, 0.3);
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.login-btn:disabled {
|
| 121 |
+
opacity: 0.6;
|
| 122 |
+
cursor: not-allowed;
|
| 123 |
+
transform: none;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.error-message {
|
| 127 |
+
background: #fee;
|
| 128 |
+
color: #c33;
|
| 129 |
+
padding: 12px 16px;
|
| 130 |
+
border-radius: 8px;
|
| 131 |
+
margin-bottom: 20px;
|
| 132 |
+
font-size: 14px;
|
| 133 |
+
display: none;
|
| 134 |
+
border-left: 4px solid #c33;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
.error-message.show {
|
| 138 |
+
display: block;
|
| 139 |
+
animation: shake 0.4s ease-in-out;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
@keyframes shake {
|
| 143 |
+
0%, 100% { transform: translateX(0); }
|
| 144 |
+
25% { transform: translateX(-10px); }
|
| 145 |
+
75% { transform: translateX(10px); }
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.spinner-container {
|
| 149 |
+
display: none;
|
| 150 |
+
justify-content: center;
|
| 151 |
+
align-items: center;
|
| 152 |
+
margin-top: 20px;
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.spinner-container.show {
|
| 156 |
+
display: flex;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
.spinner {
|
| 160 |
+
width: 40px;
|
| 161 |
+
height: 40px;
|
| 162 |
+
border: 4px solid #f3f3f3;
|
| 163 |
+
border-top: 4px solid #5dade2;
|
| 164 |
+
border-radius: 50%;
|
| 165 |
+
animation: spin 1s linear infinite;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
@keyframes spin {
|
| 169 |
+
0% { transform: rotate(0deg); }
|
| 170 |
+
100% { transform: rotate(360deg); }
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
.footer-text {
|
| 174 |
+
text-align: center;
|
| 175 |
+
margin-top: 30px;
|
| 176 |
+
color: #999;
|
| 177 |
+
font-size: 13px;
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
.divider {
|
| 181 |
+
display: flex;
|
| 182 |
+
align-items: center;
|
| 183 |
+
text-align: center;
|
| 184 |
+
margin: 25px 0;
|
| 185 |
+
color: #999;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.divider::before,
|
| 189 |
+
.divider::after {
|
| 190 |
+
content: '';
|
| 191 |
+
flex: 1;
|
| 192 |
+
border-bottom: 1px solid #e0e0e0;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.divider span {
|
| 196 |
+
padding: 0 15px;
|
| 197 |
+
font-size: 14px;
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
.google-btn {
|
| 201 |
+
width: 100%;
|
| 202 |
+
padding: 14px 16px;
|
| 203 |
+
background: white;
|
| 204 |
+
color: #333;
|
| 205 |
+
border: 2px solid #e0e0e0;
|
| 206 |
+
border-radius: 12px;
|
| 207 |
+
font-size: 16px;
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
font-family: 'EB Garamond', serif;
|
| 210 |
+
cursor: pointer;
|
| 211 |
+
transition: all 0.3s ease;
|
| 212 |
+
display: flex;
|
| 213 |
+
align-items: center;
|
| 214 |
+
justify-content: center;
|
| 215 |
+
gap: 12px;
|
| 216 |
+
margin-bottom: 10px;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.google-btn:hover {
|
| 220 |
+
background: #f8f9fa;
|
| 221 |
+
border-color: #007B8F;
|
| 222 |
+
transform: translateY(-2px);
|
| 223 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
.google-btn:active {
|
| 227 |
+
transform: translateY(0);
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.google-btn:disabled {
|
| 231 |
+
opacity: 0.6;
|
| 232 |
+
cursor: not-allowed;
|
| 233 |
+
transform: none;
|
| 234 |
+
}
|
| 235 |
+
|
| 236 |
+
.google-icon {
|
| 237 |
+
width: 20px;
|
| 238 |
+
height: 20px;
|
| 239 |
+
}
|
| 240 |
+
</style>
|
| 241 |
+
</head>
|
| 242 |
+
<body>
|
| 243 |
+
<div class="login-container">
|
| 244 |
+
<div class="login-header">
|
| 245 |
+
<img src="assets/ismo7.png" alt="Embryo Grading System Logo">
|
| 246 |
+
<p>Sign in to continue</p>
|
| 247 |
+
</div>
|
| 248 |
+
|
| 249 |
+
<div id="errorMessage" class="error-message"></div>
|
| 250 |
+
|
| 251 |
+
<button type="button" class="google-btn" id="googleSignInBtn">
|
| 252 |
+
<svg class="google-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
| 253 |
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
| 254 |
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
| 255 |
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
| 256 |
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
| 257 |
+
</svg>
|
| 258 |
+
Sign in with Google
|
| 259 |
+
</button>
|
| 260 |
+
|
| 261 |
+
<div class="divider">
|
| 262 |
+
<span>or</span>
|
| 263 |
+
</div>
|
| 264 |
+
|
| 265 |
+
<form id="loginForm">
|
| 266 |
+
<div class="form-group">
|
| 267 |
+
<label for="email">Email Address</label>
|
| 268 |
+
<input
|
| 269 |
+
type="email"
|
| 270 |
+
id="email"
|
| 271 |
+
name="email"
|
| 272 |
+
placeholder="Enter your email"
|
| 273 |
+
required
|
| 274 |
+
autocomplete="email"
|
| 275 |
+
>
|
| 276 |
+
</div>
|
| 277 |
+
|
| 278 |
+
<div class="form-group">
|
| 279 |
+
<label for="password">Password</label>
|
| 280 |
+
<input
|
| 281 |
+
type="password"
|
| 282 |
+
id="password"
|
| 283 |
+
name="password"
|
| 284 |
+
placeholder="Enter your password"
|
| 285 |
+
required
|
| 286 |
+
autocomplete="current-password"
|
| 287 |
+
>
|
| 288 |
+
</div>
|
| 289 |
+
|
| 290 |
+
<button type="submit" class="login-btn" id="loginBtn">
|
| 291 |
+
Sign In
|
| 292 |
+
</button>
|
| 293 |
+
</form>
|
| 294 |
+
|
| 295 |
+
<div class="spinner-container" id="loadingSpinner">
|
| 296 |
+
<div class="spinner"></div>
|
| 297 |
+
</div>
|
| 298 |
+
|
| 299 |
+
<div class="footer-text">
|
| 300 |
+
© 2025 Embryo Grading System
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
|
| 304 |
+
<script type="module">
|
| 305 |
+
import { signIn, signInWithGoogle } from './src/utils/firebaseService.js';
|
| 306 |
+
|
| 307 |
+
const loginForm = document.getElementById('loginForm');
|
| 308 |
+
const errorMessage = document.getElementById('errorMessage');
|
| 309 |
+
const loginBtn = document.getElementById('loginBtn');
|
| 310 |
+
const googleSignInBtn = document.getElementById('googleSignInBtn');
|
| 311 |
+
const loadingSpinner = document.getElementById('loadingSpinner');
|
| 312 |
+
|
| 313 |
+
// Google Sign-In
|
| 314 |
+
googleSignInBtn.addEventListener('click', async () => {
|
| 315 |
+
errorMessage.classList.remove('show');
|
| 316 |
+
googleSignInBtn.disabled = true;
|
| 317 |
+
googleSignInBtn.textContent = 'Signing in with Google...';
|
| 318 |
+
loadingSpinner.classList.add('show');
|
| 319 |
+
|
| 320 |
+
try {
|
| 321 |
+
await signInWithGoogle();
|
| 322 |
+
window.location.href = 'index.html';
|
| 323 |
+
} catch (error) {
|
| 324 |
+
console.error('Google sign-in failed:', error);
|
| 325 |
+
|
| 326 |
+
let errorText = 'Failed to sign in with Google.';
|
| 327 |
+
|
| 328 |
+
if (error.code === 'auth/popup-closed-by-user') {
|
| 329 |
+
errorText = 'Sign-in popup was closed. Please try again.';
|
| 330 |
+
} else if (error.code === 'auth/cancelled-popup-request') {
|
| 331 |
+
errorText = 'Sign-in cancelled.';
|
| 332 |
+
} else if (error.code === 'auth/popup-blocked') {
|
| 333 |
+
errorText = 'Popup blocked. Please enable popups for this site.';
|
| 334 |
+
} else if (error.code === 'auth/network-request-failed') {
|
| 335 |
+
errorText = 'Network error. Please check your connection.';
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
errorMessage.textContent = errorText;
|
| 339 |
+
errorMessage.classList.add('show');
|
| 340 |
+
|
| 341 |
+
googleSignInBtn.disabled = false;
|
| 342 |
+
googleSignInBtn.innerHTML = `
|
| 343 |
+
<svg class="google-icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
| 344 |
+
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
|
| 345 |
+
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
|
| 346 |
+
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z" fill="#FBBC05"/>
|
| 347 |
+
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
|
| 348 |
+
</svg>
|
| 349 |
+
Sign in with Google
|
| 350 |
+
`;
|
| 351 |
+
loadingSpinner.classList.remove('show');
|
| 352 |
+
}
|
| 353 |
+
});
|
| 354 |
+
|
| 355 |
+
// Email/Password Sign-In
|
| 356 |
+
loginForm.addEventListener('submit', async (e) => {
|
| 357 |
+
e.preventDefault();
|
| 358 |
+
|
| 359 |
+
const email = document.getElementById('email').value.trim();
|
| 360 |
+
const password = document.getElementById('password').value;
|
| 361 |
+
|
| 362 |
+
// Clear previous error
|
| 363 |
+
errorMessage.classList.remove('show');
|
| 364 |
+
|
| 365 |
+
// Show loading state
|
| 366 |
+
loginBtn.disabled = true;
|
| 367 |
+
loginBtn.textContent = 'Signing in...';
|
| 368 |
+
loadingSpinner.classList.add('show');
|
| 369 |
+
|
| 370 |
+
try {
|
| 371 |
+
await signIn(email, password);
|
| 372 |
+
// Redirect to main app on successful login
|
| 373 |
+
window.location.href = 'index.html';
|
| 374 |
+
} catch (error) {
|
| 375 |
+
console.error('Login failed:', error);
|
| 376 |
+
|
| 377 |
+
// Show error message
|
| 378 |
+
let errorText = 'Failed to sign in. Please check your credentials.';
|
| 379 |
+
|
| 380 |
+
if (error.code === 'auth/invalid-email') {
|
| 381 |
+
errorText = 'Invalid email address format.';
|
| 382 |
+
} else if (error.code === 'auth/user-not-found') {
|
| 383 |
+
errorText = 'No account found with this email.';
|
| 384 |
+
} else if (error.code === 'auth/wrong-password') {
|
| 385 |
+
errorText = 'Incorrect password.';
|
| 386 |
+
} else if (error.code === 'auth/too-many-requests') {
|
| 387 |
+
errorText = 'Too many failed attempts. Please try again later.';
|
| 388 |
+
} else if (error.code === 'auth/network-request-failed') {
|
| 389 |
+
errorText = 'Network error. Please check your connection.';
|
| 390 |
+
} else if (error.code === 'auth/invalid-credential') {
|
| 391 |
+
errorText = 'Invalid credentials. Please check your email and password.';
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
errorMessage.textContent = errorText;
|
| 395 |
+
errorMessage.classList.add('show');
|
| 396 |
+
|
| 397 |
+
// Reset button state
|
| 398 |
+
loginBtn.disabled = false;
|
| 399 |
+
loginBtn.textContent = 'Sign In';
|
| 400 |
+
loadingSpinner.classList.remove('show');
|
| 401 |
+
}
|
| 402 |
+
});
|
| 403 |
+
|
| 404 |
+
// Check if user is already logged in
|
| 405 |
+
import { onAuthChange } from './src/utils/firebaseService.js';
|
| 406 |
+
onAuthChange((user) => {
|
| 407 |
+
if (user) {
|
| 408 |
+
// User is already logged in, redirect to main app
|
| 409 |
+
window.location.href = 'index.html';
|
| 410 |
+
}
|
| 411 |
+
});
|
| 412 |
+
</script>
|
| 413 |
+
</body>
|
| 414 |
+
</html>
|
models/README.md
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Models Directory
|
| 2 |
+
|
| 3 |
+
This directory will contain the ONNX models after conversion.
|
| 4 |
+
|
| 5 |
+
## Structure
|
| 6 |
+
|
| 7 |
+
After running the conversion scripts, this directory will contain:
|
| 8 |
+
|
| 9 |
+
```
|
| 10 |
+
models/
|
| 11 |
+
βββ classifier_model_compressed/
|
| 12 |
+
β βββ model.onnx # ONNX model for embryo detection
|
| 13 |
+
β βββ config.json # Model configuration
|
| 14 |
+
β βββ preprocessor_config.json # Image preprocessing config
|
| 15 |
+
β
|
| 16 |
+
βββ poor_good_compressed/
|
| 17 |
+
β βββ model.onnx # ONNX model for quality assessment
|
| 18 |
+
β βββ config.json # Model configuration
|
| 19 |
+
β βββ preprocessor_config.json # Image preprocessing config
|
| 20 |
+
β
|
| 21 |
+
βββ grader_model_compressed/
|
| 22 |
+
β βββ model.onnx # ONNX model for Gardner grading
|
| 23 |
+
β βββ config.json # Model configuration
|
| 24 |
+
β βββ preprocessor_config.json # Image preprocessing config
|
| 25 |
+
β
|
| 26 |
+
βββ yolo-cropper/
|
| 27 |
+
βββ best.onnx # ONNX model for embryo detection
|
| 28 |
+
```
|
| 29 |
+
|
| 30 |
+
## How to Generate Models
|
| 31 |
+
|
| 32 |
+
Run the conversion scripts from the parent directory:
|
| 33 |
+
|
| 34 |
+
```bash
|
| 35 |
+
# Convert SigLIP models
|
| 36 |
+
python convert_to_onnx.py
|
| 37 |
+
|
| 38 |
+
# Convert YOLO model
|
| 39 |
+
python convert_yolo_to_onnx.py
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
## Model Sizes (Approximate)
|
| 43 |
+
|
| 44 |
+
- Classifier: ~95 MB
|
| 45 |
+
- Poor/Good: ~95 MB
|
| 46 |
+
- Grader: ~95 MB
|
| 47 |
+
- YOLO: ~6 MB
|
| 48 |
+
|
| 49 |
+
Total: ~290 MB
|
| 50 |
+
|
| 51 |
+
## Note
|
| 52 |
+
|
| 53 |
+
These models are not included in the repository due to their size.
|
| 54 |
+
You must convert them from the original PyTorch models.
|
models/classifier_model_compressed/config.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"SiglipForImageClassification"
|
| 4 |
+
],
|
| 5 |
+
"dtype": "float16",
|
| 6 |
+
"id2label": {
|
| 7 |
+
"0": "no",
|
| 8 |
+
"1": "yes"
|
| 9 |
+
},
|
| 10 |
+
"initializer_factor": 1.0,
|
| 11 |
+
"label2id": {
|
| 12 |
+
"no": 0,
|
| 13 |
+
"yes": 1
|
| 14 |
+
},
|
| 15 |
+
"model_type": "siglip",
|
| 16 |
+
"problem_type": "single_label_classification",
|
| 17 |
+
"text_config": {
|
| 18 |
+
"attention_dropout": 0.0,
|
| 19 |
+
"dtype": "float16",
|
| 20 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 21 |
+
"hidden_size": 768,
|
| 22 |
+
"intermediate_size": 3072,
|
| 23 |
+
"layer_norm_eps": 1e-06,
|
| 24 |
+
"max_position_embeddings": 64,
|
| 25 |
+
"model_type": "siglip_text_model",
|
| 26 |
+
"num_attention_heads": 12,
|
| 27 |
+
"num_hidden_layers": 12,
|
| 28 |
+
"projection_size": 768,
|
| 29 |
+
"vocab_size": 256000
|
| 30 |
+
},
|
| 31 |
+
"transformers_version": "4.56.1",
|
| 32 |
+
"vision_config": {
|
| 33 |
+
"attention_dropout": 0.0,
|
| 34 |
+
"dtype": "float16",
|
| 35 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 36 |
+
"hidden_size": 768,
|
| 37 |
+
"image_size": 224,
|
| 38 |
+
"intermediate_size": 3072,
|
| 39 |
+
"layer_norm_eps": 1e-06,
|
| 40 |
+
"model_type": "siglip_vision_model",
|
| 41 |
+
"num_attention_heads": 12,
|
| 42 |
+
"num_channels": 3,
|
| 43 |
+
"num_hidden_layers": 12,
|
| 44 |
+
"patch_size": 16
|
| 45 |
+
}
|
| 46 |
+
}
|
models/classifier_model_compressed/model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1e43d3c5a5b27ace84dc5ed27477f95ac51d33013e5a6bc1ad95598bf41e4e22
|
| 3 |
+
size 86628965
|
models/classifier_model_compressed/preprocessor_config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_convert_rgb": null,
|
| 3 |
+
"do_normalize": true,
|
| 4 |
+
"do_rescale": true,
|
| 5 |
+
"do_resize": true,
|
| 6 |
+
"image_mean": [
|
| 7 |
+
0.5,
|
| 8 |
+
0.5,
|
| 9 |
+
0.5
|
| 10 |
+
],
|
| 11 |
+
"image_processor_type": "SiglipImageProcessor",
|
| 12 |
+
"image_std": [
|
| 13 |
+
0.5,
|
| 14 |
+
0.5,
|
| 15 |
+
0.5
|
| 16 |
+
],
|
| 17 |
+
"processor_class": "SiglipProcessor",
|
| 18 |
+
"resample": 2,
|
| 19 |
+
"rescale_factor": 0.00392156862745098,
|
| 20 |
+
"size": {
|
| 21 |
+
"height": 224,
|
| 22 |
+
"width": 224
|
| 23 |
+
}
|
| 24 |
+
}
|
models/grader_model_compressed/config.json
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"SiglipForImageClassification"
|
| 4 |
+
],
|
| 5 |
+
"dtype": "float16",
|
| 6 |
+
"id2label": {
|
| 7 |
+
"0": "3AA",
|
| 8 |
+
"1": "3AB",
|
| 9 |
+
"2": "3AC",
|
| 10 |
+
"3": "3BA",
|
| 11 |
+
"4": "3BB",
|
| 12 |
+
"5": "3BC",
|
| 13 |
+
"6": "3CA",
|
| 14 |
+
"7": "3CB",
|
| 15 |
+
"8": "3CC",
|
| 16 |
+
"9": "4AA",
|
| 17 |
+
"10": "4AB",
|
| 18 |
+
"11": "4AC",
|
| 19 |
+
"12": "4BA",
|
| 20 |
+
"13": "4BB",
|
| 21 |
+
"14": "4BC",
|
| 22 |
+
"15": "4CA",
|
| 23 |
+
"16": "4CB",
|
| 24 |
+
"17": "4CC",
|
| 25 |
+
"18": "5AA",
|
| 26 |
+
"19": "5AB",
|
| 27 |
+
"20": "5AC",
|
| 28 |
+
"21": "5BA",
|
| 29 |
+
"22": "5BB",
|
| 30 |
+
"23": "5BC"
|
| 31 |
+
},
|
| 32 |
+
"initializer_factor": 1.0,
|
| 33 |
+
"label2id": {
|
| 34 |
+
"3AA": 0,
|
| 35 |
+
"3AB": 1,
|
| 36 |
+
"3AC": 2,
|
| 37 |
+
"3BA": 3,
|
| 38 |
+
"3BB": 4,
|
| 39 |
+
"3BC": 5,
|
| 40 |
+
"3CA": 6,
|
| 41 |
+
"3CB": 7,
|
| 42 |
+
"3CC": 8,
|
| 43 |
+
"4AA": 9,
|
| 44 |
+
"4AB": 10,
|
| 45 |
+
"4AC": 11,
|
| 46 |
+
"4BA": 12,
|
| 47 |
+
"4BB": 13,
|
| 48 |
+
"4BC": 14,
|
| 49 |
+
"4CA": 15,
|
| 50 |
+
"4CB": 16,
|
| 51 |
+
"4CC": 17,
|
| 52 |
+
"5AA": 18,
|
| 53 |
+
"5AB": 19,
|
| 54 |
+
"5AC": 20,
|
| 55 |
+
"5BA": 21,
|
| 56 |
+
"5BB": 22,
|
| 57 |
+
"5BC": 23
|
| 58 |
+
},
|
| 59 |
+
"model_type": "siglip",
|
| 60 |
+
"problem_type": "single_label_classification",
|
| 61 |
+
"text_config": {
|
| 62 |
+
"attention_dropout": 0.0,
|
| 63 |
+
"dtype": "float16",
|
| 64 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 65 |
+
"hidden_size": 768,
|
| 66 |
+
"intermediate_size": 3072,
|
| 67 |
+
"layer_norm_eps": 1e-06,
|
| 68 |
+
"max_position_embeddings": 64,
|
| 69 |
+
"model_type": "siglip_text_model",
|
| 70 |
+
"num_attention_heads": 12,
|
| 71 |
+
"num_hidden_layers": 12,
|
| 72 |
+
"projection_size": 768,
|
| 73 |
+
"vocab_size": 256000
|
| 74 |
+
},
|
| 75 |
+
"transformers_version": "4.56.1",
|
| 76 |
+
"vision_config": {
|
| 77 |
+
"attention_dropout": 0.0,
|
| 78 |
+
"dtype": "float16",
|
| 79 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 80 |
+
"hidden_size": 768,
|
| 81 |
+
"image_size": 224,
|
| 82 |
+
"intermediate_size": 3072,
|
| 83 |
+
"layer_norm_eps": 1e-06,
|
| 84 |
+
"model_type": "siglip_vision_model",
|
| 85 |
+
"num_attention_heads": 12,
|
| 86 |
+
"num_channels": 3,
|
| 87 |
+
"num_hidden_layers": 12,
|
| 88 |
+
"patch_size": 16
|
| 89 |
+
}
|
| 90 |
+
}
|
models/grader_model_compressed/model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:dd41fbf211119409410ec7cb29e051f9150f17aa4083da9766a6390cd74fc450
|
| 3 |
+
size 86645953
|
models/grader_model_compressed/preprocessor_config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_convert_rgb": null,
|
| 3 |
+
"do_normalize": true,
|
| 4 |
+
"do_rescale": true,
|
| 5 |
+
"do_resize": true,
|
| 6 |
+
"image_mean": [
|
| 7 |
+
0.5,
|
| 8 |
+
0.5,
|
| 9 |
+
0.5
|
| 10 |
+
],
|
| 11 |
+
"image_processor_type": "SiglipImageProcessor",
|
| 12 |
+
"image_std": [
|
| 13 |
+
0.5,
|
| 14 |
+
0.5,
|
| 15 |
+
0.5
|
| 16 |
+
],
|
| 17 |
+
"processor_class": "SiglipProcessor",
|
| 18 |
+
"resample": 2,
|
| 19 |
+
"rescale_factor": 0.00392156862745098,
|
| 20 |
+
"size": {
|
| 21 |
+
"height": 224,
|
| 22 |
+
"width": 224
|
| 23 |
+
}
|
| 24 |
+
}
|
models/poor_good_compressed/config.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"architectures": [
|
| 3 |
+
"SiglipForImageClassification"
|
| 4 |
+
],
|
| 5 |
+
"dtype": "float16",
|
| 6 |
+
"id2label": {
|
| 7 |
+
"0": "good grade embryo",
|
| 8 |
+
"1": "poor grade embryo"
|
| 9 |
+
},
|
| 10 |
+
"initializer_factor": 1.0,
|
| 11 |
+
"label2id": {
|
| 12 |
+
"good grade embryo": 0,
|
| 13 |
+
"poor grade embryo": 1
|
| 14 |
+
},
|
| 15 |
+
"model_type": "siglip",
|
| 16 |
+
"problem_type": "single_label_classification",
|
| 17 |
+
"text_config": {
|
| 18 |
+
"attention_dropout": 0.0,
|
| 19 |
+
"dtype": "float16",
|
| 20 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 21 |
+
"hidden_size": 768,
|
| 22 |
+
"intermediate_size": 3072,
|
| 23 |
+
"layer_norm_eps": 1e-06,
|
| 24 |
+
"max_position_embeddings": 64,
|
| 25 |
+
"model_type": "siglip_text_model",
|
| 26 |
+
"num_attention_heads": 12,
|
| 27 |
+
"num_hidden_layers": 12,
|
| 28 |
+
"projection_size": 768,
|
| 29 |
+
"vocab_size": 256000
|
| 30 |
+
},
|
| 31 |
+
"transformers_version": "4.56.1",
|
| 32 |
+
"vision_config": {
|
| 33 |
+
"attention_dropout": 0.0,
|
| 34 |
+
"dtype": "float16",
|
| 35 |
+
"hidden_act": "gelu_pytorch_tanh",
|
| 36 |
+
"hidden_size": 768,
|
| 37 |
+
"image_size": 224,
|
| 38 |
+
"intermediate_size": 3072,
|
| 39 |
+
"layer_norm_eps": 1e-06,
|
| 40 |
+
"model_type": "siglip_vision_model",
|
| 41 |
+
"num_attention_heads": 12,
|
| 42 |
+
"num_channels": 3,
|
| 43 |
+
"num_hidden_layers": 12,
|
| 44 |
+
"patch_size": 16
|
| 45 |
+
}
|
| 46 |
+
}
|
models/poor_good_compressed/model.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:9df2b1350507a76ee13715d879ee27539a65ca1acdee6b4d63ffca552ca84334
|
| 3 |
+
size 86628968
|
models/poor_good_compressed/preprocessor_config.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"do_convert_rgb": null,
|
| 3 |
+
"do_normalize": true,
|
| 4 |
+
"do_rescale": true,
|
| 5 |
+
"do_resize": true,
|
| 6 |
+
"image_mean": [
|
| 7 |
+
0.5,
|
| 8 |
+
0.5,
|
| 9 |
+
0.5
|
| 10 |
+
],
|
| 11 |
+
"image_processor_type": "SiglipImageProcessor",
|
| 12 |
+
"image_std": [
|
| 13 |
+
0.5,
|
| 14 |
+
0.5,
|
| 15 |
+
0.5
|
| 16 |
+
],
|
| 17 |
+
"processor_class": "SiglipProcessor",
|
| 18 |
+
"resample": 2,
|
| 19 |
+
"rescale_factor": 0.00392156862745098,
|
| 20 |
+
"size": {
|
| 21 |
+
"height": 224,
|
| 22 |
+
"width": 224
|
| 23 |
+
}
|
| 24 |
+
}
|
models/yolo-cropper/best.onnx
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:b49fd86d506a287711ac3771590ef60af0d0f1e75fafb5227aef4429ca5ed910
|
| 3 |
+
size 3355906
|
package-lock.json
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "embryo-grading-system",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"lockfileVersion": 3,
|
| 5 |
+
"requires": true,
|
| 6 |
+
"packages": {
|
| 7 |
+
"": {
|
| 8 |
+
"name": "embryo-grading-system",
|
| 9 |
+
"version": "1.0.0",
|
| 10 |
+
"license": "MIT",
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"onnxruntime-web": "^1.17.0"
|
| 13 |
+
},
|
| 14 |
+
"devDependencies": {}
|
| 15 |
+
},
|
| 16 |
+
"node_modules/@protobufjs/aspromise": {
|
| 17 |
+
"version": "1.1.2",
|
| 18 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
|
| 19 |
+
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
|
| 20 |
+
"license": "BSD-3-Clause"
|
| 21 |
+
},
|
| 22 |
+
"node_modules/@protobufjs/base64": {
|
| 23 |
+
"version": "1.1.2",
|
| 24 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
|
| 25 |
+
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
|
| 26 |
+
"license": "BSD-3-Clause"
|
| 27 |
+
},
|
| 28 |
+
"node_modules/@protobufjs/codegen": {
|
| 29 |
+
"version": "2.0.4",
|
| 30 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz",
|
| 31 |
+
"integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==",
|
| 32 |
+
"license": "BSD-3-Clause"
|
| 33 |
+
},
|
| 34 |
+
"node_modules/@protobufjs/eventemitter": {
|
| 35 |
+
"version": "1.1.0",
|
| 36 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz",
|
| 37 |
+
"integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==",
|
| 38 |
+
"license": "BSD-3-Clause"
|
| 39 |
+
},
|
| 40 |
+
"node_modules/@protobufjs/fetch": {
|
| 41 |
+
"version": "1.1.0",
|
| 42 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz",
|
| 43 |
+
"integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==",
|
| 44 |
+
"license": "BSD-3-Clause",
|
| 45 |
+
"dependencies": {
|
| 46 |
+
"@protobufjs/aspromise": "^1.1.1",
|
| 47 |
+
"@protobufjs/inquire": "^1.1.0"
|
| 48 |
+
}
|
| 49 |
+
},
|
| 50 |
+
"node_modules/@protobufjs/float": {
|
| 51 |
+
"version": "1.0.2",
|
| 52 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
|
| 53 |
+
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
|
| 54 |
+
"license": "BSD-3-Clause"
|
| 55 |
+
},
|
| 56 |
+
"node_modules/@protobufjs/inquire": {
|
| 57 |
+
"version": "1.1.0",
|
| 58 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz",
|
| 59 |
+
"integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==",
|
| 60 |
+
"license": "BSD-3-Clause"
|
| 61 |
+
},
|
| 62 |
+
"node_modules/@protobufjs/path": {
|
| 63 |
+
"version": "1.1.2",
|
| 64 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
|
| 65 |
+
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
|
| 66 |
+
"license": "BSD-3-Clause"
|
| 67 |
+
},
|
| 68 |
+
"node_modules/@protobufjs/pool": {
|
| 69 |
+
"version": "1.1.0",
|
| 70 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
|
| 71 |
+
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
|
| 72 |
+
"license": "BSD-3-Clause"
|
| 73 |
+
},
|
| 74 |
+
"node_modules/@protobufjs/utf8": {
|
| 75 |
+
"version": "1.1.0",
|
| 76 |
+
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz",
|
| 77 |
+
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
|
| 78 |
+
"license": "BSD-3-Clause"
|
| 79 |
+
},
|
| 80 |
+
"node_modules/@types/node": {
|
| 81 |
+
"version": "24.9.1",
|
| 82 |
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz",
|
| 83 |
+
"integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==",
|
| 84 |
+
"license": "MIT",
|
| 85 |
+
"dependencies": {
|
| 86 |
+
"undici-types": "~7.16.0"
|
| 87 |
+
}
|
| 88 |
+
},
|
| 89 |
+
"node_modules/flatbuffers": {
|
| 90 |
+
"version": "25.9.23",
|
| 91 |
+
"resolved": "https://registry.npmjs.org/flatbuffers/-/flatbuffers-25.9.23.tgz",
|
| 92 |
+
"integrity": "sha512-MI1qs7Lo4Syw0EOzUl0xjs2lsoeqFku44KpngfIduHBYvzm8h2+7K8YMQh1JtVVVrUvhLpNwqVi4DERegUJhPQ==",
|
| 93 |
+
"license": "Apache-2.0"
|
| 94 |
+
},
|
| 95 |
+
"node_modules/guid-typescript": {
|
| 96 |
+
"version": "1.0.9",
|
| 97 |
+
"resolved": "https://registry.npmjs.org/guid-typescript/-/guid-typescript-1.0.9.tgz",
|
| 98 |
+
"integrity": "sha512-Y8T4vYhEfwJOTbouREvG+3XDsjr8E3kIr7uf+JZ0BYloFsttiHU0WfvANVsR7TxNUJa/WpCnw/Ino/p+DeBhBQ==",
|
| 99 |
+
"license": "ISC"
|
| 100 |
+
},
|
| 101 |
+
"node_modules/long": {
|
| 102 |
+
"version": "5.3.2",
|
| 103 |
+
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
| 104 |
+
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
| 105 |
+
"license": "Apache-2.0"
|
| 106 |
+
},
|
| 107 |
+
"node_modules/onnxruntime-common": {
|
| 108 |
+
"version": "1.23.0",
|
| 109 |
+
"resolved": "https://registry.npmjs.org/onnxruntime-common/-/onnxruntime-common-1.23.0.tgz",
|
| 110 |
+
"integrity": "sha512-Auz8S9D7vpF8ok7fzTobvD1XdQDftRf/S7pHmjeCr3Xdymi4z1C7zx4vnT6nnUjbpelZdGwda0BmWHCCTMKUTg==",
|
| 111 |
+
"license": "MIT"
|
| 112 |
+
},
|
| 113 |
+
"node_modules/onnxruntime-web": {
|
| 114 |
+
"version": "1.23.0",
|
| 115 |
+
"resolved": "https://registry.npmjs.org/onnxruntime-web/-/onnxruntime-web-1.23.0.tgz",
|
| 116 |
+
"integrity": "sha512-w0bvC2RwDxphOUFF8jFGZ/dYw+duaX20jM6V4BIZJPCfK4QuCpB/pVREV+hjYbT3x4hyfa2ZbTaWx4e1Vot0fQ==",
|
| 117 |
+
"license": "MIT",
|
| 118 |
+
"dependencies": {
|
| 119 |
+
"flatbuffers": "^25.1.24",
|
| 120 |
+
"guid-typescript": "^1.0.9",
|
| 121 |
+
"long": "^5.2.3",
|
| 122 |
+
"onnxruntime-common": "1.23.0",
|
| 123 |
+
"platform": "^1.3.6",
|
| 124 |
+
"protobufjs": "^7.2.4"
|
| 125 |
+
}
|
| 126 |
+
},
|
| 127 |
+
"node_modules/platform": {
|
| 128 |
+
"version": "1.3.6",
|
| 129 |
+
"resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz",
|
| 130 |
+
"integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==",
|
| 131 |
+
"license": "MIT"
|
| 132 |
+
},
|
| 133 |
+
"node_modules/protobufjs": {
|
| 134 |
+
"version": "7.5.4",
|
| 135 |
+
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.4.tgz",
|
| 136 |
+
"integrity": "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==",
|
| 137 |
+
"hasInstallScript": true,
|
| 138 |
+
"license": "BSD-3-Clause",
|
| 139 |
+
"dependencies": {
|
| 140 |
+
"@protobufjs/aspromise": "^1.1.2",
|
| 141 |
+
"@protobufjs/base64": "^1.1.2",
|
| 142 |
+
"@protobufjs/codegen": "^2.0.4",
|
| 143 |
+
"@protobufjs/eventemitter": "^1.1.0",
|
| 144 |
+
"@protobufjs/fetch": "^1.1.0",
|
| 145 |
+
"@protobufjs/float": "^1.0.2",
|
| 146 |
+
"@protobufjs/inquire": "^1.1.0",
|
| 147 |
+
"@protobufjs/path": "^1.1.2",
|
| 148 |
+
"@protobufjs/pool": "^1.1.0",
|
| 149 |
+
"@protobufjs/utf8": "^1.1.0",
|
| 150 |
+
"@types/node": ">=13.7.0",
|
| 151 |
+
"long": "^5.0.0"
|
| 152 |
+
},
|
| 153 |
+
"engines": {
|
| 154 |
+
"node": ">=12.0.0"
|
| 155 |
+
}
|
| 156 |
+
},
|
| 157 |
+
"node_modules/undici-types": {
|
| 158 |
+
"version": "7.16.0",
|
| 159 |
+
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
| 160 |
+
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
| 161 |
+
"license": "MIT"
|
| 162 |
+
}
|
| 163 |
+
}
|
| 164 |
+
}
|
package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "embryo-grading-system",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"description": "Browser-based Embryo Grading System using ONNX and Transformers.js",
|
| 5 |
+
"main": "server.js",
|
| 6 |
+
"type": "module",
|
| 7 |
+
"scripts": {
|
| 8 |
+
"dev": "node server.js",
|
| 9 |
+
"start": "node server.js",
|
| 10 |
+
"convert": "python convert_to_onnx.py && python convert_yolo_to_onnx.py",
|
| 11 |
+
"convert-siglip": "python convert_to_onnx.py",
|
| 12 |
+
"convert-yolo": "python convert_yolo_to_onnx.py"
|
| 13 |
+
},
|
| 14 |
+
"keywords": [
|
| 15 |
+
"embryo",
|
| 16 |
+
"grading",
|
| 17 |
+
"AI",
|
| 18 |
+
"ONNX",
|
| 19 |
+
"transformers",
|
| 20 |
+
"computer-vision",
|
| 21 |
+
"medical",
|
| 22 |
+
"SigLIP",
|
| 23 |
+
"YOLO"
|
| 24 |
+
],
|
| 25 |
+
"author": "",
|
| 26 |
+
"license": "MIT",
|
| 27 |
+
"dependencies": {
|
| 28 |
+
"onnxruntime-web": "^1.17.0"
|
| 29 |
+
},
|
| 30 |
+
"devDependencies": {},
|
| 31 |
+
"repository": {
|
| 32 |
+
"type": "git",
|
| 33 |
+
"url": ""
|
| 34 |
+
},
|
| 35 |
+
"bugs": {
|
| 36 |
+
"url": ""
|
| 37 |
+
},
|
| 38 |
+
"homepage": ""
|
| 39 |
+
}
|
server.js
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Simple HTTP Server for Embryo Grading System
|
| 3 |
+
* Node.js server to serve the application
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import http from 'http';
|
| 7 |
+
import fs from 'fs';
|
| 8 |
+
import path from 'path';
|
| 9 |
+
import { fileURLToPath } from 'url';
|
| 10 |
+
|
| 11 |
+
const __filename = fileURLToPath(import.meta.url);
|
| 12 |
+
const __dirname = path.dirname(__filename);
|
| 13 |
+
|
| 14 |
+
const PORT = 8000;
|
| 15 |
+
|
| 16 |
+
// MIME types for different file extensions
|
| 17 |
+
const mimeTypes = {
|
| 18 |
+
'.html': 'text/html',
|
| 19 |
+
'.js': 'text/javascript',
|
| 20 |
+
'.css': 'text/css',
|
| 21 |
+
'.json': 'application/json',
|
| 22 |
+
'.png': 'image/png',
|
| 23 |
+
'.jpg': 'image/jpeg',
|
| 24 |
+
'.jpeg': 'image/jpeg',
|
| 25 |
+
'.gif': 'image/gif',
|
| 26 |
+
'.svg': 'image/svg+xml',
|
| 27 |
+
'.ico': 'image/x-icon',
|
| 28 |
+
'.wasm': 'application/wasm',
|
| 29 |
+
'.onnx': 'application/octet-stream',
|
| 30 |
+
'.pt': 'application/octet-stream',
|
| 31 |
+
'.safetensors': 'application/octet-stream'
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const server = http.createServer((req, res) => {
|
| 35 |
+
console.log(`${new Date().toISOString()} - ${req.method} ${req.url}`);
|
| 36 |
+
|
| 37 |
+
// Parse URL and remove query string
|
| 38 |
+
let filePath = '.' + req.url.split('?')[0];
|
| 39 |
+
|
| 40 |
+
// Default to login.html instead of index.html
|
| 41 |
+
if (filePath === './') {
|
| 42 |
+
filePath = './login.html';
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Get file extension
|
| 46 |
+
const extname = String(path.extname(filePath)).toLowerCase();
|
| 47 |
+
const contentType = mimeTypes[extname] || 'application/octet-stream';
|
| 48 |
+
|
| 49 |
+
// Read and serve file
|
| 50 |
+
fs.readFile(filePath, (error, content) => {
|
| 51 |
+
if (error) {
|
| 52 |
+
if (error.code === 'ENOENT') {
|
| 53 |
+
// File not found
|
| 54 |
+
res.writeHead(404, { 'Content-Type': 'text/html' });
|
| 55 |
+
res.end('<h1>404 - File Not Found</h1>', 'utf-8');
|
| 56 |
+
} else {
|
| 57 |
+
// Server error
|
| 58 |
+
res.writeHead(500);
|
| 59 |
+
res.end(`Server Error: ${error.code}`, 'utf-8');
|
| 60 |
+
}
|
| 61 |
+
} else {
|
| 62 |
+
// Success
|
| 63 |
+
res.writeHead(200, {
|
| 64 |
+
'Content-Type': contentType,
|
| 65 |
+
'Access-Control-Allow-Origin': '*'
|
| 66 |
+
});
|
| 67 |
+
res.end(content, 'utf-8');
|
| 68 |
+
}
|
| 69 |
+
});
|
| 70 |
+
});
|
| 71 |
+
|
| 72 |
+
server.listen(PORT, () => {
|
| 73 |
+
console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
|
| 74 |
+
console.log('β Embryo Grading System - Server Running β');
|
| 75 |
+
console.log('ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
|
| 76 |
+
console.log('');
|
| 77 |
+
console.log(` π Server running at: http://localhost:${PORT}`);
|
| 78 |
+
console.log(` π Serving files from: ${__dirname}`);
|
| 79 |
+
console.log('');
|
| 80 |
+
console.log(' Press Ctrl+C to stop the server');
|
| 81 |
+
console.log('');
|
| 82 |
+
console.log('βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ');
|
| 83 |
+
});
|
| 84 |
+
|
| 85 |
+
// Handle server errors
|
| 86 |
+
server.on('error', (err) => {
|
| 87 |
+
if (err.code === 'EADDRINUSE') {
|
| 88 |
+
console.error(`\nβ Error: Port ${PORT} is already in use.`);
|
| 89 |
+
console.error(' Please stop the other server or use a different port.\n');
|
| 90 |
+
} else {
|
| 91 |
+
console.error('\nβ Server error:', err);
|
| 92 |
+
}
|
| 93 |
+
process.exit(1);
|
| 94 |
+
});
|
| 95 |
+
|
| 96 |
+
// Graceful shutdown
|
| 97 |
+
process.on('SIGINT', () => {
|
| 98 |
+
console.log('\n\nπ Shutting down server...');
|
| 99 |
+
server.close(() => {
|
| 100 |
+
console.log('β
Server stopped successfully\n');
|
| 101 |
+
process.exit(0);
|
| 102 |
+
});
|
| 103 |
+
});
|
src/README.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Source Code Structure
|
| 2 |
+
|
| 3 |
+
The application has been refactored into a modular structure for better maintainability and debugging.
|
| 4 |
+
|
| 5 |
+
## Directory Structure
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
src/
|
| 9 |
+
βββ config.js # Application configuration and constants
|
| 10 |
+
βββ state.js # Application state management
|
| 11 |
+
βββ main.js # Main entry point
|
| 12 |
+
β
|
| 13 |
+
βββ models/ # AI Model management
|
| 14 |
+
β βββ modelLoader.js # Load and initialize ONNX models
|
| 15 |
+
β βββ inference.js # Run model predictions
|
| 16 |
+
β βββ yoloDetection.js # YOLO-specific detection logic
|
| 17 |
+
β
|
| 18 |
+
βββ processing/ # Data processing
|
| 19 |
+
β βββ imagePreprocessing.js # Image preprocessing for models
|
| 20 |
+
β βββ postprocessing.js # Model output postprocessing
|
| 21 |
+
β βββ yoloPostprocessing.js # YOLO-specific postprocessing with NMS
|
| 22 |
+
β
|
| 23 |
+
βββ ui/ # UI components and handlers
|
| 24 |
+
β βββ eventHandlers.js # Setup all event listeners
|
| 25 |
+
β βββ imageDisplay.js # Image preview and display
|
| 26 |
+
β βββ loading.js # Loading overlay controls
|
| 27 |
+
β βββ quickEval.js # Quick evaluation handlers
|
| 28 |
+
β βββ results.js # Results display and formatting
|
| 29 |
+
β βββ tabs.js # Tab switching logic
|
| 30 |
+
β βββ toast.js # Toast notifications
|
| 31 |
+
β βββ workflow.js # Main workflow logic
|
| 32 |
+
β βββ zoom.js # Zoom controls
|
| 33 |
+
β
|
| 34 |
+
βββ utils/ # Utility functions
|
| 35 |
+
βββ imageUtils.js # Image manipulation helpers
|
| 36 |
+
βββ mathUtils.js # Math operations (softmax, etc.)
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Module Responsibilities
|
| 40 |
+
|
| 41 |
+
### Core Modules
|
| 42 |
+
|
| 43 |
+
**config.js**
|
| 44 |
+
- Model paths configuration
|
| 45 |
+
- Detection thresholds and parameters
|
| 46 |
+
- Label mappings
|
| 47 |
+
- ONNX Runtime configuration
|
| 48 |
+
- Zoom settings
|
| 49 |
+
|
| 50 |
+
**state.js**
|
| 51 |
+
- Centralized application state
|
| 52 |
+
- State getters and setters
|
| 53 |
+
- Current image, models, embryos tracking
|
| 54 |
+
|
| 55 |
+
**main.js**
|
| 56 |
+
- Application initialization
|
| 57 |
+
- Orchestrates model loading
|
| 58 |
+
- Sets up event listeners
|
| 59 |
+
- Entry point for the app
|
| 60 |
+
|
| 61 |
+
### Model Modules
|
| 62 |
+
|
| 63 |
+
**models/modelLoader.js**
|
| 64 |
+
- Initialize ONNX Runtime
|
| 65 |
+
- Load label mappings from config files
|
| 66 |
+
- Load all 4 ONNX models (classifier, quality, grader, YOLO)
|
| 67 |
+
- Provide model status
|
| 68 |
+
|
| 69 |
+
**models/inference.js**
|
| 70 |
+
- Run classification (embryo yes/no)
|
| 71 |
+
- Run quality check (poor/good)
|
| 72 |
+
- Run grading (Gardner scale)
|
| 73 |
+
- Orchestrate full embryo evaluation pipeline
|
| 74 |
+
|
| 75 |
+
**models/yoloDetection.js**
|
| 76 |
+
- Detect embryos using YOLO model
|
| 77 |
+
- Check YOLO availability
|
| 78 |
+
|
| 79 |
+
### Processing Modules
|
| 80 |
+
|
| 81 |
+
**processing/imagePreprocessing.js**
|
| 82 |
+
- Preprocess images for SigLIP models (224x224)
|
| 83 |
+
- Preprocess images for YOLO (640x640)
|
| 84 |
+
- Normalize with mean/std
|
| 85 |
+
|
| 86 |
+
**processing/postprocessing.js**
|
| 87 |
+
- Postprocess classification results
|
| 88 |
+
- Postprocess grading results with all predictions
|
| 89 |
+
- Apply softmax to logits
|
| 90 |
+
|
| 91 |
+
**processing/yoloPostprocessing.js**
|
| 92 |
+
- Parse YOLO output format [1, 5, 8400]
|
| 93 |
+
- Apply confidence threshold
|
| 94 |
+
- Non-Maximum Suppression (NMS)
|
| 95 |
+
- Calculate IoU (Intersection over Union)
|
| 96 |
+
- Crop detected embryo regions
|
| 97 |
+
|
| 98 |
+
### UI Modules
|
| 99 |
+
|
| 100 |
+
**ui/eventHandlers.js**
|
| 101 |
+
- Central event listener setup
|
| 102 |
+
- Handle image uploads
|
| 103 |
+
- Wire up all UI interactions
|
| 104 |
+
|
| 105 |
+
**ui/imageDisplay.js**
|
| 106 |
+
- Display images in main/quick previews
|
| 107 |
+
- Show cropped embryos
|
| 108 |
+
- Manage image visibility
|
| 109 |
+
|
| 110 |
+
**ui/loading.js**
|
| 111 |
+
- Show/hide loading overlay
|
| 112 |
+
- Update progress bar
|
| 113 |
+
- Display loading messages
|
| 114 |
+
|
| 115 |
+
**ui/quickEval.js**
|
| 116 |
+
- Quick evaluation workflow
|
| 117 |
+
- Clear quick evaluation results
|
| 118 |
+
|
| 119 |
+
**ui/results.js**
|
| 120 |
+
- Format and display results
|
| 121 |
+
- Interpret Gardner grades
|
| 122 |
+
- Show top-5 predictions
|
| 123 |
+
- Display classification status
|
| 124 |
+
- Generate image quality tips
|
| 125 |
+
|
| 126 |
+
**ui/tabs.js**
|
| 127 |
+
- Switch between main workflow and quick evaluation
|
| 128 |
+
- Manage tab state
|
| 129 |
+
|
| 130 |
+
**ui/toast.js**
|
| 131 |
+
- Show success/error/info toast notifications
|
| 132 |
+
- Auto-dismiss after 3 seconds
|
| 133 |
+
|
| 134 |
+
**ui/workflow.js**
|
| 135 |
+
- Main workflow orchestration
|
| 136 |
+
- Classify uploaded images
|
| 137 |
+
- Process single/multiple embryo modes
|
| 138 |
+
- Navigate between detected embryos
|
| 139 |
+
- Evaluate selected embryos
|
| 140 |
+
|
| 141 |
+
**ui/zoom.js**
|
| 142 |
+
- Adjust zoom level
|
| 143 |
+
- Reset zoom
|
| 144 |
+
- Apply zoom transform
|
| 145 |
+
|
| 146 |
+
### Utility Modules
|
| 147 |
+
|
| 148 |
+
**utils/imageUtils.js**
|
| 149 |
+
- Load images from URLs/data URIs
|
| 150 |
+
- Crop image regions with padding
|
| 151 |
+
- Read files as data URLs
|
| 152 |
+
|
| 153 |
+
**utils/mathUtils.js**
|
| 154 |
+
- Softmax function
|
| 155 |
+
- Mean calculation
|
| 156 |
+
- Value clamping
|
| 157 |
+
|
| 158 |
+
## Benefits of Modular Structure
|
| 159 |
+
|
| 160 |
+
1. **Easier Debugging**: Each module has a specific responsibility, making it easier to locate and fix bugs
|
| 161 |
+
2. **Better Testing**: Individual modules can be tested in isolation
|
| 162 |
+
3. **Code Reusability**: Functions can be easily imported and reused across different parts of the app
|
| 163 |
+
4. **Maintainability**: Changes to one module don't affect others if interfaces remain stable
|
| 164 |
+
5. **Readability**: Smaller files are easier to understand and navigate
|
| 165 |
+
6. **Collaboration**: Multiple developers can work on different modules simultaneously
|
| 166 |
+
|
| 167 |
+
## How to Add New Features
|
| 168 |
+
|
| 169 |
+
### Adding a New Model
|
| 170 |
+
1. Add model path to `config.js`
|
| 171 |
+
2. Load model in `models/modelLoader.js`
|
| 172 |
+
3. Create inference function in `models/inference.js`
|
| 173 |
+
4. Add preprocessing in `processing/imagePreprocessing.js` if needed
|
| 174 |
+
5. Add postprocessing in `processing/postprocessing.js`
|
| 175 |
+
|
| 176 |
+
### Adding a New UI Component
|
| 177 |
+
1. Create new file in `ui/` directory
|
| 178 |
+
2. Export relevant functions
|
| 179 |
+
3. Import and wire up in `ui/eventHandlers.js`
|
| 180 |
+
4. Update `main.js` if initialization is needed
|
| 181 |
+
|
| 182 |
+
### Modifying Detection Parameters
|
| 183 |
+
1. Update thresholds in `config.js`
|
| 184 |
+
2. No code changes needed elsewhere
|
| 185 |
+
|
| 186 |
+
## Import/Export Pattern
|
| 187 |
+
|
| 188 |
+
All modules use ES6 modules:
|
| 189 |
+
```javascript
|
| 190 |
+
// Export
|
| 191 |
+
export function myFunction() { ... }
|
| 192 |
+
export const myConstant = 42;
|
| 193 |
+
|
| 194 |
+
// Import
|
| 195 |
+
import { myFunction, myConstant } from './module.js';
|
| 196 |
+
```
|
| 197 |
+
|
| 198 |
+
## Debugging Tips
|
| 199 |
+
|
| 200 |
+
1. **Check browser console**: All errors are logged with descriptive messages
|
| 201 |
+
2. **Check Network tab**: Verify models are loading correctly
|
| 202 |
+
3. **Breakpoints**: Set breakpoints in specific modules to debug
|
| 203 |
+
4. **State inspection**: Check `appState` in console to see current state
|
| 204 |
+
5. **Module isolation**: Test individual functions by importing in console
|
| 205 |
+
|
| 206 |
+
## Backup
|
| 207 |
+
|
| 208 |
+
The original monolithic `app.js` has been backed up as `app.js.backup`.
|
src/config.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Configuration - Application constants and model paths
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export const MODEL_PATHS = {
|
| 6 |
+
classifier: './models/classifier_model_compressed/model.onnx',
|
| 7 |
+
poorGood: './models/poor_good_compressed/model.onnx',
|
| 8 |
+
grader: './models/grader_model_compressed/model.onnx',
|
| 9 |
+
yolo: './models/yolo-cropper/best.onnx'
|
| 10 |
+
};
|
| 11 |
+
|
| 12 |
+
export const DETECTION_CONFIG = {
|
| 13 |
+
yolo: {
|
| 14 |
+
inputSize: 640,
|
| 15 |
+
confThreshold: 0.6,
|
| 16 |
+
iouThreshold: 0.45,
|
| 17 |
+
padding: 0.25
|
| 18 |
+
},
|
| 19 |
+
siglip: {
|
| 20 |
+
inputSize: 224,
|
| 21 |
+
mean: [0.5, 0.5, 0.5],
|
| 22 |
+
std: [0.5, 0.5, 0.5]
|
| 23 |
+
}
|
| 24 |
+
};
|
| 25 |
+
|
| 26 |
+
export const LABEL_MAPPINGS = {
|
| 27 |
+
classifier: { 0: 'no', 1: 'yes' },
|
| 28 |
+
poorGood: { 0: 'good', 1: 'poor' },
|
| 29 |
+
grader: {} // Will be loaded from config.json
|
| 30 |
+
};
|
| 31 |
+
|
| 32 |
+
export const ONNX_CONFIG = {
|
| 33 |
+
wasmPaths: 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.0/dist/',
|
| 34 |
+
numThreads: 4
|
| 35 |
+
};
|
| 36 |
+
|
| 37 |
+
export const ZOOM_CONFIG = {
|
| 38 |
+
min: 0.5,
|
| 39 |
+
max: 3,
|
| 40 |
+
step: 0.1,
|
| 41 |
+
default: 1
|
| 42 |
+
};
|
src/main.js
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Embryo Grading System - Main Application Entry Point
|
| 3 |
+
* Modular structure for better debugging and maintenance
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { initONNXRuntime, loadLabelMappings, loadAllModels } from './models/modelLoader.js';
|
| 7 |
+
import { setupEventListeners } from './ui/eventHandlers.js';
|
| 8 |
+
import { updateLoadingStatus, hideLoading } from './ui/loading.js';
|
| 9 |
+
import { showToast } from './ui/toast.js';
|
| 10 |
+
import { initializeStepper } from './ui/stepper.js';
|
| 11 |
+
import { initializeCropEditor } from './ui/cropEditor.js';
|
| 12 |
+
import { onAuthChange, signOutUser, getCurrentUser } from './utils/firebaseService.js';
|
| 13 |
+
|
| 14 |
+
/**
|
| 15 |
+
* Check authentication status
|
| 16 |
+
*/
|
| 17 |
+
function checkAuthentication() {
|
| 18 |
+
return new Promise((resolve) => {
|
| 19 |
+
onAuthChange((user) => {
|
| 20 |
+
if (user) {
|
| 21 |
+
// User is logged in
|
| 22 |
+
const userEmail = document.getElementById('userEmail');
|
| 23 |
+
const userMenu = document.getElementById('userMenu');
|
| 24 |
+
|
| 25 |
+
if (userEmail) {
|
| 26 |
+
userEmail.textContent = user.email;
|
| 27 |
+
}
|
| 28 |
+
if (userMenu) {
|
| 29 |
+
userMenu.style.display = 'flex';
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
resolve(true);
|
| 33 |
+
} else {
|
| 34 |
+
// User is not logged in, redirect to login
|
| 35 |
+
if (window.location.pathname !== '/login.html' && !window.location.pathname.endsWith('login.html')) {
|
| 36 |
+
window.location.href = '/login.html';
|
| 37 |
+
}
|
| 38 |
+
resolve(false);
|
| 39 |
+
}
|
| 40 |
+
});
|
| 41 |
+
});
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Setup logout functionality
|
| 46 |
+
*/
|
| 47 |
+
function setupLogout() {
|
| 48 |
+
const logoutBtn = document.getElementById('logoutBtn');
|
| 49 |
+
if (logoutBtn) {
|
| 50 |
+
logoutBtn.addEventListener('click', async () => {
|
| 51 |
+
try {
|
| 52 |
+
await signOutUser();
|
| 53 |
+
window.location.href = '/'; // Redirect to root which serves login.html
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.error('Logout error:', error);
|
| 56 |
+
showToast('Failed to logout', 'error');
|
| 57 |
+
}
|
| 58 |
+
});
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
/**
|
| 63 |
+
* Initialize the application
|
| 64 |
+
*/
|
| 65 |
+
async function init() {
|
| 66 |
+
try {
|
| 67 |
+
// Auto-logout on page refresh/reload
|
| 68 |
+
// Check if this is a page reload (not initial load from login)
|
| 69 |
+
const isPageReload = performance.navigation.type === 1 ||
|
| 70 |
+
performance.getEntriesByType('navigation')[0]?.type === 'reload';
|
| 71 |
+
|
| 72 |
+
if (isPageReload) {
|
| 73 |
+
// Page was reloaded, logout and redirect to login
|
| 74 |
+
try {
|
| 75 |
+
await signOutUser();
|
| 76 |
+
} catch (error) {
|
| 77 |
+
console.error('Auto-logout error:', error);
|
| 78 |
+
}
|
| 79 |
+
window.location.href = '/';
|
| 80 |
+
return;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
// Check authentication first
|
| 84 |
+
const isAuthenticated = await checkAuthentication();
|
| 85 |
+
if (!isAuthenticated) {
|
| 86 |
+
return; // Will redirect to login
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
// Setup logout
|
| 90 |
+
setupLogout();
|
| 91 |
+
|
| 92 |
+
updateLoadingStatus('Initializing ONNX Runtime...', 10);
|
| 93 |
+
|
| 94 |
+
// Configure ONNX Runtime
|
| 95 |
+
await initONNXRuntime();
|
| 96 |
+
|
| 97 |
+
// Load label mappings
|
| 98 |
+
await loadLabelMappings();
|
| 99 |
+
|
| 100 |
+
// Load models
|
| 101 |
+
await loadAllModels();
|
| 102 |
+
|
| 103 |
+
// Setup UI event listeners
|
| 104 |
+
setupEventListeners();
|
| 105 |
+
|
| 106 |
+
// Initialize stepper
|
| 107 |
+
initializeStepper();
|
| 108 |
+
|
| 109 |
+
// Initialize crop editor
|
| 110 |
+
initializeCropEditor();
|
| 111 |
+
|
| 112 |
+
// Hide loading overlay
|
| 113 |
+
hideLoading();
|
| 114 |
+
|
| 115 |
+
showToast('AI models loaded successfully!', 'success');
|
| 116 |
+
} catch (error) {
|
| 117 |
+
console.error('Initialization error:', error);
|
| 118 |
+
showToast(`Initialization failed: ${error.message}`, 'error');
|
| 119 |
+
updateLoadingStatus(`Error: ${error.message}`, 100);
|
| 120 |
+
}
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
// Initialize app when DOM is loaded
|
| 124 |
+
if (document.readyState === 'loading') {
|
| 125 |
+
document.addEventListener('DOMContentLoaded', init);
|
| 126 |
+
} else {
|
| 127 |
+
init();
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
export { init };
|
src/models/inference.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Model Inference - Run predictions on models
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { getModel } from '../state.js';
|
| 6 |
+
import { LABEL_MAPPINGS } from '../config.js';
|
| 7 |
+
import { preprocessImage } from '../processing/imagePreprocessing.js';
|
| 8 |
+
import { postprocessClassification, postprocessGrading } from '../processing/postprocessing.js';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Run classification model (embryo yes/no)
|
| 12 |
+
*/
|
| 13 |
+
export async function runClassification(imageData) {
|
| 14 |
+
const inputTensor = await preprocessImage(imageData, 224);
|
| 15 |
+
const feeds = { pixel_values: inputTensor };
|
| 16 |
+
const results = await getModel('classifier').run(feeds);
|
| 17 |
+
|
| 18 |
+
return postprocessClassification(results, LABEL_MAPPINGS.classifier);
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Run quality check model (poor/good)
|
| 23 |
+
*/
|
| 24 |
+
export async function runQualityCheck(imageData) {
|
| 25 |
+
const inputTensor = await preprocessImage(imageData, 224);
|
| 26 |
+
const feeds = { pixel_values: inputTensor };
|
| 27 |
+
const results = await getModel('poorGood').run(feeds);
|
| 28 |
+
|
| 29 |
+
return postprocessClassification(results, LABEL_MAPPINGS.poorGood);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Run grading model (Gardner scale)
|
| 34 |
+
*/
|
| 35 |
+
export async function runGrading(imageData) {
|
| 36 |
+
const inputTensor = await preprocessImage(imageData, 224);
|
| 37 |
+
const feeds = { pixel_values: inputTensor };
|
| 38 |
+
const results = await getModel('grader').run(feeds);
|
| 39 |
+
|
| 40 |
+
return postprocessGrading(results);
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Evaluate embryo quality and grade
|
| 45 |
+
*/
|
| 46 |
+
export async function evaluateEmbryo(imageData) {
|
| 47 |
+
try {
|
| 48 |
+
// Step 1: Check quality (poor/good)
|
| 49 |
+
const qualityResult = await runQualityCheck(imageData);
|
| 50 |
+
|
| 51 |
+
if (qualityResult.label === 'poor') {
|
| 52 |
+
return {
|
| 53 |
+
quality: 'poor',
|
| 54 |
+
poorGoodConfidence: qualityResult.confidence,
|
| 55 |
+
grade: null,
|
| 56 |
+
predictions: {}
|
| 57 |
+
};
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Step 2: Get grade
|
| 61 |
+
const gradeResult = await runGrading(imageData);
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
quality: 'good',
|
| 65 |
+
poorGoodConfidence: qualityResult.confidence,
|
| 66 |
+
grade: gradeResult.label,
|
| 67 |
+
confidence: gradeResult.confidence,
|
| 68 |
+
predictions: gradeResult.allPredictions
|
| 69 |
+
};
|
| 70 |
+
} catch (error) {
|
| 71 |
+
console.error('Error evaluating embryo:', error);
|
| 72 |
+
throw new Error(`Failed to evaluate embryo: ${error.message}`);
|
| 73 |
+
}
|
| 74 |
+
}
|
src/models/modelLoader.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Model Loading - Load and initialize ONNX models
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.0/dist/esm/ort.min.js';
|
| 6 |
+
import { MODEL_PATHS, ONNX_CONFIG, LABEL_MAPPINGS } from '../config.js';
|
| 7 |
+
import { setModel, appState } from '../state.js';
|
| 8 |
+
import { updateLoadingStatus } from '../ui/loading.js';
|
| 9 |
+
|
| 10 |
+
/**
|
| 11 |
+
* Initialize ONNX Runtime
|
| 12 |
+
*/
|
| 13 |
+
export async function initONNXRuntime() {
|
| 14 |
+
ort.env.wasm.wasmPaths = ONNX_CONFIG.wasmPaths;
|
| 15 |
+
ort.env.wasm.numThreads = ONNX_CONFIG.numThreads;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Load label mappings from config files
|
| 20 |
+
*/
|
| 21 |
+
export async function loadLabelMappings() {
|
| 22 |
+
try {
|
| 23 |
+
const graderConfig = await fetch('./models/grader_model_compressed/config.json');
|
| 24 |
+
const graderData = await graderConfig.json();
|
| 25 |
+
LABEL_MAPPINGS.grader = graderData.id2label || {};
|
| 26 |
+
|
| 27 |
+
console.log('Label mappings loaded:', LABEL_MAPPINGS);
|
| 28 |
+
} catch (error) {
|
| 29 |
+
console.warn('Could not load label mappings, using defaults:', error);
|
| 30 |
+
}
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
/**
|
| 34 |
+
* Load all ONNX models
|
| 35 |
+
*/
|
| 36 |
+
export async function loadAllModels() {
|
| 37 |
+
try {
|
| 38 |
+
updateLoadingStatus('Loading Classifier Model...', 25);
|
| 39 |
+
const classifier = await ort.InferenceSession.create(MODEL_PATHS.classifier);
|
| 40 |
+
setModel('classifier', classifier);
|
| 41 |
+
|
| 42 |
+
updateLoadingStatus('Loading Quality Assessment Model...', 50);
|
| 43 |
+
const poorGood = await ort.InferenceSession.create(MODEL_PATHS.poorGood);
|
| 44 |
+
setModel('poorGood', poorGood);
|
| 45 |
+
|
| 46 |
+
updateLoadingStatus('Loading Grader Model...', 75);
|
| 47 |
+
const grader = await ort.InferenceSession.create(MODEL_PATHS.grader);
|
| 48 |
+
setModel('grader', grader);
|
| 49 |
+
|
| 50 |
+
updateLoadingStatus('Loading YOLO Detection Model...', 90);
|
| 51 |
+
try {
|
| 52 |
+
const yolo = await ort.InferenceSession.create(MODEL_PATHS.yolo);
|
| 53 |
+
setModel('yolo', yolo);
|
| 54 |
+
} catch (error) {
|
| 55 |
+
console.warn('YOLO model not available:', error);
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
updateLoadingStatus('Models loaded successfully!', 100);
|
| 59 |
+
} catch (error) {
|
| 60 |
+
throw new Error(`Model loading failed: ${error.message}`);
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Get model status for UI display
|
| 66 |
+
*/
|
| 67 |
+
export function getModelStatus() {
|
| 68 |
+
return {
|
| 69 |
+
classifier: !!appState.models.classifier,
|
| 70 |
+
poorGood: !!appState.models.poorGood,
|
| 71 |
+
grader: !!appState.models.grader,
|
| 72 |
+
yolo: !!appState.models.yolo
|
| 73 |
+
};
|
| 74 |
+
}
|
src/models/yoloDetection.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* YOLO Detection - Detect and crop embryos
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { getModel } from '../state.js';
|
| 6 |
+
import { DETECTION_CONFIG } from '../config.js';
|
| 7 |
+
import { preprocessImageYOLO } from '../processing/imagePreprocessing.js';
|
| 8 |
+
import { postprocessYOLO } from '../processing/yoloPostprocessing.js';
|
| 9 |
+
import { loadImage } from '../utils/imageUtils.js';
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Detect embryos using YOLO
|
| 13 |
+
*/
|
| 14 |
+
export async function detectEmbryos(imageData) {
|
| 15 |
+
const img = await loadImage(imageData);
|
| 16 |
+
const inputTensor = await preprocessImageYOLO(img, DETECTION_CONFIG.yolo.inputSize);
|
| 17 |
+
|
| 18 |
+
const feeds = { images: inputTensor };
|
| 19 |
+
const results = await getModel('yolo').run(feeds);
|
| 20 |
+
|
| 21 |
+
return postprocessYOLO(results, img);
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
/**
|
| 25 |
+
* Check if YOLO model is available
|
| 26 |
+
*/
|
| 27 |
+
export function isYOLOAvailable() {
|
| 28 |
+
return !!getModel('yolo');
|
| 29 |
+
}
|
src/processing/imagePreprocessing.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Image Preprocessing - Prepare images for model inference
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.0/dist/esm/ort.min.js';
|
| 6 |
+
import { DETECTION_CONFIG } from '../config.js';
|
| 7 |
+
import { loadImage } from '../utils/imageUtils.js';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Preprocess image for SigLIP models
|
| 11 |
+
*/
|
| 12 |
+
export async function preprocessImage(imageData, size) {
|
| 13 |
+
const img = await loadImage(imageData);
|
| 14 |
+
|
| 15 |
+
// Create canvas and resize
|
| 16 |
+
const canvas = document.createElement('canvas');
|
| 17 |
+
canvas.width = size;
|
| 18 |
+
canvas.height = size;
|
| 19 |
+
const ctx = canvas.getContext('2d');
|
| 20 |
+
ctx.drawImage(img, 0, 0, size, size);
|
| 21 |
+
|
| 22 |
+
// Get image data
|
| 23 |
+
const imageDataObj = ctx.getImageData(0, 0, size, size);
|
| 24 |
+
const data = imageDataObj.data;
|
| 25 |
+
|
| 26 |
+
// Convert to float32 tensor [1, 3, size, size] and normalize
|
| 27 |
+
const float32Data = new Float32Array(3 * size * size);
|
| 28 |
+
const { mean, std } = DETECTION_CONFIG.siglip;
|
| 29 |
+
|
| 30 |
+
for (let i = 0; i < size * size; i++) {
|
| 31 |
+
float32Data[i] = ((data[i * 4] / 255.0) - mean[0]) / std[0]; // R
|
| 32 |
+
float32Data[size * size + i] = ((data[i * 4 + 1] / 255.0) - mean[1]) / std[1]; // G
|
| 33 |
+
float32Data[2 * size * size + i] = ((data[i * 4 + 2] / 255.0) - mean[2]) / std[2]; // B
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
return new ort.Tensor('float32', float32Data, [1, 3, size, size]);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
/**
|
| 40 |
+
* Preprocess image for YOLO
|
| 41 |
+
*/
|
| 42 |
+
export async function preprocessImageYOLO(img, size) {
|
| 43 |
+
const canvas = document.createElement('canvas');
|
| 44 |
+
canvas.width = size;
|
| 45 |
+
canvas.height = size;
|
| 46 |
+
const ctx = canvas.getContext('2d');
|
| 47 |
+
ctx.drawImage(img, 0, 0, size, size);
|
| 48 |
+
|
| 49 |
+
const imageData = ctx.getImageData(0, 0, size, size);
|
| 50 |
+
const data = imageData.data;
|
| 51 |
+
|
| 52 |
+
const float32Data = new Float32Array(3 * size * size);
|
| 53 |
+
for (let i = 0; i < size * size; i++) {
|
| 54 |
+
float32Data[i] = data[i * 4] / 255.0;
|
| 55 |
+
float32Data[size * size + i] = data[i * 4 + 1] / 255.0;
|
| 56 |
+
float32Data[2 * size * size + i] = data[i * 4 + 2] / 255.0;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
return new ort.Tensor('float32', float32Data, [1, 3, size, size]);
|
| 60 |
+
}
|
src/processing/postprocessing.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Postprocessing - Process model outputs
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { LABEL_MAPPINGS } from '../config.js';
|
| 6 |
+
import { softmax } from '../utils/mathUtils.js';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Postprocess classification results
|
| 10 |
+
*/
|
| 11 |
+
export function postprocessClassification(results, mapping = LABEL_MAPPINGS.classifier) {
|
| 12 |
+
const logits = results.logits.data;
|
| 13 |
+
const probabilities = softmax(Array.from(logits));
|
| 14 |
+
const maxIndex = probabilities.indexOf(Math.max(...probabilities));
|
| 15 |
+
|
| 16 |
+
return {
|
| 17 |
+
label: mapping[maxIndex] || maxIndex.toString(),
|
| 18 |
+
confidence: probabilities[maxIndex]
|
| 19 |
+
};
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Postprocess grading results
|
| 24 |
+
*/
|
| 25 |
+
export function postprocessGrading(results) {
|
| 26 |
+
const logits = results.logits.data;
|
| 27 |
+
const probabilities = softmax(Array.from(logits));
|
| 28 |
+
const maxIndex = probabilities.indexOf(Math.max(...probabilities));
|
| 29 |
+
|
| 30 |
+
const allPredictions = {};
|
| 31 |
+
probabilities.forEach((prob, idx) => {
|
| 32 |
+
const label = LABEL_MAPPINGS.grader[idx] || idx.toString();
|
| 33 |
+
allPredictions[label] = prob;
|
| 34 |
+
});
|
| 35 |
+
|
| 36 |
+
return {
|
| 37 |
+
label: LABEL_MAPPINGS.grader[maxIndex] || maxIndex.toString(),
|
| 38 |
+
confidence: probabilities[maxIndex],
|
| 39 |
+
allPredictions: allPredictions
|
| 40 |
+
};
|
| 41 |
+
}
|
src/processing/yoloPostprocessing.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* YOLO Postprocessing - Process YOLO detection outputs
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { DETECTION_CONFIG } from '../config.js';
|
| 6 |
+
import { cropImage } from '../utils/imageUtils.js';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Postprocess YOLO detections
|
| 10 |
+
*/
|
| 11 |
+
export async function postprocessYOLO(results, originalImage) {
|
| 12 |
+
const output = results.output0 || results[Object.keys(results)[0]];
|
| 13 |
+
const data = output.data;
|
| 14 |
+
const dims = output.dims; // [1, 5, 8400]
|
| 15 |
+
|
| 16 |
+
const detections = [];
|
| 17 |
+
const { confThreshold, iouThreshold } = DETECTION_CONFIG.yolo;
|
| 18 |
+
|
| 19 |
+
// YOLOv8 output format: [batch, [x, y, w, h, conf], num_anchors]
|
| 20 |
+
// dims = [1, 5, 8400]
|
| 21 |
+
const numAnchors = dims[2]; // 8400
|
| 22 |
+
|
| 23 |
+
// Extract boxes with confidence above threshold
|
| 24 |
+
const boxes = [];
|
| 25 |
+
for (let i = 0; i < numAnchors; i++) {
|
| 26 |
+
// Data is organized as: [x_center, y_center, width, height, confidence] for each anchor
|
| 27 |
+
const x_center = data[i];
|
| 28 |
+
const y_center = data[numAnchors + i];
|
| 29 |
+
const width = data[2 * numAnchors + i];
|
| 30 |
+
const height = data[3 * numAnchors + i];
|
| 31 |
+
const confidence = data[4 * numAnchors + i];
|
| 32 |
+
|
| 33 |
+
if (confidence > confThreshold) {
|
| 34 |
+
// Convert from center format to corner format
|
| 35 |
+
const x1 = (x_center - width / 2) / 640 * originalImage.width;
|
| 36 |
+
const y1 = (y_center - height / 2) / 640 * originalImage.height;
|
| 37 |
+
const x2 = (x_center + width / 2) / 640 * originalImage.width;
|
| 38 |
+
const y2 = (y_center + height / 2) / 640 * originalImage.height;
|
| 39 |
+
|
| 40 |
+
boxes.push({
|
| 41 |
+
x1: Math.max(0, x1),
|
| 42 |
+
y1: Math.max(0, y1),
|
| 43 |
+
x2: Math.min(originalImage.width, x2),
|
| 44 |
+
y2: Math.min(originalImage.height, y2),
|
| 45 |
+
confidence: confidence
|
| 46 |
+
});
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
// Apply Non-Maximum Suppression
|
| 51 |
+
const selectedBoxes = nonMaximumSuppression(boxes, iouThreshold);
|
| 52 |
+
|
| 53 |
+
// Crop each detected embryo
|
| 54 |
+
for (const box of selectedBoxes) {
|
| 55 |
+
const croppedData = await cropImage(originalImage, box.x1, box.y1, box.x2, box.y2);
|
| 56 |
+
|
| 57 |
+
detections.push({
|
| 58 |
+
box: box,
|
| 59 |
+
confidence: box.confidence,
|
| 60 |
+
imageData: croppedData
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
return detections;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
/**
|
| 68 |
+
* Non-Maximum Suppression to remove overlapping boxes
|
| 69 |
+
*/
|
| 70 |
+
function nonMaximumSuppression(boxes, iouThreshold) {
|
| 71 |
+
// Sort boxes by confidence (descending)
|
| 72 |
+
boxes.sort((a, b) => b.confidence - a.confidence);
|
| 73 |
+
|
| 74 |
+
const selected = [];
|
| 75 |
+
const suppressed = new Set();
|
| 76 |
+
|
| 77 |
+
for (let i = 0; i < boxes.length; i++) {
|
| 78 |
+
if (suppressed.has(i)) continue;
|
| 79 |
+
|
| 80 |
+
selected.push(boxes[i]);
|
| 81 |
+
|
| 82 |
+
// Suppress overlapping boxes
|
| 83 |
+
for (let j = i + 1; j < boxes.length; j++) {
|
| 84 |
+
if (suppressed.has(j)) continue;
|
| 85 |
+
|
| 86 |
+
const iou = calculateIoU(boxes[i], boxes[j]);
|
| 87 |
+
if (iou > iouThreshold) {
|
| 88 |
+
suppressed.add(j);
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
return selected;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
/**
|
| 97 |
+
* Calculate Intersection over Union (IoU) between two boxes
|
| 98 |
+
*/
|
| 99 |
+
function calculateIoU(box1, box2) {
|
| 100 |
+
const x1 = Math.max(box1.x1, box2.x1);
|
| 101 |
+
const y1 = Math.max(box1.y1, box2.y1);
|
| 102 |
+
const x2 = Math.min(box1.x2, box2.x2);
|
| 103 |
+
const y2 = Math.min(box1.y2, box2.y2);
|
| 104 |
+
|
| 105 |
+
const intersectionArea = Math.max(0, x2 - x1) * Math.max(0, y2 - y1);
|
| 106 |
+
|
| 107 |
+
const box1Area = (box1.x2 - box1.x1) * (box1.y2 - box1.y1);
|
| 108 |
+
const box2Area = (box2.x2 - box2.x1) * (box2.y2 - box2.y1);
|
| 109 |
+
|
| 110 |
+
const unionArea = box1Area + box2Area - intersectionArea;
|
| 111 |
+
|
| 112 |
+
return intersectionArea / unionArea;
|
| 113 |
+
}
|
src/state.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Application State Management
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
export const appState = {
|
| 6 |
+
models: {
|
| 7 |
+
classifier: null,
|
| 8 |
+
poorGood: null,
|
| 9 |
+
grader: null,
|
| 10 |
+
yolo: null
|
| 11 |
+
},
|
| 12 |
+
croppedEmbryos: [],
|
| 13 |
+
embryoResults: [],
|
| 14 |
+
embryoRemarks: {}, // Store remarks for each embryo by index
|
| 15 |
+
currentEmbryoIndex: 0,
|
| 16 |
+
currentImage: null,
|
| 17 |
+
zoomLevel: 1,
|
| 18 |
+
finalResult: null
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Update model state
|
| 23 |
+
*/
|
| 24 |
+
export function setModel(modelName, model) {
|
| 25 |
+
appState.models[modelName] = model;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/**
|
| 29 |
+
* Get model by name
|
| 30 |
+
*/
|
| 31 |
+
export function getModel(modelName) {
|
| 32 |
+
return appState.models[modelName];
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Set cropped embryos
|
| 37 |
+
*/
|
| 38 |
+
export function setCroppedEmbryos(embryos) {
|
| 39 |
+
appState.croppedEmbryos = embryos;
|
| 40 |
+
appState.currentEmbryoIndex = 0;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Get current embryo
|
| 45 |
+
*/
|
| 46 |
+
export function getCurrentEmbryo() {
|
| 47 |
+
return appState.croppedEmbryos[appState.currentEmbryoIndex];
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
/**
|
| 51 |
+
* Set current image
|
| 52 |
+
*/
|
| 53 |
+
export function setCurrentImage(imageData) {
|
| 54 |
+
appState.currentImage = imageData;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
/**
|
| 58 |
+
* Update zoom level
|
| 59 |
+
*/
|
| 60 |
+
export function setZoomLevel(level) {
|
| 61 |
+
appState.zoomLevel = level;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
/**
|
| 65 |
+
* Get zoom level
|
| 66 |
+
*/
|
| 67 |
+
export function getZoomLevel() {
|
| 68 |
+
return appState.zoomLevel;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
/**
|
| 72 |
+
* Set current embryo index
|
| 73 |
+
*/
|
| 74 |
+
export function setCurrentEmbryoIndex(index) {
|
| 75 |
+
appState.currentEmbryoIndex = index;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
/**
|
| 79 |
+
* Get embryo count
|
| 80 |
+
*/
|
| 81 |
+
export function getEmbryoCount() {
|
| 82 |
+
return appState.croppedEmbryos.length;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/**
|
| 86 |
+
* Set remark for an embryo
|
| 87 |
+
*/
|
| 88 |
+
export function setEmbryoRemark(index, remark) {
|
| 89 |
+
appState.embryoRemarks[index] = remark;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
/**
|
| 93 |
+
* Get remark for an embryo
|
| 94 |
+
*/
|
| 95 |
+
export function getEmbryoRemark(index) {
|
| 96 |
+
return appState.embryoRemarks[index] || '';
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
/**
|
| 100 |
+
* Get all remarks
|
| 101 |
+
*/
|
| 102 |
+
export function getAllRemarks() {
|
| 103 |
+
return appState.embryoRemarks;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/**
|
| 107 |
+
* Clear all remarks
|
| 108 |
+
*/
|
| 109 |
+
export function clearAllRemarks() {
|
| 110 |
+
appState.embryoRemarks = {};
|
| 111 |
+
}
|
src/ui/cropEditor.js
ADDED
|
@@ -0,0 +1,604 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { appState } from '../state.js';
|
| 2 |
+
import { DETECTION_CONFIG } from '../config.js';
|
| 3 |
+
import { loadImage } from '../utils/imageUtils.js';
|
| 4 |
+
|
| 5 |
+
let cropState = {
|
| 6 |
+
originalBox: null,
|
| 7 |
+
currentBox: null,
|
| 8 |
+
isDragging: false,
|
| 9 |
+
isResizing: false,
|
| 10 |
+
resizeHandle: null,
|
| 11 |
+
dragStartX: 0,
|
| 12 |
+
dragStartY: 0,
|
| 13 |
+
imageWidth: 0,
|
| 14 |
+
imageHeight: 0,
|
| 15 |
+
loadedImage: null,
|
| 16 |
+
canvas: null,
|
| 17 |
+
scale: 1
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
export function initializeCropEditor() {
|
| 21 |
+
const editCropBtn = document.getElementById('editCropBtn');
|
| 22 |
+
const applyCropBtn = document.getElementById('applyCropBtn');
|
| 23 |
+
const resetCropBtn = document.getElementById('resetCropBtn');
|
| 24 |
+
const cropControls = document.getElementById('cropControls');
|
| 25 |
+
|
| 26 |
+
if (editCropBtn) {
|
| 27 |
+
editCropBtn.addEventListener('click', () => {
|
| 28 |
+
showManualCropEditor();
|
| 29 |
+
});
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
if (resetCropBtn) {
|
| 33 |
+
resetCropBtn.addEventListener('click', () => {
|
| 34 |
+
resetCropBox();
|
| 35 |
+
});
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
if (applyCropBtn) {
|
| 39 |
+
applyCropBtn.addEventListener('click', () => {
|
| 40 |
+
applyCropSettings();
|
| 41 |
+
hideManualCropEditor();
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
export function showCropEditor(embryoIndex) {
|
| 47 |
+
const editCropBtn = document.getElementById('editCropBtn');
|
| 48 |
+
const cropControls = document.getElementById('cropControls');
|
| 49 |
+
const embryo = appState.croppedEmbryos[embryoIndex];
|
| 50 |
+
|
| 51 |
+
if (!embryo || !embryo.box) {
|
| 52 |
+
editCropBtn.style.display = 'none';
|
| 53 |
+
if (cropControls) cropControls.style.display = 'none';
|
| 54 |
+
return;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
// Store original box coordinates
|
| 58 |
+
cropState.originalBox = { ...embryo.box };
|
| 59 |
+
cropState.currentBox = { ...embryo.box };
|
| 60 |
+
|
| 61 |
+
editCropBtn.style.display = 'block';
|
| 62 |
+
|
| 63 |
+
// Always hide crop controls when switching embryos
|
| 64 |
+
if (cropControls) cropControls.style.display = 'none';
|
| 65 |
+
|
| 66 |
+
// Reset the display to show the image, not the canvas
|
| 67 |
+
const croppedImage = document.getElementById('croppedImage');
|
| 68 |
+
if (croppedImage) croppedImage.style.display = 'block';
|
| 69 |
+
|
| 70 |
+
// Hide crop canvas if exists
|
| 71 |
+
const cropCanvas = document.getElementById('manualCropCanvas');
|
| 72 |
+
if (cropCanvas) cropCanvas.style.display = 'none';
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
export function hideCropEditor() {
|
| 76 |
+
const editCropBtn = document.getElementById('editCropBtn');
|
| 77 |
+
const cropControls = document.getElementById('cropControls');
|
| 78 |
+
editCropBtn.style.display = 'none';
|
| 79 |
+
cropControls.style.display = 'none';
|
| 80 |
+
hideManualCropEditor();
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
function showManualCropEditor() {
|
| 84 |
+
const cropControls = document.getElementById('cropControls');
|
| 85 |
+
const editCropBtn = document.getElementById('editCropBtn');
|
| 86 |
+
|
| 87 |
+
cropControls.style.display = 'block';
|
| 88 |
+
editCropBtn.style.display = 'none';
|
| 89 |
+
|
| 90 |
+
// Show touch hint on touch devices
|
| 91 |
+
const touchHint = document.querySelector('.touch-hint');
|
| 92 |
+
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
| 93 |
+
if (touchHint && isTouchDevice) {
|
| 94 |
+
touchHint.style.display = 'block';
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
// Initialize the interactive crop canvas
|
| 98 |
+
initializeInteractiveCrop();
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
function hideManualCropEditor() {
|
| 102 |
+
const cropControls = document.getElementById('cropControls');
|
| 103 |
+
const editCropBtn = document.getElementById('editCropBtn');
|
| 104 |
+
const cropCanvas = document.getElementById('manualCropCanvas');
|
| 105 |
+
const croppedImage = document.getElementById('croppedImage');
|
| 106 |
+
|
| 107 |
+
cropControls.style.display = 'none';
|
| 108 |
+
editCropBtn.style.display = 'block';
|
| 109 |
+
|
| 110 |
+
if (cropCanvas) cropCanvas.style.display = 'none';
|
| 111 |
+
if (croppedImage) croppedImage.style.display = 'block';
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
async function initializeInteractiveCrop() {
|
| 115 |
+
if (!appState.currentImage || !cropState.originalBox) {
|
| 116 |
+
console.error('Missing image or box data');
|
| 117 |
+
return;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
const container = document.getElementById('croppedImagePreview');
|
| 121 |
+
const croppedImage = document.getElementById('croppedImage');
|
| 122 |
+
|
| 123 |
+
if (!container) {
|
| 124 |
+
console.error('Container not found');
|
| 125 |
+
return;
|
| 126 |
+
}
|
| 127 |
+
|
| 128 |
+
// Hide the static image
|
| 129 |
+
if (croppedImage) croppedImage.style.display = 'none';
|
| 130 |
+
|
| 131 |
+
// Create or get canvas
|
| 132 |
+
let canvas = document.getElementById('manualCropCanvas');
|
| 133 |
+
if (!canvas) {
|
| 134 |
+
canvas = document.createElement('canvas');
|
| 135 |
+
canvas.id = 'manualCropCanvas';
|
| 136 |
+
canvas.className = 'manual-crop-canvas';
|
| 137 |
+
container.appendChild(canvas);
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
canvas.style.display = 'block';
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
// Load the original full image
|
| 144 |
+
const img = await loadImage(appState.currentImage);
|
| 145 |
+
|
| 146 |
+
// Store the image in cropState for redraws
|
| 147 |
+
cropState.loadedImage = img;
|
| 148 |
+
|
| 149 |
+
// Set canvas size to fit container while maintaining aspect ratio
|
| 150 |
+
const containerWidth = Math.max(container.clientWidth - 40, 300);
|
| 151 |
+
const containerHeight = 400;
|
| 152 |
+
const scale = Math.min(containerWidth / img.width, containerHeight / img.height, 1);
|
| 153 |
+
|
| 154 |
+
canvas.width = Math.floor(img.width * scale);
|
| 155 |
+
canvas.height = Math.floor(img.height * scale);
|
| 156 |
+
cropState.imageWidth = canvas.width;
|
| 157 |
+
cropState.imageHeight = canvas.height;
|
| 158 |
+
cropState.scale = scale;
|
| 159 |
+
|
| 160 |
+
console.log('Crop editor initialized:', {
|
| 161 |
+
imageSize: `${img.width}x${img.height}`,
|
| 162 |
+
canvasSize: `${canvas.width}x${canvas.height}`,
|
| 163 |
+
scale: scale,
|
| 164 |
+
originalBox: cropState.originalBox
|
| 165 |
+
});
|
| 166 |
+
|
| 167 |
+
// Scale the crop box coordinates
|
| 168 |
+
cropState.currentBox = {
|
| 169 |
+
x1: Math.floor(cropState.originalBox.x1 * scale),
|
| 170 |
+
y1: Math.floor(cropState.originalBox.y1 * scale),
|
| 171 |
+
x2: Math.floor(cropState.originalBox.x2 * scale),
|
| 172 |
+
y2: Math.floor(cropState.originalBox.y2 * scale)
|
| 173 |
+
};
|
| 174 |
+
|
| 175 |
+
console.log('Scaled crop box:', cropState.currentBox);
|
| 176 |
+
|
| 177 |
+
// Draw initial state
|
| 178 |
+
drawCropInterface(canvas);
|
| 179 |
+
|
| 180 |
+
// Add event listeners
|
| 181 |
+
setupCanvasEventListeners(canvas);
|
| 182 |
+
} catch (error) {
|
| 183 |
+
console.error('Error initializing crop editor:', error);
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
function drawCropInterface(canvas) {
|
| 188 |
+
if (!canvas || !cropState.loadedImage || !cropState.currentBox) {
|
| 189 |
+
console.error('Canvas, image, or crop box not available', {
|
| 190 |
+
canvas: !!canvas,
|
| 191 |
+
image: !!cropState.loadedImage,
|
| 192 |
+
box: !!cropState.currentBox
|
| 193 |
+
});
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
const ctx = canvas.getContext('2d');
|
| 198 |
+
const img = cropState.loadedImage;
|
| 199 |
+
|
| 200 |
+
// Clear canvas
|
| 201 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 202 |
+
|
| 203 |
+
// Draw the full image
|
| 204 |
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
| 205 |
+
|
| 206 |
+
// Ensure crop box is within bounds
|
| 207 |
+
const box = cropState.currentBox;
|
| 208 |
+
const x1 = Math.max(0, Math.min(box.x1, canvas.width));
|
| 209 |
+
const y1 = Math.max(0, Math.min(box.y1, canvas.height));
|
| 210 |
+
const x2 = Math.max(0, Math.min(box.x2, canvas.width));
|
| 211 |
+
const y2 = Math.max(0, Math.min(box.y2, canvas.height));
|
| 212 |
+
|
| 213 |
+
// Draw dark overlay outside crop box
|
| 214 |
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
| 215 |
+
|
| 216 |
+
// Top
|
| 217 |
+
ctx.fillRect(0, 0, canvas.width, y1);
|
| 218 |
+
// Bottom
|
| 219 |
+
ctx.fillRect(0, y2, canvas.width, canvas.height - y2);
|
| 220 |
+
// Left
|
| 221 |
+
ctx.fillRect(0, y1, x1, y2 - y1);
|
| 222 |
+
// Right
|
| 223 |
+
ctx.fillRect(x2, y1, canvas.width - x2, y2 - y1);
|
| 224 |
+
|
| 225 |
+
// Draw crop box border
|
| 226 |
+
ctx.strokeStyle = '#00B8D4';
|
| 227 |
+
ctx.lineWidth = 3;
|
| 228 |
+
ctx.strokeRect(
|
| 229 |
+
x1,
|
| 230 |
+
y1,
|
| 231 |
+
x2 - x1,
|
| 232 |
+
y2 - y1
|
| 233 |
+
);
|
| 234 |
+
|
| 235 |
+
// Determine if touch device (larger handles for touch)
|
| 236 |
+
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
| 237 |
+
const handleSize = isTouchDevice ? 20 : 12;
|
| 238 |
+
|
| 239 |
+
// Draw resize handles with white border for better visibility
|
| 240 |
+
ctx.fillStyle = '#00B8D4';
|
| 241 |
+
ctx.strokeStyle = 'white';
|
| 242 |
+
ctx.lineWidth = 2;
|
| 243 |
+
|
| 244 |
+
// Corner handles
|
| 245 |
+
const corners = [
|
| 246 |
+
{ x: x1, y: y1 },
|
| 247 |
+
{ x: x2, y: y1 },
|
| 248 |
+
{ x: x1, y: y2 },
|
| 249 |
+
{ x: x2, y: y2 }
|
| 250 |
+
];
|
| 251 |
+
|
| 252 |
+
corners.forEach(corner => {
|
| 253 |
+
ctx.fillRect(corner.x - handleSize / 2, corner.y - handleSize / 2, handleSize, handleSize);
|
| 254 |
+
ctx.strokeRect(corner.x - handleSize / 2, corner.y - handleSize / 2, handleSize, handleSize);
|
| 255 |
+
});
|
| 256 |
+
|
| 257 |
+
// Edge handles
|
| 258 |
+
const edges = [
|
| 259 |
+
{ x: (x1 + x2) / 2, y: y1 },
|
| 260 |
+
{ x: (x1 + x2) / 2, y: y2 },
|
| 261 |
+
{ x: x1, y: (y1 + y2) / 2 },
|
| 262 |
+
{ x: x2, y: (y1 + y2) / 2 }
|
| 263 |
+
];
|
| 264 |
+
|
| 265 |
+
edges.forEach(edge => {
|
| 266 |
+
ctx.fillRect(edge.x - handleSize / 2, edge.y - handleSize / 2, handleSize, handleSize);
|
| 267 |
+
ctx.strokeRect(edge.x - handleSize / 2, edge.y - handleSize / 2, handleSize, handleSize);
|
| 268 |
+
});
|
| 269 |
+
|
| 270 |
+
// Draw center move icon (larger for touch)
|
| 271 |
+
const centerX = (x1 + x2) / 2;
|
| 272 |
+
const centerY = (y1 + y2) / 2;
|
| 273 |
+
const centerRadius = isTouchDevice ? 20 : 15;
|
| 274 |
+
ctx.fillStyle = 'rgba(0, 184, 212, 0.3)';
|
| 275 |
+
ctx.beginPath();
|
| 276 |
+
ctx.arc(centerX, centerY, centerRadius, 0, Math.PI * 2);
|
| 277 |
+
ctx.fill();
|
| 278 |
+
|
| 279 |
+
// Draw crosshair in center
|
| 280 |
+
ctx.strokeStyle = 'white';
|
| 281 |
+
ctx.lineWidth = 2;
|
| 282 |
+
const crosshairSize = isTouchDevice ? 10 : 8;
|
| 283 |
+
ctx.beginPath();
|
| 284 |
+
ctx.moveTo(centerX - crosshairSize, centerY);
|
| 285 |
+
ctx.lineTo(centerX + crosshairSize, centerY);
|
| 286 |
+
ctx.moveTo(centerX, centerY - crosshairSize);
|
| 287 |
+
ctx.lineTo(centerX, centerY + crosshairSize);
|
| 288 |
+
ctx.stroke();
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
function setupCanvasEventListeners(canvas) {
|
| 292 |
+
// Store canvas reference
|
| 293 |
+
cropState.canvas = canvas;
|
| 294 |
+
|
| 295 |
+
// Mouse events
|
| 296 |
+
canvas.addEventListener('mousedown', handleMouseDown);
|
| 297 |
+
canvas.addEventListener('mousemove', handleMouseMove);
|
| 298 |
+
canvas.addEventListener('mouseup', handleMouseUp);
|
| 299 |
+
canvas.addEventListener('mouseleave', handleMouseUp);
|
| 300 |
+
|
| 301 |
+
// Touch events for mobile/tablet support
|
| 302 |
+
canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
|
| 303 |
+
canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
|
| 304 |
+
canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
|
| 305 |
+
canvas.addEventListener('touchcancel', handleTouchEnd, { passive: false });
|
| 306 |
+
}
|
| 307 |
+
|
| 308 |
+
function handleMouseDown(e) {
|
| 309 |
+
const canvas = e.target;
|
| 310 |
+
const rect = canvas.getBoundingClientRect();
|
| 311 |
+
const x = e.clientX - rect.left;
|
| 312 |
+
const y = e.clientY - rect.top;
|
| 313 |
+
|
| 314 |
+
// Check if clicking on a resize handle
|
| 315 |
+
const handle = getResizeHandle(x, y);
|
| 316 |
+
if (handle) {
|
| 317 |
+
cropState.isResizing = true;
|
| 318 |
+
cropState.resizeHandle = handle;
|
| 319 |
+
cropState.dragStartX = x;
|
| 320 |
+
cropState.dragStartY = y;
|
| 321 |
+
e.preventDefault();
|
| 322 |
+
return;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
// Check if clicking inside crop box for dragging
|
| 326 |
+
if (isInsideCropBox(x, y)) {
|
| 327 |
+
cropState.isDragging = true;
|
| 328 |
+
cropState.dragStartX = x;
|
| 329 |
+
cropState.dragStartY = y;
|
| 330 |
+
e.preventDefault();
|
| 331 |
+
}
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
function handleMouseMove(e) {
|
| 335 |
+
const canvas = e.target;
|
| 336 |
+
const rect = canvas.getBoundingClientRect();
|
| 337 |
+
const x = e.clientX - rect.left;
|
| 338 |
+
const y = e.clientY - rect.top;
|
| 339 |
+
|
| 340 |
+
// Update cursor
|
| 341 |
+
if (!cropState.isDragging && !cropState.isResizing) {
|
| 342 |
+
const handle = getResizeHandle(x, y);
|
| 343 |
+
if (handle) {
|
| 344 |
+
canvas.style.cursor = getCursorStyle(handle);
|
| 345 |
+
} else if (isInsideCropBox(x, y)) {
|
| 346 |
+
canvas.style.cursor = 'move';
|
| 347 |
+
} else {
|
| 348 |
+
canvas.style.cursor = 'default';
|
| 349 |
+
}
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
if (cropState.isResizing) {
|
| 353 |
+
handleResize(x, y);
|
| 354 |
+
drawCropInterface(canvas);
|
| 355 |
+
e.preventDefault();
|
| 356 |
+
} else if (cropState.isDragging) {
|
| 357 |
+
handleDrag(x, y);
|
| 358 |
+
drawCropInterface(canvas);
|
| 359 |
+
e.preventDefault();
|
| 360 |
+
}
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
function handleMouseUp(e) {
|
| 364 |
+
cropState.isDragging = false;
|
| 365 |
+
cropState.isResizing = false;
|
| 366 |
+
cropState.resizeHandle = null;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
// Touch event handlers
|
| 370 |
+
function handleTouchStart(e) {
|
| 371 |
+
// Only handle single touch
|
| 372 |
+
if (e.touches.length !== 1) return;
|
| 373 |
+
|
| 374 |
+
e.preventDefault(); // Prevent scrolling
|
| 375 |
+
|
| 376 |
+
const canvas = e.target;
|
| 377 |
+
const rect = canvas.getBoundingClientRect();
|
| 378 |
+
const touch = e.touches[0];
|
| 379 |
+
const x = touch.clientX - rect.left;
|
| 380 |
+
const y = touch.clientY - rect.top;
|
| 381 |
+
|
| 382 |
+
// Check if touching a resize handle (increased threshold for touch)
|
| 383 |
+
const handle = getResizeHandle(x, y, true);
|
| 384 |
+
if (handle) {
|
| 385 |
+
cropState.isResizing = true;
|
| 386 |
+
cropState.resizeHandle = handle;
|
| 387 |
+
cropState.dragStartX = x;
|
| 388 |
+
cropState.dragStartY = y;
|
| 389 |
+
return;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
// Check if touching inside crop box for dragging
|
| 393 |
+
if (isInsideCropBox(x, y)) {
|
| 394 |
+
cropState.isDragging = true;
|
| 395 |
+
cropState.dragStartX = x;
|
| 396 |
+
cropState.dragStartY = y;
|
| 397 |
+
}
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
function handleTouchMove(e) {
|
| 401 |
+
// Only handle single touch
|
| 402 |
+
if (e.touches.length !== 1) return;
|
| 403 |
+
|
| 404 |
+
// Prevent scrolling while manipulating crop box
|
| 405 |
+
if (cropState.isDragging || cropState.isResizing) {
|
| 406 |
+
e.preventDefault();
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
const canvas = e.target;
|
| 410 |
+
const rect = canvas.getBoundingClientRect();
|
| 411 |
+
const touch = e.touches[0];
|
| 412 |
+
const x = touch.clientX - rect.left;
|
| 413 |
+
const y = touch.clientY - rect.top;
|
| 414 |
+
|
| 415 |
+
if (cropState.isResizing) {
|
| 416 |
+
handleResize(x, y);
|
| 417 |
+
drawCropInterface(canvas);
|
| 418 |
+
} else if (cropState.isDragging) {
|
| 419 |
+
handleDrag(x, y);
|
| 420 |
+
drawCropInterface(canvas);
|
| 421 |
+
}
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
function handleTouchEnd(e) {
|
| 425 |
+
e.preventDefault();
|
| 426 |
+
cropState.isDragging = false;
|
| 427 |
+
cropState.isResizing = false;
|
| 428 |
+
cropState.resizeHandle = null;
|
| 429 |
+
}
|
| 430 |
+
|
| 431 |
+
function getResizeHandle(x, y, isTouch = false) {
|
| 432 |
+
// Larger threshold for touch screens
|
| 433 |
+
const threshold = isTouch ? 25 : 15;
|
| 434 |
+
|
| 435 |
+
// Corner handles
|
| 436 |
+
if (Math.abs(x - cropState.currentBox.x1) < threshold && Math.abs(y - cropState.currentBox.y1) < threshold) return 'nw';
|
| 437 |
+
if (Math.abs(x - cropState.currentBox.x2) < threshold && Math.abs(y - cropState.currentBox.y1) < threshold) return 'ne';
|
| 438 |
+
if (Math.abs(x - cropState.currentBox.x1) < threshold && Math.abs(y - cropState.currentBox.y2) < threshold) return 'sw';
|
| 439 |
+
if (Math.abs(x - cropState.currentBox.x2) < threshold && Math.abs(y - cropState.currentBox.y2) < threshold) return 'se';
|
| 440 |
+
|
| 441 |
+
// Edge handles
|
| 442 |
+
const centerX = (cropState.currentBox.x1 + cropState.currentBox.x2) / 2;
|
| 443 |
+
const centerY = (cropState.currentBox.y1 + cropState.currentBox.y2) / 2;
|
| 444 |
+
|
| 445 |
+
if (Math.abs(x - centerX) < threshold && Math.abs(y - cropState.currentBox.y1) < threshold) return 'n';
|
| 446 |
+
if (Math.abs(x - centerX) < threshold && Math.abs(y - cropState.currentBox.y2) < threshold) return 's';
|
| 447 |
+
if (Math.abs(x - cropState.currentBox.x1) < threshold && Math.abs(y - centerY) < threshold) return 'w';
|
| 448 |
+
if (Math.abs(x - cropState.currentBox.x2) < threshold && Math.abs(y - centerY) < threshold) return 'e';
|
| 449 |
+
|
| 450 |
+
return null;
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
function getCursorStyle(handle) {
|
| 454 |
+
const cursors = {
|
| 455 |
+
'nw': 'nw-resize',
|
| 456 |
+
'ne': 'ne-resize',
|
| 457 |
+
'sw': 'sw-resize',
|
| 458 |
+
'se': 'se-resize',
|
| 459 |
+
'n': 'n-resize',
|
| 460 |
+
's': 's-resize',
|
| 461 |
+
'w': 'w-resize',
|
| 462 |
+
'e': 'e-resize'
|
| 463 |
+
};
|
| 464 |
+
return cursors[handle] || 'default';
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function isInsideCropBox(x, y) {
|
| 468 |
+
return x >= cropState.currentBox.x1 && x <= cropState.currentBox.x2 &&
|
| 469 |
+
y >= cropState.currentBox.y1 && y <= cropState.currentBox.y2;
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
function handleResize(x, y) {
|
| 473 |
+
const minSize = 50;
|
| 474 |
+
|
| 475 |
+
switch (cropState.resizeHandle) {
|
| 476 |
+
case 'nw':
|
| 477 |
+
cropState.currentBox.x1 = Math.max(0, Math.min(x, cropState.currentBox.x2 - minSize));
|
| 478 |
+
cropState.currentBox.y1 = Math.max(0, Math.min(y, cropState.currentBox.y2 - minSize));
|
| 479 |
+
break;
|
| 480 |
+
case 'ne':
|
| 481 |
+
cropState.currentBox.x2 = Math.min(cropState.imageWidth, Math.max(x, cropState.currentBox.x1 + minSize));
|
| 482 |
+
cropState.currentBox.y1 = Math.max(0, Math.min(y, cropState.currentBox.y2 - minSize));
|
| 483 |
+
break;
|
| 484 |
+
case 'sw':
|
| 485 |
+
cropState.currentBox.x1 = Math.max(0, Math.min(x, cropState.currentBox.x2 - minSize));
|
| 486 |
+
cropState.currentBox.y2 = Math.min(cropState.imageHeight, Math.max(y, cropState.currentBox.y1 + minSize));
|
| 487 |
+
break;
|
| 488 |
+
case 'se':
|
| 489 |
+
cropState.currentBox.x2 = Math.min(cropState.imageWidth, Math.max(x, cropState.currentBox.x1 + minSize));
|
| 490 |
+
cropState.currentBox.y2 = Math.min(cropState.imageHeight, Math.max(y, cropState.currentBox.y1 + minSize));
|
| 491 |
+
break;
|
| 492 |
+
case 'n':
|
| 493 |
+
cropState.currentBox.y1 = Math.max(0, Math.min(y, cropState.currentBox.y2 - minSize));
|
| 494 |
+
break;
|
| 495 |
+
case 's':
|
| 496 |
+
cropState.currentBox.y2 = Math.min(cropState.imageHeight, Math.max(y, cropState.currentBox.y1 + minSize));
|
| 497 |
+
break;
|
| 498 |
+
case 'w':
|
| 499 |
+
cropState.currentBox.x1 = Math.max(0, Math.min(x, cropState.currentBox.x2 - minSize));
|
| 500 |
+
break;
|
| 501 |
+
case 'e':
|
| 502 |
+
cropState.currentBox.x2 = Math.min(cropState.imageWidth, Math.max(x, cropState.currentBox.x1 + minSize));
|
| 503 |
+
break;
|
| 504 |
+
}
|
| 505 |
+
}
|
| 506 |
+
|
| 507 |
+
function handleDrag(x, y) {
|
| 508 |
+
const dx = x - cropState.dragStartX;
|
| 509 |
+
const dy = y - cropState.dragStartY;
|
| 510 |
+
|
| 511 |
+
const boxWidth = cropState.currentBox.x2 - cropState.currentBox.x1;
|
| 512 |
+
const boxHeight = cropState.currentBox.y2 - cropState.currentBox.y1;
|
| 513 |
+
|
| 514 |
+
let newX1 = cropState.currentBox.x1 + dx;
|
| 515 |
+
let newY1 = cropState.currentBox.y1 + dy;
|
| 516 |
+
|
| 517 |
+
// Clamp to image bounds
|
| 518 |
+
if (newX1 < 0) newX1 = 0;
|
| 519 |
+
if (newY1 < 0) newY1 = 0;
|
| 520 |
+
if (newX1 + boxWidth > cropState.imageWidth) newX1 = cropState.imageWidth - boxWidth;
|
| 521 |
+
if (newY1 + boxHeight > cropState.imageHeight) newY1 = cropState.imageHeight - boxHeight;
|
| 522 |
+
|
| 523 |
+
cropState.currentBox.x1 = newX1;
|
| 524 |
+
cropState.currentBox.y1 = newY1;
|
| 525 |
+
cropState.currentBox.x2 = newX1 + boxWidth;
|
| 526 |
+
cropState.currentBox.y2 = newY1 + boxHeight;
|
| 527 |
+
|
| 528 |
+
cropState.dragStartX = x;
|
| 529 |
+
cropState.dragStartY = y;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
function resetCropBox() {
|
| 533 |
+
if (!cropState.originalBox || !cropState.scale) return;
|
| 534 |
+
|
| 535 |
+
const canvas = document.getElementById('manualCropCanvas');
|
| 536 |
+
if (!canvas) return;
|
| 537 |
+
|
| 538 |
+
// Reset to original box with current scale
|
| 539 |
+
cropState.currentBox = {
|
| 540 |
+
x1: cropState.originalBox.x1 * cropState.scale,
|
| 541 |
+
y1: cropState.originalBox.y1 * cropState.scale,
|
| 542 |
+
x2: cropState.originalBox.x2 * cropState.scale,
|
| 543 |
+
y2: cropState.originalBox.y2 * cropState.scale
|
| 544 |
+
};
|
| 545 |
+
|
| 546 |
+
drawCropInterface(canvas);
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
async function applyCropSettings() {
|
| 550 |
+
if (appState.currentEmbryoIndex === undefined || !cropState.currentBox) return;
|
| 551 |
+
|
| 552 |
+
const embryo = appState.croppedEmbryos[appState.currentEmbryoIndex];
|
| 553 |
+
if (!embryo) return;
|
| 554 |
+
|
| 555 |
+
// Load original image
|
| 556 |
+
const img = await loadImage(appState.currentImage);
|
| 557 |
+
|
| 558 |
+
// Convert canvas coordinates back to image coordinates
|
| 559 |
+
const scaleX = img.width / cropState.imageWidth;
|
| 560 |
+
const scaleY = img.height / cropState.imageHeight;
|
| 561 |
+
|
| 562 |
+
const x1 = cropState.currentBox.x1 * scaleX;
|
| 563 |
+
const y1 = cropState.currentBox.y1 * scaleY;
|
| 564 |
+
const x2 = cropState.currentBox.x2 * scaleX;
|
| 565 |
+
const y2 = cropState.currentBox.y2 * scaleY;
|
| 566 |
+
|
| 567 |
+
// Crop the image
|
| 568 |
+
const canvas = document.createElement('canvas');
|
| 569 |
+
const ctx = canvas.getContext('2d');
|
| 570 |
+
canvas.width = x2 - x1;
|
| 571 |
+
canvas.height = y2 - y1;
|
| 572 |
+
|
| 573 |
+
ctx.drawImage(img, x1, y1, x2 - x1, y2 - y1, 0, 0, canvas.width, canvas.height);
|
| 574 |
+
|
| 575 |
+
const croppedImageData = canvas.toDataURL();
|
| 576 |
+
|
| 577 |
+
// Update the embryo's cropped image
|
| 578 |
+
embryo.imageData = croppedImageData;
|
| 579 |
+
|
| 580 |
+
// Update the box coordinates
|
| 581 |
+
embryo.box = {
|
| 582 |
+
x1: x1,
|
| 583 |
+
y1: y1,
|
| 584 |
+
x2: x2,
|
| 585 |
+
y2: y2
|
| 586 |
+
};
|
| 587 |
+
|
| 588 |
+
// Update display
|
| 589 |
+
const croppedImage = document.getElementById('croppedImage');
|
| 590 |
+
if (croppedImage) {
|
| 591 |
+
croppedImage.src = croppedImageData;
|
| 592 |
+
croppedImage.style.display = 'block';
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
// Update thumbnail
|
| 596 |
+
updateThumbnail(appState.currentEmbryoIndex, croppedImageData);
|
| 597 |
+
}
|
| 598 |
+
|
| 599 |
+
function updateThumbnail(index, imageData) {
|
| 600 |
+
const thumbnail = document.querySelector(`[data-embryo-index="${index}"] img`);
|
| 601 |
+
if (thumbnail) {
|
| 602 |
+
thumbnail.src = imageData;
|
| 603 |
+
}
|
| 604 |
+
}
|
src/ui/eventHandlers.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Event Handlers - Setup and manage UI event listeners
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { displayMainImage } from './imageDisplay.js';
|
| 6 |
+
import { classifyImage, navigateEmbryo } from './workflow.js';
|
| 7 |
+
import { setupZoomListeners } from './zoom.js';
|
| 8 |
+
import { readFileAsDataURL } from '../utils/imageUtils.js';
|
| 9 |
+
import { appState } from '../state.js';
|
| 10 |
+
|
| 11 |
+
/**
|
| 12 |
+
* Setup all UI event listeners
|
| 13 |
+
*/
|
| 14 |
+
export function setupEventListeners() {
|
| 15 |
+
setupZoomListeners();
|
| 16 |
+
setupImageUploadListeners();
|
| 17 |
+
setupWorkflowListeners();
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Setup image upload listeners
|
| 22 |
+
*/
|
| 23 |
+
function setupImageUploadListeners() {
|
| 24 |
+
// Main workflow image upload
|
| 25 |
+
const imageInput = document.getElementById('imageInput');
|
| 26 |
+
if (imageInput) {
|
| 27 |
+
imageInput.addEventListener('change', (e) => handleImageUpload(e));
|
| 28 |
+
}
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
+
/**
|
| 32 |
+
* Setup workflow listeners
|
| 33 |
+
*/
|
| 34 |
+
function setupWorkflowListeners() {
|
| 35 |
+
// No longer needed - inline detection handles its own interactions
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
/**
|
| 39 |
+
* Handle image upload - auto-classify
|
| 40 |
+
*/
|
| 41 |
+
async function handleImageUpload(event) {
|
| 42 |
+
const file = event.target.files[0];
|
| 43 |
+
if (!file) return;
|
| 44 |
+
|
| 45 |
+
// Validate file type
|
| 46 |
+
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/bmp'];
|
| 47 |
+
if (!validTypes.includes(file.type.toLowerCase())) {
|
| 48 |
+
import('./toast.js').then(module => {
|
| 49 |
+
module.showToast('Please upload a valid image file (JPEG, PNG, WebP, or BMP)', 'error');
|
| 50 |
+
});
|
| 51 |
+
event.target.value = '';
|
| 52 |
+
return;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Validate file size (max 10MB)
|
| 56 |
+
const maxSize = 10 * 1024 * 1024; // 10MB in bytes
|
| 57 |
+
if (file.size > maxSize) {
|
| 58 |
+
import('./toast.js').then(module => {
|
| 59 |
+
module.showToast('Image file is too large. Please upload an image smaller than 10MB', 'error');
|
| 60 |
+
});
|
| 61 |
+
event.target.value = '';
|
| 62 |
+
return;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
try {
|
| 66 |
+
const imageData = await readFileAsDataURL(file);
|
| 67 |
+
await displayMainImage(imageData);
|
| 68 |
+
|
| 69 |
+
// Auto-trigger classification
|
| 70 |
+
await classifyImage(imageData);
|
| 71 |
+
} catch (error) {
|
| 72 |
+
console.error('Image upload error:', error);
|
| 73 |
+
import('./toast.js').then(module => {
|
| 74 |
+
module.showToast(`Failed to load image: ${error.message}`, 'error');
|
| 75 |
+
});
|
| 76 |
+
event.target.value = '';
|
| 77 |
+
}
|
| 78 |
+
}
|
src/ui/imageDisplay.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Image Display - Handle image preview and display
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { setCurrentImage } from '../state.js';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Display image in main preview
|
| 9 |
+
*/
|
| 10 |
+
export async function displayMainImage(imageData) {
|
| 11 |
+
const preview = document.getElementById('mainImagePreview');
|
| 12 |
+
const img = document.getElementById('mainImage');
|
| 13 |
+
const placeholder = preview.querySelector('.placeholder');
|
| 14 |
+
|
| 15 |
+
if (!img || !placeholder) return;
|
| 16 |
+
|
| 17 |
+
img.src = imageData;
|
| 18 |
+
img.style.display = 'block';
|
| 19 |
+
placeholder.style.display = 'none';
|
| 20 |
+
|
| 21 |
+
setCurrentImage(imageData);
|
| 22 |
+
|
| 23 |
+
// Reset zoom level and transform
|
| 24 |
+
import('../state.js').then(module => {
|
| 25 |
+
module.setZoomLevel(1);
|
| 26 |
+
});
|
| 27 |
+
img.style.transform = 'scale(1)';
|
| 28 |
+
|
| 29 |
+
// Update zoom display
|
| 30 |
+
const zoomDisplay = document.getElementById('zoomLevel');
|
| 31 |
+
if (zoomDisplay) {
|
| 32 |
+
zoomDisplay.textContent = 'Zoom: 100%';
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
// Enable zoom controls
|
| 36 |
+
const zoomIn = document.getElementById('zoomIn');
|
| 37 |
+
const zoomOut = document.getElementById('zoomOut');
|
| 38 |
+
const zoomReset = document.getElementById('zoomReset');
|
| 39 |
+
|
| 40 |
+
if (zoomIn) zoomIn.disabled = false;
|
| 41 |
+
if (zoomOut) zoomOut.disabled = false;
|
| 42 |
+
if (zoomReset) zoomReset.disabled = false;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
/**
|
| 46 |
+
* Display image in quick preview
|
| 47 |
+
*/
|
| 48 |
+
export function displayQuickImage(imageData) {
|
| 49 |
+
const preview = document.getElementById('quickImagePreview');
|
| 50 |
+
const img = document.getElementById('quickImage');
|
| 51 |
+
const placeholder = preview.querySelector('.placeholder');
|
| 52 |
+
|
| 53 |
+
if (!img || !placeholder) return;
|
| 54 |
+
|
| 55 |
+
img.src = imageData;
|
| 56 |
+
img.style.display = 'block';
|
| 57 |
+
placeholder.style.display = 'none';
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
/**
|
| 61 |
+
* Display cropped embryo
|
| 62 |
+
*/
|
| 63 |
+
export function displayCroppedEmbryo(index, detection, totalCount) {
|
| 64 |
+
const img = document.getElementById('croppedImage');
|
| 65 |
+
const counter = document.getElementById('embryoCounter');
|
| 66 |
+
|
| 67 |
+
if (img) {
|
| 68 |
+
img.src = detection.imageData;
|
| 69 |
+
img.style.display = 'block';
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
if (counter) {
|
| 73 |
+
counter.textContent = `Embryo ${index + 1} of ${totalCount}`;
|
| 74 |
+
}
|
| 75 |
+
}
|
src/ui/inlineDetection.js
ADDED
|
@@ -0,0 +1,766 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Inline Detection View - Show all detected embryos on the main image
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { appState, setCurrentEmbryoIndex, getEmbryoRemark } from '../state.js';
|
| 6 |
+
import { loadImage } from '../utils/imageUtils.js';
|
| 7 |
+
import { showToast } from './toast.js';
|
| 8 |
+
import { showConfirmModal } from './modal.js';
|
| 9 |
+
|
| 10 |
+
let canvas, ctx;
|
| 11 |
+
let selectedEmbryoIndex = null;
|
| 12 |
+
let isDragging = false;
|
| 13 |
+
let dragType = null; // 'move', 'nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'
|
| 14 |
+
let dragStart = { x: 0, y: 0 };
|
| 15 |
+
let originalBox = null;
|
| 16 |
+
let imageElement = null;
|
| 17 |
+
let scaleFactor = 1;
|
| 18 |
+
let offsetX = 0;
|
| 19 |
+
let offsetY = 0;
|
| 20 |
+
|
| 21 |
+
const HANDLE_SIZE = 10;
|
| 22 |
+
const COLORS = [
|
| 23 |
+
'#FF6B6B', '#4ECDC4', '#45B7D1', '#FFA07A', '#98D8C8',
|
| 24 |
+
'#F7DC6F', '#BB8FCE', '#85C1E2', '#F8B739', '#52B788'
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
/**
|
| 28 |
+
* Initialize the inline detection canvas
|
| 29 |
+
*/
|
| 30 |
+
export async function initializeInlineDetection(imageData, detections) {
|
| 31 |
+
canvas = document.getElementById('detectionCanvas');
|
| 32 |
+
if (!canvas) {
|
| 33 |
+
console.error('Detection canvas not found');
|
| 34 |
+
showToast('Failed to initialize detection view', 'error');
|
| 35 |
+
return;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
ctx = canvas.getContext('2d');
|
| 39 |
+
if (!ctx) {
|
| 40 |
+
console.error('Failed to get canvas context');
|
| 41 |
+
showToast('Canvas not supported', 'error');
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Load the original image
|
| 46 |
+
imageElement = await loadImage(imageData);
|
| 47 |
+
|
| 48 |
+
// Set canvas size to match image
|
| 49 |
+
canvas.width = imageElement.width;
|
| 50 |
+
canvas.height = imageElement.height;
|
| 51 |
+
|
| 52 |
+
// Calculate scale factor for display
|
| 53 |
+
const container = canvas.parentElement;
|
| 54 |
+
if (!container) {
|
| 55 |
+
console.error('Canvas container not found');
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const maxWidth = container.clientWidth - 40;
|
| 60 |
+
const maxHeight = 600;
|
| 61 |
+
|
| 62 |
+
scaleFactor = Math.min(
|
| 63 |
+
maxWidth / imageElement.width,
|
| 64 |
+
maxHeight / imageElement.height,
|
| 65 |
+
1
|
| 66 |
+
);
|
| 67 |
+
|
| 68 |
+
canvas.style.width = `${imageElement.width * scaleFactor}px`;
|
| 69 |
+
canvas.style.height = `${imageElement.height * scaleFactor}px`;
|
| 70 |
+
|
| 71 |
+
// Draw initial view
|
| 72 |
+
drawDetections();
|
| 73 |
+
|
| 74 |
+
// Add event listeners
|
| 75 |
+
canvas.addEventListener('mousedown', handleMouseDown);
|
| 76 |
+
canvas.addEventListener('mousemove', handleMouseMove);
|
| 77 |
+
canvas.addEventListener('mouseup', handleMouseUp);
|
| 78 |
+
canvas.addEventListener('mouseleave', handleMouseUp);
|
| 79 |
+
|
| 80 |
+
// Touch events for mobile
|
| 81 |
+
canvas.addEventListener('touchstart', handleTouchStart);
|
| 82 |
+
canvas.addEventListener('touchmove', handleTouchMove);
|
| 83 |
+
canvas.addEventListener('touchend', handleTouchEnd);
|
| 84 |
+
|
| 85 |
+
// Update embryo list
|
| 86 |
+
updateEmbryoList(detections);
|
| 87 |
+
|
| 88 |
+
// Setup detection info with manual add button
|
| 89 |
+
const info = document.getElementById('detectionInfo');
|
| 90 |
+
if (info) {
|
| 91 |
+
info.innerHTML = `
|
| 92 |
+
<p>Click on an embryo to adjust its crop area</p>
|
| 93 |
+
<button class="btn btn-secondary" id="addManualEmbryoBtn" style="margin-top: 10px;">
|
| 94 |
+
+ Add Embryo Manually
|
| 95 |
+
</button>
|
| 96 |
+
`;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
// Setup manual add button
|
| 100 |
+
setupManualAddButton();
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
/**
|
| 104 |
+
* Draw all detections on the canvas
|
| 105 |
+
*/
|
| 106 |
+
function drawDetections() {
|
| 107 |
+
if (!imageElement || !ctx) return;
|
| 108 |
+
|
| 109 |
+
// Clear canvas
|
| 110 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 111 |
+
|
| 112 |
+
// Draw the original image
|
| 113 |
+
ctx.drawImage(imageElement, 0, 0, canvas.width, canvas.height);
|
| 114 |
+
|
| 115 |
+
// Draw all bounding boxes
|
| 116 |
+
appState.croppedEmbryos.forEach((detection, index) => {
|
| 117 |
+
const isSelected = index === selectedEmbryoIndex;
|
| 118 |
+
const color = COLORS[index % COLORS.length];
|
| 119 |
+
|
| 120 |
+
drawBoundingBox(detection.box, color, isSelected, index);
|
| 121 |
+
});
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
/**
|
| 125 |
+
* Draw a single bounding box
|
| 126 |
+
*/
|
| 127 |
+
function drawBoundingBox(box, color, isSelected, index) {
|
| 128 |
+
const lineWidth = isSelected ? 3 : 2;
|
| 129 |
+
const alpha = isSelected ? 1 : 0.7;
|
| 130 |
+
|
| 131 |
+
// Draw box
|
| 132 |
+
ctx.strokeStyle = color;
|
| 133 |
+
ctx.lineWidth = lineWidth;
|
| 134 |
+
ctx.globalAlpha = alpha;
|
| 135 |
+
ctx.strokeRect(box.x1, box.y1, box.x2 - box.x1, box.y2 - box.y1);
|
| 136 |
+
|
| 137 |
+
// Draw label background
|
| 138 |
+
const label = `Embryo ${index + 1}`;
|
| 139 |
+
ctx.font = '14px Arial';
|
| 140 |
+
const textWidth = ctx.measureText(label).width;
|
| 141 |
+
const labelHeight = 20;
|
| 142 |
+
|
| 143 |
+
ctx.fillStyle = color;
|
| 144 |
+
ctx.globalAlpha = 0.9;
|
| 145 |
+
ctx.fillRect(box.x1, box.y1 - labelHeight, textWidth + 10, labelHeight);
|
| 146 |
+
|
| 147 |
+
// Draw label text
|
| 148 |
+
ctx.fillStyle = 'white';
|
| 149 |
+
ctx.globalAlpha = 1;
|
| 150 |
+
ctx.fillText(label, box.x1 + 5, box.y1 - 5);
|
| 151 |
+
|
| 152 |
+
// Draw resize handles if selected
|
| 153 |
+
if (isSelected) {
|
| 154 |
+
drawResizeHandles(box, color);
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
ctx.globalAlpha = 1;
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/**
|
| 161 |
+
* Draw resize handles on the selected box
|
| 162 |
+
*/
|
| 163 |
+
function drawResizeHandles(box, color) {
|
| 164 |
+
const handles = [
|
| 165 |
+
{ x: box.x1, y: box.y1, type: 'nw' },
|
| 166 |
+
{ x: box.x2, y: box.y1, type: 'ne' },
|
| 167 |
+
{ x: box.x1, y: box.y2, type: 'sw' },
|
| 168 |
+
{ x: box.x2, y: box.y2, type: 'se' },
|
| 169 |
+
{ x: (box.x1 + box.x2) / 2, y: box.y1, type: 'n' },
|
| 170 |
+
{ x: (box.x1 + box.x2) / 2, y: box.y2, type: 's' },
|
| 171 |
+
{ x: box.x1, y: (box.y1 + box.y2) / 2, type: 'w' },
|
| 172 |
+
{ x: box.x2, y: (box.y1 + box.y2) / 2, type: 'e' }
|
| 173 |
+
];
|
| 174 |
+
|
| 175 |
+
ctx.fillStyle = color;
|
| 176 |
+
handles.forEach(handle => {
|
| 177 |
+
ctx.fillRect(
|
| 178 |
+
handle.x - HANDLE_SIZE / 2,
|
| 179 |
+
handle.y - HANDLE_SIZE / 2,
|
| 180 |
+
HANDLE_SIZE,
|
| 181 |
+
HANDLE_SIZE
|
| 182 |
+
);
|
| 183 |
+
});
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* Get mouse position relative to canvas
|
| 188 |
+
*/
|
| 189 |
+
function getMousePos(e) {
|
| 190 |
+
const rect = canvas.getBoundingClientRect();
|
| 191 |
+
const scaleX = canvas.width / rect.width;
|
| 192 |
+
const scaleY = canvas.height / rect.height;
|
| 193 |
+
|
| 194 |
+
return {
|
| 195 |
+
x: (e.clientX - rect.left) * scaleX,
|
| 196 |
+
y: (e.clientY - rect.top) * scaleY
|
| 197 |
+
};
|
| 198 |
+
}
|
| 199 |
+
|
| 200 |
+
/**
|
| 201 |
+
* Get handle at position
|
| 202 |
+
*/
|
| 203 |
+
function getHandleAtPosition(box, x, y) {
|
| 204 |
+
const handles = [
|
| 205 |
+
{ x: box.x1, y: box.y1, type: 'nw' },
|
| 206 |
+
{ x: box.x2, y: box.y1, type: 'ne' },
|
| 207 |
+
{ x: box.x1, y: box.y2, type: 'sw' },
|
| 208 |
+
{ x: box.x2, y: box.y2, type: 'se' },
|
| 209 |
+
{ x: (box.x1 + box.x2) / 2, y: box.y1, type: 'n' },
|
| 210 |
+
{ x: (box.x1 + box.x2) / 2, y: box.y2, type: 's' },
|
| 211 |
+
{ x: box.x1, y: (box.y1 + box.y2) / 2, type: 'w' },
|
| 212 |
+
{ x: box.x2, y: (box.y1 + box.y2) / 2, type: 'e' }
|
| 213 |
+
];
|
| 214 |
+
|
| 215 |
+
for (const handle of handles) {
|
| 216 |
+
const dist = Math.sqrt(
|
| 217 |
+
Math.pow(x - handle.x, 2) + Math.pow(y - handle.y, 2)
|
| 218 |
+
);
|
| 219 |
+
if (dist < HANDLE_SIZE) {
|
| 220 |
+
return handle.type;
|
| 221 |
+
}
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
return null;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
/**
|
| 228 |
+
* Check if point is inside a box
|
| 229 |
+
*/
|
| 230 |
+
function isPointInBox(box, x, y) {
|
| 231 |
+
return x >= box.x1 && x <= box.x2 && y >= box.y1 && y <= box.y2;
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
/**
|
| 235 |
+
* Handle mouse down
|
| 236 |
+
*/
|
| 237 |
+
function handleMouseDown(e) {
|
| 238 |
+
const pos = getMousePos(e);
|
| 239 |
+
|
| 240 |
+
// If in manual draw mode, start drawing new box
|
| 241 |
+
if (isDrawingNewBox) {
|
| 242 |
+
newBoxStart = pos;
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
// Check if clicking on selected box handles first
|
| 247 |
+
if (selectedEmbryoIndex !== null) {
|
| 248 |
+
const box = appState.croppedEmbryos[selectedEmbryoIndex].box;
|
| 249 |
+
const handle = getHandleAtPosition(box, pos.x, pos.y);
|
| 250 |
+
|
| 251 |
+
if (handle) {
|
| 252 |
+
isDragging = true;
|
| 253 |
+
dragType = handle;
|
| 254 |
+
dragStart = pos;
|
| 255 |
+
originalBox = { ...box };
|
| 256 |
+
return;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Check if clicking inside selected box to move it
|
| 260 |
+
if (isPointInBox(box, pos.x, pos.y)) {
|
| 261 |
+
isDragging = true;
|
| 262 |
+
dragType = 'move';
|
| 263 |
+
dragStart = pos;
|
| 264 |
+
originalBox = { ...box };
|
| 265 |
+
return;
|
| 266 |
+
}
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
// Check if clicking on any box to select it
|
| 270 |
+
for (let i = appState.croppedEmbryos.length - 1; i >= 0; i--) {
|
| 271 |
+
const box = appState.croppedEmbryos[i].box;
|
| 272 |
+
if (isPointInBox(box, pos.x, pos.y)) {
|
| 273 |
+
selectEmbryo(i);
|
| 274 |
+
return;
|
| 275 |
+
}
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
// Clicked outside all boxes - deselect
|
| 279 |
+
selectedEmbryoIndex = null;
|
| 280 |
+
drawDetections();
|
| 281 |
+
updateEmbryoList(appState.croppedEmbryos);
|
| 282 |
+
|
| 283 |
+
// Reset detection info with button
|
| 284 |
+
const info = document.getElementById('detectionInfo');
|
| 285 |
+
if (info) {
|
| 286 |
+
info.innerHTML = `
|
| 287 |
+
<p>Click on an embryo to adjust its crop area</p>
|
| 288 |
+
<button class="btn btn-secondary" id="addManualEmbryoBtn" style="margin-top: 10px;">
|
| 289 |
+
+ Add Embryo Manually
|
| 290 |
+
</button>
|
| 291 |
+
`;
|
| 292 |
+
setupManualAddButton();
|
| 293 |
+
}
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
/**
|
| 297 |
+
* Handle mouse move
|
| 298 |
+
*/
|
| 299 |
+
function handleMouseMove(e) {
|
| 300 |
+
const pos = getMousePos(e);
|
| 301 |
+
|
| 302 |
+
// If drawing new box, show preview
|
| 303 |
+
if (isDrawingNewBox && newBoxStart) {
|
| 304 |
+
drawDetections();
|
| 305 |
+
|
| 306 |
+
// Draw preview box
|
| 307 |
+
ctx.strokeStyle = '#00B8D4';
|
| 308 |
+
ctx.lineWidth = 3;
|
| 309 |
+
ctx.setLineDash([5, 5]);
|
| 310 |
+
ctx.strokeRect(
|
| 311 |
+
newBoxStart.x,
|
| 312 |
+
newBoxStart.y,
|
| 313 |
+
pos.x - newBoxStart.x,
|
| 314 |
+
pos.y - newBoxStart.y
|
| 315 |
+
);
|
| 316 |
+
ctx.setLineDash([]);
|
| 317 |
+
return;
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
if (!isDragging || selectedEmbryoIndex === null) {
|
| 321 |
+
// Update cursor based on hover
|
| 322 |
+
updateCursor(pos);
|
| 323 |
+
return;
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
const dx = pos.x - dragStart.x;
|
| 327 |
+
const dy = pos.y - dragStart.y;
|
| 328 |
+
|
| 329 |
+
const box = appState.croppedEmbryos[selectedEmbryoIndex].box;
|
| 330 |
+
|
| 331 |
+
if (dragType === 'move') {
|
| 332 |
+
// Move the entire box
|
| 333 |
+
box.x1 = Math.max(0, Math.min(originalBox.x1 + dx, canvas.width - (originalBox.x2 - originalBox.x1)));
|
| 334 |
+
box.y1 = Math.max(0, Math.min(originalBox.y1 + dy, canvas.height - (originalBox.y2 - originalBox.y1)));
|
| 335 |
+
box.x2 = box.x1 + (originalBox.x2 - originalBox.x1);
|
| 336 |
+
box.y2 = box.y1 + (originalBox.y2 - originalBox.y1);
|
| 337 |
+
} else {
|
| 338 |
+
// Resize the box based on handle
|
| 339 |
+
resizeBox(box, originalBox, dx, dy, dragType);
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
drawDetections();
|
| 343 |
+
updateEmbryoCrop(selectedEmbryoIndex);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
/**
|
| 347 |
+
* Resize box based on drag type
|
| 348 |
+
*/
|
| 349 |
+
function resizeBox(box, original, dx, dy, type) {
|
| 350 |
+
const minSize = 30;
|
| 351 |
+
|
| 352 |
+
switch (type) {
|
| 353 |
+
case 'nw':
|
| 354 |
+
box.x1 = Math.max(0, Math.min(original.x1 + dx, original.x2 - minSize));
|
| 355 |
+
box.y1 = Math.max(0, Math.min(original.y1 + dy, original.y2 - minSize));
|
| 356 |
+
break;
|
| 357 |
+
case 'ne':
|
| 358 |
+
box.x2 = Math.min(canvas.width, Math.max(original.x2 + dx, original.x1 + minSize));
|
| 359 |
+
box.y1 = Math.max(0, Math.min(original.y1 + dy, original.y2 - minSize));
|
| 360 |
+
break;
|
| 361 |
+
case 'sw':
|
| 362 |
+
box.x1 = Math.max(0, Math.min(original.x1 + dx, original.x2 - minSize));
|
| 363 |
+
box.y2 = Math.min(canvas.height, Math.max(original.y2 + dy, original.y1 + minSize));
|
| 364 |
+
break;
|
| 365 |
+
case 'se':
|
| 366 |
+
box.x2 = Math.min(canvas.width, Math.max(original.x2 + dx, original.x1 + minSize));
|
| 367 |
+
box.y2 = Math.min(canvas.height, Math.max(original.y2 + dy, original.y1 + minSize));
|
| 368 |
+
break;
|
| 369 |
+
case 'n':
|
| 370 |
+
box.y1 = Math.max(0, Math.min(original.y1 + dy, original.y2 - minSize));
|
| 371 |
+
break;
|
| 372 |
+
case 's':
|
| 373 |
+
box.y2 = Math.min(canvas.height, Math.max(original.y2 + dy, original.y1 + minSize));
|
| 374 |
+
break;
|
| 375 |
+
case 'w':
|
| 376 |
+
box.x1 = Math.max(0, Math.min(original.x1 + dx, original.x2 - minSize));
|
| 377 |
+
break;
|
| 378 |
+
case 'e':
|
| 379 |
+
box.x2 = Math.min(canvas.width, Math.max(original.x2 + dx, original.x1 + minSize));
|
| 380 |
+
break;
|
| 381 |
+
}
|
| 382 |
+
}
|
| 383 |
+
|
| 384 |
+
/**
|
| 385 |
+
* Update cursor based on position
|
| 386 |
+
*/
|
| 387 |
+
function updateCursor(pos) {
|
| 388 |
+
// If in drawing mode, show crosshair cursor
|
| 389 |
+
if (isDrawingNewBox) {
|
| 390 |
+
canvas.style.cursor = 'crosshair';
|
| 391 |
+
return;
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
if (selectedEmbryoIndex !== null) {
|
| 395 |
+
const box = appState.croppedEmbryos[selectedEmbryoIndex].box;
|
| 396 |
+
const handle = getHandleAtPosition(box, pos.x, pos.y);
|
| 397 |
+
|
| 398 |
+
if (handle) {
|
| 399 |
+
const cursors = {
|
| 400 |
+
'nw': 'nw-resize', 'ne': 'ne-resize',
|
| 401 |
+
'sw': 'sw-resize', 'se': 'se-resize',
|
| 402 |
+
'n': 'n-resize', 's': 's-resize',
|
| 403 |
+
'w': 'w-resize', 'e': 'e-resize'
|
| 404 |
+
};
|
| 405 |
+
canvas.style.cursor = cursors[handle];
|
| 406 |
+
return;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
if (isPointInBox(box, pos.x, pos.y)) {
|
| 410 |
+
canvas.style.cursor = 'move';
|
| 411 |
+
return;
|
| 412 |
+
}
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
// Check if over any box
|
| 416 |
+
for (const detection of appState.croppedEmbryos) {
|
| 417 |
+
if (isPointInBox(detection.box, pos.x, pos.y)) {
|
| 418 |
+
canvas.style.cursor = 'pointer';
|
| 419 |
+
return;
|
| 420 |
+
}
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
canvas.style.cursor = 'default';
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
/**
|
| 427 |
+
* Handle mouse up
|
| 428 |
+
*/
|
| 429 |
+
function handleMouseUp(e) {
|
| 430 |
+
// If finishing drawing new box
|
| 431 |
+
if (isDrawingNewBox && newBoxStart) {
|
| 432 |
+
const pos = getMousePos(e);
|
| 433 |
+
const newBox = {
|
| 434 |
+
x1: newBoxStart.x,
|
| 435 |
+
y1: newBoxStart.y,
|
| 436 |
+
x2: pos.x,
|
| 437 |
+
y2: pos.y
|
| 438 |
+
};
|
| 439 |
+
|
| 440 |
+
completeManualCrop(newBox);
|
| 441 |
+
return;
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
if (isDragging && selectedEmbryoIndex !== null) {
|
| 445 |
+
// Finalize the crop update
|
| 446 |
+
updateEmbryoCrop(selectedEmbryoIndex);
|
| 447 |
+
showToast('Crop area updated', 'success');
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
isDragging = false;
|
| 451 |
+
dragType = null;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
/**
|
| 455 |
+
* Handle touch events
|
| 456 |
+
*/
|
| 457 |
+
function handleTouchStart(e) {
|
| 458 |
+
e.preventDefault();
|
| 459 |
+
const touch = e.touches[0];
|
| 460 |
+
const mouseEvent = new MouseEvent('mousedown', {
|
| 461 |
+
clientX: touch.clientX,
|
| 462 |
+
clientY: touch.clientY
|
| 463 |
+
});
|
| 464 |
+
canvas.dispatchEvent(mouseEvent);
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
function handleTouchMove(e) {
|
| 468 |
+
e.preventDefault();
|
| 469 |
+
const touch = e.touches[0];
|
| 470 |
+
const mouseEvent = new MouseEvent('mousemove', {
|
| 471 |
+
clientX: touch.clientX,
|
| 472 |
+
clientY: touch.clientY
|
| 473 |
+
});
|
| 474 |
+
canvas.dispatchEvent(mouseEvent);
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
function handleTouchEnd(e) {
|
| 478 |
+
e.preventDefault();
|
| 479 |
+
// Get the last touch position or use changedTouches
|
| 480 |
+
const touch = e.changedTouches[0];
|
| 481 |
+
const mouseEvent = new MouseEvent('mouseup', {
|
| 482 |
+
clientX: touch.clientX,
|
| 483 |
+
clientY: touch.clientY
|
| 484 |
+
});
|
| 485 |
+
canvas.dispatchEvent(mouseEvent);
|
| 486 |
+
}
|
| 487 |
+
|
| 488 |
+
/**
|
| 489 |
+
* Select an embryo
|
| 490 |
+
*/
|
| 491 |
+
function selectEmbryo(index) {
|
| 492 |
+
selectedEmbryoIndex = index;
|
| 493 |
+
setCurrentEmbryoIndex(index);
|
| 494 |
+
drawDetections();
|
| 495 |
+
updateEmbryoList(appState.croppedEmbryos);
|
| 496 |
+
|
| 497 |
+
const info = document.getElementById('detectionInfo');
|
| 498 |
+
if (info) {
|
| 499 |
+
info.innerHTML = `
|
| 500 |
+
<p><strong>Embryo ${index + 1} selected</strong> - Drag to move, drag handles to resize</p>
|
| 501 |
+
<button class="btn btn-secondary" id="addManualEmbryoBtn" style="margin-top: 10px;">
|
| 502 |
+
+ Add Embryo Manually
|
| 503 |
+
</button>
|
| 504 |
+
`;
|
| 505 |
+
setupManualAddButton();
|
| 506 |
+
}
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
/**
|
| 510 |
+
* Update embryo crop after modification
|
| 511 |
+
*/
|
| 512 |
+
async function updateEmbryoCrop(index) {
|
| 513 |
+
const detection = appState.croppedEmbryos[index];
|
| 514 |
+
const box = detection.box;
|
| 515 |
+
|
| 516 |
+
// Create a temporary canvas to crop the image
|
| 517 |
+
const tempCanvas = document.createElement('canvas');
|
| 518 |
+
const tempCtx = tempCanvas.getContext('2d');
|
| 519 |
+
|
| 520 |
+
const width = box.x2 - box.x1;
|
| 521 |
+
const height = box.y2 - box.y1;
|
| 522 |
+
|
| 523 |
+
tempCanvas.width = width;
|
| 524 |
+
tempCanvas.height = height;
|
| 525 |
+
|
| 526 |
+
// Draw the cropped portion
|
| 527 |
+
tempCtx.drawImage(
|
| 528 |
+
imageElement,
|
| 529 |
+
box.x1, box.y1, width, height,
|
| 530 |
+
0, 0, width, height
|
| 531 |
+
);
|
| 532 |
+
|
| 533 |
+
// Update the detection's imageData
|
| 534 |
+
detection.imageData = tempCanvas.toDataURL('image/png');
|
| 535 |
+
|
| 536 |
+
// Update the list
|
| 537 |
+
updateEmbryoList(appState.croppedEmbryos);
|
| 538 |
+
}
|
| 539 |
+
|
| 540 |
+
/**
|
| 541 |
+
* Update the embryo list in the sidebar
|
| 542 |
+
*/
|
| 543 |
+
function updateEmbryoList(detections) {
|
| 544 |
+
const listContainer = document.getElementById('embryoList');
|
| 545 |
+
if (!listContainer) return;
|
| 546 |
+
|
| 547 |
+
listContainer.innerHTML = '';
|
| 548 |
+
|
| 549 |
+
detections.forEach((detection, index) => {
|
| 550 |
+
const card = document.createElement('div');
|
| 551 |
+
card.className = 'embryo-card';
|
| 552 |
+
if (index === selectedEmbryoIndex) {
|
| 553 |
+
card.classList.add('selected');
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
const hasRemark = getEmbryoRemark(index).trim().length > 0;
|
| 557 |
+
|
| 558 |
+
card.innerHTML = `
|
| 559 |
+
<div class="embryo-card-thumbnail">
|
| 560 |
+
<img src="${detection.imageData}" alt="Embryo ${index + 1}">
|
| 561 |
+
</div>
|
| 562 |
+
<div class="embryo-card-info">
|
| 563 |
+
<div class="embryo-card-title">
|
| 564 |
+
Embryo ${index + 1}
|
| 565 |
+
</div>
|
| 566 |
+
<div class="embryo-card-confidence">
|
| 567 |
+
Confidence: ${(detection.confidence * 100).toFixed(1)}%
|
| 568 |
+
</div>
|
| 569 |
+
</div>
|
| 570 |
+
<div class="embryo-card-actions">
|
| 571 |
+
<button class="embryo-card-btn discard" data-index="${index}">
|
| 572 |
+
Discard
|
| 573 |
+
</button>
|
| 574 |
+
</div>
|
| 575 |
+
`;
|
| 576 |
+
|
| 577 |
+
// Click to select
|
| 578 |
+
card.addEventListener('click', (e) => {
|
| 579 |
+
if (!e.target.classList.contains('embryo-card-btn')) {
|
| 580 |
+
selectEmbryo(index);
|
| 581 |
+
}
|
| 582 |
+
});
|
| 583 |
+
|
| 584 |
+
// Discard button
|
| 585 |
+
const discardBtn = card.querySelector('.discard');
|
| 586 |
+
discardBtn.addEventListener('click', (e) => {
|
| 587 |
+
e.stopPropagation();
|
| 588 |
+
discardEmbryo(index);
|
| 589 |
+
});
|
| 590 |
+
|
| 591 |
+
listContainer.appendChild(card);
|
| 592 |
+
});
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
/**
|
| 596 |
+
* Discard an embryo
|
| 597 |
+
*/
|
| 598 |
+
async function discardEmbryo(index) {
|
| 599 |
+
if (appState.croppedEmbryos.length <= 1) {
|
| 600 |
+
showToast('Cannot discard the last embryo', 'warning');
|
| 601 |
+
return;
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
const confirmed = await showConfirmModal(
|
| 605 |
+
`Are you sure you want to discard Embryo ${index + 1}?`,
|
| 606 |
+
'Discard Embryo'
|
| 607 |
+
);
|
| 608 |
+
|
| 609 |
+
if (!confirmed) {
|
| 610 |
+
return;
|
| 611 |
+
}
|
| 612 |
+
|
| 613 |
+
// Remove embryo
|
| 614 |
+
appState.croppedEmbryos.splice(index, 1);
|
| 615 |
+
|
| 616 |
+
// Adjust selected index
|
| 617 |
+
if (selectedEmbryoIndex === index) {
|
| 618 |
+
selectedEmbryoIndex = Math.max(0, Math.min(selectedEmbryoIndex, appState.croppedEmbryos.length - 1));
|
| 619 |
+
} else if (selectedEmbryoIndex > index) {
|
| 620 |
+
selectedEmbryoIndex--;
|
| 621 |
+
}
|
| 622 |
+
|
| 623 |
+
// Redraw
|
| 624 |
+
drawDetections();
|
| 625 |
+
updateEmbryoList(appState.croppedEmbryos);
|
| 626 |
+
|
| 627 |
+
showToast('Embryo discarded', 'success');
|
| 628 |
+
}
|
| 629 |
+
|
| 630 |
+
/**
|
| 631 |
+
* Setup manual add embryo button
|
| 632 |
+
*/
|
| 633 |
+
function setupManualAddButton() {
|
| 634 |
+
const addBtn = document.getElementById('addManualEmbryoBtn');
|
| 635 |
+
if (!addBtn) return;
|
| 636 |
+
|
| 637 |
+
addBtn.addEventListener('click', startManualCrop);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
let isDrawingNewBox = false;
|
| 641 |
+
let newBoxStart = null;
|
| 642 |
+
|
| 643 |
+
/**
|
| 644 |
+
* Start manual crop mode
|
| 645 |
+
*/
|
| 646 |
+
function startManualCrop() {
|
| 647 |
+
isDrawingNewBox = true;
|
| 648 |
+
selectedEmbryoIndex = null;
|
| 649 |
+
|
| 650 |
+
const detectionInfo = document.getElementById('detectionInfo');
|
| 651 |
+
if (detectionInfo) {
|
| 652 |
+
detectionInfo.innerHTML = `
|
| 653 |
+
<p style="color: var(--primary-color); font-weight: 600;">Draw a box around the embryo</p>
|
| 654 |
+
<p style="font-size: 0.9em;">Click and drag on the image to create a new bounding box</p>
|
| 655 |
+
<button class="btn btn-secondary" id="cancelManualAdd" style="margin-top: 10px;">Cancel</button>
|
| 656 |
+
`;
|
| 657 |
+
|
| 658 |
+
const cancelBtn = document.getElementById('cancelManualAdd');
|
| 659 |
+
if (cancelBtn) {
|
| 660 |
+
cancelBtn.addEventListener('click', cancelManualCrop);
|
| 661 |
+
}
|
| 662 |
+
}
|
| 663 |
+
|
| 664 |
+
drawDetections();
|
| 665 |
+
showToast('Draw a box around the embryo', 'info');
|
| 666 |
+
}
|
| 667 |
+
|
| 668 |
+
/**
|
| 669 |
+
* Cancel manual crop mode
|
| 670 |
+
*/
|
| 671 |
+
function cancelManualCrop() {
|
| 672 |
+
isDrawingNewBox = false;
|
| 673 |
+
newBoxStart = null;
|
| 674 |
+
|
| 675 |
+
const detectionInfo = document.getElementById('detectionInfo');
|
| 676 |
+
if (detectionInfo) {
|
| 677 |
+
detectionInfo.innerHTML = `
|
| 678 |
+
<p>Click on an embryo to adjust its crop area</p>
|
| 679 |
+
<button class="btn btn-secondary" id="addManualEmbryoBtn" style="margin-top: 10px;">
|
| 680 |
+
+ Add Embryo Manually
|
| 681 |
+
</button>
|
| 682 |
+
`;
|
| 683 |
+
setupManualAddButton();
|
| 684 |
+
}
|
| 685 |
+
|
| 686 |
+
drawDetections();
|
| 687 |
+
}
|
| 688 |
+
|
| 689 |
+
/**
|
| 690 |
+
* Complete manual crop and add embryo
|
| 691 |
+
*/
|
| 692 |
+
async function completeManualCrop(box) {
|
| 693 |
+
// Validate box size
|
| 694 |
+
const minSize = 50;
|
| 695 |
+
const boxWidth = Math.abs(box.x2 - box.x1);
|
| 696 |
+
const boxHeight = Math.abs(box.y2 - box.y1);
|
| 697 |
+
|
| 698 |
+
if (boxWidth < minSize || boxHeight < minSize) {
|
| 699 |
+
showToast('Box too small. Please draw a larger area.', 'warning');
|
| 700 |
+
return;
|
| 701 |
+
}
|
| 702 |
+
|
| 703 |
+
// Normalize box coordinates
|
| 704 |
+
const normalizedBox = {
|
| 705 |
+
x1: Math.min(box.x1, box.x2),
|
| 706 |
+
y1: Math.min(box.y1, box.y2),
|
| 707 |
+
x2: Math.max(box.x1, box.x2),
|
| 708 |
+
y2: Math.max(box.y1, box.y2)
|
| 709 |
+
};
|
| 710 |
+
|
| 711 |
+
// Crop the embryo from the original image
|
| 712 |
+
const croppedCanvas = document.createElement('canvas');
|
| 713 |
+
const croppedCtx = croppedCanvas.getContext('2d');
|
| 714 |
+
|
| 715 |
+
croppedCanvas.width = normalizedBox.x2 - normalizedBox.x1;
|
| 716 |
+
croppedCanvas.height = normalizedBox.y2 - normalizedBox.y1;
|
| 717 |
+
|
| 718 |
+
croppedCtx.drawImage(
|
| 719 |
+
imageElement,
|
| 720 |
+
normalizedBox.x1, normalizedBox.y1,
|
| 721 |
+
croppedCanvas.width, croppedCanvas.height,
|
| 722 |
+
0, 0,
|
| 723 |
+
croppedCanvas.width, croppedCanvas.height
|
| 724 |
+
);
|
| 725 |
+
|
| 726 |
+
const croppedImageData = croppedCanvas.toDataURL();
|
| 727 |
+
|
| 728 |
+
// Add to embryos array
|
| 729 |
+
const newEmbryo = {
|
| 730 |
+
imageData: croppedImageData,
|
| 731 |
+
box: normalizedBox,
|
| 732 |
+
confidence: 1.0, // Manual additions have 100% confidence
|
| 733 |
+
isManual: true
|
| 734 |
+
};
|
| 735 |
+
|
| 736 |
+
appState.croppedEmbryos.push(newEmbryo);
|
| 737 |
+
|
| 738 |
+
// Reset manual mode
|
| 739 |
+
isDrawingNewBox = false;
|
| 740 |
+
newBoxStart = null;
|
| 741 |
+
|
| 742 |
+
// Update UI
|
| 743 |
+
cancelManualCrop();
|
| 744 |
+
drawDetections();
|
| 745 |
+
updateEmbryoList(appState.croppedEmbryos);
|
| 746 |
+
|
| 747 |
+
// Enable next button if this is the first embryo
|
| 748 |
+
if (appState.croppedEmbryos.length > 0) {
|
| 749 |
+
import('./stepper.js').then(module => {
|
| 750 |
+
module.enableNextButton(2);
|
| 751 |
+
});
|
| 752 |
+
}
|
| 753 |
+
|
| 754 |
+
showToast('Embryo added successfully', 'success');
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
+
/**
|
| 758 |
+
* Export functions
|
| 759 |
+
*/
|
| 760 |
+
export function getSelectedEmbryoIndex() {
|
| 761 |
+
return selectedEmbryoIndex;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
export function redrawDetections() {
|
| 765 |
+
drawDetections();
|
| 766 |
+
}
|
src/ui/loading.js
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Loading UI - Loading overlay and progress indicators
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Update loading status
|
| 7 |
+
*/
|
| 8 |
+
export function updateLoadingStatus(message, progress) {
|
| 9 |
+
const loadingText = document.getElementById('loadingText');
|
| 10 |
+
const progressBar = document.getElementById('progressBar');
|
| 11 |
+
|
| 12 |
+
if (loadingText) loadingText.textContent = message;
|
| 13 |
+
if (progressBar) progressBar.style.width = `${progress}%`;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Hide loading overlay
|
| 18 |
+
*/
|
| 19 |
+
export function hideLoading() {
|
| 20 |
+
const overlay = document.getElementById('loadingOverlay');
|
| 21 |
+
if (overlay) {
|
| 22 |
+
overlay.classList.add('hidden');
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Show loading overlay
|
| 28 |
+
*/
|
| 29 |
+
export function showLoading(message = 'Loading...') {
|
| 30 |
+
const overlay = document.getElementById('loadingOverlay');
|
| 31 |
+
if (overlay) {
|
| 32 |
+
overlay.classList.remove('hidden');
|
| 33 |
+
updateLoadingStatus(message, 0);
|
| 34 |
+
}
|
| 35 |
+
}
|
src/ui/modal.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Modal Component - Custom confirmation and alert dialogs
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Show confirmation modal
|
| 7 |
+
* @param {string} message - The confirmation message
|
| 8 |
+
* @param {string} title - Optional title (defaults to "Confirm")
|
| 9 |
+
* @returns {Promise<boolean>} - Resolves to true if OK clicked, false if Cancel clicked
|
| 10 |
+
*/
|
| 11 |
+
export function showConfirmModal(message, title = 'Confirm') {
|
| 12 |
+
return new Promise((resolve) => {
|
| 13 |
+
// Create modal overlay
|
| 14 |
+
const overlay = document.createElement('div');
|
| 15 |
+
overlay.className = 'modal-overlay';
|
| 16 |
+
|
| 17 |
+
// Create modal container
|
| 18 |
+
const modal = document.createElement('div');
|
| 19 |
+
modal.className = 'modal-container';
|
| 20 |
+
|
| 21 |
+
// Create modal content
|
| 22 |
+
modal.innerHTML = `
|
| 23 |
+
<div class="modal-header">
|
| 24 |
+
<h3 class="modal-title">${title}</h3>
|
| 25 |
+
</div>
|
| 26 |
+
<div class="modal-body">
|
| 27 |
+
<p class="modal-message">${message}</p>
|
| 28 |
+
</div>
|
| 29 |
+
<div class="modal-footer">
|
| 30 |
+
<button class="btn btn-secondary modal-cancel-btn">Cancel</button>
|
| 31 |
+
<button class="btn btn-primary modal-ok-btn">OK</button>
|
| 32 |
+
</div>
|
| 33 |
+
`;
|
| 34 |
+
|
| 35 |
+
overlay.appendChild(modal);
|
| 36 |
+
document.body.appendChild(overlay);
|
| 37 |
+
|
| 38 |
+
// Add animation
|
| 39 |
+
setTimeout(() => {
|
| 40 |
+
overlay.classList.add('active');
|
| 41 |
+
modal.classList.add('active');
|
| 42 |
+
}, 10);
|
| 43 |
+
|
| 44 |
+
// Handle button clicks
|
| 45 |
+
const okBtn = modal.querySelector('.modal-ok-btn');
|
| 46 |
+
const cancelBtn = modal.querySelector('.modal-cancel-btn');
|
| 47 |
+
|
| 48 |
+
const closeModal = (result) => {
|
| 49 |
+
overlay.classList.remove('active');
|
| 50 |
+
modal.classList.remove('active');
|
| 51 |
+
|
| 52 |
+
setTimeout(() => {
|
| 53 |
+
document.body.removeChild(overlay);
|
| 54 |
+
resolve(result);
|
| 55 |
+
}, 300);
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
okBtn.addEventListener('click', () => closeModal(true));
|
| 59 |
+
cancelBtn.addEventListener('click', () => closeModal(false));
|
| 60 |
+
|
| 61 |
+
// Close on overlay click
|
| 62 |
+
overlay.addEventListener('click', (e) => {
|
| 63 |
+
if (e.target === overlay) {
|
| 64 |
+
closeModal(false);
|
| 65 |
+
}
|
| 66 |
+
});
|
| 67 |
+
|
| 68 |
+
// Close on Escape key
|
| 69 |
+
const handleEscape = (e) => {
|
| 70 |
+
if (e.key === 'Escape') {
|
| 71 |
+
closeModal(false);
|
| 72 |
+
document.removeEventListener('keydown', handleEscape);
|
| 73 |
+
}
|
| 74 |
+
};
|
| 75 |
+
document.addEventListener('keydown', handleEscape);
|
| 76 |
+
});
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/**
|
| 80 |
+
* Show alert modal
|
| 81 |
+
* @param {string} message - The alert message
|
| 82 |
+
* @param {string} title - Optional title (defaults to "Alert")
|
| 83 |
+
* @returns {Promise<void>}
|
| 84 |
+
*/
|
| 85 |
+
export function showAlertModal(message, title = 'Alert') {
|
| 86 |
+
return new Promise((resolve) => {
|
| 87 |
+
// Create modal overlay
|
| 88 |
+
const overlay = document.createElement('div');
|
| 89 |
+
overlay.className = 'modal-overlay';
|
| 90 |
+
|
| 91 |
+
// Create modal container
|
| 92 |
+
const modal = document.createElement('div');
|
| 93 |
+
modal.className = 'modal-container';
|
| 94 |
+
|
| 95 |
+
// Create modal content
|
| 96 |
+
modal.innerHTML = `
|
| 97 |
+
<div class="modal-header">
|
| 98 |
+
<h3 class="modal-title">${title}</h3>
|
| 99 |
+
</div>
|
| 100 |
+
<div class="modal-body">
|
| 101 |
+
<p class="modal-message">${message}</p>
|
| 102 |
+
</div>
|
| 103 |
+
<div class="modal-footer">
|
| 104 |
+
<button class="btn btn-primary modal-ok-btn">OK</button>
|
| 105 |
+
</div>
|
| 106 |
+
`;
|
| 107 |
+
|
| 108 |
+
overlay.appendChild(modal);
|
| 109 |
+
document.body.appendChild(overlay);
|
| 110 |
+
|
| 111 |
+
// Add animation
|
| 112 |
+
setTimeout(() => {
|
| 113 |
+
overlay.classList.add('active');
|
| 114 |
+
modal.classList.add('active');
|
| 115 |
+
}, 10);
|
| 116 |
+
|
| 117 |
+
// Handle button click
|
| 118 |
+
const okBtn = modal.querySelector('.modal-ok-btn');
|
| 119 |
+
|
| 120 |
+
const closeModal = () => {
|
| 121 |
+
overlay.classList.remove('active');
|
| 122 |
+
modal.classList.remove('active');
|
| 123 |
+
|
| 124 |
+
setTimeout(() => {
|
| 125 |
+
document.body.removeChild(overlay);
|
| 126 |
+
resolve();
|
| 127 |
+
}, 300);
|
| 128 |
+
};
|
| 129 |
+
|
| 130 |
+
okBtn.addEventListener('click', closeModal);
|
| 131 |
+
|
| 132 |
+
// Close on overlay click
|
| 133 |
+
overlay.addEventListener('click', (e) => {
|
| 134 |
+
if (e.target === overlay) {
|
| 135 |
+
closeModal();
|
| 136 |
+
}
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
// Close on Escape key
|
| 140 |
+
const handleEscape = (e) => {
|
| 141 |
+
if (e.key === 'Escape') {
|
| 142 |
+
closeModal();
|
| 143 |
+
document.removeEventListener('keydown', handleEscape);
|
| 144 |
+
}
|
| 145 |
+
};
|
| 146 |
+
document.addEventListener('keydown', handleEscape);
|
| 147 |
+
});
|
| 148 |
+
}
|
src/ui/quickEval.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Quick Evaluation Handler - Quick analysis workflow
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { evaluateEmbryo } from '../models/inference.js';
|
| 6 |
+
import { showToast } from '../ui/toast.js';
|
| 7 |
+
import { displayQuickResults } from '../ui/results.js';
|
| 8 |
+
|
| 9 |
+
/**
|
| 10 |
+
* Quick analyze button handler
|
| 11 |
+
*/
|
| 12 |
+
export async function quickAnalyze() {
|
| 13 |
+
const img = document.getElementById('quickImage');
|
| 14 |
+
if (!img || !img.src) return;
|
| 15 |
+
|
| 16 |
+
try {
|
| 17 |
+
showToast('Analyzing embryo...', 'info');
|
| 18 |
+
|
| 19 |
+
const result = await evaluateEmbryo(img.src);
|
| 20 |
+
displayQuickResults(result);
|
| 21 |
+
|
| 22 |
+
showToast('Analysis complete!', 'success');
|
| 23 |
+
} catch (error) {
|
| 24 |
+
console.error('Quick analysis error:', error);
|
| 25 |
+
showToast(`Analysis failed: ${error.message}`, 'error');
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
/**
|
| 30 |
+
* Clear quick evaluation
|
| 31 |
+
*/
|
| 32 |
+
export function clearQuickEvaluation() {
|
| 33 |
+
const img = document.getElementById('quickImage');
|
| 34 |
+
const preview = document.getElementById('quickImagePreview');
|
| 35 |
+
const resultsDiv = document.getElementById('quickResults');
|
| 36 |
+
const predictionsDiv = document.getElementById('quickPredictions');
|
| 37 |
+
const input = document.getElementById('quickImageInput');
|
| 38 |
+
const analyzeBtn = document.getElementById('quickAnalyzeBtn');
|
| 39 |
+
|
| 40 |
+
if (img) img.style.display = 'none';
|
| 41 |
+
if (preview) {
|
| 42 |
+
const placeholder = preview.querySelector('.placeholder');
|
| 43 |
+
if (placeholder) placeholder.style.display = 'flex';
|
| 44 |
+
}
|
| 45 |
+
if (resultsDiv) resultsDiv.innerHTML = '<p>Upload and analyze an embryo image to see detailed grading results.</p>';
|
| 46 |
+
if (predictionsDiv) predictionsDiv.innerHTML = '';
|
| 47 |
+
if (input) input.value = '';
|
| 48 |
+
if (analyzeBtn) analyzeBtn.disabled = true;
|
| 49 |
+
}
|
src/ui/results.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Results Display - Show evaluation results to user
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Interpret Gardner grade
|
| 7 |
+
*/
|
| 8 |
+
export function interpretGrade(grade) {
|
| 9 |
+
if (grade === 'poor grade embryo' || !grade || grade.length < 3) {
|
| 10 |
+
return '<p>This embryo has been classified as <strong>poor grade</strong>.</p>';
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
const stage = grade[0];
|
| 14 |
+
const icm = grade[1];
|
| 15 |
+
const te = grade[2];
|
| 16 |
+
|
| 17 |
+
const stageDesc = {
|
| 18 |
+
'3': 'Early Blastocyst (cavity < 50% of embryo)',
|
| 19 |
+
'4': 'Expanded Blastocyst (cavity >= 50% of embryo)',
|
| 20 |
+
'5': 'Hatching/Hatched Blastocyst'
|
| 21 |
+
};
|
| 22 |
+
|
| 23 |
+
const icmDesc = {
|
| 24 |
+
'A': 'Excellent - Tightly packed, many cells',
|
| 25 |
+
'B': 'Good - Loosely grouped, several cells',
|
| 26 |
+
'C': 'Fair - Very few cells'
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const teDesc = {
|
| 30 |
+
'A': 'Excellent - Many cells, cohesive epithelium',
|
| 31 |
+
'B': 'Good - Few cells, loose epithelium',
|
| 32 |
+
'C': 'Fair - Very few large cells'
|
| 33 |
+
};
|
| 34 |
+
|
| 35 |
+
return `
|
| 36 |
+
<ul style="list-style: none; padding-left: 0; margin-top: 15px;">
|
| 37 |
+
<li style="margin: 10px 0;"><strong>Stage ${stage}:</strong> ${stageDesc[stage] || 'Unknown stage'}</li>
|
| 38 |
+
<li style="margin: 10px 0;"><strong>ICM Quality ${icm}:</strong> ${icmDesc[icm] || 'Unknown quality'}</li>
|
| 39 |
+
<li style="margin: 10px 0;"><strong>TE Quality ${te}:</strong> ${teDesc[te] || 'Unknown quality'}</li>
|
| 40 |
+
</ul>
|
| 41 |
+
`;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/**
|
| 45 |
+
* Display top predictions
|
| 46 |
+
*/
|
| 47 |
+
export function displayPredictions(predictions, container) {
|
| 48 |
+
if (!predictions || Object.keys(predictions).length === 0) {
|
| 49 |
+
container.innerHTML = '';
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const sortedPredictions = Object.entries(predictions)
|
| 54 |
+
.sort((a, b) => b[1] - a[1])
|
| 55 |
+
.slice(0, 5);
|
| 56 |
+
|
| 57 |
+
container.innerHTML = sortedPredictions.map(([label, confidence]) => `
|
| 58 |
+
<div class="prediction-item">
|
| 59 |
+
<span class="prediction-label">${label}</span>
|
| 60 |
+
<span class="prediction-confidence">${(confidence * 100).toFixed(1)}%</span>
|
| 61 |
+
<div class="prediction-bar">
|
| 62 |
+
<div class="prediction-bar-fill" style="width: ${confidence * 100}%"></div>
|
| 63 |
+
</div>
|
| 64 |
+
</div>
|
| 65 |
+
`).join('');
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/**
|
| 69 |
+
* Get image quality tip HTML
|
| 70 |
+
*/
|
| 71 |
+
function getImageQualityTip() {
|
| 72 |
+
return `
|
| 73 |
+
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 15px; border-radius: 4px; margin-top: 15px;">
|
| 74 |
+
<h4 style="margin: 0 0 10px 0; color: #856404;">Image Quality Tip</h4>
|
| 75 |
+
<p style="margin: 0; color: #856404;">If this result seems incorrect, please ensure:</p>
|
| 76 |
+
<ul style="margin: 10px 0 0 20px; color: #856404;">
|
| 77 |
+
<li>The image is clear and well-focused</li>
|
| 78 |
+
</ul>
|
| 79 |
+
<p style="margin: 10px 0 0 0; color: #856404;"><strong>Try uploading a clearer image for more accurate results.</strong></p>
|
| 80 |
+
</div>
|
| 81 |
+
`;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Display single embryo results
|
| 86 |
+
*/
|
| 87 |
+
export function displaySingleEmbryoResults(result) {
|
| 88 |
+
const resultsDiv = document.getElementById('singleResults');
|
| 89 |
+
const predictionsDiv = document.getElementById('singlePredictions');
|
| 90 |
+
|
| 91 |
+
if (!resultsDiv || !predictionsDiv) return;
|
| 92 |
+
|
| 93 |
+
if (result.quality === 'poor') {
|
| 94 |
+
resultsDiv.innerHTML = `
|
| 95 |
+
<div class="grade-result grade-poor">
|
| 96 |
+
<h2>Quality: Poor Grade Embryo</h2>
|
| 97 |
+
</div>
|
| 98 |
+
<hr style="margin: 20px 0;">
|
| 99 |
+
<h3>Assessment</h3>
|
| 100 |
+
<p>This embryo has been classified as <strong>poor quality</strong> and is <strong>not suitable for transfer</strong>.</p>
|
| 101 |
+
<p><strong>Recommendation:</strong> This embryo does not meet the minimum quality standards for viable transfer.</p>
|
| 102 |
+
<hr style="margin: 20px 0;">
|
| 103 |
+
${getImageQualityTip()}
|
| 104 |
+
`;
|
| 105 |
+
predictionsDiv.innerHTML = '';
|
| 106 |
+
} else {
|
| 107 |
+
resultsDiv.innerHTML = `
|
| 108 |
+
<div class="grade-result grade-good">
|
| 109 |
+
<h2>Predicted Grade: ${result.grade}</h2>
|
| 110 |
+
</div>
|
| 111 |
+
<hr style="margin: 20px 0;">
|
| 112 |
+
<h3>Grade Interpretation</h3>
|
| 113 |
+
${interpretGrade(result.grade)}
|
| 114 |
+
`;
|
| 115 |
+
displayPredictions(result.predictions, predictionsDiv);
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Display cropped embryo results
|
| 121 |
+
*/
|
| 122 |
+
export function displayCroppedEmbryoResults(result) {
|
| 123 |
+
const resultsDiv = document.getElementById('croppedResults');
|
| 124 |
+
const predictionsDiv = document.getElementById('croppedPredictions');
|
| 125 |
+
|
| 126 |
+
if (!resultsDiv || !predictionsDiv) return;
|
| 127 |
+
|
| 128 |
+
if (result.quality === 'poor') {
|
| 129 |
+
resultsDiv.innerHTML = `
|
| 130 |
+
<div class="grade-result grade-poor">
|
| 131 |
+
<h2>Quality: Poor Grade Embryo</h2>
|
| 132 |
+
</div>
|
| 133 |
+
<p style="margin-top: 15px;">This embryo is <strong>not suitable for transfer</strong>.</p>
|
| 134 |
+
<div style="background: #fff3cd; border-left: 4px solid #ffc107; padding: 12px; border-radius: 4px; margin-top: 15px;">
|
| 135 |
+
<p style="margin: 0; color: #856404; font-size: 0.95em;"><strong>Tip:</strong> If this seems incorrect, try uploading a clearer, well-focused image of the embryo for more accurate assessment.</p>
|
| 136 |
+
</div>
|
| 137 |
+
`;
|
| 138 |
+
predictionsDiv.innerHTML = '';
|
| 139 |
+
} else {
|
| 140 |
+
resultsDiv.innerHTML = `
|
| 141 |
+
<div class="grade-result grade-good">
|
| 142 |
+
<h2>Predicted Grade: ${result.grade}</h2>
|
| 143 |
+
</div>
|
| 144 |
+
${interpretGrade(result.grade)}
|
| 145 |
+
`;
|
| 146 |
+
displayPredictions(result.predictions, predictionsDiv);
|
| 147 |
+
}
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
/**
|
| 151 |
+
* Display quick evaluation results
|
| 152 |
+
*/
|
| 153 |
+
export function displayQuickResults(result) {
|
| 154 |
+
const resultsDiv = document.getElementById('quickResults');
|
| 155 |
+
const predictionsDiv = document.getElementById('quickPredictions');
|
| 156 |
+
|
| 157 |
+
if (!resultsDiv || !predictionsDiv) return;
|
| 158 |
+
|
| 159 |
+
if (result.quality === 'poor') {
|
| 160 |
+
resultsDiv.innerHTML = `
|
| 161 |
+
<div class="grade-result grade-poor">
|
| 162 |
+
<h2>Quality: Poor Grade Embryo</h2>
|
| 163 |
+
</div>
|
| 164 |
+
<hr style="margin: 20px 0;">
|
| 165 |
+
<p>This embryo has been classified as <strong>poor quality</strong> and is <strong>not suitable for transfer</strong>.</p>
|
| 166 |
+
${getImageQualityTip()}
|
| 167 |
+
`;
|
| 168 |
+
predictionsDiv.innerHTML = '';
|
| 169 |
+
} else {
|
| 170 |
+
resultsDiv.innerHTML = `
|
| 171 |
+
<div class="grade-result grade-good">
|
| 172 |
+
<h2>Predicted Grade: ${result.grade}</h2>
|
| 173 |
+
</div>
|
| 174 |
+
<hr style="margin: 20px 0;">
|
| 175 |
+
${interpretGrade(result.grade)}
|
| 176 |
+
`;
|
| 177 |
+
displayPredictions(result.predictions, predictionsDiv);
|
| 178 |
+
}
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
/**
|
| 182 |
+
* Display classification status
|
| 183 |
+
*/
|
| 184 |
+
export function displayClassificationStatus(result) {
|
| 185 |
+
const statusDiv = document.getElementById('classificationStatus');
|
| 186 |
+
if (!statusDiv) return;
|
| 187 |
+
|
| 188 |
+
const isEmbryo = result.label === 'yes';
|
| 189 |
+
|
| 190 |
+
if (isEmbryo) {
|
| 191 |
+
statusDiv.innerHTML = `
|
| 192 |
+
<div class="grade-result grade-good">
|
| 193 |
+
<h3>Embryo Detected</h3>
|
| 194 |
+
</div>
|
| 195 |
+
<hr style="margin: 15px 0;">
|
| 196 |
+
<p><strong>Image successfully classified as embryo.</strong></p>
|
| 197 |
+
<p>Confidence: ${(result.confidence * 100).toFixed(1)}%</p>
|
| 198 |
+
<p style="margin-top: 10px;">Click 'Next: Detect Embryos' to continue.</p>
|
| 199 |
+
`;
|
| 200 |
+
} else {
|
| 201 |
+
statusDiv.innerHTML = `
|
| 202 |
+
<div class="grade-result grade-poor">
|
| 203 |
+
<h3>Not an Embryo</h3>
|
| 204 |
+
</div>
|
| 205 |
+
<hr style="margin: 15px 0;">
|
| 206 |
+
<p><strong>The image does not appear to contain an embryo.</strong></p>
|
| 207 |
+
<p>Confidence: ${(result.confidence * 100).toFixed(1)}%</p>
|
| 208 |
+
<p style="margin-top: 10px;">Please upload a valid embryo image to proceed.</p>
|
| 209 |
+
`;
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
/**
|
| 214 |
+
* Display final results in Step 5
|
| 215 |
+
*/
|
| 216 |
+
export function displayFinalResults(result) {
|
| 217 |
+
const resultsDiv = document.getElementById('finalResults');
|
| 218 |
+
const predictionsDiv = document.getElementById('finalPredictions');
|
| 219 |
+
|
| 220 |
+
if (!resultsDiv || !predictionsDiv) return;
|
| 221 |
+
|
| 222 |
+
if (result.quality === 'poor') {
|
| 223 |
+
resultsDiv.innerHTML = `
|
| 224 |
+
<div class="grade-result grade-poor">
|
| 225 |
+
<h2>Quality: Poor Grade Embryo</h2>
|
| 226 |
+
</div>
|
| 227 |
+
<hr style="margin: 20px 0;">
|
| 228 |
+
<h3>Assessment</h3>
|
| 229 |
+
<p>This embryo has been classified as <strong>poor quality</strong> and is <strong>not suitable for transfer</strong>.</p>
|
| 230 |
+
<p><strong>Recommendation:</strong> This embryo does not meet the minimum quality standards for viable transfer.</p>
|
| 231 |
+
<hr style="margin: 20px 0;">
|
| 232 |
+
${getImageQualityTip()}
|
| 233 |
+
`;
|
| 234 |
+
predictionsDiv.innerHTML = '';
|
| 235 |
+
} else {
|
| 236 |
+
resultsDiv.innerHTML = `
|
| 237 |
+
<div class="grade-result grade-good">
|
| 238 |
+
<h2>Predicted Grade: ${result.grade}</h2>
|
| 239 |
+
</div>
|
| 240 |
+
<hr style="margin: 20px 0;">
|
| 241 |
+
<h3>Grade Interpretation</h3>
|
| 242 |
+
${interpretGrade(result.grade)}
|
| 243 |
+
<hr style="margin: 20px 0;">
|
| 244 |
+
<h3>Analysis Complete</h3>
|
| 245 |
+
<p>The embryo has been successfully evaluated using AI-powered analysis.</p>
|
| 246 |
+
`;
|
| 247 |
+
displayPredictions(result.predictions, predictionsDiv);
|
| 248 |
+
}
|
| 249 |
+
}
|
src/ui/stepper.js
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Stepper Navigation - Handles multi-step workflow
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { showConfirmModal } from './modal.js';
|
| 6 |
+
import { saveGradingResults, prepareEmbryoData, isFirebaseReady } from '../utils/firebaseService.js';
|
| 7 |
+
import { appState } from '../state.js';
|
| 8 |
+
import { showToast } from './toast.js';
|
| 9 |
+
|
| 10 |
+
let currentStep = 1;
|
| 11 |
+
const totalSteps = 3;
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* Initialize stepper navigation
|
| 15 |
+
*/
|
| 16 |
+
export function initializeStepper() {
|
| 17 |
+
updateStepperUI();
|
| 18 |
+
attachStepperEventListeners();
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
/**
|
| 22 |
+
* Navigate to a specific step
|
| 23 |
+
*/
|
| 24 |
+
export function goToStep(stepNumber) {
|
| 25 |
+
if (stepNumber < 1 || stepNumber > totalSteps) return;
|
| 26 |
+
|
| 27 |
+
currentStep = stepNumber;
|
| 28 |
+
updateStepperUI();
|
| 29 |
+
scrollToTop();
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
/**
|
| 33 |
+
* Go to next step
|
| 34 |
+
*/
|
| 35 |
+
export function nextStep() {
|
| 36 |
+
if (currentStep < totalSteps) {
|
| 37 |
+
currentStep++;
|
| 38 |
+
updateStepperUI();
|
| 39 |
+
scrollToTop();
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Go to previous step
|
| 45 |
+
*/
|
| 46 |
+
export function prevStep() {
|
| 47 |
+
if (currentStep > 1) {
|
| 48 |
+
currentStep--;
|
| 49 |
+
updateStepperUI();
|
| 50 |
+
scrollToTop();
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Mark a step as completed
|
| 56 |
+
*/
|
| 57 |
+
export function markStepCompleted(stepNumber) {
|
| 58 |
+
const step = document.querySelector(`.step[data-step="${stepNumber}"]`);
|
| 59 |
+
if (step) {
|
| 60 |
+
step.classList.add('completed');
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
// Update step line
|
| 64 |
+
const stepLines = document.querySelectorAll('.step-line');
|
| 65 |
+
if (stepLines[stepNumber - 1]) {
|
| 66 |
+
stepLines[stepNumber - 1].classList.add('completed');
|
| 67 |
+
}
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
/**
|
| 71 |
+
* Update stepper UI
|
| 72 |
+
*/
|
| 73 |
+
function updateStepperUI() {
|
| 74 |
+
// Update step indicators
|
| 75 |
+
document.querySelectorAll('.step').forEach((step, index) => {
|
| 76 |
+
const stepNum = index + 1;
|
| 77 |
+
step.classList.remove('active');
|
| 78 |
+
|
| 79 |
+
if (stepNum === currentStep) {
|
| 80 |
+
step.classList.add('active');
|
| 81 |
+
}
|
| 82 |
+
});
|
| 83 |
+
|
| 84 |
+
// Update step content visibility
|
| 85 |
+
document.querySelectorAll('.step-content').forEach((content, index) => {
|
| 86 |
+
const stepNum = index + 1;
|
| 87 |
+
content.classList.remove('active');
|
| 88 |
+
|
| 89 |
+
if (stepNum === currentStep) {
|
| 90 |
+
content.classList.add('active');
|
| 91 |
+
}
|
| 92 |
+
});
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
/**
|
| 96 |
+
* Reset stepper to first step
|
| 97 |
+
*/
|
| 98 |
+
export function resetStepper() {
|
| 99 |
+
currentStep = 1;
|
| 100 |
+
|
| 101 |
+
// Remove completed status from all steps
|
| 102 |
+
document.querySelectorAll('.step').forEach(step => {
|
| 103 |
+
step.classList.remove('completed', 'active');
|
| 104 |
+
});
|
| 105 |
+
|
| 106 |
+
document.querySelectorAll('.step-line').forEach(line => {
|
| 107 |
+
line.classList.remove('completed');
|
| 108 |
+
});
|
| 109 |
+
|
| 110 |
+
updateStepperUI();
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Get current step number
|
| 115 |
+
*/
|
| 116 |
+
export function getCurrentStep() {
|
| 117 |
+
return currentStep;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
/**
|
| 121 |
+
* Scroll to top of page
|
| 122 |
+
*/
|
| 123 |
+
function scrollToTop() {
|
| 124 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
/**
|
| 128 |
+
* Attach event listeners for step navigation
|
| 129 |
+
*/
|
| 130 |
+
function attachStepperEventListeners() {
|
| 131 |
+
// Step 1 navigation
|
| 132 |
+
const nextStep1 = document.getElementById('nextStep1');
|
| 133 |
+
if (nextStep1) {
|
| 134 |
+
nextStep1.addEventListener('click', () => {
|
| 135 |
+
markStepCompleted(1);
|
| 136 |
+
goToStep(2);
|
| 137 |
+
initializeStep2();
|
| 138 |
+
});
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
const startOverBtn = document.getElementById('startOverBtn');
|
| 142 |
+
if (startOverBtn) {
|
| 143 |
+
startOverBtn.addEventListener('click', async () => {
|
| 144 |
+
const confirmed = await showConfirmModal(
|
| 145 |
+
'Are you sure you want to start over? All progress will be lost.',
|
| 146 |
+
'Start Over'
|
| 147 |
+
);
|
| 148 |
+
|
| 149 |
+
if (confirmed) {
|
| 150 |
+
resetStepper();
|
| 151 |
+
resetWorkflow();
|
| 152 |
+
}
|
| 153 |
+
});
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
// Step 2 navigation
|
| 157 |
+
const prevStep2 = document.getElementById('prevStep2');
|
| 158 |
+
if (prevStep2) {
|
| 159 |
+
prevStep2.addEventListener('click', () => goToStep(1));
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
const nextStep2 = document.getElementById('nextStep2');
|
| 163 |
+
if (nextStep2) {
|
| 164 |
+
nextStep2.addEventListener('click', () => {
|
| 165 |
+
markStepCompleted(2);
|
| 166 |
+
goToStep(3);
|
| 167 |
+
initializeStep3();
|
| 168 |
+
});
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
// Step 3 navigation
|
| 172 |
+
const prevStep3 = document.getElementById('prevStep3');
|
| 173 |
+
if (prevStep3) {
|
| 174 |
+
prevStep3.addEventListener('click', () => goToStep(2));
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
const analyzeAnother = document.getElementById('analyzeAnother');
|
| 178 |
+
if (analyzeAnother) {
|
| 179 |
+
analyzeAnother.addEventListener('click', async () => {
|
| 180 |
+
// Save results first, then reset
|
| 181 |
+
await handleSaveAndReset();
|
| 182 |
+
});
|
| 183 |
+
}
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
/**
|
| 187 |
+
* Initialize Step 2 - Show embryo image and prepare for detection
|
| 188 |
+
*/
|
| 189 |
+
function initializeStep2() {
|
| 190 |
+
// Show the embryo image first, then trigger detection after UI is ready
|
| 191 |
+
setTimeout(() => {
|
| 192 |
+
import('./workflow.js').then(module => {
|
| 193 |
+
module.showEmbryoImageAndDetect();
|
| 194 |
+
});
|
| 195 |
+
}, 150);
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
/**
|
| 199 |
+
* Initialize Step 3 - Evaluate all embryos in background
|
| 200 |
+
*/
|
| 201 |
+
function initializeStep3() {
|
| 202 |
+
// Trigger evaluation of all embryos in background
|
| 203 |
+
import('./workflow.js').then(module => {
|
| 204 |
+
module.evaluateAllEmbryos();
|
| 205 |
+
});
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
/**
|
| 209 |
+
* Reset workflow (to be called from workflow.js)
|
| 210 |
+
*/
|
| 211 |
+
export function resetWorkflow() {
|
| 212 |
+
// Reset file input to allow re-uploading the same file
|
| 213 |
+
const imageInput = document.getElementById('imageInput');
|
| 214 |
+
if (imageInput) {
|
| 215 |
+
imageInput.value = '';
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
// Clear images
|
| 219 |
+
const mainImage = document.getElementById('mainImage');
|
| 220 |
+
const mainImagePreview = document.getElementById('mainImagePreview');
|
| 221 |
+
const croppedImage = document.getElementById('croppedImage');
|
| 222 |
+
const cropCanvas = document.getElementById('cropCanvas');
|
| 223 |
+
|
| 224 |
+
if (mainImage) {
|
| 225 |
+
mainImage.style.display = 'none';
|
| 226 |
+
mainImage.src = '';
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
if (croppedImage) {
|
| 230 |
+
croppedImage.style.display = 'none';
|
| 231 |
+
croppedImage.src = '';
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
if (cropCanvas) {
|
| 235 |
+
cropCanvas.style.display = 'none';
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
if (mainImagePreview) {
|
| 239 |
+
const placeholder = mainImagePreview.querySelector('.placeholder');
|
| 240 |
+
if (placeholder) placeholder.style.display = 'flex';
|
| 241 |
+
}
|
| 242 |
+
|
| 243 |
+
// Reset buttons
|
| 244 |
+
const nextStep1 = document.getElementById('nextStep1');
|
| 245 |
+
if (nextStep1) nextStep1.disabled = true;
|
| 246 |
+
|
| 247 |
+
const nextStep2 = document.getElementById('nextStep2');
|
| 248 |
+
if (nextStep2) nextStep2.disabled = true;
|
| 249 |
+
|
| 250 |
+
// Clear classification status
|
| 251 |
+
const classificationStatus = document.getElementById('classificationStatus');
|
| 252 |
+
if (classificationStatus) {
|
| 253 |
+
classificationStatus.innerHTML = `
|
| 254 |
+
<p>Upload an image to check if it contains an embryo.</p>
|
| 255 |
+
<p>The system will automatically classify the image upon upload.</p>
|
| 256 |
+
`;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Clear results
|
| 260 |
+
const finalResults = document.getElementById('finalResults');
|
| 261 |
+
if (finalResults) finalResults.innerHTML = '<p>Processing all embryos...</p>';
|
| 262 |
+
|
| 263 |
+
const finalPredictions = document.getElementById('finalPredictions');
|
| 264 |
+
if (finalPredictions) finalPredictions.innerHTML = '';
|
| 265 |
+
|
| 266 |
+
const selectionInfo = document.getElementById('selectionInfo');
|
| 267 |
+
if (selectionInfo) {
|
| 268 |
+
selectionInfo.innerHTML = '<p>Select an embryo to continue.</p>';
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
// Clear embryo thumbnails
|
| 272 |
+
const embryoThumbnails = document.getElementById('embryoThumbnails');
|
| 273 |
+
if (embryoThumbnails) {
|
| 274 |
+
embryoThumbnails.innerHTML = '';
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
// Disable zoom controls
|
| 278 |
+
const zoomIn = document.getElementById('zoomIn');
|
| 279 |
+
const zoomOut = document.getElementById('zoomOut');
|
| 280 |
+
const zoomReset = document.getElementById('zoomReset');
|
| 281 |
+
if (zoomIn) zoomIn.disabled = true;
|
| 282 |
+
if (zoomOut) zoomOut.disabled = true;
|
| 283 |
+
if (zoomReset) zoomReset.disabled = true;
|
| 284 |
+
|
| 285 |
+
// Hide crop editor
|
| 286 |
+
const cropControls = document.getElementById('cropControls');
|
| 287 |
+
const editCropBtn = document.getElementById('editCropBtn');
|
| 288 |
+
if (cropControls) cropControls.style.display = 'none';
|
| 289 |
+
if (editCropBtn) editCropBtn.style.display = 'none';
|
| 290 |
+
|
| 291 |
+
// Reset appState
|
| 292 |
+
import('../state.js').then(module => {
|
| 293 |
+
module.appState.croppedEmbryos = [];
|
| 294 |
+
module.appState.embryoResults = [];
|
| 295 |
+
module.appState.embryoRemarks = {}; // Clear all remarks
|
| 296 |
+
module.appState.currentEmbryoIndex = 0;
|
| 297 |
+
module.appState.currentImage = null;
|
| 298 |
+
module.appState.zoomLevel = 1;
|
| 299 |
+
module.appState.finalResult = null;
|
| 300 |
+
});
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
/**
|
| 304 |
+
* Enable next button for a step
|
| 305 |
+
*/
|
| 306 |
+
export function enableNextButton(stepNumber) {
|
| 307 |
+
const button = document.getElementById(`nextStep${stepNumber}`);
|
| 308 |
+
if (button) {
|
| 309 |
+
button.disabled = false;
|
| 310 |
+
}
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
/**
|
| 314 |
+
* Disable next button for a step
|
| 315 |
+
*/
|
| 316 |
+
export function disableNextButton(stepNumber) {
|
| 317 |
+
const button = document.getElementById(`nextStep${stepNumber}`);
|
| 318 |
+
if (button) {
|
| 319 |
+
button.disabled = true;
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/**
|
| 324 |
+
* Handle saving results to Firebase
|
| 325 |
+
*/
|
| 326 |
+
async function handleSaveResults() {
|
| 327 |
+
if (!isFirebaseReady()) {
|
| 328 |
+
console.warn('Firebase is not configured.');
|
| 329 |
+
return false;
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
if (!appState.croppedEmbryos || appState.croppedEmbryos.length === 0) {
|
| 333 |
+
console.warn('No embryos to save');
|
| 334 |
+
return false;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
try {
|
| 338 |
+
// Prepare the data with results and remarks
|
| 339 |
+
const embryoData = prepareEmbryoData(
|
| 340 |
+
appState.croppedEmbryos,
|
| 341 |
+
appState.currentImage,
|
| 342 |
+
appState.embryoResults,
|
| 343 |
+
appState.embryoRemarks
|
| 344 |
+
);
|
| 345 |
+
|
| 346 |
+
// Save to Firebase silently
|
| 347 |
+
const docId = await saveGradingResults(embryoData);
|
| 348 |
+
|
| 349 |
+
// Log to console for debugging
|
| 350 |
+
console.log('Results saved successfully! Document ID:', docId);
|
| 351 |
+
return true;
|
| 352 |
+
} catch (error) {
|
| 353 |
+
console.error('Error saving results:', error);
|
| 354 |
+
return false;
|
| 355 |
+
}
|
| 356 |
+
}
|
| 357 |
+
|
| 358 |
+
/**
|
| 359 |
+
* Handle saving results and then resetting for a new analysis
|
| 360 |
+
*/
|
| 361 |
+
async function handleSaveAndReset() {
|
| 362 |
+
// Save silently in background
|
| 363 |
+
await handleSaveResults();
|
| 364 |
+
|
| 365 |
+
// Reset immediately
|
| 366 |
+
resetStepper();
|
| 367 |
+
resetWorkflow();
|
| 368 |
+
}
|
src/ui/tabs.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Tab Management - Switch between different views
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Switch between tabs
|
| 7 |
+
*/
|
| 8 |
+
export function switchTab(tabName) {
|
| 9 |
+
document.querySelectorAll('.tab-button').forEach(btn => btn.classList.remove('active'));
|
| 10 |
+
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
| 11 |
+
|
| 12 |
+
const tabButton = document.querySelector(`[data-tab="${tabName}"]`);
|
| 13 |
+
const tabContent = document.getElementById(tabName);
|
| 14 |
+
|
| 15 |
+
if (tabButton) tabButton.classList.add('active');
|
| 16 |
+
if (tabContent) tabContent.classList.add('active');
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Setup tab event listeners
|
| 21 |
+
*/
|
| 22 |
+
export function setupTabListeners() {
|
| 23 |
+
document.querySelectorAll('.tab-button').forEach(button => {
|
| 24 |
+
button.addEventListener('click', () => switchTab(button.dataset.tab));
|
| 25 |
+
});
|
| 26 |
+
}
|
src/ui/toast.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Toast Notifications - User feedback messages
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Show toast notification
|
| 7 |
+
*/
|
| 8 |
+
export function showToast(message, type = 'info') {
|
| 9 |
+
const toast = document.getElementById('toast');
|
| 10 |
+
if (!toast) return;
|
| 11 |
+
|
| 12 |
+
toast.textContent = message;
|
| 13 |
+
toast.className = `toast ${type} show`;
|
| 14 |
+
|
| 15 |
+
setTimeout(() => {
|
| 16 |
+
toast.classList.remove('show');
|
| 17 |
+
}, 3000);
|
| 18 |
+
}
|
src/ui/workflow.js
ADDED
|
@@ -0,0 +1,642 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Workflow Handlers - Simplified 3-Step Workflow
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { appState, setCroppedEmbryos, setCurrentEmbryoIndex, getEmbryoRemark, setEmbryoRemark } from '../state.js';
|
| 6 |
+
import { runClassification, evaluateEmbryo } from '../models/inference.js';
|
| 7 |
+
import { detectEmbryos, isYOLOAvailable } from '../models/yoloDetection.js';
|
| 8 |
+
import { showToast } from '../ui/toast.js';
|
| 9 |
+
import { displayClassificationStatus, displayFinalResults } from '../ui/results.js';
|
| 10 |
+
import { displayCroppedEmbryo } from '../ui/imageDisplay.js';
|
| 11 |
+
import { enableNextButton, disableNextButton, markStepCompleted } from '../ui/stepper.js';
|
| 12 |
+
import { showCropEditor, hideCropEditor } from '../ui/cropEditor.js';
|
| 13 |
+
import { showConfirmModal } from '../ui/modal.js';
|
| 14 |
+
import { initializeInlineDetection } from '../ui/inlineDetection.js';
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Classify if image contains embryo (auto-triggered on upload)
|
| 18 |
+
*/
|
| 19 |
+
export async function classifyImage(imageData) {
|
| 20 |
+
const statusDiv = document.getElementById('classificationStatus');
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
if (statusDiv) {
|
| 24 |
+
statusDiv.innerHTML = `
|
| 25 |
+
<div style="text-align: center;">
|
| 26 |
+
<div class="spinner" style="width: 30px; height: 30px; margin: 10px auto;"></div>
|
| 27 |
+
<p>Analyzing image...</p>
|
| 28 |
+
</div>
|
| 29 |
+
`;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
showToast('Classifying image...', 'info');
|
| 33 |
+
|
| 34 |
+
const result = await runClassification(imageData);
|
| 35 |
+
displayClassificationStatus(result);
|
| 36 |
+
|
| 37 |
+
// Enable next button if classification successful and it's an embryo
|
| 38 |
+
if (result && result.label === 'yes') {
|
| 39 |
+
enableNextButton(1);
|
| 40 |
+
showToast('Image classified successfully - Embryo detected!', 'success');
|
| 41 |
+
} else if (result && result.label === 'no') {
|
| 42 |
+
disableNextButton(1); // Ensure button is disabled for non-embryo images
|
| 43 |
+
showToast('No embryo detected in the image', 'warning');
|
| 44 |
+
if (statusDiv) {
|
| 45 |
+
statusDiv.innerHTML = `
|
| 46 |
+
<p style="color: var(--warning-color);"><strong>No embryo detected</strong></p>
|
| 47 |
+
<p>Please upload an image containing an embryo to continue.</p>
|
| 48 |
+
`;
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
} catch (error) {
|
| 52 |
+
console.error('Classification error:', error);
|
| 53 |
+
showToast(`Classification failed: ${error.message}`, 'error');
|
| 54 |
+
if (statusDiv) {
|
| 55 |
+
statusDiv.innerHTML = `
|
| 56 |
+
<p style="color: var(--error-color);"><strong>Classification failed</strong></p>
|
| 57 |
+
<p>${error.message}</p>
|
| 58 |
+
`;
|
| 59 |
+
}
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* Show embryo image in Step 2 and then trigger detection
|
| 65 |
+
*/
|
| 66 |
+
export async function showEmbryoImageAndDetect() {
|
| 67 |
+
// First, display the original embryo image
|
| 68 |
+
const step2Content = document.getElementById('step2');
|
| 69 |
+
if (!step2Content || !step2Content.classList.contains('active')) {
|
| 70 |
+
console.error('Step 2 not active yet, waiting...');
|
| 71 |
+
await new Promise(resolve => setTimeout(resolve, 200));
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
const canvas = document.getElementById('detectionCanvas');
|
| 75 |
+
|
| 76 |
+
// Show the original embryo image on the canvas first
|
| 77 |
+
if (canvas && appState.currentImage) {
|
| 78 |
+
try {
|
| 79 |
+
const ctx = canvas.getContext('2d');
|
| 80 |
+
const { loadImage } = await import('../utils/imageUtils.js');
|
| 81 |
+
const imageElement = await loadImage(appState.currentImage);
|
| 82 |
+
|
| 83 |
+
// Set canvas size to match image
|
| 84 |
+
canvas.width = imageElement.width;
|
| 85 |
+
canvas.height = imageElement.height;
|
| 86 |
+
|
| 87 |
+
// Calculate scale factor for display
|
| 88 |
+
const container = canvas.parentElement;
|
| 89 |
+
if (container) {
|
| 90 |
+
const maxWidth = container.clientWidth - 40;
|
| 91 |
+
const maxHeight = 600;
|
| 92 |
+
|
| 93 |
+
const scaleFactor = Math.min(
|
| 94 |
+
maxWidth / imageElement.width,
|
| 95 |
+
maxHeight / imageElement.height,
|
| 96 |
+
1
|
| 97 |
+
);
|
| 98 |
+
|
| 99 |
+
canvas.style.width = `${imageElement.width * scaleFactor}px`;
|
| 100 |
+
canvas.style.height = `${imageElement.height * scaleFactor}px`;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
// Draw the original image
|
| 104 |
+
ctx.drawImage(imageElement, 0, 0);
|
| 105 |
+
} catch (error) {
|
| 106 |
+
console.error('Error displaying embryo image:', error);
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// Wait a moment to let the UI update and show the image
|
| 111 |
+
await new Promise(resolve => setTimeout(resolve, 500));
|
| 112 |
+
|
| 113 |
+
// Now trigger detection
|
| 114 |
+
await detectAndDisplayEmbryos();
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
/**
|
| 118 |
+
* Detect and display embryos (triggered on Step 2)
|
| 119 |
+
*/
|
| 120 |
+
export async function detectAndDisplayEmbryos() {
|
| 121 |
+
if (!isYOLOAvailable()) {
|
| 122 |
+
showToast('Detection model not available', 'error');
|
| 123 |
+
return;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
// Verify Step 2 is visible
|
| 127 |
+
const step2Content = document.getElementById('step2');
|
| 128 |
+
if (!step2Content || !step2Content.classList.contains('active')) {
|
| 129 |
+
console.error('Step 2 not active yet, waiting...');
|
| 130 |
+
// Wait and retry
|
| 131 |
+
await new Promise(resolve => setTimeout(resolve, 200));
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
try {
|
| 135 |
+
showToast('Detecting embryos...', 'info');
|
| 136 |
+
|
| 137 |
+
const detections = await detectEmbryos(appState.currentImage);
|
| 138 |
+
|
| 139 |
+
if (detections.length === 0) {
|
| 140 |
+
showToast('No embryos detected - You can add manually', 'info');
|
| 141 |
+
|
| 142 |
+
// Initialize with empty array to allow manual addition
|
| 143 |
+
setCroppedEmbryos([]);
|
| 144 |
+
await displayDetectedEmbryosForSelection([]);
|
| 145 |
+
return;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
setCroppedEmbryos(detections);
|
| 149 |
+
await displayDetectedEmbryosForSelection(detections);
|
| 150 |
+
|
| 151 |
+
showToast(`Detected ${detections.length} embryo(s) - Click to select`, 'success');
|
| 152 |
+
} catch (error) {
|
| 153 |
+
console.error('Detection error:', error);
|
| 154 |
+
showToast(`Detection failed: ${error.message}`, 'error');
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
/**
|
| 159 |
+
* Display detected embryos for selection (Step 2) - Using inline detection view
|
| 160 |
+
*/
|
| 161 |
+
async function displayDetectedEmbryosForSelection(detections) {
|
| 162 |
+
// Wait a bit for DOM to be ready (Step 2 to be visible)
|
| 163 |
+
await new Promise(resolve => setTimeout(resolve, 100));
|
| 164 |
+
|
| 165 |
+
// Initialize the inline detection canvas with all embryos (or empty array)
|
| 166 |
+
await initializeInlineDetection(appState.currentImage, detections);
|
| 167 |
+
|
| 168 |
+
// Enable next button only if there are embryos (either detected or manually added)
|
| 169 |
+
if (detections.length > 0) {
|
| 170 |
+
enableNextButton(2);
|
| 171 |
+
}
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
/**
|
| 175 |
+
* Select embryo for analysis (updates selection in Step 2)
|
| 176 |
+
*/
|
| 177 |
+
function selectEmbryoForAnalysis(index) {
|
| 178 |
+
setCurrentEmbryoIndex(index);
|
| 179 |
+
displayCroppedEmbryo(index, appState.croppedEmbryos[index], appState.croppedEmbryos.length);
|
| 180 |
+
|
| 181 |
+
// Update active thumbnail
|
| 182 |
+
document.querySelectorAll('.embryo-thumbnail-wrapper').forEach((wrapper, i) => {
|
| 183 |
+
const thumbnail = wrapper.querySelector('.embryo-thumbnail');
|
| 184 |
+
if (thumbnail) {
|
| 185 |
+
thumbnail.classList.toggle('active', i === index);
|
| 186 |
+
}
|
| 187 |
+
});
|
| 188 |
+
|
| 189 |
+
const selectionInfo = document.getElementById('selectionInfo');
|
| 190 |
+
if (selectionInfo) {
|
| 191 |
+
selectionInfo.innerHTML = `
|
| 192 |
+
<p><strong>Embryo ${index + 1} of ${appState.croppedEmbryos.length} selected</strong></p>
|
| 193 |
+
<p>Click 'Analyze Selected Embryo' to continue</p>
|
| 194 |
+
`;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
// Show crop editor for selected embryo
|
| 198 |
+
showCropEditor(index);
|
| 199 |
+
|
| 200 |
+
// Update navigation buttons
|
| 201 |
+
updateNavigationButtons();
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
/**
|
| 205 |
+
* Navigate between embryos in Step 2
|
| 206 |
+
*/
|
| 207 |
+
export function navigateEmbryo(direction) {
|
| 208 |
+
const newIndex = appState.currentEmbryoIndex + direction;
|
| 209 |
+
if (newIndex >= 0 && newIndex < appState.croppedEmbryos.length) {
|
| 210 |
+
selectEmbryoForAnalysis(newIndex);
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
// Update navigation button states
|
| 214 |
+
updateNavigationButtons();
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
/**
|
| 218 |
+
* Update navigation button states
|
| 219 |
+
*/
|
| 220 |
+
function updateNavigationButtons() {
|
| 221 |
+
const prevBtn = document.getElementById('prevEmbryoBtn');
|
| 222 |
+
const nextBtn = document.getElementById('nextEmbryoBtn');
|
| 223 |
+
|
| 224 |
+
if (prevBtn) {
|
| 225 |
+
prevBtn.disabled = appState.currentEmbryoIndex === 0;
|
| 226 |
+
}
|
| 227 |
+
|
| 228 |
+
if (nextBtn) {
|
| 229 |
+
nextBtn.disabled = appState.currentEmbryoIndex >= appState.croppedEmbryos.length - 1;
|
| 230 |
+
}
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
/**
|
| 234 |
+
* Discard an embryo from the analysis
|
| 235 |
+
*/
|
| 236 |
+
async function discardEmbryo(index) {
|
| 237 |
+
if (appState.croppedEmbryos.length <= 1) {
|
| 238 |
+
showToast('Cannot discard the last embryo', 'warning');
|
| 239 |
+
return;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
const confirmed = await showConfirmModal(
|
| 243 |
+
`Are you sure you want to discard Embryo ${index + 1}?`,
|
| 244 |
+
'Discard Embryo'
|
| 245 |
+
);
|
| 246 |
+
|
| 247 |
+
if (!confirmed) {
|
| 248 |
+
return;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
// Remove embryo from array
|
| 252 |
+
appState.croppedEmbryos.splice(index, 1);
|
| 253 |
+
|
| 254 |
+
// Adjust current index if necessary
|
| 255 |
+
if (appState.currentEmbryoIndex >= appState.croppedEmbryos.length) {
|
| 256 |
+
appState.currentEmbryoIndex = Math.max(0, appState.croppedEmbryos.length - 1);
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
// Update the display
|
| 260 |
+
redisplayEmbryos();
|
| 261 |
+
|
| 262 |
+
showToast('Embryo discarded', 'success');
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
/**
|
| 266 |
+
* Redisplay all embryos after changes (e.g., discard)
|
| 267 |
+
*/
|
| 268 |
+
function redisplayEmbryos() {
|
| 269 |
+
const thumbnailContainer = document.getElementById('embryoThumbnails');
|
| 270 |
+
if (!thumbnailContainer) return;
|
| 271 |
+
|
| 272 |
+
thumbnailContainer.innerHTML = '';
|
| 273 |
+
|
| 274 |
+
appState.croppedEmbryos.forEach((detection, index) => {
|
| 275 |
+
const thumbnailWrapper = document.createElement('div');
|
| 276 |
+
thumbnailWrapper.className = 'embryo-thumbnail-wrapper';
|
| 277 |
+
thumbnailWrapper.setAttribute('data-embryo-index', index);
|
| 278 |
+
|
| 279 |
+
const thumbnail = document.createElement('div');
|
| 280 |
+
thumbnail.className = 'embryo-thumbnail';
|
| 281 |
+
if (index === appState.currentEmbryoIndex) thumbnail.classList.add('active');
|
| 282 |
+
|
| 283 |
+
const img = document.createElement('img');
|
| 284 |
+
img.src = detection.imageData;
|
| 285 |
+
img.alt = `Embryo ${index + 1}`;
|
| 286 |
+
|
| 287 |
+
// Add discard button with closure to capture current index
|
| 288 |
+
const discardBtn = document.createElement('button');
|
| 289 |
+
discardBtn.className = 'discard-embryo-btn';
|
| 290 |
+
discardBtn.innerHTML = 'Γ';
|
| 291 |
+
discardBtn.title = 'Discard this embryo';
|
| 292 |
+
discardBtn.addEventListener('click', ((currentIndex) => {
|
| 293 |
+
return (e) => {
|
| 294 |
+
e.stopPropagation();
|
| 295 |
+
discardEmbryo(currentIndex);
|
| 296 |
+
};
|
| 297 |
+
})(index));
|
| 298 |
+
|
| 299 |
+
thumbnail.appendChild(img);
|
| 300 |
+
thumbnail.appendChild(discardBtn);
|
| 301 |
+
|
| 302 |
+
// Add click handler with closure to capture current index
|
| 303 |
+
thumbnail.addEventListener('click', ((currentIndex) => {
|
| 304 |
+
return () => selectEmbryoForAnalysis(currentIndex);
|
| 305 |
+
})(index));
|
| 306 |
+
|
| 307 |
+
thumbnailWrapper.appendChild(thumbnail);
|
| 308 |
+
thumbnailContainer.appendChild(thumbnailWrapper);
|
| 309 |
+
});
|
| 310 |
+
|
| 311 |
+
// Select current embryo (index has been adjusted in discardEmbryo)
|
| 312 |
+
if (appState.croppedEmbryos.length > 0) {
|
| 313 |
+
selectEmbryoForAnalysis(appState.currentEmbryoIndex);
|
| 314 |
+
}
|
| 315 |
+
}
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
/**
|
| 319 |
+
* Evaluate all embryos in background (triggered when entering Step 3)
|
| 320 |
+
*/
|
| 321 |
+
export async function evaluateAllEmbryos() {
|
| 322 |
+
const resultsDiv = document.getElementById('finalResults');
|
| 323 |
+
|
| 324 |
+
try {
|
| 325 |
+
showToast('Processing all embryos...', 'info');
|
| 326 |
+
|
| 327 |
+
if (resultsDiv) {
|
| 328 |
+
resultsDiv.innerHTML = `
|
| 329 |
+
<div style="text-align: center;">
|
| 330 |
+
<div class="spinner" style="width: 40px; height: 40px; margin: 20px auto;"></div>
|
| 331 |
+
<p>Analyzing ${appState.croppedEmbryos.length} embryo(s)...</p>
|
| 332 |
+
<p style="font-size: 0.9em; color: var(--text-secondary);">This may take a few moments</p>
|
| 333 |
+
</div>
|
| 334 |
+
`;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
// Process all embryos in parallel
|
| 338 |
+
const evaluationPromises = appState.croppedEmbryos.map(async (embryo, index) => {
|
| 339 |
+
try {
|
| 340 |
+
const result = await evaluateEmbryo(embryo.imageData);
|
| 341 |
+
return {
|
| 342 |
+
index,
|
| 343 |
+
embryo,
|
| 344 |
+
result,
|
| 345 |
+
success: true
|
| 346 |
+
};
|
| 347 |
+
} catch (error) {
|
| 348 |
+
console.error(`Error evaluating embryo ${index + 1}:`, error);
|
| 349 |
+
return {
|
| 350 |
+
index,
|
| 351 |
+
embryo,
|
| 352 |
+
result: null,
|
| 353 |
+
success: false,
|
| 354 |
+
error: error.message
|
| 355 |
+
};
|
| 356 |
+
}
|
| 357 |
+
});
|
| 358 |
+
|
| 359 |
+
const results = await Promise.all(evaluationPromises);
|
| 360 |
+
|
| 361 |
+
// Store results in appState
|
| 362 |
+
appState.embryoResults = results;
|
| 363 |
+
|
| 364 |
+
// Display all embryos with their results
|
| 365 |
+
displayAllEmbryosWithResults(results);
|
| 366 |
+
|
| 367 |
+
// Mark step as completed
|
| 368 |
+
markStepCompleted(3);
|
| 369 |
+
|
| 370 |
+
const successCount = results.filter(r => r.success).length;
|
| 371 |
+
if (successCount === results.length) {
|
| 372 |
+
showToast(`Successfully processed all ${results.length} embryos`, 'success');
|
| 373 |
+
} else {
|
| 374 |
+
showToast(`Processed ${successCount} of ${results.length} embryos (${results.length - successCount} failed)`, 'warning');
|
| 375 |
+
}
|
| 376 |
+
} catch (error) {
|
| 377 |
+
console.error('Evaluation error:', error);
|
| 378 |
+
showToast(`Evaluation failed: ${error.message}`, 'error');
|
| 379 |
+
if (resultsDiv) {
|
| 380 |
+
resultsDiv.innerHTML = `
|
| 381 |
+
<p style="color: var(--error-color);"><strong>Evaluation failed</strong></p>
|
| 382 |
+
<p>${error.message}</p>
|
| 383 |
+
`;
|
| 384 |
+
}
|
| 385 |
+
}
|
| 386 |
+
}
|
| 387 |
+
|
| 388 |
+
/**
|
| 389 |
+
* Display all embryos with clickable thumbnails in Step 3
|
| 390 |
+
*/
|
| 391 |
+
function displayAllEmbryosWithResults(results) {
|
| 392 |
+
const resultsDiv = document.getElementById('finalResults');
|
| 393 |
+
if (!resultsDiv) return;
|
| 394 |
+
|
| 395 |
+
resultsDiv.innerHTML = `
|
| 396 |
+
<div class="all-embryos-results">
|
| 397 |
+
<h3>All Embryos Results</h3>
|
| 398 |
+
<p style="margin-bottom: 20px; color: var(--text-secondary);">
|
| 399 |
+
Click on any embryo to view its detailed results
|
| 400 |
+
</p>
|
| 401 |
+
<div class="embryo-results-grid" id="embryoResultsGrid"></div>
|
| 402 |
+
<div class="selected-embryo-details" id="selectedEmbryoDetails" style="margin-top: 30px;"></div>
|
| 403 |
+
</div>
|
| 404 |
+
`;
|
| 405 |
+
|
| 406 |
+
const grid = document.getElementById('embryoResultsGrid');
|
| 407 |
+
|
| 408 |
+
results.forEach((resultData, index) => {
|
| 409 |
+
const card = document.createElement('div');
|
| 410 |
+
card.className = 'embryo-result-card';
|
| 411 |
+
card.setAttribute('data-embryo-index', index);
|
| 412 |
+
|
| 413 |
+
const hasRemark = getEmbryoRemark(index).trim().length > 0;
|
| 414 |
+
|
| 415 |
+
if (resultData.success) {
|
| 416 |
+
const grade = resultData.result.grade || 'Unknown';
|
| 417 |
+
const quality = resultData.result.quality || 'Unknown';
|
| 418 |
+
|
| 419 |
+
card.innerHTML = `
|
| 420 |
+
<div class="embryo-result-thumbnail">
|
| 421 |
+
<img src="${resultData.embryo.imageData}" alt="Embryo ${index + 1}">
|
| 422 |
+
<div class="embryo-result-badge">${grade}</div>
|
| 423 |
+
</div>
|
| 424 |
+
<div class="embryo-result-info">
|
| 425 |
+
<strong>Embryo ${index + 1}</strong>
|
| 426 |
+
<span class="quality-indicator quality-${quality.toLowerCase()}">${quality}</span>
|
| 427 |
+
</div>
|
| 428 |
+
`;
|
| 429 |
+
} else {
|
| 430 |
+
card.innerHTML = `
|
| 431 |
+
<div class="embryo-result-thumbnail">
|
| 432 |
+
<img src="${resultData.embryo.imageData}" alt="Embryo ${index + 1}">
|
| 433 |
+
<div class="embryo-result-badge error">Error</div>
|
| 434 |
+
</div>
|
| 435 |
+
<div class="embryo-result-info">
|
| 436 |
+
<strong>Embryo ${index + 1}</strong>
|
| 437 |
+
<span class="quality-indicator quality-error">Failed</span>
|
| 438 |
+
</div>
|
| 439 |
+
`;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
card.addEventListener('click', () => showEmbryoDetailedResult(index, resultData));
|
| 443 |
+
|
| 444 |
+
grid.appendChild(card);
|
| 445 |
+
});
|
| 446 |
+
|
| 447 |
+
// Auto-select first embryo
|
| 448 |
+
if (results.length > 0) {
|
| 449 |
+
showEmbryoDetailedResult(0, results[0]);
|
| 450 |
+
}
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
/**
|
| 454 |
+
* Show detailed result for a specific embryo
|
| 455 |
+
*/
|
| 456 |
+
function showEmbryoDetailedResult(index, resultData) {
|
| 457 |
+
const detailsDiv = document.getElementById('selectedEmbryoDetails');
|
| 458 |
+
if (!detailsDiv) return;
|
| 459 |
+
|
| 460 |
+
// Update active card
|
| 461 |
+
document.querySelectorAll('.embryo-result-card').forEach((card, i) => {
|
| 462 |
+
card.classList.toggle('active', i === index);
|
| 463 |
+
});
|
| 464 |
+
|
| 465 |
+
const currentRemark = getEmbryoRemark(index);
|
| 466 |
+
|
| 467 |
+
if (!resultData.success) {
|
| 468 |
+
detailsDiv.innerHTML = `
|
| 469 |
+
<div class="detailed-result-container">
|
| 470 |
+
<h3>Embryo ${index + 1} - Processing Failed</h3>
|
| 471 |
+
<div class="error-message">
|
| 472 |
+
<p>${resultData.error || 'Unknown error occurred'}</p>
|
| 473 |
+
</div>
|
| 474 |
+
<div class="remarks-section">
|
| 475 |
+
<label for="embryoRemark${index}">Remarks:</label>
|
| 476 |
+
<textarea
|
| 477 |
+
id="embryoRemark${index}"
|
| 478 |
+
class="remarks-textarea"
|
| 479 |
+
placeholder="Add your observations, notes, or comments about this embryo..."
|
| 480 |
+
data-embryo-index="${index}"
|
| 481 |
+
>${currentRemark}</textarea>
|
| 482 |
+
</div>
|
| 483 |
+
</div>
|
| 484 |
+
`;
|
| 485 |
+
|
| 486 |
+
// Add event listener to save remarks when changed
|
| 487 |
+
const remarkTextarea = document.getElementById(`embryoRemark${index}`);
|
| 488 |
+
if (remarkTextarea) {
|
| 489 |
+
remarkTextarea.addEventListener('input', (e) => {
|
| 490 |
+
setEmbryoRemark(index, e.target.value);
|
| 491 |
+
});
|
| 492 |
+
}
|
| 493 |
+
return;
|
| 494 |
+
}
|
| 495 |
+
|
| 496 |
+
const result = resultData.result;
|
| 497 |
+
|
| 498 |
+
detailsDiv.innerHTML = `
|
| 499 |
+
<div class="detailed-result-container">
|
| 500 |
+
<div class="result-header">
|
| 501 |
+
<h3>Embryo ${index + 1} - Detailed Results</h3>
|
| 502 |
+
</div>
|
| 503 |
+
<div class="result-content">
|
| 504 |
+
<div class="result-image-section">
|
| 505 |
+
<img src="${resultData.embryo.imageData}" alt="Embryo ${index + 1}" class="result-embryo-image">
|
| 506 |
+
</div>
|
| 507 |
+
<div class="result-details-section">
|
| 508 |
+
<div class="result-row">
|
| 509 |
+
<span class="result-label">Quality Assessment:</span>
|
| 510 |
+
<span class="result-value quality-${result.quality?.toLowerCase() || 'unknown'}">${result.quality || 'Unknown'}</span>
|
| 511 |
+
</div>
|
| 512 |
+
<div class="result-row">
|
| 513 |
+
<span class="result-label">Predicted Grade:</span>
|
| 514 |
+
<span class="result-value grade-value">${result.grade || 'Unknown'}</span>
|
| 515 |
+
</div>
|
| 516 |
+
<div class="result-row">
|
| 517 |
+
<span class="result-label">Actual Grade:</span>
|
| 518 |
+
<div class="actual-grade-selectors">
|
| 519 |
+
<select id="actualGradeNum${index}" class="grade-select" data-embryo-index="${index}">
|
| 520 |
+
<option value="">-</option>
|
| 521 |
+
<option value="1">1</option>
|
| 522 |
+
<option value="2">2</option>
|
| 523 |
+
<option value="3">3</option>
|
| 524 |
+
<option value="4">4</option>
|
| 525 |
+
<option value="5">5</option>
|
| 526 |
+
<option value="6">6</option>
|
| 527 |
+
</select>
|
| 528 |
+
<select id="actualGradeLetter1${index}" class="grade-select" data-embryo-index="${index}">
|
| 529 |
+
<option value="">-</option>
|
| 530 |
+
<option value="A">A</option>
|
| 531 |
+
<option value="B">B</option>
|
| 532 |
+
<option value="C">C</option>
|
| 533 |
+
</select>
|
| 534 |
+
<select id="actualGradeLetter2${index}" class="grade-select" data-embryo-index="${index}">
|
| 535 |
+
<option value="">-</option>
|
| 536 |
+
<option value="A">A</option>
|
| 537 |
+
<option value="B">B</option>
|
| 538 |
+
<option value="C">C</option>
|
| 539 |
+
</select>
|
| 540 |
+
<span class="grade-preview" id="gradePreview${index}"></span>
|
| 541 |
+
</div>
|
| 542 |
+
</div>
|
| 543 |
+
${result.confidence ? `
|
| 544 |
+
<div class="result-row">
|
| 545 |
+
<span class="result-label">Confidence:</span>
|
| 546 |
+
<span class="result-value">${(result.confidence * 100).toFixed(1)}%</span>
|
| 547 |
+
</div>
|
| 548 |
+
` : ''}
|
| 549 |
+
${result.poorGoodConfidence ? `
|
| 550 |
+
<div class="result-row">
|
| 551 |
+
<span class="result-label">Quality Confidence:</span>
|
| 552 |
+
<span class="result-value">${(result.poorGoodConfidence * 100).toFixed(1)}%</span>
|
| 553 |
+
</div>
|
| 554 |
+
` : ''}
|
| 555 |
+
<div class="remarks-section">
|
| 556 |
+
<label for="embryoRemark${index}">Remarks:</label>
|
| 557 |
+
<textarea
|
| 558 |
+
id="embryoRemark${index}"
|
| 559 |
+
class="remarks-textarea"
|
| 560 |
+
placeholder="Add your observations, notes, or comments about this embryo..."
|
| 561 |
+
data-embryo-index="${index}"
|
| 562 |
+
>${currentRemark}</textarea>
|
| 563 |
+
</div>
|
| 564 |
+
</div>
|
| 565 |
+
</div>
|
| 566 |
+
</div>
|
| 567 |
+
`;
|
| 568 |
+
|
| 569 |
+
// Add event listener to save remarks when changed
|
| 570 |
+
const remarkTextarea = document.getElementById(`embryoRemark${index}`);
|
| 571 |
+
if (remarkTextarea) {
|
| 572 |
+
remarkTextarea.addEventListener('input', (e) => {
|
| 573 |
+
setEmbryoRemark(index, e.target.value);
|
| 574 |
+
});
|
| 575 |
+
}
|
| 576 |
+
|
| 577 |
+
// Parse existing actualGrade if available
|
| 578 |
+
const existingGrade = resultData.embryo.actualGrade || '';
|
| 579 |
+
if (existingGrade) {
|
| 580 |
+
// Parse grade like "3AA" into number and letters
|
| 581 |
+
const match = existingGrade.match(/^(\d)([A-C])([A-C])$/);
|
| 582 |
+
if (match) {
|
| 583 |
+
const numSelect = document.getElementById(`actualGradeNum${index}`);
|
| 584 |
+
const letter1Select = document.getElementById(`actualGradeLetter1${index}`);
|
| 585 |
+
const letter2Select = document.getElementById(`actualGradeLetter2${index}`);
|
| 586 |
+
|
| 587 |
+
if (numSelect) numSelect.value = match[1];
|
| 588 |
+
if (letter1Select) letter1Select.value = match[2];
|
| 589 |
+
if (letter2Select) letter2Select.value = match[3];
|
| 590 |
+
}
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
// Function to update the combined grade
|
| 594 |
+
const updateActualGrade = () => {
|
| 595 |
+
const numSelect = document.getElementById(`actualGradeNum${index}`);
|
| 596 |
+
const letter1Select = document.getElementById(`actualGradeLetter1${index}`);
|
| 597 |
+
const letter2Select = document.getElementById(`actualGradeLetter2${index}`);
|
| 598 |
+
const gradePreview = document.getElementById(`gradePreview${index}`);
|
| 599 |
+
|
| 600 |
+
const num = numSelect?.value || '';
|
| 601 |
+
const letter1 = letter1Select?.value || '';
|
| 602 |
+
const letter2 = letter2Select?.value || '';
|
| 603 |
+
|
| 604 |
+
const combinedGrade = num + letter1 + letter2;
|
| 605 |
+
|
| 606 |
+
// Update preview
|
| 607 |
+
if (gradePreview) {
|
| 608 |
+
gradePreview.textContent = combinedGrade || '-';
|
| 609 |
+
}
|
| 610 |
+
|
| 611 |
+
// Save to embryo object
|
| 612 |
+
const embryo = appState.croppedEmbryos[index];
|
| 613 |
+
if (embryo) {
|
| 614 |
+
embryo.actualGrade = combinedGrade;
|
| 615 |
+
}
|
| 616 |
+
};
|
| 617 |
+
|
| 618 |
+
// Add event listeners to all three selects
|
| 619 |
+
const numSelect = document.getElementById(`actualGradeNum${index}`);
|
| 620 |
+
const letter1Select = document.getElementById(`actualGradeLetter1${index}`);
|
| 621 |
+
const letter2Select = document.getElementById(`actualGradeLetter2${index}`);
|
| 622 |
+
|
| 623 |
+
if (numSelect) {
|
| 624 |
+
numSelect.addEventListener('change', updateActualGrade);
|
| 625 |
+
}
|
| 626 |
+
if (letter1Select) {
|
| 627 |
+
letter1Select.addEventListener('change', updateActualGrade);
|
| 628 |
+
}
|
| 629 |
+
if (letter2Select) {
|
| 630 |
+
letter2Select.addEventListener('change', updateActualGrade);
|
| 631 |
+
}
|
| 632 |
+
|
| 633 |
+
// Initial update
|
| 634 |
+
updateActualGrade();
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
/**
|
| 638 |
+
* Legacy function - kept for compatibility but redirects to new function
|
| 639 |
+
*/
|
| 640 |
+
export async function evaluateSelectedEmbryo() {
|
| 641 |
+
return evaluateAllEmbryos();
|
| 642 |
+
}
|
src/ui/zoom.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Zoom Controls - Image zoom functionality
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { getZoomLevel, setZoomLevel } from '../state.js';
|
| 6 |
+
import { ZOOM_CONFIG } from '../config.js';
|
| 7 |
+
|
| 8 |
+
/**
|
| 9 |
+
* Adjust zoom level
|
| 10 |
+
*/
|
| 11 |
+
export function adjustZoom(delta) {
|
| 12 |
+
const currentZoom = getZoomLevel();
|
| 13 |
+
const newZoom = Math.max(ZOOM_CONFIG.min, Math.min(ZOOM_CONFIG.max, currentZoom + delta));
|
| 14 |
+
setZoomLevel(newZoom);
|
| 15 |
+
applyZoom();
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
/**
|
| 19 |
+
* Reset zoom to default
|
| 20 |
+
*/
|
| 21 |
+
export function resetZoom() {
|
| 22 |
+
setZoomLevel(ZOOM_CONFIG.default);
|
| 23 |
+
applyZoom();
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
/**
|
| 27 |
+
* Apply current zoom level to image
|
| 28 |
+
*/
|
| 29 |
+
function applyZoom() {
|
| 30 |
+
const img = document.getElementById('mainImage');
|
| 31 |
+
const zoomLevel = getZoomLevel();
|
| 32 |
+
|
| 33 |
+
if (img) {
|
| 34 |
+
img.style.transform = `scale(${zoomLevel})`;
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const zoomDisplay = document.getElementById('zoomLevel');
|
| 38 |
+
if (zoomDisplay) {
|
| 39 |
+
zoomDisplay.textContent = `Zoom: ${Math.round(zoomLevel * 100)}%`;
|
| 40 |
+
}
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Setup zoom event listeners
|
| 45 |
+
*/
|
| 46 |
+
export function setupZoomListeners() {
|
| 47 |
+
const zoomIn = document.getElementById('zoomIn');
|
| 48 |
+
const zoomOut = document.getElementById('zoomOut');
|
| 49 |
+
const zoomReset = document.getElementById('zoomReset');
|
| 50 |
+
|
| 51 |
+
if (zoomIn) zoomIn.addEventListener('click', () => adjustZoom(ZOOM_CONFIG.step));
|
| 52 |
+
if (zoomOut) zoomOut.addEventListener('click', () => adjustZoom(-ZOOM_CONFIG.step));
|
| 53 |
+
if (zoomReset) zoomReset.addEventListener('click', resetZoom);
|
| 54 |
+
}
|
src/utils/firebaseService.js
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Firebase Service - Handle data persistence and authentication
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { initializeApp } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-app.js';
|
| 6 |
+
import { getFirestore, collection, addDoc, serverTimestamp } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-firestore.js';
|
| 7 |
+
import { getAuth, signInWithEmailAndPassword, signInWithPopup, GoogleAuthProvider, signOut, onAuthStateChanged } from 'https://www.gstatic.com/firebasejs/10.7.1/firebase-auth.js';
|
| 8 |
+
|
| 9 |
+
// Firebase configuration
|
| 10 |
+
const firebaseConfig = {
|
| 11 |
+
apiKey: "AIzaSyAYbKEaWT99sDO7bUC76LcXKDRK3BBMZ00",
|
| 12 |
+
authDomain: "embryo-one.firebaseapp.com",
|
| 13 |
+
projectId: "embryo-one",
|
| 14 |
+
storageBucket: "embryo-one.firebasestorage.app",
|
| 15 |
+
messagingSenderId: "23510355257",
|
| 16 |
+
appId: "1:23510355257:web:0ae7edce822704820f6ce8",
|
| 17 |
+
measurementId: "G-140V7Z1GRK"
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
// Initialize Firebase
|
| 21 |
+
let app;
|
| 22 |
+
let db;
|
| 23 |
+
let auth;
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
app = initializeApp(firebaseConfig);
|
| 27 |
+
db = getFirestore(app);
|
| 28 |
+
auth = getAuth(app);
|
| 29 |
+
console.log('Firebase initialized successfully');
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error('Firebase initialization error:', error);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
/**
|
| 35 |
+
* Save embryo grading results to Firebase
|
| 36 |
+
* @param {Object} resultData - The grading result data
|
| 37 |
+
* @returns {Promise<string>} - Document ID
|
| 38 |
+
*/
|
| 39 |
+
export async function saveGradingResults(resultData) {
|
| 40 |
+
try {
|
| 41 |
+
const docRef = await addDoc(collection(db, 'embryo_gradings'), {
|
| 42 |
+
...resultData,
|
| 43 |
+
createdAt: serverTimestamp()
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
console.log('Results saved with ID:', docRef.id);
|
| 47 |
+
return docRef.id;
|
| 48 |
+
} catch (error) {
|
| 49 |
+
console.error('Error saving results:', error);
|
| 50 |
+
throw error;
|
| 51 |
+
}
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
/**
|
| 55 |
+
* Prepare embryo data for saving
|
| 56 |
+
* @param {Array} embryos - Array of embryo objects
|
| 57 |
+
* @param {string} mainImageUrl - URL of the main uploaded image
|
| 58 |
+
* @param {Array} results - Array of evaluation results
|
| 59 |
+
* @param {Object} remarks - Object mapping index to remarks
|
| 60 |
+
* @returns {Object} - Formatted data for Firebase
|
| 61 |
+
*/
|
| 62 |
+
export function prepareEmbryoData(embryos, mainImageUrl, results = [], remarks = {}) {
|
| 63 |
+
console.log('Preparing embryo data for Firebase:');
|
| 64 |
+
console.log('- Embryos count:', embryos.length);
|
| 65 |
+
console.log('- Results count:', results.length);
|
| 66 |
+
console.log('- Remarks:', remarks);
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
mainImage: mainImageUrl,
|
| 70 |
+
embryos: embryos.map((embryo, index) => {
|
| 71 |
+
// Get result for this embryo
|
| 72 |
+
const resultData = results[index];
|
| 73 |
+
const result = resultData?.result;
|
| 74 |
+
|
| 75 |
+
const embryoData = {
|
| 76 |
+
embryoNumber: index + 1,
|
| 77 |
+
croppedImage: embryo.imageData,
|
| 78 |
+
predictedGrade: result?.grade || embryo.grade || 'Unknown',
|
| 79 |
+
actualGrade: embryo.actualGrade || '',
|
| 80 |
+
quality: result?.quality || embryo.quality || 'Unknown',
|
| 81 |
+
confidence: result?.confidence || embryo.confidence || 0,
|
| 82 |
+
poorGoodConfidence: result?.poorGoodConfidence || 0,
|
| 83 |
+
remarks: remarks[index] || embryo.remarks || '',
|
| 84 |
+
isManual: embryo.isManual || false,
|
| 85 |
+
detectionConfidence: embryo.confidence || 0
|
| 86 |
+
};
|
| 87 |
+
|
| 88 |
+
console.log(`Embryo ${index + 1} data:`, {
|
| 89 |
+
grade: embryoData.predictedGrade,
|
| 90 |
+
quality: embryoData.quality,
|
| 91 |
+
remarks: embryoData.remarks
|
| 92 |
+
});
|
| 93 |
+
|
| 94 |
+
return embryoData;
|
| 95 |
+
}),
|
| 96 |
+
totalEmbryos: embryos.length,
|
| 97 |
+
timestamp: new Date().toISOString()
|
| 98 |
+
};
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
/**
|
| 102 |
+
* Check if Firebase is initialized
|
| 103 |
+
* @returns {boolean}
|
| 104 |
+
*/
|
| 105 |
+
export function isFirebaseReady() {
|
| 106 |
+
return db !== null && db !== undefined && auth !== null && auth !== undefined;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* Authentication Functions
|
| 111 |
+
*/
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* Sign in with email and password
|
| 115 |
+
* @param {string} email - User email
|
| 116 |
+
* @param {string} password - User password
|
| 117 |
+
* @returns {Promise<Object>} - User credential
|
| 118 |
+
*/
|
| 119 |
+
export async function signIn(email, password) {
|
| 120 |
+
try {
|
| 121 |
+
const userCredential = await signInWithEmailAndPassword(auth, email, password);
|
| 122 |
+
console.log('User signed in:', userCredential.user.email);
|
| 123 |
+
return userCredential;
|
| 124 |
+
} catch (error) {
|
| 125 |
+
console.error('Sign in error:', error);
|
| 126 |
+
throw error;
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
/**
|
| 131 |
+
* Sign in with Google
|
| 132 |
+
* @returns {Promise<Object>} - User credential
|
| 133 |
+
*/
|
| 134 |
+
export async function signInWithGoogle() {
|
| 135 |
+
try {
|
| 136 |
+
const provider = new GoogleAuthProvider();
|
| 137 |
+
provider.setCustomParameters({
|
| 138 |
+
prompt: 'select_account'
|
| 139 |
+
});
|
| 140 |
+
const userCredential = await signInWithPopup(auth, provider);
|
| 141 |
+
console.log('User signed in with Google:', userCredential.user.email);
|
| 142 |
+
return userCredential;
|
| 143 |
+
} catch (error) {
|
| 144 |
+
console.error('Google sign in error:', error);
|
| 145 |
+
throw error;
|
| 146 |
+
}
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* Sign out current user
|
| 151 |
+
* @returns {Promise<void>}
|
| 152 |
+
*/
|
| 153 |
+
export async function signOutUser() {
|
| 154 |
+
try {
|
| 155 |
+
await signOut(auth);
|
| 156 |
+
console.log('User signed out');
|
| 157 |
+
} catch (error) {
|
| 158 |
+
console.error('Sign out error:', error);
|
| 159 |
+
throw error;
|
| 160 |
+
}
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
/**
|
| 164 |
+
* Get current user
|
| 165 |
+
* @returns {Object|null} - Current user or null
|
| 166 |
+
*/
|
| 167 |
+
export function getCurrentUser() {
|
| 168 |
+
return auth?.currentUser || null;
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
/**
|
| 172 |
+
* Listen to authentication state changes
|
| 173 |
+
* @param {Function} callback - Callback function to execute on auth state change
|
| 174 |
+
* @returns {Function} - Unsubscribe function
|
| 175 |
+
*/
|
| 176 |
+
export function onAuthChange(callback) {
|
| 177 |
+
if (!auth) {
|
| 178 |
+
console.error('Auth not initialized');
|
| 179 |
+
return () => {};
|
| 180 |
+
}
|
| 181 |
+
return onAuthStateChanged(auth, callback);
|
| 182 |
+
}
|
src/utils/imageUtils.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Image Utilities - Helper functions for image manipulation
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
import { DETECTION_CONFIG } from '../config.js';
|
| 6 |
+
|
| 7 |
+
/**
|
| 8 |
+
* Load image from data URL or path
|
| 9 |
+
*/
|
| 10 |
+
export function loadImage(src) {
|
| 11 |
+
return new Promise((resolve, reject) => {
|
| 12 |
+
const img = new Image();
|
| 13 |
+
img.crossOrigin = 'anonymous';
|
| 14 |
+
img.onload = () => resolve(img);
|
| 15 |
+
img.onerror = reject;
|
| 16 |
+
img.src = src;
|
| 17 |
+
});
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/**
|
| 21 |
+
* Crop image region with optional padding
|
| 22 |
+
*/
|
| 23 |
+
export async function cropImage(img, x1, y1, x2, y2, padding = DETECTION_CONFIG.yolo.padding) {
|
| 24 |
+
const width = x2 - x1;
|
| 25 |
+
const height = y2 - y1;
|
| 26 |
+
const padX = width * padding;
|
| 27 |
+
const padY = height * padding;
|
| 28 |
+
|
| 29 |
+
x1 = Math.max(0, x1 - padX);
|
| 30 |
+
y1 = Math.max(0, y1 - padY);
|
| 31 |
+
x2 = Math.min(img.width, x2 + padX);
|
| 32 |
+
y2 = Math.min(img.height, y2 + padY);
|
| 33 |
+
|
| 34 |
+
const canvas = document.createElement('canvas');
|
| 35 |
+
canvas.width = x2 - x1;
|
| 36 |
+
canvas.height = y2 - y1;
|
| 37 |
+
const ctx = canvas.getContext('2d');
|
| 38 |
+
ctx.drawImage(img, x1, y1, x2 - x1, y2 - y1, 0, 0, x2 - x1, y2 - y1);
|
| 39 |
+
|
| 40 |
+
return canvas.toDataURL();
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/**
|
| 44 |
+
* Read file as data URL
|
| 45 |
+
*/
|
| 46 |
+
export function readFileAsDataURL(file) {
|
| 47 |
+
return new Promise((resolve, reject) => {
|
| 48 |
+
const reader = new FileReader();
|
| 49 |
+
reader.onload = (e) => resolve(e.target.result);
|
| 50 |
+
reader.onerror = reject;
|
| 51 |
+
reader.readAsDataURL(file);
|
| 52 |
+
});
|
| 53 |
+
}
|
src/utils/mathUtils.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Math Utilities - Mathematical helper functions
|
| 3 |
+
*/
|
| 4 |
+
|
| 5 |
+
/**
|
| 6 |
+
* Softmax function for converting logits to probabilities
|
| 7 |
+
*/
|
| 8 |
+
export function softmax(logits) {
|
| 9 |
+
const maxLogit = Math.max(...logits);
|
| 10 |
+
const scores = logits.map(l => Math.exp(l - maxLogit));
|
| 11 |
+
const sum = scores.reduce((a, b) => a + b);
|
| 12 |
+
return scores.map(s => s / sum);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
/**
|
| 16 |
+
* Calculate mean of array
|
| 17 |
+
*/
|
| 18 |
+
export function mean(array) {
|
| 19 |
+
return array.reduce((a, b) => a + b, 0) / array.length;
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
/**
|
| 23 |
+
* Clamp value between min and max
|
| 24 |
+
*/
|
| 25 |
+
export function clamp(value, min, max) {
|
| 26 |
+
return Math.max(min, Math.min(max, value));
|
| 27 |
+
}
|
styles.css
ADDED
|
@@ -0,0 +1,1611 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* Embryo Grading System - Styles */
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
--primary-color: #007B8F;
|
| 5 |
+
--primary-dark: #004E64;
|
| 6 |
+
--primary-light: #009FB8;
|
| 7 |
+
--secondary-color: #006F8E;
|
| 8 |
+
--secondary-light: #0089A6;
|
| 9 |
+
--success-color: #66bb6a;
|
| 10 |
+
--success-light: #81c784;
|
| 11 |
+
--warning-color: #ffa726;
|
| 12 |
+
--warning-light: #ffb74d;
|
| 13 |
+
--error-color: #ef5350;
|
| 14 |
+
--error-light: #e57373;
|
| 15 |
+
--background: #F8F9FA;
|
| 16 |
+
--background-secondary: #f0f2f5;
|
| 17 |
+
--card-background: #ffffff;
|
| 18 |
+
--card-background-hover: #f8f9fa;
|
| 19 |
+
--text-primary: #333333;
|
| 20 |
+
--text-secondary: #666666;
|
| 21 |
+
--text-muted: #999999;
|
| 22 |
+
--border-color: #E0E0E0;
|
| 23 |
+
--border-color-light: #f0f0f0;
|
| 24 |
+
--shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 25 |
+
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.12);
|
| 26 |
+
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15);
|
| 27 |
+
--shadow-hover: 0 4px 16px rgba(0, 0, 0, 0.15);
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
* {
|
| 31 |
+
margin: 0;
|
| 32 |
+
padding: 0;
|
| 33 |
+
box-sizing: border-box;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
body {
|
| 37 |
+
font-family: 'EB Garamond', serif;
|
| 38 |
+
background: var(--background);
|
| 39 |
+
color: var(--text-primary);
|
| 40 |
+
line-height: 1.6;
|
| 41 |
+
font-size: 16px;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
h1, h2, h3, h4, h5, h6 {
|
| 45 |
+
font-family: 'EB Garamond', serif;
|
| 46 |
+
font-weight: 700;
|
| 47 |
+
color: var(--text-primary);
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
h1 { font-size: 2.5em; }
|
| 51 |
+
h2 { font-size: 2em; }
|
| 52 |
+
h3 { font-size: 1.5em; }
|
| 53 |
+
h4 { font-size: 1.25em; }
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
.container {
|
| 57 |
+
max-width: 1400px;
|
| 58 |
+
margin: 0 auto;
|
| 59 |
+
padding: 20px;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
header {
|
| 63 |
+
text-align: center;
|
| 64 |
+
margin-bottom: 30px;
|
| 65 |
+
padding: 20px;
|
| 66 |
+
background: transparent;
|
| 67 |
+
border-radius: 0;
|
| 68 |
+
box-shadow: none;
|
| 69 |
+
display: flex;
|
| 70 |
+
flex-direction: row;
|
| 71 |
+
align-items: center;
|
| 72 |
+
justify-content: space-between;
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
header .logo {
|
| 76 |
+
max-height: 140px;
|
| 77 |
+
height: auto;
|
| 78 |
+
width: auto;
|
| 79 |
+
max-width: 280px;
|
| 80 |
+
object-fit: contain;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.user-menu {
|
| 84 |
+
display: flex;
|
| 85 |
+
align-items: center;
|
| 86 |
+
gap: 15px;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
.user-email {
|
| 90 |
+
color: var(--text-primary);
|
| 91 |
+
font-size: 14px;
|
| 92 |
+
font-weight: 500;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.subtitle {
|
| 96 |
+
font-family: 'EB Garamond', serif;
|
| 97 |
+
font-size: 2em;
|
| 98 |
+
font-weight: 700;
|
| 99 |
+
opacity: 1;
|
| 100 |
+
margin: 0;
|
| 101 |
+
color: white;
|
| 102 |
+
letter-spacing: -0.5px;
|
| 103 |
+
line-height: 1.2;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
/* Stepper Styles */
|
| 107 |
+
.stepper-container {
|
| 108 |
+
margin-bottom: 40px;
|
| 109 |
+
background: var(--card-background);
|
| 110 |
+
padding: 30px;
|
| 111 |
+
border-radius: 12px;
|
| 112 |
+
box-shadow: var(--shadow);
|
| 113 |
+
border: 1px solid var(--border-color);
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.stepper {
|
| 117 |
+
display: flex;
|
| 118 |
+
align-items: center;
|
| 119 |
+
justify-content: space-between;
|
| 120 |
+
position: relative;
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
.step {
|
| 124 |
+
display: flex;
|
| 125 |
+
flex-direction: column;
|
| 126 |
+
align-items: center;
|
| 127 |
+
position: relative;
|
| 128 |
+
z-index: 2;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
.step-circle {
|
| 132 |
+
width: 50px;
|
| 133 |
+
height: 50px;
|
| 134 |
+
border-radius: 50%;
|
| 135 |
+
background: var(--border-color);
|
| 136 |
+
color: var(--text-secondary);
|
| 137 |
+
display: flex;
|
| 138 |
+
align-items: center;
|
| 139 |
+
justify-content: center;
|
| 140 |
+
font-weight: 700;
|
| 141 |
+
font-size: 1.2em;
|
| 142 |
+
transition: all 0.3s ease;
|
| 143 |
+
border: 3px solid var(--border-color);
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
.step-label {
|
| 147 |
+
margin-top: 10px;
|
| 148 |
+
font-size: 0.9em;
|
| 149 |
+
font-weight: 600;
|
| 150 |
+
color: var(--text-secondary);
|
| 151 |
+
text-align: center;
|
| 152 |
+
max-width: 100px;
|
| 153 |
+
transition: all 0.3s ease;
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
+
.step.active .step-circle {
|
| 157 |
+
background: var(--primary-color);
|
| 158 |
+
color: white;
|
| 159 |
+
border-color: var(--primary-color);
|
| 160 |
+
box-shadow: 0 0 0 4px rgba(0, 123, 143, 0.15);
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
.step.active .step-label {
|
| 164 |
+
color: var(--primary-color);
|
| 165 |
+
font-weight: 700;
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
.step.completed .step-circle {
|
| 169 |
+
background: var(--success-color);
|
| 170 |
+
border-color: var(--success-color);
|
| 171 |
+
color: white;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.step.completed .step-label {
|
| 175 |
+
color: var(--success-color);
|
| 176 |
+
}
|
| 177 |
+
|
| 178 |
+
.step-line {
|
| 179 |
+
flex: 1;
|
| 180 |
+
height: 3px;
|
| 181 |
+
background: var(--border-color);
|
| 182 |
+
margin: 0 10px;
|
| 183 |
+
position: relative;
|
| 184 |
+
top: -20px;
|
| 185 |
+
transition: all 0.3s ease;
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
.step-line.completed {
|
| 189 |
+
background: var(--success-color);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
/* Step Content */
|
| 193 |
+
.step-content {
|
| 194 |
+
display: none;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.step-content.active {
|
| 198 |
+
display: block;
|
| 199 |
+
animation: fadeIn 0.3s ease;
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
@keyframes fadeIn {
|
| 203 |
+
from {
|
| 204 |
+
opacity: 0;
|
| 205 |
+
transform: translateY(10px);
|
| 206 |
+
}
|
| 207 |
+
to {
|
| 208 |
+
opacity: 1;
|
| 209 |
+
transform: translateY(0);
|
| 210 |
+
}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.step-navigation {
|
| 214 |
+
display: flex;
|
| 215 |
+
justify-content: space-between;
|
| 216 |
+
align-items: center;
|
| 217 |
+
gap: 15px;
|
| 218 |
+
margin-top: 30px;
|
| 219 |
+
padding-top: 20px;
|
| 220 |
+
border-top: 2px solid var(--border-color);
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
.step-navigation .btn {
|
| 224 |
+
min-width: 150px;
|
| 225 |
+
}
|
| 226 |
+
|
| 227 |
+
.step-navigation .btn:first-child {
|
| 228 |
+
margin-right: auto;
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
.step-navigation .btn:last-child {
|
| 232 |
+
margin-left: auto;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
/* Loading Overlay */
|
| 236 |
+
.loading-overlay {
|
| 237 |
+
position: fixed;
|
| 238 |
+
top: 0;
|
| 239 |
+
left: 0;
|
| 240 |
+
width: 100%;
|
| 241 |
+
height: 100%;
|
| 242 |
+
background: rgba(0, 0, 0, 0.8);
|
| 243 |
+
display: flex;
|
| 244 |
+
justify-content: center;
|
| 245 |
+
align-items: center;
|
| 246 |
+
z-index: 9999;
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
.loading-overlay.hidden {
|
| 250 |
+
display: none;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.loading-content {
|
| 254 |
+
text-align: center;
|
| 255 |
+
color: white;
|
| 256 |
+
}
|
| 257 |
+
|
| 258 |
+
.spinner {
|
| 259 |
+
border: 4px solid rgba(255, 255, 255, 0.3);
|
| 260 |
+
border-top: 4px solid white;
|
| 261 |
+
border-radius: 50%;
|
| 262 |
+
width: 60px;
|
| 263 |
+
height: 60px;
|
| 264 |
+
animation: spin 1s linear infinite;
|
| 265 |
+
margin: 0 auto 20px;
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
@keyframes spin {
|
| 269 |
+
0% { transform: rotate(0deg); }
|
| 270 |
+
100% { transform: rotate(360deg); }
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
#loadingText {
|
| 274 |
+
font-size: 1.2em;
|
| 275 |
+
margin-bottom: 20px;
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
.progress-container {
|
| 279 |
+
width: 300px;
|
| 280 |
+
height: 6px;
|
| 281 |
+
background: rgba(255, 255, 255, 0.2);
|
| 282 |
+
border-radius: 3px;
|
| 283 |
+
overflow: hidden;
|
| 284 |
+
margin: 0 auto;
|
| 285 |
+
}
|
| 286 |
+
|
| 287 |
+
.progress-bar {
|
| 288 |
+
height: 100%;
|
| 289 |
+
background: linear-gradient(90deg, var(--primary-color), var(--primary-light));
|
| 290 |
+
width: 0%;
|
| 291 |
+
transition: width 0.3s ease;
|
| 292 |
+
}
|
| 293 |
+
|
| 294 |
+
/* Cards */
|
| 295 |
+
.card {
|
| 296 |
+
background: var(--card-background);
|
| 297 |
+
border-radius: 12px;
|
| 298 |
+
padding: 30px;
|
| 299 |
+
margin-bottom: 20px;
|
| 300 |
+
box-shadow: var(--shadow);
|
| 301 |
+
border: 1px solid var(--border-color);
|
| 302 |
+
transition: all 0.3s ease;
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
.card:hover {
|
| 306 |
+
box-shadow: var(--shadow-hover);
|
| 307 |
+
border-color: var(--border-color-light);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
.section-title {
|
| 311 |
+
font-size: 1.5em;
|
| 312 |
+
font-weight: 700;
|
| 313 |
+
color: var(--text-primary);
|
| 314 |
+
margin-bottom: 20px;
|
| 315 |
+
padding-bottom: 15px;
|
| 316 |
+
border-bottom: 2px solid var(--border-color);
|
| 317 |
+
font-family: 'EB Garamond', serif;
|
| 318 |
+
letter-spacing: -0.5px;
|
| 319 |
+
}
|
| 320 |
+
|
| 321 |
+
/* Upload Section */
|
| 322 |
+
.upload-section {
|
| 323 |
+
display: grid;
|
| 324 |
+
grid-template-columns: 1fr 1fr;
|
| 325 |
+
gap: 30px;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
.image-preview-container h3,
|
| 329 |
+
.status-container h3,
|
| 330 |
+
.upload-container h3,
|
| 331 |
+
.results-container h3,
|
| 332 |
+
.embryo-preview-container h3 {
|
| 333 |
+
font-size: 1.2em;
|
| 334 |
+
font-weight: 600;
|
| 335 |
+
margin-bottom: 15px;
|
| 336 |
+
color: var(--text-primary);
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
.image-preview {
|
| 340 |
+
position: relative;
|
| 341 |
+
width: 100%;
|
| 342 |
+
height: 400px;
|
| 343 |
+
border: 2px dashed var(--border-color);
|
| 344 |
+
border-radius: 8px;
|
| 345 |
+
display: flex;
|
| 346 |
+
align-items: center;
|
| 347 |
+
justify-content: center;
|
| 348 |
+
background: var(--background-secondary);
|
| 349 |
+
overflow: hidden;
|
| 350 |
+
margin-bottom: 15px;
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
.image-preview img,
|
| 354 |
+
.image-preview canvas {
|
| 355 |
+
max-width: 100%;
|
| 356 |
+
max-height: 100%;
|
| 357 |
+
object-fit: contain;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.placeholder {
|
| 361 |
+
text-align: center;
|
| 362 |
+
color: var(--text-secondary);
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
.placeholder svg {
|
| 366 |
+
margin-bottom: 10px;
|
| 367 |
+
opacity: 0.5;
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
.zoom-controls {
|
| 371 |
+
position: absolute;
|
| 372 |
+
bottom: 10px;
|
| 373 |
+
right: 10px;
|
| 374 |
+
display: flex;
|
| 375 |
+
gap: 5px;
|
| 376 |
+
background: rgba(255, 255, 255, 0.95);
|
| 377 |
+
padding: 8px;
|
| 378 |
+
border-radius: 8px;
|
| 379 |
+
box-shadow: var(--shadow);
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.icon-button {
|
| 383 |
+
background: var(--card-background);
|
| 384 |
+
border: 1px solid var(--border-color);
|
| 385 |
+
border-radius: 6px;
|
| 386 |
+
padding: 6px 12px;
|
| 387 |
+
cursor: pointer;
|
| 388 |
+
font-size: 0.85em;
|
| 389 |
+
transition: all 0.2s ease;
|
| 390 |
+
font-weight: 500;
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
.icon-button span {
|
| 394 |
+
display: inline-block;
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
.icon-button:hover:not(:disabled) {
|
| 398 |
+
background: var(--primary-color);
|
| 399 |
+
color: white;
|
| 400 |
+
border-color: var(--primary-color);
|
| 401 |
+
transform: scale(1.05);
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.icon-button:disabled {
|
| 405 |
+
opacity: 0.5;
|
| 406 |
+
cursor: not-allowed;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.zoom-level {
|
| 410 |
+
display: flex;
|
| 411 |
+
align-items: center;
|
| 412 |
+
padding: 0 10px;
|
| 413 |
+
font-size: 0.9em;
|
| 414 |
+
font-weight: 500;
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
/* Status Box */
|
| 418 |
+
.status-box,
|
| 419 |
+
.results-box,
|
| 420 |
+
.info-box {
|
| 421 |
+
background: var(--background-secondary);
|
| 422 |
+
border-left: 4px solid var(--primary-color);
|
| 423 |
+
padding: 20px;
|
| 424 |
+
border-radius: 8px;
|
| 425 |
+
min-height: 100px;
|
| 426 |
+
border: 1px solid var(--border-color);
|
| 427 |
+
border-left: 4px solid var(--primary-color);
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.info-box {
|
| 431 |
+
border-left-color: var(--secondary-color);
|
| 432 |
+
margin-bottom: 20px;
|
| 433 |
+
background: #e6f7f9;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
/* Buttons */
|
| 437 |
+
.btn {
|
| 438 |
+
padding: 12px 24px;
|
| 439 |
+
font-size: 1em;
|
| 440 |
+
font-weight: 600;
|
| 441 |
+
border: none;
|
| 442 |
+
border-radius: 8px;
|
| 443 |
+
cursor: pointer;
|
| 444 |
+
transition: all 0.3s ease;
|
| 445 |
+
font-family: 'EB Garamond', serif;
|
| 446 |
+
letter-spacing: 0.5px;
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
.btn-primary {
|
| 450 |
+
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
| 451 |
+
color: white;
|
| 452 |
+
box-shadow: 0 4px 6px rgba(0, 123, 143, 0.25);
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
.btn-primary:hover:not(:disabled) {
|
| 456 |
+
background: linear-gradient(135deg, var(--primary-dark) 0%, #003847 100%);
|
| 457 |
+
transform: translateY(-2px);
|
| 458 |
+
box-shadow: 0 6px 12px rgba(0, 123, 143, 0.35);
|
| 459 |
+
}
|
| 460 |
+
|
| 461 |
+
.btn-primary:active:not(:disabled) {
|
| 462 |
+
transform: translateY(0);
|
| 463 |
+
box-shadow: 0 2px 4px rgba(0, 123, 143, 0.2);
|
| 464 |
+
}
|
| 465 |
+
|
| 466 |
+
.btn-secondary {
|
| 467 |
+
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-dark) 100%);
|
| 468 |
+
color: white;
|
| 469 |
+
box-shadow: 0 4px 6px rgba(0, 123, 143, 0.25);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.btn-secondary:hover:not(:disabled) {
|
| 473 |
+
background: linear-gradient(135deg, var(--primary-dark) 0%, #003847 100%);
|
| 474 |
+
transform: translateY(-2px);
|
| 475 |
+
box-shadow: 0 6px 12px rgba(0, 123, 143, 0.35);
|
| 476 |
+
}
|
| 477 |
+
|
| 478 |
+
.btn-secondary:active:not(:disabled) {
|
| 479 |
+
transform: translateY(0);
|
| 480 |
+
box-shadow: 0 2px 4px rgba(0, 123, 143, 0.2);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.btn-large {
|
| 484 |
+
padding: 16px 32px;
|
| 485 |
+
font-size: 1.1em;
|
| 486 |
+
width: 100%;
|
| 487 |
+
margin-top: 15px;
|
| 488 |
+
}
|
| 489 |
+
|
| 490 |
+
.btn:disabled {
|
| 491 |
+
opacity: 0.5;
|
| 492 |
+
cursor: not-allowed;
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.button-group {
|
| 496 |
+
display: flex;
|
| 497 |
+
gap: 10px;
|
| 498 |
+
flex-wrap: wrap;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
/* Detection Mode */
|
| 502 |
+
.detection-mode {
|
| 503 |
+
display: flex;
|
| 504 |
+
flex-direction: column;
|
| 505 |
+
gap: 15px;
|
| 506 |
+
}
|
| 507 |
+
|
| 508 |
+
.mode-option {
|
| 509 |
+
display: flex;
|
| 510 |
+
align-items: flex-start;
|
| 511 |
+
padding: 20px;
|
| 512 |
+
border: 2px solid var(--border-color);
|
| 513 |
+
border-radius: 8px;
|
| 514 |
+
transition: all 0.3s ease;
|
| 515 |
+
cursor: pointer;
|
| 516 |
+
}
|
| 517 |
+
|
| 518 |
+
.mode-option:hover {
|
| 519 |
+
border-color: var(--primary-color);
|
| 520 |
+
background: var(--background-secondary);
|
| 521 |
+
box-shadow: var(--shadow);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.mode-option input[type="radio"] {
|
| 525 |
+
margin-right: 15px;
|
| 526 |
+
margin-top: 5px;
|
| 527 |
+
width: 20px;
|
| 528 |
+
height: 20px;
|
| 529 |
+
cursor: pointer;
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.mode-option label {
|
| 533 |
+
flex: 1;
|
| 534 |
+
cursor: pointer;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
.mode-option label strong {
|
| 538 |
+
display: block;
|
| 539 |
+
font-size: 1.1em;
|
| 540 |
+
margin-bottom: 5px;
|
| 541 |
+
color: var(--text-primary);
|
| 542 |
+
}
|
| 543 |
+
|
| 544 |
+
.mode-option label p {
|
| 545 |
+
color: var(--text-secondary);
|
| 546 |
+
font-size: 0.9em;
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
/* Embryo Evaluation Section */
|
| 550 |
+
.embryo-evaluation-section {
|
| 551 |
+
display: grid;
|
| 552 |
+
grid-template-columns: 1fr 1fr;
|
| 553 |
+
gap: 30px;
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
/* Detection Layout - New inline detection view */
|
| 557 |
+
.detection-layout {
|
| 558 |
+
display: grid;
|
| 559 |
+
grid-template-columns: 2fr 1fr;
|
| 560 |
+
gap: 30px;
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
.detection-preview-container {
|
| 564 |
+
display: flex;
|
| 565 |
+
flex-direction: column;
|
| 566 |
+
}
|
| 567 |
+
|
| 568 |
+
.image-preview-with-boxes {
|
| 569 |
+
position: relative;
|
| 570 |
+
background: var(--background-secondary);
|
| 571 |
+
border: 2px solid var(--border-color);
|
| 572 |
+
border-radius: 12px;
|
| 573 |
+
overflow: hidden;
|
| 574 |
+
min-height: 400px;
|
| 575 |
+
display: flex;
|
| 576 |
+
align-items: center;
|
| 577 |
+
justify-content: center;
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
#detectionCanvas {
|
| 581 |
+
max-width: 100%;
|
| 582 |
+
max-height: 600px;
|
| 583 |
+
cursor: pointer;
|
| 584 |
+
display: block;
|
| 585 |
+
}
|
| 586 |
+
|
| 587 |
+
.detection-info {
|
| 588 |
+
margin-top: 15px;
|
| 589 |
+
padding: 12px;
|
| 590 |
+
background: #e6f7f9;
|
| 591 |
+
border-left: 4px solid var(--primary-color);
|
| 592 |
+
border-radius: 4px;
|
| 593 |
+
}
|
| 594 |
+
|
| 595 |
+
.detection-info p {
|
| 596 |
+
margin: 0;
|
| 597 |
+
color: var(--text-primary);
|
| 598 |
+
font-size: 0.95em;
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
.embryo-list-container {
|
| 602 |
+
display: flex;
|
| 603 |
+
flex-direction: column;
|
| 604 |
+
}
|
| 605 |
+
|
| 606 |
+
.embryo-list {
|
| 607 |
+
display: flex;
|
| 608 |
+
flex-direction: column;
|
| 609 |
+
gap: 12px;
|
| 610 |
+
max-height: 600px;
|
| 611 |
+
overflow-y: auto;
|
| 612 |
+
padding: 5px;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.embryo-card {
|
| 616 |
+
display: flex;
|
| 617 |
+
align-items: center;
|
| 618 |
+
gap: 12px;
|
| 619 |
+
padding: 12px;
|
| 620 |
+
background: white;
|
| 621 |
+
border: 2px solid var(--border-color);
|
| 622 |
+
border-radius: 8px;
|
| 623 |
+
cursor: pointer;
|
| 624 |
+
transition: all 0.3s ease;
|
| 625 |
+
position: relative;
|
| 626 |
+
}
|
| 627 |
+
|
| 628 |
+
.embryo-card:hover {
|
| 629 |
+
border-color: var(--primary-color);
|
| 630 |
+
box-shadow: var(--shadow-md);
|
| 631 |
+
transform: translateY(-1px);
|
| 632 |
+
}
|
| 633 |
+
|
| 634 |
+
.embryo-card.selected {
|
| 635 |
+
border-color: var(--primary-color);
|
| 636 |
+
background: rgba(0, 123, 143, 0.05);
|
| 637 |
+
box-shadow: var(--shadow);
|
| 638 |
+
}
|
| 639 |
+
|
| 640 |
+
.embryo-card-thumbnail {
|
| 641 |
+
width: 60px;
|
| 642 |
+
height: 60px;
|
| 643 |
+
border-radius: 6px;
|
| 644 |
+
overflow: hidden;
|
| 645 |
+
flex-shrink: 0;
|
| 646 |
+
}
|
| 647 |
+
|
| 648 |
+
.embryo-card-thumbnail img {
|
| 649 |
+
width: 100%;
|
| 650 |
+
height: 100%;
|
| 651 |
+
object-fit: cover;
|
| 652 |
+
}
|
| 653 |
+
|
| 654 |
+
.embryo-card-info {
|
| 655 |
+
flex: 1;
|
| 656 |
+
display: flex;
|
| 657 |
+
flex-direction: column;
|
| 658 |
+
gap: 4px;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
.embryo-card-title {
|
| 662 |
+
font-weight: 600;
|
| 663 |
+
color: var(--text-primary);
|
| 664 |
+
font-size: 0.95em;
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
.embryo-card-confidence {
|
| 668 |
+
font-size: 0.85em;
|
| 669 |
+
color: var(--text-secondary);
|
| 670 |
+
}
|
| 671 |
+
|
| 672 |
+
.embryo-card-actions {
|
| 673 |
+
display: flex;
|
| 674 |
+
gap: 8px;
|
| 675 |
+
}
|
| 676 |
+
|
| 677 |
+
.embryo-card-btn {
|
| 678 |
+
padding: 6px 12px;
|
| 679 |
+
font-size: 0.85em;
|
| 680 |
+
border: 1px solid var(--border-color);
|
| 681 |
+
background: white;
|
| 682 |
+
border-radius: 6px;
|
| 683 |
+
cursor: pointer;
|
| 684 |
+
transition: all 0.2s ease;
|
| 685 |
+
}
|
| 686 |
+
|
| 687 |
+
.embryo-card-btn:hover {
|
| 688 |
+
background: var(--background-secondary);
|
| 689 |
+
border-color: var(--text-secondary);
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.embryo-card-btn.discard {
|
| 693 |
+
color: var(--error-color);
|
| 694 |
+
border-color: var(--error-color);
|
| 695 |
+
}
|
| 696 |
+
|
| 697 |
+
.embryo-card-btn.discard:hover {
|
| 698 |
+
background: var(--error-color);
|
| 699 |
+
color: white;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
+
.embryo-thumbnail-slider {
|
| 703 |
+
display: flex;
|
| 704 |
+
gap: 10px;
|
| 705 |
+
overflow-x: auto;
|
| 706 |
+
padding: 10px 0;
|
| 707 |
+
margin-bottom: 15px;
|
| 708 |
+
}
|
| 709 |
+
|
| 710 |
+
.embryo-thumbnail-wrapper {
|
| 711 |
+
position: relative;
|
| 712 |
+
flex-shrink: 0;
|
| 713 |
+
}
|
| 714 |
+
|
| 715 |
+
.embryo-thumbnail {
|
| 716 |
+
position: relative;
|
| 717 |
+
min-width: 80px;
|
| 718 |
+
height: 80px;
|
| 719 |
+
border: 3px solid var(--border-color);
|
| 720 |
+
border-radius: 8px;
|
| 721 |
+
cursor: pointer;
|
| 722 |
+
overflow: hidden;
|
| 723 |
+
transition: all 0.3s ease;
|
| 724 |
+
}
|
| 725 |
+
|
| 726 |
+
.embryo-thumbnail:hover {
|
| 727 |
+
border-color: var(--primary-color);
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.embryo-thumbnail.active {
|
| 731 |
+
border-color: var(--primary-color);
|
| 732 |
+
box-shadow: 0 0 0 2px rgba(0, 123, 143, 0.2);
|
| 733 |
+
}
|
| 734 |
+
|
| 735 |
+
.embryo-thumbnail img {
|
| 736 |
+
width: 100%;
|
| 737 |
+
height: 100%;
|
| 738 |
+
object-fit: cover;
|
| 739 |
+
}
|
| 740 |
+
|
| 741 |
+
.discard-embryo-btn {
|
| 742 |
+
position: absolute;
|
| 743 |
+
top: 4px;
|
| 744 |
+
right: 4px;
|
| 745 |
+
width: 22px;
|
| 746 |
+
height: 22px;
|
| 747 |
+
border-radius: 4px;
|
| 748 |
+
background: rgba(239, 83, 80, 0.9);
|
| 749 |
+
color: white;
|
| 750 |
+
border: none;
|
| 751 |
+
font-size: 16px;
|
| 752 |
+
font-weight: bold;
|
| 753 |
+
line-height: 1;
|
| 754 |
+
cursor: pointer;
|
| 755 |
+
display: flex;
|
| 756 |
+
align-items: center;
|
| 757 |
+
justify-content: center;
|
| 758 |
+
transition: all 0.2s ease;
|
| 759 |
+
z-index: 10;
|
| 760 |
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
| 761 |
+
opacity: 1;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.embryo-thumbnail-wrapper:hover .discard-embryo-btn {
|
| 765 |
+
display: flex;
|
| 766 |
+
opacity: 1;
|
| 767 |
+
}
|
| 768 |
+
|
| 769 |
+
.discard-embryo-btn:hover {
|
| 770 |
+
background: rgba(211, 47, 47, 1);
|
| 771 |
+
transform: scale(1.05);
|
| 772 |
+
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.4);
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
+
.discard-embryo-btn:active {
|
| 776 |
+
transform: scale(0.95);
|
| 777 |
+
}
|
| 778 |
+
|
| 779 |
+
.embryo-preview {
|
| 780 |
+
height: 350px;
|
| 781 |
+
}
|
| 782 |
+
|
| 783 |
+
.navigation-controls {
|
| 784 |
+
display: flex;
|
| 785 |
+
align-items: center;
|
| 786 |
+
justify-content: space-between;
|
| 787 |
+
gap: 15px;
|
| 788 |
+
margin-bottom: 15px;
|
| 789 |
+
}
|
| 790 |
+
|
| 791 |
+
.embryo-counter {
|
| 792 |
+
flex: 1;
|
| 793 |
+
text-align: center;
|
| 794 |
+
font-weight: 600;
|
| 795 |
+
font-size: 1.1em;
|
| 796 |
+
padding: 10px;
|
| 797 |
+
background: var(--background-secondary);
|
| 798 |
+
border-radius: 8px;
|
| 799 |
+
border: 1px solid var(--border-color);
|
| 800 |
+
}
|
| 801 |
+
|
| 802 |
+
/* Crop Controls */
|
| 803 |
+
.crop-controls {
|
| 804 |
+
background: var(--background-secondary);
|
| 805 |
+
border-radius: 8px;
|
| 806 |
+
padding: 15px;
|
| 807 |
+
margin-top: 15px;
|
| 808 |
+
border: 1px solid var(--border-color);
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
.crop-controls h4 {
|
| 812 |
+
color: var(--text-primary);
|
| 813 |
+
margin-bottom: 10px;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.crop-controls label {
|
| 817 |
+
color: var(--text-secondary);
|
| 818 |
+
font-weight: 500;
|
| 819 |
+
}
|
| 820 |
+
|
| 821 |
+
.crop-controls input[type="range"] {
|
| 822 |
+
width: 100%;
|
| 823 |
+
margin: 5px 0;
|
| 824 |
+
accent-color: var(--primary-color);
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
.crop-controls input[type="range"]::-webkit-slider-thumb {
|
| 828 |
+
cursor: pointer;
|
| 829 |
+
}
|
| 830 |
+
|
| 831 |
+
.crop-controls input[type="range"]::-moz-range-thumb {
|
| 832 |
+
cursor: pointer;
|
| 833 |
+
}
|
| 834 |
+
|
| 835 |
+
#cropCanvas {
|
| 836 |
+
border-radius: 8px;
|
| 837 |
+
box-shadow: var(--shadow);
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
/* Quick Evaluation */
|
| 841 |
+
.quick-evaluation-section {
|
| 842 |
+
display: grid;
|
| 843 |
+
grid-template-columns: 1fr 1fr;
|
| 844 |
+
gap: 30px;
|
| 845 |
+
}
|
| 846 |
+
|
| 847 |
+
/* Predictions Box */
|
| 848 |
+
.predictions-box {
|
| 849 |
+
background: var(--card-background);
|
| 850 |
+
border: 1px solid var(--border-color);
|
| 851 |
+
border-radius: 8px;
|
| 852 |
+
padding: 15px;
|
| 853 |
+
margin-top: 15px;
|
| 854 |
+
}
|
| 855 |
+
|
| 856 |
+
.prediction-item {
|
| 857 |
+
display: flex;
|
| 858 |
+
justify-content: space-between;
|
| 859 |
+
align-items: center;
|
| 860 |
+
padding: 12px;
|
| 861 |
+
margin-bottom: 10px;
|
| 862 |
+
background: var(--background-secondary);
|
| 863 |
+
border-radius: 6px;
|
| 864 |
+
border-left: 4px solid var(--primary-color);
|
| 865 |
+
}
|
| 866 |
+
|
| 867 |
+
.prediction-label {
|
| 868 |
+
font-weight: 600;
|
| 869 |
+
color: var(--text-primary);
|
| 870 |
+
}
|
| 871 |
+
|
| 872 |
+
.prediction-confidence {
|
| 873 |
+
font-weight: 500;
|
| 874 |
+
color: var(--primary-color);
|
| 875 |
+
}
|
| 876 |
+
|
| 877 |
+
.prediction-bar {
|
| 878 |
+
height: 8px;
|
| 879 |
+
background: var(--border-color);
|
| 880 |
+
border-radius: 4px;
|
| 881 |
+
overflow: hidden;
|
| 882 |
+
margin-top: 8px;
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
.prediction-bar-fill {
|
| 886 |
+
height: 100%;
|
| 887 |
+
background: linear-gradient(90deg, var(--primary-color), var(--primary-light));
|
| 888 |
+
transition: width 0.5s ease;
|
| 889 |
+
}
|
| 890 |
+
|
| 891 |
+
/* Result Styling */
|
| 892 |
+
.grade-result {
|
| 893 |
+
padding: 20px;
|
| 894 |
+
border-radius: 10px;
|
| 895 |
+
margin: 15px 0;
|
| 896 |
+
box-shadow: var(--shadow-md);
|
| 897 |
+
}
|
| 898 |
+
|
| 899 |
+
.grade-good {
|
| 900 |
+
background: linear-gradient(135deg, var(--success-color) 0%, var(--success-light) 100%);
|
| 901 |
+
color: white;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
.grade-poor {
|
| 905 |
+
background: linear-gradient(135deg, var(--error-color) 0%, var(--error-light) 100%);
|
| 906 |
+
color: white;
|
| 907 |
+
}
|
| 908 |
+
|
| 909 |
+
.grade-result h2 {
|
| 910 |
+
margin: 0 0 10px 0;
|
| 911 |
+
font-family: 'Montserrat', sans-serif;
|
| 912 |
+
font-weight: 600;
|
| 913 |
+
}
|
| 914 |
+
|
| 915 |
+
.grade-result p {
|
| 916 |
+
margin: 5px 0;
|
| 917 |
+
font-size: 1.1em;
|
| 918 |
+
font-weight: 600;
|
| 919 |
+
}
|
| 920 |
+
|
| 921 |
+
/* Toast Notifications */
|
| 922 |
+
.toast {
|
| 923 |
+
position: fixed;
|
| 924 |
+
bottom: 30px;
|
| 925 |
+
right: 30px;
|
| 926 |
+
background: var(--text-primary);
|
| 927 |
+
color: white;
|
| 928 |
+
padding: 16px 24px;
|
| 929 |
+
border-radius: 8px;
|
| 930 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
| 931 |
+
opacity: 0;
|
| 932 |
+
transform: translateY(20px);
|
| 933 |
+
transition: all 0.3s ease;
|
| 934 |
+
z-index: 10000;
|
| 935 |
+
max-width: 400px;
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
.toast.show {
|
| 939 |
+
opacity: 1;
|
| 940 |
+
transform: translateY(0);
|
| 941 |
+
}
|
| 942 |
+
|
| 943 |
+
.toast.success {
|
| 944 |
+
background: var(--success-color);
|
| 945 |
+
}
|
| 946 |
+
|
| 947 |
+
.toast.error {
|
| 948 |
+
background: var(--error-color);
|
| 949 |
+
}
|
| 950 |
+
|
| 951 |
+
.toast.warning {
|
| 952 |
+
background: var(--warning-color);
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
+
/* Footer */
|
| 956 |
+
footer {
|
| 957 |
+
text-align: center;
|
| 958 |
+
padding: 30px 20px;
|
| 959 |
+
margin-top: 40px;
|
| 960 |
+
border-top: 2px solid var(--border-color);
|
| 961 |
+
color: var(--text-secondary);
|
| 962 |
+
}
|
| 963 |
+
|
| 964 |
+
footer p {
|
| 965 |
+
margin: 10px 0;
|
| 966 |
+
}
|
| 967 |
+
|
| 968 |
+
.model-info {
|
| 969 |
+
font-size: 0.9em;
|
| 970 |
+
font-family: 'Courier New', monospace;
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
/* Responsive Design */
|
| 974 |
+
@media (max-width: 1024px) {
|
| 975 |
+
.upload-section,
|
| 976 |
+
.embryo-evaluation-section,
|
| 977 |
+
.quick-evaluation-section,
|
| 978 |
+
.detection-layout {
|
| 979 |
+
grid-template-columns: 1fr;
|
| 980 |
+
}
|
| 981 |
+
|
| 982 |
+
.embryo-list {
|
| 983 |
+
max-height: 300px;
|
| 984 |
+
}
|
| 985 |
+
|
| 986 |
+
header h1 {
|
| 987 |
+
font-size: 2em;
|
| 988 |
+
}
|
| 989 |
+
|
| 990 |
+
.stepper {
|
| 991 |
+
overflow-x: auto;
|
| 992 |
+
padding-bottom: 10px;
|
| 993 |
+
}
|
| 994 |
+
|
| 995 |
+
.step-label {
|
| 996 |
+
font-size: 0.8em;
|
| 997 |
+
max-width: 80px;
|
| 998 |
+
}
|
| 999 |
+
|
| 1000 |
+
.step-circle {
|
| 1001 |
+
width: 40px;
|
| 1002 |
+
height: 40px;
|
| 1003 |
+
font-size: 1em;
|
| 1004 |
+
}
|
| 1005 |
+
}
|
| 1006 |
+
|
| 1007 |
+
@media (max-width: 768px) {
|
| 1008 |
+
.container {
|
| 1009 |
+
padding: 10px;
|
| 1010 |
+
}
|
| 1011 |
+
|
| 1012 |
+
header {
|
| 1013 |
+
padding: 20px 15px;
|
| 1014 |
+
}
|
| 1015 |
+
|
| 1016 |
+
header h1 {
|
| 1017 |
+
font-size: 1.5em;
|
| 1018 |
+
}
|
| 1019 |
+
|
| 1020 |
+
.subtitle {
|
| 1021 |
+
font-size: 0.9em;
|
| 1022 |
+
}
|
| 1023 |
+
|
| 1024 |
+
.card {
|
| 1025 |
+
padding: 20px;
|
| 1026 |
+
}
|
| 1027 |
+
|
| 1028 |
+
.stepper-container {
|
| 1029 |
+
padding: 20px 10px;
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
.stepper {
|
| 1033 |
+
flex-wrap: nowrap;
|
| 1034 |
+
overflow-x: auto;
|
| 1035 |
+
}
|
| 1036 |
+
|
| 1037 |
+
.step {
|
| 1038 |
+
min-width: 70px;
|
| 1039 |
+
}
|
| 1040 |
+
|
| 1041 |
+
.step-circle {
|
| 1042 |
+
width: 35px;
|
| 1043 |
+
height: 35px;
|
| 1044 |
+
font-size: 0.9em;
|
| 1045 |
+
}
|
| 1046 |
+
|
| 1047 |
+
.step-label {
|
| 1048 |
+
font-size: 0.7em;
|
| 1049 |
+
max-width: 70px;
|
| 1050 |
+
}
|
| 1051 |
+
|
| 1052 |
+
.step-line {
|
| 1053 |
+
min-width: 20px;
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
+
.tabs {
|
| 1057 |
+
overflow-x: auto;
|
| 1058 |
+
}
|
| 1059 |
+
|
| 1060 |
+
.tab-button {
|
| 1061 |
+
padding: 12px 20px;
|
| 1062 |
+
font-size: 0.9em;
|
| 1063 |
+
}
|
| 1064 |
+
|
| 1065 |
+
.image-preview {
|
| 1066 |
+
height: 300px;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
.navigation-controls {
|
| 1070 |
+
flex-direction: column;
|
| 1071 |
+
}
|
| 1072 |
+
|
| 1073 |
+
.step-navigation {
|
| 1074 |
+
flex-direction: column;
|
| 1075 |
+
gap: 10px;
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
.step-navigation .btn {
|
| 1079 |
+
width: 100%;
|
| 1080 |
+
min-width: auto;
|
| 1081 |
+
}
|
| 1082 |
+
|
| 1083 |
+
.step-navigation .btn:first-child,
|
| 1084 |
+
.step-navigation .btn:last-child {
|
| 1085 |
+
margin: 0;
|
| 1086 |
+
}
|
| 1087 |
+
|
| 1088 |
+
.button-group {
|
| 1089 |
+
flex-direction: column;
|
| 1090 |
+
}
|
| 1091 |
+
|
| 1092 |
+
.btn-large {
|
| 1093 |
+
font-size: 1em;
|
| 1094 |
+
}
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
/* All Embryos Results Grid */
|
| 1098 |
+
.all-embryos-results h3 {
|
| 1099 |
+
color: var(--text-primary);
|
| 1100 |
+
margin-bottom: 10px;
|
| 1101 |
+
}
|
| 1102 |
+
|
| 1103 |
+
.embryo-results-grid {
|
| 1104 |
+
display: grid;
|
| 1105 |
+
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
| 1106 |
+
gap: 20px;
|
| 1107 |
+
margin-bottom: 30px;
|
| 1108 |
+
}
|
| 1109 |
+
|
| 1110 |
+
.embryo-result-card {
|
| 1111 |
+
background: var(--card-background);
|
| 1112 |
+
border: 2px solid var(--border-color);
|
| 1113 |
+
border-radius: 12px;
|
| 1114 |
+
padding: 15px;
|
| 1115 |
+
cursor: pointer;
|
| 1116 |
+
transition: all 0.3s ease;
|
| 1117 |
+
}
|
| 1118 |
+
|
| 1119 |
+
.embryo-result-card:hover {
|
| 1120 |
+
border-color: var(--primary-color);
|
| 1121 |
+
box-shadow: var(--shadow-hover);
|
| 1122 |
+
transform: translateY(-2px);
|
| 1123 |
+
}
|
| 1124 |
+
|
| 1125 |
+
.embryo-result-card.active {
|
| 1126 |
+
border-color: var(--primary-color);
|
| 1127 |
+
background: rgba(0, 123, 143, 0.05);
|
| 1128 |
+
box-shadow: var(--shadow);
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
.embryo-result-thumbnail {
|
| 1132 |
+
position: relative;
|
| 1133 |
+
width: 100%;
|
| 1134 |
+
aspect-ratio: 1;
|
| 1135 |
+
border-radius: 8px;
|
| 1136 |
+
overflow: hidden;
|
| 1137 |
+
margin-bottom: 10px;
|
| 1138 |
+
}
|
| 1139 |
+
|
| 1140 |
+
.embryo-result-thumbnail img {
|
| 1141 |
+
width: 100%;
|
| 1142 |
+
height: 100%;
|
| 1143 |
+
object-fit: cover;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
.embryo-result-badge {
|
| 1147 |
+
position: absolute;
|
| 1148 |
+
top: 8px;
|
| 1149 |
+
right: 8px;
|
| 1150 |
+
background: var(--primary-color);
|
| 1151 |
+
color: white;
|
| 1152 |
+
padding: 4px 10px;
|
| 1153 |
+
border-radius: 12px;
|
| 1154 |
+
font-size: 0.85em;
|
| 1155 |
+
font-weight: 600;
|
| 1156 |
+
}
|
| 1157 |
+
|
| 1158 |
+
.embryo-result-badge.error {
|
| 1159 |
+
background: var(--error-color);
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
.remark-indicator {
|
| 1163 |
+
position: absolute;
|
| 1164 |
+
bottom: 8px;
|
| 1165 |
+
right: 8px;
|
| 1166 |
+
background: rgba(255, 193, 7, 0.95);
|
| 1167 |
+
color: white;
|
| 1168 |
+
width: 28px;
|
| 1169 |
+
height: 28px;
|
| 1170 |
+
border-radius: 50%;
|
| 1171 |
+
display: flex;
|
| 1172 |
+
align-items: center;
|
| 1173 |
+
justify-content: center;
|
| 1174 |
+
font-size: 14px;
|
| 1175 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
| 1176 |
+
cursor: pointer;
|
| 1177 |
+
}
|
| 1178 |
+
|
| 1179 |
+
.embryo-result-info {
|
| 1180 |
+
display: flex;
|
| 1181 |
+
flex-direction: column;
|
| 1182 |
+
gap: 5px;
|
| 1183 |
+
}
|
| 1184 |
+
|
| 1185 |
+
.embryo-result-info strong {
|
| 1186 |
+
color: var(--text-primary);
|
| 1187 |
+
font-size: 0.95em;
|
| 1188 |
+
}
|
| 1189 |
+
|
| 1190 |
+
.quality-indicator {
|
| 1191 |
+
display: inline-block;
|
| 1192 |
+
padding: 3px 10px;
|
| 1193 |
+
border-radius: 12px;
|
| 1194 |
+
font-size: 0.85em;
|
| 1195 |
+
font-weight: 500;
|
| 1196 |
+
}
|
| 1197 |
+
|
| 1198 |
+
.quality-good {
|
| 1199 |
+
background: rgba(102, 187, 106, 0.2);
|
| 1200 |
+
color: var(--success-color);
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
.quality-poor {
|
| 1204 |
+
background: rgba(239, 83, 80, 0.2);
|
| 1205 |
+
color: var(--error-color);
|
| 1206 |
+
}
|
| 1207 |
+
|
| 1208 |
+
.quality-unknown, .quality-error {
|
| 1209 |
+
background: rgba(158, 158, 158, 0.2);
|
| 1210 |
+
color: var(--text-secondary);
|
| 1211 |
+
}
|
| 1212 |
+
|
| 1213 |
+
/* Detailed Result Container */
|
| 1214 |
+
.detailed-result-container {
|
| 1215 |
+
background: var(--card-background);
|
| 1216 |
+
border: 2px solid var(--border-color);
|
| 1217 |
+
border-radius: 12px;
|
| 1218 |
+
padding: 25px;
|
| 1219 |
+
}
|
| 1220 |
+
|
| 1221 |
+
.result-header h3 {
|
| 1222 |
+
color: var(--text-primary);
|
| 1223 |
+
margin-bottom: 20px;
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
.result-content {
|
| 1227 |
+
display: grid;
|
| 1228 |
+
grid-template-columns: 1fr 1fr;
|
| 1229 |
+
gap: 30px;
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
.result-image-section {
|
| 1233 |
+
display: flex;
|
| 1234 |
+
align-items: center;
|
| 1235 |
+
justify-content: center;
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
+
.result-embryo-image {
|
| 1239 |
+
max-width: 100%;
|
| 1240 |
+
max-height: 400px;
|
| 1241 |
+
border-radius: 12px;
|
| 1242 |
+
box-shadow: var(--shadow);
|
| 1243 |
+
}
|
| 1244 |
+
|
| 1245 |
+
.result-details-section {
|
| 1246 |
+
display: flex;
|
| 1247 |
+
flex-direction: column;
|
| 1248 |
+
gap: 20px;
|
| 1249 |
+
}
|
| 1250 |
+
|
| 1251 |
+
.result-row {
|
| 1252 |
+
display: flex;
|
| 1253 |
+
justify-content: space-between;
|
| 1254 |
+
align-items: center;
|
| 1255 |
+
padding: 15px;
|
| 1256 |
+
background: #f8f9fa;
|
| 1257 |
+
border-radius: 8px;
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
.result-label {
|
| 1261 |
+
font-weight: 600;
|
| 1262 |
+
color: var(--text-primary);
|
| 1263 |
+
font-size: 1.05em;
|
| 1264 |
+
}
|
| 1265 |
+
|
| 1266 |
+
.result-value {
|
| 1267 |
+
font-weight: 500;
|
| 1268 |
+
font-size: 1.1em;
|
| 1269 |
+
color: var(--text-secondary);
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
.result-value.grade-value {
|
| 1273 |
+
color: var(--primary-color);
|
| 1274 |
+
font-weight: 700;
|
| 1275 |
+
font-size: 1.3em;
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
/* Remarks section */
|
| 1279 |
+
.remarks-section {
|
| 1280 |
+
margin-top: 20px;
|
| 1281 |
+
padding: 15px;
|
| 1282 |
+
background: var(--background-secondary);
|
| 1283 |
+
border-radius: 8px;
|
| 1284 |
+
border: 1px solid var(--border-color);
|
| 1285 |
+
}
|
| 1286 |
+
|
| 1287 |
+
.remarks-section label {
|
| 1288 |
+
display: block;
|
| 1289 |
+
font-weight: 600;
|
| 1290 |
+
color: var(--text-primary);
|
| 1291 |
+
margin-bottom: 8px;
|
| 1292 |
+
font-size: 1.05em;
|
| 1293 |
+
}
|
| 1294 |
+
|
| 1295 |
+
.remarks-textarea {
|
| 1296 |
+
width: 100%;
|
| 1297 |
+
min-height: 100px;
|
| 1298 |
+
padding: 12px;
|
| 1299 |
+
border: 2px solid var(--border-color);
|
| 1300 |
+
border-radius: 8px;
|
| 1301 |
+
font-family: inherit;
|
| 1302 |
+
font-size: 0.95em;
|
| 1303 |
+
resize: vertical;
|
| 1304 |
+
transition: border-color 0.3s ease;
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
.remarks-textarea:focus {
|
| 1308 |
+
outline: none;
|
| 1309 |
+
border-color: var(--primary-color);
|
| 1310 |
+
box-shadow: 0 0 0 3px rgba(0, 123, 143, 0.1);
|
| 1311 |
+
}
|
| 1312 |
+
|
| 1313 |
+
.remarks-textarea::placeholder {
|
| 1314 |
+
color: var(--text-muted);
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
.actual-grade-input {
|
| 1318 |
+
width: 100%;
|
| 1319 |
+
padding: 10px 12px;
|
| 1320 |
+
border: 2px solid var(--border-color);
|
| 1321 |
+
border-radius: 8px;
|
| 1322 |
+
font-family: inherit;
|
| 1323 |
+
font-size: 0.95em;
|
| 1324 |
+
transition: border-color 0.3s ease;
|
| 1325 |
+
}
|
| 1326 |
+
|
| 1327 |
+
.actual-grade-input:focus {
|
| 1328 |
+
outline: none;
|
| 1329 |
+
border-color: var(--primary-color);
|
| 1330 |
+
box-shadow: 0 0 0 3px rgba(0, 123, 143, 0.1);
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
.actual-grade-input::placeholder {
|
| 1334 |
+
color: var(--text-muted);
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
/* Actual Grade Selectors */
|
| 1338 |
+
.actual-grade-selectors {
|
| 1339 |
+
display: flex;
|
| 1340 |
+
align-items: center;
|
| 1341 |
+
gap: 10px;
|
| 1342 |
+
}
|
| 1343 |
+
|
| 1344 |
+
.grade-select {
|
| 1345 |
+
padding: 8px 12px;
|
| 1346 |
+
border: 2px solid var(--border-color);
|
| 1347 |
+
border-radius: 8px;
|
| 1348 |
+
font-family: inherit;
|
| 1349 |
+
font-size: 0.95em;
|
| 1350 |
+
font-weight: 600;
|
| 1351 |
+
background: white;
|
| 1352 |
+
cursor: pointer;
|
| 1353 |
+
transition: all 0.3s ease;
|
| 1354 |
+
min-width: 60px;
|
| 1355 |
+
}
|
| 1356 |
+
|
| 1357 |
+
.grade-select:hover {
|
| 1358 |
+
border-color: var(--primary-color);
|
| 1359 |
+
}
|
| 1360 |
+
|
| 1361 |
+
.grade-select:focus {
|
| 1362 |
+
outline: none;
|
| 1363 |
+
border-color: var(--primary-color);
|
| 1364 |
+
box-shadow: 0 0 0 3px rgba(0, 123, 143, 0.1);
|
| 1365 |
+
}
|
| 1366 |
+
|
| 1367 |
+
.grade-preview {
|
| 1368 |
+
font-weight: 700;
|
| 1369 |
+
font-size: 1.1em;
|
| 1370 |
+
color: var(--primary-color);
|
| 1371 |
+
min-width: 60px;
|
| 1372 |
+
padding: 8px 12px;
|
| 1373 |
+
background: rgba(0, 123, 143, 0.1);
|
| 1374 |
+
border-radius: 8px;
|
| 1375 |
+
text-align: center;
|
| 1376 |
+
}
|
| 1377 |
+
|
| 1378 |
+
.error-message {
|
| 1379 |
+
background: rgba(239, 83, 80, 0.1);
|
| 1380 |
+
border: 1px solid var(--error-color);
|
| 1381 |
+
border-radius: 8px;
|
| 1382 |
+
padding: 15px;
|
| 1383 |
+
color: var(--error-color);
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
@media (max-width: 768px) {
|
| 1387 |
+
.embryo-results-grid {
|
| 1388 |
+
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
| 1389 |
+
gap: 15px;
|
| 1390 |
+
}
|
| 1391 |
+
|
| 1392 |
+
.result-content {
|
| 1393 |
+
grid-template-columns: 1fr;
|
| 1394 |
+
}
|
| 1395 |
+
}
|
| 1396 |
+
|
| 1397 |
+
/* Utility Classes */
|
| 1398 |
+
.hidden {
|
| 1399 |
+
display: none !important;
|
| 1400 |
+
}
|
| 1401 |
+
|
| 1402 |
+
.text-center {
|
| 1403 |
+
text-align: center;
|
| 1404 |
+
}
|
| 1405 |
+
|
| 1406 |
+
.mt-20 {
|
| 1407 |
+
margin-top: 20px;
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
.mb-20 {
|
| 1411 |
+
margin-bottom: 20px;
|
| 1412 |
+
}
|
| 1413 |
+
|
| 1414 |
+
/* Modal Styles */
|
| 1415 |
+
.modal-overlay {
|
| 1416 |
+
position: fixed;
|
| 1417 |
+
top: 0;
|
| 1418 |
+
left: 0;
|
| 1419 |
+
width: 100%;
|
| 1420 |
+
height: 100%;
|
| 1421 |
+
background: rgba(0, 0, 0, 0.5);
|
| 1422 |
+
backdrop-filter: blur(4px);
|
| 1423 |
+
display: flex;
|
| 1424 |
+
align-items: center;
|
| 1425 |
+
justify-content: center;
|
| 1426 |
+
z-index: 10000;
|
| 1427 |
+
opacity: 0;
|
| 1428 |
+
transition: opacity 0.3s ease;
|
| 1429 |
+
}
|
| 1430 |
+
|
| 1431 |
+
.modal-overlay.active {
|
| 1432 |
+
opacity: 1;
|
| 1433 |
+
}
|
| 1434 |
+
|
| 1435 |
+
.modal-container {
|
| 1436 |
+
background: var(--card-background);
|
| 1437 |
+
border-radius: 12px;
|
| 1438 |
+
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
| 1439 |
+
min-width: 400px;
|
| 1440 |
+
max-width: 500px;
|
| 1441 |
+
max-height: 90vh;
|
| 1442 |
+
overflow: hidden;
|
| 1443 |
+
transform: scale(0.9) translateY(-20px);
|
| 1444 |
+
opacity: 0;
|
| 1445 |
+
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
| 1446 |
+
}
|
| 1447 |
+
|
| 1448 |
+
.modal-container.active {
|
| 1449 |
+
transform: scale(1) translateY(0);
|
| 1450 |
+
opacity: 1;
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
.modal-header {
|
| 1454 |
+
padding: 20px 24px;
|
| 1455 |
+
border-bottom: 1px solid var(--border-color);
|
| 1456 |
+
background: linear-gradient(135deg, var(--primary-color), var(--primary-dark));
|
| 1457 |
+
}
|
| 1458 |
+
|
| 1459 |
+
.modal-title {
|
| 1460 |
+
font-size: 18px;
|
| 1461 |
+
font-weight: 600;
|
| 1462 |
+
color: white;
|
| 1463 |
+
margin: 0;
|
| 1464 |
+
}
|
| 1465 |
+
|
| 1466 |
+
.modal-body {
|
| 1467 |
+
padding: 24px;
|
| 1468 |
+
max-height: 60vh;
|
| 1469 |
+
overflow-y: auto;
|
| 1470 |
+
}
|
| 1471 |
+
|
| 1472 |
+
.modal-message {
|
| 1473 |
+
font-size: 15px;
|
| 1474 |
+
line-height: 1.6;
|
| 1475 |
+
color: var(--text-primary);
|
| 1476 |
+
margin: 0;
|
| 1477 |
+
}
|
| 1478 |
+
|
| 1479 |
+
.modal-footer {
|
| 1480 |
+
padding: 16px 24px;
|
| 1481 |
+
border-top: 1px solid var(--border-color);
|
| 1482 |
+
display: flex;
|
| 1483 |
+
justify-content: flex-end;
|
| 1484 |
+
gap: 12px;
|
| 1485 |
+
background: var(--background-secondary);
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
.modal-footer .btn {
|
| 1489 |
+
min-width: 80px;
|
| 1490 |
+
padding: 10px 20px;
|
| 1491 |
+
font-size: 14px;
|
| 1492 |
+
font-weight: 500;
|
| 1493 |
+
border-radius: 6px;
|
| 1494 |
+
transition: all 0.2s ease;
|
| 1495 |
+
}
|
| 1496 |
+
|
| 1497 |
+
.modal-ok-btn {
|
| 1498 |
+
background: var(--primary-color);
|
| 1499 |
+
color: white;
|
| 1500 |
+
border: none;
|
| 1501 |
+
}
|
| 1502 |
+
|
| 1503 |
+
.modal-ok-btn:hover {
|
| 1504 |
+
background: var(--primary-dark);
|
| 1505 |
+
transform: translateY(-1px);
|
| 1506 |
+
box-shadow: 0 4px 12px rgba(0, 123, 143, 0.3);
|
| 1507 |
+
}
|
| 1508 |
+
|
| 1509 |
+
.modal-cancel-btn {
|
| 1510 |
+
background: #e0e0e0;
|
| 1511 |
+
color: var(--text-primary);
|
| 1512 |
+
border: 1px solid var(--border-color);
|
| 1513 |
+
}
|
| 1514 |
+
|
| 1515 |
+
.modal-cancel-btn:hover {
|
| 1516 |
+
background: #d0d0d0;
|
| 1517 |
+
transform: translateY(-1px);
|
| 1518 |
+
}
|
| 1519 |
+
|
| 1520 |
+
@media (max-width: 768px) {
|
| 1521 |
+
.modal-container {
|
| 1522 |
+
min-width: 90%;
|
| 1523 |
+
max-width: 90%;
|
| 1524 |
+
margin: 0 20px;
|
| 1525 |
+
}
|
| 1526 |
+
|
| 1527 |
+
.modal-footer {
|
| 1528 |
+
flex-direction: column-reverse;
|
| 1529 |
+
}
|
| 1530 |
+
|
| 1531 |
+
.modal-footer .btn {
|
| 1532 |
+
width: 100%;
|
| 1533 |
+
}
|
| 1534 |
+
}
|
| 1535 |
+
|
| 1536 |
+
/* Manual Crop Canvas */
|
| 1537 |
+
.manual-crop-canvas {
|
| 1538 |
+
max-width: 100%;
|
| 1539 |
+
max-height: 500px;
|
| 1540 |
+
border: 2px solid var(--border-color);
|
| 1541 |
+
border-radius: 8px;
|
| 1542 |
+
cursor: default;
|
| 1543 |
+
display: block;
|
| 1544 |
+
margin: 0 auto;
|
| 1545 |
+
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
| 1546 |
+
/* Touch-friendly settings */
|
| 1547 |
+
touch-action: none;
|
| 1548 |
+
-webkit-user-select: none;
|
| 1549 |
+
user-select: none;
|
| 1550 |
+
-webkit-tap-highlight-color: transparent;
|
| 1551 |
+
}
|
| 1552 |
+
|
| 1553 |
+
.crop-controls {
|
| 1554 |
+
background: #f8f9fa;
|
| 1555 |
+
padding: 20px;
|
| 1556 |
+
border-radius: 8px;
|
| 1557 |
+
border: 1px solid var(--border-color);
|
| 1558 |
+
margin-top: 15px;
|
| 1559 |
+
}
|
| 1560 |
+
|
| 1561 |
+
.crop-controls h4 {
|
| 1562 |
+
color: var(--primary-color);
|
| 1563 |
+
margin-bottom: 10px;
|
| 1564 |
+
font-size: 16px;
|
| 1565 |
+
}
|
| 1566 |
+
|
| 1567 |
+
.crop-controls p {
|
| 1568 |
+
line-height: 1.6;
|
| 1569 |
+
margin-bottom: 15px;
|
| 1570 |
+
}
|
| 1571 |
+
|
| 1572 |
+
.touch-hint {
|
| 1573 |
+
color: var(--primary-color) !important;
|
| 1574 |
+
font-style: italic;
|
| 1575 |
+
}
|
| 1576 |
+
|
| 1577 |
+
/* Touch-friendly button sizing for mobile */
|
| 1578 |
+
@media (max-width: 768px) {
|
| 1579 |
+
.manual-crop-canvas {
|
| 1580 |
+
max-height: 400px;
|
| 1581 |
+
}
|
| 1582 |
+
|
| 1583 |
+
.crop-controls {
|
| 1584 |
+
padding: 15px;
|
| 1585 |
+
}
|
| 1586 |
+
|
| 1587 |
+
.crop-controls .btn-group .btn {
|
| 1588 |
+
padding: 12px 20px;
|
| 1589 |
+
font-size: 14px;
|
| 1590 |
+
min-height: 44px; /* iOS recommended minimum touch target */
|
| 1591 |
+
}
|
| 1592 |
+
|
| 1593 |
+
#croppedImagePreview {
|
| 1594 |
+
min-height: 300px;
|
| 1595 |
+
}
|
| 1596 |
+
}
|
| 1597 |
+
|
| 1598 |
+
@media (max-width: 480px) {
|
| 1599 |
+
.manual-crop-canvas {
|
| 1600 |
+
max-height: 300px;
|
| 1601 |
+
}
|
| 1602 |
+
|
| 1603 |
+
.crop-controls .btn-group {
|
| 1604 |
+
flex-direction: column;
|
| 1605 |
+
gap: 10px;
|
| 1606 |
+
}
|
| 1607 |
+
|
| 1608 |
+
.crop-controls .btn-group .btn {
|
| 1609 |
+
width: 100%;
|
| 1610 |
+
}
|
| 1611 |
+
}
|
test.html
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 6 |
+
<title>Model Test</title>
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<h1>Testing Model Loading...</h1>
|
| 10 |
+
<div id="status">Initializing...</div>
|
| 11 |
+
<div id="results"></div>
|
| 12 |
+
|
| 13 |
+
<script type="module">
|
| 14 |
+
import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.0/dist/esm/ort.min.js';
|
| 15 |
+
|
| 16 |
+
const statusDiv = document.getElementById('status');
|
| 17 |
+
const resultsDiv = document.getElementById('results');
|
| 18 |
+
|
| 19 |
+
async function testModels() {
|
| 20 |
+
try {
|
| 21 |
+
statusDiv.textContent = 'Configuring ONNX Runtime...';
|
| 22 |
+
|
| 23 |
+
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.17.0/dist/';
|
| 24 |
+
ort.env.wasm.numThreads = 4;
|
| 25 |
+
|
| 26 |
+
// Test Classifier
|
| 27 |
+
statusDiv.textContent = 'Loading Classifier...';
|
| 28 |
+
const classifier = await ort.InferenceSession.create('./models/classifier_model_compressed/model.onnx');
|
| 29 |
+
resultsDiv.innerHTML += '<p>β
Classifier loaded successfully!</p>';
|
| 30 |
+
|
| 31 |
+
// Test Quality
|
| 32 |
+
statusDiv.textContent = 'Loading Quality Model...';
|
| 33 |
+
const quality = await ort.InferenceSession.create('./models/poor_good_compressed/model.onnx');
|
| 34 |
+
resultsDiv.innerHTML += '<p>β
Quality model loaded successfully!</p>';
|
| 35 |
+
|
| 36 |
+
// Test Grader
|
| 37 |
+
statusDiv.textContent = 'Loading Grader...';
|
| 38 |
+
const grader = await ort.InferenceSession.create('./models/grader_model_compressed/model.onnx');
|
| 39 |
+
resultsDiv.innerHTML += '<p>β
Grader model loaded successfully!</p>';
|
| 40 |
+
|
| 41 |
+
// Test YOLO
|
| 42 |
+
statusDiv.textContent = 'Loading YOLO...';
|
| 43 |
+
const yolo = await ort.InferenceSession.create('./models/yolo-cropper/best.onnx');
|
| 44 |
+
resultsDiv.innerHTML += '<p>β
YOLO model loaded successfully!</p>';
|
| 45 |
+
|
| 46 |
+
statusDiv.textContent = 'β
All models loaded successfully!';
|
| 47 |
+
resultsDiv.innerHTML += '<h2>π Ready to use!</h2>';
|
| 48 |
+
|
| 49 |
+
} catch (error) {
|
| 50 |
+
statusDiv.textContent = 'β Error: ' + error.message;
|
| 51 |
+
resultsDiv.innerHTML += '<p style="color:red;">Error: ' + error.stack + '</p>';
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
testModels();
|
| 56 |
+
</script>
|
| 57 |
+
</body>
|
| 58 |
+
</html>
|