File size: 8,979 Bytes
8e932ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Phase 1.1 — Group-aware split builder for Augmented dataset.

Strategy: perceptual hash (pHash) every image in both Original and Augmented datasets,
then group images by Hamming-distance < threshold. Result: each unique source image
(plus all of its augmented derivatives) gets one group_id. We then do a stratified
GroupKFold split so all derivatives of a source stay on the same side.
"""
import argparse, json, os, sys
from pathlib import Path
from collections import defaultdict
from PIL import Image
import imagehash
import numpy as np
from sklearn.model_selection import StratifiedGroupKFold, train_test_split
from tqdm import tqdm


# Maps original-dataset class folder name -> canonical class label.
# (Augmented dataset uses slightly different folder names for some classes.)
CLASS_CANON = {
    "Central Serous Chorioretinopathy [Color Fundus]": "CSC",
    "Diabetic Retinopathy": "DR",
    "Disc Edema": "DiscEdema",
    "Glaucoma": "Glaucoma",
    "Healthy": "Healthy",
    "Macular Scar": "MacularScar",
    "Myopia": "Myopia",
    "Pterygium": "Pterygium",
    "Retinal Detachment": "RetinalDet",
    "Retinitis Pigmentosa": "RetinitisPig",
}


def list_images(root: Path):
    """Yield (path, class_canon) for every image."""
    out = []
    for class_dir in sorted(root.iterdir()):
        if not class_dir.is_dir():
            continue
        canon = CLASS_CANON.get(class_dir.name, class_dir.name)
        for img in sorted(class_dir.iterdir()):
            if img.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp"}:
                out.append((str(img), canon))
    return out


def phash_image(path, hash_size=8):
    try:
        with Image.open(path) as im:
            im = im.convert("RGB")
            return imagehash.phash(im, hash_size=hash_size)
    except Exception as e:
        print(f"  hash error {path}: {e}", file=sys.stderr)
        return None


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--original-dir", default="Database/Original_Dataset")
    ap.add_argument("--augmented-dir", default="Database/Augmented_Dataset")
    ap.add_argument("--output", default="holdout_split_augmented.json")
    ap.add_argument("--hamming-threshold", type=int, default=8,
                    help="pHash Hamming distance for considering two images near-duplicates (8/64 bits)")
    ap.add_argument("--seed", type=int, default=42)
    ap.add_argument("--n-folds", type=int, default=5)
    ap.add_argument("--test-frac", type=float, default=0.15)
    ap.add_argument("--val-frac", type=float, default=0.15)
    args = ap.parse_args()

    orig_imgs = list_images(Path(args.original_dir))
    aug_imgs = list_images(Path(args.augmented_dir))
    print(f"original: {len(orig_imgs)} images")
    print(f"augmented: {len(aug_imgs)} images")

    # Compute hashes
    print("\nHashing original dataset ...")
    orig_hashes = []
    for p, c in tqdm(orig_imgs):
        h = phash_image(p)
        if h is not None:
            orig_hashes.append((p, c, h))

    print("\nHashing augmented dataset ...")
    aug_hashes = []
    for p, c in tqdm(aug_imgs):
        h = phash_image(p)
        if h is not None:
            aug_hashes.append((p, c, h))

    # Each original image becomes its own group (group_id = orig index)
    # Each augmented image is assigned to the nearest original IN THE SAME CLASS
    # (constrains search and avoids cross-class matches due to vignette).
    # If the nearest original is further than `hamming_threshold`, the augmented
    # image becomes its own standalone group.
    print(f"\nGrouping augmented images to originals (Hamming <= {args.hamming_threshold}) ...")

    orig_by_class = defaultdict(list)  # class -> list of (idx_in_global, path, hash)
    for i, (p, c, h) in enumerate(orig_hashes):
        orig_by_class[c].append((i, p, h))

    groups = {}             # path -> group_id
    group_class = {}        # group_id -> class
    next_standalone_id = len(orig_hashes)

    # Originals: trivially their own group
    for i, (p, c, _) in enumerate(orig_hashes):
        groups[p] = i
        group_class[i] = c

    # Augmented: nearest-original lookup within same class
    matched, standalone = 0, 0
    for p, c, h in tqdm(aug_hashes):
        cands = orig_by_class.get(c, [])
        if not cands:
            groups[p] = next_standalone_id
            group_class[next_standalone_id] = c
            next_standalone_id += 1
            standalone += 1
            continue
        best_idx, best_dist = None, 10**6
        for (oi, _op, oh) in cands:
            d = h - oh
            if d < best_dist:
                best_dist = d; best_idx = oi
            if best_dist == 0:
                break
        if best_dist <= args.hamming_threshold:
            groups[p] = best_idx
            matched += 1
        else:
            groups[p] = next_standalone_id
            group_class[next_standalone_id] = c
            next_standalone_id += 1
            standalone += 1

    print(f"  matched to an original: {matched}")
    print(f"  standalone augmented (no near original): {standalone}")
    print(f"  total groups: {next_standalone_id}")

    # Build pool (Original + Augmented unioned), excluding nothing
    all_items = []  # (path, class_label_int, group_id)
    class_to_int = {c: i for i, c in enumerate(sorted(set(group_class.values())))}
    for p, c, _h in orig_hashes:
        all_items.append((p, class_to_int[c], groups[p]))
    for p, c, _h in aug_hashes:
        all_items.append((p, class_to_int[c], groups[p]))

    paths = np.array([x[0] for x in all_items])
    labels = np.array([x[1] for x in all_items])
    grps = np.array([x[2] for x in all_items])

    # Stratified-by-class, grouped-by-source split:
    # 1) Holdout test set: 15% by group (stratified on group majority label)
    # 2) From the remaining, build StratifiedGroupKFold folds for CV
    # 3) Also produce a single train/val cut from the pool for the "final" retrain
    rng = np.random.default_rng(args.seed)

    # build group -> (class_label, [item_indices])
    group_indices = defaultdict(list)
    for idx, g in enumerate(grps):
        group_indices[g].append(idx)
    group_ids = np.array(sorted(group_indices.keys()))
    group_labels = np.array([labels[group_indices[g][0]] for g in group_ids])

    # Stratified split of groups into (pool, test)
    pool_groups, test_groups = train_test_split(
        group_ids, test_size=args.test_frac, stratify=group_labels, random_state=args.seed
    )
    # From pool, further split val
    pool_labels = np.array([labels[group_indices[g][0]] for g in pool_groups])
    train_groups, val_groups = train_test_split(
        pool_groups, test_size=args.val_frac / (1 - args.test_frac),
        stratify=pool_labels, random_state=args.seed
    )

    def items_for(grps_subset):
        idxs = []
        for g in grps_subset:
            idxs.extend(group_indices[g])
        return [(paths[i], int(labels[i])) for i in idxs]

    splits = {
        "train": items_for(train_groups),
        "val":   items_for(val_groups),
        "test":  items_for(test_groups),
    }

    # k-fold over (train+val) groups
    pool_groups_sorted = np.concatenate([train_groups, val_groups])
    pool_labels_sorted = np.array([labels[group_indices[g][0]] for g in pool_groups_sorted])
    # Need also indices into the *pool_paths* list for the folds
    pool_items = items_for(pool_groups_sorted)
    pool_paths = [it[0] for it in pool_items]
    pool_labels_flat = [it[1] for it in pool_items]
    # And the group ID for every pool item
    pool_groups_flat = []
    for g in pool_groups_sorted:
        for _ in group_indices[g]:
            pool_groups_flat.append(int(g))

    sgkf = StratifiedGroupKFold(n_splits=args.n_folds, shuffle=True, random_state=args.seed)
    folds = []
    for fold_i, (tr_idx, va_idx) in enumerate(
        sgkf.split(np.zeros(len(pool_paths)), pool_labels_flat, groups=pool_groups_flat)
    ):
        folds.append({"train_idx": tr_idx.tolist(), "val_idx": va_idx.tolist()})

    out = {
        "seed": args.seed,
        "hamming_threshold": args.hamming_threshold,
        "classes": [c for c, _ in sorted(class_to_int.items(), key=lambda x: x[1])],
        "n_groups_total": int(next_standalone_id),
        "n_train_items": len(splits["train"]),
        "n_val_items": len(splits["val"]),
        "n_test_items": len(splits["test"]),
        "splits": splits,
        "pool_paths": pool_paths,
        "pool_labels": pool_labels_flat,
        "pool_groups": pool_groups_flat,
        "folds": folds,
    }
    with open(args.output, "w") as f:
        json.dump(out, f)
    print(f"\nManifest -> {args.output}")
    print(f"  train: {len(splits['train'])} items")
    print(f"  val:   {len(splits['val'])} items")
    print(f"  test:  {len(splits['test'])} items")
    print(f"  pool size for k-fold: {len(pool_paths)} items across {len(pool_groups_sorted)} groups")


if __name__ == "__main__":
    main()