Spaces:
Runtime error
Runtime error
Numan Saeed commited on
Commit ·
4e5e939
1
Parent(s): bedc7e7
Update with GA feedback, export dialog, and Help page improvements
Browse files- backend/app/routes/__pycache__/feedback.cpython-310.pyc +0 -0
- backend/app/routes/feedback.py +26 -2
- backend/app/services/__pycache__/feedback.cpython-310.pyc +0 -0
- backend/app/services/feedback.py +33 -3
- frontend/src/.DS_Store +0 -0
- frontend/src/App.tsx +10 -3
- frontend/src/components/GAFeedbackSection.tsx +200 -0
- frontend/src/lib/ImageContext.tsx +70 -6
- frontend/src/lib/api.ts +46 -2
- frontend/src/pages/ClassificationPage.tsx +196 -40
- frontend/src/pages/GestationalAgePage.tsx +97 -35
- frontend/src/pages/HelpPage.tsx +101 -19
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
|
@@ -33,6 +33,14 @@ class FeedbackCreate(BaseModel):
|
|
| 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):
|
|
@@ -50,6 +58,14 @@ class FeedbackResponse(BaseModel):
|
|
| 50 |
correct_label: Optional[str]
|
| 51 |
reviewer_notes: Optional[str]
|
| 52 |
preprocessed_image_path: Optional[str] = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
class SessionCreate(BaseModel):
|
|
@@ -108,7 +124,7 @@ async def record_image_analyzed(session_id: str):
|
|
| 108 |
|
| 109 |
@router.post("/", response_model=FeedbackResponse)
|
| 110 |
async def create_feedback(feedback: FeedbackCreate):
|
| 111 |
-
"""Submit feedback for a prediction."""
|
| 112 |
# Convert pydantic models to dicts
|
| 113 |
all_predictions_dict = [{"label": p.label, "probability": p.probability} for p in feedback.all_predictions]
|
| 114 |
|
|
@@ -124,7 +140,15 @@ async def create_feedback(feedback: FeedbackCreate):
|
|
| 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
|
|
|
|
| 33 |
patient_id: Optional[str] = None
|
| 34 |
image_hash: Optional[str] = None
|
| 35 |
preprocessed_image_base64: Optional[str] = None
|
| 36 |
+
# GA-specific fields
|
| 37 |
+
feedback_type: str = "classification" # "classification" or "gestational_age"
|
| 38 |
+
estimated_ga_weeks: Optional[float] = None
|
| 39 |
+
estimated_ga_days: Optional[int] = None
|
| 40 |
+
view_type: Optional[str] = None # brain, abdomen, femur
|
| 41 |
+
biometry_value: Optional[float] = None # measured HC/AC/FL in mm
|
| 42 |
+
percentile: Optional[float] = None
|
| 43 |
+
correct_ga_weeks: Optional[float] = None # For corrections
|
| 44 |
|
| 45 |
|
| 46 |
class FeedbackResponse(BaseModel):
|
|
|
|
| 58 |
correct_label: Optional[str]
|
| 59 |
reviewer_notes: Optional[str]
|
| 60 |
preprocessed_image_path: Optional[str] = None
|
| 61 |
+
# GA-specific fields
|
| 62 |
+
feedback_type: str = "classification"
|
| 63 |
+
estimated_ga_weeks: Optional[float] = None
|
| 64 |
+
estimated_ga_days: Optional[int] = None
|
| 65 |
+
view_type: Optional[str] = None
|
| 66 |
+
biometry_value: Optional[float] = None
|
| 67 |
+
percentile: Optional[float] = None
|
| 68 |
+
correct_ga_weeks: Optional[float] = None
|
| 69 |
|
| 70 |
|
| 71 |
class SessionCreate(BaseModel):
|
|
|
|
| 124 |
|
| 125 |
@router.post("/", response_model=FeedbackResponse)
|
| 126 |
async def create_feedback(feedback: FeedbackCreate):
|
| 127 |
+
"""Submit feedback for a prediction or GA estimation."""
|
| 128 |
# Convert pydantic models to dicts
|
| 129 |
all_predictions_dict = [{"label": p.label, "probability": p.probability} for p in feedback.all_predictions]
|
| 130 |
|
|
|
|
| 140 |
reviewer_notes=feedback.reviewer_notes,
|
| 141 |
patient_id=feedback.patient_id,
|
| 142 |
image_hash=feedback.image_hash,
|
| 143 |
+
preprocessed_image_base64=feedback.preprocessed_image_base64,
|
| 144 |
+
# GA-specific fields
|
| 145 |
+
feedback_type=feedback.feedback_type,
|
| 146 |
+
estimated_ga_weeks=feedback.estimated_ga_weeks,
|
| 147 |
+
estimated_ga_days=feedback.estimated_ga_days,
|
| 148 |
+
view_type=feedback.view_type,
|
| 149 |
+
biometry_value=feedback.biometry_value,
|
| 150 |
+
percentile=feedback.percentile,
|
| 151 |
+
correct_ga_weeks=feedback.correct_ga_weeks
|
| 152 |
)
|
| 153 |
|
| 154 |
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
|
@@ -132,7 +132,15 @@ class FeedbackService:
|
|
| 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]
|
|
@@ -164,7 +172,15 @@ class FeedbackService:
|
|
| 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()
|
|
@@ -213,17 +229,24 @@ class FeedbackService:
|
|
| 213 |
|
| 214 |
output = io.StringIO()
|
| 215 |
|
| 216 |
-
# Define CSV columns
|
| 217 |
fieldnames = [
|
| 218 |
"timestamp",
|
| 219 |
"session_id",
|
| 220 |
"filename",
|
| 221 |
"patient_id",
|
| 222 |
"file_type",
|
|
|
|
| 223 |
"predicted_label",
|
| 224 |
"predicted_confidence",
|
| 225 |
"is_correct",
|
| 226 |
"correct_label",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
"reviewer_notes",
|
| 228 |
"preprocessed_image_path"
|
| 229 |
]
|
|
@@ -247,10 +270,17 @@ class FeedbackService:
|
|
| 247 |
"filename": entry.get("filename", ""),
|
| 248 |
"patient_id": entry.get("patient_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 |
}
|
|
|
|
| 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 |
+
# GA-specific fields
|
| 137 |
+
feedback_type: str = "classification",
|
| 138 |
+
estimated_ga_weeks: Optional[float] = None,
|
| 139 |
+
estimated_ga_days: Optional[int] = None,
|
| 140 |
+
view_type: Optional[str] = None,
|
| 141 |
+
biometry_value: Optional[float] = None,
|
| 142 |
+
percentile: Optional[float] = None,
|
| 143 |
+
correct_ga_weeks: Optional[float] = None
|
| 144 |
) -> Dict:
|
| 145 |
"""Add new feedback entry."""
|
| 146 |
feedback_id = str(uuid.uuid4())[:12]
|
|
|
|
| 172 |
"is_correct": is_correct,
|
| 173 |
"correct_label": correct_label if is_correct is False else None,
|
| 174 |
"reviewer_notes": reviewer_notes,
|
| 175 |
+
"preprocessed_image_path": preprocessed_image_path,
|
| 176 |
+
# GA-specific fields
|
| 177 |
+
"feedback_type": feedback_type,
|
| 178 |
+
"estimated_ga_weeks": estimated_ga_weeks,
|
| 179 |
+
"estimated_ga_days": estimated_ga_days,
|
| 180 |
+
"view_type": view_type,
|
| 181 |
+
"biometry_value": biometry_value,
|
| 182 |
+
"percentile": percentile,
|
| 183 |
+
"correct_ga_weeks": correct_ga_weeks
|
| 184 |
}
|
| 185 |
|
| 186 |
feedback_list = self._load_feedback()
|
|
|
|
| 229 |
|
| 230 |
output = io.StringIO()
|
| 231 |
|
| 232 |
+
# Define CSV columns (unified for classification and GA)
|
| 233 |
fieldnames = [
|
| 234 |
"timestamp",
|
| 235 |
"session_id",
|
| 236 |
"filename",
|
| 237 |
"patient_id",
|
| 238 |
"file_type",
|
| 239 |
+
"feedback_type",
|
| 240 |
"predicted_label",
|
| 241 |
"predicted_confidence",
|
| 242 |
"is_correct",
|
| 243 |
"correct_label",
|
| 244 |
+
"view_type",
|
| 245 |
+
"ga_weeks",
|
| 246 |
+
"ga_days",
|
| 247 |
+
"biometry_mm",
|
| 248 |
+
"percentile",
|
| 249 |
+
"correct_ga_weeks",
|
| 250 |
"reviewer_notes",
|
| 251 |
"preprocessed_image_path"
|
| 252 |
]
|
|
|
|
| 270 |
"filename": entry.get("filename", ""),
|
| 271 |
"patient_id": entry.get("patient_id", ""),
|
| 272 |
"file_type": entry.get("file_type", ""),
|
| 273 |
+
"feedback_type": entry.get("feedback_type", "classification"),
|
| 274 |
"predicted_label": entry.get("predicted_label", ""),
|
| 275 |
"predicted_confidence": entry.get("predicted_confidence", ""),
|
| 276 |
"is_correct": is_correct_str,
|
| 277 |
"correct_label": entry.get("correct_label", ""),
|
| 278 |
+
"view_type": entry.get("view_type", ""),
|
| 279 |
+
"ga_weeks": entry.get("estimated_ga_weeks", ""),
|
| 280 |
+
"ga_days": entry.get("estimated_ga_days", ""),
|
| 281 |
+
"biometry_mm": entry.get("biometry_value", ""),
|
| 282 |
+
"percentile": entry.get("percentile", ""),
|
| 283 |
+
"correct_ga_weeks": entry.get("correct_ga_weeks", ""),
|
| 284 |
"reviewer_notes": entry.get("reviewer_notes", ""),
|
| 285 |
"preprocessed_image_path": entry.get("preprocessed_image_path", "")
|
| 286 |
}
|
frontend/src/.DS_Store
CHANGED
|
Binary files a/frontend/src/.DS_Store and b/frontend/src/.DS_Store differ
|
|
|
frontend/src/App.tsx
CHANGED
|
@@ -62,10 +62,17 @@ function App() {
|
|
| 62 |
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
| 63 |
|
| 64 |
{/* Main content - fills remaining space */}
|
|
|
|
| 65 |
<main className="flex-1 flex min-h-0 overflow-hidden">
|
| 66 |
-
{activeTab === 'classification'
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</main>
|
| 70 |
|
| 71 |
{/* Footer - fixed height, always visible */}
|
|
|
|
| 62 |
<Tabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} />
|
| 63 |
|
| 64 |
{/* Main content - fills remaining space */}
|
| 65 |
+
{/* All tabs stay mounted, hidden/shown via CSS to preserve running operations */}
|
| 66 |
<main className="flex-1 flex min-h-0 overflow-hidden">
|
| 67 |
+
<div className={`flex-1 flex min-h-0 ${activeTab === 'classification' ? '' : 'hidden'}`}>
|
| 68 |
+
<ClassificationPage onFeedbackUpdate={loadFeedbackStats} />
|
| 69 |
+
</div>
|
| 70 |
+
<div className={`flex-1 flex min-h-0 ${activeTab === 'gestational-age' ? '' : 'hidden'}`}>
|
| 71 |
+
<GestationalAgePage />
|
| 72 |
+
</div>
|
| 73 |
+
<div className={`flex-1 flex min-h-0 ${activeTab === 'help' ? '' : 'hidden'}`}>
|
| 74 |
+
<HelpPage />
|
| 75 |
+
</div>
|
| 76 |
</main>
|
| 77 |
|
| 78 |
{/* Footer - fixed height, always visible */}
|
frontend/src/components/GAFeedbackSection.tsx
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from 'react';
|
| 2 |
+
import { Check, X, Send, HelpCircle } from 'lucide-react';
|
| 3 |
+
import { submitFeedback, FeedbackCreate, GestationalAgeResponse } from '../lib/api';
|
| 4 |
+
import { GA_BIOMETRY_LABELS } from '../lib/ImageContext';
|
| 5 |
+
|
| 6 |
+
interface GAFeedbackSectionProps {
|
| 7 |
+
sessionId: string;
|
| 8 |
+
filename: string;
|
| 9 |
+
fileType: 'dicom' | 'image';
|
| 10 |
+
viewType: string;
|
| 11 |
+
gaResults: GestationalAgeResponse;
|
| 12 |
+
patientId?: string;
|
| 13 |
+
imageHash?: string;
|
| 14 |
+
preprocessedImageBase64?: string;
|
| 15 |
+
onFeedbackSubmitted?: () => void;
|
| 16 |
+
disabled?: boolean;
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export function GAFeedbackSection({
|
| 20 |
+
sessionId,
|
| 21 |
+
filename,
|
| 22 |
+
fileType,
|
| 23 |
+
viewType,
|
| 24 |
+
gaResults,
|
| 25 |
+
patientId,
|
| 26 |
+
imageHash,
|
| 27 |
+
preprocessedImageBase64,
|
| 28 |
+
onFeedbackSubmitted,
|
| 29 |
+
disabled = false,
|
| 30 |
+
}: GAFeedbackSectionProps) {
|
| 31 |
+
const [feedbackState, setFeedbackState] = useState<'none' | 'correct' | 'incorrect' | 'not_sure'>('none');
|
| 32 |
+
const [notes, setNotes] = useState('');
|
| 33 |
+
const [correctGA, setCorrectGA] = useState('');
|
| 34 |
+
const [isSubmitting, setIsSubmitting] = useState(false);
|
| 35 |
+
const [submitted, setSubmitted] = useState(false);
|
| 36 |
+
|
| 37 |
+
// Get biometry value based on view type
|
| 38 |
+
const getBiometryValue = (): number | undefined => {
|
| 39 |
+
if (gaResults.head_circumference) return gaResults.head_circumference.p50;
|
| 40 |
+
if (gaResults.abdominal_circumference) return gaResults.abdominal_circumference.p50;
|
| 41 |
+
if (gaResults.femur_length) return gaResults.femur_length.p50;
|
| 42 |
+
return undefined;
|
| 43 |
+
};
|
| 44 |
+
|
| 45 |
+
const submitFeedbackData = async (isCorrect: boolean | null, overrideNotes?: string) => {
|
| 46 |
+
setIsSubmitting(true);
|
| 47 |
+
try {
|
| 48 |
+
// Build feedback payload
|
| 49 |
+
const feedback: FeedbackCreate = {
|
| 50 |
+
session_id: sessionId,
|
| 51 |
+
filename,
|
| 52 |
+
file_type: fileType,
|
| 53 |
+
predicted_label: viewType,
|
| 54 |
+
predicted_confidence: 1.0, // GA doesn't have confidence
|
| 55 |
+
all_predictions: [{ label: viewType, probability: 1.0 }],
|
| 56 |
+
is_correct: isCorrect,
|
| 57 |
+
reviewer_notes: overrideNotes || notes || undefined,
|
| 58 |
+
patient_id: patientId,
|
| 59 |
+
image_hash: imageHash,
|
| 60 |
+
preprocessed_image_base64: preprocessedImageBase64,
|
| 61 |
+
// GA-specific fields
|
| 62 |
+
feedback_type: 'gestational_age',
|
| 63 |
+
estimated_ga_weeks: gaResults.gestational_age.weeks + gaResults.gestational_age.days / 7,
|
| 64 |
+
estimated_ga_days: gaResults.gestational_age.total_days,
|
| 65 |
+
view_type: viewType,
|
| 66 |
+
biometry_value: getBiometryValue(),
|
| 67 |
+
percentile: undefined, // Add if available
|
| 68 |
+
correct_ga_weeks: correctGA ? parseFloat(correctGA) : undefined,
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
+
await submitFeedback(feedback);
|
| 72 |
+
setSubmitted(true);
|
| 73 |
+
onFeedbackSubmitted?.();
|
| 74 |
+
} catch (error) {
|
| 75 |
+
console.error('Failed to submit feedback:', error);
|
| 76 |
+
} finally {
|
| 77 |
+
setIsSubmitting(false);
|
| 78 |
+
}
|
| 79 |
+
};
|
| 80 |
+
|
| 81 |
+
const handleFeedback = (type: 'correct' | 'incorrect' | 'not_sure') => {
|
| 82 |
+
setFeedbackState(type);
|
| 83 |
+
|
| 84 |
+
if (type === 'correct') {
|
| 85 |
+
submitFeedbackData(true);
|
| 86 |
+
} else if (type === 'not_sure') {
|
| 87 |
+
submitFeedbackData(null);
|
| 88 |
+
}
|
| 89 |
+
// 'incorrect' requires additional input before submission
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handleSubmitIncorrect = () => {
|
| 93 |
+
submitFeedbackData(false);
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
if (submitted) {
|
| 97 |
+
return (
|
| 98 |
+
<div className="bg-nvidia-green/10 rounded-xl p-4 border border-nvidia-green/20">
|
| 99 |
+
<div className="flex items-center gap-2 text-nvidia-green">
|
| 100 |
+
<Check className="w-5 h-5" />
|
| 101 |
+
<span className="font-medium">Feedback submitted - Thank you!</span>
|
| 102 |
+
</div>
|
| 103 |
+
</div>
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<div className="bg-white rounded-xl p-4 border border-slate-200">
|
| 109 |
+
<h3 className="font-semibold text-slate-800 mb-3">Is this GA estimation correct?</h3>
|
| 110 |
+
|
| 111 |
+
<div className="flex items-center gap-2 mb-3 text-sm text-slate-600">
|
| 112 |
+
<span>Estimated: <strong>{gaResults.gestational_age.weeks}w {gaResults.gestational_age.days}d</strong></span>
|
| 113 |
+
<span className="text-slate-300">|</span>
|
| 114 |
+
<span>View: <strong>{GA_BIOMETRY_LABELS[viewType] || viewType}</strong></span>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div className="flex gap-2 mb-3">
|
| 118 |
+
<button
|
| 119 |
+
onClick={() => handleFeedback('correct')}
|
| 120 |
+
disabled={disabled || isSubmitting || feedbackState !== 'none'}
|
| 121 |
+
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${feedbackState === 'correct'
|
| 122 |
+
? 'bg-nvidia-green text-white'
|
| 123 |
+
: 'bg-slate-100 hover:bg-green-50 text-slate-700 hover:text-green-700'
|
| 124 |
+
} ${disabled || isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
| 125 |
+
>
|
| 126 |
+
<Check className="w-4 h-4" />
|
| 127 |
+
Correct
|
| 128 |
+
</button>
|
| 129 |
+
|
| 130 |
+
<button
|
| 131 |
+
onClick={() => handleFeedback('incorrect')}
|
| 132 |
+
disabled={disabled || isSubmitting || feedbackState !== 'none'}
|
| 133 |
+
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${feedbackState === 'incorrect'
|
| 134 |
+
? 'bg-red-500 text-white'
|
| 135 |
+
: 'bg-slate-100 hover:bg-red-50 text-slate-700 hover:text-red-700'
|
| 136 |
+
} ${disabled || isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
| 137 |
+
>
|
| 138 |
+
<X className="w-4 h-4" />
|
| 139 |
+
Incorrect
|
| 140 |
+
</button>
|
| 141 |
+
|
| 142 |
+
<button
|
| 143 |
+
onClick={() => handleFeedback('not_sure')}
|
| 144 |
+
disabled={disabled || isSubmitting || feedbackState !== 'none'}
|
| 145 |
+
className={`flex-1 flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium transition-all ${feedbackState === 'not_sure'
|
| 146 |
+
? 'bg-amber-500 text-white'
|
| 147 |
+
: 'bg-slate-100 hover:bg-amber-50 text-slate-700 hover:text-amber-700'
|
| 148 |
+
} ${disabled || isSubmitting ? 'opacity-50 cursor-not-allowed' : ''}`}
|
| 149 |
+
>
|
| 150 |
+
<HelpCircle className="w-4 h-4" />
|
| 151 |
+
Not Sure
|
| 152 |
+
</button>
|
| 153 |
+
</div>
|
| 154 |
+
|
| 155 |
+
{/* Incorrect state - show correction input */}
|
| 156 |
+
{feedbackState === 'incorrect' && (
|
| 157 |
+
<div className="space-y-3 border-t pt-3">
|
| 158 |
+
<div>
|
| 159 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 160 |
+
Correct GA (weeks, e.g. 32.5):
|
| 161 |
+
</label>
|
| 162 |
+
<input
|
| 163 |
+
type="text"
|
| 164 |
+
value={correctGA}
|
| 165 |
+
onChange={(e) => setCorrectGA(e.target.value)}
|
| 166 |
+
placeholder="e.g. 32.5"
|
| 167 |
+
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-nvidia-green focus:border-transparent"
|
| 168 |
+
/>
|
| 169 |
+
</div>
|
| 170 |
+
|
| 171 |
+
<div>
|
| 172 |
+
<label className="block text-sm font-medium text-slate-700 mb-1">
|
| 173 |
+
Notes (optional):
|
| 174 |
+
</label>
|
| 175 |
+
<textarea
|
| 176 |
+
value={notes}
|
| 177 |
+
onChange={(e) => setNotes(e.target.value)}
|
| 178 |
+
placeholder="Any additional notes..."
|
| 179 |
+
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-nvidia-green focus:border-transparent resize-none"
|
| 180 |
+
rows={2}
|
| 181 |
+
/>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<button
|
| 185 |
+
onClick={handleSubmitIncorrect}
|
| 186 |
+
disabled={isSubmitting}
|
| 187 |
+
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-nvidia-green text-white rounded-lg font-medium hover:bg-nvidia-green-dark transition-colors disabled:opacity-50"
|
| 188 |
+
>
|
| 189 |
+
<Send className="w-4 h-4" />
|
| 190 |
+
Submit Feedback
|
| 191 |
+
</button>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
|
| 195 |
+
{isSubmitting && (
|
| 196 |
+
<div className="text-sm text-slate-500 text-center mt-2">Submitting...</div>
|
| 197 |
+
)}
|
| 198 |
+
</div>
|
| 199 |
+
);
|
| 200 |
+
}
|
frontend/src/lib/ImageContext.tsx
CHANGED
|
@@ -1,13 +1,21 @@
|
|
| 1 |
-
import { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
| 2 |
-
import { ClassificationResult } from './api';
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export interface ImageState {
|
| 5 |
-
// Current image
|
| 6 |
file: File | null;
|
| 7 |
preview: string | null;
|
| 8 |
processedImage: string | null;
|
| 9 |
|
| 10 |
-
// Classification results
|
| 11 |
classificationResults: ClassificationResult[] | null;
|
| 12 |
|
| 13 |
// View (corrected takes priority)
|
|
@@ -16,18 +24,32 @@ export interface ImageState {
|
|
| 16 |
|
| 17 |
// Session ID for feedback
|
| 18 |
sessionId: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
interface ImageContextType extends ImageState {
|
| 22 |
-
//
|
| 23 |
setFile: (file: File | null, preview: string | null) => void;
|
| 24 |
setClassificationResults: (results: ClassificationResult[] | null, processedImage?: string | null) => void;
|
| 25 |
setCorrectedView: (view: string | null) => void;
|
| 26 |
resetState: () => void;
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// Computed
|
| 29 |
-
currentView: string | null;
|
| 30 |
isViewGAEligible: boolean;
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
|
| 33 |
// GA-eligible views (match keys from prompt_fetal_view.json)
|
|
@@ -56,12 +78,15 @@ const initialState: ImageState = {
|
|
| 56 |
predictedView: null,
|
| 57 |
correctedView: null,
|
| 58 |
sessionId: generateSessionId(),
|
|
|
|
|
|
|
| 59 |
};
|
| 60 |
|
| 61 |
const ImageContext = createContext<ImageContextType | null>(null);
|
| 62 |
|
| 63 |
export function ImageProvider({ children }: { children: ReactNode }) {
|
| 64 |
const [state, setState] = useState<ImageState>(initialState);
|
|
|
|
| 65 |
|
| 66 |
const setFile = useCallback((file: File | null, preview: string | null) => {
|
| 67 |
setState(prev => ({
|
|
@@ -95,12 +120,44 @@ export function ImageProvider({ children }: { children: ReactNode }) {
|
|
| 95 |
}, []);
|
| 96 |
|
| 97 |
const resetState = useCallback(() => {
|
|
|
|
| 98 |
setState({
|
| 99 |
...initialState,
|
| 100 |
sessionId: generateSessionId(),
|
| 101 |
});
|
| 102 |
}, []);
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
// Computed: current view (corrected takes priority)
|
| 105 |
const currentView = state.correctedView || state.predictedView;
|
| 106 |
|
|
@@ -115,8 +172,14 @@ export function ImageProvider({ children }: { children: ReactNode }) {
|
|
| 115 |
setClassificationResults,
|
| 116 |
setCorrectedView,
|
| 117 |
resetState,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
currentView,
|
| 119 |
isViewGAEligible,
|
|
|
|
| 120 |
}}
|
| 121 |
>
|
| 122 |
{children}
|
|
@@ -131,3 +194,4 @@ export function useImageContext() {
|
|
| 131 |
}
|
| 132 |
return context;
|
| 133 |
}
|
|
|
|
|
|
| 1 |
+
import { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
|
| 2 |
+
import { ClassificationResult, PreprocessingInfo } from './api';
|
| 3 |
+
|
| 4 |
+
// Type for cached image results (shared with pages)
|
| 5 |
+
export interface CachedImageResult {
|
| 6 |
+
results: ClassificationResult[];
|
| 7 |
+
preprocessingInfo: PreprocessingInfo;
|
| 8 |
+
processedImage: string | null;
|
| 9 |
+
preview: string | null;
|
| 10 |
+
}
|
| 11 |
|
| 12 |
export interface ImageState {
|
| 13 |
+
// Current single image (for single uploads)
|
| 14 |
file: File | null;
|
| 15 |
preview: string | null;
|
| 16 |
processedImage: string | null;
|
| 17 |
|
| 18 |
+
// Classification results for current image
|
| 19 |
classificationResults: ClassificationResult[] | null;
|
| 20 |
|
| 21 |
// View (corrected takes priority)
|
|
|
|
| 24 |
|
| 25 |
// Session ID for feedback
|
| 26 |
sessionId: string;
|
| 27 |
+
|
| 28 |
+
// Multi-file state (persists across tabs)
|
| 29 |
+
files: File[];
|
| 30 |
+
currentIndex: number;
|
| 31 |
}
|
| 32 |
|
| 33 |
interface ImageContextType extends ImageState {
|
| 34 |
+
// Single image actions
|
| 35 |
setFile: (file: File | null, preview: string | null) => void;
|
| 36 |
setClassificationResults: (results: ClassificationResult[] | null, processedImage?: string | null) => void;
|
| 37 |
setCorrectedView: (view: string | null) => void;
|
| 38 |
resetState: () => void;
|
| 39 |
|
| 40 |
+
// Multi-file actions
|
| 41 |
+
setFiles: (files: File[], startIndex?: number) => void;
|
| 42 |
+
setCurrentIndex: (index: number) => void;
|
| 43 |
+
setCachedResult: (index: number, result: CachedImageResult) => void;
|
| 44 |
+
getCachedResult: (index: number) => CachedImageResult | undefined;
|
| 45 |
+
clearCache: () => void;
|
| 46 |
+
|
| 47 |
// Computed
|
| 48 |
+
currentView: string | null;
|
| 49 |
isViewGAEligible: boolean;
|
| 50 |
+
|
| 51 |
+
// Cache ref (for direct access)
|
| 52 |
+
resultsCache: React.MutableRefObject<Map<number, CachedImageResult>>;
|
| 53 |
}
|
| 54 |
|
| 55 |
// GA-eligible views (match keys from prompt_fetal_view.json)
|
|
|
|
| 78 |
predictedView: null,
|
| 79 |
correctedView: null,
|
| 80 |
sessionId: generateSessionId(),
|
| 81 |
+
files: [],
|
| 82 |
+
currentIndex: 0,
|
| 83 |
};
|
| 84 |
|
| 85 |
const ImageContext = createContext<ImageContextType | null>(null);
|
| 86 |
|
| 87 |
export function ImageProvider({ children }: { children: ReactNode }) {
|
| 88 |
const [state, setState] = useState<ImageState>(initialState);
|
| 89 |
+
const resultsCache = useRef<Map<number, CachedImageResult>>(new Map());
|
| 90 |
|
| 91 |
const setFile = useCallback((file: File | null, preview: string | null) => {
|
| 92 |
setState(prev => ({
|
|
|
|
| 120 |
}, []);
|
| 121 |
|
| 122 |
const resetState = useCallback(() => {
|
| 123 |
+
resultsCache.current.clear();
|
| 124 |
setState({
|
| 125 |
...initialState,
|
| 126 |
sessionId: generateSessionId(),
|
| 127 |
});
|
| 128 |
}, []);
|
| 129 |
|
| 130 |
+
// Multi-file actions
|
| 131 |
+
const setFiles = useCallback((files: File[], startIndex: number = 0) => {
|
| 132 |
+
resultsCache.current.clear(); // Clear cache when new folder is loaded
|
| 133 |
+
setState(prev => ({
|
| 134 |
+
...prev,
|
| 135 |
+
files,
|
| 136 |
+
currentIndex: startIndex,
|
| 137 |
+
file: files.length > 0 ? files[startIndex] : null,
|
| 138 |
+
}));
|
| 139 |
+
}, []);
|
| 140 |
+
|
| 141 |
+
const setCurrentIndex = useCallback((index: number) => {
|
| 142 |
+
setState(prev => ({
|
| 143 |
+
...prev,
|
| 144 |
+
currentIndex: index,
|
| 145 |
+
file: prev.files.length > index ? prev.files[index] : prev.file,
|
| 146 |
+
}));
|
| 147 |
+
}, []);
|
| 148 |
+
|
| 149 |
+
const setCachedResult = useCallback((index: number, result: CachedImageResult) => {
|
| 150 |
+
resultsCache.current.set(index, result);
|
| 151 |
+
}, []);
|
| 152 |
+
|
| 153 |
+
const getCachedResult = useCallback((index: number): CachedImageResult | undefined => {
|
| 154 |
+
return resultsCache.current.get(index);
|
| 155 |
+
}, []);
|
| 156 |
+
|
| 157 |
+
const clearCache = useCallback(() => {
|
| 158 |
+
resultsCache.current.clear();
|
| 159 |
+
}, []);
|
| 160 |
+
|
| 161 |
// Computed: current view (corrected takes priority)
|
| 162 |
const currentView = state.correctedView || state.predictedView;
|
| 163 |
|
|
|
|
| 172 |
setClassificationResults,
|
| 173 |
setCorrectedView,
|
| 174 |
resetState,
|
| 175 |
+
setFiles,
|
| 176 |
+
setCurrentIndex,
|
| 177 |
+
setCachedResult,
|
| 178 |
+
getCachedResult,
|
| 179 |
+
clearCache,
|
| 180 |
currentView,
|
| 181 |
isViewGAEligible,
|
| 182 |
+
resultsCache,
|
| 183 |
}}
|
| 184 |
>
|
| 185 |
{children}
|
|
|
|
| 194 |
}
|
| 195 |
return context;
|
| 196 |
}
|
| 197 |
+
|
frontend/src/lib/api.ts
CHANGED
|
@@ -155,6 +155,14 @@ export interface FeedbackEntry {
|
|
| 155 |
is_correct: boolean | null;
|
| 156 |
correct_label: string | null;
|
| 157 |
reviewer_notes: string | null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
}
|
| 159 |
|
| 160 |
export interface SessionInfo {
|
|
@@ -188,6 +196,14 @@ export interface FeedbackCreate {
|
|
| 188 |
patient_id?: string;
|
| 189 |
image_hash?: string;
|
| 190 |
preprocessed_image_base64?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
}
|
| 192 |
|
| 193 |
// Create a new session
|
|
@@ -269,7 +285,7 @@ export async function getFeedbackStats(sessionId?: string): Promise<FeedbackStat
|
|
| 269 |
return response.json();
|
| 270 |
}
|
| 271 |
|
| 272 |
-
// Export feedback as CSV
|
| 273 |
export async function exportFeedbackCSV(sessionId?: string): Promise<void> {
|
| 274 |
const url = sessionId
|
| 275 |
? `${API_BASE}/api/v1/feedback/export/csv?session_id=${sessionId}`
|
|
@@ -282,10 +298,38 @@ export async function exportFeedbackCSV(sessionId?: string): Promise<void> {
|
|
| 282 |
}
|
| 283 |
|
| 284 |
const blob = await response.blob();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 285 |
const downloadUrl = window.URL.createObjectURL(blob);
|
| 286 |
const a = document.createElement('a');
|
| 287 |
a.href = downloadUrl;
|
| 288 |
-
a.download =
|
| 289 |
document.body.appendChild(a);
|
| 290 |
a.click();
|
| 291 |
a.remove();
|
|
|
|
| 155 |
is_correct: boolean | null;
|
| 156 |
correct_label: string | null;
|
| 157 |
reviewer_notes: string | null;
|
| 158 |
+
// GA-specific fields
|
| 159 |
+
feedback_type?: string;
|
| 160 |
+
estimated_ga_weeks?: number;
|
| 161 |
+
estimated_ga_days?: number;
|
| 162 |
+
view_type?: string;
|
| 163 |
+
biometry_value?: number;
|
| 164 |
+
percentile?: number;
|
| 165 |
+
correct_ga_weeks?: number;
|
| 166 |
}
|
| 167 |
|
| 168 |
export interface SessionInfo {
|
|
|
|
| 196 |
patient_id?: string;
|
| 197 |
image_hash?: string;
|
| 198 |
preprocessed_image_base64?: string;
|
| 199 |
+
// GA-specific fields
|
| 200 |
+
feedback_type?: string; // "classification" or "gestational_age"
|
| 201 |
+
estimated_ga_weeks?: number;
|
| 202 |
+
estimated_ga_days?: number;
|
| 203 |
+
view_type?: string;
|
| 204 |
+
biometry_value?: number;
|
| 205 |
+
percentile?: number;
|
| 206 |
+
correct_ga_weeks?: number;
|
| 207 |
}
|
| 208 |
|
| 209 |
// Create a new session
|
|
|
|
| 285 |
return response.json();
|
| 286 |
}
|
| 287 |
|
| 288 |
+
// Export feedback as CSV with save dialog
|
| 289 |
export async function exportFeedbackCSV(sessionId?: string): Promise<void> {
|
| 290 |
const url = sessionId
|
| 291 |
? `${API_BASE}/api/v1/feedback/export/csv?session_id=${sessionId}`
|
|
|
|
| 298 |
}
|
| 299 |
|
| 300 |
const blob = await response.blob();
|
| 301 |
+
const defaultFilename = `fetalclip_feedback_${new Date().toISOString().slice(0, 10)}.csv`;
|
| 302 |
+
|
| 303 |
+
// Try to use File System Access API for save dialog (Chromium browsers)
|
| 304 |
+
if ('showSaveFilePicker' in window) {
|
| 305 |
+
try {
|
| 306 |
+
const handle = await (window as unknown as { showSaveFilePicker: (options: object) => Promise<FileSystemFileHandle> }).showSaveFilePicker({
|
| 307 |
+
suggestedName: defaultFilename,
|
| 308 |
+
types: [
|
| 309 |
+
{
|
| 310 |
+
description: 'CSV Files',
|
| 311 |
+
accept: { 'text/csv': ['.csv'] },
|
| 312 |
+
},
|
| 313 |
+
],
|
| 314 |
+
});
|
| 315 |
+
const writable = await handle.createWritable();
|
| 316 |
+
await writable.write(blob);
|
| 317 |
+
await writable.close();
|
| 318 |
+
return;
|
| 319 |
+
} catch (err) {
|
| 320 |
+
// User cancelled the save dialog or API not fully supported
|
| 321 |
+
if ((err as Error).name === 'AbortError') {
|
| 322 |
+
return; // User cancelled, don't fallback
|
| 323 |
+
}
|
| 324 |
+
// Fallback to traditional download for other errors
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
// Fallback: Traditional download for unsupported browsers
|
| 329 |
const downloadUrl = window.URL.createObjectURL(blob);
|
| 330 |
const a = document.createElement('a');
|
| 331 |
a.href = downloadUrl;
|
| 332 |
+
a.download = defaultFilename;
|
| 333 |
document.body.appendChild(a);
|
| 334 |
a.click();
|
| 335 |
a.remove();
|
frontend/src/pages/ClassificationPage.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import { useState, useCallback, useEffect } from 'react';
|
| 2 |
-
import { Search, ChevronLeft, ChevronRight, FolderOpen, Upload, AlertTriangle } from 'lucide-react';
|
| 3 |
import { Panel } from '../components/Panel';
|
| 4 |
import { FileUpload } from '../components/FileUpload';
|
| 5 |
import { ResultsCard } from '../components/ResultsCard';
|
|
@@ -14,6 +14,7 @@ import {
|
|
| 14 |
isDicomFile,
|
| 15 |
createSession,
|
| 16 |
recordImageAnalyzed,
|
|
|
|
| 17 |
type ClassificationResult,
|
| 18 |
type PreprocessingInfo
|
| 19 |
} from '../lib/api';
|
|
@@ -25,20 +26,19 @@ interface ClassificationPageProps {
|
|
| 25 |
export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps) {
|
| 26 |
const imageContext = useImageContext();
|
| 27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
// Session state
|
| 29 |
const [sessionId, setSessionId] = useState<string>('');
|
| 30 |
const [feedbackRefresh, setFeedbackRefresh] = useState(0);
|
| 31 |
|
| 32 |
-
//
|
| 33 |
-
const [file, setFile] = useState<File | null>(null);
|
| 34 |
const [preview, setPreview] = useState<string | null>(null);
|
| 35 |
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
| 36 |
|
| 37 |
-
//
|
| 38 |
-
const [files, setFiles] = useState<File[]>([]);
|
| 39 |
-
const [currentIndex, setCurrentIndex] = useState(0);
|
| 40 |
-
|
| 41 |
-
// Settings & results
|
| 42 |
const [topK, setTopK] = useState(5);
|
| 43 |
const [results, setResults] = useState<ClassificationResult[] | null>(null);
|
| 44 |
const [preprocessingInfo, setPreprocessingInfo] = useState<PreprocessingInfo | null>(null);
|
|
@@ -46,9 +46,35 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 46 |
const [isLoading, setIsLoading] = useState(false);
|
| 47 |
const [error, setError] = useState<string | null>(null);
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
// Image view tab
|
| 50 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
// Initialize session
|
| 53 |
useEffect(() => {
|
| 54 |
const initSession = async () => {
|
|
@@ -83,16 +109,15 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 83 |
}, []);
|
| 84 |
|
| 85 |
const handleSingleUpload = useCallback((uploadedFile: File) => {
|
| 86 |
-
|
| 87 |
-
setFiles([]);
|
| 88 |
-
setCurrentIndex(0);
|
| 89 |
setResults(null);
|
| 90 |
setPreprocessingInfo(null);
|
| 91 |
setProcessedImage(null);
|
| 92 |
setError(null);
|
| 93 |
setImageTab('input');
|
| 94 |
loadPreview(uploadedFile);
|
| 95 |
-
}, [loadPreview]);
|
| 96 |
|
| 97 |
const handleFolderUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 98 |
const fileList = e.target.files;
|
|
@@ -103,9 +128,7 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 103 |
).sort((a, b) => a.name.localeCompare(b.name));
|
| 104 |
|
| 105 |
if (validFiles.length > 0) {
|
| 106 |
-
setFiles(validFiles);
|
| 107 |
-
setCurrentIndex(0);
|
| 108 |
-
setFile(validFiles[0]);
|
| 109 |
setResults(null);
|
| 110 |
setPreprocessingInfo(null);
|
| 111 |
setProcessedImage(null);
|
|
@@ -113,7 +136,7 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 113 |
setImageTab('input');
|
| 114 |
loadPreview(validFiles[0]);
|
| 115 |
}
|
| 116 |
-
}, [loadPreview]);
|
| 117 |
|
| 118 |
const navigateImage = useCallback((direction: 'prev' | 'next') => {
|
| 119 |
if (files.length === 0) return;
|
|
@@ -126,15 +149,37 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 126 |
}
|
| 127 |
|
| 128 |
if (newIndex !== currentIndex) {
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
}
|
| 137 |
-
}, [files, currentIndex, loadPreview]);
|
| 138 |
|
| 139 |
const handleClassify = async () => {
|
| 140 |
if (!file) return;
|
|
@@ -156,7 +201,14 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 156 |
}
|
| 157 |
setImageTab('processed');
|
| 158 |
|
| 159 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
imageContext.setClassificationResults(response.predictions, processedImageData);
|
| 161 |
|
| 162 |
if (sessionId) {
|
|
@@ -173,9 +225,81 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 173 |
|
| 174 |
const handleFeedbackSubmitted = () => {
|
| 175 |
setFeedbackRefresh(prev => prev + 1);
|
|
|
|
| 176 |
onFeedbackUpdate?.();
|
| 177 |
};
|
| 178 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
const fileType = file ? getFileType(file.name) : null;
|
| 180 |
const displayImage = imageTab === 'processed' && processedImage ? processedImage : preview;
|
| 181 |
|
|
@@ -294,6 +418,17 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 294 |
/>
|
| 295 |
</label>
|
| 296 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
<div className="w-px h-6 bg-slate-600" />
|
| 298 |
|
| 299 |
{/* Top-K selector */}
|
|
@@ -313,21 +448,41 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 313 |
<div className="flex-1" />
|
| 314 |
|
| 315 |
{/* Classify Button */}
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 331 |
</div>
|
| 332 |
|
| 333 |
{/* Error row */}
|
|
@@ -358,6 +513,7 @@ export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps
|
|
| 358 |
{/* Feedback Section */}
|
| 359 |
{results && results.length > 0 && file && (
|
| 360 |
<FeedbackSection
|
|
|
|
| 361 |
sessionId={sessionId}
|
| 362 |
filename={file.name}
|
| 363 |
fileType={fileType || 'image'}
|
|
|
|
| 1 |
import { useState, useCallback, useEffect } from 'react';
|
| 2 |
+
import { Search, ChevronLeft, ChevronRight, FolderOpen, Upload, AlertTriangle, Download } from 'lucide-react';
|
| 3 |
import { Panel } from '../components/Panel';
|
| 4 |
import { FileUpload } from '../components/FileUpload';
|
| 5 |
import { ResultsCard } from '../components/ResultsCard';
|
|
|
|
| 14 |
isDicomFile,
|
| 15 |
createSession,
|
| 16 |
recordImageAnalyzed,
|
| 17 |
+
exportFeedbackCSV,
|
| 18 |
type ClassificationResult,
|
| 19 |
type PreprocessingInfo
|
| 20 |
} from '../lib/api';
|
|
|
|
| 26 |
export function ClassificationPage({ onFeedbackUpdate }: ClassificationPageProps) {
|
| 27 |
const imageContext = useImageContext();
|
| 28 |
|
| 29 |
+
// Use shared state from context
|
| 30 |
+
const { files, currentIndex, resultsCache } = imageContext;
|
| 31 |
+
const file = files.length > 0 ? files[currentIndex] : imageContext.file;
|
| 32 |
+
|
| 33 |
// Session state
|
| 34 |
const [sessionId, setSessionId] = useState<string>('');
|
| 35 |
const [feedbackRefresh, setFeedbackRefresh] = useState(0);
|
| 36 |
|
| 37 |
+
// Local preview state (derived from file)
|
|
|
|
| 38 |
const [preview, setPreview] = useState<string | null>(null);
|
| 39 |
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
| 40 |
|
| 41 |
+
// Settings & results (local, but synced with cache)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
const [topK, setTopK] = useState(5);
|
| 43 |
const [results, setResults] = useState<ClassificationResult[] | null>(null);
|
| 44 |
const [preprocessingInfo, setPreprocessingInfo] = useState<PreprocessingInfo | null>(null);
|
|
|
|
| 46 |
const [isLoading, setIsLoading] = useState(false);
|
| 47 |
const [error, setError] = useState<string | null>(null);
|
| 48 |
|
| 49 |
+
// Export button visibility (tracks if any feedback exists)
|
| 50 |
+
const [hasFeedback, setHasFeedback] = useState(false);
|
| 51 |
+
|
| 52 |
+
// Batch processing state
|
| 53 |
+
const [isBatchProcessing, setIsBatchProcessing] = useState(false);
|
| 54 |
+
const [batchProgress, setBatchProgress] = useState({ current: 0, total: 0 });
|
| 55 |
+
|
| 56 |
// Image view tab
|
| 57 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 58 |
|
| 59 |
+
// Load cached results when mounting or when currentIndex changes
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
const cached = resultsCache.current.get(currentIndex);
|
| 62 |
+
if (cached) {
|
| 63 |
+
setResults(cached.results);
|
| 64 |
+
setPreprocessingInfo(cached.preprocessingInfo);
|
| 65 |
+
setProcessedImage(cached.processedImage);
|
| 66 |
+
setPreview(cached.preview);
|
| 67 |
+
setImageTab(cached.processedImage ? 'processed' : 'input');
|
| 68 |
+
} else if (file) {
|
| 69 |
+
// No cache, load preview for current file
|
| 70 |
+
setResults(null);
|
| 71 |
+
setPreprocessingInfo(null);
|
| 72 |
+
setProcessedImage(null);
|
| 73 |
+
setImageTab('input');
|
| 74 |
+
loadPreview(file);
|
| 75 |
+
}
|
| 76 |
+
}, [currentIndex, file]);
|
| 77 |
+
|
| 78 |
// Initialize session
|
| 79 |
useEffect(() => {
|
| 80 |
const initSession = async () => {
|
|
|
|
| 109 |
}, []);
|
| 110 |
|
| 111 |
const handleSingleUpload = useCallback((uploadedFile: File) => {
|
| 112 |
+
// For single file, clear folder data and set single file in context
|
| 113 |
+
imageContext.setFiles([uploadedFile], 0);
|
|
|
|
| 114 |
setResults(null);
|
| 115 |
setPreprocessingInfo(null);
|
| 116 |
setProcessedImage(null);
|
| 117 |
setError(null);
|
| 118 |
setImageTab('input');
|
| 119 |
loadPreview(uploadedFile);
|
| 120 |
+
}, [loadPreview, imageContext]);
|
| 121 |
|
| 122 |
const handleFolderUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 123 |
const fileList = e.target.files;
|
|
|
|
| 128 |
).sort((a, b) => a.name.localeCompare(b.name));
|
| 129 |
|
| 130 |
if (validFiles.length > 0) {
|
| 131 |
+
imageContext.setFiles(validFiles, 0);
|
|
|
|
|
|
|
| 132 |
setResults(null);
|
| 133 |
setPreprocessingInfo(null);
|
| 134 |
setProcessedImage(null);
|
|
|
|
| 136 |
setImageTab('input');
|
| 137 |
loadPreview(validFiles[0]);
|
| 138 |
}
|
| 139 |
+
}, [loadPreview, imageContext]);
|
| 140 |
|
| 141 |
const navigateImage = useCallback((direction: 'prev' | 'next') => {
|
| 142 |
if (files.length === 0) return;
|
|
|
|
| 149 |
}
|
| 150 |
|
| 151 |
if (newIndex !== currentIndex) {
|
| 152 |
+
// Save current results to cache before navigating
|
| 153 |
+
if (results && preprocessingInfo) {
|
| 154 |
+
imageContext.setCachedResult(currentIndex, {
|
| 155 |
+
results,
|
| 156 |
+
preprocessingInfo,
|
| 157 |
+
processedImage,
|
| 158 |
+
preview
|
| 159 |
+
});
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
imageContext.setCurrentIndex(newIndex);
|
| 163 |
+
|
| 164 |
+
// Check if we have cached results for the new index
|
| 165 |
+
const cached = resultsCache.current.get(newIndex);
|
| 166 |
+
if (cached) {
|
| 167 |
+
// Restore from cache
|
| 168 |
+
setResults(cached.results);
|
| 169 |
+
setPreprocessingInfo(cached.preprocessingInfo);
|
| 170 |
+
setProcessedImage(cached.processedImage);
|
| 171 |
+
setPreview(cached.preview);
|
| 172 |
+
setImageTab(cached.processedImage ? 'processed' : 'input');
|
| 173 |
+
} else {
|
| 174 |
+
// No cache, reset results
|
| 175 |
+
setResults(null);
|
| 176 |
+
setPreprocessingInfo(null);
|
| 177 |
+
setProcessedImage(null);
|
| 178 |
+
setImageTab('input');
|
| 179 |
+
loadPreview(files[newIndex]);
|
| 180 |
+
}
|
| 181 |
}
|
| 182 |
+
}, [files, currentIndex, loadPreview, results, preprocessingInfo, processedImage, preview, imageContext, resultsCache]);
|
| 183 |
|
| 184 |
const handleClassify = async () => {
|
| 185 |
if (!file) return;
|
|
|
|
| 201 |
}
|
| 202 |
setImageTab('processed');
|
| 203 |
|
| 204 |
+
// Cache results in context
|
| 205 |
+
imageContext.setCachedResult(currentIndex, {
|
| 206 |
+
results: response.predictions,
|
| 207 |
+
preprocessingInfo: response.preprocessing,
|
| 208 |
+
processedImage: processedImageData,
|
| 209 |
+
preview
|
| 210 |
+
});
|
| 211 |
+
|
| 212 |
imageContext.setClassificationResults(response.predictions, processedImageData);
|
| 213 |
|
| 214 |
if (sessionId) {
|
|
|
|
| 225 |
|
| 226 |
const handleFeedbackSubmitted = () => {
|
| 227 |
setFeedbackRefresh(prev => prev + 1);
|
| 228 |
+
setHasFeedback(true);
|
| 229 |
onFeedbackUpdate?.();
|
| 230 |
};
|
| 231 |
|
| 232 |
+
const handleExport = async () => {
|
| 233 |
+
try {
|
| 234 |
+
await exportFeedbackCSV(sessionId);
|
| 235 |
+
} catch (error) {
|
| 236 |
+
console.error('Failed to export:', error);
|
| 237 |
+
}
|
| 238 |
+
};
|
| 239 |
+
|
| 240 |
+
const handleClassifyAll = async () => {
|
| 241 |
+
if (files.length === 0) return;
|
| 242 |
+
|
| 243 |
+
setIsBatchProcessing(true);
|
| 244 |
+
setBatchProgress({ current: 0, total: files.length });
|
| 245 |
+
setError(null);
|
| 246 |
+
|
| 247 |
+
for (let i = 0; i < files.length; i++) {
|
| 248 |
+
setBatchProgress({ current: i + 1, total: files.length });
|
| 249 |
+
|
| 250 |
+
// Skip if already cached
|
| 251 |
+
if (resultsCache.current.has(i)) continue;
|
| 252 |
+
|
| 253 |
+
try {
|
| 254 |
+
// Load preview first
|
| 255 |
+
let currentPreview: string | null = null;
|
| 256 |
+
if (!isDicomFile(files[i].name)) {
|
| 257 |
+
currentPreview = URL.createObjectURL(files[i]);
|
| 258 |
+
} else {
|
| 259 |
+
const previewResponse = await getFilePreview(files[i]);
|
| 260 |
+
if (previewResponse.success) {
|
| 261 |
+
currentPreview = `data:image/png;base64,${previewResponse.preview}`;
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
// Classify
|
| 266 |
+
const response = await classifyImage(files[i], topK);
|
| 267 |
+
const processedImageData = response.preprocessing.processed_image_base64
|
| 268 |
+
? `data:image/png;base64,${response.preprocessing.processed_image_base64}`
|
| 269 |
+
: null;
|
| 270 |
+
|
| 271 |
+
// Store in cache using context
|
| 272 |
+
imageContext.setCachedResult(i, {
|
| 273 |
+
results: response.predictions,
|
| 274 |
+
preprocessingInfo: response.preprocessing,
|
| 275 |
+
processedImage: processedImageData,
|
| 276 |
+
preview: currentPreview
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
// Track analyzed image
|
| 280 |
+
if (sessionId) {
|
| 281 |
+
await recordImageAnalyzed(sessionId);
|
| 282 |
+
}
|
| 283 |
+
} catch (err) {
|
| 284 |
+
console.error(`Failed to classify ${files[i].name}:`, err);
|
| 285 |
+
}
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
setIsBatchProcessing(false);
|
| 289 |
+
|
| 290 |
+
// Load results for current image from cache
|
| 291 |
+
const cached = resultsCache.current.get(currentIndex);
|
| 292 |
+
if (cached) {
|
| 293 |
+
setResults(cached.results);
|
| 294 |
+
setPreprocessingInfo(cached.preprocessingInfo);
|
| 295 |
+
setProcessedImage(cached.processedImage);
|
| 296 |
+
setPreview(cached.preview);
|
| 297 |
+
setImageTab(cached.processedImage ? 'processed' : 'input');
|
| 298 |
+
imageContext.setFile(file, cached.preview);
|
| 299 |
+
imageContext.setClassificationResults(cached.results, cached.processedImage);
|
| 300 |
+
}
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
const fileType = file ? getFileType(file.name) : null;
|
| 304 |
const displayImage = imageTab === 'processed' && processedImage ? processedImage : preview;
|
| 305 |
|
|
|
|
| 418 |
/>
|
| 419 |
</label>
|
| 420 |
|
| 421 |
+
{/* Export button - only visible when feedback exists */}
|
| 422 |
+
{hasFeedback && (
|
| 423 |
+
<button
|
| 424 |
+
onClick={handleExport}
|
| 425 |
+
className="flex items-center gap-1.5 px-3 py-1.5 bg-nvidia-green/20 hover:bg-nvidia-green/30 text-nvidia-green rounded-lg transition-colors"
|
| 426 |
+
>
|
| 427 |
+
<Download className="w-3.5 h-3.5" />
|
| 428 |
+
<span className="text-xs font-medium">Export</span>
|
| 429 |
+
</button>
|
| 430 |
+
)}
|
| 431 |
+
|
| 432 |
<div className="w-px h-6 bg-slate-600" />
|
| 433 |
|
| 434 |
{/* Top-K selector */}
|
|
|
|
| 448 |
<div className="flex-1" />
|
| 449 |
|
| 450 |
{/* Classify Button */}
|
| 451 |
+
{isBatchProcessing ? (
|
| 452 |
+
<div className="flex items-center gap-2 px-4 py-1.5 bg-nvidia-green/20 text-nvidia-green rounded-lg">
|
| 453 |
+
<div className="w-4 h-4 border-2 border-nvidia-green/30 border-t-nvidia-green rounded-full animate-spin" />
|
| 454 |
+
<span className="text-sm font-medium">Processing {batchProgress.current}/{batchProgress.total}...</span>
|
| 455 |
+
</div>
|
| 456 |
+
) : (
|
| 457 |
+
<>
|
| 458 |
+
<button
|
| 459 |
+
onClick={handleClassify}
|
| 460 |
+
disabled={!file || isLoading}
|
| 461 |
+
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm font-semibold transition-all ${!file
|
| 462 |
+
? 'bg-slate-600 text-slate-400 cursor-not-allowed'
|
| 463 |
+
: 'bg-nvidia-green text-white hover:bg-nvidia-green-hover shadow-lg'
|
| 464 |
+
}`}
|
| 465 |
+
>
|
| 466 |
+
{isLoading ? (
|
| 467 |
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 468 |
+
) : (
|
| 469 |
+
<Search className="w-4 h-4" />
|
| 470 |
+
)}
|
| 471 |
+
Classify
|
| 472 |
+
</button>
|
| 473 |
+
|
| 474 |
+
{/* Classify All button - only visible with multiple files */}
|
| 475 |
+
{files.length > 1 && (
|
| 476 |
+
<button
|
| 477 |
+
onClick={handleClassifyAll}
|
| 478 |
+
disabled={isLoading}
|
| 479 |
+
className="flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm font-semibold transition-all bg-slate-700 text-white hover:bg-slate-600"
|
| 480 |
+
>
|
| 481 |
+
Classify All
|
| 482 |
+
</button>
|
| 483 |
+
)}
|
| 484 |
+
</>
|
| 485 |
+
)}
|
| 486 |
</div>
|
| 487 |
|
| 488 |
{/* Error row */}
|
|
|
|
| 513 |
{/* Feedback Section */}
|
| 514 |
{results && results.length > 0 && file && (
|
| 515 |
<FeedbackSection
|
| 516 |
+
key={`${file.name}-${currentIndex}`}
|
| 517 |
sessionId={sessionId}
|
| 518 |
filename={file.name}
|
| 519 |
fileType={fileType || 'image'}
|
frontend/src/pages/GestationalAgePage.tsx
CHANGED
|
@@ -4,12 +4,14 @@ import { Panel } from '../components/Panel';
|
|
| 4 |
import { FileUpload } from '../components/FileUpload';
|
| 5 |
import { GAResultsCard } from '../components/GAResultsCard';
|
| 6 |
import { PreprocessingBadge } from '../components/PreprocessingBadge';
|
|
|
|
| 7 |
import { useImageContext, GA_ELIGIBLE_VIEWS, GA_BIOMETRY_LABELS } from '../lib/ImageContext';
|
| 8 |
import {
|
| 9 |
estimateGestationalAge,
|
| 10 |
getFilePreview,
|
| 11 |
getFileType,
|
| 12 |
isDicomFile,
|
|
|
|
| 13 |
FETAL_VIEW_LABELS,
|
| 14 |
type GestationalAgeResponse,
|
| 15 |
type PreprocessingInfo
|
|
@@ -19,15 +21,21 @@ export function GestationalAgePage() {
|
|
| 19 |
// Shared context
|
| 20 |
const imageContext = useImageContext();
|
| 21 |
|
| 22 |
-
//
|
| 23 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
const [preview, setPreview] = useState<string | null>(null);
|
| 25 |
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
| 26 |
|
| 27 |
-
// Multiple files state (folder)
|
| 28 |
-
const [files, setFiles] = useState<File[]>([]);
|
| 29 |
-
const [currentIndex, setCurrentIndex] = useState(0);
|
| 30 |
-
|
| 31 |
// View selection state
|
| 32 |
const [selectedView, setSelectedView] = useState<string>('');
|
| 33 |
const [viewSource, setViewSource] = useState<'classification' | 'corrected' | 'manual' | null>(null);
|
|
@@ -43,27 +51,48 @@ export function GestationalAgePage() {
|
|
| 43 |
// Image view tab
|
| 44 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 45 |
|
| 46 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
useEffect(() => {
|
| 48 |
-
if (
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
setResults(null);
|
| 55 |
setPreprocessingInfo(null);
|
| 56 |
setError(null);
|
| 57 |
-
|
| 58 |
-
if (imageContext.correctedView) {
|
| 59 |
-
setSelectedView(imageContext.correctedView);
|
| 60 |
-
setViewSource('corrected');
|
| 61 |
-
} else if (imageContext.predictedView) {
|
| 62 |
-
setSelectedView(imageContext.predictedView);
|
| 63 |
-
setViewSource('classification');
|
| 64 |
-
}
|
| 65 |
}
|
| 66 |
-
}, [
|
| 67 |
|
| 68 |
const isViewEligible = selectedView ? GA_ELIGIBLE_VIEWS.includes(selectedView) : false;
|
| 69 |
|
|
@@ -88,9 +117,9 @@ export function GestationalAgePage() {
|
|
| 88 |
}, []);
|
| 89 |
|
| 90 |
const handleSingleUpload = useCallback((uploadedFile: File) => {
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
setResults(null);
|
| 95 |
setPreprocessingInfo(null);
|
| 96 |
setProcessedImage(null);
|
|
@@ -111,9 +140,9 @@ export function GestationalAgePage() {
|
|
| 111 |
).sort((a, b) => a.name.localeCompare(b.name));
|
| 112 |
|
| 113 |
if (validFiles.length > 0) {
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
setResults(null);
|
| 118 |
setPreprocessingInfo(null);
|
| 119 |
setProcessedImage(null);
|
|
@@ -136,17 +165,35 @@ export function GestationalAgePage() {
|
|
| 136 |
}
|
| 137 |
|
| 138 |
if (newIndex !== currentIndex) {
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
setResults(null);
|
| 142 |
setPreprocessingInfo(null);
|
| 143 |
-
setProcessedImage(null);
|
| 144 |
setImageTab('input');
|
| 145 |
-
setSelectedView('');
|
| 146 |
-
setViewSource(null);
|
| 147 |
-
loadPreview(files[newIndex]);
|
| 148 |
}
|
| 149 |
-
}, [files, currentIndex, loadPreview]);
|
| 150 |
|
| 151 |
const handleViewChange = (view: string) => {
|
| 152 |
setSelectedView(view);
|
|
@@ -418,6 +465,21 @@ export function GestationalAgePage() {
|
|
| 418 |
</div>
|
| 419 |
)}
|
| 420 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
{/* Preprocessing Badge - same style as Classification */}
|
| 422 |
{preprocessingInfo && (
|
| 423 |
<div className="mt-4">
|
|
|
|
| 4 |
import { FileUpload } from '../components/FileUpload';
|
| 5 |
import { GAResultsCard } from '../components/GAResultsCard';
|
| 6 |
import { PreprocessingBadge } from '../components/PreprocessingBadge';
|
| 7 |
+
import { GAFeedbackSection } from '../components/GAFeedbackSection';
|
| 8 |
import { useImageContext, GA_ELIGIBLE_VIEWS, GA_BIOMETRY_LABELS } from '../lib/ImageContext';
|
| 9 |
import {
|
| 10 |
estimateGestationalAge,
|
| 11 |
getFilePreview,
|
| 12 |
getFileType,
|
| 13 |
isDicomFile,
|
| 14 |
+
createSession,
|
| 15 |
FETAL_VIEW_LABELS,
|
| 16 |
type GestationalAgeResponse,
|
| 17 |
type PreprocessingInfo
|
|
|
|
| 21 |
// Shared context
|
| 22 |
const imageContext = useImageContext();
|
| 23 |
|
| 24 |
+
// Use shared files from context (persisted from Classification tab)
|
| 25 |
+
const { files: contextFiles, currentIndex: contextIndex, resultsCache } = imageContext;
|
| 26 |
+
|
| 27 |
+
// Local file state (for this tab's independent uploads, falls back to context)
|
| 28 |
+
const [localFiles, setLocalFiles] = useState<File[]>([]);
|
| 29 |
+
const [localIndex, setLocalIndex] = useState(0);
|
| 30 |
+
|
| 31 |
+
// Decide which files to use: local uploads take precedence, then context
|
| 32 |
+
const files = localFiles.length > 0 ? localFiles : contextFiles;
|
| 33 |
+
const currentIndex = localFiles.length > 0 ? localIndex : contextIndex;
|
| 34 |
+
const file = files.length > 0 ? files[currentIndex] : null;
|
| 35 |
+
|
| 36 |
const [preview, setPreview] = useState<string | null>(null);
|
| 37 |
const [isLoadingPreview, setIsLoadingPreview] = useState(false);
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
// View selection state
|
| 40 |
const [selectedView, setSelectedView] = useState<string>('');
|
| 41 |
const [viewSource, setViewSource] = useState<'classification' | 'corrected' | 'manual' | null>(null);
|
|
|
|
| 51 |
// Image view tab
|
| 52 |
const [imageTab, setImageTab] = useState<'input' | 'processed'>('input');
|
| 53 |
|
| 54 |
+
// Session ID for feedback
|
| 55 |
+
const [sessionId, setSessionId] = useState<string>('');
|
| 56 |
+
|
| 57 |
+
// Initialize session
|
| 58 |
+
useEffect(() => {
|
| 59 |
+
const initSession = async () => {
|
| 60 |
+
try {
|
| 61 |
+
const session = await createSession();
|
| 62 |
+
setSessionId(session.session_id);
|
| 63 |
+
} catch (error) {
|
| 64 |
+
console.error('Failed to create session:', error);
|
| 65 |
+
}
|
| 66 |
+
};
|
| 67 |
+
initSession();
|
| 68 |
+
}, []);
|
| 69 |
+
|
| 70 |
+
// Sync with shared context - load classification results and auto-set view
|
| 71 |
useEffect(() => {
|
| 72 |
+
if (files.length > 0 && file) {
|
| 73 |
+
// Check for cached classification results for current image
|
| 74 |
+
const cached = resultsCache.current.get(currentIndex);
|
| 75 |
+
if (cached) {
|
| 76 |
+
setPreview(cached.preview);
|
| 77 |
+
setProcessedImage(cached.processedImage);
|
| 78 |
+
|
| 79 |
+
// Auto-set view from classification
|
| 80 |
+
if (cached.results && cached.results.length > 0) {
|
| 81 |
+
const topLabel = cached.results[0].label;
|
| 82 |
+
setSelectedView(topLabel);
|
| 83 |
+
setViewSource('classification');
|
| 84 |
+
}
|
| 85 |
+
} else {
|
| 86 |
+
// No cache, load preview
|
| 87 |
+
loadPreview(file);
|
| 88 |
+
setSelectedView('');
|
| 89 |
+
setViewSource(null);
|
| 90 |
+
}
|
| 91 |
setResults(null);
|
| 92 |
setPreprocessingInfo(null);
|
| 93 |
setError(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
}
|
| 95 |
+
}, [files, currentIndex, file]);
|
| 96 |
|
| 97 |
const isViewEligible = selectedView ? GA_ELIGIBLE_VIEWS.includes(selectedView) : false;
|
| 98 |
|
|
|
|
| 117 |
}, []);
|
| 118 |
|
| 119 |
const handleSingleUpload = useCallback((uploadedFile: File) => {
|
| 120 |
+
// Use local state for GA-specific uploads
|
| 121 |
+
setLocalFiles([uploadedFile]);
|
| 122 |
+
setLocalIndex(0);
|
| 123 |
setResults(null);
|
| 124 |
setPreprocessingInfo(null);
|
| 125 |
setProcessedImage(null);
|
|
|
|
| 140 |
).sort((a, b) => a.name.localeCompare(b.name));
|
| 141 |
|
| 142 |
if (validFiles.length > 0) {
|
| 143 |
+
// Use local state for GA-specific uploads
|
| 144 |
+
setLocalFiles(validFiles);
|
| 145 |
+
setLocalIndex(0);
|
| 146 |
setResults(null);
|
| 147 |
setPreprocessingInfo(null);
|
| 148 |
setProcessedImage(null);
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
if (newIndex !== currentIndex) {
|
| 168 |
+
// Update the correct index based on which files we're using
|
| 169 |
+
if (localFiles.length > 0) {
|
| 170 |
+
setLocalIndex(newIndex);
|
| 171 |
+
} else {
|
| 172 |
+
imageContext.setCurrentIndex(newIndex);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Check for cached classification results
|
| 176 |
+
const cached = resultsCache.current.get(newIndex);
|
| 177 |
+
if (cached) {
|
| 178 |
+
setPreview(cached.preview);
|
| 179 |
+
setProcessedImage(cached.processedImage);
|
| 180 |
+
if (cached.results && cached.results.length > 0) {
|
| 181 |
+
setSelectedView(cached.results[0].label);
|
| 182 |
+
setViewSource('classification');
|
| 183 |
+
}
|
| 184 |
+
} else {
|
| 185 |
+
setPreview(null);
|
| 186 |
+
setProcessedImage(null);
|
| 187 |
+
setSelectedView('');
|
| 188 |
+
setViewSource(null);
|
| 189 |
+
loadPreview(files[newIndex]);
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
setResults(null);
|
| 193 |
setPreprocessingInfo(null);
|
|
|
|
| 194 |
setImageTab('input');
|
|
|
|
|
|
|
|
|
|
| 195 |
}
|
| 196 |
+
}, [files, currentIndex, loadPreview, localFiles.length, imageContext, resultsCache]);
|
| 197 |
|
| 198 |
const handleViewChange = (view: string) => {
|
| 199 |
setSelectedView(view);
|
|
|
|
| 465 |
</div>
|
| 466 |
)}
|
| 467 |
|
| 468 |
+
{/* Feedback Section - appears after estimation */}
|
| 469 |
+
{results && selectedView && file && sessionId && (
|
| 470 |
+
<div className="mt-4">
|
| 471 |
+
<GAFeedbackSection
|
| 472 |
+
key={`${file.name}-${currentIndex}`}
|
| 473 |
+
sessionId={sessionId}
|
| 474 |
+
filename={file.name}
|
| 475 |
+
fileType={fileType || 'image'}
|
| 476 |
+
viewType={selectedView}
|
| 477 |
+
gaResults={results}
|
| 478 |
+
preprocessedImageBase64={preprocessingInfo?.processed_image_base64}
|
| 479 |
+
/>
|
| 480 |
+
</div>
|
| 481 |
+
)}
|
| 482 |
+
|
| 483 |
{/* Preprocessing Badge - same style as Classification */}
|
| 484 |
{preprocessingInfo && (
|
| 485 |
<div className="mt-4">
|
frontend/src/pages/HelpPage.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { HelpCircle,
|
| 2 |
|
| 3 |
export function HelpPage() {
|
| 4 |
return (
|
|
@@ -13,39 +13,75 @@ export function HelpPage() {
|
|
| 13 |
FetalCLIP User Guide
|
| 14 |
</h1>
|
| 15 |
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 16 |
-
A
|
| 17 |
</p>
|
| 18 |
</div>
|
| 19 |
|
| 20 |
-
{/* Quick Start
|
| 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 |
-
<
|
| 25 |
</span>
|
| 26 |
-
|
| 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
|
| 36 |
</Step>
|
| 37 |
|
| 38 |
-
<Step number={3} title="
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
</Step>
|
| 41 |
</div>
|
| 42 |
</div>
|
| 43 |
|
| 44 |
-
{/*
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</span>
|
| 50 |
Feedback Options
|
| 51 |
</h2>
|
|
@@ -54,19 +90,19 @@ export function HelpPage() {
|
|
| 54 |
<FeedbackCard
|
| 55 |
icon={<Check className="w-5 h-5" />}
|
| 56 |
title="Correct"
|
| 57 |
-
description="The prediction matches the actual
|
| 58 |
color="green"
|
| 59 |
/>
|
| 60 |
<FeedbackCard
|
| 61 |
icon={<UnsureIcon className="w-5 h-5" />}
|
| 62 |
title="Not Sure"
|
| 63 |
-
description="You're uncertain
|
| 64 |
color="amber"
|
| 65 |
/>
|
| 66 |
<FeedbackCard
|
| 67 |
icon={<X className="w-5 h-5" />}
|
| 68 |
title="Incorrect"
|
| 69 |
-
description="
|
| 70 |
color="red"
|
| 71 |
/>
|
| 72 |
</div>
|
|
@@ -75,19 +111,50 @@ export function HelpPage() {
|
|
| 75 |
{/* Features Overview */}
|
| 76 |
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
| 77 |
<FeatureCard
|
| 78 |
-
icon={<
|
| 79 |
title="Batch Processing"
|
| 80 |
-
description="Load a folder of images and navigate through them
|
| 81 |
color="purple"
|
| 82 |
/>
|
| 83 |
<FeatureCard
|
| 84 |
icon={<Download className="w-5 h-5 text-blue-600" />}
|
| 85 |
-
title="Export
|
| 86 |
-
description="
|
| 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">
|
|
@@ -97,13 +164,28 @@ export function HelpPage() {
|
|
| 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.
|
| 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">
|
|
|
|
| 1 |
+
import { HelpCircle, Scan, Check, X, HelpCircle as UnsureIcon, Download, Shield, Mail, Baby, BarChart3, FolderOpen } from 'lucide-react';
|
| 2 |
|
| 3 |
export function HelpPage() {
|
| 4 |
return (
|
|
|
|
| 13 |
FetalCLIP User Guide
|
| 14 |
</h1>
|
| 15 |
<p className="text-lg text-gray-600 max-w-2xl mx-auto">
|
| 16 |
+
A comprehensive guide for using the FetalCLIP fetal ultrasound analysis tool.
|
| 17 |
</p>
|
| 18 |
</div>
|
| 19 |
|
| 20 |
+
{/* Quick Start - Classification */}
|
| 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 |
+
<Scan className="w-4 h-4 text-nvidia-green" />
|
| 25 |
</span>
|
| 26 |
+
View Classification
|
| 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 using the <strong>folder icon</strong>.
|
| 32 |
</Step>
|
| 33 |
|
| 34 |
<Step number={2} title="Run Classification">
|
| 35 |
+
Click <strong>"Classify View"</strong> to analyze the image. The model identifies the anatomical view (brain, abdomen, femur, heart, etc.) with confidence scores.
|
| 36 |
</Step>
|
| 37 |
|
| 38 |
+
<Step number={3} title="Batch Classification">
|
| 39 |
+
For folders, use <strong>"Classify All"</strong> to process all images at once. Navigate between images using the arrow buttons.
|
| 40 |
+
</Step>
|
| 41 |
+
|
| 42 |
+
<Step number={4} title="Provide Feedback">
|
| 43 |
+
Mark the prediction as Correct, Incorrect, or Not Sure. Your feedback is saved for quality analysis.
|
| 44 |
</Step>
|
| 45 |
</div>
|
| 46 |
</div>
|
| 47 |
|
| 48 |
+
{/* Gestational Age Estimation */}
|
| 49 |
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-8 mb-8">
|
| 50 |
<h2 className="text-xl font-semibold text-gray-900 mb-6 flex items-center gap-2">
|
| 51 |
<span className="w-8 h-8 rounded-lg bg-blue-100 flex items-center justify-center">
|
| 52 |
+
<Baby className="w-4 h-4 text-blue-600" />
|
| 53 |
+
</span>
|
| 54 |
+
Gestational Age Estimation
|
| 55 |
+
</h2>
|
| 56 |
+
|
| 57 |
+
<div className="space-y-4">
|
| 58 |
+
<Step number={1} title="Select View Type">
|
| 59 |
+
GA estimation works for <strong>brain</strong>, <strong>abdomen</strong>, and <strong>femur</strong> views. The view can be auto-detected from Classification or manually selected.
|
| 60 |
+
</Step>
|
| 61 |
+
|
| 62 |
+
<Step number={2} title="Configure Pixel Spacing">
|
| 63 |
+
For DICOM files, pixel spacing is extracted automatically. For regular images, enter the spacing (mm/pixel) manually for accurate measurements.
|
| 64 |
+
</Step>
|
| 65 |
+
|
| 66 |
+
<Step number={3} title="Run Estimation">
|
| 67 |
+
Click <strong>"Estimate"</strong> to calculate gestational age. Results include weeks+days, biometry measurements (HC/AC/FL), and WHO percentiles.
|
| 68 |
+
</Step>
|
| 69 |
+
|
| 70 |
+
<Step number={4} title="Provide Feedback">
|
| 71 |
+
Mark the estimation as Correct or Incorrect. If incorrect, you can enter the correct GA value for quality tracking.
|
| 72 |
+
</Step>
|
| 73 |
+
</div>
|
| 74 |
+
|
| 75 |
+
<div className="mt-4 p-3 bg-blue-50 rounded-lg text-sm text-blue-800">
|
| 76 |
+
<strong>Tip:</strong> Images from the Classification tab are automatically available in the GA tab. Classification results are used to pre-select the correct view.
|
| 77 |
+
</div>
|
| 78 |
+
</div>
|
| 79 |
+
|
| 80 |
+
{/* Feedback Options */}
|
| 81 |
+
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-8 mb-8">
|
| 82 |
+
<h2 className="text-xl font-semibold text-gray-900 mb-6 flex items-center gap-2">
|
| 83 |
+
<span className="w-8 h-8 rounded-lg bg-purple-100 flex items-center justify-center">
|
| 84 |
+
<BarChart3 className="w-4 h-4 text-purple-600" />
|
| 85 |
</span>
|
| 86 |
Feedback Options
|
| 87 |
</h2>
|
|
|
|
| 90 |
<FeedbackCard
|
| 91 |
icon={<Check className="w-5 h-5" />}
|
| 92 |
title="Correct"
|
| 93 |
+
description="The prediction matches the actual value. Submitted immediately."
|
| 94 |
color="green"
|
| 95 |
/>
|
| 96 |
<FeedbackCard
|
| 97 |
icon={<UnsureIcon className="w-5 h-5" />}
|
| 98 |
title="Not Sure"
|
| 99 |
+
description="You're uncertain. Add optional notes to explain."
|
| 100 |
color="amber"
|
| 101 |
/>
|
| 102 |
<FeedbackCard
|
| 103 |
icon={<X className="w-5 h-5" />}
|
| 104 |
title="Incorrect"
|
| 105 |
+
description="Wrong prediction. Select the correct label or enter correct GA."
|
| 106 |
color="red"
|
| 107 |
/>
|
| 108 |
</div>
|
|
|
|
| 111 |
{/* Features Overview */}
|
| 112 |
<div className="grid md:grid-cols-2 gap-6 mb-8">
|
| 113 |
<FeatureCard
|
| 114 |
+
icon={<FolderOpen className="w-5 h-5 text-purple-600" />}
|
| 115 |
title="Batch Processing"
|
| 116 |
+
description="Load a folder of images and navigate through them. Run 'Classify All' to process all images at once."
|
| 117 |
color="purple"
|
| 118 |
/>
|
| 119 |
<FeatureCard
|
| 120 |
icon={<Download className="w-5 h-5 text-blue-600" />}
|
| 121 |
+
title="Export Results"
|
| 122 |
+
description="Export all feedback to CSV. Includes classification results, GA estimations, biometry, and feedback data."
|
| 123 |
color="blue"
|
| 124 |
/>
|
| 125 |
</div>
|
| 126 |
|
| 127 |
+
{/* Export Data Section */}
|
| 128 |
+
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6 mb-8">
|
| 129 |
+
<h2 className="text-lg font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
| 130 |
+
<Download className="w-5 h-5 text-gray-600" />
|
| 131 |
+
Exported CSV Data
|
| 132 |
+
</h2>
|
| 133 |
+
<p className="text-gray-600 text-sm mb-3">
|
| 134 |
+
The exported CSV includes both classification and GA feedback:
|
| 135 |
+
</p>
|
| 136 |
+
<div className="grid grid-cols-2 gap-3 text-sm">
|
| 137 |
+
<div className="space-y-1">
|
| 138 |
+
<p className="font-medium text-gray-700">Classification Data:</p>
|
| 139 |
+
<ul className="text-gray-500 space-y-0.5 list-disc list-inside">
|
| 140 |
+
<li>Filename, timestamp, session ID</li>
|
| 141 |
+
<li>Predicted view label & confidence</li>
|
| 142 |
+
<li>Feedback (correct/incorrect/not sure)</li>
|
| 143 |
+
<li>Corrected label (if applicable)</li>
|
| 144 |
+
</ul>
|
| 145 |
+
</div>
|
| 146 |
+
<div className="space-y-1">
|
| 147 |
+
<p className="font-medium text-gray-700">GA Estimation Data:</p>
|
| 148 |
+
<ul className="text-gray-500 space-y-0.5 list-disc list-inside">
|
| 149 |
+
<li>Estimated GA (weeks + days)</li>
|
| 150 |
+
<li>View type (brain/abdomen/femur)</li>
|
| 151 |
+
<li>Biometry measurement (mm)</li>
|
| 152 |
+
<li>Correct GA (if provided)</li>
|
| 153 |
+
</ul>
|
| 154 |
+
</div>
|
| 155 |
+
</div>
|
| 156 |
+
</div>
|
| 157 |
+
|
| 158 |
{/* Data Privacy Notice */}
|
| 159 |
<div className="bg-green-50 rounded-2xl border border-green-200 p-6 mb-8">
|
| 160 |
<div className="flex gap-4">
|
|
|
|
| 164 |
<div>
|
| 165 |
<h3 className="font-semibold text-green-900 mb-1">Data Privacy</h3>
|
| 166 |
<p className="text-green-800 text-sm">
|
| 167 |
+
All data is processed locally on your machine. Images and feedback are stored in your home directory
|
| 168 |
(<code className="bg-green-100 px-1 rounded">~/.fetalclip/</code>) and are not uploaded to any external server.
|
| 169 |
</p>
|
| 170 |
</div>
|
| 171 |
</div>
|
| 172 |
</div>
|
| 173 |
|
| 174 |
+
{/* Supported Views */}
|
| 175 |
+
<div className="bg-white rounded-2xl border border-gray-200 shadow-sm p-6 mb-8">
|
| 176 |
+
<h2 className="text-lg font-semibold text-gray-900 mb-3">Supported Anatomical Views</h2>
|
| 177 |
+
<div className="flex flex-wrap gap-2">
|
| 178 |
+
{['abdomen', 'brain', 'femur', 'heart', 'kidney', 'lips_nose', 'profile_patient', 'spine', 'cervix', 'cord', 'diaphragm', 'feet', 'orbit'].map(view => (
|
| 179 |
+
<span key={view} className="px-3 py-1 bg-gray-100 text-gray-700 rounded-full text-sm">
|
| 180 |
+
{view}
|
| 181 |
+
</span>
|
| 182 |
+
))}
|
| 183 |
+
</div>
|
| 184 |
+
<p className="text-gray-500 text-xs mt-3">
|
| 185 |
+
* GA estimation is only available for brain, abdomen, and femur views.
|
| 186 |
+
</p>
|
| 187 |
+
</div>
|
| 188 |
+
|
| 189 |
{/* Contact Section */}
|
| 190 |
<div className="text-center py-6 border-t border-gray-200">
|
| 191 |
<div className="flex items-center justify-center gap-2 text-gray-500 text-sm">
|