Kaifulimaan commited on
Commit
ee5617c
·
1 Parent(s): 3ac72b4

Update explainability with attention bbox, crop, and zone reference; remove GradCAM from UI

Browse files
backend/app/api/explainability.py CHANGED
@@ -26,7 +26,9 @@ class ExplainRequest(BaseModel):
26
 
27
  class ExplainResponse(BaseModel):
28
  attention_heatmap_base64: str
29
- gradcam_heatmap_base64: str
 
 
30
  gpt_statement: str
31
 
32
  def get_image_bytes(image_url: str) -> bytes:
@@ -89,24 +91,34 @@ async def explain_prediction(request: ExplainRequest):
89
 
90
  # 3. Generate XAI Maps
91
  service = ExplainabilityService(wrapper)
92
- attention_heatmap, gradcam_heatmap, _ = service.generate_heatmaps(
93
  preprocessed_image,
 
94
  request.diagnosis_data
95
  )
96
 
97
  # 4. Extract comprehensive features for GPT
98
- features = service.generate_comprehensive_features(attention_heatmap, original_array)
99
 
100
- # 5. Generate Overlays
101
- attention_overlay = build_overlay(original_array, attention_heatmap)
102
- gradcam_overlay = build_overlay(original_array, gradcam_heatmap)
103
 
 
 
 
 
 
 
 
 
104
  # 6. Generate GPT Statement
105
  gpt_statement = service.generate_gpt_explanation(features, request.diagnosis_data)
106
 
107
  return ExplainResponse(
108
  attention_heatmap_base64=tensor_to_base64(attention_overlay),
109
- gradcam_heatmap_base64=tensor_to_base64(gradcam_overlay),
 
 
110
  gpt_statement=gpt_statement
111
  )
112
 
 
26
 
27
  class ExplainResponse(BaseModel):
28
  attention_heatmap_base64: str
29
+ attention_bbox_base64: str
30
+ highest_attention_crop_base64: str
31
+ zone_reference_base64: str
32
  gpt_statement: str
33
 
34
  def get_image_bytes(image_url: str) -> bytes:
 
91
 
92
  # 3. Generate XAI Maps
93
  service = ExplainabilityService(wrapper)
94
+ xai_results = service.generate_heatmaps(
95
  preprocessed_image,
96
+ original_array,
97
  request.diagnosis_data
98
  )
99
 
100
  # 4. Extract comprehensive features for GPT
101
+ features = service.generate_comprehensive_features(xai_results['attention_heatmap'], original_array)
102
 
103
+ # 5. Generate Overlays & Base64 conversions
104
+ attention_overlay = build_overlay(original_array, xai_results['attention_heatmap'])
 
105
 
106
+ # Helper for direct image to base64 (for bbox, crop, zone which are already RGB/BGR)
107
+ def image_to_base64(img: np.ndarray) -> str:
108
+ # If it's BGR from cv2, keep it. If RGB, convert to BGR for imencode
109
+ img_bgr = cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
110
+ _, buffer = cv2.imencode('.jpg', img_bgr, [cv2.IMWRITE_JPEG_QUALITY, 85])
111
+ b64 = base64.b64encode(buffer).decode('utf-8')
112
+ return f"data:image/jpeg;base64,{b64}"
113
+
114
  # 6. Generate GPT Statement
115
  gpt_statement = service.generate_gpt_explanation(features, request.diagnosis_data)
116
 
117
  return ExplainResponse(
118
  attention_heatmap_base64=tensor_to_base64(attention_overlay),
119
+ attention_bbox_base64=image_to_base64(xai_results['bbox_overlay']),
120
+ highest_attention_crop_base64=image_to_base64(xai_results['highest_attention_crop']),
121
+ zone_reference_base64=image_to_base64(xai_results['zone_reference']),
122
  gpt_statement=gpt_statement
123
  )
124
 
backend/app/services/explainability_service.py CHANGED
@@ -5,6 +5,10 @@ import cv2
5
  import os
6
  import logging
7
  import time
 
 
 
 
