Amarthya7 commited on
Commit
5cdf85c
·
verified ·
1 Parent(s): 05e3595

Update mediSync/models/multimodal_fusion.py

Browse files
Files changed (1) hide show
  1. mediSync/models/multimodal_fusion.py +631 -631
mediSync/models/multimodal_fusion.py CHANGED
@@ -1,631 +1,631 @@
1
- import logging
2
-
3
- from .image_analyzer import XRayImageAnalyzer
4
- from .text_analyzer import MedicalReportAnalyzer
5
-
6
-
7
- class MultimodalFusion:
8
- """
9
- A class for fusing insights from image analysis and text analysis of medical data.
10
-
11
- This fusion approach combines the strengths of both modalities:
12
- - Images provide visual evidence of abnormalities
13
- - Text reports provide context, history and radiologist interpretations
14
-
15
- The combined analysis provides a more comprehensive understanding than either modality alone.
16
- """
17
-
18
- def __init__(self, image_model=None, text_model=None, device=None):
19
- """
20
- Initialize the multimodal fusion module with image and text analyzers.
21
-
22
- Args:
23
- image_model (str, optional): Model to use for image analysis
24
- text_model (str, optional): Model to use for text analysis
25
- device (str, optional): Device to run models on ('cuda' or 'cpu')
26
- """
27
- self.logger = logging.getLogger(__name__)
28
-
29
- # Determine device
30
- if device is None:
31
- import torch
32
-
33
- self.device = "cuda" if torch.cuda.is_available() else "cpu"
34
- else:
35
- self.device = device
36
-
37
- self.logger.info(f"Using device: {self.device}")
38
-
39
- # Initialize image analyzer
40
- try:
41
- self.image_analyzer = XRayImageAnalyzer(
42
- model_name=image_model
43
- if image_model
44
- else "facebook/deit-base-patch16-224-medical-cxr",
45
- device=self.device,
46
- )
47
- self.logger.info("Successfully initialized image analyzer")
48
- except Exception as e:
49
- self.logger.error(f"Failed to initialize image analyzer: {e}")
50
- self.image_analyzer = None
51
-
52
- # Initialize text analyzer
53
- try:
54
- self.text_analyzer = MedicalReportAnalyzer(
55
- classifier_model=text_model if text_model else "medicalai/ClinicalBERT",
56
- device=self.device,
57
- )
58
- self.logger.info("Successfully initialized text analyzer")
59
- except Exception as e:
60
- self.logger.error(f"Failed to initialize text analyzer: {e}")
61
- self.text_analyzer = None
62
-
63
- def analyze_image(self, image_path):
64
- """
65
- Analyze a medical image.
66
-
67
- Args:
68
- image_path (str): Path to the medical image
69
-
70
- Returns:
71
- dict: Image analysis results
72
- """
73
- if not self.image_analyzer:
74
- self.logger.warning("Image analyzer not available")
75
- return {"error": "Image analyzer not available"}
76
-
77
- try:
78
- return self.image_analyzer.analyze(image_path)
79
- except Exception as e:
80
- self.logger.error(f"Error analyzing image: {e}")
81
- return {"error": str(e)}
82
-
83
- def analyze_text(self, text):
84
- """
85
- Analyze medical report text.
86
-
87
- Args:
88
- text (str): Medical report text
89
-
90
- Returns:
91
- dict: Text analysis results
92
- """
93
- if not self.text_analyzer:
94
- self.logger.warning("Text analyzer not available")
95
- return {"error": "Text analyzer not available"}
96
-
97
- try:
98
- return self.text_analyzer.analyze(text)
99
- except Exception as e:
100
- self.logger.error(f"Error analyzing text: {e}")
101
- return {"error": str(e)}
102
-
103
- def _calculate_agreement_score(self, image_results, text_results):
104
- """
105
- Calculate agreement score between image and text analyses.
106
-
107
- Args:
108
- image_results (dict): Results from image analysis
109
- text_results (dict): Results from text analysis
110
-
111
- Returns:
112
- float: Agreement score (0-1, where 1 is perfect agreement)
113
- """
114
- try:
115
- # Default to neutral agreement
116
- agreement = 0.5
117
-
118
- # Check if image detected abnormality
119
- image_abnormal = image_results.get("has_abnormality", False)
120
-
121
- # Check text severity
122
- text_severity = text_results.get("severity", {}).get("level", "Unknown")
123
- text_abnormal = text_severity not in ["Normal", "Unknown"]
124
-
125
- # Basic agreement check
126
- if image_abnormal == text_abnormal:
127
- agreement += 0.25
128
- else:
129
- agreement -= 0.25
130
-
131
- # Check if specific findings match
132
- image_finding = image_results.get("primary_finding", "").lower()
133
-
134
- # Extract problem entities from text
135
- problems = text_results.get("entities", {}).get("problem", [])
136
- problem_text = " ".join(problems).lower()
137
-
138
- # Check for common keywords in both
139
- common_conditions = [
140
- "pneumonia",
141
- "effusion",
142
- "nodule",
143
- "mass",
144
- "cardiomegaly",
145
- "opacity",
146
- "fracture",
147
- "tumor",
148
- "edema",
149
- ]
150
-
151
- matching_conditions = 0
152
- total_mentioned = 0
153
-
154
- for condition in common_conditions:
155
- in_image = condition in image_finding
156
- in_text = condition in problem_text
157
-
158
- if in_image or in_text:
159
- total_mentioned += 1
160
-
161
- if in_image and in_text:
162
- matching_conditions += 1
163
- agreement += 0.05 # Boost agreement for each matching condition
164
-
165
- # Calculate condition match ratio if any conditions were mentioned
166
- if total_mentioned > 0:
167
- match_ratio = matching_conditions / total_mentioned
168
- agreement += match_ratio * 0.2
169
-
170
- # Normalize agreement to 0-1 range
171
- agreement = max(0, min(1, agreement))
172
-
173
- return agreement
174
-
175
- except Exception as e:
176
- self.logger.error(f"Error calculating agreement score: {e}")
177
- return 0.5 # Return neutral agreement on error
178
-
179
- def _get_confidence_weighted_finding(self, image_results, text_results, agreement):
180
- """
181
- Get the most confident finding weighted by modality confidence.
182
-
183
- Args:
184
- image_results (dict): Results from image analysis
185
- text_results (dict): Results from text analysis
186
- agreement (float): Agreement score between modalities
187
-
188
- Returns:
189
- str: Most confident finding
190
- """
191
- try:
192
- image_finding = image_results.get("primary_finding", "")
193
- image_confidence = image_results.get("confidence", 0.5)
194
-
195
- # For text, use the most severe problem as primary finding
196
- problems = text_results.get("entities", {}).get("problem", [])
197
-
198
- text_confidence = text_results.get("severity", {}).get("confidence", 0.5)
199
-
200
- if not problems:
201
- # No problems identified in text
202
- if image_confidence > 0.7:
203
- return image_finding
204
- else:
205
- return "No significant findings"
206
-
207
- # Simple confidence-weighted selection
208
- if image_confidence > text_confidence + 0.2:
209
- return image_finding
210
- elif problems and text_confidence > image_confidence + 0.2:
211
- return (
212
- problems[0]
213
- if isinstance(problems, list) and problems
214
- else "Unknown finding"
215
- )
216
- else:
217
- # Similar confidence, check agreement
218
- if agreement > 0.7:
219
- # High agreement, try to find the specific condition mentioned in both
220
- for problem in problems:
221
- if problem.lower() in image_finding.lower():
222
- return problem
223
-
224
- # Default to image finding if high confidence
225
- if image_confidence > 0.6:
226
- return image_finding
227
- elif problems:
228
- return problems[0]
229
- else:
230
- return image_finding
231
- else:
232
- # Low agreement, include both perspectives
233
- if image_finding and problems:
234
- return f"{image_finding} (image) / {problems[0]} (report)"
235
- elif image_finding:
236
- return image_finding
237
- elif problems:
238
- return problems[0]
239
- else:
240
- return "Findings unclear - review recommended"
241
-
242
- except Exception as e:
243
- self.logger.error(f"Error getting weighted finding: {e}")
244
- return "Unable to determine primary finding"
245
-
246
- def _merge_followup_recommendations(self, image_results, text_results):
247
- """
248
- Merge follow-up recommendations from both modalities.
249
-
250
- Args:
251
- image_results (dict): Results from image analysis
252
- text_results (dict): Results from text analysis
253
-
254
- Returns:
255
- list: Combined follow-up recommendations
256
- """
257
- try:
258
- # Get text-based recommendations
259
- text_recommendations = text_results.get("followup_recommendations", [])
260
-
261
- # Create image-based recommendations based on findings
262
- image_recommendations = []
263
-
264
- if image_results.get("has_abnormality", False):
265
- primary = image_results.get("primary_finding", "")
266
- confidence = image_results.get("confidence", 0)
267
-
268
- if (
269
- "nodule" in primary.lower()
270
- or "mass" in primary.lower()
271
- or "tumor" in primary.lower()
272
- ):
273
- image_recommendations.append(
274
- f"Follow-up imaging recommended to further evaluate {primary}."
275
- )
276
- elif "pneumonia" in primary.lower():
277
- image_recommendations.append(
278
- "Clinical correlation and follow-up imaging recommended."
279
- )
280
- elif confidence > 0.8:
281
- image_recommendations.append(
282
- f"Consider follow-up imaging to monitor {primary}."
283
- )
284
- elif confidence > 0.5:
285
- image_recommendations.append(
286
- "Consider clinical correlation and potential follow-up."
287
- )
288
-
289
- # Combine recommendations, removing duplicates
290
- all_recommendations = text_recommendations + image_recommendations
291
-
292
- # Remove near-duplicates (similar recommendations)
293
- unique_recommendations = []
294
- for rec in all_recommendations:
295
- if not any(
296
- self._is_similar_recommendation(rec, existing)
297
- for existing in unique_recommendations
298
- ):
299
- unique_recommendations.append(rec)
300
-
301
- return unique_recommendations
302
-
303
- except Exception as e:
304
- self.logger.error(f"Error merging follow-up recommendations: {e}")
305
- return ["Follow-up recommended based on findings."]
306
-
307
- def _is_similar_recommendation(self, rec1, rec2):
308
- """Check if two recommendations are semantically similar."""
309
- # Convert to lowercase for comparison
310
- rec1_lower = rec1.lower()
311
- rec2_lower = rec2.lower()
312
-
313
- # Check for significant overlap
314
- words1 = set(rec1_lower.split())
315
- words2 = set(rec2_lower.split())
316
-
317
- # Calculate Jaccard similarity
318
- intersection = words1.intersection(words2)
319
- union = words1.union(words2)
320
-
321
- similarity = len(intersection) / len(union) if union else 0
322
-
323
- # Consider similar if more than 60% overlap
324
- return similarity > 0.6
325
-
326
- def _get_final_severity(self, image_results, text_results, agreement):
327
- """
328
- Determine final severity based on both modalities.
329
-
330
- Args:
331
- image_results (dict): Results from image analysis
332
- text_results (dict): Results from text analysis
333
- agreement (float): Agreement score between modalities
334
-
335
- Returns:
336
- dict: Final severity assessment
337
- """
338
- try:
339
- # Get text-based severity
340
- text_severity = text_results.get("severity", {})
341
- text_level = text_severity.get("level", "Unknown")
342
- text_score = text_severity.get("score", 0)
343
- text_confidence = text_severity.get("confidence", 0.5)
344
-
345
- # Convert image findings to severity
346
- image_abnormal = image_results.get("has_abnormality", False)
347
- image_confidence = image_results.get("confidence", 0.5)
348
-
349
- # Default severity mapping from image
350
- image_severity = "Normal" if not image_abnormal else "Moderate"
351
- image_score = 0 if not image_abnormal else 2.0
352
-
353
- # Adjust image severity based on specific findings
354
- primary_finding = image_results.get("primary_finding", "").lower()
355
-
356
- # Map certain conditions to severity levels
357
- severity_mapping = {
358
- "pneumonia": ("Moderate", 2.5),
359
- "pneumothorax": ("Severe", 3.0),
360
- "effusion": ("Moderate", 2.0),
361
- "pulmonary edema": ("Moderate", 2.5),
362
- "nodule": ("Mild", 1.5),
363
- "mass": ("Moderate", 2.5),
364
- "tumor": ("Severe", 3.0),
365
- "cardiomegaly": ("Mild", 1.5),
366
- "fracture": ("Moderate", 2.0),
367
- "consolidation": ("Moderate", 2.0),
368
- }
369
-
370
- # Check if any key terms are in the primary finding
371
- for key, (severity, score) in severity_mapping.items():
372
- if key in primary_finding:
373
- image_severity = severity
374
- image_score = score
375
- break
376
-
377
- # Weight based on confidence and agreement
378
- if agreement > 0.7:
379
- # High agreement - weight equally
380
- final_score = (image_score + text_score) / 2
381
- else:
382
- # Lower agreement - weight by confidence
383
- total_confidence = image_confidence + text_confidence
384
- if total_confidence > 0:
385
- image_weight = image_confidence / total_confidence
386
- text_weight = text_confidence / total_confidence
387
- final_score = (image_score * image_weight) + (
388
- text_score * text_weight
389
- )
390
- else:
391
- final_score = (image_score + text_score) / 2
392
-
393
- # Map score to severity level
394
- severity_levels = {
395
- 0: "Normal",
396
- 1: "Mild",
397
- 2: "Moderate",
398
- 3: "Severe",
399
- 4: "Critical",
400
- }
401
-
402
- # Round to nearest level
403
- level_index = round(min(4, max(0, final_score)))
404
- final_level = severity_levels[level_index]
405
-
406
- return {
407
- "level": final_level,
408
- "score": round(final_score, 1),
409
- "confidence": round((image_confidence + text_confidence) / 2, 2),
410
- }
411
-
412
- except Exception as e:
413
- self.logger.error(f"Error determining final severity: {e}")
414
- return {"level": "Unknown", "score": 0, "confidence": 0}
415
-
416
- def fuse_analyses(self, image_results, text_results):
417
- """
418
- Fuse the results from image and text analyses.
419
-
420
- Args:
421
- image_results (dict): Results from image analysis
422
- text_results (dict): Results from text analysis
423
-
424
- Returns:
425
- dict: Fused analysis results
426
- """
427
- try:
428
- # Calculate agreement between modalities
429
- agreement = self._calculate_agreement_score(image_results, text_results)
430
- self.logger.info(f"Agreement score between modalities: {agreement:.2f}")
431
-
432
- # Get confidence-weighted primary finding
433
- primary_finding = self._get_confidence_weighted_finding(
434
- image_results, text_results, agreement
435
- )
436
-
437
- # Merge follow-up recommendations
438
- followup = self._merge_followup_recommendations(image_results, text_results)
439
-
440
- # Get final severity assessment
441
- severity = self._get_final_severity(image_results, text_results, agreement)
442
-
443
- # Create comprehensive findings list
444
- findings = []
445
-
446
- # Add text-extracted findings
447
- text_findings = text_results.get("findings", [])
448
- if text_findings:
449
- findings.extend(text_findings)
450
-
451
- # Add primary image finding if not already included
452
- image_finding = image_results.get("primary_finding", "")
453
- if image_finding and not any(
454
- image_finding.lower() in f.lower() for f in findings
455
- ):
456
- findings.append(f"Image finding: {image_finding}")
457
-
458
- # Create fused result
459
- fused_result = {
460
- "agreement_score": round(agreement, 2),
461
- "primary_finding": primary_finding,
462
- "severity": severity,
463
- "findings": findings,
464
- "followup_recommendations": followup,
465
- "modality_results": {"image": image_results, "text": text_results},
466
- }
467
-
468
- return fused_result
469
-
470
- except Exception as e:
471
- self.logger.error(f"Error fusing analyses: {e}")
472
- return {
473
- "error": str(e),
474
- "modality_results": {"image": image_results, "text": text_results},
475
- }
476
-
477
- def analyze(self, image_path, report_text):
478
- """
479
- Perform multimodal analysis of medical image and report.
480
-
481
- Args:
482
- image_path (str): Path to the medical image
483
- report_text (str): Medical report text
484
-
485
- Returns:
486
- dict: Fused analysis results
487
- """
488
- try:
489
- # Analyze image
490
- image_results = self.analyze_image(image_path)
491
-
492
- # Analyze text
493
- text_results = self.analyze_text(report_text)
494
-
495
- # Fuse the analyses
496
- return self.fuse_analyses(image_results, text_results)
497
-
498
- except Exception as e:
499
- self.logger.error(f"Error in multimodal analysis: {e}")
500
- return {"error": str(e)}
501
-
502
- def get_explanation(self, fused_results):
503
- """
504
- Generate a human-readable explanation of the fused analysis.
505
-
506
- Args:
507
- fused_results (dict): Results from the fused analysis
508
-
509
- Returns:
510
- str: A text explanation of the fused analysis
511
- """
512
- try:
513
- explanation = []
514
-
515
- # Add overview section
516
- primary_finding = fused_results.get("primary_finding", "Unknown")
517
- severity = fused_results.get("severity", {}).get("level", "Unknown")
518
-
519
- explanation.append("# Medical Analysis Summary\n")
520
- explanation.append("## Overview\n")
521
- explanation.append(f"Primary finding: **{primary_finding}**\n")
522
- explanation.append(f"Severity level: **{severity}**\n")
523
-
524
- # Add agreement information
525
- agreement = fused_results.get("agreement_score", 0)
526
- agreement_text = (
527
- "High" if agreement > 0.7 else "Moderate" if agreement > 0.4 else "Low"
528
- )
529
-
530
- explanation.append(
531
- f"Image and text analysis agreement: **{agreement_text}** ({agreement:.0%})\n"
532
- )
533
-
534
- # Add findings section
535
- explanation.append("\n## Detailed Findings\n")
536
- findings = fused_results.get("findings", [])
537
-
538
- if findings:
539
- for finding in findings:
540
- explanation.append(f"- {finding}\n")
541
- else:
542
- explanation.append("No specific findings detailed.\n")
543
-
544
- # Add follow-up section
545
- explanation.append("\n## Recommended Follow-up\n")
546
- followups = fused_results.get("followup_recommendations", [])
547
-
548
- if followups:
549
- for followup in followups:
550
- explanation.append(f"- {followup}\n")
551
- else:
552
- explanation.append("No specific follow-up recommendations provided.\n")
553
-
554
- # Add confidence note
555
- confidence = fused_results.get("severity", {}).get("confidence", 0)
556
- explanation.append(
557
- f"\n*Note: This analysis has a confidence level of {confidence:.0%}. "
558
- f"Please consult with healthcare professionals for official diagnosis.*"
559
- )
560
-
561
- return "\n".join(explanation)
562
-
563
- except Exception as e:
564
- self.logger.error(f"Error generating explanation: {e}")
565
- return "Error generating analysis explanation."
566
-
567
-
568
- # Example usage
569
- if __name__ == "__main__":
570
- # Set up logging
571
- logging.basicConfig(level=logging.INFO)
572
-
573
- # Test on sample data if available
574
- import os
575
-
576
- fusion = MultimodalFusion()
577
-
578
- # Sample text report
579
- sample_report = """
580
- CHEST X-RAY EXAMINATION
581
-
582
- CLINICAL HISTORY: 55-year-old male with cough and fever.
583
-
584
- FINDINGS: The heart size is at the upper limits of normal. The lungs are clear without focal consolidation,
585
- effusion, or pneumothorax. There is mild prominence of the pulmonary vasculature. No pleural effusion is seen.
586
- There is a small nodular opacity noted in the right lower lobe measuring approximately 8mm, which is suspicious
587
- and warrants further investigation. The mediastinum is unremarkable. The visualized bony structures show no acute abnormalities.
588
-
589
- IMPRESSION:
590
- 1. Mild cardiomegaly.
591
- 2. 8mm nodular opacity in the right lower lobe, recommend follow-up CT for further evaluation.
592
- 3. No acute pulmonary parenchymal abnormality.
593
-
594
- RECOMMENDATIONS: Follow-up chest CT to further characterize the nodular opacity in the right lower lobe.
595
- """
596
-
597
- # Check if sample data directory exists and contains images
598
- sample_dir = "../data/sample"
599
- if os.path.exists(sample_dir) and os.listdir(sample_dir):
600
- sample_image = os.path.join(sample_dir, os.listdir(sample_dir)[0])
601
- print(f"Analyzing sample image: {sample_image}")
602
-
603
- # Perform multimodal analysis
604
- fused_results = fusion.analyze(sample_image, sample_report)
605
- explanation = fusion.get_explanation(fused_results)
606
-
607
- print("\nFused Analysis Results:")
608
- print(explanation)
609
- else:
610
- print("No sample images found. Only analyzing text report.")
611
-
612
- # Analyze just the text
613
- text_results = fusion.analyze_text(sample_report)
614
-
615
- print("\nText Analysis Results:")
616
- print(
617
- f"Severity: {text_results['severity']['level']} (Score: {text_results['severity']['score']})"
618
- )
619
-
620
- print("\nKey Findings:")
621
- for finding in text_results["findings"]:
622
- print(f"- {finding}")
623
-
624
- print("\nEntities:")
625
- for category, items in text_results["entities"].items():
626
- if items:
627
- print(f"- {category.capitalize()}: {', '.join(items)}")
628
-
629
- print("\nFollow-up Recommendations:")
630
- for rec in text_results["followup_recommendations"]:
631
- print(f"- {rec}")
 
