File size: 25,996 Bytes
71ae2f0
 
 
 
 
 
 
 
 
 
b955807
 
 
 
 
 
 
 
 
 
49fbf68
b955807
49fbf68
b955807
 
 
4780d8d
42a4892
b955807
 
 
 
 
 
 
 
a2d70a9
 
 
b955807
 
 
 
 
 
 
 
 
 
 
 
4780d8d
 
 
 
 
 
b955807
 
3cb7ed4
b955807
 
 
 
4780d8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b955807
4780d8d
 
 
 
b955807
 
 
 
7c7a1ba
 
 
 
4780d8d
 
 
 
7c7a1ba
 
 
 
 
 
 
 
 
 
 
4780d8d
a2d70a9
 
 
4780d8d
a2d70a9
 
 
 
 
 
 
 
 
 
 
4780d8d
 
 
0234c58
4780d8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7c7a1ba
4780d8d
 
 
 
 
 
 
 
 
0234c58
4780d8d
 
 
7774e34
 
4780d8d
 
 
 
 
 
 
7c7a1ba
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4780d8d
7c7a1ba
4780d8d
7c7a1ba
 
 
 
4780d8d
b955807
4780d8d
 
 
a2d70a9
 
 
 
4780d8d
a2d70a9
 
 
0234c58
b955807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0234c58
b955807
0234c58
 
 
 
 
7c7a1ba
 
 
b955807
 
 
 
 
 
 
 
4780d8d
7c7a1ba
 
42a4892
4fda083
 
 
42a4892
 
4fda083
42a4892
4fda083
 
 
 
42a4892
 
4fda083
 
4780d8d
7c7a1ba
4fda083
b955807
 
4fda083
b955807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49fbf68
 
 
7c7a1ba
49fbf68
 
 
 
 
 
b955807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49fbf68
b955807
49fbf68
b955807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4780d8d
 
 
 
 
 
 
 
b955807
 
4780d8d
 
 
 
b955807
 
 
 
 
 
 
4780d8d
 
 
7c7a1ba
4780d8d
 
 
 
 
b955807
 
 
 
4780d8d
 
 
 
 
 
7c7a1ba
 
 
6bb43ff
 
 
4780d8d
 
 
 
 
b955807
 
 
49fbf68
 
b955807
 
 
 
 
 
4780d8d
 
 
 
b955807
 
 
 
4780d8d
 
 
 
 
 
 
b955807
 
 
 
 
4780d8d
b955807
 
 
 
4780d8d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b955807
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4780d8d
 
 
 
 
 
b955807
 
 
7c7a1ba
b955807
 
 
 
 
 
 
 
7c7a1ba
b955807
 
4780d8d
 
 
 
 
 
b955807
4780d8d
b955807
 
 
 
 
 
 
42a4892
 
 
 
b955807
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
"""Gradio web interface for Mosaic.

This module provides the web-based user interface for analyzing whole slide images.
It includes functionality for:
- Multi-slide upload and analysis
- Settings configuration (site type, cancer subtype, IHC subtype, segmentation)
- Results visualization and export
- CSV-based batch processing
"""

import gradio as gr
import pandas as pd
from pathlib import Path
from loguru import logger

from mosaic.ui.utils import (
    get_oncotree_code_name,
    create_user_directory,
    load_settings,
    validate_settings,
    get_tissue_sites,
    IHC_SUBTYPES,
    SEX_OPTIONS,
    SETTINGS_COLUMNS,
)
from mosaic.analysis import analyze_slide
from mosaic.model_manager import load_all_models
from mosaic.hardware import DEFAULT_CONCURRENCY_LIMIT, IS_T4_GPU

current_dir = Path(__file__).parent.parent

# Global variables for cancer subtypes (set by download_and_process_models)
cancer_subtype_name_map = {}
reversed_cancer_subtype_name_map = {}
cancer_subtypes = []

# Global model cache for T4 (to persist models across sequential requests)
_global_model_cache = None


def set_cancer_subtype_maps(csn_map, rcsn_map, cs):
    """Set the global cancer subtype maps."""
    global cancer_subtype_name_map, reversed_cancer_subtype_name_map, cancer_subtypes
    cancer_subtype_name_map = csn_map
    reversed_cancer_subtype_name_map = rcsn_map
    cancer_subtypes = cs