8
  from scipy.ndimage import gaussian_filter, zoom, maximum_filter
9
  from sklearn.cluster import DBSCAN
10
  from skimage.feature import graycomatrix, graycoprops
@@ -160,6 +164,84 @@ def create_patch_attention_heatmap(patch_attention_grid, target_shape):
160
  heatmap = gaussian_filter(heatmap, sigma=5)
161
  return heatmap
162
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
163
  # -------------------------------------------
164
  # Feature Extractor
165
  # -------------------------------------------
@@ -289,15 +371,11 @@ class HeatmapFeatureExtractor:
289
  heatmap_norm = (self.heatmap - self.heatmap.min()) / (self.heatmap.max() - self.heatmap.min() + 1e-6)
290
  mask = (heatmap_norm >= threshold_ratio).astype(np.uint8)
291
 
292
- if np.sum(mask) < 100: return {"classification": "insufficient", "scores": {"uniformity": 0, "smoothness": 0}}
293
 
294
  gray = cv2.cvtColor(self.original_image, cv2.COLOR_RGB2GRAY)
295
  focused_gray = gray[mask == 1]
296
 
297
- # For simplicity, we use the whole original image's GLCM if mask is complex,
298
- # or just quantize the focused region.
299
- quantized = (focused_gray / 4).astype(np.uint8)
300
- # GLCM requires a 2D array, so we take a bounding box
301
  y_coords, x_coords = np.where(mask == 1)
302
  region = gray[y_coords.min():y_coords.max(), x_coords.min():x_coords.max()]
303
  quantized_region = (region / 4).astype(np.uint8)
@@ -321,7 +399,45 @@ class HeatmapFeatureExtractor:
321
  }
322
  }
323
  except:
324
- return {"classification": "error", "scores": {"uniformity": 50, "smoothness": 50}}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
 
326
  # -------------------------------------------
327
  # Explainability Service
@@ -332,39 +448,41 @@ class ExplainabilityService:
332
  self.device = self.wrapper.device
333
  self.model = self.wrapper.model
334
 
335
- def generate_heatmaps(self, preprocessed_image, diagnosis_data: dict) -> tuple[np.ndarray, np.ndarray, dict]:
336
  """
337
- Generates Attention Heatmap, GradCAM, and visual features.
338
  """
339
- disease_name = diagnosis_data.get('disease', {}).get('key', '')
340
  disease_idx = diagnosis_data.get('disease', {}).get('index', 0)
341
 
342
  # 1. Attention Heatmap
343
  patch_attention = extract_patch_level_attention(self.model.backbone, preprocessed_image, self.device)
344
  attention_heatmap = create_patch_attention_heatmap(patch_attention, (256, 256))
345
 
346
- # 2. GradCAM
347
- gradcam_wrapper = GradCAMVisionTransformer(self.model, self.device)
348
- disease_cam = gradcam_wrapper.generate_heatmap(preprocessed_image.clone(), disease_idx, head_type='disease')
 
 
 
 
349
 
 
350
  try:
351
- severity_idx = diagnosis_data.get('severity', {}).get('level', 1)
352
- severity_cam = gradcam_wrapper.generate_heatmap(preprocessed_image.clone(), severity_idx, head_type='severity', disease_name=disease_name)
 
 
353
  except Exception as e:
354
- logger.warning(f"Failed to generate severity GradCAM: {e}")
355
- severity_cam = disease_cam
356
 
357
- union_cam = (disease_cam + severity_cam) / 2.0
358
-
359
- # 3. Features
360
- original_img = np.array(zoom(preprocessed_image[0].cpu().numpy().transpose(1, 2, 0), (256/224, 256/224, 1), order=1))
361
- # Note: zoom might shift values, let's use a cleaner way or just use the original image array passed from API
362
- # Actually the API already passes original_array. Let's assume we use that.
363
-
364
- # We'll pass original_array from API to this method later, for now we calculate features using attention_heatmap
365
- # and we need the original_image for color/texture.
366
-
367
- return attention_heatmap, union_cam, {} # Placeholder for now, features extracted in generate_comprehensive_features
368
 
