alexandra commited on
Commit
9a5c06b
·
1 Parent(s): 96bbf4c
Files changed (4) hide show
  1. README.md +108 -12
  2. app.py +13 -4
  3. config.py +2 -2
  4. pipeline/thyroid_pipeline.py +3 -3
README.md CHANGED
@@ -1,15 +1,111 @@
 
 
 
 
1
  ---
2
- title: Thyroid Pipeline
3
- emoji: 🚀
4
- colorFrom: red
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 6.14.0
8
- python_version: '3.12'
9
- app_file: app.py
10
- pinned: false
11
- license: mit
12
- short_description: Automated thyroid nodule detection, segmentation, classifica
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Thyroid Nodule Analysis Pipeline
2
+
3
+ An automated pipeline for thyroid nodule analysis in ultrasound images, covering detection, segmentation, malignancy classification, and ACR TI-RADS risk scoring, all in a single inference flow through a web interface.
4
+
5
  ---
6
+
7
+ ## What it does
8
+
9
+ Given one or more thyroid ultrasound images, the system runs four sequential steps:
10
+
11
+ 1. **Detection** - localises nodules with bounding boxes (YOLOv8s)
12
+ 2. **Segmentation** - produces a pixel-level mask of each nodule (UNet++ with SCSE attention)
13
+ 3. **Malignancy classification** - estimates benign/malignant probability with a Grad-CAM visual explanation (ResNet50)
14
+ 4. **TI-RADS scoring** - assigns an ACR TI-RADS category (TR1–TR5) from 25 radiomic descriptors (Random Forest)
15
+
16
+ When multiple images of the same patient are uploaded, the results are automatically aggregated at patient level.
17
+
18
+ > **Note:** Results are intended as decision support for a specialist and do not replace clinical judgement.
19
+
20
  ---
21
 
22
+ ## Project structure
23
+
24
+ ```
25
+ thyroid-pipeline/
26
+ ├── models/ # Trained model weights
27
+ │ ├── yolo_finetuned_thyroidxl.pt
28
+ │ ├── best_unet_finetuned.pth
29
+ │ ├── resnet50_finetuned_thyroidxl.pth
30
+ │ ├── best_model_expB_RandomForest.pkl
31
+ │ └── train_features_patient_level_unetmask.csv
32
+
33
+ ├── pipeline/
34
+ │ ├── detector.py # YOLOv8s nodule detection
35
+ │ ├── segmentor.py # UNet++ pixel-level segmentation
36
+ │ ├── classifier.py # ResNet50 malignancy classification + Grad-CAM
37
+ │ ├── feature_extractor.py # 25 radiomic descriptors
38
+ │ ├── tirads_scorer.py # Random Forest TI-RADS scoring (TR1–TR5)
39
+ │ └── aggregator.py # Patient-level aggregation
40
+
41
+ ├── utils/
42
+ │ └── visualization.py # Detection and segmentation overlays
43
+
44
+ ├── app.py # Gradio web interface
45
+ ├── config.py # Paths, thresholds, TI-RADS lookup tables
46
+ ├── thyroid_pipeline.py # Main pipeline orchestrator
47
+ └── requirements.txt
48
+ ```
49
+
50
+ ---
51
+
52
+ ## Installation
53
+
54
+ **Requirements:** Python 3.10+, and a CUDA-capable GPU (recommended) or CPU.
55
+
56
+ ```bash
57
+ # Clone the repository
58
+ git clone <repo-url>
59
+ cd thyroid-pipeline
60
+
61
+ # Create and activate a virtual environment
62
+ python -m venv venv
63
+ # Windows:
64
+ venv\Scripts\activate
65
+ # Linux / macOS:
66
+ source venv/bin/activate
67
+
68
+ # Install dependencies
69
+ pip install -r requirements.txt
70
+ ```
71
+
72
+ Download the model weights and place them in the `models/` folder.
73
+
74
+ ---
75
+
76
+ ## Running the app
77
+
78
+ ```bash
79
+ python app.py
80
+ ```
81
+
82
+ The interface will be available at `http://localhost:7860`.
83
+
84
+ The app is also deployed on Hugging Face Spaces: https://huggingface.co/spaces/Ale22M/thyroid-pipeline and can be used without any local setup.
85
+
86
+ ---
87
+
88
+ ## Usage
89
+
90
+ **Single image mode** — upload one `.png` / `.jpg` / `.jpeg` ultrasound image and click *Analyze*. The interface returns:
91
+ - Detection overlay (bounding box)
92
+ - Segmentation overlay (nodule mask)
93
+ - Nodule crop fed to the classifier
94
+ - Grad-CAM heatmap
95
+ - Malignancy probability and label
96
+ - Top radiomic features
97
+
98
+ **Patient mode** - upload multiple images of the same patient. In addition to per-image results, the system aggregates everything at patient level and returns a final malignancy classification and an ACR TI-RADS category with the corresponding biopsy recommendation.
99
+
100
+ ---
101
+
102
+ ## Dependencies
103
+
104
+ ultralytics -> YOLOv8 detection
105
+ segmentation-models-pytorch -> UNet++ with pretrained encoders
106
+ grad-cam -> Grad-CAM explanations
107
+ scikit-learn -> Random Forest TI-RADS classifier
108
+ scikit-image, scipy, opencv-python -> radiomic feature extraction
109
+ gradio -> web interface
110
+
111
+ Full pinned versions are in requirements.txt.
app.py CHANGED
@@ -8,7 +8,7 @@ Supports two analysis modes:
8
  feature aggregation.
