File size: 34,575 Bytes
01857e4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
#!/usr/bin/env python3
"""
End-to-End Medical Image Dataset Preprocessing Pipeline

This script combines three steps into one:
1. Convert raw BiomedParse format to HF-compatible format
2. Pack dataset and extract template features  
3. Precompute template matches

Usage:
    python end_to_end_preprocessing.py \
        --dataset_name ACDC \
        --template_percent 5 \
        --raw_data_dir /projects/p32685/medical_data/ACDC \
        --output_base_dir /projects/p32685/RL_dataset \
        --device cuda:0

Output:
    /projects/p32685/RL_dataset/acdc/
    /projects/p32685/RL_dataset/acdc_5percent_processed/
        ├── backup_entries/  (with precomputed template matches)
        ├── hf_dataset/
        └── template/
"""

import os
import sys
import json
import argparse
import subprocess
import random
import glob
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Tuple

# Add LISA-main to path
SCRIPT_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(SCRIPT_DIR))

# Add VESSA to path for TemplateManager
# Try local template_manager first, then fallback to VESSA path if exists
VESSA_PATH = "/home/wfm8324/VESSA/train_vessa"
if os.path.exists(VESSA_PATH) and VESSA_PATH not in sys.path:
    sys.path.insert(0, VESSA_PATH)
# template_manager.py is in the same directory, so it will be found automatically

import numpy as np
from PIL import Image

# Ensure logs directory exists
os.makedirs(os.path.join(SCRIPT_DIR, "logs"), exist_ok=True)

# Known dataset configurations
DATASET_TYPES = {
    "ACDC": "ACDC",
    "CAMUS": "CAMUS",
    "MMs": "ACDC",  # Uses ACDC config
    "LGG": "LGG",
    "CXR": "CXR",
    "BreastUS": "BreastUS",
    "BUS": "BUS",
    "kits23": "kits23",
    "abdomenct": "abdomenct",
    "lungx": "lungx",
    "perfusion": "perfusion",
    "amos22_CT": "AMOS22_CT",
    "amos22_MRI": "AMOS22_MRI",
    # MSD Tasks
    "Task01_BrainTumour": "Task01_BrainTumour",
    "Task02_Heart": "Task02_Heart",
    "Task03_Liver": "Task03_Liver",
    "Task04_Hippocampus": "Task04_Hippocampus",
    "Task05_Prostate": "Task05_Prostate",
    "Task06_Lung": "Task06_Lung",
    "Task07_Pancreas": "Task07_Pancreas",
    "Task08_HepaticVessel": "Task08_HepaticVessel",
    "Task09_Spleen": "Task09_Spleen",
    "Task10_Colon": "Task10_Colon",
    # Six new datasets
    "BraTS2020": "BraTS2020",
    "SPIDER": "SPIDER",
    "STS-2D-Tooth": "STS-2D-Tooth",
    "STS-3D-Tooth": "STS-3D-Tooth",
    "TotalSegmentator": "TotalSegmentator",
    "TotalSegmentatorMRI": "TotalSegmentatorMRI",
    "TotalSegmentator_reduced": "TotalSegmentator_reduced",
    "TotalSegmentatorMRI_reduced": "TotalSegmentatorMRI_reduced"
}


def get_output_folder_name(dataset_name):
    """Output folder name in RL_dataset: remove '_reduced' for reduced datasets."""
    if dataset_name == "TotalSegmentator_reduced":
        return "totalsegmentator"
    if dataset_name == "TotalSegmentatorMRI_reduced":
        return "totalsegmentatormri"
    return dataset_name.lower()