def analyze_slides(
    slides,
    settings_input,
    site_type,
    sex,
    tissue_site,
    cancer_subtype,
    ihc_subtype,
    seg_config,
    user_dir,
    progress=gr.Progress(track_tqdm=True),
    request: gr.Request = None,
):
    if slides is None or len(slides) == 0:
        raise gr.Error("Please upload at least one slide.")
    if user_dir is None:
        if request is not None:
            user_dir = create_user_directory(None, request)
        if user_dir is None:
            # Fallback to temp directory if session hash not available
            import tempfile

            user_dir = Path(tempfile.mkdtemp(prefix="mosaic_"))

    # Handle empty settings_input (e.g., when dataframe is hidden for single slide)
    # Regenerate settings from dropdowns if settings_input is empty
    if settings_input is None or len(settings_input) == 0:
        logger.info("Settings dataframe is empty, regenerating from dropdown values")
        settings = []
        for file in slides:
            filename = file.name if hasattr(file, "name") else file
            slide_name = filename.split("/")[-1]
            settings.append(
                [
                    slide_name,
                    site_type,
                    sex,
                    tissue_site,
                    cancer_subtype,
                    ihc_subtype,
                    seg_config,
                ]
            )
        settings_input = pd.DataFrame(settings, columns=SETTINGS_COLUMNS)

    settings_input = validate_settings(
        settings_input,
        cancer_subtype_name_map,
        cancer_subtypes,
        reversed_cancer_subtype_name_map,
    )
    if len(slides) != len(settings_input):
        raise gr.Error("Missing settings for uploaded slides")

    # Check that all slides have sex specified
    if settings_input["Sex"].isna().any() or (settings_input["Sex"] == "").any() or (settings_input["Sex"] == None).any():
        raise gr.Error("Sex is required for all slides. Please select Male or Female.")

    all_slide_masks = []
    all_aeon_results = []
    all_paladin_results = []

    # Yield initial state to make settings table visible immediately
    yield (
        gr.Dataframe(value=settings_input, visible=True),  # Make settings visible
        [],  # Empty slide masks
        gr.DataFrame(visible=False),  # Hidden AEON table
        gr.DownloadButton(visible=False),  # Hidden AEON download
        None,  # No PALADIN results yet
        gr.DownloadButton(visible=False),  # Hidden PALADIN download
        user_dir,  # user_dir_state
    )

    # Load models once (for batch) or per-slide (for single)
    # On T4: Keep models loaded globally across all requests (concurrency=1 ensures no conflicts)
    # On high-memory GPUs: Load models per-batch, reload for single slides
    global _global_model_cache
    model_cache = None

    if IS_T4_GPU:
        # T4: Use global cache to keep models loaded across requests
        if _global_model_cache is None:
            logger.info("T4: Loading models once (will persist across all requests)")
            progress(0.0, desc="Loading models (one-time initialization)")
            _global_model_cache = load_all_models(use_gpu=True, aggressive_memory_mgmt=None)
        else:
            logger.info(f"T4: Reusing pre-loaded models from global cache")
        model_cache = _global_model_cache
    elif len(slides) > 1:
        logger.info(f"Batch mode: Loading models once for {len(slides)} slides")
        progress(0.0, desc=f"Loading models for batch processing")
        model_cache = load_all_models(use_gpu=True, aggressive_memory_mgmt=None)
    else:
        logger.info("Single-slide mode: models loaded within analyze_slide")

    try:
        # Process all slides with unified analyze_slide function
        for idx, slide_path in enumerate(slides):
            row = settings_input.iloc[idx]
            slide_name = row["Slide"]

            logger.info(f"[{idx + 1}/{len(slides)}] Processing: {slide_name}")
            slide_progress = idx / len(slides)
            progress(slide_progress, desc=f"Analyzing slide {idx + 1}/{len(slides)}")

            slide_mask, aeon_results, paladin_results = analyze_slide(
                slide_path=slide_path,
                seg_config=row["Segmentation Config"],
                site_type=row["Site Type"],
                sex=row["Sex"],
                tissue_site=row.get("Tissue Site", "Unknown"),
                cancer_subtype=row["Cancer Subtype"],
                cancer_subtype_name_map=cancer_subtype_name_map,
                ihc_subtype=row.get("IHC Subtype", ""),
                num_workers=4,
                progress=progress,
                request=request,
                model_cache=model_cache,  # Pre-loaded for batch, None for single
            )

            if slide_mask is not None:
                all_slide_masks.append((slide_mask, slide_name))
            if aeon_results is not None:
                # Rename "Confidence" column to slide name for proper concatenation
                aeon_results = aeon_results.rename(columns={"Confidence": slide_name})
                all_aeon_results.append(aeon_results)
            if paladin_results is not None:
                paladin_results.insert(
                    0, "Slide", pd.Series([slide_name] * len(paladin_results))
                )
                all_paladin_results.append(paladin_results)

            # Build partial AEON results for display
            partial_aeon_df = gr.DataFrame(visible=False)
            if all_aeon_results:
                partial_aeon = pd.concat(all_aeon_results, axis=1)
                partial_aeon.reset_index(inplace=True)
                partial_aeon = partial_aeon.round(3)

                # Convert OncoTree codes to names for display
                cancer_subtype_names = [
                    f"{get_oncotree_code_name(code)} ({code})"
                    for code in partial_aeon["Cancer Subtype"]
                ]
                partial_aeon["Cancer Subtype"] = cancer_subtype_names

                partial_aeon_df = gr.DataFrame(
                    partial_aeon,
                    visible=True,
                    column_widths=["4px"] + ["2px"] * (partial_aeon.shape[1] - 1),
                )

            # Build partial PALADIN results for display
            partial_paladin_df = None
            if all_paladin_results:
                partial_paladin = pd.concat(all_paladin_results, ignore_index=True)

                # Convert OncoTree codes to names for display
                cancer_subtype_names = [
                    f"{get_oncotree_code_name(code)} ({code})"
                    for code in partial_paladin["Cancer Subtype"]
                ]
                partial_paladin["Cancer Subtype"] = cancer_subtype_names

                # Ensure Score is numeric before rounding
                partial_paladin["Score"] = pd.to_numeric(partial_paladin["Score"], errors='coerce')
                partial_paladin["Score"] = partial_paladin["Score"].round(3)

                partial_paladin_df = partial_paladin

            # Yield intermediate update to show progressive results
            # Download buttons stay hidden until all slides are processed
            # Make settings visible during processing (for progress bar display)
            yield (
                gr.Dataframe(value=settings_input, visible=True),  # Settings visible for progress
                all_slide_masks.copy(),  # Current slide masks
                partial_aeon_df,  # Partial AEON results (growing)
                gr.DownloadButton(visible=False),  # Download button hidden until complete
                partial_paladin_df,  # Partial PALADIN results (growing)
                gr.DownloadButton(visible=False),  # Download button hidden until complete
                user_dir,  # user_dir_state
            )

    finally:
        # Clean up model cache if it was loaded for batch processing
        # On T4: Keep global cache loaded, only cleanup Paladin models
        # On high-memory GPUs: Cleanup everything after batch
        if model_cache is not None and not IS_T4_GPU:
            logger.info("Cleaning up model cache after batch")
            model_cache.cleanup()
        elif IS_T4_GPU and model_cache is not None:
            logger.info("T4: Keeping core models loaded, cleaning up Paladin models only")
            model_cache.cleanup_paladin()

    progress(0.99, desc="Analysis complete, wrapping up results")

    timestamp = pd.Timestamp.now().strftime("%Y%m%d-%H%M%S")
    combined_paladin_results = (
        pd.concat(all_paladin_results, ignore_index=True)
        if all_paladin_results
        else pd.DataFrame()
    )
    combined_aeon_results = gr.DataFrame(visible=False)
    aeon_output = gr.DownloadButton(visible=False)
    if all_aeon_results:
        combined_aeon_results = pd.concat(all_aeon_results, axis=1)
        combined_aeon_results.reset_index(inplace=True)

        combined_aeon_results = combined_aeon_results.round(3)
        cancer_subtype_names = [
            f"{get_oncotree_code_name(code)} ({code})"
            for code in combined_aeon_results["Cancer Subtype"]
        ]
        combined_aeon_results["Cancer Subtype"] = cancer_subtype_names

        aeon_output_path = user_dir / f"aeon_results-{timestamp}.csv"
        combined_aeon_results.to_csv(aeon_output_path)

        combined_aeon_results = gr.DataFrame(
            combined_aeon_results,
            visible=True,
            column_widths=["4px"] + ["2px"] * (combined_aeon_results.shape[1] - 1),
        )
        aeon_output = gr.DownloadButton(value=aeon_output_path, visible=True)

    # Convert Oncotree codes to names for display
    paladin_output = gr.DownloadButton(visible=False)
    if len(combined_paladin_results) > 0:
        cancer_subtype_names = [
            f"{get_oncotree_code_name(code)} ({code})"
            for code in combined_paladin_results["Cancer Subtype"]
        ]
        combined_paladin_results["Cancer Subtype"] = cancer_subtype_names

        # Ensure Score is numeric before rounding
        combined_paladin_results["Score"] = pd.to_numeric(combined_paladin_results["Score"], errors='coerce')
        combined_paladin_results["Score"] = combined_paladin_results["Score"].round(3)

        paladin_output_path = user_dir / f"paladin_results-{timestamp}.csv"
        combined_paladin_results.to_csv(paladin_output_path, index=False)
        paladin_output = gr.DownloadButton(value=paladin_output_path, visible=True)

    progress(1.0, desc="All done!")

    # Final yield with complete results
    # Hide settings table if only one slide, keep visible for multiple slides
    settings_visible = len(slides) > 1

    # Store final results before cleanup
    final_slide_masks = all_slide_masks
    final_combined_paladin = combined_paladin_results if len(combined_paladin_results) > 0 else None

    # Memory cleanup: Clear intermediate data structures from RAM
    import gc

    all_slide_masks = None
    all_aeon_results = None
    all_paladin_results = None
    combined_paladin_results = None

    # Force garbage collection to free Python memory
    gc.collect()
    
    yield (
        gr.Dataframe(value=settings_input, visible=settings_visible),  # Hide if single slide
        final_slide_masks,
        combined_aeon_results,
        aeon_output,
        final_combined_paladin,
        paladin_output,
        user_dir,
    )