1
+ import logging
2
+
3
+ from .image_analyzer import XRayImageAnalyzer
4
+ from .text_analyzer import MedicalReportAnalyzer
5
+
6
+
7
+ class MultimodalFusion:
8
+ """
9
+ A class for fusing insights from image analysis and text analysis of medical data.
10
+
11
+ This fusion approach combines the strengths of both modalities:
12
+ - Images provide visual evidence of abnormalities
13
+ - Text reports provide context, history and radiologist interpretations
14
+
15
+ The combined analysis provides a more comprehensive understanding than either modality alone.
16
+ """
17
+
18
+ def __init__(self, image_model=None, text_model=None, device=None):
19
+ """
20
+ Initialize the multimodal fusion module with image and text analyzers.
21
+
22
+ Args:
23
+ image_model (str, optional): Model to use for image analysis
24
+ text_model (str, optional): Model to use for text analysis
25
+ device (str, optional): Device to run models on ('cuda' or 'cpu')
26
+ """
27
+ self.logger = logging.getLogger(__name__)
28
+
29
+ # Determine device
30
+ if device is None:
31
+ import torch
32
+
33
+ self.device = "cuda" if torch.cuda.is_available() else "cpu"
34
+ else:
35
+ self.device = device
36
+
37
+ self.logger.info(f"Using device: {self.device}")
38
+
39
+ # Initialize image analyzer
40
+ try:
41
+ self.image_analyzer = XRayImageAnalyzer(
42
+ model_name=image_model
43
+ if image_model
44
+ else "codewithdark/vit-chest-xray",
45
+ device=self.device,
46
+ )
47
+ self.logger.info("Successfully initialized image analyzer")
48
+ except Exception as e:
49
+ self.logger.error(f"Failed to initialize image analyzer: {e}")
50
+ self.image_analyzer = None
51
+
52
+ # Initialize text analyzer
53
+ try:
54
+ self.text_analyzer = MedicalReportAnalyzer(
55
+ classifier_model=text_model if text_model else "medicalai/ClinicalBERT",
56
+ device=self.device,
57
+ )
58
+ self.logger.info("Successfully initialized text analyzer")
59
+ except Exception as e:
60
+ self.logger.error(f"Failed to initialize text analyzer: {e}")
61
+ self.text_analyzer = None
62
+
63
+ def analyze_image(self, image_path):
64
+ """
65
+ Analyze a medical image.
66
+
67
+ Args:
68
+ image_path (str): Path to the medical image
69
+
70
+ Returns:
71
+ dict: Image analysis results
72
+ """
73
+ if not self.image_analyzer:
74
+ self.logger.warning("Image analyzer not available")
75
+ return {"error": "Image analyzer not available"}
76
+
77
+ try:
78
+ return self.image_analyzer.analyze(image_path)
79
+ except Exception as e:
80
+ self.logger.error(f"Error analyzing image: {e}")
81
+ return {"error": str(e)}
82
+
83
+ def analyze_text(self, text):
84
+ """
85
+ Analyze medical report text.
86
+
87
+ Args:
88
+ text (str): Medical report text
89
+
90
+ Returns:
91
+ dict: Text analysis results
92
+ """
93
+ if not self.text_analyzer:
94
+ self.logger.warning("Text analyzer not available")
95
+ return {"error": "Text analyzer not available"}
96
+
97
+ try:
98
+ return self.text_analyzer.analyze(text)
99
+ except Exception as e:
100
+ self.logger.error(f"Error analyzing text: {e}")
101
+ return {"error": str(e)}
102
+
103
+ def _calculate_agreement_score(self, image_results, text_results):
104
+ """
105
+ Calculate agreement score between image and text analyses.
106
+
107
+ Args:
108
+ image_results (dict): Results from image analysis
109
+ text_results (dict): Results from text analysis
110
+
111
+ Returns:
112
+ float: Agreement score (0-1, where 1 is perfect agreement)
113
+ """
114
+ try:
115
+ # Default to neutral agreement
116
+ agreement = 0.5
117
+
118
+ # Check if image detected abnormality
119
+ image_abnormal = image_results.get("has_abnormality", False)
120
+
121
+ # Check text severity
122
+ text_severity = text_results.get("severity", {}).get("level", "Unknown")
123
+ text_abnormal = text_severity not in ["Normal", "Unknown"]
124
+
125
+ # Basic agreement check
126
+ if image_abnormal == text_abnormal:
127
+ agreement += 0.25
128
+ else:
129
+ agreement -= 0.25
130
+
131
+ # Check if specific findings match
132
+ image_finding = image_results.get("primary_finding", "").lower()
133
+
134
+ # Extract problem entities from text
135
+ problems = text_results.get("entities", {}).get("problem", [])
136
+ problem_text = " ".join(problems).lower()
137
+
138
+ # Check for common keywords in both
139
+ common_conditions = [
140
+ "pneumonia",
141
+ "effusion",
142
+ "nodule",
143
+ "mass",
144
+ "cardiomegaly",
145
+ "opacity",
146
+ "fracture",
147
+ "tumor",
148
+ "edema",
149
+ ]
150
+
151
+ matching_conditions = 0
152
+ total_mentioned = 0
153
+
154
+ for condition in common_conditions:
155
+ in_image = condition in image_finding
156
+ in_text = condition in problem_text
157
+
158
+ if in_image or in_text:
159
+ total_mentioned += 1
160
+
161
+ if in_image and in_text:
162
+ matching_conditions += 1
163
+ agreement += 0.05 # Boost agreement for each matching condition
164
+
165
+ # Calculate condition match ratio if any conditions were mentioned
166
+ if total_mentioned > 0:
167
+ match_ratio = matching_conditions / total_mentioned
168
+ agreement += match_ratio * 0.2
169
+
170
+ # Normalize agreement to 0-1 range
171
+ agreement = max(0, min(1, agreement))
172
+
173
+ return agreement
174
+
175
+ except Exception as e:
176
+ self.logger.error(f"Error calculating agreement score: {e}")
177
+ return 0.5 # Return neutral agreement on error
178
+
179
+ def _get_confidence_weighted_finding(self, image_results, text_results, agreement):
180
+ """
181
+ Get the most confident finding weighted by modality confidence.
182
+
183
+ Args:
184
+ image_results (dict): Results from image analysis
185
+ text_results (dict): Results from text analysis
186
+ agreement (float): Agreement score between modalities
187
+
188
+ Returns:
189
+ str: Most confident finding
190
+ """
191
+ try:
192
+ image_finding = image_results.get("primary_finding", "")
193
+ image_confidence = image_results.get("confidence", 0.5)
194
+
195
+ # For text, use the most severe problem as primary finding
196
+ problems = text_results.get("entities", {}).get("problem", [])
197
+
198
+ text_confidence = text_results.get("severity", {}).get("confidence", 0.5)
199
+
200
+ if not problems:
201
+ # No problems identified in text
202
+ if image_confidence > 0.7:
203
+ return image_finding
204
+ else:
205
+ return "No significant findings"
206
+
207
+ # Simple confidence-weighted selection
208
+ if image_confidence > text_confidence + 0.2:
209
+ return image_finding
210
+ elif problems and text_confidence > image_confidence + 0.2:
211
+ return (
212
+ problems[0]
213
+ if isinstance(problems, list) and problems
214
+ else "Unknown finding"
215
+ )
216
+ else:
217
+ # Similar confidence, check agreement
218
+ if agreement > 0.7:
219
+ # High agreement, try to find the specific condition mentioned in both
220
+ for problem in problems:
221
+ if problem.lower() in image_finding.lower():
222
+ return problem
223
+
224
+ # Default to image finding if high confidence
225
+ if image_confidence > 0.6:
226
+ return image_finding
227
+ elif problems:
228
+ return problems[0]
229
+ else:
230
+ return image_finding
231
+ else:
232
+ # Low agreement, include both perspectives
233
+ if image_finding and problems:
234
+ return f"{image_finding} (image) / {problems[0]} (report)"
235
+ elif image_finding:
236
+ return image_finding
237
+ elif problems:
238
+ return problems[0]
239
+ else:
240
+ return "Findings unclear - review recommended"
241
+
242
+ except Exception as e:
243
+ self.logger.error(f"Error getting weighted finding: {e}")
244
+ return "Unable to determine primary finding"
245
+
246
+ def _merge_followup_recommendations(self, image_results, text_results):
247
+ """
248
+ Merge follow-up recommendations from both modalities.
249
+
250
+ Args:
251
+ image_results (dict): Results from image analysis
252
+ text_results (dict): Results from text analysis
253
+
254
+ Returns:
255
+ list: Combined follow-up recommendations
256
+ """
257
+ try:
258
+ # Get text-based recommendations
259
+ text_recommendations = text_results.get("followup_recommendations", [])
260
+
261
+ # Create image-based recommendations based on findings
262
+ image_recommendations = []
263
+
264
+ if image_results.get("has_abnormality", False):
265
+ primary = image_results.get("primary_finding", "")
266
+ confidence = image_results.get("confidence", 0)
267
+
268
+ if (
269
+ "nodule" in primary.lower()
270
+ or "mass" in primary.lower()
271
+ or "tumor" in primary.lower()
272
+ ):
273
+ image_recommendations.append(
274
+ f"Follow-up imaging recommended to further evaluate {primary}."
275
+ )
276
+ elif "pneumonia" in primary.lower():
277
+ image_recommendations.append(
278
+ "Clinical correlation and follow-up imaging recommended."
279
+ )
280
+ elif confidence > 0.8:
281
+ image_recommendations.append(
282
+ f"Consider follow-up imaging to monitor {primary}."
283
+ )
284
+ elif confidence > 0.5:
285
+ image_recommendations.append(
286
+ "Consider clinical correlation and potential follow-up."
287
+ )
288
+
289
+ # Combine recommendations, removing duplicates
290
+ all_recommendations = text_recommendations + image_recommendations
291
+
292
+ # Remove near-duplicates (similar recommendations)
293
+ unique_recommendations = []
294
+ for rec in all_recommendations:
295
+ if not any(
296
+ self._is_similar_recommendation(rec, existing)
297
+ for existing in unique_recommendations
298
+ ):
299
+ unique_recommendations.append(rec)
300
+
301
+ return unique_recommendations
302
+
303
+ except Exception as e:
304
+ self.logger.error(f"Error merging follow-up recommendations: {e}")
305
+ return ["Follow-up recommended based on findings."]
306
+
307
+ def _is_similar_recommendation(self, rec1, rec2):
308
+ """Check if two recommendations are semantically similar."""
309
+ # Convert to lowercase for comparison
310
+ rec1_lower = rec1.lower()
311
+ rec2_lower = rec2.lower()
312
+
313
+ # Check for significant overlap
314
+ words1 = set(rec1_lower.split())
315
+ words2 = set(rec2_lower.split())
316
+
317
+ # Calculate Jaccard similarity
318
+ intersection = words1.intersection(words2)
319
+ union = words1.union(words2)
320
+
321
+ similarity = len(intersection) / len(union) if union else 0
322
+
323
+ # Consider similar if more than 60% overlap
324
+ return similarity > 0.6
325
+
326
+ def _get_final_severity(self, image_results, text_results, agreement):
327
+ """
328
+ Determine final severity based on both modalities.
329
+
330
+ Args:
331
+ image_results (dict): Results from image analysis
332
+ text_results (dict): Results from text analysis
333
+ agreement (float): Agreement score between modalities
334
+
335
+ Returns:
336
+ dict: Final severity assessment
337
+ """
338
+ try:
339
+ # Get text-based severity
340
+ text_severity = text_results.get("severity", {})
341
+ text_level = text_severity.get("level", "Unknown")
342
+ text_score = text_severity.get("score", 0)
343
+ text_confidence = text_severity.get("confidence", 0.5)
344
+
345
+ # Convert image findings to severity
346
+ image_abnormal = image_results.get("has_abnormality", False)
347
+ image_confidence = image_results.get("confidence", 0.5)
348
+
349
+ # Default severity mapping from image
350
+ image_severity = "Normal" if not image_abnormal else "Moderate"
351
+ image_score = 0 if not image_abnormal else 2.0
352
+
353
+ # Adjust image severity based on specific findings
354
+ primary_finding = image_results.get("primary_finding", "").lower()
355
+
356
+ # Map certain conditions to severity levels
357
+ severity_mapping = {
358
+ "pneumonia": ("Moderate", 2.5),
359
+ "pneumothorax": ("Severe", 3.0),
360
+ "effusion": ("Moderate", 2.0),
361
+ "pulmonary edema": ("Moderate", 2.5),
362
+ "nodule": ("Mild", 1.5),
363
+ "mass": ("Moderate", 2.5),
364
+ "tumor": ("Severe", 3.0),
365
+ "cardiomegaly": ("Mild", 1.5),
366
+ "fracture": ("Moderate", 2.0),
367
+ "consolidation": ("Moderate", 2.0),
368
+ }
369
+
370
+ # Check if any key terms are in the primary finding
371
+ for key, (severity, score) in severity_mapping.items():
372
+ if key in primary_finding:
373
+ image_severity = severity
374
+ image_score = score
375
+ break
376
+
377
+ # Weight based on confidence and agreement
378
+ if agreement > 0.7:
379
+ # High agreement - weight equally
380
+ final_score = (image_score + text_score) / 2
381
+ else:
382
+ # Lower agreement - weight by confidence
383
+ total_confidence = image_confidence + text_confidence
384
+ if total_confidence > 0:
385
+ image_weight = image_confidence / total_confidence
386
+ text_weight = text_confidence / total_confidence
387
+ final_score = (image_score * image_weight) + (
388
+ text_score * text_weight
389
+ )
390
+ else:
391
+ final_score = (image_score + text_score) / 2
392
+
393
+ # Map score to severity level
394
+ severity_levels = {
395
+ 0: "Normal",
396
+ 1: "Mild",
397
+ 2: "Moderate",
398
+ 3: "Severe",
399
+ 4: "Critical",
400
+ }
401
+
402
+ # Round to nearest level
403
+ level_index = round(min(4, max(0, final_score)))
404
+ final_level = severity_levels[level_index]
405
+
406
+ return {
407
+ "level": final_level,
408
+ "score": round(final_score, 1),
409
+ "confidence": round((image_confidence + text_confidence) / 2, 2),
410
+ }
411
+
412
+ except Exception as e:
413
+ self.logger.error(f"Error determining final severity: {e}")
414
+ return {"level": "Unknown", "score": 0, "confidence": 0}
415
+
416
+ def fuse_analyses(self, image_results, text_results):
417
+ """
418
+ Fuse the results from image and text analyses.
419
+
420
+ Args:
421
+ image_results (dict): Results from image analysis
422
+ text_results (dict): Results from text analysis
423
+
424
+ Returns:
425
+ dict: Fused analysis results
426
+ """
427
+ try:
428
+ # Calculate agreement between modalities
429
+ agreement = self._calculate_agreement_score(image_results, text_results)
430
+ self.logger.info(f"Agreement score between modalities: {agreement:.2f}")
431
+
432
+ # Get confidence-weighted primary finding
433
+ primary_finding = self._get_confidence_weighted_finding(
434
+ image_results, text_results, agreement
435
+ )
436
+
437
+ # Merge follow-up recommendations
438
+ followup = self._merge_followup_recommendations(image_results, text_results)
439
+
440
+ # Get final severity assessment
441
+ severity = self._get_final_severity(image_results, text_results, agreement)
442
+
443
+ # Create comprehensive findings list
444
+ findings = []
445
+
446
+ # Add text-extracted findings
447
+ text_findings = text_results.get("findings", [])
448
+ if text_findings:
449
+ findings.extend(text_findings)
450
+
451
+ # Add primary image finding if not already included
452
+ image_finding = image_results.get("primary_finding", "")
453
+ if image_finding and not any(
454
+ image_finding.lower() in f.lower() for f in findings
455
+ ):
456
+ findings.append(f"Image finding: {image_finding}")
457
+
458
+ # Create fused result
459
+ fused_result = {
460
+ "agreement_score": round(agreement, 2),
461
+ "primary_finding": primary_finding,
462
+ "severity": severity,
463
+ "findings": findings,
464
+ "followup_recommendations": followup,
465
+ "modality_results": {"image": image_results, "text": text_results},
466
+ }
467
+
468
+ return fused_result
469
+
470
+ except Exception as e:
471
+ self.logger.error(f"Error fusing analyses: {e}")
472
+ return {
473
+ "error": str(e),
474
+ "modality_results": {"image": image_results, "text": text_results},
475
+ }
476
+
477
+ def analyze(self, image_path, report_text):
478
+ """
479
+ Perform multimodal analysis of medical image and report.
480
+
481
+ Args:
482
+ image_path (str): Path to the medical image
483
+ report_text (str): Medical report text
484
+
485
+ Returns:
486
+ dict: Fused analysis results
487
+ """
488
+ try:
489
+ # Analyze image
490
+ image_results = self.analyze_image(image_path)
491
+
492
+ # Analyze text
493
+ text_results = self.analyze_text(report_text)
494
+
495
+ # Fuse the analyses
496
+ return self.fuse_analyses(image_results, text_results)
497
+
498
+ except Exception as e:
499
+ self.logger.error(f"Error in multimodal analysis: {e}")
500
+ return {"error": str(e)}
501
+
502
+ def get_explanation(self, fused_results):
503
+ """
504
+ Generate a human-readable explanation of the fused analysis.
505
+
506
+ Args:
507
+ fused_results (dict): Results from the fused analysis
508
+
509
+ Returns:
510
+ str: A text explanation of the fused analysis
511
+ """
512
+ try:
513
+ explanation = []
514
+
515
+ # Add overview section
516
+ primary_finding = fused_results.get("primary_finding", "Unknown")
517
+ severity = fused_results.get("severity", {}).get("level", "Unknown")
518
+
519
+ explanation.append("# Medical Analysis Summary\n")
520
+ explanation.append("## Overview\n")
521
+ explanation.append(f"Primary finding: **{primary_finding}**\n")
522
+ explanation.append(f"Severity level: **{severity}**\n")
523
+
524
+ # Add agreement information
525
+ agreement = fused_results.get("agreement_score", 0)
526
+ agreement_text = (
527
+ "High" if agreement > 0.7 else "Moderate" if agreement > 0.4 else "Low"
528
+ )
529
+
530
+ explanation.append(
531
+ f"Image and text analysis agreement: **{agreement_text}** ({agreement:.0%})\n"
532
+ )
533
+
534
+ # Add findings section
535
+ explanation.append("\n## Detailed Findings\n")
536
+ findings = fused_results.get("findings", [])
537
+
538
+ if findings:
539
+ for finding in findings:
540
+ explanation.append(f"- {finding}\n")
541
+ else:
542
+ explanation.append("No specific findings detailed.\n")
543
+
544
+ # Add follow-up section
545
+ explanation.append("\n## Recommended Follow-up\n")
546
+ followups = fused_results.get("followup_recommendations", [])
547
+
548
+ if followups:
549
+ for followup in followups:
550
+ explanation.append(f"- {followup}\n")
551
+ else:
552
+ explanation.append("No specific follow-up recommendations provided.\n")
553
+
554
+ # Add confidence note
555
+ confidence = fused_results.get("severity", {}).get("confidence", 0)
556
+ explanation.append(
557
+ f"\n*Note: This analysis has a confidence level of {confidence:.0%}. "
558
+ f"Please consult with healthcare professionals for official diagnosis.*"
559
+ )
560
+
561
+ return "\n".join(explanation)
562
+
563
+ except Exception as e:
564
+ self.logger.error(f"Error generating explanation: {e}")
565
+ return "Error generating analysis explanation."
566
+
567
+
568
+ # Example usage
569
+ if __name__ == "__main__":
570
+ # Set up logging
571
+ logging.basicConfig(level=logging.INFO)
572
+
573
+ # Test on sample data if available
574
+ import os
575
+
576
+ fusion = MultimodalFusion()
577
+
578
+ # Sample text report
579
+ sample_report = """
580
+ CHEST X-RAY EXAMINATION
581
+
582
+ CLINICAL HISTORY: 55-year-old male with cough and fever.
583
+
584
+ FINDINGS: The heart size is at the upper limits of normal. The lungs are clear without focal consolidation,
585
+ effusion, or pneumothorax. There is mild prominence of the pulmonary vasculature. No pleural effusion is seen.
586
+ There is a small nodular opacity noted in the right lower lobe measuring approximately 8mm, which is suspicious
587
+ and warrants further investigation. The mediastinum is unremarkable. The visualized bony structures show no acute abnormalities.
588
+
589
+ IMPRESSION:
590
+ 1. Mild cardiomegaly.
591
+ 2. 8mm nodular opacity in the right lower lobe, recommend follow-up CT for further evaluation.
592
+ 3. No acute pulmonary parenchymal abnormality.
593
+
594
+ RECOMMENDATIONS: Follow-up chest CT to further characterize the nodular opacity in the right lower lobe.
595
+ """
596
+
597
+ # Check if sample data directory exists and contains images
598
+ sample_dir = "../data/sample"
599
+ if os.path.exists(sample_dir) and os.listdir(sample_dir):
600
+ sample_image = os.path.join(sample_dir, os.listdir(sample_dir)[0])
601
+ print(f"Analyzing sample image: {sample_image}")
602
+
603
+ # Perform multimodal analysis
604
+ fused_results = fusion.analyze(sample_image, sample_report)
605
+ explanation = fusion.get_explanation(fused_results)
606
+
607
+ print("\nFused Analysis Results:")
608
+ print(explanation)
609
+ else:
610
+ print("No sample images found. Only analyzing text report.")
611
+
612
+ # Analyze just the text
613
+ text_results = fusion.analyze_text(sample_report)
614
+
615
+ print("\nText Analysis Results:")
616
+ print(
617
+ f"Severity: {text_results['severity']['level']} (Score: {text_results['severity']['score']})"
618
+ )
619
+
620
+ print("\nKey Findings:")
621
+ for finding in text_results["findings"]:
622
+ print(f"- {finding}")
623
+
624
+ print("\nEntities:")
625
+ for category, items in text_results["entities"].items():
626
+ if items:
627
+ print(f"- {category.capitalize()}: {', '.join(items)}")
628
+
629
+ print("\nFollow-up Recommendations:")
630
+ for rec in text_results["followup_recommendations"]:
631
+ print(f"- {rec}")