Spaces:
Running
Running
Numan Saeed
commited on
Commit
·
f84688d
1
Parent(s):
f66c31e
Add Not Sure feedback, save preprocessed scans, add Help tab
Browse files- .DS_Store +0 -0
- README.md +2 -2
- backend/app/routes/__pycache__/feedback.cpython-310.pyc +0 -0
- backend/app/routes/feedback.py +8 -4
- backend/app/services/__pycache__/feedback.cpython-310.pyc +0 -0
- backend/app/services/feedback.py +67 -20
- frontend/.DS_Store +0 -0
- frontend/src/App.tsx +6 -3
- frontend/src/components/FeedbackSection.tsx +79 -9
- frontend/src/lib/api.ts +11 -9
- frontend/src/pages/ClassificationPage.tsx +35 -37
- frontend/src/pages/HelpPage.tsx +192 -0
- server.py +2 -1
.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
README.md
CHANGED
|
@@ -64,6 +64,6 @@ If you use this model, please cite:
|
|
| 64 |
|
| 65 |
## Links
|
| 66 |
|
| 67 |
-
- 📄 [Paper](https://arxiv.org/abs/2502.14807)
|
| 68 |
- 📦 [Model Hub](https://huggingface.co/numansaeed/fetalclip-model)
|
| 69 |
-
-
|
|
|
|
|
|
| 64 |
|
| 65 |
## Links
|
| 66 |
|
|
|
|
| 67 |
- 📦 [Model Hub](https://huggingface.co/numansaeed/fetalclip-model)
|
| 68 |
+
- 📄 [Paper](https://arxiv.org/abs/2502.14807)
|
| 69 |
+
- 💻 [GitHub](#)
|
backend/app/routes/__pycache__/feedback.cpython-310.pyc
CHANGED
|
Binary files a/backend/app/routes/__pycache__/feedback.cpython-310.pyc and b/backend/app/routes/__pycache__/feedback.cpython-310.pyc differ
|
|
|
backend/app/routes/feedback.py
CHANGED
|
@@ -27,11 +27,12 @@ class FeedbackCreate(BaseModel):
|
|
| 27 |
predicted_label: str
|
| 28 |
predicted_confidence: float
|
| 29 |
all_predictions: List[PredictionDetail]
|
| 30 |
-
is_correct: bool
|
| 31 |
correct_label: Optional[str] = None
|
| 32 |
reviewer_notes: Optional[str] = None
|
| 33 |
patient_id: Optional[str] = None
|
| 34 |
image_hash: Optional[str] = None
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class FeedbackResponse(BaseModel):
|
|
@@ -45,9 +46,10 @@ class FeedbackResponse(BaseModel):
|
|
| 45 |
predicted_label: str
|
| 46 |
predicted_confidence: float
|
| 47 |
all_predictions: List[Dict]
|
| 48 |
-
is_correct: bool
|
| 49 |
correct_label: Optional[str]
|
| 50 |
reviewer_notes: Optional[str]
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
class SessionCreate(BaseModel):
|
|
@@ -61,12 +63,14 @@ class SessionResponse(BaseModel):
|
|
| 61 |
feedback_count: int
|
| 62 |
correct_count: int
|
| 63 |
incorrect_count: int
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
class StatisticsResponse(BaseModel):
|
| 67 |
total_feedback: int
|
| 68 |
correct_count: int
|
| 69 |
incorrect_count: int
|
|
|
|
| 70 |
accuracy: float
|
| 71 |
by_label: Dict
|
| 72 |
|
|
@@ -102,7 +106,6 @@ async def record_image_analyzed(session_id: str):
|
|
| 102 |
return {"status": "ok"}
|
| 103 |
|
| 104 |
|
| 105 |
-
# Feedback endpoints
|
| 106 |
@router.post("/", response_model=FeedbackResponse)
|
| 107 |
async def create_feedback(feedback: FeedbackCreate):
|
| 108 |
"""Submit feedback for a prediction."""
|
|
@@ -120,7 +123,8 @@ async def create_feedback(feedback: FeedbackCreate):
|
|
| 120 |
correct_label=feedback.correct_label,
|
| 121 |
reviewer_notes=feedback.reviewer_notes,
|
| 122 |
patient_id=feedback.patient_id,
|
| 123 |
-
image_hash=feedback.image_hash
|
|
|
|
| 124 |
)
|
| 125 |
|
| 126 |
return entry
|
|
|
|
| 27 |
predicted_label: str
|
| 28 |
predicted_confidence: float
|
| 29 |
all_predictions: List[PredictionDetail]
|
| 30 |
+
is_correct: Optional[bool] = None
|
| 31 |
correct_label: Optional[str] = None
|
| 32 |
reviewer_notes: Optional[str] = None
|
| 33 |
patient_id: Optional[str] = None
|
| 34 |
image_hash: Optional[str] = None
|
| 35 |
+
preprocessed_image_base64: Optional[str] = None
|
| 36 |
|
| 37 |
|
| 38 |
class FeedbackResponse(BaseModel):
|
|
|
|
| 46 |
predicted_label: str
|
| 47 |
predicted_confidence: float
|
| 48 |
all_predictions: List[Dict]
|
| 49 |
+
is_correct: Optional[bool]
|
| 50 |
correct_label: Optional[str]
|
| 51 |
reviewer_notes: Optional[str]
|
| 52 |
+
preprocessed_image_path: Optional[str] = None
|
| 53 |
|
| 54 |
|
| 55 |
class SessionCreate(BaseModel):
|
|
|
|
| 63 |
feedback_count: int
|
| 64 |
correct_count: int
|
| 65 |
incorrect_count: int
|
| 66 |
+
not_sure_count: int = 0
|
| 67 |
|
| 68 |
|
| 69 |
class StatisticsResponse(BaseModel):
|
| 70 |
total_feedback: int
|
| 71 |
correct_count: int
|
| 72 |
incorrect_count: int
|
| 73 |
+
not_sure_count: int = 0
|
| 74 |
accuracy: float
|
| 75 |
by_label: Dict
|
| 76 |
|
|
|
|
| 106 |
return {"status": "ok"}
|
| 107 |
|
| 108 |
|
|
|
|
| 109 |
@router.post("/", response_model=FeedbackResponse)
|
| 110 |
async def create_feedback(feedback: FeedbackCreate):
|
| 111 |
"""Submit feedback for a prediction."""
|
|
|
|
| 123 |
correct_label=feedback.correct_label,
|
| 124 |
reviewer_notes=feedback.reviewer_notes,
|
| 125 |
patient_id=feedback.patient_id,
|
| 126 |
+
image_hash=feedback.image_hash,
|
| 127 |
+
preprocessed_image_base64=feedback.preprocessed_image_base64
|
| 128 |
)
|
| 129 |
|
| 130 |
return entry
|
backend/app/services/__pycache__/feedback.cpython-310.pyc
CHANGED
|
Binary files a/backend/app/services/__pycache__/feedback.cpython-310.pyc and b/backend/app/services/__pycache__/feedback.cpython-310.pyc differ
|
|
|
backend/app/services/feedback.py
CHANGED
|
@@ -6,6 +6,7 @@ Stores feedback in a local JSON file for simplicity and privacy.
|
|
| 6 |
import json
|
| 7 |
import uuid
|
| 8 |
import hashlib
|
|
|
|
| 9 |
from datetime import datetime
|
| 10 |
from pathlib import Path
|
| 11 |
from typing import List, Dict, Optional
|
|
@@ -26,6 +27,8 @@ class FeedbackService:
|
|
| 26 |
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
| 27 |
self.feedback_file = self.storage_dir / "feedback.json"
|
| 28 |
self.sessions_file = self.storage_dir / "sessions.json"
|
|
|
|
|
|
|
| 29 |
|
| 30 |
# Initialize files if they don't exist
|
| 31 |
if not self.feedback_file.exists():
|
|
@@ -68,7 +71,8 @@ class FeedbackService:
|
|
| 68 |
"image_count": 0,
|
| 69 |
"feedback_count": 0,
|
| 70 |
"correct_count": 0,
|
| 71 |
-
"incorrect_count": 0
|
|
|
|
| 72 |
}
|
| 73 |
self._save_sessions(sessions)
|
| 74 |
return session_id
|
|
@@ -91,18 +95,27 @@ class FeedbackService:
|
|
| 91 |
"image_count": 0,
|
| 92 |
"feedback_count": 0,
|
| 93 |
"correct_count": 0,
|
| 94 |
-
"incorrect_count": 0
|
|
|
|
| 95 |
}
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
if image_analyzed:
|
| 98 |
sessions[session_id]["image_count"] += 1
|
| 99 |
|
| 100 |
-
if is_correct is
|
| 101 |
sessions[session_id]["feedback_count"] += 1
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
self._save_sessions(sessions)
|
| 108 |
|
|
@@ -114,15 +127,29 @@ class FeedbackService:
|
|
| 114 |
predicted_label: str,
|
| 115 |
predicted_confidence: float,
|
| 116 |
all_predictions: List[Dict],
|
| 117 |
-
is_correct: bool,
|
| 118 |
correct_label: Optional[str] = None,
|
| 119 |
reviewer_notes: Optional[str] = None,
|
| 120 |
patient_id: Optional[str] = None,
|
| 121 |
-
image_hash: Optional[str] = None
|
|
|
|
| 122 |
) -> Dict:
|
| 123 |
"""Add new feedback entry."""
|
| 124 |
feedback_id = str(uuid.uuid4())[:12]
|
| 125 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
entry = {
|
| 127 |
"id": feedback_id,
|
| 128 |
"session_id": session_id,
|
|
@@ -135,8 +162,9 @@ class FeedbackService:
|
|
| 135 |
"predicted_confidence": round(predicted_confidence, 2),
|
| 136 |
"all_predictions": all_predictions,
|
| 137 |
"is_correct": is_correct,
|
| 138 |
-
"correct_label": correct_label if
|
| 139 |
-
"reviewer_notes": reviewer_notes
|
|
|
|
| 140 |
}
|
| 141 |
|
| 142 |
feedback_list = self._load_feedback()
|
|
@@ -196,7 +224,8 @@ class FeedbackService:
|
|
| 196 |
"predicted_confidence",
|
| 197 |
"is_correct",
|
| 198 |
"correct_label",
|
| 199 |
-
"reviewer_notes"
|
|
|
|
| 200 |
]
|
| 201 |
|
| 202 |
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
|
|
@@ -204,6 +233,14 @@ class FeedbackService:
|
|
| 204 |
|
| 205 |
for entry in feedback_list:
|
| 206 |
# Flatten the entry for CSV
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
row = {
|
| 208 |
"timestamp": entry.get("timestamp", ""),
|
| 209 |
"session_id": entry.get("session_id", ""),
|
|
@@ -212,9 +249,10 @@ class FeedbackService:
|
|
| 212 |
"file_type": entry.get("file_type", ""),
|
| 213 |
"predicted_label": entry.get("predicted_label", ""),
|
| 214 |
"predicted_confidence": entry.get("predicted_confidence", ""),
|
| 215 |
-
"is_correct":
|
| 216 |
"correct_label": entry.get("correct_label", ""),
|
| 217 |
-
"reviewer_notes": entry.get("reviewer_notes", "")
|
|
|
|
| 218 |
}
|
| 219 |
writer.writerow(row)
|
| 220 |
|
|
@@ -225,26 +263,35 @@ class FeedbackService:
|
|
| 225 |
feedback_list = self.get_feedback(session_id)
|
| 226 |
|
| 227 |
total = len(feedback_list)
|
| 228 |
-
correct = sum(1 for f in feedback_list if f.get("is_correct"))
|
| 229 |
-
incorrect =
|
|
|
|
| 230 |
|
| 231 |
# Count by predicted label
|
| 232 |
label_stats = {}
|
| 233 |
for entry in feedback_list:
|
| 234 |
label = entry.get("predicted_label", "Unknown")
|
| 235 |
if label not in label_stats:
|
| 236 |
-
label_stats[label] = {"total": 0, "correct": 0, "incorrect": 0}
|
| 237 |
label_stats[label]["total"] += 1
|
| 238 |
-
|
|
|
|
| 239 |
label_stats[label]["correct"] += 1
|
| 240 |
-
|
| 241 |
label_stats[label]["incorrect"] += 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
|
| 243 |
return {
|
| 244 |
"total_feedback": total,
|
| 245 |
"correct_count": correct,
|
| 246 |
"incorrect_count": incorrect,
|
| 247 |
-
"
|
|
|
|
| 248 |
"by_label": label_stats
|
| 249 |
}
|
| 250 |
|
|
|
|
| 6 |
import json
|
| 7 |
import uuid
|
| 8 |
import hashlib
|
| 9 |
+
import base64
|
| 10 |
from datetime import datetime
|
| 11 |
from pathlib import Path
|
| 12 |
from typing import List, Dict, Optional
|
|
|
|
| 27 |
self.storage_dir.mkdir(parents=True, exist_ok=True)
|
| 28 |
self.feedback_file = self.storage_dir / "feedback.json"
|
| 29 |
self.sessions_file = self.storage_dir / "sessions.json"
|
| 30 |
+
self.images_dir = self.storage_dir / "images"
|
| 31 |
+
self.images_dir.mkdir(parents=True, exist_ok=True)
|
| 32 |
|
| 33 |
# Initialize files if they don't exist
|
| 34 |
if not self.feedback_file.exists():
|
|
|
|
| 71 |
"image_count": 0,
|
| 72 |
"feedback_count": 0,
|
| 73 |
"correct_count": 0,
|
| 74 |
+
"incorrect_count": 0,
|
| 75 |
+
"not_sure_count": 0
|
| 76 |
}
|
| 77 |
self._save_sessions(sessions)
|
| 78 |
return session_id
|
|
|
|
| 95 |
"image_count": 0,
|
| 96 |
"feedback_count": 0,
|
| 97 |
"correct_count": 0,
|
| 98 |
+
"incorrect_count": 0,
|
| 99 |
+
"not_sure_count": 0
|
| 100 |
}
|
| 101 |
|
| 102 |
+
# Ensure not_sure_count exists for older sessions
|
| 103 |
+
if "not_sure_count" not in sessions[session_id]:
|
| 104 |
+
sessions[session_id]["not_sure_count"] = 0
|
| 105 |
+
|
| 106 |
if image_analyzed:
|
| 107 |
sessions[session_id]["image_count"] += 1
|
| 108 |
|
| 109 |
+
if is_correct is True:
|
| 110 |
sessions[session_id]["feedback_count"] += 1
|
| 111 |
+
sessions[session_id]["correct_count"] += 1
|
| 112 |
+
elif is_correct is False:
|
| 113 |
+
sessions[session_id]["feedback_count"] += 1
|
| 114 |
+
sessions[session_id]["incorrect_count"] += 1
|
| 115 |
+
elif is_correct is None:
|
| 116 |
+
# Not sure case - only increment feedback_count and not_sure_count
|
| 117 |
+
sessions[session_id]["feedback_count"] += 1
|
| 118 |
+
sessions[session_id]["not_sure_count"] += 1
|
| 119 |
|
| 120 |
self._save_sessions(sessions)
|
| 121 |
|
|
|
|
| 127 |
predicted_label: str,
|
| 128 |
predicted_confidence: float,
|
| 129 |
all_predictions: List[Dict],
|
| 130 |
+
is_correct: Optional[bool],
|
| 131 |
correct_label: Optional[str] = None,
|
| 132 |
reviewer_notes: Optional[str] = None,
|
| 133 |
patient_id: Optional[str] = None,
|
| 134 |
+
image_hash: Optional[str] = None,
|
| 135 |
+
preprocessed_image_base64: Optional[str] = None
|
| 136 |
) -> Dict:
|
| 137 |
"""Add new feedback entry."""
|
| 138 |
feedback_id = str(uuid.uuid4())[:12]
|
| 139 |
|
| 140 |
+
# Save preprocessed image if provided
|
| 141 |
+
preprocessed_image_path = None
|
| 142 |
+
if preprocessed_image_base64:
|
| 143 |
+
try:
|
| 144 |
+
image_data = base64.b64decode(preprocessed_image_base64)
|
| 145 |
+
image_filename = f"{feedback_id}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
| 146 |
+
image_path = self.images_dir / image_filename
|
| 147 |
+
with open(image_path, 'wb') as f:
|
| 148 |
+
f.write(image_data)
|
| 149 |
+
preprocessed_image_path = str(image_path)
|
| 150 |
+
except Exception as e:
|
| 151 |
+
print(f"Failed to save preprocessed image: {e}")
|
| 152 |
+
|
| 153 |
entry = {
|
| 154 |
"id": feedback_id,
|
| 155 |
"session_id": session_id,
|
|
|
|
| 162 |
"predicted_confidence": round(predicted_confidence, 2),
|
| 163 |
"all_predictions": all_predictions,
|
| 164 |
"is_correct": is_correct,
|
| 165 |
+
"correct_label": correct_label if is_correct is False else None,
|
| 166 |
+
"reviewer_notes": reviewer_notes,
|
| 167 |
+
"preprocessed_image_path": preprocessed_image_path
|
| 168 |
}
|
| 169 |
|
| 170 |
feedback_list = self._load_feedback()
|
|
|
|
| 224 |
"predicted_confidence",
|
| 225 |
"is_correct",
|
| 226 |
"correct_label",
|
| 227 |
+
"reviewer_notes",
|
| 228 |
+
"preprocessed_image_path"
|
| 229 |
]
|
| 230 |
|
| 231 |
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction='ignore')
|
|
|
|
| 233 |
|
| 234 |
for entry in feedback_list:
|
| 235 |
# Flatten the entry for CSV
|
| 236 |
+
is_correct = entry.get("is_correct")
|
| 237 |
+
if is_correct is True:
|
| 238 |
+
is_correct_str = "Yes"
|
| 239 |
+
elif is_correct is False:
|
| 240 |
+
is_correct_str = "No"
|
| 241 |
+
else:
|
| 242 |
+
is_correct_str = "Not Sure"
|
| 243 |
+
|
| 244 |
row = {
|
| 245 |
"timestamp": entry.get("timestamp", ""),
|
| 246 |
"session_id": entry.get("session_id", ""),
|
|
|
|
| 249 |
"file_type": entry.get("file_type", ""),
|
| 250 |
"predicted_label": entry.get("predicted_label", ""),
|
| 251 |
"predicted_confidence": entry.get("predicted_confidence", ""),
|
| 252 |
+
"is_correct": is_correct_str,
|
| 253 |
"correct_label": entry.get("correct_label", ""),
|
| 254 |
+
"reviewer_notes": entry.get("reviewer_notes", ""),
|
| 255 |
+
"preprocessed_image_path": entry.get("preprocessed_image_path", "")
|
| 256 |
}
|
| 257 |
writer.writerow(row)
|
| 258 |
|
|
|
|
| 263 |
feedback_list = self.get_feedback(session_id)
|
| 264 |
|
| 265 |
total = len(feedback_list)
|
| 266 |
+
correct = sum(1 for f in feedback_list if f.get("is_correct") is True)
|
| 267 |
+
incorrect = sum(1 for f in feedback_list if f.get("is_correct") is False)
|
| 268 |
+
not_sure = sum(1 for f in feedback_list if f.get("is_correct") is None)
|
| 269 |
|
| 270 |
# Count by predicted label
|
| 271 |
label_stats = {}
|
| 272 |
for entry in feedback_list:
|
| 273 |
label = entry.get("predicted_label", "Unknown")
|
| 274 |
if label not in label_stats:
|
| 275 |
+
label_stats[label] = {"total": 0, "correct": 0, "incorrect": 0, "not_sure": 0}
|
| 276 |
label_stats[label]["total"] += 1
|
| 277 |
+
is_correct = entry.get("is_correct")
|
| 278 |
+
if is_correct is True:
|
| 279 |
label_stats[label]["correct"] += 1
|
| 280 |
+
elif is_correct is False:
|
| 281 |
label_stats[label]["incorrect"] += 1
|
| 282 |
+
else:
|
| 283 |
+
label_stats[label]["not_sure"] += 1
|
| 284 |
+
|
| 285 |
+
# Calculate accuracy only from definite answers (excluding not sure)
|
| 286 |
+
definite_answers = correct + incorrect
|
| 287 |
+
accuracy = round(correct / definite_answers * 100, 1) if definite_answers > 0 else 0
|
| 288 |
|
| 289 |
return {
|
| 290 |
"total_feedback": total,
|
| 291 |
"correct_count": correct,
|
| 292 |
"incorrect_count": incorrect,
|
| 293 |
+
"not_sure_count": not_sure,
|
| 294 |
+
"accuracy": accuracy,
|
| 295 |
"by_label": label_stats
|
| 296 |
}
|
| 297 |
|
frontend/.DS_Store
ADDED
|
Binary file (6.15 kB). View file
|
|
|
frontend/src/App.tsx
CHANGED
|
@@ -1,14 +1,16 @@
|
|
| 1 |
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
-
import { Scan, Calendar } from 'lucide-react';
|
| 3 |
import { Header } from './components/Header';
|
| 4 |
import { Tabs } from './components/Tabs';
|
| 5 |
import { ClassificationPage } from './pages/ClassificationPage';
|
| 6 |
import { GestationalAgePage } from './pages/GestationalAgePage';
|
|
|
|
| 7 |
import { checkHealth, getFeedbackStats } from './lib/api';
|
| 8 |
|
| 9 |
const tabs = [
|
| 10 |
{ id: 'classification', label: 'View Classification', icon: <Scan className="w-4 h-4" /> },
|
| 11 |
{ id: 'gestational-age', label: 'Gestational Age', icon: <Calendar className="w-4 h-4" /> },
|
|
|
|
| 12 |
];
|
| 13 |
|
| 14 |
function App() {
|
|
@@ -53,14 +55,15 @@ function App() {
|
|
| 53 |
<div className="h-screen flex flex-col bg-dark-bg overflow-hidden">
|
| 54 |
{/* Header - fixed height */}
|
| 55 |
<Header isConnected={isConnected} feedbackStats={feedbackStats} />
|
| 56 |
-
|
| 57 |
{/* Tabs - fixed height */}
|
| 58 |
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
| 59 |
-
|
| 60 |
{/* Main content - fills remaining space */}
|
| 61 |
<main className="flex-1 flex min-h-0 overflow-hidden">
|
| 62 |
{activeTab === 'classification' && <ClassificationPage onFeedbackUpdate={loadFeedbackStats} />}
|
| 63 |
{activeTab === 'gestational-age' && <GestationalAgePage />}
|
|
|
|
| 64 |
</main>
|
| 65 |
|
| 66 |
{/* Footer - fixed height, always visible */}
|
|
|
|
| 1 |
import { useState, useEffect, useCallback } from 'react';
|
| 2 |
+
import { Scan, Calendar, HelpCircle } from 'lucide-react';
|
| 3 |
import { Header } from './components/Header';
|
| 4 |
import { Tabs } from './components/Tabs';
|
| 5 |
import { ClassificationPage } from './pages/ClassificationPage';
|
| 6 |
import { GestationalAgePage } from './pages/GestationalAgePage';
|
| 7 |
+
import { HelpPage } from './pages/HelpPage';
|
| 8 |
import { checkHealth, getFeedbackStats } from './lib/api';
|
| 9 |
|
| 10 |
const tabs = [
|
| 11 |
{ id: 'classification', label: 'View Classification', icon: <Scan className="w-4 h-4" /> },
|
| 12 |
{ id: 'gestational-age', label: 'Gestational Age', icon: <Calendar className="w-4 h-4" /> },
|
| 13 |
+
{ id: 'help', label: 'Help', icon: <HelpCircle className="w-4 h-4" /> },
|
| 14 |
];
|
| 15 |
|
| 16 |
function App() {
|
|
|
|
| 55 |
<div className="h-screen flex flex-col bg-dark-bg overflow-hidden">
|
| 56 |
{/* Header - fixed height */}
|
| 57 |
<Header isConnected={isConnected} feedbackStats={feedbackStats} />
|
| 58 |
+
|
| 59 |
{/* Tabs - fixed height */}
|
| 60 |
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
| 61 |
+
|
| 62 |
{/* Main content - fills remaining space */}
|
| 63 |
<main className="flex-1 flex min-h-0 overflow-hidden">
|
| 64 |
{activeTab === 'classification' && <ClassificationPage onFeedbackUpdate={loadFeedbackStats} />}
|
| 65 |
{activeTab === 'gestational-age' && <GestationalAgePage />}
|
| 66 |
+
{activeTab === 'help' && <HelpPage />}
|
| 67 |
</main>
|
| 68 |
|
| 69 |
{/* Footer - fixed height, always visible */}
|
frontend/src/components/FeedbackSection.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
-
import { Check, X, Send, MessageSquare } from 'lucide-react';
|
| 3 |
import { FETAL_VIEW_LABELS, submitFeedback, FeedbackCreate, ClassificationResult } from '../lib/api';
|
| 4 |
|
| 5 |
interface FeedbackSectionProps {
|
|
@@ -10,6 +10,7 @@ interface FeedbackSectionProps {
|
|
| 10 |
topPrediction: ClassificationResult | null;
|
| 11 |
patientId?: string;
|
| 12 |
imageHash?: string;
|
|
|
|
| 13 |
onFeedbackSubmitted?: () => void;
|
| 14 |
disabled?: boolean;
|
| 15 |
}
|
|
@@ -22,29 +23,33 @@ export function FeedbackSection({
|
|
| 22 |
topPrediction,
|
| 23 |
patientId,
|
| 24 |
imageHash,
|
|
|
|
| 25 |
onFeedbackSubmitted,
|
| 26 |
disabled = false,
|
| 27 |
}: FeedbackSectionProps) {
|
| 28 |
-
const [feedbackState, setFeedbackState] = useState<'none' | 'correct' | 'incorrect'>('none');
|
| 29 |
const [correctLabel, setCorrectLabel] = useState<string>('');
|
| 30 |
const [notes, setNotes] = useState<string>('');
|
| 31 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 32 |
const [submitted, setSubmitted] = useState(false);
|
| 33 |
|
| 34 |
-
const handleFeedback = async (isCorrect: boolean) => {
|
| 35 |
if (disabled || !topPrediction) return;
|
| 36 |
|
| 37 |
-
if (isCorrect) {
|
| 38 |
// Submit immediately for correct predictions
|
| 39 |
setFeedbackState('correct');
|
| 40 |
await submitFeedbackData(true);
|
|
|
|
|
|
|
|
|
|
| 41 |
} else {
|
| 42 |
// Show correction form for incorrect predictions
|
| 43 |
setFeedbackState('incorrect');
|
| 44 |
}
|
| 45 |
};
|
| 46 |
|
| 47 |
-
const submitFeedbackData = async (isCorrect: boolean, overrideLabel?: string, overrideNotes?: string) => {
|
| 48 |
if (!topPrediction || isSubmitting) return;
|
| 49 |
|
| 50 |
setIsSubmitting(true);
|
|
@@ -64,6 +69,7 @@ export function FeedbackSection({
|
|
| 64 |
reviewer_notes: overrideNotes || notes || undefined,
|
| 65 |
patient_id: patientId,
|
| 66 |
image_hash: imageHash,
|
|
|
|
| 67 |
};
|
| 68 |
|
| 69 |
await submitFeedback(feedbackData);
|
|
@@ -81,6 +87,10 @@ export function FeedbackSection({
|
|
| 81 |
await submitFeedbackData(false, correctLabel, notes);
|
| 82 |
};
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
if (submitted) {
|
| 85 |
return (
|
| 86 |
<div className="bg-primary/10 border border-primary/30 rounded-lg p-3">
|
|
@@ -89,6 +99,8 @@ export function FeedbackSection({
|
|
| 89 |
<span className="text-sm font-medium">Feedback recorded</span>
|
| 90 |
{feedbackState === 'correct' ? (
|
| 91 |
<span className="text-xs text-text-muted ml-auto">Confirmed correct</span>
|
|
|
|
|
|
|
| 92 |
) : (
|
| 93 |
<span className="text-xs text-text-muted ml-auto">Corrected to: {correctLabel}</span>
|
| 94 |
)}
|
|
@@ -116,7 +128,7 @@ export function FeedbackSection({
|
|
| 116 |
<MessageSquare className="w-4 h-4" />
|
| 117 |
<span className="text-sm font-medium">Is this prediction correct?</span>
|
| 118 |
</div>
|
| 119 |
-
|
| 120 |
{feedbackState === 'none' && (
|
| 121 |
<div className="flex gap-2">
|
| 122 |
<button
|
|
@@ -126,6 +138,13 @@ export function FeedbackSection({
|
|
| 126 |
<Check className="w-3.5 h-3.5" />
|
| 127 |
Correct
|
| 128 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
<button
|
| 130 |
onClick={() => handleFeedback(false)}
|
| 131 |
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-600 rounded-md text-sm font-medium transition-colors"
|
|
@@ -150,8 +169,8 @@ export function FeedbackSection({
|
|
| 150 |
>
|
| 151 |
<option value="" disabled>Select correct view...</option>
|
| 152 |
{FETAL_VIEW_LABELS.map((label) => (
|
| 153 |
-
<option
|
| 154 |
-
key={label}
|
| 155 |
value={label}
|
| 156 |
disabled={label === topPrediction?.label}
|
| 157 |
>
|
|
@@ -187,7 +206,57 @@ export function FeedbackSection({
|
|
| 187 |
<button
|
| 188 |
onClick={handleSubmitCorrection}
|
| 189 |
disabled={!correctLabel || isSubmitting}
|
| 190 |
-
className=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
>
|
| 192 |
{isSubmitting ? (
|
| 193 |
<span>Submitting...</span>
|
|
@@ -204,3 +273,4 @@ export function FeedbackSection({
|
|
| 204 |
</div>
|
| 205 |
);
|
| 206 |
}
|
|
|
|
|
|
| 1 |
import { useState } from 'react';
|
| 2 |
+
import { Check, X, Send, MessageSquare, HelpCircle } from 'lucide-react';
|
| 3 |
import { FETAL_VIEW_LABELS, submitFeedback, FeedbackCreate, ClassificationResult } from '../lib/api';
|
| 4 |
|
| 5 |
interface FeedbackSectionProps {
|
|
|
|
| 10 |
topPrediction: ClassificationResult | null;
|
| 11 |
patientId?: string;
|
| 12 |
imageHash?: string;
|
| 13 |
+
preprocessedImageBase64?: string;
|
| 14 |
onFeedbackSubmitted?: () => void;
|
| 15 |
disabled?: boolean;
|
| 16 |
}
|
|
|
|
| 23 |
topPrediction,
|
| 24 |
patientId,
|
| 25 |
imageHash,
|
| 26 |
+
preprocessedImageBase64,
|
| 27 |
onFeedbackSubmitted,
|
| 28 |
disabled = false,
|
| 29 |
}: FeedbackSectionProps) {
|
| 30 |
+
const [feedbackState, setFeedbackState] = useState<'none' | 'correct' | 'incorrect' | 'not_sure'>('none');
|
| 31 |
const [correctLabel, setCorrectLabel] = useState<string>('');
|
| 32 |
const [notes, setNotes] = useState<string>('');
|
| 33 |
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 34 |
const [submitted, setSubmitted] = useState(false);
|
| 35 |
|
| 36 |
+
const handleFeedback = async (isCorrect: boolean | null) => {
|
| 37 |
if (disabled || !topPrediction) return;
|
| 38 |
|
| 39 |
+
if (isCorrect === true) {
|
| 40 |
// Submit immediately for correct predictions
|
| 41 |
setFeedbackState('correct');
|
| 42 |
await submitFeedbackData(true);
|
| 43 |
+
} else if (isCorrect === null) {
|
| 44 |
+
// Show notes form for uncertain predictions
|
| 45 |
+
setFeedbackState('not_sure');
|
| 46 |
} else {
|
| 47 |
// Show correction form for incorrect predictions
|
| 48 |
setFeedbackState('incorrect');
|
| 49 |
}
|
| 50 |
};
|
| 51 |
|
| 52 |
+
const submitFeedbackData = async (isCorrect: boolean | null, overrideLabel?: string, overrideNotes?: string) => {
|
| 53 |
if (!topPrediction || isSubmitting) return;
|
| 54 |
|
| 55 |
setIsSubmitting(true);
|
|
|
|
| 69 |
reviewer_notes: overrideNotes || notes || undefined,
|
| 70 |
patient_id: patientId,
|
| 71 |
image_hash: imageHash,
|
| 72 |
+
preprocessed_image_base64: preprocessedImageBase64,
|
| 73 |
};
|
| 74 |
|
| 75 |
await submitFeedback(feedbackData);
|
|
|
|
| 87 |
await submitFeedbackData(false, correctLabel, notes);
|
| 88 |
};
|
| 89 |
|
| 90 |
+
const handleSubmitNotSure = async () => {
|
| 91 |
+
await submitFeedbackData(null, undefined, notes);
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
if (submitted) {
|
| 95 |
return (
|
| 96 |
<div className="bg-primary/10 border border-primary/30 rounded-lg p-3">
|
|
|
|
| 99 |
<span className="text-sm font-medium">Feedback recorded</span>
|
| 100 |
{feedbackState === 'correct' ? (
|
| 101 |
<span className="text-xs text-text-muted ml-auto">Confirmed correct</span>
|
| 102 |
+
) : feedbackState === 'not_sure' ? (
|
| 103 |
+
<span className="text-xs text-text-muted ml-auto">Marked as uncertain</span>
|
| 104 |
) : (
|
| 105 |
<span className="text-xs text-text-muted ml-auto">Corrected to: {correctLabel}</span>
|
| 106 |
)}
|
|
|
|
| 128 |
<MessageSquare className="w-4 h-4" />
|
| 129 |
<span className="text-sm font-medium">Is this prediction correct?</span>
|
| 130 |
</div>
|
| 131 |
+
|
| 132 |
{feedbackState === 'none' && (
|
| 133 |
<div className="flex gap-2">
|
| 134 |
<button
|
|
|
|
| 138 |
<Check className="w-3.5 h-3.5" />
|
| 139 |
Correct
|
| 140 |
</button>
|
| 141 |
+
<button
|
| 142 |
+
onClick={() => handleFeedback(null)}
|
| 143 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500/20 hover:bg-amber-500/30 text-amber-600 rounded-md text-sm font-medium transition-colors"
|
| 144 |
+
>
|
| 145 |
+
<HelpCircle className="w-3.5 h-3.5" />
|
| 146 |
+
Not Sure
|
| 147 |
+
</button>
|
| 148 |
<button
|
| 149 |
onClick={() => handleFeedback(false)}
|
| 150 |
className="flex items-center gap-1.5 px-3 py-1.5 bg-red-500/20 hover:bg-red-500/30 text-red-600 rounded-md text-sm font-medium transition-colors"
|
|
|
|
| 169 |
>
|
| 170 |
<option value="" disabled>Select correct view...</option>
|
| 171 |
{FETAL_VIEW_LABELS.map((label) => (
|
| 172 |
+
<option
|
| 173 |
+
key={label}
|
| 174 |
value={label}
|
| 175 |
disabled={label === topPrediction?.label}
|
| 176 |
>
|
|
|
|
| 206 |
<button
|
| 207 |
onClick={handleSubmitCorrection}
|
| 208 |
disabled={!correctLabel || isSubmitting}
|
| 209 |
+
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-colors ${!correctLabel || isSubmitting
|
| 210 |
+
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
|
| 211 |
+
: 'bg-green-600 hover:bg-green-700 text-white'
|
| 212 |
+
}`}
|
| 213 |
+
>
|
| 214 |
+
{isSubmitting ? (
|
| 215 |
+
<span>Submitting...</span>
|
| 216 |
+
) : (
|
| 217 |
+
<>
|
| 218 |
+
<Send className="w-3.5 h-3.5" />
|
| 219 |
+
<span>Submit Correction</span>
|
| 220 |
+
</>
|
| 221 |
+
)}
|
| 222 |
+
</button>
|
| 223 |
+
</div>
|
| 224 |
+
</div>
|
| 225 |
+
)}
|
| 226 |
+
|
| 227 |
+
{/* Not Sure form */}
|
| 228 |
+
{feedbackState === 'not_sure' && (
|
| 229 |
+
<div className="space-y-3 pt-2 border-t border-border">
|
| 230 |
+
<p className="text-xs text-amber-600">
|
| 231 |
+
You can add optional notes to explain your uncertainty.
|
| 232 |
+
</p>
|
| 233 |
+
|
| 234 |
+
{/* Notes input */}
|
| 235 |
+
<div>
|
| 236 |
+
<label className="block text-xs font-medium text-gray-600 mb-1.5">Notes (optional)</label>
|
| 237 |
+
<textarea
|
| 238 |
+
value={notes}
|
| 239 |
+
onChange={(e) => setNotes(e.target.value)}
|
| 240 |
+
placeholder="Why are you unsure about this prediction?"
|
| 241 |
+
className="w-full px-3 py-2 bg-white border border-gray-300 rounded-lg text-sm text-gray-800 resize-none h-14 focus:outline-none focus:ring-2 focus:ring-amber-500/20 focus:border-amber-500/50 transition-all"
|
| 242 |
+
/>
|
| 243 |
+
</div>
|
| 244 |
+
|
| 245 |
+
{/* Action buttons - inline */}
|
| 246 |
+
<div className="flex gap-2 items-center">
|
| 247 |
+
<button
|
| 248 |
+
onClick={() => {
|
| 249 |
+
setFeedbackState('none');
|
| 250 |
+
setNotes('');
|
| 251 |
+
}}
|
| 252 |
+
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors font-medium"
|
| 253 |
+
>
|
| 254 |
+
Cancel
|
| 255 |
+
</button>
|
| 256 |
+
<button
|
| 257 |
+
onClick={handleSubmitNotSure}
|
| 258 |
+
disabled={isSubmitting}
|
| 259 |
+
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 bg-amber-500 hover:bg-amber-600 text-white rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
| 260 |
>
|
| 261 |
{isSubmitting ? (
|
| 262 |
<span>Submitting...</span>
|
|
|
|
| 273 |
</div>
|
| 274 |
);
|
| 275 |
}
|
| 276 |
+
|
frontend/src/lib/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
const API_BASE =
|
| 2 |
|
| 3 |
export interface ClassificationResult {
|
| 4 |
label: string;
|
|
@@ -135,7 +135,7 @@ export interface FeedbackEntry {
|
|
| 135 |
predicted_label: string;
|
| 136 |
predicted_confidence: number;
|
| 137 |
all_predictions: PredictionDetail[];
|
| 138 |
-
is_correct: boolean;
|
| 139 |
correct_label: string | null;
|
| 140 |
reviewer_notes: string | null;
|
| 141 |
}
|
|
@@ -153,8 +153,9 @@ export interface FeedbackStats {
|
|
| 153 |
total_feedback: number;
|
| 154 |
correct_count: number;
|
| 155 |
incorrect_count: number;
|
|
|
|
| 156 |
accuracy: number;
|
| 157 |
-
by_label: Record<string, { total: number; correct: number; incorrect: number }>;
|
| 158 |
}
|
| 159 |
|
| 160 |
export interface FeedbackCreate {
|
|
@@ -164,11 +165,12 @@ export interface FeedbackCreate {
|
|
| 164 |
predicted_label: string;
|
| 165 |
predicted_confidence: number;
|
| 166 |
all_predictions: PredictionDetail[];
|
| 167 |
-
is_correct: boolean;
|
| 168 |
correct_label?: string;
|
| 169 |
reviewer_notes?: string;
|
| 170 |
patient_id?: string;
|
| 171 |
image_hash?: string;
|
|
|
|
| 172 |
}
|
| 173 |
|
| 174 |
// Create a new session
|
|
@@ -187,7 +189,7 @@ export async function createSession(): Promise<SessionInfo> {
|
|
| 187 |
// Get session info
|
| 188 |
export async function getSession(sessionId: string): Promise<SessionInfo> {
|
| 189 |
const response = await fetch(`${API_BASE}/api/v1/feedback/session/${sessionId}`);
|
| 190 |
-
|
| 191 |
if (!response.ok) {
|
| 192 |
throw new Error('Session not found');
|
| 193 |
}
|
|
@@ -222,10 +224,10 @@ export async function submitFeedback(feedback: FeedbackCreate): Promise<Feedback
|
|
| 222 |
|
| 223 |
// Get all feedback
|
| 224 |
export async function getFeedback(sessionId?: string): Promise<FeedbackEntry[]> {
|
| 225 |
-
const url = sessionId
|
| 226 |
? `${API_BASE}/api/v1/feedback/?session_id=${sessionId}`
|
| 227 |
: `${API_BASE}/api/v1/feedback/`;
|
| 228 |
-
|
| 229 |
const response = await fetch(url);
|
| 230 |
|
| 231 |
if (!response.ok) {
|
|
@@ -240,7 +242,7 @@ export async function getFeedbackStats(sessionId?: string): Promise<FeedbackStat
|
|
| 240 |
const url = sessionId
|
| 241 |
? `${API_BASE}/api/v1/feedback/statistics?session_id=${sessionId}`
|
| 242 |
: `${API_BASE}/api/v1/feedback/statistics`;
|
| 243 |
-
|
| 244 |
const response = await fetch(url);
|
| 245 |
|
| 246 |
if (!response.ok) {
|
|
@@ -255,7 +257,7 @@ export async function exportFeedbackCSV(sessionId?: string): Promise<void> {
|
|
| 255 |
const url = sessionId
|
| 256 |
? `${API_BASE}/api/v1/feedback/export/csv?session_id=${sessionId}`
|
| 257 |
: `${API_BASE}/api/v1/feedback/export/csv`;
|
| 258 |
-
|
| 259 |
const response = await fetch(url);
|
| 260 |
|
| 261 |
if (!response.ok) {
|
|
|
|
| 1 |
+
const API_BASE = window.location.hostname === 'localhost' ? 'http://localhost:8000' : '';
|
| 2 |
|
| 3 |
export interface ClassificationResult {
|
| 4 |
label: string;
|
|
|
|
| 135 |
predicted_label: string;
|
| 136 |
predicted_confidence: number;
|
| 137 |
all_predictions: PredictionDetail[];
|
| 138 |
+
is_correct: boolean | null;
|
| 139 |
correct_label: string | null;
|
| 140 |
reviewer_notes: string | null;
|
| 141 |
}
|
|
|
|
| 153 |
total_feedback: number;
|
| 154 |
correct_count: number;
|
| 155 |
incorrect_count: number;
|
| 156 |
+
not_sure_count: number;
|
| 157 |
accuracy: number;
|
| 158 |
+
by_label: Record<string, { total: number; correct: number; incorrect: number; not_sure: number }>;
|
| 159 |
}
|
| 160 |
|
| 161 |
export interface FeedbackCreate {
|
|
|
|
| 165 |
predicted_label: string;
|
| 166 |
predicted_confidence: number;
|
| 167 |
all_predictions: PredictionDetail[];
|
| 168 |
+
is_correct: boolean | null;
|
| 169 |
correct_label?: string;
|
| 170 |
reviewer_notes?: string;
|
| 171 |
patient_id?: string;
|
| 172 |
image_hash?: string;
|
| 173 |
+
preprocessed_image_base64?: string;
|
| 174 |
}
|
| 175 |
|
| 176 |
// Create a new session
|
|
|
|
| 189 |
// Get session info
|
| 190 |
export async function getSession(sessionId: string): Promise<SessionInfo> {
|
| 191 |
const response = await fetch(`${API_BASE}/api/v1/feedback/session/${sessionId}`);
|
| 192 |
+
|
| 193 |
if (!response.ok) {
|
| 194 |
throw new Error('Session not found');
|
| 195 |
}
|
|
|
|
| 224 |
|
| 225 |
// Get all feedback
|
| 226 |
export async function getFeedback(sessionId?: string): Promise<FeedbackEntry[]> {
|
| 227 |
+
const url = sessionId
|
| 228 |
? `${API_BASE}/api/v1/feedback/?session_id=${sessionId}`
|
| 229 |
: `${API_BASE}/api/v1/feedback/`;
|
| 230 |
+
|
| 231 |
const response = await fetch(url);
|
| 232 |
|
| 233 |
if (!response.ok) {
|
|
|
|
| 242 |
const url = sessionId
|
| 243 |
? `${API_BASE}/api/v1/feedback/statistics?session_id=${sessionId}`
|
| 244 |
: `${API_BASE}/api/v1/feedback/statistics`;
|
| 245 |
+
|
| 246 |
const response = await fetch(url);
|
| 247 |
|
| 248 |
if (!response.ok) {
|
|
|
|
| 257 |
const url = sessionId
|
| 258 |
? `${API_BASE}/api/v1/feedback/export/csv?session_id=${sessionId}`
|
| 259 |
: `${API_BASE}/api/v1/feedback/export/csv`;
|
| 260 |
+
|
| 261 |
const response = await fetch(url);
|
| 262 |
|
| 263 |
if (!response.ok) {
|
frontend/src/pages/ClassificationPage.tsx
CHANGED
|
@@ -8,15 +8,15 @@ import { ResultsCard } from '../components/ResultsCard';
|
|
| 8 |
import { PreprocessingBadge } from '../components/PreprocessingBadge';
|
| 9 |
import { FeedbackSection } from '../components/FeedbackSection';
|
| 10 |
import { SessionHistory } from '../components/SessionHistory';
|
| 11 |
-
import {
|
| 12 |
-
classifyImage,
|
| 13 |
-
getFilePreview,
|
| 14 |
-
getFileType,
|
| 15 |
-
isDicomFile,
|
| 16 |
createSession,
|
| 17 |
recordImageAnalyzed,
|
| 18 |
-
type ClassificationResult,
|
| 19 |
-
type PreprocessingInfo
|
| 20 |
} from '../lib/api';
|
| 21 |
|
| 22 |
interface ClassificationPageProps {
|
|
@@ -27,16 +27,16 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 27 |
// Session state
|
| 28 |
const [sessionId, setSessionId] = useState<string>('');
|
| 29 |
const [feedbackRefresh, setFeedbackRefresh] = useState(0);
|
| 30 |
-
|
| 31 |
// File state
|
| 32 |
const [file, setFile] = useState<File | null>(null);
|
| 33 |
const [preview, setPreview] = useState<string | null>(null);
|
| 34 |
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
| 35 |
-
|
| 36 |
// Multiple files state (folder)
|
| 37 |
const [files, setFiles] = useState<File[]>([]);
|
| 38 |
const [currentIndex, setCurrentIndex] = useState(0);
|
| 39 |
-
|
| 40 |
// Settings & results
|
| 41 |
const [topK, setTopK] = useState(5);
|
| 42 |
const [results, setResults] = useState<ClassificationResult[] | null>(null);
|
|
@@ -44,7 +44,7 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 44 |
const [processedImage, setProcessedImage] = useState<string | null>(null);
|
| 45 |
const [isLoading, setIsLoading] = useState(false);
|
| 46 |
const [error, setError] = useState<string | null>(null);
|
| 47 |
-
|
| 48 |
// Image view tab
|
| 49 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 50 |
|
|
@@ -67,7 +67,7 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 67 |
setPreview(URL.createObjectURL(selectedFile));
|
| 68 |
return;
|
| 69 |
}
|
| 70 |
-
|
| 71 |
// For DICOM, fetch preview from backend
|
| 72 |
setIsLoadingPreview(true);
|
| 73 |
try {
|
|
@@ -98,13 +98,13 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 98 |
const handleFolderUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 99 |
const fileList = e.target.files;
|
| 100 |
if (!fileList) return;
|
| 101 |
-
|
| 102 |
// Accept both images and DICOM files
|
| 103 |
-
const validFiles = Array.from(fileList).filter(f =>
|
| 104 |
-
f.type.startsWith('image/') ||
|
| 105 |
isDicomFile(f.name)
|
| 106 |
).sort((a, b) => a.name.localeCompare(b.name));
|
| 107 |
-
|
| 108 |
if (validFiles.length > 0) {
|
| 109 |
setFiles(validFiles);
|
| 110 |
setCurrentIndex(0);
|
|
@@ -120,14 +120,14 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 120 |
|
| 121 |
const navigateImage = useCallback((direction: 'prev' | 'next') => {
|
| 122 |
if (files.length === 0) return;
|
| 123 |
-
|
| 124 |
let newIndex = currentIndex;
|
| 125 |
if (direction === 'prev' && currentIndex > 0) {
|
| 126 |
newIndex = currentIndex - 1;
|
| 127 |
} else if (direction === 'next' && currentIndex < files.length - 1) {
|
| 128 |
newIndex = currentIndex + 1;
|
| 129 |
}
|
| 130 |
-
|
| 131 |
if (newIndex !== currentIndex) {
|
| 132 |
setCurrentIndex(newIndex);
|
| 133 |
setFile(files[newIndex]);
|
|
@@ -149,13 +149,13 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 149 |
const response = await classifyImage(file, topK);
|
| 150 |
setResults(response.predictions);
|
| 151 |
setPreprocessingInfo(response.preprocessing);
|
| 152 |
-
|
| 153 |
// Use the processed image from backend
|
| 154 |
if (response.preprocessing.processed_image_base64) {
|
| 155 |
setProcessedImage(`data:image/png;base64,${response.preprocessing.processed_image_base64}`);
|
| 156 |
}
|
| 157 |
setImageTab('processed');
|
| 158 |
-
|
| 159 |
// Record image analyzed for session stats
|
| 160 |
if (sessionId) {
|
| 161 |
await recordImageAnalyzed(sessionId);
|
|
@@ -186,22 +186,20 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 186 |
<div className="flex-shrink-0 flex gap-1 bg-dark-input p-1 rounded-xl">
|
| 187 |
<button
|
| 188 |
onClick={() => setImageTab('input')}
|
| 189 |
-
className={`flex-1 px-4 py-2 text-xs font-semibold rounded-lg transition-all ${
|
| 190 |
-
imageTab === 'input'
|
| 191 |
? 'bg-nvidia-green text-white shadow-md'
|
| 192 |
: 'text-text-secondary hover:text-text-primary hover:bg-white'
|
| 193 |
-
|
| 194 |
>
|
| 195 |
Input
|
| 196 |
</button>
|
| 197 |
<button
|
| 198 |
onClick={() => setImageTab('processed')}
|
| 199 |
disabled={!processedImage}
|
| 200 |
-
className={`flex-1 px-4 py-2 text-xs font-semibold rounded-lg transition-all ${
|
| 201 |
-
imageTab === 'processed'
|
| 202 |
? 'bg-nvidia-green text-white shadow-md'
|
| 203 |
: 'text-text-secondary hover:text-text-primary hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent'
|
| 204 |
-
|
| 205 |
>
|
| 206 |
Processed
|
| 207 |
</button>
|
|
@@ -281,10 +279,10 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 281 |
|
| 282 |
{/* Preprocessing Badge */}
|
| 283 |
{(fileType || preprocessingInfo) && (
|
| 284 |
-
<PreprocessingBadge
|
| 285 |
-
info={preprocessingInfo}
|
| 286 |
fileType={fileType}
|
| 287 |
-
compact
|
| 288 |
/>
|
| 289 |
)}
|
| 290 |
|
|
@@ -320,15 +318,14 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 320 |
|
| 321 |
{/* Right Panel - Results */}
|
| 322 |
<div className="w-1/2 bg-dark-bg flex flex-col min-h-0">
|
| 323 |
-
<Panel
|
| 324 |
-
title="Results"
|
| 325 |
action={
|
| 326 |
preprocessingInfo && (
|
| 327 |
-
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold ${
|
| 328 |
-
|
| 329 |
-
? 'bg-nvidia-green/10 text-nvidia-green'
|
| 330 |
: 'bg-amber-500/10 text-amber-600'
|
| 331 |
-
|
| 332 |
{preprocessingInfo.pipeline === 'full' ? 'Full Pipeline' : 'Basic Pipeline'}
|
| 333 |
</span>
|
| 334 |
)
|
|
@@ -337,7 +334,7 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 337 |
>
|
| 338 |
<div className="flex-1 overflow-y-auto space-y-4 pb-6">
|
| 339 |
<ResultsCard results={results} isLoading={isLoading} />
|
| 340 |
-
|
| 341 |
{/* Feedback Section */}
|
| 342 |
{results && results.length > 0 && file && (
|
| 343 |
<FeedbackSection
|
|
@@ -346,10 +343,11 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 346 |
fileType={fileType || 'image'}
|
| 347 |
predictions={results}
|
| 348 |
topPrediction={results[0]}
|
|
|
|
| 349 |
onFeedbackSubmitted={handleFeedbackSubmitted}
|
| 350 |
/>
|
| 351 |
)}
|
| 352 |
-
|
| 353 |
{/* Session History */}
|
| 354 |
{sessionId && (
|
| 355 |
<SessionHistory
|
|
|
|
| 8 |
import { PreprocessingBadge } from '../components/PreprocessingBadge';
|
| 9 |
import { FeedbackSection } from '../components/FeedbackSection';
|
| 10 |
import { SessionHistory } from '../components/SessionHistory';
|
| 11 |
+
import {
|
| 12 |
+
classifyImage,
|
| 13 |
+
getFilePreview,
|
| 14 |
+
getFileType,
|
| 15 |
+
isDicomFile,
|
| 16 |
createSession,
|
| 17 |
recordImageAnalyzed,
|
| 18 |
+
type ClassificationResult,
|
| 19 |
+
type PreprocessingInfo
|
| 20 |
} from '../lib/api';
|
| 21 |
|
| 22 |
interface ClassificationPageProps {
|
|
|
|
| 27 |
// Session state
|
| 28 |
const [sessionId, setSessionId] = useState<string>('');
|
| 29 |
const [feedbackRefresh, setFeedbackRefresh] = useState(0);
|
| 30 |
+
|
| 31 |
// File state
|
| 32 |
const [file, setFile] = useState<File | null>(null);
|
| 33 |
const [preview, setPreview] = useState<string | null>(null);
|
| 34 |
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
| 35 |
+
|
| 36 |
// Multiple files state (folder)
|
| 37 |
const [files, setFiles] = useState<File[]>([]);
|
| 38 |
const [currentIndex, setCurrentIndex] = useState(0);
|
| 39 |
+
|
| 40 |
// Settings & results
|
| 41 |
const [topK, setTopK] = useState(5);
|
| 42 |
const [results, setResults] = useState<ClassificationResult[] | null>(null);
|
|
|
|
| 44 |
const [processedImage, setProcessedImage] = useState<string | null>(null);
|
| 45 |
const [isLoading, setIsLoading] = useState(false);
|
| 46 |
const [error, setError] = useState<string | null>(null);
|
| 47 |
+
|
| 48 |
// Image view tab
|
| 49 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 50 |
|
|
|
|
| 67 |
setPreview(URL.createObjectURL(selectedFile));
|
| 68 |
return;
|
| 69 |
}
|
| 70 |
+
|
| 71 |
// For DICOM, fetch preview from backend
|
| 72 |
setIsLoadingPreview(true);
|
| 73 |
try {
|
|
|
|
| 98 |
const handleFolderUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 99 |
const fileList = e.target.files;
|
| 100 |
if (!fileList) return;
|
| 101 |
+
|
| 102 |
// Accept both images and DICOM files
|
| 103 |
+
const validFiles = Array.from(fileList).filter(f =>
|
| 104 |
+
f.type.startsWith('image/') ||
|
| 105 |
isDicomFile(f.name)
|
| 106 |
).sort((a, b) => a.name.localeCompare(b.name));
|
| 107 |
+
|
| 108 |
if (validFiles.length > 0) {
|
| 109 |
setFiles(validFiles);
|
| 110 |
setCurrentIndex(0);
|
|
|
|
| 120 |
|
| 121 |
const navigateImage = useCallback((direction: 'prev' | 'next') => {
|
| 122 |
if (files.length === 0) return;
|
| 123 |
+
|
| 124 |
let newIndex = currentIndex;
|
| 125 |
if (direction === 'prev' && currentIndex > 0) {
|
| 126 |
newIndex = currentIndex - 1;
|
| 127 |
} else if (direction === 'next' && currentIndex < files.length - 1) {
|
| 128 |
newIndex = currentIndex + 1;
|
| 129 |
}
|
| 130 |
+
|
| 131 |
if (newIndex !== currentIndex) {
|
| 132 |
setCurrentIndex(newIndex);
|
| 133 |
setFile(files[newIndex]);
|
|
|
|
| 149 |
const response = await classifyImage(file, topK);
|
| 150 |
setResults(response.predictions);
|
| 151 |
setPreprocessingInfo(response.preprocessing);
|
| 152 |
+
|
| 153 |
// Use the processed image from backend
|
| 154 |
if (response.preprocessing.processed_image_base64) {
|
| 155 |
setProcessedImage(`data:image/png;base64,${response.preprocessing.processed_image_base64}`);
|
| 156 |
}
|
| 157 |
setImageTab('processed');
|
| 158 |
+
|
| 159 |
// Record image analyzed for session stats
|
| 160 |
if (sessionId) {
|
| 161 |
await recordImageAnalyzed(sessionId);
|
|
|
|
| 186 |
<div className="flex-shrink-0 flex gap-1 bg-dark-input p-1 rounded-xl">
|
| 187 |
<button
|
| 188 |
onClick={() => setImageTab('input')}
|
| 189 |
+
className={`flex-1 px-4 py-2 text-xs font-semibold rounded-lg transition-all ${imageTab === 'input'
|
|
|
|
| 190 |
? 'bg-nvidia-green text-white shadow-md'
|
| 191 |
: 'text-text-secondary hover:text-text-primary hover:bg-white'
|
| 192 |
+
}`}
|
| 193 |
>
|
| 194 |
Input
|
| 195 |
</button>
|
| 196 |
<button
|
| 197 |
onClick={() => setImageTab('processed')}
|
| 198 |
disabled={!processedImage}
|
| 199 |
+
className={`flex-1 px-4 py-2 text-xs font-semibold rounded-lg transition-all ${imageTab === 'processed'
|
|
|
|
| 200 |
? 'bg-nvidia-green text-white shadow-md'
|
| 201 |
: 'text-text-secondary hover:text-text-primary hover:bg-white disabled:opacity-40 disabled:cursor-not-allowed disabled:hover:bg-transparent'
|
| 202 |
+
}`}
|
| 203 |
>
|
| 204 |
Processed
|
| 205 |
</button>
|
|
|
|
| 279 |
|
| 280 |
{/* Preprocessing Badge */}
|
| 281 |
{(fileType || preprocessingInfo) && (
|
| 282 |
+
<PreprocessingBadge
|
| 283 |
+
info={preprocessingInfo}
|
| 284 |
fileType={fileType}
|
| 285 |
+
compact
|
| 286 |
/>
|
| 287 |
)}
|
| 288 |
|
|
|
|
| 318 |
|
| 319 |
{/* Right Panel - Results */}
|
| 320 |
<div className="w-1/2 bg-dark-bg flex flex-col min-h-0">
|
| 321 |
+
<Panel
|
| 322 |
+
title="Results"
|
| 323 |
action={
|
| 324 |
preprocessingInfo && (
|
| 325 |
+
<span className={`text-xs px-2.5 py-1 rounded-full font-semibold ${preprocessingInfo.pipeline === 'full'
|
| 326 |
+
? 'bg-nvidia-green/10 text-nvidia-green'
|
|
|
|
| 327 |
: 'bg-amber-500/10 text-amber-600'
|
| 328 |
+
}`}>
|
| 329 |
{preprocessingInfo.pipeline === 'full' ? 'Full Pipeline' : 'Basic Pipeline'}
|
| 330 |
</span>
|
| 331 |
)
|
|
|
|
| 334 |
>
|
| 335 |
<div className="flex-1 overflow-y-auto space-y-4 pb-6">
|
| 336 |
<ResultsCard results={results} isLoading={isLoading} />
|
| 337 |
+
|
| 338 |
{/* Feedback Section */}
|
| 339 |
{results && results.length > 0 && file && (
|
| 340 |
<FeedbackSection
|
|
|
|
| 343 |
fileType={fileType || 'image'}
|
| 344 |
predictions={results}
|
| 345 |
topPrediction={results[0]}
|
| 346 |
+
preprocessedImageBase64={processedImage ? processedImage.split(',')[1] : undefined}
|
| 347 |
onFeedbackSubmitted={handleFeedbackSubmitted}
|
| 348 |
/>
|
| 349 |
)}
|
| 350 |
+
|
| 351 |
{/* Session History */}
|
| 352 |
{sessionId && (
|
| 353 |
<SessionHistory
|
frontend/src/pages/HelpPage.tsx
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { HelpCircle, Upload, Scan, Check, X, HelpCircle as UnsureIcon, Download, ArrowRight, Shield, Mail } from 'lucide-react';
|
| 2 |
+
|
| 3 |
+
export function HelpPage() {
|
| 4 |
+
return (
|
| 5 |
+
<div className="flex-1 overflow-y-auto bg-gradient-to-b from-white to-gray-50">
|
| 6 |
+
<div className="max-w-4xl mx-auto px-8 py-10">
|
| 7 |
+
{/* Hero Section */}
|
| 8 |
+
<div className="text-center mb-12">
|
| 9 |
+
<div className="inline-flex items-center justify-center w-16 h-16 bg-nvidia-green/10 rounded-2xl mb-4">
|
| 10 |
+
<HelpCircle className="w-8 h-8 text-nvidia-green" />
|
| 11 |
+
</div>
|
| 12 |
+
<h1 className="text-3xl font-bold text-gray-900 mb-3">
|
| 13 |
+
FetalCLIP User Guide
|
| 14 |
+
</h1>
|
| 15 |
+
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 16 |
+
A quick reference guide for using the FetalCLIP fetal ultrasound analysis tool.
|
| 17 |
+
</p>
|
| 18 |
+
</div>
|
| 19 |
+
|
| 20 |
+
{/* Quick Start Steps */}
|
| 21 |
+
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-8 mb-8">
|
| 22 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-6 flex items-center gap-2">
|
| 23 |
+
<span className="w-8 h-8 rounded-lg bg-nvidia-green/10 flex items-center justify-center">
|
| 24 |
+
<ArrowRight className="w-4 h-4 text-nvidia-green" />
|
| 25 |
+
</span>
|
| 26 |
+
Quick Start
|
| 27 |
+
</h2>
|
| 28 |
+
|
| 29 |
+
<div className="space-y-4">
|
| 30 |
+
<Step number={1} title="Upload an Image">
|
| 31 |
+
Upload a fetal ultrasound image (PNG, JPEG) or DICOM file. You can also load an entire folder for batch processing.
|
| 32 |
+
</Step>
|
| 33 |
+
|
| 34 |
+
<Step number={2} title="Run Classification">
|
| 35 |
+
Click <strong>"Classify View"</strong> to analyze the image. The model will identify the anatomical view and display confidence scores.
|
| 36 |
+
</Step>
|
| 37 |
+
|
| 38 |
+
<Step number={3} title="Review & Provide Feedback">
|
| 39 |
+
Review the prediction and indicate whether it's correct, incorrect, or uncertain. Your feedback helps improve the model.
|
| 40 |
+
</Step>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
|
| 44 |
+
{/* Feedback Options */}
|
| 45 |
+
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-8 mb-8">
|
| 46 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-6 flex items-center gap-2">
|
| 47 |
+
<span className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
| 48 |
+
<Scan className="w-4 h-4 text-blue-600" />
|
| 49 |
+
</span>
|
| 50 |
+
Feedback Options
|
| 51 |
+
</h2>
|
| 52 |
+
|
| 53 |
+
<div className="grid md:grid-cols-3 gap-4">
|
| 54 |
+
<FeedbackCard
|
| 55 |
+
icon={<Check className="w-5 h-5" />}
|
| 56 |
+
title="Correct"
|
| 57 |
+
description="The prediction matches the actual anatomical view."
|
| 58 |
+
color="green"
|
| 59 |
+
/>
|
| 60 |
+
<FeedbackCard
|
| 61 |
+
icon={<UnsureIcon className="w-5 h-5" />}
|
| 62 |
+
title="Not Sure"
|
| 63 |
+
description="You're uncertain about the correct classification."
|
| 64 |
+
color="amber"
|
| 65 |
+
/>
|
| 66 |
+
<FeedbackCard
|
| 67 |
+
icon={<X className="w-5 h-5" />}
|
| 68 |
+
title="Incorrect"
|
| 69 |
+
description="The prediction is wrong. Select the correct label."
|
| 70 |
+
color="red"
|
| 71 |
+
/>
|
| 72 |
+
</div>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
{/* Features Overview */}
|
| 76 |
+
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
| 77 |
+
<FeatureCard
|
| 78 |
+
icon={<Upload className="w-5 h-5 text-purple-600" />}
|
| 79 |
+
title="Batch Processing"
|
| 80 |
+
description="Load a folder of images and navigate through them using the arrow controls. Each image can be classified individually."
|
| 81 |
+
color="purple"
|
| 82 |
+
/>
|
| 83 |
+
<FeatureCard
|
| 84 |
+
icon={<Download className="w-5 h-5 text-blue-600" />}
|
| 85 |
+
title="Export Data"
|
| 86 |
+
description="All feedback is automatically saved and can be exported as CSV for further analysis."
|
| 87 |
+
color="blue"
|
| 88 |
+
/>
|
| 89 |
+
</div>
|
| 90 |
+
|
| 91 |
+
{/* Data Privacy Notice */}
|
| 92 |
+
<div className="bg-green-50 rounded-2xl border border-green-200 p-6 mb-8">
|
| 93 |
+
<div className="flex gap-4">
|
| 94 |
+
<div className="flex-shrink-0">
|
| 95 |
+
<Shield className="w-6 h-6 text-green-600" />
|
| 96 |
+
</div>
|
| 97 |
+
<div>
|
| 98 |
+
<h3 className="font-semibold text-green-900 mb-1">Data Privacy</h3>
|
| 99 |
+
<p className="text-green-800 text-sm">
|
| 100 |
+
All data is processed locally on your machine. Feedback and images are stored in your home directory
|
| 101 |
+
(<code className="bg-green-100 px-1 rounded">~/.fetalclip/</code>) and are not uploaded to any external server.
|
| 102 |
+
</p>
|
| 103 |
+
</div>
|
| 104 |
+
</div>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
{/* Contact Section */}
|
| 108 |
+
<div className="text-center py-6 border-t border-gray-200">
|
| 109 |
+
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
|
| 110 |
+
<Mail className="w-4 h-4" />
|
| 111 |
+
<span>For questions or support, contact the research team.</span>
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
);
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
interface StepProps {
|
| 120 |
+
number: number;
|
| 121 |
+
title: string;
|
| 122 |
+
children: React.ReactNode;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
function Step({ number, title, children }: StepProps) {
|
| 126 |
+
return (
|
| 127 |
+
<div className="flex gap-4">
|
| 128 |
+
<div className="flex-shrink-0 w-8 h-8 rounded-full bg-nvidia-green text-white flex items-center justify-center font-semibold text-sm">
|
| 129 |
+
{number}
|
| 130 |
+
</div>
|
| 131 |
+
<div className="flex-1">
|
| 132 |
+
<h3 className="font-medium text-gray-900 mb-1">{title}</h3>
|
| 133 |
+
<p className="text-gray-600 text-sm">{children}</p>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
);
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
interface FeedbackCardProps {
|
| 140 |
+
icon: React.ReactNode;
|
| 141 |
+
title: string;
|
| 142 |
+
description: string;
|
| 143 |
+
color: 'green' | 'amber' | 'red';
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
function FeedbackCard({ icon, title, description, color }: FeedbackCardProps) {
|
| 147 |
+
const colorClasses = {
|
| 148 |
+
green: 'bg-green-50 border-green-200 text-green-600',
|
| 149 |
+
amber: 'bg-amber-50 border-amber-200 text-amber-600',
|
| 150 |
+
red: 'bg-red-50 border-red-200 text-red-600',
|
| 151 |
+
};
|
| 152 |
+
|
| 153 |
+
const iconBgClasses = {
|
| 154 |
+
green: 'bg-green-100',
|
| 155 |
+
amber: 'bg-amber-100',
|
| 156 |
+
red: 'bg-red-100',
|
| 157 |
+
};
|
| 158 |
+
|
| 159 |
+
return (
|
| 160 |
+
<div className={`rounded-xl border p-4 ${colorClasses[color]}`}>
|
| 161 |
+
<div className={`w-10 h-10 rounded-lg ${iconBgClasses[color]} flex items-center justify-center mb-3`}>
|
| 162 |
+
{icon}
|
| 163 |
+
</div>
|
| 164 |
+
<h3 className="font-semibold text-gray-900 mb-1">{title}</h3>
|
| 165 |
+
<p className="text-gray-600 text-xs">{description}</p>
|
| 166 |
+
</div>
|
| 167 |
+
);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
interface FeatureCardProps {
|
| 171 |
+
icon: React.ReactNode;
|
| 172 |
+
title: string;
|
| 173 |
+
description: string;
|
| 174 |
+
color: 'purple' | 'blue';
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function FeatureCard({ icon, title, description, color }: FeatureCardProps) {
|
| 178 |
+
const bgClasses = {
|
| 179 |
+
purple: 'bg-purple-50',
|
| 180 |
+
blue: 'bg-blue-50',
|
| 181 |
+
};
|
| 182 |
+
|
| 183 |
+
return (
|
| 184 |
+
<div className={`rounded-xl p-5 ${bgClasses[color]}`}>
|
| 185 |
+
<div className="w-10 h-10 rounded-lg bg-white shadow-sm flex items-center justify-center mb-3">
|
| 186 |
+
{icon}
|
| 187 |
+
</div>
|
| 188 |
+
<h3 className="font-semibold text-gray-900 mb-1">{title}</h3>
|
| 189 |
+
<p className="text-gray-600 text-sm">{description}</p>
|
| 190 |
+
</div>
|
| 191 |
+
);
|
| 192 |
+
}
|
server.py
CHANGED
|
@@ -22,7 +22,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|
| 22 |
from contextlib import asynccontextmanager
|
| 23 |
|
| 24 |
# Import the backend app components
|
| 25 |
-
from app.routes import classification_router, gestational_age_router
|
| 26 |
from app.services.model import model_service
|
| 27 |
|
| 28 |
# Assets directory
|
|
@@ -60,6 +60,7 @@ app.add_middleware(
|
|
| 60 |
# Include API routers
|
| 61 |
app.include_router(classification_router)
|
| 62 |
app.include_router(gestational_age_router)
|
|
|
|
| 63 |
|
| 64 |
|
| 65 |
# Health check endpoint
|
|
|
|
| 22 |
from contextlib import asynccontextmanager
|
| 23 |
|
| 24 |
# Import the backend app components
|
| 25 |
+
from app.routes import classification_router, gestational_age_router, feedback_router
|
| 26 |
from app.services.model import model_service
|
| 27 |
|
| 28 |
# Assets directory
|
|
|
|
| 60 |
# Include API routers
|
| 61 |
app.include_router(classification_router)
|
| 62 |
app.include_router(gestational_age_router)
|
| 63 |
+
app.include_router(feedback_router)
|
| 64 |
|
| 65 |
|
| 66 |
# Health check endpoint
|