Embryo-One commited on
Commit
ed9f15f
Β·
verified Β·
1 Parent(s): 26bf5eb

Upload 49 files

Browse files
Files changed (49) hide show
  1. .gitattributes +35 -35
  2. README.md +9 -10
  3. assets/example_embryo.jpg +0 -0
  4. assets/ismo.png +0 -0
  5. assets/ismo7.png +0 -0
  6. index.html +158 -19
  7. login.html +414 -0
  8. models/README.md +54 -0
  9. models/classifier_model_compressed/config.json +46 -0
  10. models/classifier_model_compressed/model.onnx +3 -0
  11. models/classifier_model_compressed/preprocessor_config.json +24 -0
  12. models/grader_model_compressed/config.json +90 -0
  13. models/grader_model_compressed/model.onnx +3 -0
  14. models/grader_model_compressed/preprocessor_config.json +24 -0
  15. models/poor_good_compressed/config.json +46 -0
  16. models/poor_good_compressed/model.onnx +3 -0
  17. models/poor_good_compressed/preprocessor_config.json +24 -0
  18. models/yolo-cropper/best.onnx +3 -0
  19. package-lock.json +164 -0
  20. package.json +39 -0
  21. server.js +103 -0
  22. src/README.md +208 -0
  23. src/config.js +42 -0
  24. src/main.js +130 -0
  25. src/models/inference.js +74 -0
  26. src/models/modelLoader.js +74 -0
  27. src/models/yoloDetection.js +29 -0
  28. src/processing/imagePreprocessing.js +60 -0
  29. src/processing/postprocessing.js +41 -0
  30. src/processing/yoloPostprocessing.js +113 -0
  31. src/state.js +111 -0
  32. src/ui/cropEditor.js +604 -0
  33. src/ui/eventHandlers.js +78 -0
  34. src/ui/imageDisplay.js +75 -0
  35. src/ui/inlineDetection.js +766 -0
  36. src/ui/loading.js +35 -0
  37. src/ui/modal.js +148 -0
  38. src/ui/quickEval.js +49 -0
  39. src/ui/results.js +249 -0
  40. src/ui/stepper.js +368 -0
  41. src/ui/tabs.js +26 -0
  42. src/ui/toast.js +18 -0
  43. src/ui/workflow.js +642 -0
  44. src/ui/zoom.js +54 -0
  45. src/utils/firebaseService.js +182 -0
  46. src/utils/imageUtils.js +53 -0
  47. src/utils/mathUtils.js +27 -0
  48. styles.css +1611 -0
  49. 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 Final Java Script
3
- emoji: 🐨
4
- colorFrom: indigo
5
- colorTo: yellow
6
- sdk: static
7
- pinned: false
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
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ &copy; 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>