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 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' && <ClassificationPage onFeedbackUpdate={loadFeedbackStats} />}
67
- {activeTab === 'gestational-age' && <GestationalAgePage />}
68
- {activeTab === 'help' && <HelpPage />}
 
 
 
 
 
 
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
- // Actions
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; // Returns correctedView if set, otherwise topPrediction
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 = `fetalclip_feedback_${new Date().toISOString().slice(0, 10)}.csv`;
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
- // File state
33
- const [file, setFile] = useState<File | null>(null);
34
  const [preview, setPreview] = useState<string | null>(null);
35
  const [isLoadingPreview, setIsLoadingPreview] = useState(false);
36
 
37
- // Multiple files state
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
- setFile(uploadedFile);
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
- setCurrentIndex(newIndex);
130
- setFile(files[newIndex]);
131
- setResults(null);
132
- setPreprocessingInfo(null);
133
- setProcessedImage(null);
134
- setImageTab('input');
135
- loadPreview(files[newIndex]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- imageContext.setFile(file, preview);
 
 
 
 
 
 
 
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
- <button
317
- onClick={handleClassify}
318
- disabled={!file || isLoading}
319
- className={`flex items-center gap-2 px-4 py-1.5 rounded-lg text-sm font-semibold transition-all ${!file
320
- ? 'bg-slate-600 text-slate-400 cursor-not-allowed'
321
- : 'bg-nvidia-green text-white hover:bg-nvidia-green-hover shadow-lg'
322
- }`}
323
- >
324
- {isLoading ? (
325
- <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
326
- ) : (
327
- <Search className="w-4 h-4" />
328
- )}
329
- Classify
330
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- // File state (local for this tab's independent uploads)
23
- const [file, setFile] = useState<File | null>(null);
 
 
 
 
 
 
 
 
 
 
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
- // Sync with shared context when it has data from Classification tab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  useEffect(() => {
48
- if (imageContext.file && imageContext.classificationResults) {
49
- setFile(imageContext.file);
50
- setPreview(imageContext.preview);
51
- setProcessedImage(imageContext.processedImage);
52
- setFiles([]);
53
- setCurrentIndex(0);
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- }, [imageContext.file, imageContext.classificationResults, imageContext.correctedView, imageContext.predictedView, imageContext.preview, imageContext.processedImage]);
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
- setFile(uploadedFile);
92
- setFiles([]);
93
- setCurrentIndex(0);
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
- setFiles(validFiles);
115
- setCurrentIndex(0);
116
- setFile(validFiles[0]);
117
  setResults(null);
118
  setPreprocessingInfo(null);
119
  setProcessedImage(null);
@@ -136,17 +165,35 @@ export function GestationalAgePage() {
136
  }
137
 
138
  if (newIndex !== currentIndex) {
139
- setCurrentIndex(newIndex);
140
- setFile(files[newIndex]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, Upload, Scan, Check, X, HelpCircle as UnsureIcon, Download, ArrowRight, Shield, Mail } from 'lucide-react';
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 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>
@@ -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 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>
@@ -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={<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">
@@ -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. 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">
 
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">