VibecoderMcSwaggins commited on
Commit
d77e99f
·
unverified ·
1 Parent(s): 3f8bf9c

feat(phase-4): Gradio UI with NiiVue visualization (#5)

Browse files

* feat(phase-4): Gradio UI with NiiVue visualization

Implements Phase 4 of the stroke-deepisles-demo project:

UI Components:
- Gradio 5 application with case selection and segmentation
- NiiVue WebGL viewer with base64 data URLs for 3D visualization
- Matplotlib slice comparison views (axial/coronal/sagittal)
- Settings accordion for fast mode and ground truth toggle

Data Path Refactoring:
- Renamed data/scratch/ to data/isles24/ for clarity
- Added data/discovery/ for schema reports
- Updated all specs and code to use new paths
- Added data/README.md documenting folder structure

Files Added:
- src/stroke_deepisles_demo/ui/{app,viewer,components}.py
- app.py (HF Spaces entry point)
- tests/ui/{test_app,test_viewer}.py
- data/README.md

Quality:
- 88 tests passing (15 new UI tests)
- ruff + mypy clean
- Lazy initialization pattern to avoid import-time side effects

* fix: address CodeRabbit review feedback

- data/README.md: Add `text` language to code blocks (markdownlint MD040)
- test_viewer.py: Move matplotlib.use("Agg") before pyplot import (real bug)
- components.py: Add logger.warning for exception handling (debugging aid)
- app.py: Add explicit noqa comment explaining runtime import need

.gitignore CHANGED
@@ -206,6 +206,9 @@ marimo/_static/
206
  marimo/_lsp/
207
  __marimo__/
208
 
209
- # Data Discovery (per docs/specs/data-discovery.md)
210
- data/scratch/*
211
- !data/scratch/.gitkeep
 
 
 
 
206
  marimo/_lsp/
207
  __marimo__/
208
 
209
+ # Local ISLES24 data (git-ignored, download separately)
210
+ data/isles24/
211
+
212
+ # Discovery artifacts (schema reports, samples)
213
+ data/discovery/
214
+ data/scratch/
app.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entry point for Hugging Face Spaces deployment."""
2
+
3
+ import gradio as gr
4
+
5
+ from stroke_deepisles_demo.ui.app import get_demo
6
+
7
+ # Create the demo instance at module level for Gradio
8
+ demo = get_demo()
9
+
10
+ if __name__ == "__main__":
11
+ demo.launch(theme=gr.themes.Soft(), css="footer {visibility: hidden}")
data/README.md ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data Directory
2
+
3
+ This folder contains local neuroimaging data for the stroke-deepisles-demo project.
4
+
5
+ ## Structure
6
+
7
+ ```text
8
+ data/
9
+ ├── README.md # This file (tracked)
10
+ ├── isles24/ # ISLES24 NIfTI files (gitignored)
11
+ │ ├── Images-DWI/ # DWI volumes (149 files)
12
+ │ ├── Images-ADC/ # ADC maps (149 files)
13
+ │ └── Masks/ # Ground truth lesion masks (149 files)
14
+ └── discovery/ # Schema reports (gitignored)
15
+ └── isles24_schema_report.txt
16
+ ```
17
+
18
+ ## Setup
19
+
20
+ 1. Download ISLES24-MR-Lite from [HuggingFace](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite)
21
+ 2. Extract the ZIP files into `data/isles24/`:
22
+ - `Images-DWI.zip` → `data/isles24/Images-DWI/`
23
+ - `Images-ADC.zip` → `data/isles24/Images-ADC/`
24
+ - `Masks.zip` → `data/isles24/Masks/`
25
+
26
+ ## File Naming Convention
27
+
28
+ Files follow BIDS-like naming:
29
+ ```text
30
+ sub-stroke{XXXX}_ses-02_{modality}.nii.gz
31
+ ```
32
+
33
+ Example: `sub-stroke0005_ses-02_dwi.nii.gz`
34
+
35
+ ## Notes
36
+
37
+ - All data files are gitignored to avoid committing large binaries
38
+ - The `discovery/` folder contains schema reports from data exploration scripts
39
+ - See `docs/specs/02-phase-1-data-access.md` for detailed data loading documentation
data/scratch/.gitkeep DELETED
File without changes
docs/specs/00-context.md CHANGED
@@ -40,9 +40,9 @@ This showcases that:
40
 
41
  **The original ISLES24-MR-Lite dataset is NOT properly uploaded to HuggingFace.**
42
 
43
- It's just raw ZIP files dumped on HF, not a proper Dataset with parquet/Arrow format. This means `load_dataset()` fails. See `data/scratch/isles24_schema_report.txt` for full details.
44
 
45
- **Workaround**: We extracted the ZIPs locally to `data/scratch/isles24_extracted/` (git-ignored) and will implement a file-based loader first. Later, we'll re-upload properly and verify full HF consumption.
46
 
47
  ## why we need tobias's datasets fork
48
 
@@ -75,20 +75,20 @@ We pin to this branch until upstream merges the PRs.
75
  ### 1. data source: ISLES24-MR-Lite
76
 
77
  - **HF Dataset**: [YongchengYAO/ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite) (**BROKEN** - raw ZIPs, not proper dataset)
78
- - **Local extracted**: `data/scratch/isles24_extracted/` (git-ignored)
79
  - **Content**: 149 acute stroke MRI cases with DWI, ADC, and manual infarct masks
80
  - **Origin**: Subset of ISLES 2024 challenge data
81
  - **Why suitable**: DeepISLES was trained on ISLES 2022, so ISLES24 is an **external** test set (no data leakage)
82
 
83
  **File structure** (after extraction):
84
  ```
85
- data/scratch/isles24_extracted/
86
  ├── Images-DWI/sub-stroke{XXXX}_ses-02_dwi.nii.gz # 149 files
87
  ├── Images-ADC/sub-stroke{XXXX}_ses-02_adc.nii.gz # 149 files
88
  └── Masks/sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz # 149 files
89
  ```
90
 
91
- **Schema reference**: `data/scratch/isles24_schema_report.txt`
92
 
93
  ### 2. model: DeepISLES
94
 
 
40
 
41
  **The original ISLES24-MR-Lite dataset is NOT properly uploaded to HuggingFace.**
42
 
43
+ It's just raw ZIP files dumped on HF, not a proper Dataset with parquet/Arrow format. This means `load_dataset()` fails. See `data/discovery/isles24_schema_report.txt` for full details.
44
 
45
+ **Workaround**: We extracted the ZIPs locally to `data/isles24/` (git-ignored) and will implement a file-based loader first. Later, we'll re-upload properly and verify full HF consumption.
46
 
47
  ## why we need tobias's datasets fork
48
 
 
75
  ### 1. data source: ISLES24-MR-Lite
76
 
77
  - **HF Dataset**: [YongchengYAO/ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite) (**BROKEN** - raw ZIPs, not proper dataset)
78
+ - **Local extracted**: `data/isles24/` (git-ignored)
79
  - **Content**: 149 acute stroke MRI cases with DWI, ADC, and manual infarct masks
80
  - **Origin**: Subset of ISLES 2024 challenge data
81
  - **Why suitable**: DeepISLES was trained on ISLES 2022, so ISLES24 is an **external** test set (no data leakage)
82
 
83
  **File structure** (after extraction):
84
  ```
85
+ data/isles24/
86
  ├── Images-DWI/sub-stroke{XXXX}_ses-02_dwi.nii.gz # 149 files
87
  ├── Images-ADC/sub-stroke{XXXX}_ses-02_adc.nii.gz # 149 files
88
  └── Masks/sub-stroke{XXXX}_ses-02_lesion-msk.nii.gz # 149 files
89
  ```
90
 
91
+ **Schema reference**: `data/discovery/isles24_schema_report.txt`
92
 
93
  ### 2. model: DeepISLES
94
 
docs/specs/02-phase-1-data-access.md CHANGED
@@ -14,7 +14,7 @@ Implement a data loading layer that provides typed access to ISLES24 neuroimagin
14
  | Columns: `dwi`, `adc`, `mask`, `participant_id` | No columns - just raw ZIP files |
15
  | Parquet/Arrow format | Three ZIP archives dumped on HF |
16
 
17
- **Evidence**: `data/scratch/isles24_schema_report.txt`
18
 
19
  This means the demo must be built in phases:
20
  1. **Phase 1A**: Local file loader (works NOW with extracted data)
@@ -29,7 +29,7 @@ This means the demo must be built in phases:
29
  ### data location
30
 
31
  ```
32
- data/scratch/isles24_extracted/ # Git-ignored
33
  ├── Images-DWI/ # 149 files
34
  │ └── sub-stroke{XXXX}_ses-02_dwi.nii.gz
35
  ├── Images-ADC/ # 149 files
@@ -87,7 +87,7 @@ class DatasetInfo:
87
 
88
 
89
  def load_isles_dataset(
90
- source: str | Path = "data/scratch/isles24_extracted",
91
  *,
92
  local_mode: bool = True, # Default to local for now
93
  ) -> LocalDataset:
@@ -288,7 +288,7 @@ def test_get_case_returns_case_files(synthetic_isles_dir):
288
  ### done criteria (phase 1a)
289
 
290
  - [ ] `uv run pytest tests/data/ -v` passes
291
- - [ ] Can load all 149 cases from `data/scratch/isles24_extracted/`
292
  - [ ] `list_case_ids()` returns 149 subject IDs
293
  - [ ] `get_case("sub-stroke0005")` returns valid CaseFiles
294
  - [ ] Type checking passes: `uv run mypy src/stroke_deepisles_demo/data/`
 
14
  | Columns: `dwi`, `adc`, `mask`, `participant_id` | No columns - just raw ZIP files |
15
  | Parquet/Arrow format | Three ZIP archives dumped on HF |
16
 
17
+ **Evidence**: `data/discovery/isles24_schema_report.txt`
18
 
19
  This means the demo must be built in phases:
20
  1. **Phase 1A**: Local file loader (works NOW with extracted data)
 
29
  ### data location
30
 
31
  ```
32
+ data/isles24/ # Git-ignored
33
  ├── Images-DWI/ # 149 files
34
  │ └── sub-stroke{XXXX}_ses-02_dwi.nii.gz
35
  ├── Images-ADC/ # 149 files
 
87
 
88
 
89
  def load_isles_dataset(
90
+ source: str | Path = "data/isles24",
91
  *,
92
  local_mode: bool = True, # Default to local for now
93
  ) -> LocalDataset:
 
288
  ### done criteria (phase 1a)
289
 
290
  - [ ] `uv run pytest tests/data/ -v` passes
291
+ - [ ] Can load all 149 cases from `data/isles24/`
292
  - [ ] `list_case_ids()` returns 149 subject IDs
293
  - [ ] `get_case("sub-stroke0005")` returns valid CaseFiles
294
  - [ ] Type checking passes: `uv run mypy src/stroke_deepisles_demo/data/`
docs/specs/data-discovery.md CHANGED
@@ -23,10 +23,9 @@ scripts/discovery/
23
  ### data & artifacts
24
  All downloaded samples, temporary outputs, and schema reports reside in:
25
  ```
26
- data/scratch/
27
- ├── .gitkeep # Tracked
28
- ├── schema_report.txt # Generated report
29
- └── samples/ # Raw data samples (IGNORED)
30
  ```
31
 
32
  ## discovery workflow
@@ -44,11 +43,11 @@ Write a focused script in `scripts/discovery/` that:
44
  ### 2. execution
45
  Run the script from the project root:
46
  ```bash
47
- uv run scripts/discovery/inspect_hf_dataset.py > data/scratch/schema_report.txt
48
  ```
49
 
50
  ### 3. verification
51
- Manually review `data/scratch/schema_report.txt`.
52
  - **Check**: Do column names match `CaseAdapter` expectations?
53
  - **Check**: Are file paths strings or objects?
54
  - **Check**: Are required fields (DWI, ADC) actually present?
@@ -62,6 +61,6 @@ If the report contradicts the code/specs:
62
  ## git configuration
63
  Ensure `.gitignore` includes:
64
  ```gitignore
65
- data/scratch/*
66
- !data/scratch/.gitkeep
67
  ```
 
23
  ### data & artifacts
24
  All downloaded samples, temporary outputs, and schema reports reside in:
25
  ```
26
+ data/
27
+ ├── isles24/ # Extracted ISLES24 data (IGNORED)
28
+ └── discovery/ # Schema reports, samples (IGNORED)
 
29
  ```
30
 
31
  ## discovery workflow
 
43
  ### 2. execution
44
  Run the script from the project root:
45
  ```bash
46
+ uv run scripts/discovery/inspect_hf_dataset.py > data/discovery/schema_report.txt
47
  ```
48
 
49
  ### 3. verification
50
+ Manually review `data/discovery/schema_report.txt`.
51
  - **Check**: Do column names match `CaseAdapter` expectations?
52
  - **Check**: Are file paths strings or objects?
53
  - **Check**: Are required fields (DWI, ADC) actually present?
 
61
  ## git configuration
62
  Ensure `.gitignore` includes:
63
  ```gitignore
64
+ data/isles24/
65
+ data/discovery/
66
  ```
pyproject.toml CHANGED
@@ -33,6 +33,7 @@ dependencies = [
33
 
34
  # UI (Gradio 5.x)
35
  "gradio>=5.0.0",
 
36
 
37
  # Networking
38
  "requests>=2.0.0",
 
33
 
34
  # UI (Gradio 5.x)
35
  "gradio>=5.0.0",
36
+ "matplotlib>=3.8.0",
37
 
38
  # Networking
39
  "requests>=2.0.0",
scripts/discovery/inspect_isles24.py CHANGED
@@ -7,7 +7,7 @@ to document its exact schema before building adapters.
7
 
8
  Per: docs/specs/data-discovery.md
9
 
10
- Output: data/scratch/isles24_schema_report.txt
11
  """
12
 
13
  from __future__ import annotations
@@ -20,7 +20,7 @@ from typing import Any
20
 
21
  # Constants
22
  DATASET_ID = "YongchengYAO/ISLES24-MR-Lite"
23
- OUTPUT_DIR = Path(__file__).parent.parent.parent / "data" / "scratch"
24
  REPORT_FILE = OUTPUT_DIR / "isles24_schema_report.txt"
25
 
26
 
 
7
 
8
  Per: docs/specs/data-discovery.md
9
 
10
+ Output: data/discovery/isles24_schema_report.txt
11
  """
12
 
13
  from __future__ import annotations
 
20
 
21
  # Constants
22
  DATASET_ID = "YongchengYAO/ISLES24-MR-Lite"
23
+ OUTPUT_DIR = Path(__file__).parent.parent.parent / "data" / "discovery"
24
  REPORT_FILE = OUTPUT_DIR / "isles24_schema_report.txt"
25
 
26
 
src/stroke_deepisles_demo/data/loader.py CHANGED
@@ -21,7 +21,7 @@ class DatasetInfo:
21
 
22
 
23
  def load_isles_dataset(
24
- source: str | Path = "data/scratch/isles24_extracted",
25
  *,
26
  local_mode: bool = True, # Default to local for now
27
  ) -> LocalDataset:
 
21
 
22
 
23
  def load_isles_dataset(
24
+ source: str | Path = "data/isles24",
25
  *,
26
  local_mode: bool = True, # Default to local for now
27
  ) -> LocalDataset:
src/stroke_deepisles_demo/ui/__init__.py CHANGED
@@ -1 +1,5 @@
1
- """Gradio UI module for stroke-deepisles-demo."""
 
 
 
 
 
1
+ """UI module for stroke-deepisles-demo."""
2
+
3
+ from stroke_deepisles_demo.ui.app import create_app, get_demo
4
+
5
+ __all__ = ["create_app", "get_demo"]
src/stroke_deepisles_demo/ui/app.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Main Gradio application for stroke-deepisles-demo."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import gradio as gr
9
+ from matplotlib.figure import Figure # noqa: TC002 - needed at runtime for Gradio
10
+
11
+ from stroke_deepisles_demo.pipeline import run_pipeline_on_case
12
+ from stroke_deepisles_demo.ui.components import (
13
+ create_case_selector,
14
+ create_results_display,
15
+ create_settings_accordion,
16
+ )
17
+ from stroke_deepisles_demo.ui.viewer import (
18
+ create_niivue_html,
19
+ nifti_to_data_url,
20
+ render_slice_comparison,
21
+ )
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ def run_segmentation(
27
+ case_id: str, fast_mode: bool, show_ground_truth: bool
28
+ ) -> tuple[str, Figure | None, dict[str, Any], str | None, str]:
29
+ """
30
+ Run segmentation and return results for display.
31
+
32
+ Args:
33
+ case_id: Selected case identifier
34
+ fast_mode: Whether to use fast mode (SEALS)
35
+ show_ground_truth: Whether to show ground truth in plots
36
+
37
+ Returns:
38
+ Tuple of (niivue_html, slice_fig, metrics_dict, download_path, status_msg)
39
+ """
40
+ if not case_id:
41
+ return (
42
+ "",
43
+ None,
44
+ {},
45
+ None,
46
+ "Please select a case first.",
47
+ )
48
+
49
+ try:
50
+ logger.info("Running segmentation for %s", case_id)
51
+ result = run_pipeline_on_case(case_id, fast=fast_mode, compute_dice=True)
52
+
53
+ # 1. NiiVue Visualization
54
+ # We need data URLs for the browser
55
+ # Note: This reads the file content into memory (base64)
56
+ # For large datasets, this might be heavy, but for ISLES24-MR-Lite (cropped) it's fine.
57
+ # Assuming DWI is the background
58
+ dwi_path = result.input_files["dwi"]
59
+ dwi_url = nifti_to_data_url(dwi_path)
60
+
61
+ mask_url = None
62
+ if result.prediction_mask and result.prediction_mask.exists():
63
+ mask_url = nifti_to_data_url(result.prediction_mask)
64
+
65
+ niivue_html = create_niivue_html(
66
+ dwi_url,
67
+ mask_url,
68
+ height=500,
69
+ )
70
+
71
+ # 2. Slice Comparison (Static Plot)
72
+ gt_path = result.ground_truth if show_ground_truth else None
73
+ slice_fig = render_slice_comparison(
74
+ dwi_path=dwi_path,
75
+ prediction_path=result.prediction_mask,
76
+ ground_truth_path=gt_path,
77
+ orientation="axial",
78
+ )
79
+
80
+ # 3. Metrics
81
+ metrics = {
82
+ "case_id": result.case_id,
83
+ "dice_score": result.dice_score,
84
+ "elapsed_seconds": round(result.elapsed_seconds, 2),
85
+ "model": "SEALS (Fast)" if fast_mode else "Ensemble",
86
+ }
87
+
88
+ # 4. Download
89
+ download_path = str(result.prediction_mask)
90
+
91
+ status_msg = (
92
+ f"Success! Dice: {result.dice_score:.3f}"
93
+ if result.dice_score is not None
94
+ else "Success!"
95
+ )
96
+
97
+ return niivue_html, slice_fig, metrics, download_path, status_msg
98
+
99
+ except Exception as e:
100
+ logger.exception("Error running segmentation")
101
+ return "", None, {}, None, f"Error: {e!s}"
102
+
103
+
104
+ def create_app() -> gr.Blocks:
105
+ """
106
+ Create the Gradio application.
107
+
108
+ Returns:
109
+ Configured gr.Blocks application
110
+ """
111
+ with gr.Blocks(
112
+ title="Stroke Lesion Segmentation Demo",
113
+ ) as demo:
114
+ # Header
115
+ gr.Markdown("""
116
+ # 🧠 Stroke Lesion Segmentation Demo
117
+
118
+ This demo runs [DeepISLES](https://github.com/ezequieldlrosa/DeepIsles)
119
+ stroke segmentation on cases from
120
+ [ISLES24-MR-Lite](https://huggingface.co/datasets/YongchengYAO/ISLES24-MR-Lite).
121
+
122
+ **Note:** The pipeline runs inside a Docker container. First run might be slow (pulling image).
123
+ """)
124
+
125
+ with gr.Row():
126
+ # Left column: Controls
127
+ with gr.Column(scale=1):
128
+ case_selector = create_case_selector()
129
+ settings = create_settings_accordion()
130
+ run_btn = gr.Button("Run Segmentation", variant="primary")
131
+ status = gr.Textbox(label="Status", interactive=False)
132
+
133
+ # Right column: Results
134
+ with gr.Column(scale=2):
135
+ results = create_results_display()
136
+
137
+ # Event handlers
138
+ run_btn.click(
139
+ fn=run_segmentation,
140
+ inputs=[
141
+ case_selector,
142
+ settings["fast_mode"],
143
+ settings["show_ground_truth"],
144
+ ],
145
+ outputs=[
146
+ results["niivue_viewer"],
147
+ results["slice_plot"],
148
+ results["metrics"],
149
+ results["download"],
150
+ status,
151
+ ],
152
+ )
153
+
154
+ return demo # type: ignore[no-any-return]
155
+
156
+
157
+ # Lazy initialization pattern
158
+ _demo: gr.Blocks | None = None
159
+
160
+
161
+ def get_demo() -> gr.Blocks:
162
+ """Get the global demo instance, creating it if necessary."""
163
+ global _demo
164
+ if _demo is None:
165
+ _demo = create_app()
166
+ return _demo
167
+
168
+
169
+ if __name__ == "__main__":
170
+ get_demo().launch(theme=gr.themes.Soft(), css="footer {visibility: hidden}")
src/stroke_deepisles_demo/ui/components.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reusable UI components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import gradio as gr
8
+
9
+ from stroke_deepisles_demo.data import list_case_ids
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def create_case_selector() -> gr.Dropdown:
15
+ """
16
+ Create a dropdown for selecting cases.
17
+
18
+ Returns:
19
+ Configured gr.Dropdown component
20
+ """
21
+ try:
22
+ case_ids = list_case_ids()
23
+ except Exception:
24
+ logger.warning("Failed to load case IDs, using fallback", exc_info=True)
25
+ case_ids = ["Error loading cases"]
26
+
27
+ return gr.Dropdown(
28
+ choices=case_ids,
29
+ value=case_ids[0] if case_ids else None,
30
+ label="Select Case",
31
+ info="Choose a case from ISLES24-MR-Lite",
32
+ filterable=True,
33
+ )
34
+
35
+
36
+ def create_results_display() -> dict[str, gr.components.Component]:
37
+ """
38
+ Create results display components.
39
+
40
+ Returns:
41
+ Dictionary of component name -> gr.Component
42
+ """
43
+ # Using gr.Group to group them visually
44
+ with gr.Group():
45
+ # NiiVue visualization uses HTML
46
+ niivue_viewer = gr.HTML(label="Interactive 3D Viewer")
47
+
48
+ # Slice comparisons (Matplotlib)
49
+ slice_plot = gr.Plot(label="Slice Comparison")
50
+
51
+ metrics = gr.JSON(label="Metrics")
52
+ download = gr.File(label="Download Prediction")
53
+
54
+ return {
55
+ "niivue_viewer": niivue_viewer,
56
+ "slice_plot": slice_plot,
57
+ "metrics": metrics,
58
+ "download": download,
59
+ }
60
+
61
+
62
+ def create_settings_accordion() -> dict[str, gr.components.Component]:
63
+ """
64
+ Create expandable settings section.
65
+
66
+ Returns:
67
+ Dictionary of setting name -> gr.Component
68
+ """
69
+ with gr.Accordion("Advanced Settings", open=False):
70
+ fast_mode = gr.Checkbox(
71
+ value=True,
72
+ label="Fast Mode (SEALS)",
73
+ info="Run SEALS only (ISLES'22 winner, requires DWI+ADC). Disable for full ensemble (requires FLAIR).",
74
+ )
75
+ show_ground_truth = gr.Checkbox(
76
+ value=True,
77
+ label="Show Ground Truth",
78
+ info="Display ground truth mask if available",
79
+ )
80
+
81
+ return {
82
+ "fast_mode": fast_mode,
83
+ "show_ground_truth": show_ground_truth,
84
+ }
src/stroke_deepisles_demo/ui/viewer.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Neuroimaging visualization for Gradio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from typing import TYPE_CHECKING
7
+
8
+ import matplotlib.pyplot as plt
9
+ import numpy as np
10
+
11
+ from stroke_deepisles_demo.metrics import load_nifti_as_array
12
+
13
+ if TYPE_CHECKING:
14
+ from pathlib import Path
15
+
16
+ from matplotlib.figure import Figure
17
+
18
+
19
+ def nifti_to_data_url(nifti_path: Path) -> str:
20
+ """
21
+ Convert NIfTI file to base64 data URL for NiiVue.
22
+
23
+ Args:
24
+ nifti_path: Path to NIfTI file
25
+
26
+ Returns:
27
+ Data URL string
28
+ """
29
+ # We load the raw bytes directly to avoid re-serialization overhead if possible
30
+ # But nibabel might be safer to ensure valid nifti if we were manipulating
31
+ # Here we just want the file content.
32
+ with nifti_path.open("rb") as f:
33
+ nifti_bytes = f.read()
34
+
35
+ nifti_b64 = base64.b64encode(nifti_bytes).decode("utf-8")
36
+ return f"data:application/octet-stream;base64,{nifti_b64}"
37
+
38
+
39
+ def get_slice_at_max_lesion(
40
+ mask_path: Path,
41
+ orientation: str = "axial",
42
+ ) -> int:
43
+ """
44
+ Find slice index with maximum lesion area.
45
+
46
+ Useful for displaying the most informative slice.
47
+
48
+ Args:
49
+ mask_path: Path to lesion mask NIfTI
50
+ orientation: Slice orientation ("axial", "coronal", "sagittal")
51
+
52
+ Returns:
53
+ Slice index with maximum lesion area
54
+ """
55
+ data, _ = load_nifti_as_array(mask_path)
56
+
57
+ # Determine axes to sum over
58
+ # Default NIfTI (RAS+): x=sagittal, y=coronal, z=axial
59
+ # array indices: [x, y, z]
60
+ if orientation == "sagittal":
61
+ # Sum over y and z (axes 1, 2) -> result shape [x]
62
+ lesion_counts = np.sum(data > 0, axis=(1, 2))
63
+ elif orientation == "coronal":
64
+ # Sum over x and z (axes 0, 2) -> result shape [y]
65
+ lesion_counts = np.sum(data > 0, axis=(0, 2))
66
+ else: # axial
67
+ # Sum over x and y (axes 0, 1) -> result shape [z]
68
+ lesion_counts = np.sum(data > 0, axis=(0, 1))
69
+
70
+ max_slice = int(np.argmax(lesion_counts))
71
+
72
+ # If mask is empty, return middle slice
73
+ if np.max(lesion_counts) == 0:
74
+ if orientation == "sagittal":
75
+ return int(data.shape[0] // 2)
76
+ elif orientation == "coronal":
77
+ return int(data.shape[1] // 2)
78
+ else:
79
+ return int(data.shape[2] // 2)
80
+
81
+ return max_slice
82
+
83
+
84
+ def render_3panel_view(
85
+ nifti_path: Path,
86
+ mask_path: Path | None = None,
87
+ *,
88
+ mask_alpha: float = 0.5,
89
+ ) -> Figure:
90
+ """
91
+ Render axial/coronal/sagittal slices with optional mask overlay.
92
+
93
+ Args:
94
+ nifti_path: Path to base NIfTI volume
95
+ mask_path: Optional path to mask for overlay
96
+ mask_alpha: Transparency of mask overlay
97
+
98
+ Returns:
99
+ Matplotlib figure with 3-panel view
100
+ """
101
+ data, _ = load_nifti_as_array(nifti_path)
102
+ mask_data = None
103
+ if mask_path:
104
+ mask_data, _ = load_nifti_as_array(mask_path)
105
+
106
+ # Get slices (middle by default, or max lesion if mask exists)
107
+ mid_x, mid_y, mid_z = data.shape[0] // 2, data.shape[1] // 2, data.shape[2] // 2
108
+
109
+ if mask_data is not None and np.any(mask_data > 0):
110
+ # Try to find a slice that intersects the lesion best
111
+ # Simplified: use center of mass of lesion
112
+ coords = np.argwhere(mask_data > 0)
113
+ center = coords.mean(axis=0).astype(int)
114
+ mid_x, mid_y, mid_z = center[0], center[1], center[2]
115
+
116
+ # Create figure
117
+ fig, axes = plt.subplots(1, 3, figsize=(15, 5))
118
+ fig.patch.set_facecolor("black")
119
+
120
+ # Axial (XY plane, Z fixed) - often needs rotation 90 deg
121
+ # NIfTI data[x, y, z]. To display standard axial:
122
+ # usually imshow(data[:, :, z].T, origin='lower')
123
+ ax_slice = np.rot90(data[:, :, mid_z])
124
+ axes[0].imshow(ax_slice, cmap="gray")
125
+ axes[0].set_title(f"Axial (z={mid_z})", color="white")
126
+ if mask_data is not None:
127
+ m_slice = np.rot90(mask_data[:, :, mid_z])
128
+ axes[0].imshow(
129
+ np.ma.masked_where(m_slice == 0, m_slice), # type: ignore[no-untyped-call]
130
+ cmap="Reds",
131
+ alpha=mask_alpha,
132
+ vmin=0,
133
+ vmax=1,
134
+ )
135
+
136
+ # Coronal (XZ plane, Y fixed)
137
+ cor_slice = np.rot90(data[:, mid_y, :])
138
+ axes[1].imshow(cor_slice, cmap="gray")
139
+ axes[1].set_title(f"Coronal (y={mid_y})", color="white")
140
+ if mask_data is not None:
141
+ m_slice = np.rot90(mask_data[:, mid_y, :])
142
+ axes[1].imshow(
143
+ np.ma.masked_where(m_slice == 0, m_slice), # type: ignore[no-untyped-call]
144
+ cmap="Reds",
145
+ alpha=mask_alpha,
146
+ vmin=0,
147
+ vmax=1,
148
+ )
149
+
150
+ # Sagittal (YZ plane, X fixed)
151
+ sag_slice = np.rot90(data[mid_x, :, :])
152
+ axes[2].imshow(sag_slice, cmap="gray")
153
+ axes[2].set_title(f"Sagittal (x={mid_x})", color="white")
154
+ if mask_data is not None:
155
+ m_slice = np.rot90(mask_data[mid_x, :, :])
156
+ axes[2].imshow(
157
+ np.ma.masked_where(m_slice == 0, m_slice), # type: ignore[no-untyped-call]
158
+ cmap="Reds",
159
+ alpha=mask_alpha,
160
+ vmin=0,
161
+ vmax=1,
162
+ )
163
+
164
+ for ax in axes:
165
+ ax.axis("off")
166
+
167
+ plt.tight_layout()
168
+ return fig
169
+
170
+
171
+ def render_slice_comparison(
172
+ dwi_path: Path,
173
+ prediction_path: Path,
174
+ ground_truth_path: Path | None = None,
175
+ *,
176
+ slice_idx: int | None = None,
177
+ orientation: str = "axial",
178
+ ) -> Figure:
179
+ """
180
+ Render side-by-side comparison of DWI, prediction, and ground truth.
181
+
182
+ Args:
183
+ dwi_path: Path to DWI NIfTI
184
+ prediction_path: Path to predicted mask NIfTI
185
+ ground_truth_path: Optional path to ground truth mask
186
+ slice_idx: Slice index (default: max lesion or middle)
187
+ orientation: One of "axial", "coronal", "sagittal"
188
+
189
+ Returns:
190
+ Matplotlib figure with comparison view
191
+ """
192
+ dwi_data, _ = load_nifti_as_array(dwi_path)
193
+ pred_data, _ = load_nifti_as_array(prediction_path)
194
+ gt_data = None
195
+ if ground_truth_path:
196
+ gt_data, _ = load_nifti_as_array(ground_truth_path)
197
+
198
+ # Determine slice index
199
+ if slice_idx is None:
200
+ # Use prediction to find best slice
201
+ slice_idx = get_slice_at_max_lesion(prediction_path, orientation)
202
+
203
+ # Extract slices based on orientation
204
+ # Assuming data[x, y, z]
205
+ if orientation == "sagittal":
206
+ # X fixed
207
+ d_slice = np.rot90(dwi_data[slice_idx, :, :])
208
+ p_slice = np.rot90(pred_data[slice_idx, :, :])
209
+ g_slice = np.rot90(gt_data[slice_idx, :, :]) if gt_data is not None else None
210
+ elif orientation == "coronal":
211
+ # Y fixed
212
+ d_slice = np.rot90(dwi_data[:, slice_idx, :])
213
+ p_slice = np.rot90(pred_data[:, slice_idx, :])
214
+ g_slice = np.rot90(gt_data[:, slice_idx, :]) if gt_data is not None else None
215
+ else:
216
+ # Z fixed (axial)
217
+ d_slice = np.rot90(dwi_data[:, :, slice_idx])
218
+ p_slice = np.rot90(pred_data[:, :, slice_idx])
219
+ g_slice = np.rot90(gt_data[:, :, slice_idx]) if gt_data is not None else None
220
+
221
+ # Plotting
222
+ num_plots = 3 if gt_data is not None else 2
223
+ fig, axes = plt.subplots(1, num_plots, figsize=(5 * num_plots, 5))
224
+ fig.patch.set_facecolor("black")
225
+ if num_plots == 2:
226
+ axes = np.array(axes) # handle single case if needed, but subplots(1,2) returns array
227
+
228
+ # 1. DWI
229
+ axes[0].imshow(d_slice, cmap="gray")
230
+ axes[0].set_title("DWI Input", color="white")
231
+
232
+ # 2. Prediction
233
+ axes[1].imshow(d_slice, cmap="gray")
234
+ axes[1].imshow(
235
+ np.ma.masked_where(p_slice == 0, p_slice), # type: ignore[no-untyped-call]
236
+ cmap="Reds",
237
+ alpha=0.5,
238
+ vmin=0,
239
+ vmax=1,
240
+ )
241
+ axes[1].set_title("Prediction", color="white")
242
+
243
+ # 3. GT (if available)
244
+ if gt_data is not None:
245
+ axes[2].imshow(d_slice, cmap="gray")
246
+ axes[2].imshow(
247
+ np.ma.masked_where(g_slice == 0, g_slice), # type: ignore[no-untyped-call]
248
+ cmap="Greens",
249
+ alpha=0.5,
250
+ vmin=0,
251
+ vmax=1,
252
+ )
253
+ axes[2].set_title("Ground Truth", color="white")
254
+
255
+ for ax in axes:
256
+ ax.axis("off")
257
+
258
+ plt.tight_layout()
259
+ return fig
260
+
261
+
262
+ def create_niivue_html(
263
+ volume_url: str,
264
+ mask_url: str | None = None,
265
+ *,
266
+ height: int = 400,
267
+ ) -> str:
268
+ """
269
+ Create HTML/JS for NiiVue viewer.
270
+
271
+ Args:
272
+ volume_url: URL to volume NIfTI file
273
+ mask_url: Optional URL to mask NIfTI file
274
+ height: Viewer height in pixels
275
+
276
+ Returns:
277
+ HTML string with embedded NiiVue viewer
278
+ """
279
+ mask_js = ""
280
+ if mask_url:
281
+ mask_js = f"""
282
+ volumes.push({{
283
+ url: '{mask_url}',
284
+ colorMap: 'red',
285
+ opacity: 0.5
286
+ }});
287
+ """
288
+
289
+ return f"""
290
+ <div style="width:100%; height:{height}px; background:#000; border-radius:8px; position: relative;">
291
+ <canvas id="niivue-canvas" style="width:100%; height:100%;"></canvas>
292
+ </div>
293
+ <script type="module">
294
+ const niivueModule = await import('https://unpkg.com/@niivue/niivue@0.57.0/dist/index.js');
295
+ const Niivue = niivueModule.Niivue;
296
+
297
+ const nv = new Niivue({{
298
+ logging: false,
299
+ show3Dcrosshair: true,
300
+ textHeight: 0.04,
301
+ backColor: [0, 0, 0, 1]
302
+ }});
303
+
304
+ await nv.attachTo('niivue-canvas');
305
+
306
+ const volumes = [{{
307
+ url: '{volume_url}',
308
+ name: 'input.nii.gz'
309
+ }}];
310
+ {mask_js}
311
+
312
+ await nv.loadVolumes(volumes);
313
+
314
+ // Multiplanar + 3D view
315
+ nv.setSliceType(nv.sliceTypeMultiplanar);
316
+ // Check if setMultiplanarLayout exists (added in newer versions, 0.57 has it)
317
+ if (nv.setMultiplanarLayout) {{
318
+ nv.setMultiplanarLayout(2);
319
+ }}
320
+ nv.opts.show3Dcrosshair = true;
321
+ nv.setRenderAzimuthElevation(120, 10);
322
+ nv.drawScene();
323
+ </script>
324
+ """
tests/data/test_integration_real_data.py CHANGED
@@ -8,10 +8,10 @@ import pytest
8
 
9
  from stroke_deepisles_demo.data.loader import load_isles_dataset
10
 
11
- REAL_DATA_PATH = Path("data/scratch/isles24_extracted")
12
 
13
 
14
- @pytest.mark.skipif(not REAL_DATA_PATH.exists(), reason="Real data not found in data/scratch")
15
  def test_load_real_data_count() -> None:
16
  """Verify that we can load the expected number of cases from real data."""
17
  dataset = load_isles_dataset(source=REAL_DATA_PATH)
@@ -28,7 +28,7 @@ def test_load_real_data_count() -> None:
28
  assert case["ground_truth"].exists()
29
 
30
 
31
- @pytest.mark.skipif(not REAL_DATA_PATH.exists(), reason="Real data not found in data/scratch")
32
  def test_real_data_subject_ids() -> None:
33
  """Verify subject ID formatting on real data."""
34
  dataset = load_isles_dataset(source=REAL_DATA_PATH)
 
8
 
9
  from stroke_deepisles_demo.data.loader import load_isles_dataset
10
 
11
+ REAL_DATA_PATH = Path("data/isles24")
12
 
13
 
14
+ @pytest.mark.skipif(not REAL_DATA_PATH.exists(), reason="Real data not found in data/isles24")
15
  def test_load_real_data_count() -> None:
16
  """Verify that we can load the expected number of cases from real data."""
17
  dataset = load_isles_dataset(source=REAL_DATA_PATH)
 
28
  assert case["ground_truth"].exists()
29
 
30
 
31
+ @pytest.mark.skipif(not REAL_DATA_PATH.exists(), reason="Real data not found in data/isles24")
32
  def test_real_data_subject_ids() -> None:
33
  """Verify subject ID formatting on real data."""
34
  dataset = load_isles_dataset(source=REAL_DATA_PATH)
tests/ui/test_app.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke tests for Gradio app."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from unittest.mock import MagicMock, patch
6
+
7
+
8
+ def test_app_module_imports() -> None:
9
+ """App module imports without side effects."""
10
+ # This should not launch the app or make network calls
11
+ from stroke_deepisles_demo.ui import app
12
+
13
+ assert hasattr(app, "create_app")
14
+ assert hasattr(app, "get_demo")
15
+
16
+
17
+ def test_create_app_returns_blocks() -> None:
18
+ """create_app returns a gr.Blocks instance."""
19
+ import gradio as gr
20
+
21
+ # Mock list_case_ids to avoid network call
22
+ with patch("stroke_deepisles_demo.ui.components.list_case_ids", return_value=["sub-001"]):
23
+ from stroke_deepisles_demo.ui.app import create_app
24
+
25
+ app = create_app()
26
+
27
+ assert isinstance(app, gr.Blocks)
28
+
29
+
30
+ def test_viewer_module_imports() -> None:
31
+ """Viewer module imports without errors."""
32
+ from stroke_deepisles_demo.ui import viewer
33
+
34
+ assert hasattr(viewer, "render_3panel_view")
35
+ assert hasattr(viewer, "create_niivue_html")
36
+
37
+
38
+ def test_components_module_imports() -> None:
39
+ """Components module imports without errors."""
40
+ from stroke_deepisles_demo.ui import components
41
+
42
+ assert hasattr(components, "create_case_selector")
43
+ assert hasattr(components, "create_results_display")
44
+
45
+
46
+ def test_run_segmentation_logic() -> None:
47
+ """Test run_segmentation logic with mocks."""
48
+ from stroke_deepisles_demo.pipeline import PipelineResult
49
+ from stroke_deepisles_demo.ui.app import run_segmentation
50
+
51
+ mock_result = PipelineResult(
52
+ case_id="sub-001",
53
+ input_files={"dwi": MagicMock(), "adc": MagicMock()},
54
+ staged_dir=MagicMock(),
55
+ prediction_mask=MagicMock(),
56
+ ground_truth=MagicMock(),
57
+ dice_score=0.85,
58
+ elapsed_seconds=10.5,
59
+ )
60
+
61
+ # Mock everything that touches files/network
62
+ with (
63
+ patch("stroke_deepisles_demo.ui.app.run_pipeline_on_case", return_value=mock_result),
64
+ patch("stroke_deepisles_demo.ui.app.nifti_to_data_url", return_value="data:image..."),
65
+ patch("stroke_deepisles_demo.ui.app.create_niivue_html", return_value="<div></div>"),
66
+ patch("stroke_deepisles_demo.ui.app.render_slice_comparison", return_value=MagicMock()),
67
+ ):
68
+ html, _fig, metrics, _dl_path, status = run_segmentation(
69
+ "sub-001", fast_mode=True, show_ground_truth=True
70
+ )
71
+
72
+ assert html == "<div></div>"
73
+ assert metrics["case_id"] == "sub-001"
74
+ assert metrics["dice_score"] == 0.85
75
+ assert "Success" in status
tests/ui/test_viewer.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for viewer module."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import matplotlib
8
+
9
+ # Non-interactive backend for tests - must be before pyplot import
10
+ matplotlib.use("Agg")
11
+
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ from matplotlib.figure import Figure
15
+
16
+ from stroke_deepisles_demo.ui.viewer import (
17
+ create_niivue_html,
18
+ get_slice_at_max_lesion,
19
+ render_3panel_view,
20
+ render_slice_comparison,
21
+ )
22
+
23
+ if TYPE_CHECKING:
24
+ from pathlib import Path
25
+
26
+
27
+ class TestRender3PanelView:
28
+ """Tests for render_3panel_view."""
29
+
30
+ def test_returns_matplotlib_figure(self, synthetic_nifti_3d: Path) -> None:
31
+ """Returns a matplotlib Figure object."""
32
+ fig = render_3panel_view(synthetic_nifti_3d)
33
+
34
+ assert isinstance(fig, Figure)
35
+ plt.close(fig)
36
+
37
+ def test_has_three_axes(self, synthetic_nifti_3d: Path) -> None:
38
+ """Figure has 3 subplots (axial, coronal, sagittal)."""
39
+ fig = render_3panel_view(synthetic_nifti_3d)
40
+
41
+ assert len(fig.axes) == 3
42
+ plt.close(fig)
43
+
44
+ def test_overlay_mask_when_provided(self, synthetic_nifti_3d: Path, temp_dir: Path) -> None:
45
+ """Overlays mask when mask_path provided."""
46
+ # Create a simple mask
47
+ import nibabel as nib
48
+
49
+ mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
50
+ mask_data[4:6, 4:6, 4:6] = 1
51
+ mask_img = nib.Nifti1Image(mask_data, np.eye(4)) # type: ignore
52
+ mask_path = temp_dir / "mask.nii.gz"
53
+ nib.save(mask_img, mask_path) # type: ignore
54
+
55
+ fig = render_3panel_view(synthetic_nifti_3d, mask_path=mask_path)
56
+
57
+ # Should not raise
58
+ assert fig is not None
59
+ plt.close(fig)
60
+
61
+
62
+ class TestRenderSliceComparison:
63
+ """Tests for render_slice_comparison."""
64
+
65
+ def test_comparison_without_ground_truth(self, synthetic_nifti_3d: Path) -> None:
66
+ """Works when ground truth is None."""
67
+ fig = render_slice_comparison(
68
+ synthetic_nifti_3d,
69
+ synthetic_nifti_3d, # Use same as prediction for test
70
+ ground_truth_path=None,
71
+ )
72
+
73
+ assert isinstance(fig, Figure)
74
+ plt.close(fig)
75
+
76
+ def test_comparison_with_ground_truth(self, synthetic_nifti_3d: Path) -> None:
77
+ """Works when ground truth is provided."""
78
+ fig = render_slice_comparison(
79
+ synthetic_nifti_3d,
80
+ synthetic_nifti_3d,
81
+ ground_truth_path=synthetic_nifti_3d,
82
+ )
83
+
84
+ assert isinstance(fig, Figure)
85
+ plt.close(fig)
86
+
87
+
88
+ class TestGetSliceAtMaxLesion:
89
+ """Tests for get_slice_at_max_lesion."""
90
+
91
+ def test_finds_slice_with_lesion(self, temp_dir: Path) -> None:
92
+ """Returns slice index where lesion is largest."""
93
+ import nibabel as nib
94
+
95
+ # Create mask with lesion at slice 7
96
+ mask_data = np.zeros((10, 10, 10), dtype=np.uint8)
97
+ mask_data[:, :, 7] = 1 # Full slice 7 is lesion
98
+
99
+ mask_img = nib.Nifti1Image(mask_data, np.eye(4)) # type: ignore
100
+ mask_path = temp_dir / "mask.nii.gz"
101
+ nib.save(mask_img, mask_path) # type: ignore
102
+
103
+ slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
104
+
105
+ assert slice_idx == 7
106
+
107
+ def test_returns_middle_for_empty_mask(self, temp_dir: Path) -> None:
108
+ """Returns middle slice when mask is empty."""
109
+ import nibabel as nib
110
+
111
+ mask_data = np.zeros((10, 10, 20), dtype=np.uint8)
112
+ mask_img = nib.Nifti1Image(mask_data, np.eye(4)) # type: ignore
113
+ mask_path = temp_dir / "mask.nii.gz"
114
+ nib.save(mask_img, mask_path) # type: ignore
115
+
116
+ slice_idx = get_slice_at_max_lesion(mask_path, orientation="axial")
117
+
118
+ assert slice_idx == 10 # Middle of 20
119
+
120
+
121
+ class TestCreateNiivueHtml:
122
+ """Tests for create_niivue_html."""
123
+
124
+ def test_includes_volume_url(self) -> None:
125
+ """Generated HTML includes the volume URL."""
126
+ html = create_niivue_html("http://example.com/brain.nii.gz")
127
+
128
+ assert "http://example.com/brain.nii.gz" in html
129
+
130
+ def test_includes_mask_when_provided(self) -> None:
131
+ """Generated HTML includes mask URL when provided."""
132
+ html = create_niivue_html(
133
+ "http://example.com/brain.nii.gz",
134
+ mask_url="http://example.com/mask.nii.gz",
135
+ )
136
+
137
+ assert "http://example.com/mask.nii.gz" in html
138
+
139
+ def test_sets_height(self) -> None:
140
+ """Generated HTML respects height parameter."""
141
+ html = create_niivue_html(
142
+ "http://example.com/brain.nii.gz",
143
+ height=600,
144
+ )
145
+
146
+ assert "height:600px" in html
uv.lock CHANGED
@@ -396,6 +396,88 @@ wheels = [
396
  { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
397
  ]
398
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
399
  [[package]]
400
  name = "coverage"
401
  version = "7.12.0"
@@ -488,6 +570,15 @@ toml = [
488
  { name = "tomli", marker = "python_full_version <= '3.11'" },
489
  ]
490
 
 
 
 
 
 
 
 
 
 
491
  [[package]]
492
  name = "datasets"
493
  version = "4.4.2.dev0"
@@ -560,6 +651,55 @@ wheels = [
560
  { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 },
561
  ]
562
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
563
  [[package]]
564
  name = "frozenlist"
565
  version = "1.8.0"
@@ -878,6 +1018,96 @@ wheels = [
878
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
879
  ]
880
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
881
  [[package]]
882
  name = "librt"
883
  version = "0.6.3"
@@ -1027,6 +1257,70 @@ wheels = [
1027
  { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
1028
  ]
1029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1030
  [[package]]
1031
  name = "mdurl"
1032
  version = "0.1.2"
@@ -1880,6 +2174,15 @@ wheels = [
1880
  { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
1881
  ]
1882
 
 
 
 
 
 
 
 
 
 
1883
  [[package]]
1884
  name = "pytest"
1885
  version = "9.0.1"
@@ -2130,6 +2433,7 @@ dependencies = [
2130
  { name = "datasets" },
2131
  { name = "gradio" },
2132
  { name = "huggingface-hub" },
 
2133
  { name = "nibabel" },
2134
  { name = "numpy" },
2135
  { name = "pydantic" },
@@ -2153,6 +2457,7 @@ requires-dist = [
2153
  { name = "datasets", git = "https://github.com/CloseChoice/datasets.git?rev=feat%2Fbids-loader-streaming-upload-fix" },
2154
  { name = "gradio", specifier = ">=5.0.0" },
2155
  { name = "huggingface-hub", specifier = ">=0.25.0" },
 
2156
  { name = "nibabel", specifier = ">=5.2.0" },
2157
  { name = "numpy", specifier = ">=1.26.0" },
2158
  { name = "pydantic", specifier = ">=2.5.0" },
 
396
  { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
397
  ]
398
 
399
+ [[package]]
400
+ name = "contourpy"
401
+ version = "1.3.3"
402
+ source = { registry = "https://pypi.org/simple" }
403
+ dependencies = [
404
+ { name = "numpy" },
405
+ ]
406
+ sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174 }
407
+ wheels = [
408
+ { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773 },
409
+ { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149 },
410
+ { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222 },
411
+ { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234 },
412
+ { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555 },
413
+ { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238 },
414
+ { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218 },
415
+ { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867 },
416
+ { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677 },
417
+ { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234 },
418
+ { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123 },
419
+ { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419 },
420
+ { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979 },
421
+ { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653 },
422
+ { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536 },
423
+ { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397 },
424
+ { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601 },
425
+ { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288 },
426
+ { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386 },
427
+ { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018 },
428
+ { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567 },
429
+ { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655 },
430
+ { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257 },
431
+ { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034 },
432
+ { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672 },
433
+ { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234 },
434
+ { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169 },
435
+ { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859 },
436
+ { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062 },
437
+ { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932 },
438
+ { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024 },
439
+ { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578 },
440
+ { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524 },
441
+ { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730 },
442
+ { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897 },
443
+ { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751 },
444
+ { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486 },
445
+ { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106 },
446
+ { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548 },
447
+ { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297 },
448
+ { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023 },
449
+ { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157 },
450
+ { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570 },
451
+ { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713 },
452
+ { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189 },
453
+ { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251 },
454
+ { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810 },
455
+ { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871 },
456
+ { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264 },
457
+ { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819 },
458
+ { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650 },
459
+ { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833 },
460
+ { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692 },
461
+ { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424 },
462
+ { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300 },
463
+ { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769 },
464
+ { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892 },
465
+ { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748 },
466
+ { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554 },
467
+ { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118 },
468
+ { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555 },
469
+ { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295 },
470
+ { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027 },
471
+ { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428 },
472
+ { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331 },
473
+ { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831 },
474
+ { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809 },
475
+ { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593 },
476
+ { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202 },
477
+ { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207 },
478
+ { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315 },
479
+ ]
480
+
481
  [[package]]
482
  name = "coverage"
483
  version = "7.12.0"
 
570
  { name = "tomli", marker = "python_full_version <= '3.11'" },
571
  ]
572
 
573
+ [[package]]
574
+ name = "cycler"
575
+ version = "0.12.1"
576
+ source = { registry = "https://pypi.org/simple" }
577
+ sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 }
578
+ wheels = [
579
+ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 },
580
+ ]
581
+
582
  [[package]]
583
  name = "datasets"
584
  version = "4.4.2.dev0"
 
651
  { url = "https://files.pythonhosted.org/packages/76/91/7216b27286936c16f5b4d0c530087e4a54eead683e6b0b73dd0c64844af6/filelock-3.20.0-py3-none-any.whl", hash = "sha256:339b4732ffda5cd79b13f4e2711a31b0365ce445d95d243bb996273d072546a2", size = 16054 },
652
  ]
653
 
654
+ [[package]]
655
+ name = "fonttools"
656
+ version = "4.61.0"
657
+ source = { registry = "https://pypi.org/simple" }
658
+ sdist = { url = "https://files.pythonhosted.org/packages/33/f9/0e84d593c0e12244150280a630999835a64f2852276161b62a0f98318de0/fonttools-4.61.0.tar.gz", hash = "sha256:ec520a1f0c7758d7a858a00f090c1745f6cde6a7c5e76fb70ea4044a15f712e7", size = 3561884 }
659
+ wheels = [
660
+ { url = "https://files.pythonhosted.org/packages/fd/be/5aa89cdddf2863d8afbdc19eb8ec5d8d35d40eeeb8e6cf52c5ff1c2dbd33/fonttools-4.61.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a32a16951cbf113d38f1dd8551b277b6e06e0f6f776fece0f99f746d739e1be3", size = 2847553 },
661
+ { url = "https://files.pythonhosted.org/packages/0d/3e/6ff643b07cead1236a534f51291ae2981721cf419135af5b740c002a66dd/fonttools-4.61.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:328a9c227984bebaf69f3ac9062265f8f6acc7ddf2e4e344c63358579af0aa3d", size = 2388298 },
662
+ { url = "https://files.pythonhosted.org/packages/c3/15/fca8dfbe7b482e6f240b1aad0ed7c6e2e75e7a28efa3d3a03b570617b5e5/fonttools-4.61.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f0bafc8a3b3749c69cc610e5aa3da832d39c2a37a68f03d18ec9a02ecaac04a", size = 5054133 },
663
+ { url = "https://files.pythonhosted.org/packages/6a/a2/821c61c691b21fd09e07528a9a499cc2b075ac83ddb644aa16c9875a64bc/fonttools-4.61.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b5ca59b7417d149cf24e4c1933c9f44b2957424fc03536f132346d5242e0ebe5", size = 5031410 },
664
+ { url = "https://files.pythonhosted.org/packages/e8/f6/8b16339e93d03c732c8a23edefe3061b17a5f9107ddc47a3215ecd054cac/fonttools-4.61.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:df8cbce85cf482eb01f4551edca978c719f099c623277bda8332e5dbe7dba09d", size = 5030005 },
665
+ { url = "https://files.pythonhosted.org/packages/ac/eb/d4e150427bdaa147755239c931bbce829a88149ade5bfd8a327afe565567/fonttools-4.61.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7fb5b84f48a6a733ca3d7f41aa9551908ccabe8669ffe79586560abcc00a9cfd", size = 5154026 },
666
+ { url = "https://files.pythonhosted.org/packages/7f/5f/3dd00ce0dba6759943c707b1830af8c0bcf6f8f1a9fe46cb82e7ac2aaa74/fonttools-4.61.0-cp311-cp311-win32.whl", hash = "sha256:787ef9dfd1ea9fe49573c272412ae5f479d78e671981819538143bec65863865", size = 2276035 },
667
+ { url = "https://files.pythonhosted.org/packages/4e/44/798c472f096ddf12955eddb98f4f7c906e7497695d04ce073ddf7161d134/fonttools-4.61.0-cp311-cp311-win_amd64.whl", hash = "sha256:14fafda386377b6131d9e448af42d0926bad47e038de0e5ba1d58c25d621f028", size = 2327290 },
668
+ { url = "https://files.pythonhosted.org/packages/00/5d/19e5939f773c7cb05480fe2e881d63870b63ee2b4bdb9a77d55b1d36c7b9/fonttools-4.61.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e24a1565c4e57111ec7f4915f8981ecbb61adf66a55f378fdc00e206059fcfef", size = 2846930 },
669
+ { url = "https://files.pythonhosted.org/packages/25/b2/0658faf66f705293bd7e739a4f038302d188d424926be9c59bdad945664b/fonttools-4.61.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e2bfacb5351303cae9f072ccf3fc6ecb437a6f359c0606bae4b1ab6715201d87", size = 2383016 },
670
+ { url = "https://files.pythonhosted.org/packages/29/a3/1fa90b95b690f0d7541f48850adc40e9019374d896c1b8148d15012b2458/fonttools-4.61.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0bdcf2e29d65c26299cc3d502f4612365e8b90a939f46cd92d037b6cb7bb544a", size = 4949425 },
671
+ { url = "https://files.pythonhosted.org/packages/af/00/acf18c00f6c501bd6e05ee930f926186f8a8e268265407065688820f1c94/fonttools-4.61.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e6cd0d9051b8ddaf7385f99dd82ec2a058e2b46cf1f1961e68e1ff20fcbb61af", size = 4999632 },
672
+ { url = "https://files.pythonhosted.org/packages/5f/e0/19a2b86e54109b1d2ee8743c96a1d297238ae03243897bc5345c0365f34d/fonttools-4.61.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e074bc07c31406f45c418e17c1722e83560f181d122c412fa9e815df0ff74810", size = 4939438 },
673
+ { url = "https://files.pythonhosted.org/packages/04/35/7b57a5f57d46286360355eff8d6b88c64ab6331107f37a273a71c803798d/fonttools-4.61.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5a9b78da5d5faa17e63b2404b77feeae105c1b7e75f26020ab7a27b76e02039f", size = 5088960 },
674
+ { url = "https://files.pythonhosted.org/packages/3e/0e/6c5023eb2e0fe5d1ababc7e221e44acd3ff668781489cc1937a6f83d620a/fonttools-4.61.0-cp312-cp312-win32.whl", hash = "sha256:9821ed77bb676736b88fa87a737c97b6af06e8109667e625a4f00158540ce044", size = 2264404 },
675
+ { url = "https://files.pythonhosted.org/packages/36/0b/63273128c7c5df19b1e4cd92e0a1e6ea5bb74a400c4905054c96ad60a675/fonttools-4.61.0-cp312-cp312-win_amd64.whl", hash = "sha256:0011d640afa61053bc6590f9a3394bd222de7cfde19346588beabac374e9d8ac", size = 2314427 },
676
+ { url = "https://files.pythonhosted.org/packages/17/45/334f0d7f181e5473cfb757e1b60f4e60e7fc64f28d406e5d364a952718c0/fonttools-4.61.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba774b8cbd8754f54b8eb58124e8bd45f736b2743325ab1a5229698942b9b433", size = 2841801 },
677
+ { url = "https://files.pythonhosted.org/packages/cc/63/97b9c78e1f79bc741d4efe6e51f13872d8edb2b36e1b9fb2bab0d4491bb7/fonttools-4.61.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c84b430616ed73ce46e9cafd0bf0800e366a3e02fb7e1ad7c1e214dbe3862b1f", size = 2379024 },
678
+ { url = "https://files.pythonhosted.org/packages/4e/80/c87bc524a90dbeb2a390eea23eae448286983da59b7e02c67fa0ca96a8c5/fonttools-4.61.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b2b734d8391afe3c682320840c8191de9bd24e7eb85768dd4dc06ed1b63dbb1b", size = 4923706 },
679
+ { url = "https://files.pythonhosted.org/packages/6d/f6/a3b0374811a1de8c3f9207ec88f61ad1bb96f938ed89babae26c065c2e46/fonttools-4.61.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a5c5fff72bf31b0e558ed085e4fd7ed96eb85881404ecc39ed2a779e7cf724eb", size = 4979751 },
680
+ { url = "https://files.pythonhosted.org/packages/a5/3b/30f63b4308b449091573285f9d27619563a84f399946bca3eadc9554afbe/fonttools-4.61.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:14a290c5c93fcab76b7f451e6a4b7721b712d90b3b5ed6908f1abcf794e90d6d", size = 4921113 },
681
+ { url = "https://files.pythonhosted.org/packages/41/6c/58e6e9b7d9d8bf2d7010bd7bb493060b39b02a12d1cda64a8bfb116ce760/fonttools-4.61.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:13e3e20a5463bfeb77b3557d04b30bd6a96a6bb5c15c7b2e7908903e69d437a0", size = 5063183 },
682
+ { url = "https://files.pythonhosted.org/packages/3f/e3/52c790ab2b07492df059947a1fd7778e105aac5848c0473029a4d20481a2/fonttools-4.61.0-cp313-cp313-win32.whl", hash = "sha256:6781e7a4bb010be1cd69a29927b0305c86b843395f2613bdabe115f7d6ea7f34", size = 2263159 },
683
+ { url = "https://files.pythonhosted.org/packages/e9/1f/116013b200fbeba871046554d5d2a45fefa69a05c40e9cdfd0d4fff53edc/fonttools-4.61.0-cp313-cp313-win_amd64.whl", hash = "sha256:c53b47834ae41e8e4829171cc44fec0fdf125545a15f6da41776b926b9645a9a", size = 2313530 },
684
+ { url = "https://files.pythonhosted.org/packages/d3/99/59b1e25987787cb714aa9457cee4c9301b7c2153f0b673e2b8679d37669d/fonttools-4.61.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:96dfc9bc1f2302224e48e6ee37e656eddbab810b724b52e9d9c13a57a6abad01", size = 2841429 },
685
+ { url = "https://files.pythonhosted.org/packages/2b/b2/4c1911d4332c8a144bb3b44416e274ccca0e297157c971ea1b3fbb855590/fonttools-4.61.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3b2065d94e5d63aafc2591c8b6ccbdb511001d9619f1bca8ad39b745ebeb5efa", size = 2378987 },
686
+ { url = "https://files.pythonhosted.org/packages/24/b0/f442e90fde5d2af2ae0cb54008ab6411edc557ee33b824e13e1d04925ac9/fonttools-4.61.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e0d87e81e4d869549585ba0beb3f033718501c1095004f5e6aef598d13ebc216", size = 4873270 },
687
+ { url = "https://files.pythonhosted.org/packages/bb/04/f5d5990e33053c8a59b90b1d7e10ad9b97a73f42c745304da0e709635fab/fonttools-4.61.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cfa2eb9bae650e58f0e8ad53c49d19a844d6034d6b259f30f197238abc1ccee", size = 4968270 },
688
+ { url = "https://files.pythonhosted.org/packages/94/9f/2091402e0d27c9c8c4bab5de0e5cd146d9609a2d7d1c666bbb75c0011c1a/fonttools-4.61.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4238120002e68296d55e091411c09eab94e111c8ce64716d17df53fd0eb3bb3d", size = 4919799 },
689
+ { url = "https://files.pythonhosted.org/packages/a8/72/86adab22fde710b829f8ffbc8f264df01928e5b7a8f6177fa29979ebf256/fonttools-4.61.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b6ceac262cc62bec01b3bb59abccf41b24ef6580869e306a4e88b7e56bb4bdda", size = 5030966 },
690
+ { url = "https://files.pythonhosted.org/packages/e8/a7/7c8e31b003349e845b853f5e0a67b95ff6b052fa4f5224f8b72624f5ac69/fonttools-4.61.0-cp314-cp314-win32.whl", hash = "sha256:adbb4ecee1a779469a77377bbe490565effe8fce6fb2e6f95f064de58f8bac85", size = 2267243 },
691
+ { url = "https://files.pythonhosted.org/packages/20/ee/f434fe7749360497c52b7dcbcfdbccdaab0a71c59f19d572576066717122/fonttools-4.61.0-cp314-cp314-win_amd64.whl", hash = "sha256:02bdf8e04d1a70476564b8640380f04bb4ac74edc1fc71f1bacb840b3e398ee9", size = 2318822 },
692
+ { url = "https://files.pythonhosted.org/packages/33/b3/c16255320255e5c1863ca2b2599bb61a46e2f566db0bbb9948615a8fe692/fonttools-4.61.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:627216062d90ab0d98215176d8b9562c4dd5b61271d35f130bcd30f6a8aaa33a", size = 2924917 },
693
+ { url = "https://files.pythonhosted.org/packages/e2/b8/08067ae21de705a817777c02ef36ab0b953cbe91d8adf134f9c2da75ed6d/fonttools-4.61.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7b446623c9cd5f14a59493818eaa80255eec2468c27d2c01b56e05357c263195", size = 2413576 },
694
+ { url = "https://files.pythonhosted.org/packages/42/f1/96ff43f92addce2356780fdc203f2966206f3d22ea20e242c27826fd7442/fonttools-4.61.0-cp314-cp314t-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:70e2a0c0182ee75e493ef33061bfebf140ea57e035481d2f95aa03b66c7a0e05", size = 4877447 },
695
+ { url = "https://files.pythonhosted.org/packages/d0/1e/a3d8e51ed9ccfd7385e239ae374b78d258a0fb82d82cab99160a014a45d1/fonttools-4.61.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9064b0f55b947e929ac669af5311ab1f26f750214db6dd9a0c97e091e918f486", size = 5095681 },
696
+ { url = "https://files.pythonhosted.org/packages/eb/f6/d256bd6c1065c146a0bdddf1c62f542e08ae5b3405dbf3fcc52be272f674/fonttools-4.61.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cb5e45a824ce14b90510024d0d39dae51bd4fbb54c42a9334ea8c8cf4d95cbe", size = 4974140 },
697
+ { url = "https://files.pythonhosted.org/packages/5d/0c/96633eb4b26f138cc48561c6e0c44b4ea48acea56b20b507d6b14f8e80ce/fonttools-4.61.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6e5ca8c62efdec7972dfdfd454415c4db49b89aeaefaaacada432f3b7eea9866", size = 5001741 },
698
+ { url = "https://files.pythonhosted.org/packages/6f/9a/3b536bad3be4f26186f296e749ff17bad3e6d57232c104d752d24b2e265b/fonttools-4.61.0-cp314-cp314t-win32.whl", hash = "sha256:63c7125d31abe3e61d7bb917329b5543c5b3448db95f24081a13aaf064360fc8", size = 2330707 },
699
+ { url = "https://files.pythonhosted.org/packages/18/ea/e6b9ac610451ee9f04477c311ad126de971f6112cb579fa391d2a8edb00b/fonttools-4.61.0-cp314-cp314t-win_amd64.whl", hash = "sha256:67d841aa272be5500de7f447c40d1d8452783af33b4c3599899319f6ef9ad3c1", size = 2395950 },
700
+ { url = "https://files.pythonhosted.org/packages/0c/14/634f7daea5ffe6a5f7a0322ba8e1a0e23c9257b80aa91458107896d1dfc7/fonttools-4.61.0-py3-none-any.whl", hash = "sha256:276f14c560e6f98d24ef7f5f44438e55ff5a67f78fa85236b218462c9f5d0635", size = 1144485 },
701
+ ]
702
+
703
  [[package]]
704
  name = "frozenlist"
705
  version = "1.8.0"
 
1018
  { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
1019
  ]
1020
 
1021
+ [[package]]
1022
+ name = "kiwisolver"
1023
+ version = "1.4.9"
1024
+ source = { registry = "https://pypi.org/simple" }
1025
+ sdist = { url = "https://files.pythonhosted.org/packages/5c/3c/85844f1b0feb11ee581ac23fe5fce65cd049a200c1446708cc1b7f922875/kiwisolver-1.4.9.tar.gz", hash = "sha256:c3b22c26c6fd6811b0ae8363b95ca8ce4ea3c202d3d0975b2914310ceb1bcc4d", size = 97564 }
1026
+ wheels = [
1027
+ { url = "https://files.pythonhosted.org/packages/6f/ab/c80b0d5a9d8a1a65f4f815f2afff9798b12c3b9f31f1d304dd233dd920e2/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eb14a5da6dc7642b0f3a18f13654847cd8b7a2550e2645a5bda677862b03ba16", size = 124167 },
1028
+ { url = "https://files.pythonhosted.org/packages/a0/c0/27fe1a68a39cf62472a300e2879ffc13c0538546c359b86f149cc19f6ac3/kiwisolver-1.4.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39a219e1c81ae3b103643d2aedb90f1ef22650deb266ff12a19e7773f3e5f089", size = 66579 },
1029
+ { url = "https://files.pythonhosted.org/packages/31/a2/a12a503ac1fd4943c50f9822678e8015a790a13b5490354c68afb8489814/kiwisolver-1.4.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2405a7d98604b87f3fc28b1716783534b1b4b8510d8142adca34ee0bc3c87543", size = 65309 },
1030
+ { url = "https://files.pythonhosted.org/packages/66/e1/e533435c0be77c3f64040d68d7a657771194a63c279f55573188161e81ca/kiwisolver-1.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dc1ae486f9abcef254b5618dfb4113dd49f94c68e3e027d03cf0143f3f772b61", size = 1435596 },
1031
+ { url = "https://files.pythonhosted.org/packages/67/1e/51b73c7347f9aabdc7215aa79e8b15299097dc2f8e67dee2b095faca9cb0/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a1f570ce4d62d718dce3f179ee78dac3b545ac16c0c04bb363b7607a949c0d1", size = 1246548 },
1032
+ { url = "https://files.pythonhosted.org/packages/21/aa/72a1c5d1e430294f2d32adb9542719cfb441b5da368d09d268c7757af46c/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb27e7b78d716c591e88e0a09a2139c6577865d7f2e152488c2cc6257f460872", size = 1263618 },
1033
+ { url = "https://files.pythonhosted.org/packages/a3/af/db1509a9e79dbf4c260ce0cfa3903ea8945f6240e9e59d1e4deb731b1a40/kiwisolver-1.4.9-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:15163165efc2f627eb9687ea5f3a28137217d217ac4024893d753f46bce9de26", size = 1317437 },
1034
+ { url = "https://files.pythonhosted.org/packages/e0/f2/3ea5ee5d52abacdd12013a94130436e19969fa183faa1e7c7fbc89e9a42f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bdee92c56a71d2b24c33a7d4c2856bd6419d017e08caa7802d2963870e315028", size = 2195742 },
1035
+ { url = "https://files.pythonhosted.org/packages/6f/9b/1efdd3013c2d9a2566aa6a337e9923a00590c516add9a1e89a768a3eb2fc/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:412f287c55a6f54b0650bd9b6dce5aceddb95864a1a90c87af16979d37c89771", size = 2290810 },
1036
+ { url = "https://files.pythonhosted.org/packages/fb/e5/cfdc36109ae4e67361f9bc5b41323648cb24a01b9ade18784657e022e65f/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:2c93f00dcba2eea70af2be5f11a830a742fe6b579a1d4e00f47760ef13be247a", size = 2461579 },
1037
+ { url = "https://files.pythonhosted.org/packages/62/86/b589e5e86c7610842213994cdea5add00960076bef4ae290c5fa68589cac/kiwisolver-1.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f117e1a089d9411663a3207ba874f31be9ac8eaa5b533787024dc07aeb74f464", size = 2268071 },
1038
+ { url = "https://files.pythonhosted.org/packages/3b/c6/f8df8509fd1eee6c622febe54384a96cfaf4d43bf2ccec7a0cc17e4715c9/kiwisolver-1.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:be6a04e6c79819c9a8c2373317d19a96048e5a3f90bec587787e86a1153883c2", size = 73840 },
1039
+ { url = "https://files.pythonhosted.org/packages/e2/2d/16e0581daafd147bc11ac53f032a2b45eabac897f42a338d0a13c1e5c436/kiwisolver-1.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:0ae37737256ba2de764ddc12aed4956460277f00c4996d51a197e72f62f5eec7", size = 65159 },
1040
+ { url = "https://files.pythonhosted.org/packages/86/c9/13573a747838aeb1c76e3267620daa054f4152444d1f3d1a2324b78255b5/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ac5a486ac389dddcc5bef4f365b6ae3ffff2c433324fb38dd35e3fab7c957999", size = 123686 },
1041
+ { url = "https://files.pythonhosted.org/packages/51/ea/2ecf727927f103ffd1739271ca19c424d0e65ea473fbaeea1c014aea93f6/kiwisolver-1.4.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f2ba92255faa7309d06fe44c3a4a97efe1c8d640c2a79a5ef728b685762a6fd2", size = 66460 },
1042
+ { url = "https://files.pythonhosted.org/packages/5b/5a/51f5464373ce2aeb5194508298a508b6f21d3867f499556263c64c621914/kiwisolver-1.4.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a2899935e724dd1074cb568ce7ac0dce28b2cd6ab539c8e001a8578eb106d14", size = 64952 },
1043
+ { url = "https://files.pythonhosted.org/packages/70/90/6d240beb0f24b74371762873e9b7f499f1e02166a2d9c5801f4dbf8fa12e/kiwisolver-1.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f6008a4919fdbc0b0097089f67a1eb55d950ed7e90ce2cc3e640abadd2757a04", size = 1474756 },
1044
+ { url = "https://files.pythonhosted.org/packages/12/42/f36816eaf465220f683fb711efdd1bbf7a7005a2473d0e4ed421389bd26c/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:67bb8b474b4181770f926f7b7d2f8c0248cbcb78b660fdd41a47054b28d2a752", size = 1276404 },
1045
+ { url = "https://files.pythonhosted.org/packages/2e/64/bc2de94800adc830c476dce44e9b40fd0809cddeef1fde9fcf0f73da301f/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2327a4a30d3ee07d2fbe2e7933e8a37c591663b96ce42a00bc67461a87d7df77", size = 1294410 },
1046
+ { url = "https://files.pythonhosted.org/packages/5f/42/2dc82330a70aa8e55b6d395b11018045e58d0bb00834502bf11509f79091/kiwisolver-1.4.9-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a08b491ec91b1d5053ac177afe5290adacf1f0f6307d771ccac5de30592d198", size = 1343631 },
1047
+ { url = "https://files.pythonhosted.org/packages/22/fd/f4c67a6ed1aab149ec5a8a401c323cee7a1cbe364381bb6c9c0d564e0e20/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8fc5c867c22b828001b6a38d2eaeb88160bf5783c6cb4a5e440efc981ce286d", size = 2224963 },
1048
+ { url = "https://files.pythonhosted.org/packages/45/aa/76720bd4cb3713314677d9ec94dcc21ced3f1baf4830adde5bb9b2430a5f/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:3b3115b2581ea35bb6d1f24a4c90af37e5d9b49dcff267eeed14c3893c5b86ab", size = 2321295 },
1049
+ { url = "https://files.pythonhosted.org/packages/80/19/d3ec0d9ab711242f56ae0dc2fc5d70e298bb4a1f9dfab44c027668c673a1/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:858e4c22fb075920b96a291928cb7dea5644e94c0ee4fcd5af7e865655e4ccf2", size = 2487987 },
1050
+ { url = "https://files.pythonhosted.org/packages/39/e9/61e4813b2c97e86b6fdbd4dd824bf72d28bcd8d4849b8084a357bc0dd64d/kiwisolver-1.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed0fecd28cc62c54b262e3736f8bb2512d8dcfdc2bcf08be5f47f96bf405b145", size = 2291817 },
1051
+ { url = "https://files.pythonhosted.org/packages/a0/41/85d82b0291db7504da3c2defe35c9a8a5c9803a730f297bd823d11d5fb77/kiwisolver-1.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:f68208a520c3d86ea51acf688a3e3002615a7f0238002cccc17affecc86a8a54", size = 73895 },
1052
+ { url = "https://files.pythonhosted.org/packages/e2/92/5f3068cf15ee5cb624a0c7596e67e2a0bb2adee33f71c379054a491d07da/kiwisolver-1.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:2c1a4f57df73965f3f14df20b80ee29e6a7930a57d2d9e8491a25f676e197c60", size = 64992 },
1053
+ { url = "https://files.pythonhosted.org/packages/31/c1/c2686cda909742ab66c7388e9a1a8521a59eb89f8bcfbee28fc980d07e24/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a5d0432ccf1c7ab14f9949eec60c5d1f924f17c037e9f8b33352fa05799359b8", size = 123681 },
1054
+ { url = "https://files.pythonhosted.org/packages/ca/f0/f44f50c9f5b1a1860261092e3bc91ecdc9acda848a8b8c6abfda4a24dd5c/kiwisolver-1.4.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efb3a45b35622bb6c16dbfab491a8f5a391fe0e9d45ef32f4df85658232ca0e2", size = 66464 },
1055
+ { url = "https://files.pythonhosted.org/packages/2d/7a/9d90a151f558e29c3936b8a47ac770235f436f2120aca41a6d5f3d62ae8d/kiwisolver-1.4.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1a12cf6398e8a0a001a059747a1cbf24705e18fe413bc22de7b3d15c67cffe3f", size = 64961 },
1056
+ { url = "https://files.pythonhosted.org/packages/e9/e9/f218a2cb3a9ffbe324ca29a9e399fa2d2866d7f348ec3a88df87fc248fc5/kiwisolver-1.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b67e6efbf68e077dd71d1a6b37e43e1a99d0bff1a3d51867d45ee8908b931098", size = 1474607 },
1057
+ { url = "https://files.pythonhosted.org/packages/d9/28/aac26d4c882f14de59041636292bc838db8961373825df23b8eeb807e198/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5656aa670507437af0207645273ccdfee4f14bacd7f7c67a4306d0dcaeaf6eed", size = 1276546 },
1058
+ { url = "https://files.pythonhosted.org/packages/8b/ad/8bfc1c93d4cc565e5069162f610ba2f48ff39b7de4b5b8d93f69f30c4bed/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bfc08add558155345129c7803b3671cf195e6a56e7a12f3dde7c57d9b417f525", size = 1294482 },
1059
+ { url = "https://files.pythonhosted.org/packages/da/f1/6aca55ff798901d8ce403206d00e033191f63d82dd708a186e0ed2067e9c/kiwisolver-1.4.9-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:40092754720b174e6ccf9e845d0d8c7d8e12c3d71e7fc35f55f3813e96376f78", size = 1343720 },
1060
+ { url = "https://files.pythonhosted.org/packages/d1/91/eed031876c595c81d90d0f6fc681ece250e14bf6998c3d7c419466b523b7/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:497d05f29a1300d14e02e6441cf0f5ee81c1ff5a304b0d9fb77423974684e08b", size = 2224907 },
1061
+ { url = "https://files.pythonhosted.org/packages/e9/ec/4d1925f2e49617b9cca9c34bfa11adefad49d00db038e692a559454dfb2e/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:bdd1a81a1860476eb41ac4bc1e07b3f07259e6d55bbf739b79c8aaedcf512799", size = 2321334 },
1062
+ { url = "https://files.pythonhosted.org/packages/43/cb/450cd4499356f68802750c6ddc18647b8ea01ffa28f50d20598e0befe6e9/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:e6b93f13371d341afee3be9f7c5964e3fe61d5fa30f6a30eb49856935dfe4fc3", size = 2488313 },
1063
+ { url = "https://files.pythonhosted.org/packages/71/67/fc76242bd99f885651128a5d4fa6083e5524694b7c88b489b1b55fdc491d/kiwisolver-1.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d75aa530ccfaa593da12834b86a0724f58bff12706659baa9227c2ccaa06264c", size = 2291970 },
1064
+ { url = "https://files.pythonhosted.org/packages/75/bd/f1a5d894000941739f2ae1b65a32892349423ad49c2e6d0771d0bad3fae4/kiwisolver-1.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:dd0a578400839256df88c16abddf9ba14813ec5f21362e1fe65022e00c883d4d", size = 73894 },
1065
+ { url = "https://files.pythonhosted.org/packages/95/38/dce480814d25b99a391abbddadc78f7c117c6da34be68ca8b02d5848b424/kiwisolver-1.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:d4188e73af84ca82468f09cadc5ac4db578109e52acb4518d8154698d3a87ca2", size = 64995 },
1066
+ { url = "https://files.pythonhosted.org/packages/e2/37/7d218ce5d92dadc5ebdd9070d903e0c7cf7edfe03f179433ac4d13ce659c/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:5a0f2724dfd4e3b3ac5a82436a8e6fd16baa7d507117e4279b660fe8ca38a3a1", size = 126510 },
1067
+ { url = "https://files.pythonhosted.org/packages/23/b0/e85a2b48233daef4b648fb657ebbb6f8367696a2d9548a00b4ee0eb67803/kiwisolver-1.4.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1b11d6a633e4ed84fc0ddafd4ebfd8ea49b3f25082c04ad12b8315c11d504dc1", size = 67903 },
1068
+ { url = "https://files.pythonhosted.org/packages/44/98/f2425bc0113ad7de24da6bb4dae1343476e95e1d738be7c04d31a5d037fd/kiwisolver-1.4.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61874cdb0a36016354853593cffc38e56fc9ca5aa97d2c05d3dcf6922cd55a11", size = 66402 },
1069
+ { url = "https://files.pythonhosted.org/packages/98/d8/594657886df9f34c4177cc353cc28ca7e6e5eb562d37ccc233bff43bbe2a/kiwisolver-1.4.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:60c439763a969a6af93b4881db0eed8fadf93ee98e18cbc35bc8da868d0c4f0c", size = 1582135 },
1070
+ { url = "https://files.pythonhosted.org/packages/5c/c6/38a115b7170f8b306fc929e166340c24958347308ea3012c2b44e7e295db/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92a2f997387a1b79a75e7803aa7ded2cfbe2823852ccf1ba3bcf613b62ae3197", size = 1389409 },
1071
+ { url = "https://files.pythonhosted.org/packages/bf/3b/e04883dace81f24a568bcee6eb3001da4ba05114afa622ec9b6fafdc1f5e/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31d512c812daea6d8b3be3b2bfcbeb091dbb09177706569bcfc6240dcf8b41c", size = 1401763 },
1072
+ { url = "https://files.pythonhosted.org/packages/9f/80/20ace48e33408947af49d7d15c341eaee69e4e0304aab4b7660e234d6288/kiwisolver-1.4.9-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:52a15b0f35dad39862d376df10c5230155243a2c1a436e39eb55623ccbd68185", size = 1453643 },
1073
+ { url = "https://files.pythonhosted.org/packages/64/31/6ce4380a4cd1f515bdda976a1e90e547ccd47b67a1546d63884463c92ca9/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a30fd6fdef1430fd9e1ba7b3398b5ee4e2887783917a687d86ba69985fb08748", size = 2330818 },
1074
+ { url = "https://files.pythonhosted.org/packages/fa/e9/3f3fcba3bcc7432c795b82646306e822f3fd74df0ee81f0fa067a1f95668/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cc9617b46837c6468197b5945e196ee9ca43057bb7d9d1ae688101e4e1dddf64", size = 2419963 },
1075
+ { url = "https://files.pythonhosted.org/packages/99/43/7320c50e4133575c66e9f7dadead35ab22d7c012a3b09bb35647792b2a6d/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:0ab74e19f6a2b027ea4f845a78827969af45ce790e6cb3e1ebab71bdf9f215ff", size = 2594639 },
1076
+ { url = "https://files.pythonhosted.org/packages/65/d6/17ae4a270d4a987ef8a385b906d2bdfc9fce502d6dc0d3aea865b47f548c/kiwisolver-1.4.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dba5ee5d3981160c28d5490f0d1b7ed730c22470ff7f6cc26cfcfaacb9896a07", size = 2391741 },
1077
+ { url = "https://files.pythonhosted.org/packages/2a/8f/8f6f491d595a9e5912971f3f863d81baddccc8a4d0c3749d6a0dd9ffc9df/kiwisolver-1.4.9-cp313-cp313t-win_arm64.whl", hash = "sha256:0749fd8f4218ad2e851e11cc4dc05c7cbc0cbc4267bdfdb31782e65aace4ee9c", size = 68646 },
1078
+ { url = "https://files.pythonhosted.org/packages/6b/32/6cc0fbc9c54d06c2969faa9c1d29f5751a2e51809dd55c69055e62d9b426/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9928fe1eb816d11ae170885a74d074f57af3a0d65777ca47e9aeb854a1fba386", size = 123806 },
1079
+ { url = "https://files.pythonhosted.org/packages/b2/dd/2bfb1d4a4823d92e8cbb420fe024b8d2167f72079b3bb941207c42570bdf/kiwisolver-1.4.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d0005b053977e7b43388ddec89fa567f43d4f6d5c2c0affe57de5ebf290dc552", size = 66605 },
1080
+ { url = "https://files.pythonhosted.org/packages/f7/69/00aafdb4e4509c2ca6064646cba9cd4b37933898f426756adb2cb92ebbed/kiwisolver-1.4.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2635d352d67458b66fd0667c14cb1d4145e9560d503219034a18a87e971ce4f3", size = 64925 },
1081
+ { url = "https://files.pythonhosted.org/packages/43/dc/51acc6791aa14e5cb6d8a2e28cefb0dc2886d8862795449d021334c0df20/kiwisolver-1.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:767c23ad1c58c9e827b649a9ab7809fd5fd9db266a9cf02b0e926ddc2c680d58", size = 1472414 },
1082
+ { url = "https://files.pythonhosted.org/packages/3d/bb/93fa64a81db304ac8a246f834d5094fae4b13baf53c839d6bb6e81177129/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72d0eb9fba308b8311685c2268cf7d0a0639a6cd027d8128659f72bdd8a024b4", size = 1281272 },
1083
+ { url = "https://files.pythonhosted.org/packages/70/e6/6df102916960fb8d05069d4bd92d6d9a8202d5a3e2444494e7cd50f65b7a/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f68e4f3eeca8fb22cc3d731f9715a13b652795ef657a13df1ad0c7dc0e9731df", size = 1298578 },
1084
+ { url = "https://files.pythonhosted.org/packages/7c/47/e142aaa612f5343736b087864dbaebc53ea8831453fb47e7521fa8658f30/kiwisolver-1.4.9-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d84cd4061ae292d8ac367b2c3fa3aad11cb8625a95d135fe93f286f914f3f5a6", size = 1345607 },
1085
+ { url = "https://files.pythonhosted.org/packages/54/89/d641a746194a0f4d1a3670fb900d0dbaa786fb98341056814bc3f058fa52/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a60ea74330b91bd22a29638940d115df9dc00af5035a9a2a6ad9399ffb4ceca5", size = 2230150 },
1086
+ { url = "https://files.pythonhosted.org/packages/aa/6b/5ee1207198febdf16ac11f78c5ae40861b809cbe0e6d2a8d5b0b3044b199/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ce6a3a4e106cf35c2d9c4fa17c05ce0b180db622736845d4315519397a77beaf", size = 2325979 },
1087
+ { url = "https://files.pythonhosted.org/packages/fc/ff/b269eefd90f4ae14dcc74973d5a0f6d28d3b9bb1afd8c0340513afe6b39a/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:77937e5e2a38a7b48eef0585114fe7930346993a88060d0bf886086d2aa49ef5", size = 2491456 },
1088
+ { url = "https://files.pythonhosted.org/packages/fc/d4/10303190bd4d30de547534601e259a4fbf014eed94aae3e5521129215086/kiwisolver-1.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:24c175051354f4a28c5d6a31c93906dc653e2bf234e8a4bbfb964892078898ce", size = 2294621 },
1089
+ { url = "https://files.pythonhosted.org/packages/28/e0/a9a90416fce5c0be25742729c2ea52105d62eda6c4be4d803c2a7be1fa50/kiwisolver-1.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:0763515d4df10edf6d06a3c19734e2566368980d21ebec439f33f9eb936c07b7", size = 75417 },
1090
+ { url = "https://files.pythonhosted.org/packages/1f/10/6949958215b7a9a264299a7db195564e87900f709db9245e4ebdd3c70779/kiwisolver-1.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:0e4e2bf29574a6a7b7f6cb5fa69293b9f96c928949ac4a53ba3f525dffb87f9c", size = 66582 },
1091
+ { url = "https://files.pythonhosted.org/packages/ec/79/60e53067903d3bc5469b369fe0dfc6b3482e2133e85dae9daa9527535991/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d976bbb382b202f71c67f77b0ac11244021cfa3f7dfd9e562eefcea2df711548", size = 126514 },
1092
+ { url = "https://files.pythonhosted.org/packages/25/d1/4843d3e8d46b072c12a38c97c57fab4608d36e13fe47d47ee96b4d61ba6f/kiwisolver-1.4.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2489e4e5d7ef9a1c300a5e0196e43d9c739f066ef23270607d45aba368b91f2d", size = 67905 },
1093
+ { url = "https://files.pythonhosted.org/packages/8c/ae/29ffcbd239aea8b93108de1278271ae764dfc0d803a5693914975f200596/kiwisolver-1.4.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e2ea9f7ab7fbf18fffb1b5434ce7c69a07582f7acc7717720f1d69f3e806f90c", size = 66399 },
1094
+ { url = "https://files.pythonhosted.org/packages/a1/ae/d7ba902aa604152c2ceba5d352d7b62106bedbccc8e95c3934d94472bfa3/kiwisolver-1.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b34e51affded8faee0dfdb705416153819d8ea9250bbbf7ea1b249bdeb5f1122", size = 1582197 },
1095
+ { url = "https://files.pythonhosted.org/packages/f2/41/27c70d427eddb8bc7e4f16420a20fefc6f480312122a59a959fdfe0445ad/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8aacd3d4b33b772542b2e01beb50187536967b514b00003bdda7589722d2a64", size = 1390125 },
1096
+ { url = "https://files.pythonhosted.org/packages/41/42/b3799a12bafc76d962ad69083f8b43b12bf4fe78b097b12e105d75c9b8f1/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7cf974dd4e35fa315563ac99d6287a1024e4dc2077b8a7d7cd3d2fb65d283134", size = 1402612 },
1097
+ { url = "https://files.pythonhosted.org/packages/d2/b5/a210ea073ea1cfaca1bb5c55a62307d8252f531beb364e18aa1e0888b5a0/kiwisolver-1.4.9-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:85bd218b5ecfbee8c8a82e121802dcb519a86044c9c3b2e4aef02fa05c6da370", size = 1453990 },
1098
+ { url = "https://files.pythonhosted.org/packages/5f/ce/a829eb8c033e977d7ea03ed32fb3c1781b4fa0433fbadfff29e39c676f32/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0856e241c2d3df4efef7c04a1e46b1936b6120c9bcf36dd216e3acd84bc4fb21", size = 2331601 },
1099
+ { url = "https://files.pythonhosted.org/packages/e0/4b/b5e97eb142eb9cd0072dacfcdcd31b1c66dc7352b0f7c7255d339c0edf00/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:9af39d6551f97d31a4deebeac6f45b156f9755ddc59c07b402c148f5dbb6482a", size = 2422041 },
1100
+ { url = "https://files.pythonhosted.org/packages/40/be/8eb4cd53e1b85ba4edc3a9321666f12b83113a178845593307a3e7891f44/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:bb4ae2b57fc1d8cbd1cf7b1d9913803681ffa903e7488012be5b76dedf49297f", size = 2594897 },
1101
+ { url = "https://files.pythonhosted.org/packages/99/dd/841e9a66c4715477ea0abc78da039832fbb09dac5c35c58dc4c41a407b8a/kiwisolver-1.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:aedff62918805fb62d43a4aa2ecd4482c380dc76cd31bd7c8878588a61bd0369", size = 2391835 },
1102
+ { url = "https://files.pythonhosted.org/packages/0c/28/4b2e5c47a0da96896fdfdb006340ade064afa1e63675d01ea5ac222b6d52/kiwisolver-1.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:1fa333e8b2ce4d9660f2cda9c0e1b6bafcfb2457a9d259faa82289e73ec24891", size = 79988 },
1103
+ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260 },
1104
+ { url = "https://files.pythonhosted.org/packages/a3/0f/36d89194b5a32c054ce93e586d4049b6c2c22887b0eb229c61c68afd3078/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:720e05574713db64c356e86732c0f3c5252818d05f9df320f0ad8380641acea5", size = 60104 },
1105
+ { url = "https://files.pythonhosted.org/packages/52/ba/4ed75f59e4658fd21fe7dde1fee0ac397c678ec3befba3fe6482d987af87/kiwisolver-1.4.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:17680d737d5335b552994a2008fab4c851bcd7de33094a82067ef3a576ff02fa", size = 58592 },
1106
+ { url = "https://files.pythonhosted.org/packages/33/01/a8ea7c5ea32a9b45ceeaee051a04c8ed4320f5add3c51bfa20879b765b70/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:85b5352f94e490c028926ea567fc569c52ec79ce131dadb968d3853e809518c2", size = 80281 },
1107
+ { url = "https://files.pythonhosted.org/packages/da/e3/dbd2ecdce306f1d07a1aaf324817ee993aab7aee9db47ceac757deabafbe/kiwisolver-1.4.9-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:464415881e4801295659462c49461a24fb107c140de781d55518c4b80cb6790f", size = 78009 },
1108
+ { url = "https://files.pythonhosted.org/packages/da/e9/0d4add7873a73e462aeb45c036a2dead2562b825aa46ba326727b3f31016/kiwisolver-1.4.9-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:fb940820c63a9590d31d88b815e7a3aa5915cad3ce735ab45f0c730b39547de1", size = 73929 },
1109
+ ]
1110
+
1111
  [[package]]
1112
  name = "librt"
1113
  version = "0.6.3"
 
1257
  { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146 },
1258
  ]
1259
 
1260
+ [[package]]
1261
+ name = "matplotlib"
1262
+ version = "3.10.7"
1263
+ source = { registry = "https://pypi.org/simple" }
1264
+ dependencies = [
1265
+ { name = "contourpy" },
1266
+ { name = "cycler" },
1267
+ { name = "fonttools" },
1268
+ { name = "kiwisolver" },
1269
+ { name = "numpy" },
1270
+ { name = "packaging" },
1271
+ { name = "pillow" },
1272
+ { name = "pyparsing" },
1273
+ { name = "python-dateutil" },
1274
+ ]
1275
+ sdist = { url = "https://files.pythonhosted.org/packages/ae/e2/d2d5295be2f44c678ebaf3544ba32d20c1f9ef08c49fe47f496180e1db15/matplotlib-3.10.7.tar.gz", hash = "sha256:a06ba7e2a2ef9131c79c49e63dad355d2d878413a0376c1727c8b9335ff731c7", size = 34804865 }
1276
+ wheels = [
1277
+ { url = "https://files.pythonhosted.org/packages/fc/bc/0fb489005669127ec13f51be0c6adc074d7cf191075dab1da9fe3b7a3cfc/matplotlib-3.10.7-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:53b492410a6cd66c7a471de6c924f6ede976e963c0f3097a3b7abfadddc67d0a", size = 8257507 },
1278
+ { url = "https://files.pythonhosted.org/packages/e2/6a/d42588ad895279ff6708924645b5d2ed54a7fb2dc045c8a804e955aeace1/matplotlib-3.10.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d9749313deb729f08207718d29c86246beb2ea3fdba753595b55901dee5d2fd6", size = 8119565 },
1279
+ { url = "https://files.pythonhosted.org/packages/10/b7/4aa196155b4d846bd749cf82aa5a4c300cf55a8b5e0dfa5b722a63c0f8a0/matplotlib-3.10.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2222c7ba2cbde7fe63032769f6eb7e83ab3227f47d997a8453377709b7fe3a5a", size = 8692668 },
1280
+ { url = "https://files.pythonhosted.org/packages/e6/e7/664d2b97016f46683a02d854d730cfcf54ff92c1dafa424beebef50f831d/matplotlib-3.10.7-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e91f61a064c92c307c5a9dc8c05dc9f8a68f0a3be199d9a002a0622e13f874a1", size = 9521051 },
1281
+ { url = "https://files.pythonhosted.org/packages/a8/a3/37aef1404efa615f49b5758a5e0261c16dd88f389bc1861e722620e4a754/matplotlib-3.10.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6f1851eab59ca082c95df5a500106bad73672645625e04538b3ad0f69471ffcc", size = 9576878 },
1282
+ { url = "https://files.pythonhosted.org/packages/33/cd/b145f9797126f3f809d177ca378de57c45413c5099c5990de2658760594a/matplotlib-3.10.7-cp311-cp311-win_amd64.whl", hash = "sha256:6516ce375109c60ceec579e699524e9d504cd7578506f01150f7a6bc174a775e", size = 8115142 },
1283
+ { url = "https://files.pythonhosted.org/packages/2e/39/63bca9d2b78455ed497fcf51a9c71df200a11048f48249038f06447fa947/matplotlib-3.10.7-cp311-cp311-win_arm64.whl", hash = "sha256:b172db79759f5f9bc13ef1c3ef8b9ee7b37b0247f987fbbbdaa15e4f87fd46a9", size = 7992439 },
1284
+ { url = "https://files.pythonhosted.org/packages/be/b3/09eb0f7796932826ec20c25b517d568627754f6c6462fca19e12c02f2e12/matplotlib-3.10.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7a0edb7209e21840e8361e91ea84ea676658aa93edd5f8762793dec77a4a6748", size = 8272389 },
1285
+ { url = "https://files.pythonhosted.org/packages/11/0b/1ae80ddafb8652fd8046cb5c8460ecc8d4afccb89e2c6d6bec61e04e1eaf/matplotlib-3.10.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c380371d3c23e0eadf8ebff114445b9f970aff2010198d498d4ab4c3b41eea4f", size = 8128247 },
1286
+ { url = "https://files.pythonhosted.org/packages/7d/18/95ae2e242d4a5c98bd6e90e36e128d71cf1c7e39b0874feaed3ef782e789/matplotlib-3.10.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d5f256d49fea31f40f166a5e3131235a5d2f4b7f44520b1cf0baf1ce568ccff0", size = 8696996 },
1287
+ { url = "https://files.pythonhosted.org/packages/7e/3d/5b559efc800bd05cb2033aa85f7e13af51958136a48327f7c261801ff90a/matplotlib-3.10.7-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11ae579ac83cdf3fb72573bb89f70e0534de05266728740d478f0f818983c695", size = 9530153 },
1288
+ { url = "https://files.pythonhosted.org/packages/88/57/eab4a719fd110312d3c220595d63a3c85ec2a39723f0f4e7fa7e6e3f74ba/matplotlib-3.10.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4c14b6acd16cddc3569a2d515cfdd81c7a68ac5639b76548cfc1a9e48b20eb65", size = 9593093 },
1289
+ { url = "https://files.pythonhosted.org/packages/31/3c/80816f027b3a4a28cd2a0a6ef7f89a2db22310e945cd886ec25bfb399221/matplotlib-3.10.7-cp312-cp312-win_amd64.whl", hash = "sha256:0d8c32b7ea6fb80b1aeff5a2ceb3fb9778e2759e899d9beff75584714afcc5ee", size = 8122771 },
1290
+ { url = "https://files.pythonhosted.org/packages/de/77/ef1fc78bfe99999b2675435cc52120887191c566b25017d78beaabef7f2d/matplotlib-3.10.7-cp312-cp312-win_arm64.whl", hash = "sha256:5f3f6d315dcc176ba7ca6e74c7768fb7e4cf566c49cb143f6bc257b62e634ed8", size = 7992812 },
1291
+ { url = "https://files.pythonhosted.org/packages/02/9c/207547916a02c78f6bdd83448d9b21afbc42f6379ed887ecf610984f3b4e/matplotlib-3.10.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1d9d3713a237970569156cfb4de7533b7c4eacdd61789726f444f96a0d28f57f", size = 8273212 },
1292
+ { url = "https://files.pythonhosted.org/packages/bc/d0/b3d3338d467d3fc937f0bb7f256711395cae6f78e22cef0656159950adf0/matplotlib-3.10.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:37a1fea41153dd6ee061d21ab69c9cf2cf543160b1b85d89cd3d2e2a7902ca4c", size = 8128713 },
1293
+ { url = "https://files.pythonhosted.org/packages/22/ff/6425bf5c20d79aa5b959d1ce9e65f599632345391381c9a104133fe0b171/matplotlib-3.10.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b3c4ea4948d93c9c29dc01c0c23eef66f2101bf75158c291b88de6525c55c3d1", size = 8698527 },
1294
+ { url = "https://files.pythonhosted.org/packages/d0/7f/ccdca06f4c2e6c7989270ed7829b8679466682f4cfc0f8c9986241c023b6/matplotlib-3.10.7-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22df30ffaa89f6643206cf13877191c63a50e8f800b038bc39bee9d2d4957632", size = 9529690 },
1295
+ { url = "https://files.pythonhosted.org/packages/b8/95/b80fc2c1f269f21ff3d193ca697358e24408c33ce2b106a7438a45407b63/matplotlib-3.10.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b69676845a0a66f9da30e87f48be36734d6748024b525ec4710be40194282c84", size = 9593732 },
1296
+ { url = "https://files.pythonhosted.org/packages/e1/b6/23064a96308b9aeceeffa65e96bcde459a2ea4934d311dee20afde7407a0/matplotlib-3.10.7-cp313-cp313-win_amd64.whl", hash = "sha256:744991e0cc863dd669c8dc9136ca4e6e0082be2070b9d793cbd64bec872a6815", size = 8122727 },
1297
+ { url = "https://files.pythonhosted.org/packages/b3/a6/2faaf48133b82cf3607759027f82b5c702aa99cdfcefb7f93d6ccf26a424/matplotlib-3.10.7-cp313-cp313-win_arm64.whl", hash = "sha256:fba2974df0bf8ce3c995fa84b79cde38326e0f7b5409e7a3a481c1141340bcf7", size = 7992958 },
1298
+ { url = "https://files.pythonhosted.org/packages/4a/f0/b018fed0b599bd48d84c08794cb242227fe3341952da102ee9d9682db574/matplotlib-3.10.7-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:932c55d1fa7af4423422cb6a492a31cbcbdbe68fd1a9a3f545aa5e7a143b5355", size = 8316849 },
1299
+ { url = "https://files.pythonhosted.org/packages/b0/b7/bb4f23856197659f275e11a2a164e36e65e9b48ea3e93c4ec25b4f163198/matplotlib-3.10.7-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e38c2d581d62ee729a6e144c47a71b3f42fb4187508dbbf4fe71d5612c3433b", size = 8178225 },
1300
+ { url = "https://files.pythonhosted.org/packages/62/56/0600609893ff277e6f3ab3c0cef4eafa6e61006c058e84286c467223d4d5/matplotlib-3.10.7-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:786656bb13c237bbcebcd402f65f44dd61ead60ee3deb045af429d889c8dbc67", size = 8711708 },
1301
+ { url = "https://files.pythonhosted.org/packages/d8/1a/6bfecb0cafe94d6658f2f1af22c43b76cf7a1c2f0dc34ef84cbb6809617e/matplotlib-3.10.7-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09d7945a70ea43bf9248f4b6582734c2fe726723204a76eca233f24cffc7ef67", size = 9541409 },
1302
+ { url = "https://files.pythonhosted.org/packages/08/50/95122a407d7f2e446fd865e2388a232a23f2b81934960ea802f3171518e4/matplotlib-3.10.7-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d0b181e9fa8daf1d9f2d4c547527b167cb8838fc587deabca7b5c01f97199e84", size = 9594054 },
1303
+ { url = "https://files.pythonhosted.org/packages/13/76/75b194a43b81583478a81e78a07da8d9ca6ddf50dd0a2ccabf258059481d/matplotlib-3.10.7-cp313-cp313t-win_amd64.whl", hash = "sha256:31963603041634ce1a96053047b40961f7a29eb8f9a62e80cc2c0427aa1d22a2", size = 8200100 },
1304
+ { url = "https://files.pythonhosted.org/packages/f5/9e/6aefebdc9f8235c12bdeeda44cc0383d89c1e41da2c400caf3ee2073a3ce/matplotlib-3.10.7-cp313-cp313t-win_arm64.whl", hash = "sha256:aebed7b50aa6ac698c90f60f854b47e48cd2252b30510e7a1feddaf5a3f72cbf", size = 8042131 },
1305
+ { url = "https://files.pythonhosted.org/packages/0d/4b/e5bc2c321b6a7e3a75638d937d19ea267c34bd5a90e12bee76c4d7c7a0d9/matplotlib-3.10.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d883460c43e8c6b173fef244a2341f7f7c0e9725c7fe68306e8e44ed9c8fb100", size = 8273787 },
1306
+ { url = "https://files.pythonhosted.org/packages/86/ad/6efae459c56c2fbc404da154e13e3a6039129f3c942b0152624f1c621f05/matplotlib-3.10.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07124afcf7a6504eafcb8ce94091c5898bbdd351519a1beb5c45f7a38c67e77f", size = 8131348 },
1307
+ { url = "https://files.pythonhosted.org/packages/a6/5a/a4284d2958dee4116359cc05d7e19c057e64ece1b4ac986ab0f2f4d52d5a/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c17398b709a6cce3d9fdb1595c33e356d91c098cd9486cb2cc21ea2ea418e715", size = 9533949 },
1308
+ { url = "https://files.pythonhosted.org/packages/de/ff/f3781b5057fa3786623ad8976fc9f7b0d02b2f28534751fd5a44240de4cf/matplotlib-3.10.7-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7146d64f561498764561e9cd0ed64fcf582e570fc519e6f521e2d0cfd43365e1", size = 9804247 },
1309
+ { url = "https://files.pythonhosted.org/packages/47/5a/993a59facb8444efb0e197bf55f545ee449902dcee86a4dfc580c3b61314/matplotlib-3.10.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90ad854c0a435da3104c01e2c6f0028d7e719b690998a2333d7218db80950722", size = 9595497 },
1310
+ { url = "https://files.pythonhosted.org/packages/0d/a5/77c95aaa9bb32c345cbb49626ad8eb15550cba2e6d4c88081a6c2ac7b08d/matplotlib-3.10.7-cp314-cp314-win_amd64.whl", hash = "sha256:4645fc5d9d20ffa3a39361fcdbcec731382763b623b72627806bf251b6388866", size = 8252732 },
1311
+ { url = "https://files.pythonhosted.org/packages/74/04/45d269b4268d222390d7817dae77b159651909669a34ee9fdee336db5883/matplotlib-3.10.7-cp314-cp314-win_arm64.whl", hash = "sha256:9257be2f2a03415f9105c486d304a321168e61ad450f6153d77c69504ad764bb", size = 8124240 },
1312
+ { url = "https://files.pythonhosted.org/packages/4b/c7/ca01c607bb827158b439208c153d6f14ddb9fb640768f06f7ca3488ae67b/matplotlib-3.10.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1e4bbad66c177a8fdfa53972e5ef8be72a5f27e6a607cec0d8579abd0f3102b1", size = 8316938 },
1313
+ { url = "https://files.pythonhosted.org/packages/84/d2/5539e66e9f56d2fdec94bb8436f5e449683b4e199bcc897c44fbe3c99e28/matplotlib-3.10.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8eb7194b084b12feb19142262165832fc6ee879b945491d1c3d4660748020c4", size = 8178245 },
1314
+ { url = "https://files.pythonhosted.org/packages/77/b5/e6ca22901fd3e4fe433a82e583436dd872f6c966fca7e63cf806b40356f8/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4d41379b05528091f00e1728004f9a8d7191260f3862178b88e8fd770206318", size = 9541411 },
1315
+ { url = "https://files.pythonhosted.org/packages/9e/99/a4524db57cad8fee54b7237239a8f8360bfcfa3170d37c9e71c090c0f409/matplotlib-3.10.7-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a74f79fafb2e177f240579bc83f0b60f82cc47d2f1d260f422a0627207008ca", size = 9803664 },
1316
+ { url = "https://files.pythonhosted.org/packages/e6/a5/85e2edf76ea0ad4288d174926d9454ea85f3ce5390cc4e6fab196cbf250b/matplotlib-3.10.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:702590829c30aada1e8cef0568ddbffa77ca747b4d6e36c6d173f66e301f89cc", size = 9594066 },
1317
+ { url = "https://files.pythonhosted.org/packages/39/69/9684368a314f6d83fe5c5ad2a4121a3a8e03723d2e5c8ea17b66c1bad0e7/matplotlib-3.10.7-cp314-cp314t-win_amd64.whl", hash = "sha256:f79d5de970fc90cd5591f60053aecfce1fcd736e0303d9f0bf86be649fa68fb8", size = 8342832 },
1318
+ { url = "https://files.pythonhosted.org/packages/04/5f/e22e08da14bc1a0894184640d47819d2338b792732e20d292bf86e5ab785/matplotlib-3.10.7-cp314-cp314t-win_arm64.whl", hash = "sha256:cb783436e47fcf82064baca52ce748af71725d0352e1d31564cbe9c95df92b9c", size = 8172585 },
1319
+ { url = "https://files.pythonhosted.org/packages/58/8f/76d5dc21ac64a49e5498d7f0472c0781dae442dd266a67458baec38288ec/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:15112bcbaef211bd663fa935ec33313b948e214454d949b723998a43357b17b0", size = 8252283 },
1320
+ { url = "https://files.pythonhosted.org/packages/27/0d/9c5d4c2317feb31d819e38c9f947c942f42ebd4eb935fc6fd3518a11eaa7/matplotlib-3.10.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d2a959c640cdeecdd2ec3136e8ea0441da59bcaf58d67e9c590740addba2cb68", size = 8116733 },
1321
+ { url = "https://files.pythonhosted.org/packages/9a/cc/3fe688ff1355010937713164caacf9ed443675ac48a997bab6ed23b3f7c0/matplotlib-3.10.7-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3886e47f64611046bc1db523a09dd0a0a6bed6081e6f90e13806dd1d1d1b5e91", size = 8693919 },
1322
+ ]
1323
+
1324
  [[package]]
1325
  name = "mdurl"
1326
  version = "0.1.2"
 
2174
  { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217 },
2175
  ]
2176
 
2177
+ [[package]]
2178
+ name = "pyparsing"
2179
+ version = "3.2.5"
2180
+ source = { registry = "https://pypi.org/simple" }
2181
+ sdist = { url = "https://files.pythonhosted.org/packages/f2/a5/181488fc2b9d093e3972d2a472855aae8a03f000592dbfce716a512b3359/pyparsing-3.2.5.tar.gz", hash = "sha256:2df8d5b7b2802ef88e8d016a2eb9c7aeaa923529cd251ed0fe4608275d4105b6", size = 1099274 }
2182
+ wheels = [
2183
+ { url = "https://files.pythonhosted.org/packages/10/5e/1aa9a93198c6b64513c9d7752de7422c06402de6600a8767da1524f9570b/pyparsing-3.2.5-py3-none-any.whl", hash = "sha256:e38a4f02064cf41fe6593d328d0512495ad1f3d8a91c4f73fc401b3079a59a5e", size = 113890 },
2184
+ ]
2185
+
2186
  [[package]]
2187
  name = "pytest"
2188
  version = "9.0.1"
 
2433
  { name = "datasets" },
2434
  { name = "gradio" },
2435
  { name = "huggingface-hub" },
2436
+ { name = "matplotlib" },
2437
  { name = "nibabel" },
2438
  { name = "numpy" },
2439
  { name = "pydantic" },
 
2457
  { name = "datasets", git = "https://github.com/CloseChoice/datasets.git?rev=feat%2Fbids-loader-streaming-upload-fix" },
2458
  { name = "gradio", specifier = ">=5.0.0" },
2459
  { name = "huggingface-hub", specifier = ">=0.25.0" },
2460
+ { name = "matplotlib", specifier = ">=3.8.0" },
2461
  { name = "nibabel", specifier = ">=5.2.0" },
2462
  { name = "numpy", specifier = ">=1.26.0" },
2463
  { name = "pydantic", specifier = ">=2.5.0" },