369
  def generate_comprehensive_features(self, attention_heatmap, original_array) -> dict:
370
  extractor = HeatmapFeatureExtractor(attention_heatmap, original_array)
@@ -392,7 +510,7 @@ class ExplainabilityService:
392
  }
393
 
394
  def generate_gpt_explanation(self, features: dict, diagnosis_data: dict) -> str:
395
- """Calls OpenAI for textual explanation."""
396
  from app.config import settings
397
 
398
  disease_name = diagnosis_data.get('disease', {}).get('name', 'Unknown')
@@ -405,7 +523,7 @@ class ExplainabilityService:
405
  try:
406
  api_key = settings.openai_api_key
407
  if not api_key:
408
- raise ValueError("OPENAI_API_KEY is not set in environment variables.")
409
 
410
  client = OpenAI(api_key=api_key)
411
 
@@ -416,10 +534,10 @@ HIERARCHICAL MODEL PREDICTION:
416
  - Status Level: {severity} ({severity_conf:.1%} confidence)
417
  - Stage Level: {stage} ({stage_conf:.1%} confidence)
418
 
419
- GRADCAM ANALYSIS (Gradient-weighted Class Activation Mapping):
420
- - Note: Bright/warm regions in GradCAM indicate areas that most strongly influenced the model's prediction
421
 
422
- SPATIAL ATTENTION PATTERN AND VISUAL CHARACTERISTICS (from Attention Heatmap):
423
  - Primary Focus: {features['primary_position']} (intensity: {features['primary_intensity']:.2f})
424
  - Attention Hotspots: {features['hotspot_count']}
425
  - Spatial Distribution: Center {features['center_attention']:.1f}%, Mid-region {features['mid_attention']:.1f}%, Periphery {features['periphery_attention']:.1f}%
@@ -434,10 +552,10 @@ CRITICAL INSTRUCTIONS:
434
  3. Explain how the two explainability methods (Attention Heatmap and GradCAM) show WHERE the model focused.
435
  4. Describe WHAT visual patterns were detected, not WHY medically.
436
  5. Keep it concise but informative (under 100 words).
437
- 6. Structure with the following EXACT section headers: [MODEL DECISION], [WHERE IT LOOKED], [GRADCAM INSIGHTS], [ATTENTION HEATMAP INSIGHTS], and [VISUAL CHARACTERISTICS].
438
  7. Make it conversational but professional.
439
 