def run_step1_convert(dataset_name, dataset_type, raw_data_dir, output_base_dir, output_folder_name=None, split_mode="default"):
    """
    Step 1: Convert raw BiomedParse format to HF-compatible format
    
    For MSD tasks, output structure is:
    output_base_dir/MSD_taskXX/TaskXX_Name/
    """
    print("\n" + "="*80)
    print(f"STEP 1: Converting {dataset_name} to HF format")
    print("="*80)
    
    # Special handling for MSD tasks
    if dataset_name.startswith("Task") and dataset_name.endswith(("BrainTumour", "Heart", "Liver", "Hippocampus", 
                                                                    "Prostate", "Lung", "Pancreas", "HepaticVessel", 
                                                                    "Spleen", "Colon")):
        # Extract task number (e.g., Task01_BrainTumour -> MSD_task01)
        task_num = dataset_name.split("_")[0]  # Task01
        msd_task_dir = os.path.join(output_base_dir, f"MSD_{task_num.lower()}")
        os.makedirs(msd_task_dir, exist_ok=True)
        # Output to MSD_taskXX/TaskXX_Name/
        output_dir = os.path.join(msd_task_dir, dataset_name)
    else:
        # Standard output: output_base_dir/<folder_name> (reduced 数据集去掉 _reduced)
        folder_name = output_folder_name if output_folder_name else get_output_folder_name(dataset_name)
        output_dir = os.path.join(output_base_dir, folder_name)
    
    # Check if already converted (must have dataset_info.json AND actual data files)
    dataset_info_path = os.path.join(output_dir, "dataset_info.json")
    train_images_dir = os.path.join(output_dir, "train", "images")
    has_data = False
    if os.path.exists(train_images_dir):
        image_files = [f for f in os.listdir(train_images_dir) if f.endswith(('.jpg', '.png', '.jpeg'))]
        has_data = len(image_files) > 0
    
    if os.path.exists(output_dir) and os.path.isfile(dataset_info_path) and has_data:
        print(f"✓ Dataset already converted: {output_dir}")
        return output_dir
    if os.path.exists(output_dir):
        if not os.path.isfile(dataset_info_path):
            print(f"⚠ Output dir exists but dataset_info.json missing (incomplete run); removing and re-running conversion.")
        elif not has_data:
            print(f"⚠ Output dir exists but no image files found (incomplete run); removing and re-running conversion.")
        import shutil
        shutil.rmtree(output_dir)
        print(f"[DEBUG] Removed incomplete directory: {output_dir}")
    
    cmd = [
        sys.executable,
        os.path.join(SCRIPT_DIR, "convert_biomedparse_to_hf_format.py"),
        "--input_dir", raw_data_dir,
        "--output_dir", output_dir,
        "--dataset_type", dataset_type,
        "--num_vis", "30",
        "--split_mode", split_mode
    ]
    
    print(f"Running: {' '.join(cmd)}")
    print(f"[DEBUG] Step 1 started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    
    try:
        result = subprocess.run(cmd, check=True)
        
        if result.returncode == 0:
            print(f"[DEBUG] Step 1 completed successfully at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
            print(f"✓ Step 1 complete: {output_dir}")
            return output_dir
        else:
            print(f"[DEBUG] Step 1 failed with return code: {result.returncode}")
            raise RuntimeError(f"Step 1 failed with code {result.returncode}")
    except subprocess.CalledProcessError as e:
        print(f"[DEBUG] Step 1 subprocess error: {e}")
        print(f"[DEBUG] Step 1 failed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        raise
    except Exception as e:
        print(f"[DEBUG] Step 1 unexpected error: {e}")
        print(f"[DEBUG] Step 1 failed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        raise


def run_step2_packing(dataset_dir, output_base_dir, dataset_name, template_percent, processed_prefix=None,
                      template_extraction_device="cuda:0"):
    """
    Step 2: Pack dataset and extract template features
    
    For MSD tasks, output structure is:
    output_base_dir/MSD_taskXX/TaskXX_Name_10percent_processed/
    """
    print("\n" + "="*80)
    print(f"STEP 2: Packing {dataset_name} with {template_percent}% templates")
    print("="*80)
    
    # Special handling for MSD tasks
    if dataset_name.startswith("Task") and dataset_name.endswith(("BrainTumour", "Heart", "Liver", "Hippocampus", 
                                                                    "Prostate", "Lung", "Pancreas", "HepaticVessel", 
                                                                    "Spleen", "Colon")):
        # Extract task number (e.g., Task01_BrainTumour -> MSD_task01)
        task_num = dataset_name.split("_")[0]  # Task01
        msd_task_dir = os.path.join(output_base_dir, f"MSD_{task_num.lower()}")
        os.makedirs(msd_task_dir, exist_ok=True)
        # Output to MSD_taskXX/TaskXX_Name_10percent_processed/
        output_dir = os.path.join(msd_task_dir, f"{dataset_name}_{template_percent}percent_processed")
    else:
        # Standard output: output_base_dir/<folder_name>_Xpercent_processed
        folder_prefix = processed_prefix if processed_prefix else get_output_folder_name(dataset_name)
        output_dir = os.path.join(output_base_dir, f"{folder_prefix}_{template_percent}percent_processed")
    
    # Check if already packed
    if os.path.exists(output_dir):
        print(f"✓ Dataset already packed: {output_dir}")
        return output_dir
    
    print(f"[DEBUG] Step 2 started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"[DEBUG] Input dataset_dir: {dataset_dir}")
    print(f"[DEBUG] Output dir: {output_dir}")
    
    # First, run packing without templates
    cmd_pack = [
        sys.executable,
        os.path.join(SCRIPT_DIR, "packing_HF_dataset.py"),
        "--dataset_root_dir", dataset_dir,
        "--output_dir", output_dir,
        "--subset_size", "0",
        "--device", "cpu"
    ]
    
    print(f"Running packing: {' '.join(cmd_pack)}")
    print(f"[DEBUG] Starting packing subprocess...")
    
    try:
        result = subprocess.run(cmd_pack, check=True)
        print(f"[DEBUG] Packing subprocess completed successfully (returncode: {result.returncode})")
    except subprocess.CalledProcessError as e:
        print(f"[DEBUG] Packing subprocess failed with return code: {e.returncode}")
        print(f"[DEBUG] Step 2 failed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        raise
    except Exception as e:
        print(f"[DEBUG] Packing unexpected error: {e}")
        print(f"[DEBUG] Step 2 failed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        raise
    
    # Then extract templates
    print(f"\nExtracting templates ({template_percent}% of training data)...")
    print(f"[DEBUG] Starting template extraction...")
    
    try:
        extract_templates_python(dataset_dir, output_dir, template_percent, device=template_extraction_device)
        print(f"[DEBUG] Template extraction completed successfully")
    except Exception as e:
        print(f"[DEBUG] Template extraction failed: {e}")
        print(f"[DEBUG] Step 2 failed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        raise
    
    print(f"[DEBUG] Step 2 completed successfully at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"✓ Step 2 complete: {output_dir}")
    return output_dir


def extract_templates_python(dataset_dir, output_dir, template_percent, device="cuda:0"):
    """
    Extract template features using Python directly (DINOv2 on ``device``).
    """
    from packing_HF_dataset import extract_template_features
    from PIL import Image as PILImage
    
    image_dir = os.path.join(dataset_dir, "train", "images")
    mask_dir = os.path.join(dataset_dir, "train", "masks")
    template_dir = os.path.join(output_dir, "template")
    
    # Get all images
    all_images = sorted(glob.glob(os.path.join(image_dir, "*.jpg")))
    num_templates = int(len(all_images) * (template_percent / 100.0))
    
    # Randomly sample
    random.seed(42)  # Fixed seed for reproducibility
    selected_images = random.sample(all_images, num_templates)
    
    print(f"Total training images: {len(all_images)}")
    print(f"{template_percent}% = {num_templates} templates")
    
    # Load templates
    template_data_dict = {}
    for img_path in selected_images:
        template_id = os.path.splitext(os.path.basename(img_path))[0]
        mask_path = os.path.join(mask_dir, f"{template_id}.png")
        if os.path.exists(mask_path):
            img = PILImage.open(img_path).convert('RGB')
            mask = PILImage.open(mask_path).convert('L')
            # Get modality from dataset_info.json
            dataset_info_path = os.path.join(dataset_dir, "dataset_info.json")
            modality = "MRI"  # Default
            if os.path.exists(dataset_info_path):
                with open(dataset_info_path) as f:
                    info = json.load(f)
                    modality = info.get("modality", "MRI")
            template_data_dict[template_id] = {'image': img, 'mask': mask, 'modality': modality}
    
    # Extract features
    if template_data_dict:
        results = extract_template_features(template_data_dict=template_data_dict, output_dir=template_dir, device=device)
        print(f"✓ Extracted {len(results)} templates")
    else:
        print("✗ No templates loaded")


def get_present_label_ids_from_mask_path(mask_path: str) -> List[int]:
    """Read a label mask and return non-background class ids actually present."""
    try:
        if mask_path and os.path.exists(mask_path):
            arr = np.array(Image.open(mask_path))
            uniq = np.unique(arr)
            present = [int(v) for v in uniq.tolist() if int(v) != 0]
            return present
    except Exception:
        pass
    return []


def get_non_bg_label_ids(labels_json_str: str) -> List[int]:
    """Parse labels JSON and return non-background class IDs."""
    labels = json.loads(labels_json_str)
    return [int(k) for k, name in labels.items() if k != "0" and name != "background"]


def precompute_for_entries(
    entries: List[Dict[str, Any]],
    templates: Dict[str, Dict[str, Any]],
    index,
    template_ids_list: List[str],
    tm,
    topk: int = 1,
    match_strategy: str = "complete",
    max_search: int = 100,
    overwrite: bool = True,
    allow_fallback: bool = False
) -> int:
    """
    Precompute template matches for a list of entries.
    
    Args:
        entries: List of sample entries to process
        templates: Dictionary of template data
        index: FAISS index for similarity search
        template_ids_list: List of template IDs corresponding to index
        tm: TemplateManager instance
        topk: Number of top templates to store per sample
        match_strategy: "complete" (strict - all target classes must be in template) 
                       or "partial" (at least one class overlap)
        max_search: How many nearest neighbors to search before filtering
        overwrite: Whether to overwrite existing template entries
        allow_fallback: If True and not enough strict matches found, fall back to 
                       nearest templates regardless of class overlap (NOT RECOMMENDED)
    
    Returns:
        Number of entries updated
    """
    updated = 0
    for entry in entries:
        image_path = entry.get("image")
        if not image_path:
            continue
        
        # Skip if already has templates and not overwriting
        if (not overwrite) and ("template" in entry) and isinstance(entry["template"], list) and len(entry["template"]) >= topk:
            continue
        
        # Get present class IDs from mask
        labels_json = entry.get("labels", "{}")
        label_mask_path = entry.get("label_mask", None)
        present_ids = set(get_present_label_ids_from_mask_path(label_mask_path))
        if len(present_ids) == 0:
            present_ids = set(get_non_bg_label_ids(labels_json))
        target_label_ids = present_ids
        
        # Extract DINO features
        try:
            feat = tm._extract_feature(image_path)
        except Exception as e:
            print(f"Warning: Failed to extract feature from {image_path}: {e}")
            continue
        
        # FAISS search
        query = feat.reshape(1, -1).astype("float32")
        D, I = index.search(query, min(max_search, len(template_ids_list)))
        
        # Rank templates by similarity with STRICT class matching
        ranked = []
        for d, idx in zip(D[0], I[0]):
            tid = template_ids_list[idx]
            tentry = templates[tid]
            tclasses = set(int(c) for c in tentry.get("classes", []))
            overlap = target_label_ids.intersection(tclasses)
            
            # STRICT: Template must contain ALL target classes
            if len(target_label_ids) == 0:
                continue
            if not target_label_ids.issubset(tclasses):
                continue
            
            ranked.append({
                "template_id": tid,
                "similarity": float(d),
                "overlap": sorted(list(map(int, overlap))),
            })
            if len(ranked) >= topk:
                break
        
        # Optional fallback (disabled by default)
        # Only use if explicitly enabled via allow_fallback=True
        if len(ranked) < topk and allow_fallback:
            print(f"Warning: Only found {len(ranked)}/{topk} strict matches for {image_path}, using fallback")
            for d, idx in zip(D[0], I[0]):
                tid = template_ids_list[idx]
                if any(r["template_id"] == tid for r in ranked):
                    continue
                ranked.append({
                    "template_id": tid,
                    "similarity": float(d),
                    "overlap": [],
                })
                if len(ranked) >= topk:
                    break
        
        # Only update if we found at least one valid match
        if len(ranked) > 0:
            entry["template"] = ranked
            updated += 1
        else:
            # No matching template found - leave entry without template
            if "template" in entry:
                del entry["template"]
    
    return updated


def run_step3_precompute(processed_dir, device="cuda:0", topk=1):
    """
    Step 3: Precompute template matches using STRICT class matching
    
    Template matching strategy:
    - STRICT ONLY: Template must contain ALL classes present in target image
    - No fallback to partial matches
    - Samples without matching templates are left without template field
    """
    print("\n" + "="*80)
    print(f"STEP 3: Precomputing template matches (STRICT)")
    print("="*80)
    print(f"Strategy: Template must contain ALL target classes")
    print(f"Top-k matches per sample: {topk}")
    print(f"[DEBUG] Step 3 started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"[DEBUG] Processed dir: {processed_dir}")
    
    hf_dataset_path = os.path.join(processed_dir, "hf_dataset")
    template_dir = os.path.join(processed_dir, "template")
    backup_dir = os.path.join(processed_dir, "backup_entries")
    
    print(f"[DEBUG] HF dataset path: {hf_dataset_path}")
    print(f"[DEBUG] Template dir: {template_dir}")
    print(f"[DEBUG] Backup dir: {backup_dir}")
    
    # Check if template matches already computed
    backup_train = os.path.join(backup_dir, "train_entries.json")
    if os.path.exists(backup_train):
        with open(backup_train) as f:
            entries = json.load(f)
            if len(entries) > 0 and "template" in entries[0] and isinstance(entries[0]["template"], list):
                print(f"[DEBUG] Templates already precomputed, skipping Step 3")
                print(f"✓ Templates already precomputed in {backup_train}")
                return
    
    # Verify required directories exist
    print(f"[DEBUG] Verifying required directories...")
    if not os.path.isdir(template_dir):
        print(f"[DEBUG] ERROR: Template directory not found: {template_dir}")
        print(f"✗ Template directory not found: {template_dir}")
        return
    if not os.path.isdir(hf_dataset_path):
        print(f"[DEBUG] ERROR: HF dataset not found: {hf_dataset_path}")
        print(f"✗ HF dataset not found: {hf_dataset_path}")
        return
    if not os.path.isdir(backup_dir):
        print(f"[DEBUG] ERROR: Backup entries not found: {backup_dir}")
        print(f"✗ Backup entries not found: {backup_dir}")
        return
    print(f"[DEBUG] All required directories verified")
    
    # Import dependencies
    print(f"[DEBUG] Importing dependencies...")
    try:
        from datasets import load_from_disk
        from template_manager import TemplateManager
        print(f"[DEBUG] Dependencies imported successfully")
    except ImportError as e:
        print(f"[DEBUG] ERROR: Failed to import modules: {e}")
        print(f"✗ Failed to import required modules: {e}")
        print(f"  Make sure 'datasets' and 'template_manager' are available")
        return
    
    print(f"\nLoading HF dataset from: {hf_dataset_path}")
    print(f"[DEBUG] Loading HF dataset...")
    try:
        ds = load_from_disk(hf_dataset_path)
        train_split = ds.get("train") or ds.get("training")
        val_split = ds.get("val") or ds.get("validation")
        print(f"[DEBUG] HF dataset loaded: train={train_split is not None}, val={val_split is not None}")
    except Exception as e:
        print(f"[DEBUG] ERROR: Failed to load HF dataset: {e}")
        raise
    
    # Load backup entries
    print(f"Loading backup entries from: {backup_dir}")
    print(f"[DEBUG] Loading backup entries...")
    try:
        with open(os.path.join(backup_dir, "train_entries.json"), "r") as f:
            train_entries = json.load(f)
        with open(os.path.join(backup_dir, "val_entries.json"), "r") as f:
            val_entries = json.load(f)
        print(f"[DEBUG] Backup entries loaded: train={len(train_entries)}, val={len(val_entries)}")
    except Exception as e:
        print(f"[DEBUG] ERROR: Failed to load backup entries: {e}")
        raise
    
    # Initialize TemplateManager and load templates
    print(f"Loading templates from: {template_dir}")
    print(f"Initializing TemplateManager on {device}...")
    print(f"[DEBUG] Initializing TemplateManager...")
    try:
        tm = TemplateManager(device=device)
        templates, index, template_ids_list = tm._load_templates(template_dir)
        print(f"[DEBUG] TemplateManager initialized, loaded {len(templates)} templates")
        print(f"✓ Loaded {len(templates)} templates for search")
    except Exception as e:
        print(f"[DEBUG] ERROR: Failed to initialize TemplateManager or load templates: {e}")
        raise
    
    # Precompute matches for train and val with STRICT matching
    total_updates = 0
    
    if train_entries:
        print(f"\nProcessing {len(train_entries)} train entries (strict class matching)...")
        print(f"[DEBUG] Starting train entries precomputation...")
        try:
            updated = precompute_for_entries(
                train_entries, templates, index, template_ids_list, tm,
                topk=topk, 
                match_strategy="complete",  # STRICT: all target classes must be in template
                max_search=100,  # Search more candidates to find strict matches
                overwrite=True,
                allow_fallback=False  # NO fallback to partial matches
            )
            total_updates += updated
            matched_pct = (updated / len(train_entries) * 100) if len(train_entries) > 0 else 0
            print(f"[DEBUG] Train precomputation completed: {updated}/{len(train_entries)} matched")
            print(f"✓ Matched {updated}/{len(train_entries)} train entries ({matched_pct:.1f}%)")
        except Exception as e:
            print(f"[DEBUG] ERROR: Train precomputation failed: {e}")
            raise
    
    if val_entries:
        print(f"\nProcessing {len(val_entries)} val entries (strict class matching)...")
        print(f"[DEBUG] Starting val entries precomputation...")
        try:
            updated = precompute_for_entries(
                val_entries, templates, index, template_ids_list, tm,
                topk=topk,
                match_strategy="complete",  # STRICT: all target classes must be in template
                max_search=100,  # Search more candidates to find strict matches
                overwrite=True,
                allow_fallback=False  # NO fallback to partial matches
            )
            total_updates += updated
            matched_pct = (updated / len(val_entries) * 100) if len(val_entries) > 0 else 0
            print(f"[DEBUG] Val precomputation completed: {updated}/{len(val_entries)} matched")
            print(f"✓ Matched {updated}/{len(val_entries)} val entries ({matched_pct:.1f}%)")
        except Exception as e:
            print(f"[DEBUG] ERROR: Val precomputation failed: {e}")
            raise
    
    # Write back updated JSONs
    print(f"[DEBUG] Writing back updated JSONs...")
    train_json = os.path.join(backup_dir, "train_entries.json")
    val_json = os.path.join(backup_dir, "val_entries.json")
    
    print(f"[DEBUG] Writing train_entries.json...")
    try:
        with open(train_json, "w") as f:
            json.dump(train_entries, f, indent=2)
        print(f"[DEBUG] train_entries.json written successfully")
    except Exception as e:
        print(f"[DEBUG] ERROR: Failed to write train_entries.json: {e}")
        raise
    
    print(f"[DEBUG] Writing val_entries.json...")
    try:
        with open(val_json, "w") as f:
            json.dump(val_entries, f, indent=2)
        print(f"[DEBUG] val_entries.json written successfully")
    except Exception as e:
        print(f"[DEBUG] ERROR: Failed to write val_entries.json: {e}")
        raise
    
    print(f"[DEBUG] Step 3 completed successfully at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    print(f"\n✓ Step 3 complete: {total_updates} samples with strict template matches")
    print(f"  Updated: {train_json}")
    print(f"  Updated: {val_json}")
    print(f"\nNote: Samples without matching templates have no 'template' field")


def verify_output(processed_dir, expected_train_count, expected_val_count, expected_template_count):
    """
    Verify the processed dataset has correct structure and counts
    """
    print("\n" + "="*80)
    print("VERIFICATION")
    print("="*80)
    
    checks = []
    
    # Check backup_entries
    train_json = os.path.join(processed_dir, "backup_entries", "train_entries.json")
    val_json = os.path.join(processed_dir, "backup_entries", "val_entries.json")
    
    if os.path.exists(train_json):
        with open(train_json) as f:
            train_entries = json.load(f)
            checks.append(("backup_entries/train", len(train_entries) == expected_train_count, 
                          f"{len(train_entries)} entries (expected {expected_train_count})"))
    else:
        checks.append(("backup_entries/train", False, "File not found"))
    
    if os.path.exists(val_json):
        with open(val_json) as f:
            val_entries = json.load(f)
            checks.append(("backup_entries/val", len(val_entries) == expected_val_count,
                          f"{len(val_entries)} entries (expected {expected_val_count})"))
    else:
        checks.append(("backup_entries/val", False, "File not found"))
    
    # Check hf_dataset
    hf_dir = os.path.join(processed_dir, "hf_dataset")
    if os.path.exists(hf_dir):
        checks.append(("hf_dataset", True, "Directory exists"))
    else:
        checks.append(("hf_dataset", False, "Directory not found"))
    
    # Check templates
    template_dir = os.path.join(processed_dir, "template")
    if os.path.exists(template_dir):
        npz_files = glob.glob(os.path.join(template_dir, "*.npz"))
        checks.append(("template", len(npz_files) == expected_template_count,
                      f"{len(npz_files)} templates (expected {expected_template_count})"))
    else:
        checks.append(("template", False, "Directory not found"))
    
    # Print results
    all_passed = True
    for name, passed, detail in checks:
        status = "✓" if passed else "✗"
        print(f"{status} {name}: {detail}")
        if not passed:
            all_passed = False
    
    return all_passed


def main():
    parser = argparse.ArgumentParser(description="End-to-end preprocessing pipeline")
    parser.add_argument("--dataset_name", type=str, required=True,
                       help="Dataset name (e.g., ACDC, CAMUS, MMs)")
    parser.add_argument("--template_percent", type=int, required=True,
                       help="Percentage of training data to use as templates (e.g., 5, 10)")
    parser.add_argument("--raw_data_dir", type=str, required=True,
                       help="Path to raw dataset (e.g., /projects/p32685/MSD/Task01_BrainTumour)")
    parser.add_argument("--output_base_dir", type=str, default="/projects/p32685/RL_dataset",
                       help="Base output directory")
    parser.add_argument("--device", type=str, default="cuda:0",
                       help="Device for template precomputation")
    parser.add_argument("--skip_convert", action="store_true",
                       help="Skip step 1 (conversion) if already done")
    parser.add_argument("--skip_packing", action="store_true",
                       help="Skip step 2 (packing) if already done")
    parser.add_argument("--skip_precompute", action="store_true",
                       help="Skip step 3 (precompute) if already done")
    parser.add_argument("--output_folder_name", type=str, default=None,
                       help="Optional explicit folder name for converted dataset (Step 1 output folder).")
    parser.add_argument("--processed_prefix", type=str, default=None,
                       help="Optional explicit prefix for processed output folders (Step 2 output prefix).")
    parser.add_argument("--split_mode", type=str, default="default",
                       choices=["default", "acdc_patient_100_50"],
                       help="Split mode passed to converter.")
    
    args = parser.parse_args()
    
    # Determine dataset type
    dataset_type = DATASET_TYPES.get(args.dataset_name, args.dataset_name)
    
    print("╔" + "="*78 + "╗")
    print("║" + " "*20 + "END-TO-END PREPROCESSING PIPELINE" + " "*25 + "║")
    print("╔" + "="*78 + "╗")
    print(f"\nDataset: {args.dataset_name}")
    print(f"Type: {dataset_type}")
    print(f"Template percentage: {args.template_percent}%")
    print(f"Raw data: {args.raw_data_dir}")
    print(f"Output base: {args.output_base_dir}")
    print(f"Device: {args.device}")
    print(f"[DEBUG] Pipeline started at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    
    try:
        # Step 1: Convert
        if not args.skip_convert:
            converted_dir = run_step1_convert(args.dataset_name, dataset_type, 
                                             args.raw_data_dir, args.output_base_dir,
                                             output_folder_name=args.output_folder_name,
                                             split_mode=args.split_mode)
        else:
            # Determine converted_dir based on dataset type
            if args.dataset_name.startswith("Task") and args.dataset_name.endswith(("BrainTumour", "Heart", "Liver", "Hippocampus", 
                                                                                      "Prostate", "Lung", "Pancreas", "HepaticVessel", 
                                                                                      "Spleen", "Colon")):
                task_num = args.dataset_name.split("_")[0]
                converted_dir = os.path.join(args.output_base_dir, f"MSD_{task_num.lower()}", args.dataset_name)
            else:
                folder_name = args.output_folder_name if args.output_folder_name else get_output_folder_name(args.dataset_name)
                converted_dir = os.path.join(args.output_base_dir, folder_name)
            print(f"Skipping Step 1 (conversion), using: {converted_dir}")
        
        # Step 2: Pack and extract templates
        if not args.skip_packing:
            processed_dir = run_step2_packing(converted_dir, args.output_base_dir,
                                             args.dataset_name, args.template_percent,
                                             processed_prefix=args.processed_prefix,
                                             template_extraction_device=args.device)
        else:
            # Determine processed_dir based on dataset type
            if args.dataset_name.startswith("Task") and args.dataset_name.endswith(("BrainTumour", "Heart", "Liver", "Hippocampus", 
                                                                                      "Prostate", "Lung", "Pancreas", "HepaticVessel", 
                                                                                      "Spleen", "Colon")):
                task_num = args.dataset_name.split("_")[0]
                processed_dir = os.path.join(args.output_base_dir, f"MSD_{task_num.lower()}", 
                                           f"{args.dataset_name}_{args.template_percent}percent_processed")
            else:
                folder_prefix = args.processed_prefix if args.processed_prefix else get_output_folder_name(args.dataset_name)
                processed_dir = os.path.join(args.output_base_dir,
                                        f"{folder_prefix}_{args.template_percent}percent_processed")
            print(f"Skipping Step 2 (packing), using: {processed_dir}")
        
        # Step 3: Precompute template matches
        if not args.skip_precompute:
            run_step3_precompute(processed_dir, args.device)
        else:
            print("Skipping Step 3 (precompute)")
        
        # Verification
        print("\n" + "="*80)
        print("PIPELINE COMPLETE")
        print("="*80)
        print(f"[DEBUG] Pipeline completed successfully at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"\nOutput directories created:")
        print(f"  1. Converted: {converted_dir}")
        print(f"  2. Processed: {processed_dir}")
        
        # Calculate expected counts
        train_images = glob.glob(os.path.join(converted_dir, "train", "images", "*"))
        val_images = glob.glob(os.path.join(converted_dir, "val", "images", "*"))
        expected_templates = int(len(train_images) * (args.template_percent / 100.0))
        
        # Verify
        all_good = verify_output(processed_dir, len(train_images), len(val_images), expected_templates)
        
        if all_good:
            print("\n✓ All checks passed! Dataset ready for training.")
        else:
            print("\n✗ Some checks failed. Please review above.")
        
    except Exception as e:
        print(f"\n[DEBUG] Pipeline failed at: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
        print(f"[DEBUG] Error type: {type(e).__name__}")
        print(f"[DEBUG] Error message: {e}")
        print(f"\n✗ Pipeline failed: {e}")
        import traceback
        print(f"[DEBUG] Full traceback:")
        traceback.print_exc()
        sys.exit(1)


if __name__ == "__main__":
    main()