Numan Saeed commited on
Commit
f84688d
·
1 Parent(s): f66c31e

Add Not Sure feedback, save preprocessed scans, add Help tab

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