440
- Generate a comprehensive explanation covering: what the model decided, where it looked, what the Attention and GradCAM methods revealed, and what visual characteristics were important. Use the [HEADER] format for each section."""
441
 
442
  response = client.chat.completions.create(
443
  model="gpt-4o-mini",
 
5
  import os
6
  import logging
7
  import time
8
+ import matplotlib
9
+ matplotlib.use('Agg')
10
+ import matplotlib.pyplot as plt
11
+ import matplotlib.patches as mpatches
12
  from scipy.ndimage import gaussian_filter, zoom, maximum_filter
13
  from sklearn.cluster import DBSCAN
14
  from skimage.feature import graycomatrix, graycoprops
 
164
  heatmap = gaussian_filter(heatmap, sigma=5)
165
  return heatmap
166
 
167
+ def _generate_zone_reference_image(figsize=(5, 5)):
168
+ """
169
+ Renders a static square bullseye zone reference diagram.
170
+ """
171
+ fig, ax = plt.subplots(figsize=figsize, facecolor='#1A1A2E')
172
+ fig.subplots_adjust(left=0, right=1, top=1, bottom=0)
173
+ ax.set_facecolor('#1A1A2E')
174
+ ax.set_xlim(0, 1)
175
+ ax.set_ylim(0, 1)
176
+ ax.set_aspect('equal')
177
+ ax.axis('off')
178
+
179
+ # --- Periphery ---
180
+ periphery = mpatches.FancyBboxPatch(
181
+ (0.05, 0.05), 0.90, 0.90,
182
+ boxstyle="square,pad=0",
183
+ linewidth=2.0,
184
+ edgecolor='#2ECC71',
185
+ facecolor='#1E3A2E',
186
+ zorder=1
187
+ )
188
+ ax.add_patch(periphery)
189
+
190
+ # --- Mid-region ---
191
+ mid = mpatches.FancyBboxPatch(
192
+ (0.22, 0.22), 0.56, 0.56,
193
+ boxstyle="square,pad=0",
194
+ linewidth=2.0,
195
+ linestyle='--',
196
+ edgecolor='#3498DB',
197
+ facecolor='#1E2A3E',
198
+ zorder=2
199
+ )
200
+ ax.add_patch(mid)
201
+
202
+ # --- Center ---
203
+ center = mpatches.FancyBboxPatch(
204
+ (0.38, 0.38), 0.24, 0.24,
205
+ boxstyle="square,pad=0",
206
+ linewidth=2.0,
207
+ edgecolor='#9B59B6',
208
+ facecolor='#2E1E3E',
209
+ zorder=3
210
+ )
211
+ ax.add_patch(center)
212
+
213
+ # --- Labels ---
214
+ ax.text(0.50, 0.52, 'Center',
215
+ ha='center', va='center', fontsize=8,
216
+ color='#C39BD3', fontweight='bold', zorder=4)
217
+ ax.text(0.50, 0.46, 'innermost',
218
+ ha='center', va='center', fontsize=6.5,
219
+ color='#9B59B6', zorder=4)
220
+
221
+ ax.text(0.50, 0.17, 'Mid-region',
222
+ ha='center', va='center', fontsize=7.5,
223
+ color='#85C1E9', fontweight='bold', zorder=4)
224
+
225
+ ax.text(0.50, 0.93, 'Periphery',
226
+ ha='center', va='center', fontsize=7.5,
227
+ color='#82E0AA', fontweight='bold', zorder=4)
228
+ ax.text(0.50, 0.88, '(image frame)',
229
+ ha='center', va='center', fontsize=6.0,
230
+ color='#4A8C5C', zorder=4)
231
+
232
+ # --- Title ---
233
+ ax.text(0.50, 0.98, 'Spatial attention zone reference',
234
+ ha='center', va='top', fontsize=7,
235
+ color='#A0A0C0', fontweight='bold', zorder=4)
236
+
237
+ fig.canvas.draw()
238
+ buf = fig.canvas.buffer_rgba()
239
+ img_array = np.frombuffer(buf, dtype=np.uint8).reshape(
240
+ fig.canvas.get_width_height()[::-1] + (4,)
241
+ )
242
+ plt.close(fig)
243
+ return img_array[:, :, :3]
244
+
245
  # -------------------------------------------
246
  # Feature Extractor
247
  # -------------------------------------------
 
371
  heatmap_norm = (self.heatmap - self.heatmap.min()) / (self.heatmap.max() - self.heatmap.min() + 1e-6)
372
  mask = (heatmap_norm >= threshold_ratio).astype(np.uint8)
373
 
374
+ if np.sum(mask) < 100: return {"classification": "insufficient", "scores": {"uniformity": 0, "smoothness": 0, "complexity": 0, "organization": 0}}
375
 
376
  gray = cv2.cvtColor(self.original_image, cv2.COLOR_RGB2GRAY)
377
  focused_gray = gray[mask == 1]
378
 
 
 
 
 
379
  y_coords, x_coords = np.where(mask == 1)
380
  region = gray[y_coords.min():y_coords.max(), x_coords.min():x_coords.max()]
381
  quantized_region = (region / 4).astype(np.uint8)
 
399
  }
400
  }
401
  except:
402
+ return {"classification": "error", "scores": {"uniformity": 50, "smoothness": 50, "complexity": 50, "organization": 50}}
403
+
404
+ def get_brightest_region_overlay(self, threshold=0.6, alpha=0.5):
405
+ heatmap_norm = (self.heatmap - self.heatmap.min()) / (self.heatmap.max() - self.heatmap.min() + 1e-6)
406
+ mask = (heatmap_norm >= threshold).astype(np.uint8) * 255
407
+
408
+ contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
409
+
410
+ overlay = self.original_image.copy()
411
+ if contours:
412
+ for cnt in contours:
413
+ if cv2.contourArea(cnt) < 50: continue
414
+ x, y, w, h = cv2.boundingRect(cnt)
415
+ cv2.rectangle(overlay, (x, y), (x + w, y + h), (255, 255, 0), 2)
416
+
417
+ return {'overlay_with_bbox': overlay}
418
+
419
+ def get_highest_attention_crop(self, threshold_ratio=0.6, padding=10):
420
+ heatmap_norm = (self.heatmap - self.heatmap.min()) / (self.heatmap.max() - self.heatmap.min() + 1e-6)
421
+
422
+ coords = np.argwhere(heatmap_norm >= threshold_ratio * heatmap_norm.max())
423
+ if len(coords) == 0:
424
+ return {'composite_image': self.original_image.copy()}
425
+
426
+ y1, x1 = coords.min(axis=0)
427
+ y2, x2 = coords.max(axis=0)
428
+
429
+ h, w = self.original_image.shape[:2]
430
+ y1 = max(0, y1 - padding)
431
+ x1 = max(0, x1 - padding)
432
+ y2 = min(h, y2 + padding)
433
+ x2 = min(w, x2 + padding)
434
+
435
+ crop = self.original_image[y1:y2, x1:x2]
436
+ if crop.size == 0:
437
+ return {'composite_image': self.original_image.copy()}
438
+
439
+ crop_resized = cv2.resize(crop, (256, 256), interpolation=cv2.INTER_CUBIC)
440
+ return {'composite_image': crop_resized}
441
 
442
  # -------------------------------------------
443
  # Explainability Service
 
448
  self.device = self.wrapper.device
449
  self.model = self.wrapper.model
450
 
451
+ def generate_heatmaps(self, preprocessed_image, original_array, diagnosis_data: dict) -> dict:
452
  """