9
 
10
  File validation:
11
- Only images are accepted (.png, .jpg, .jpeg, .bmp, .tiff, .webp).
12
  Invalid formats produce a clear error message before any model inference.
13
 
14
  Partial-failure handling:
@@ -21,7 +21,7 @@ import os
21
  # ZeroGPU
22
  _ON_HF = os.environ.get("SPACE_ID") is not None
23
  if _ON_HF:
24
- import spaces
25
 
26
  import time
27
  import numpy as np
@@ -431,6 +431,14 @@ figure figcaption {
431
  border-top: none !important;
432
  padding-top: 4px !important;
433
  }
 
 
 
 
 
 
 
 
434
  """
435
 
436
  with gr.Blocks(title="Thyroid Nodule Analysis Pipeline") as demo:
@@ -455,7 +463,7 @@ with gr.Blocks(title="Thyroid Nodule Analysis Pipeline") as demo:
455
  input_files = gr.File(
456
  label = "Upload ultrasound image(s) - single image or multiple views of the same patient",
457
  file_count = "multiple",
458
- file_types = [".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"],
459
  elem_id = "file-upload-box",
460
  )
461
  run_btn = gr.Button("Analyze", variant="primary", size="lg")
@@ -478,8 +486,9 @@ with gr.Blocks(title="Thyroid Nodule Analysis Pipeline") as demo:
478
  label = "Pipeline outputs",
479
  show_label = True,
480
  columns = 5,
481
- height = 730,
482
  object_fit = "contain",
 
483
  )
484
 
485
  gr.Markdown("---")
 
8
  feature aggregation.
9
 
10
  File validation:
11
+ Only images are accepted (.png, .jpg, .jpeg).
12
  Invalid formats produce a clear error message before any model inference.
13
 
14
  Partial-failure handling:
 
21
  # ZeroGPU
22
  _ON_HF = os.environ.get("SPACE_ID") is not None
23
  if _ON_HF:
24
+ import spaces # type: ignore
25
 
26
  import time
27
  import numpy as np
 
431
  border-top: none !important;
432
  padding-top: 4px !important;
433
  }
434
+
435
+ #gallery-no-scroll,
436
+ #gallery-no-scroll .grid-wrap,
437
+ #gallery-no-scroll .grid-container {
438
+ max-height: none !important;
439
+ height: auto !important;
440
+ overflow: visible !important;
441
+ }
442
  """