def launch_gradio(server_name, server_port, share):
    with gr.Blocks(title="Mosaic") as demo:
        user_dir_state = gr.State(None)
        gr.Markdown(
            "# Mosaic: H&E Whole Slide Image Cancer Subtype and Biomarker Inference"
        )
        gr.Markdown(
            "Upload an H&E whole slide image in SVS or TIFF format. The slide will be processed to infer cancer subtype and relevant biomarkers."
        )
        with gr.Row():
            with gr.Column():
                input_slides = gr.File(
                    label="Upload H&E Whole Slide Image",
                    file_types=[".svs", ".tiff", ".tif"],
                    file_count="multiple",
                )
                site_dropdown = gr.Dropdown(
                    choices=["Primary", "Metastatic"],
                    label="Site Type",
                    value="Primary",
                )
                sex_dropdown = gr.Dropdown(
                    choices=SEX_OPTIONS,
                    label="Sex",
                    value=None,
                )
                tissue_site_dropdown = gr.Dropdown(
                    choices=get_tissue_sites(),
                    label="Tissue Site",
                    value="Unknown",
                )
                cancer_subtype_dropdown = gr.Dropdown(
                    choices=[name for name in cancer_subtype_name_map.keys()],
                    label="Cancer Subtype",
                    value="Unknown",
                )
                ihc_subtype_dropdown = gr.Dropdown(
                    choices=IHC_SUBTYPES,
                    label="IHC Subtype (if applicable)",
                    value="",
                    visible=False,
                )
                seg_config_dropdown = gr.Dropdown(
                    choices=["Biopsy", "Resection", "TCGA"],
                    label="Segmentation Config",
                    value="Biopsy",
                )
                with gr.Row():
                    settings_input = gr.Dataframe(
                        headers=SETTINGS_COLUMNS,
                        label="Current Settings",
                        datatype=["str"] * len(SETTINGS_COLUMNS),
                        visible=False,
                        interactive=True,
                        static_columns="Slide",
                    )

                with gr.Row():
                    settings_csv = gr.File(
                        file_types=[".csv"], label="Upload Settings CSV", visible=False
                    )

                with gr.Row():
                    clear_button = gr.Button("Clear")
                    analyze_button = gr.Button("Analyze", variant="primary")
            with gr.Column():
                slide_masks = gr.Gallery(
                    label="Slide Masks",
                    columns=3,
                    object_fit="contain",
                    height="auto",
                )
                aeon_output_table = gr.Dataframe(
                    headers=["Cancer Subtype", "Slide Name"],
                    label="Cancer Subtype Inference Confidence",
                    datatype=["str", "number"],
                    visible=False,
                )
                aeon_download_button = gr.DownloadButton(
                    "Download Aeon Results as CSV",
                    label="Download Results",
                    visible=False,
                )
                paladin_output_table = gr.Dataframe(
                    headers=["Slide", "Cancer Subtype", "Biomarker", "Score"],
                    label="Biomarker Inference",
                    datatype=["str", "str", "str", "number"],
                )
                paladin_download_button = gr.DownloadButton(
                    "Download Paladin Results as CSV",
                    label="Download Results",
                    visible=False,
                )

        @clear_button.click(
            outputs=[
                input_slides,
                slide_masks,
                paladin_output_table,
                paladin_download_button,
                aeon_output_table,
                aeon_download_button,
                settings_input,
                settings_csv,
            ],
        )
        def clear_fn():
            return (
                None,  # input_slides
                None,  # slide_masks
                None,  # paladin_output_table
                gr.DownloadButton(visible=False),  # paladin_download_button
                gr.Dataframe(visible=False),  # aeon_output_table
                gr.DownloadButton(visible=False),  # aeon_download_button
                gr.Dataframe(visible=False),  # settings_input
                gr.File(visible=False),  # settings_csv
            )

        def get_settings(
            files, site_type, sex, tissue_site, cancer_subtype, ihc_subtype, seg_config
        ):
            """Generate initial settings DataFrame from uploaded files and dropdown values."""
            if files is None:
                return pd.DataFrame()
            settings = []
            for file in files:
                filename = file.name if hasattr(file, "name") else file
                slide_name = filename.split("/")[-1]
                settings.append(
                    [
                        slide_name,
                        site_type,
                        sex if sex is not None else "",
                        tissue_site,
                        cancer_subtype,
                        ihc_subtype,
                        seg_config,
                    ]
                )
            df = pd.DataFrame(settings, columns=SETTINGS_COLUMNS)
            return df

        def update_settings_column(settings_df, column_name, new_value):
            """Update a specific column in the settings DataFrame."""
            if settings_df is None or len(settings_df) == 0:
                return settings_df
            # Create a copy to avoid modifying the original
            updated_df = settings_df.copy()
            # Convert None to empty string for display (especially for Sex column)
            if new_value is None:
                new_value = ""
            # Convert legacy "Unknown" sex values to empty string
            if column_name == "Sex" and new_value == "Unknown":
                new_value = ""
            updated_df[column_name] = new_value
            return updated_df

        # Handle file uploads - regenerate entire settings table
        @input_slides.change(
            inputs=[
                input_slides,
                site_dropdown,
                sex_dropdown,
                tissue_site_dropdown,
                cancer_subtype_dropdown,
                ihc_subtype_dropdown,
                seg_config_dropdown,
            ],
            outputs=[settings_input, settings_csv, ihc_subtype_dropdown],
        )
        def update_files(
            files, site_type, sex, tissue_site, cancer_subtype, ihc_subtype, seg_config
        ):
            """Handle file upload - regenerate settings table from scratch."""
            has_ihc = "Breast" in cancer_subtype
            if not files:
                return None, None, gr.Dropdown(visible=has_ihc)
            settings_df = get_settings(
                files,
                site_type,
                sex,
                tissue_site,
                cancer_subtype,
                ihc_subtype,
                seg_config,
            )
            if settings_df is not None:
                has_ihc = any("Breast" in cs for cs in settings_df["Cancer Subtype"])
            visible = files and len(files) > 1
            return (
                gr.Dataframe(value=settings_df, visible=visible),
                gr.File(visible=visible),
                gr.Dropdown(visible=has_ihc),
            )

        # Handle individual dropdown changes - only update the relevant column
        @site_dropdown.change(
            inputs=[settings_input, site_dropdown],
            outputs=[settings_input],
        )
        def update_site_type(settings_df, site_type):
            """Update Site Type column when dropdown changes."""
            if settings_df is None or len(settings_df) == 0:
                return settings_df
            updated_df = update_settings_column(settings_df, "Site Type", site_type)
            return gr.Dataframe(value=updated_df)

        @sex_dropdown.change(
            inputs=[settings_input, sex_dropdown],
            outputs=[settings_input],
        )
        def update_sex(settings_df, sex):
            """Update Sex column when dropdown changes."""
            if settings_df is None or len(settings_df) == 0:
                return settings_df
            updated_df = update_settings_column(settings_df, "Sex", sex)
            return gr.Dataframe(value=updated_df)

        @tissue_site_dropdown.change(
            inputs=[settings_input, tissue_site_dropdown],
            outputs=[settings_input],
        )
        def update_tissue_site(settings_df, tissue_site):
            """Update Tissue Site column when dropdown changes."""
            if settings_df is None or len(settings_df) == 0:
                return settings_df
            updated_df = update_settings_column(settings_df, "Tissue Site", tissue_site)
            return gr.Dataframe(value=updated_df)

        @cancer_subtype_dropdown.change(
            inputs=[settings_input, cancer_subtype_dropdown],
            outputs=[settings_input, ihc_subtype_dropdown],
        )
        def update_cancer_subtype(settings_df, cancer_subtype):
            """Update Cancer Subtype column when dropdown changes."""
            has_ihc = "Breast" in cancer_subtype
            if settings_df is None or len(settings_df) == 0:
                return settings_df, gr.Dropdown(visible=has_ihc)
            updated_df = update_settings_column(
                settings_df, "Cancer Subtype", cancer_subtype
            )
            return gr.Dataframe(value=updated_df), gr.Dropdown(visible=has_ihc)

        @ihc_subtype_dropdown.change(
            inputs=[settings_input, ihc_subtype_dropdown],
            outputs=[settings_input],
        )
        def update_ihc_subtype(settings_df, ihc_subtype):
            """Update IHC Subtype column when dropdown changes."""
            if settings_df is None or len(settings_df) == 0:
                return settings_df
            updated_df = update_settings_column(settings_df, "IHC Subtype", ihc_subtype)
            return gr.Dataframe(value=updated_df)

        @seg_config_dropdown.change(
            inputs=[settings_input, seg_config_dropdown],
            outputs=[settings_input],
        )
        def update_seg_config(settings_df, seg_config):
            """Update Segmentation Config column when dropdown changes."""
            if settings_df is None or len(settings_df) == 0:
                return settings_df
            updated_df = update_settings_column(
                settings_df, "Segmentation Config", seg_config
            )
            return gr.Dataframe(value=updated_df)

        @settings_csv.upload(
            inputs=[settings_csv],
            outputs=[settings_input],
        )
        def read_settings(file):
            if file is None:
                return None
            df = load_settings(file.name if hasattr(file, "name") else file)
            return gr.Dataframe(df, visible=True)

        analyze_button.click(
            analyze_slides,
            inputs=[
                input_slides,
                settings_input,
                site_dropdown,
                sex_dropdown,
                tissue_site_dropdown,
                cancer_subtype_dropdown,
                ihc_subtype_dropdown,
                seg_config_dropdown,
                user_dir_state,
            ],
            outputs=[
                settings_input,
                slide_masks,
                aeon_output_table,
                aeon_download_button,
                paladin_output_table,
                paladin_download_button,
                user_dir_state,
            ],
            queue=True,
            show_progress_on=settings_input,
        )
        settings_input.change(
            lambda df: validate_settings(
                df,
                cancer_subtype_name_map,
                cancer_subtypes,
                reversed_cancer_subtype_name_map,
            ),
            inputs=[settings_input],
            outputs=[settings_input],
        )
        demo.load(
            create_user_directory,
            inputs=[user_dir_state],
            outputs=[user_dir_state],
        )

    # Use hardware-specific concurrency limit
    # T4 GPUs (16GB) can only handle one analysis at a time to prevent OOM
    # Higher-memory GPUs and ZeroGPU can handle multiple concurrent analyses
    demo.queue(max_size=10, default_concurrency_limit=DEFAULT_CONCURRENCY_LIMIT)
    demo.launch(
        server_name=server_name,
        share=share,
        server_port=server_port,
        show_error=True,
        favicon_path=current_dir / "favicon.svg",
    )