453
+ Generates comprehensive XAI results.
454
  """
 
455
  disease_idx = diagnosis_data.get('disease', {}).get('index', 0)
456
 
457
  # 1. Attention Heatmap
458
  patch_attention = extract_patch_level_attention(self.model.backbone, preprocessed_image, self.device)
459
  attention_heatmap = create_patch_attention_heatmap(patch_attention, (256, 256))
460
 
461
+ # 2. Extract Features
462
+ extractor = HeatmapFeatureExtractor(attention_heatmap, original_array)
463
+
464
+ # 3. Generate Overlays & Crops
465
+ bbox_result = extractor.get_brightest_region_overlay(threshold=0.6)
466
+ crop_result = extractor.get_highest_attention_crop(threshold_ratio=0.6)
467
+ zone_ref = _generate_zone_reference_image()
468
 
469
+ # 4. GradCAM (kept for GPT explanation but not UI)
470
  try:
471
+ gradcam_wrapper = GradCAMVisionTransformer(self.model, self.device)
472
+ disease_cam = gradcam_wrapper.generate_heatmap(preprocessed_image.clone(), disease_idx, head_type='disease')
473
+ # For simplicity, we just use disease_cam for now
474
+ union_cam = disease_cam
475
  except Exception as e:
476
+ logger.warning(f"Failed to generate GradCAM: {e}")
477
+ union_cam = attention_heatmap
478
 
479
+ return {
480
+ 'attention_heatmap': attention_heatmap,
481
+ 'bbox_overlay': bbox_result['overlay_with_bbox'],
482
+ 'highest_attention_crop': crop_result['composite_image'],
483
+ 'zone_reference': zone_ref,
484
+ 'gradcam_heatmap': union_cam # Kept for GPT prompt
485
+ }
 
 
 
 
486
 
487
  def generate_comprehensive_features(self, attention_heatmap, original_array) -> dict:
488
  extractor = HeatmapFeatureExtractor(attention_heatmap, original_array)
 
510
  }
511
 
512
  def generate_gpt_explanation(self, features: dict, diagnosis_data: dict) -> str:
513
+ """Calls OpenAI for textual explanation using the updated Kaggle-style prompt."""
514
  from app.config import settings
515
 
516
  disease_name = diagnosis_data.get('disease', {}).get('name', 'Unknown')
 
523
  try:
524
  api_key = settings.openai_api_key
525
  if not api_key:
526
+ raise ValueError("OPENAI_API_KEY is not set.")
527
 
528
  client = OpenAI(api_key=api_key)
529
 
 
534
  - Status Level: {severity} ({severity_conf:.1%} confidence)
535
  - Stage Level: {stage} ({stage_conf:.1%} confidence)
536
 
537
+ GRADCAM ANALYSIS:
538
+ - Note: Bright/warm regions indicate areas that most strongly influenced the model's prediction.
539
 
540
+ SPATIAL ATTENTION PATTERN AND VISUAL CHARACTERISTICS:
541
  - Primary Focus: {features['primary_position']} (intensity: {features['primary_intensity']:.2f})
542
  - Attention Hotspots: {features['hotspot_count']}
543
  - Spatial Distribution: Center {features['center_attention']:.1f}%, Mid-region {features['mid_attention']:.1f}%, Periphery {features['periphery_attention']:.1f}%
 
552
  3. Explain how the two explainability methods (Attention Heatmap and GradCAM) show WHERE the model focused.
553
  4. Describe WHAT visual patterns were detected, not WHY medically.
554
  5. Keep it concise but informative (under 100 words).
555
+ 6. Structure with natural paragraphs, but ensure you cover what the model decided, where it looked, and visual characteristics.
556
  7. Make it conversational but professional.
557
 
558
+ Generate a comprehensive explanation covering: what the model decided, where it looked, what the Attention and GradCAM methods revealed, and what visual characteristics were important."""
559
 