443
 
444
  with gr.Blocks(title="Thyroid Nodule Analysis Pipeline") as demo:
 
463
  input_files = gr.File(
464
  label = "Upload ultrasound image(s) - single image or multiple views of the same patient",
465
  file_count = "multiple",
466
+ file_types = [".png", ".jpg", ".jpeg"],
467
  elem_id = "file-upload-box",
468
  )
469
  run_btn = gr.Button("Analyze", variant="primary", size="lg")
 
486
  label = "Pipeline outputs",
487
  show_label = True,
488
  columns = 5,
489
+ height = "auto",
490
  object_fit = "contain",
491
+ elem_id = "gallery-no-scroll",
492
  )
493
 
494
  gr.Markdown("---")
config.py CHANGED
@@ -109,5 +109,5 @@ TIRADS_FNAB = {
109
  }
110
 
111
  # Accepted image formats for upload validation
112
- ACCEPTED_EXTENSIONS = {".png", ".jpg", ".jpeg", ".bmp", ".tiff", ".tif", ".webp"}
113
- ACCEPTED_MIME_TYPES = {"image/png", "image/jpeg", "image/bmp", "image/tiff", "image/webp"}
 
109
  }
110
 
111
  # Accepted image formats for upload validation
112
+ ACCEPTED_EXTENSIONS = {".png", ".jpg", ".jpeg"}
113
+ ACCEPTED_MIME_TYPES = {"image/png", "image/jpeg"}
pipeline/thyroid_pipeline.py CHANGED
@@ -275,7 +275,7 @@ class ThyroidPipeline:
275
  result.label = label
276
  result.image_crop = Image.fromarray(crop_rgb)
277
  result.image_gradcam = Image.fromarray(cam_img)
278
- print(f"[Pipeline] ResNet50 {label} (p={prob_malign:.3f})")
279
 
280
  # Update detection overlay with classification colour
281
  result.image_detection = Image.fromarray(
@@ -312,7 +312,7 @@ class ThyroidPipeline:
312
  feats_list : list[dict] = []
313
 
314
  for idx, pil_img in enumerate(images_pil):
315
- print(f"[ThyroidPipeline] ── Image {idx + 1}/{len(images_pil)}")
316
  res = self._process_single(pil_img)
317
  per_image_results.append(res)
318
 
@@ -348,7 +348,7 @@ class ThyroidPipeline:
348
  patient.tirads_class = tr_class
349
  patient.tirads_risk = self.scorer.get_risk_label(tr_class)
350
  patient.fnab_recommendation = self.scorer.get_fnab_recommendation(tr_class)
351
- print(f"[ThyroidPipeline] Patient TI-RADS TR{tr_class}")
352
 
353
  patient.elapsed_total_s = time.perf_counter() - t0
354
  print(f"[ThyroidPipeline] Patient session done in "
 
275
  result.label = label
276
  result.image_crop = Image.fromarray(crop_rgb)
277
  result.image_gradcam = Image.fromarray(cam_img)
278
+ print(f"[Pipeline] ResNet50 -> {label} (p={prob_malign:.3f})")
279
 
280
  # Update detection overlay with classification colour
281
  result.image_detection = Image.fromarray(
 
312
  feats_list : list[dict] = []
313
 
314
  for idx, pil_img in enumerate(images_pil):
315
+ print(f"[ThyroidPipeline] - Image {idx + 1}/{len(images_pil)}")
316
  res = self._process_single(pil_img)
317
  per_image_results.append(res)
318
 
 
348
  patient.tirads_class = tr_class
349
  patient.tirads_risk = self.scorer.get_risk_label(tr_class)
350
  patient.fnab_recommendation = self.scorer.get_fnab_recommendation(tr_class)
351
+ print(f"[ThyroidPipeline] Patient TI-RADS -> TR{tr_class}")
352
 
353
  patient.elapsed_total_s = time.perf_counter() - t0
354
  print(f"[ThyroidPipeline] Patient session done in "