560
  response = client.chat.completions.create(
561
  model="gpt-4o-mini",
favicon.png ADDED
src/lib/api.ts CHANGED
@@ -315,7 +315,9 @@ export async function fetchExplainability({
315
  }) {
316
  return apiCall<{
317
  attention_heatmap_base64: string;
318
- gradcam_heatmap_base64: string;
 
 
319
  gpt_statement: string;
320
  }>("POST", "/explain", {
321
  image_url: imageUrl,
 
315
  }) {
316
  return apiCall<{
317
  attention_heatmap_base64: string;
318
+ attention_bbox_base64: string;
319
+ highest_attention_crop_base64: string;
320
+ zone_reference_base64: string;
321
  gpt_statement: string;
322
  }>("POST", "/explain", {
323
  image_url: imageUrl,
src/pages/DiagnosisResults.tsx CHANGED
@@ -50,7 +50,9 @@ const DiagnosisResults = () => {
50
 
51
  const [explainData, setExplainData] = useState<{
52
  attention_heatmap_base64: string;
53
- gradcam_heatmap_base64: string;
 
 
54
  gpt_statement: string;
55
  } | null>(null);
56
  const [explainLoading, setExplainLoading] = useState(true);
@@ -244,7 +246,7 @@ const DiagnosisResults = () => {
244
  if (formattedSections.length === 0) {
245
  return (
246
  <div className="p-6 bg-primary/5 rounded-2xl border border-primary/10 shadow-sm">
247
- <p className="text-foreground leading-relaxed whitespace-pre-wrap">
248
  {text}
249
  </p>
250
  </div>
@@ -279,26 +281,40 @@ const DiagnosisResults = () => {
279
  })()}
280
  </div>
281
 
282
- {/* Heatmaps */}
283
- <div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-4">
 
284
  <div className="group space-y-3">
285
  <div className="flex items-center gap-2 px-1">
286
  <BarChart3 className="w-4 h-4 text-purple-400" />
287
- <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Attention Heatmap</h3>
288
  </div>
289
  <div className="relative rounded-2xl overflow-hidden border border-border/50 shadow-lg group-hover:border-primary/30 transition-colors">
290
- <img src={explainData.attention_heatmap_base64} alt="Attention Heatmap" className="w-full h-auto object-contain" />
291
  <div className="absolute inset-0 bg-gradient-to-t from-background/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
292
  </div>
293
  </div>
294
 
 
295
  <div className="group space-y-3">
296
  <div className="flex items-center gap-2 px-1">
297
- <Activity className="w-4 h-4 text-orange-400" />
298
- <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Grad-CAM Heatmap</h3>
299
  </div>
300
  <div className="relative rounded-2xl overflow-hidden border border-border/50 shadow-lg group-hover:border-primary/30 transition-colors">
301
- <img src={explainData.gradcam_heatmap_base64} alt="Grad-CAM Heatmap" className="w-full h-auto object-contain" />
 
 
 
 
 
 
 
 
 
 
 
 
302
  <div className="absolute inset-0 bg-gradient-to-t from-background/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
303
  </div>
304
  </div>
 
50
 
51
  const [explainData, setExplainData] = useState<{
52
  attention_heatmap_base64: string;
53
+ attention_bbox_base64: string;
54
+ highest_attention_crop_base64: string;
55
+ zone_reference_base64: string;
56
  gpt_statement: string;
57
  } | null>(null);
58
  const [explainLoading, setExplainLoading] = useState(true);
 
246
  if (formattedSections.length === 0) {
247
  return (
248
  <div className="p-6 bg-primary/5 rounded-2xl border border-primary/10 shadow-sm">
249
+ <p className="text-foreground text-lg leading-relaxed whitespace-pre-wrap font-medium">
250
  {text}
251
  </p>
252
  </div>
 
281
  })()}
282
  </div>
283
 
284
+ {/* Heatmaps / XAI Panels */}
285
+ <div className="grid grid-cols-1 md:grid-cols-3 gap-6 pt-4">
286
+ {/* Attention Heatmap + BBox */}
287
  <div className="group space-y-3">
288
  <div className="flex items-center gap-2 px-1">
289
  <BarChart3 className="w-4 h-4 text-purple-400" />
290
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Attention + BBox</h3>
291
  </div>
292
  <div className="relative rounded-2xl overflow-hidden border border-border/50 shadow-lg group-hover:border-primary/30 transition-colors">
293
+ <img src={explainData.attention_bbox_base64} alt="Attention Heatmap + BBox" className="w-full h-auto object-contain" />
294
  <div className="absolute inset-0 bg-gradient-to-t from-background/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
295
  </div>
296
  </div>
297
 
298
+ {/* Highest Attention Crop */}
299
  <div className="group space-y-3">
300
  <div className="flex items-center gap-2 px-1">
301
+ <Eye className="w-4 h-4 text-blue-400" />
302
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Attention Crop</h3>
303
  </div>
304
  <div className="relative rounded-2xl overflow-hidden border border-border/50 shadow-lg group-hover:border-primary/30 transition-colors">
305
+ <img src={explainData.highest_attention_crop_base64} alt="Highest Attention Crop" className="w-full h-auto object-contain" />
306
+ <div className="absolute inset-0 bg-gradient-to-t from-background/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
307
+ </div>
308
+ </div>
309
+
310
+ {/* Zone Reference */}
311
+ <div className="group space-y-3">
312
+ <div className="flex items-center gap-2 px-1">
313
+ <Info className="w-4 h-4 text-primary" />
314
+ <h3 className="text-xs font-bold text-muted-foreground uppercase tracking-widest">Zone Reference</h3>
315
+ </div>
316
+ <div className="relative rounded-2xl overflow-hidden border border-border/50 shadow-lg group-hover:border-primary/30 transition-colors">
317
+ <img src={explainData.zone_reference_base64} alt="Spatial Zone Reference" className="w-full h-auto object-contain" />
318
  <div className="absolute inset-0 bg-gradient-to-t from-background/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
319
  </div>
320
  </div>