| """ |
| Data loading and preprocessing utilities for crop disease datasets. |
| """ |
| import os |
| import numpy as np |
| import pandas as pd |
| from pathlib import Path |
| from typing import Tuple, List, Dict, Optional |
| from PIL import Image, ImageEnhance |
| import tensorflow as tf |
| from sklearn.model_selection import train_test_split |
|
|
| from ml.config import ( |
| DATA_DIR, |
| CROPS, |
| TRAINING_CONFIG, |
| ) |
|
|
|
|
| class CropDatasetLoader: |
| """Loads and preprocesses crop disease datasets.""" |
| |
| def __init__(self, crop: str): |
| """ |
| Initialize dataset loader for a specific crop. |
| |
| Args: |
| crop: Crop name (corn, soybean, wheat, rice) |
| """ |
| if crop not in CROPS: |
| raise ValueError(f"Unknown crop: {crop}. Available: {list(CROPS.keys())}") |
| |
| self.crop = crop |
| self.config = CROPS[crop] |
| self.data_dir = DATA_DIR / crop |
| |
| if (self.data_dir / "data").exists(): |
| self.data_dir = self.data_dir / "data" |
| |
| elif crop == "rice" and (self.data_dir / "Rice_Leaf_AUG").exists(): |
| self.data_dir = self.data_dir / "Rice_Leaf_AUG" |
| |
| |
| |
| |
| if (self.data_dir / "train").is_dir(): |
| self.data_dir = self.data_dir / "train" |
| self.image_size = self.config["image_size"] |
| self.diseases = self.config["diseases"] |
| self.corrupt_count = 0 |
| |
| |
| |
| self.class_names = None |
| |
| def _merged_label(self, disease: str) -> str: |
| """Map a config disease to its training label, applying any label_aliases |
| (e.g. rice merges Brown Spot + Rice Blast into one class). Folders are still |
| resolved by the original disease name; only the emitted label changes.""" |
| return self.config.get("label_aliases", {}).get(disease, disease) |
|
|
| def _label_name(self, idx: int) -> str: |
| """Name for an integer label using the authoritative sorted mapping.""" |
| idx = int(idx) |
| if self.class_names is not None and idx < len(self.class_names): |
| return self.class_names[idx] |
| if idx < len(self.diseases): |
| return self.diseases[idx] |
| return "?" |
|
|
| def _candidate_folder_names_for_disease(self, disease: str) -> List[str]: |
| """Directory names to try under a data root for this config disease label.""" |
| possible_names = [ |
| disease, |
| disease.lower(), |
| disease.replace(" ", "_"), |
| disease.replace(" ", "-"), |
| ] |
| if self.crop == "rice": |
| if disease == "Rice Blast": |
| possible_names.insert(0, "Leaf Blast") |
| elif disease == "Healthy": |
| possible_names.insert(0, "Healthy Rice Leaf") |
| if self.crop == "soybean": |
| |
| if disease == "Healthy": |
| |
| |
| |
| possible_names.insert(0, "Healty") |
| possible_names.insert(1, "healthy") |
| elif disease == "Rust": |
| possible_names.insert(0, "Soybean Rust") |
| possible_names.insert(1, "ferrugen") |
| elif disease == "Sudden Death Syndrome": |
| |
| possible_names.insert(0, "Sudden Death Syndrone") |
| possible_names.insert(1, "sudden death syndrome") |
| possible_names.insert(2, "sudden_death_syndrome") |
| elif disease == "Yellow Mosaic": |
| possible_names.insert(0, "yellow_mosaic") |
| possible_names.insert(1, "Yellow Mosaic Virus") |
| if self.crop == "tomato": |
| |
| |
| tomato_aliases = { |
| "Bacterial Spot": ["Bacterial_spot"], |
| "Early Blight": ["Early_blight"], |
| "Late Blight": ["Late_blight"], |
| "Leaf Mold": ["Leaf_Mold"], |
| "Septoria Leaf Spot": ["Septoria_leaf_spot"], |
| "Spider Mites": ["Spider_mites Two-spotted_spider_mite", "Spider_mites"], |
| "Target Spot": ["Target_Spot"], |
| "Yellow Leaf Curl Virus": ["Tomato_Yellow_Leaf_Curl_Virus"], |
| "Mosaic Virus": ["Tomato_mosaic_virus"], |
| "Powdery Mildew": ["powdery_mildew"], |
| "Healthy": ["healthy"], |
| } |
| for alias in reversed(tomato_aliases.get(disease, [])): |
| possible_names.insert(0, alias) |
| if self.crop == "wheat": |
| if disease == "Leaf Rust": |
| possible_names.insert(0, "Brown Rust") |
| elif disease == "Stem Rust": |
| possible_names.insert(0, "Black Rust") |
| elif disease == "Stripe (Yellow) Rust": |
| possible_names.insert(0, "Yellow Rust") |
| possible_names.insert(1, "Stripe Rust") |
| elif disease == "Powdery Mildew": |
| possible_names.insert(0, "Mildew") |
| elif disease == "Loose Smut": |
| possible_names.insert(0, "Smut") |
| |
| return possible_names |
| |
| def _resolve_class_folder(self, root: Path, disease: str) -> Optional[Path]: |
| if not root.is_dir(): |
| return None |
| for name in self._candidate_folder_names_for_disease(disease): |
| folder_path = root / name |
| if folder_path.exists() and folder_path.is_dir(): |
| return folder_path |
| return None |
| |
| def _append_images_from_folder( |
| self, |
| folder_path: Path, |
| disease_label: str, |
| images: list, |
| labels: list, |
| class_names: list, |
| ) -> int: |
| """Load all images from folder_path into parallel lists; returns count added. |
| |
| Corrupted/unreadable images are skipped and counted in self.corrupt_count |
| so the caller can report how many were dropped per crop (item 5). |
| """ |
| image_files = self._get_image_files(folder_path) |
| if not image_files: |
| return 0 |
| print(f"Found {len(image_files)} images in {folder_path}") |
| added = 0 |
| for img_path in image_files: |
| try: |
| img = Image.open(img_path) |
| img.load() |
| img = img.convert("RGB") |
| img = img.resize(self.image_size) |
| img_array = np.array(img, dtype=np.float32) / 255.0 |
| if img_array.shape != (self.image_size[1], self.image_size[0], 3): |
| raise ValueError(f"unexpected shape {img_array.shape}") |
| images.append(img_array) |
| labels.append(disease_label) |
| class_names.append(disease_label) |
| added += 1 |
| except Exception as e: |
| self.corrupt_count += 1 |
| print(f" [SKIP corrupt] {img_path}: {e}") |
| continue |
| return added |
| |
| def _random_augment_image(self, img: np.ndarray) -> np.ndarray: |
| """Apply random aggressive augmentation to a [0,1] float32 H×W×3 image.""" |
| pil_img = Image.fromarray((img * 255).astype(np.uint8), mode='RGB') |
| if np.random.random() > 0.5: |
| pil_img = pil_img.transpose(Image.FLIP_LEFT_RIGHT) |
| if np.random.random() > 0.5: |
| pil_img = pil_img.transpose(Image.FLIP_TOP_BOTTOM) |
| angle = float(np.random.uniform(-45, 45)) |
| pil_img = pil_img.rotate(angle, resample=Image.BILINEAR, fillcolor=(128, 128, 128)) |
| pil_img = ImageEnhance.Brightness(pil_img).enhance(float(np.random.uniform(0.7, 1.3))) |
| w, h = pil_img.size |
| zoom = float(np.random.uniform(0.85, 1.0)) |
| new_w, new_h = max(1, int(w * zoom)), max(1, int(h * zoom)) |
| left = int(np.random.randint(0, max(1, w - new_w + 1))) |
| top = int(np.random.randint(0, max(1, h - new_h + 1))) |
| pil_img = pil_img.crop((left, top, left + new_w, top + new_h)) |
| pil_img = pil_img.resize((w, h), Image.BILINEAR) |
| return np.array(pil_img, dtype=np.float32) / 255.0 |
|
|
| def _cap_dominant_class( |
| self, X: np.ndarray, y: np.ndarray, max_multiplier: float = 10.0 |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """Cap any class whose count exceeds max_multiplier × the SMALLEST class count. |
| |
| Item 2: cap at 10× the smallest class so no single class can dominate the |
| (uniform, stratified) val set. Class weights (balanced, clipped to 5.0) |
| handle the remaining skew via weighted loss. |
| |
| Called before train/val split so the cap applies uniformly to all splits. |
| Uses a boolean mask applied simultaneously to X and y — no index separation, |
| so image-label pairing is structurally preserved. |
| """ |
| unique, counts = np.unique(y, return_counts=True) |
| if len(unique) < 2: |
| return X, y |
| smallest = int(np.min(counts)) |
| cap = int(smallest * max_multiplier) |
| rng = np.random.default_rng(seed=42) |
| keep_mask = np.zeros(len(y), dtype=bool) |
| modified = False |
| for label, count in zip(unique, counts): |
| cls_idx = np.where(y == label)[0] |
| if int(count) > cap: |
| chosen = rng.choice(cls_idx, size=cap, replace=False) |
| keep_mask[chosen] = True |
| print(f" [CAP] class {int(label)} ({self._label_name(label)}): " |
| f"{int(count)} → {cap} ({max_multiplier}× smallest={smallest})") |
| modified = True |
| else: |
| keep_mask[cls_idx] = True |
| if not modified: |
| print(f" No capping needed (largest {int(np.max(counts))} ≤ {cap} = {max_multiplier}× smallest {smallest}).") |
| return X, y |
| X_out, y_out = X[keep_mask], y[keep_mask] |
| assert len(X_out) == len(y_out), ( |
| f"BUG _cap_dominant_class: X({len(X_out)}) != y({len(y_out)})") |
| return X_out, y_out |
|
|
| def _oversample_minority_classes( |
| self, X: np.ndarray, y: np.ndarray |
| ) -> Tuple[np.ndarray, np.ndarray]: |
| """Augment minority classes up to the max class count (training set only). |
| |
| Oversampling targets the second-largest class count (to avoid amplifying the |
| dominant class while still giving minority classes adequate representation). |
| Image-label pairs are kept as Python tuples throughout augmentation and |
| only unzipped into separate arrays at the very end. A single permutation |
| index array is applied to both X and y simultaneously, making it |
| structurally impossible for images and labels to become misaligned. |
| """ |
| assert len(X) == len(y), f"Input mismatch: X={len(X)}, y={len(y)}" |
| unique, counts = np.unique(y, return_counts=True) |
| max_count = int(np.max(counts)) |
| min_count = int(np.min(counts)) |
|
|
| def _verify_pairing(Xa, ya, tag): |
| """Item 1: assert coupling and print 5 (index, label, class_name, image_shape).""" |
| assert len(Xa) == len(ya), ( |
| f"BUG {tag}: X({len(Xa)}) != y({len(ya)}) — image/label decoupled!") |
| print(f"\n [VERIFY {tag}] len(X)={len(Xa)} len(y)={len(ya)} — coupled ✓") |
| for i in range(min(5, len(ya))): |
| cname = self._label_name(ya[i]) |
| print(f" sample[{i}] label={int(ya[i])} ({cname}) " |
| f"image_shape={Xa[i].shape} mean={float(Xa[i].mean()):.3f}") |
|
|
| |
| |
| |
| if max_count / min_count <= 2.5: |
| print(f" No oversampling needed (imbalance ratio {max_count/min_count:.1f}× ≤ 2.5×).") |
| _verify_pairing(X, y, "AFTER (no-op)") |
| return X, y |
|
|
| |
| |
| target = int(min(np.median(counts), min_count * 2)) |
|
|
| _verify_pairing(X, y, "BEFORE") |
|
|
| |
| new_pairs: List[Tuple[np.ndarray, int]] = [] |
| for label, count in zip(unique, counts): |
| if int(count) >= target: |
| continue |
| needed = target - int(count) |
| cls_indices = np.where(y == label)[0] |
| lbl_int = int(label) |
| print(f" Oversampling class {lbl_int} ({self._label_name(lbl_int)}): " |
| f"{int(count)} → {int(count) + needed} (+{needed} augmented)") |
| chosen = np.random.choice(cls_indices, size=needed, replace=True) |
| for src_idx in chosen: |
| aug_img = self._random_augment_image(X[src_idx]) |
| new_pairs.append((aug_img, lbl_int)) |
|
|
| if not new_pairs: |
| print(" No oversampling needed (all classes at/above median).") |
| return X, y |
|
|
| |
| new_imgs, new_lbls = zip(*new_pairs) |
| new_X = np.array(new_imgs, dtype=np.float32) |
| new_y = np.array(new_lbls, dtype=y.dtype) |
| assert len(new_X) == len(new_y) == len(new_pairs), "BUG: unzip length mismatch" |
|
|
| |
| X_out = np.concatenate([X, new_X], axis=0) |
| y_out = np.concatenate([y, new_y], axis=0) |
| assert len(X_out) == len(y_out), ( |
| f"BUG after concat: X_out({len(X_out)}) != y_out({len(y_out)})") |
|
|
| |
| rng = np.random.default_rng() |
| shuffle_idx = rng.permutation(len(X_out)) |
| X_out = X_out[shuffle_idx] |
| y_out = y_out[shuffle_idx] |
| assert len(X_out) == len(y_out), ( |
| f"BUG after shuffle: X_out({len(X_out)}) != y_out({len(y_out)})") |
|
|
| |
| _verify_pairing(X_out, y_out, "AFTER") |
|
|
| |
| out_unique, out_counts = np.unique(y_out, return_counts=True) |
| print("\n [VERIFY-DIST] Class distribution after oversampling:") |
| for lbl, cnt in zip(out_unique, out_counts): |
| print(f" class {int(lbl)} ({self._label_name(lbl)}): {int(cnt)}") |
| print(f" Training set: {len(X)} → {len(X_out)} images (+{len(new_pairs)} augmented)") |
| return X_out, y_out |
|
|
| def _get_image_files(self, folder_path: Path) -> List[Path]: |
| """Collect image files from a folder recursively (case-insensitive extensions).""" |
| return ( |
| list(folder_path.rglob("*.jpg")) + list(folder_path.rglob("*.JPG")) + |
| list(folder_path.rglob("*.jpeg")) + list(folder_path.rglob("*.JPEG")) + |
| list(folder_path.rglob("*.png")) + list(folder_path.rglob("*.PNG")) |
| ) |
| |
| def load_dataset(self) -> Tuple[np.ndarray, np.ndarray, List[str]]: |
| """ |
| Load images and labels from the dataset directory. |
| |
| Returns: |
| Tuple of (images, labels, class_names) |
| """ |
| images = [] |
| labels = [] |
| class_names = [] |
| self.corrupt_count = 0 |
|
|
| disease_folders: Dict[str, Path] = {} |
| for disease in self.diseases: |
| folder_path = self._resolve_class_folder(self.data_dir, disease) |
| if folder_path is not None: |
| disease_folders[disease] = folder_path |
|
|
| if not disease_folders: |
| raise ValueError(f"No disease folders found in {self.data_dir}") |
|
|
| |
| |
| print(f"\n [FOLDER MAP] {self.crop}: config disease label → resolved folder") |
| for disease in self.diseases: |
| resolved = disease_folders.get(disease) |
| status = str(resolved.name) if resolved is not None else "*** NOT FOUND ***" |
| print(f" '{disease}' → {status}") |
| missing = [d for d in self.diseases if d not in disease_folders] |
| if missing: |
| print(f" [WARN] no base folder for: {missing} (may come from supplemental)") |
|
|
| for disease, folder_path in disease_folders.items(): |
| n = self._append_images_from_folder(folder_path, self._merged_label(disease), images, labels, class_names) |
| if n == 0: |
| print(f"Warning: No images loaded from {folder_path}") |
| |
| supplemental_root = DATA_DIR / self.crop / "supplemental" |
| if supplemental_root.is_dir(): |
| sup_added = 0 |
| for disease in self.diseases: |
| sup_folder = self._resolve_class_folder(supplemental_root, disease) |
| if sup_folder is None: |
| continue |
| n = self._append_images_from_folder( |
| sup_folder, self._merged_label(disease), images, labels, class_names |
| ) |
| sup_added += n |
| if sup_added: |
| print( |
| f"Merged {sup_added} supplemental images from {supplemental_root}" |
| ) |
| |
| |
| |
| |
| |
|
|
| if not images: |
| raise ValueError(f"No images loaded from {self.data_dir}") |
| |
| |
| images = np.array(images, dtype=np.float32) |
| |
| |
| unique_diseases = sorted(list(set(labels))) |
| disease_to_idx = {disease: idx for idx, disease in enumerate(unique_diseases)} |
| label_indices = np.array([disease_to_idx[label] for label in labels]) |
| |
| self.class_names = unique_diseases |
| |
| |
| |
| indices = np.arange(len(images)) |
| np.random.seed(42) |
| np.random.shuffle(indices) |
| images = images[indices] |
| label_indices = label_indices[indices] |
| |
| print(f"Loaded {len(images)} images for {self.crop}") |
| if self.corrupt_count: |
| print(f" [CORRUPT] skipped {self.corrupt_count} unreadable/corrupt images for {self.crop}") |
| print(f"Diseases: {unique_diseases}") |
| print(f"Class distribution: {pd.Series([unique_diseases[idx] for idx in label_indices]).value_counts().to_dict()}") |
|
|
| return images, label_indices, unique_diseases |
| |
| def create_data_generators( |
| self, |
| images: np.ndarray, |
| labels: np.ndarray, |
| augment: bool = True |
| ) -> Tuple[tf.keras.preprocessing.image.ImageDataGenerator, |
| tf.keras.preprocessing.image.ImageDataGenerator, np.ndarray]: |
| """ |
| Create data generators for training and validation. |
| |
| Args: |
| images: Image array |
| labels: Label array |
| augment: Whether to use data augmentation |
| |
| Returns: |
| Tuple of (train_generator, val_generator, y_train_labels) |
| """ |
| |
| |
| print("\nCapping dominant classes (max 2× next-largest) before split...") |
| images, labels = self._cap_dominant_class(images, labels) |
| print(f"Dataset after capping: {len(images)} images") |
|
|
| |
| |
| label_counts = np.bincount(labels.astype(int)) |
| can_stratify_first_split = np.all(label_counts[label_counts > 0] >= 2) |
| X_train, X_temp, y_train, y_temp = train_test_split( |
| images, labels, |
| test_size=TRAINING_CONFIG["test_split"] + TRAINING_CONFIG["validation_split"], |
| stratify=labels if can_stratify_first_split else None, |
| random_state=42 |
| ) |
| |
| val_size = TRAINING_CONFIG["validation_split"] / ( |
| TRAINING_CONFIG["test_split"] + TRAINING_CONFIG["validation_split"] |
| ) |
| temp_label_counts = np.bincount(y_temp.astype(int)) |
| can_stratify_second_split = np.all(temp_label_counts[temp_label_counts > 0] >= 2) |
| X_val, X_test, y_val, y_test = train_test_split( |
| X_temp, y_temp, |
| test_size=1 - val_size, |
| stratify=y_temp if can_stratify_second_split else None, |
| random_state=42 |
| ) |
| |
| |
| self.X_test = X_test |
| self.y_test = y_test |
|
|
| |
| |
| num_classes_full = len(np.unique(labels)) |
| print("\n [SPLIT DIST] per-class counts (train / val / test) and val-share:") |
| tr = np.bincount(y_train.astype(int), minlength=num_classes_full) |
| vl = np.bincount(y_val.astype(int), minlength=num_classes_full) |
| te = np.bincount(y_test.astype(int), minlength=num_classes_full) |
| for c in range(num_classes_full): |
| total_c = tr[c] + vl[c] + te[c] |
| val_share = (vl[c] / total_c) if total_c else 0.0 |
| flag = " <<< VAL >40% — SKEWED SPLIT!" if val_share > 0.40 else "" |
| print(f" class {c}: train={tr[c]:5d} val={vl[c]:5d} test={te[c]:5d} " |
| f"val_share={val_share:.1%}{flag}") |
|
|
| |
| print("\nOversampling minority classes in training set...") |
| X_train, y_train = self._oversample_minority_classes(X_train, y_train) |
| print(f"Training set after oversampling: {len(X_train)} images\n") |
|
|
| |
| self.y_train = y_train |
| |
| |
| |
| |
| |
| |
| |
| if augment and TRAINING_CONFIG["augmentation"]: |
| train_datagen = tf.keras.preprocessing.image.ImageDataGenerator( |
| rotation_range=30, |
| width_shift_range=0.2, |
| height_shift_range=0.2, |
| shear_range=0.2, |
| zoom_range=0.3, |
| horizontal_flip=True, |
| vertical_flip=True, |
| fill_mode='nearest' |
| ) |
| else: |
| train_datagen = tf.keras.preprocessing.image.ImageDataGenerator() |
| |
| |
| val_datagen = tf.keras.preprocessing.image.ImageDataGenerator() |
| |
| |
| |
| |
| num_classes = len(np.unique(labels)) |
| y_train_cat = tf.keras.utils.to_categorical(y_train, num_classes=num_classes) |
| y_val_cat = tf.keras.utils.to_categorical(y_val, num_classes=num_classes) |
| |
| train_generator = train_datagen.flow( |
| X_train, y_train_cat, |
| batch_size=TRAINING_CONFIG["batch_size"], |
| shuffle=True |
| ) |
|
|
| val_generator = val_datagen.flow( |
| X_val, y_val_cat, |
| batch_size=TRAINING_CONFIG["batch_size"], |
| shuffle=False |
| ) |
|
|
| |
| |
| probe_x, probe_y = train_generator[0] |
| train_generator.reset() |
| bmin, bmax, bmean = float(probe_x.min()), float(probe_x.max()), float(probe_x.mean()) |
| print(f" [AUG CHECK] train batch range=[{bmin:.4f},{bmax:.4f}] mean={bmean:.4f}") |
| if bmax <= 1e-6: |
| raise RuntimeError( |
| "Augmented training batch is all-zero — augmentation is destroying " |
| "images (see brightness_range bug). Aborting before wasting a run.") |
| if bmax > 2.0: |
| raise RuntimeError( |
| f"Augmented training batch exceeds [0,1] (max={bmax:.2f}); " |
| "an augmentation is rescaling to [0,255] and will break preprocessing.") |
|
|
| return train_generator, val_generator, y_train |
| |
| def get_test_set(self) -> Tuple[np.ndarray, np.ndarray]: |
| """Get the held-out test set.""" |
| if hasattr(self, 'test_paths'): |
| |
| X, kept_y = [], [] |
| for p, lbl in zip(self.test_paths, self.y_test): |
| try: |
| img = Image.open(p).convert("RGB").resize(self.image_size) |
| except Exception as e: |
| print(f" [SKIP corrupt test image] {p}: {e}") |
| continue |
| X.append(np.array(img, dtype=np.float32) / 255.0) |
| kept_y.append(lbl) |
| return np.array(X, dtype=np.float32), np.array(kept_y) |
| if not hasattr(self, 'X_test'): |
| raise ValueError("Test set not created. Call create_data_generators first.") |
| return self.X_test, self.y_test |
|
|
| |
| |
| |
| |
| |
| |
|
|
| def index_dataset(self) -> Tuple[np.ndarray, np.ndarray, List[str]]: |
| """Like load_dataset() but returns file PATHS instead of pixels. |
| |
| Corrupt/unreadable files are filtered here (header verify) so the |
| tf.data pipeline never hits a decode error mid-epoch. |
| """ |
| paths: List[str] = [] |
| labels: List[str] = [] |
| self.corrupt_count = 0 |
|
|
| def _collect(folder_path: Path, disease: str) -> int: |
| n = 0 |
| for img_path in self._get_image_files(folder_path): |
| try: |
| with Image.open(img_path) as im: |
| im.verify() |
| except Exception as e: |
| self.corrupt_count += 1 |
| print(f" [SKIP corrupt] {img_path}: {e}") |
| continue |
| paths.append(str(img_path)) |
| labels.append(disease) |
| n += 1 |
| return n |
|
|
| disease_folders: Dict[str, Path] = {} |
| for disease in self.diseases: |
| folder_path = self._resolve_class_folder(self.data_dir, disease) |
| if folder_path is not None: |
| disease_folders[disease] = folder_path |
|
|
| if not disease_folders: |
| raise ValueError(f"No disease folders found in {self.data_dir}") |
|
|
| print(f"\n [FOLDER MAP] {self.crop}: config disease label → resolved folder") |
| for disease in self.diseases: |
| resolved = disease_folders.get(disease) |
| status = str(resolved.name) if resolved is not None else "*** NOT FOUND ***" |
| print(f" '{disease}' → {status}") |
| missing = [d for d in self.diseases if d not in disease_folders] |
| if missing: |
| print(f" [WARN] no base folder for: {missing} (may come from supplemental)") |
|
|
| for disease, folder_path in disease_folders.items(): |
| if _collect(folder_path, self._merged_label(disease)) == 0: |
| print(f"Warning: No images indexed from {folder_path}") |
|
|
| supplemental_root = DATA_DIR / self.crop / "supplemental" |
| if supplemental_root.is_dir(): |
| sup_added = 0 |
| for disease in self.diseases: |
| sup_folder = self._resolve_class_folder(supplemental_root, disease) |
| if sup_folder is None: |
| continue |
| sup_added += _collect(sup_folder, self._merged_label(disease)) |
| if sup_added: |
| print(f"Merged {sup_added} supplemental images from {supplemental_root}") |
|
|
| if not paths: |
| raise ValueError(f"No images indexed from {self.data_dir}") |
|
|
| unique_diseases = sorted(set(labels)) |
| disease_to_idx = {d: i for i, d in enumerate(unique_diseases)} |
| label_indices = np.array([disease_to_idx[l] for l in labels]) |
| path_arr = np.array(paths) |
| self.class_names = unique_diseases |
|
|
| |
| np.random.seed(42) |
| order = np.random.permutation(len(path_arr)) |
| path_arr, label_indices = path_arr[order], label_indices[order] |
|
|
| print(f"Indexed {len(path_arr)} images for {self.crop} (streaming mode)") |
| if self.corrupt_count: |
| print(f" [CORRUPT] skipped {self.corrupt_count} unreadable images") |
| print(f"Diseases: {unique_diseases}") |
| print(f"Class distribution: {pd.Series([unique_diseases[i] for i in label_indices]).value_counts().to_dict()}") |
| return path_arr, label_indices, unique_diseases |
|
|
| def _oversample_paths(self, paths: np.ndarray, y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: |
| """Path-list analogue of _oversample_minority_classes. |
| |
| Duplicates minority-class paths up to min(median, 2× original); the |
| per-epoch random augmentation in the tf.data pipeline makes each |
| duplicate a different training image every epoch. |
| """ |
| unique, counts = np.unique(y, return_counts=True) |
| max_count, min_count = int(np.max(counts)), int(np.min(counts)) |
| if max_count / min_count <= 2.5: |
| print(f" No oversampling needed (imbalance ratio {max_count/min_count:.1f}× ≤ 2.5×).") |
| return paths, y |
| target = int(min(np.median(counts), min_count * 2)) |
| extra_p, extra_y = [], [] |
| for label, count in zip(unique, counts): |
| if int(count) >= target: |
| continue |
| needed = target - int(count) |
| cls_idx = np.where(y == label)[0] |
| print(f" Oversampling class {int(label)} ({self._label_name(label)}): " |
| f"{int(count)} → {target} (+{needed} duplicated paths)") |
| chosen = np.random.choice(cls_idx, size=needed, replace=True) |
| extra_p.extend(paths[chosen]) |
| extra_y.extend([label] * needed) |
| if not extra_p: |
| return paths, y |
| paths_out = np.concatenate([paths, np.array(extra_p)]) |
| y_out = np.concatenate([y, np.array(extra_y, dtype=y.dtype)]) |
| rng = np.random.default_rng() |
| order = rng.permutation(len(paths_out)) |
| return paths_out[order], y_out[order] |
|
|
| def create_tf_datasets( |
| self, |
| paths: np.ndarray, |
| labels: np.ndarray, |
| augment: bool = True, |
| ) -> Tuple[tf.data.Dataset, tf.data.Dataset, np.ndarray]: |
| """Streaming replacement for create_data_generators(). |
| |
| Same capping/split/oversampling semantics; images are decoded per batch |
| from disk. Outputs stay in [0,1] (the model's Rescaling layer expects |
| this) and brightness augmentation remains BANNED — see the |
| brightness_range bug notes in create_data_generators(). |
| """ |
| print("\nCapping dominant classes before split (streaming)...") |
| paths, labels = self._cap_dominant_class(paths, labels) |
| print(f"Dataset after capping: {len(paths)} images") |
|
|
| label_counts = np.bincount(labels.astype(int)) |
| can_stratify = np.all(label_counts[label_counts > 0] >= 2) |
| p_train, p_temp, y_train, y_temp = train_test_split( |
| paths, labels, |
| test_size=TRAINING_CONFIG["test_split"] + TRAINING_CONFIG["validation_split"], |
| stratify=labels if can_stratify else None, |
| random_state=42, |
| ) |
| val_size = TRAINING_CONFIG["validation_split"] / ( |
| TRAINING_CONFIG["test_split"] + TRAINING_CONFIG["validation_split"] |
| ) |
| temp_counts = np.bincount(y_temp.astype(int)) |
| can_stratify2 = np.all(temp_counts[temp_counts > 0] >= 2) |
| p_val, p_test, y_val, y_test = train_test_split( |
| p_temp, y_temp, |
| test_size=1 - val_size, |
| stratify=y_temp if can_stratify2 else None, |
| random_state=42, |
| ) |
|
|
| self.test_paths = p_test |
| self.y_test = y_test |
|
|
| num_classes = len(np.unique(labels)) |
| print("\n [SPLIT DIST] per-class counts (train / val / test) and val-share:") |
| tr = np.bincount(y_train.astype(int), minlength=num_classes) |
| vl = np.bincount(y_val.astype(int), minlength=num_classes) |
| te = np.bincount(y_test.astype(int), minlength=num_classes) |
| for c in range(num_classes): |
| total_c = tr[c] + vl[c] + te[c] |
| val_share = (vl[c] / total_c) if total_c else 0.0 |
| flag = " <<< VAL >40% — SKEWED SPLIT!" if val_share > 0.40 else "" |
| print(f" class {c}: train={tr[c]:5d} val={vl[c]:5d} test={te[c]:5d} " |
| f"val_share={val_share:.1%}{flag}") |
|
|
| print("\nOversampling minority classes in training set (path duplication)...") |
| p_train, y_train = self._oversample_paths(p_train, y_train) |
| print(f"Training set after oversampling: {len(p_train)} images\n") |
| self.y_train = y_train |
|
|
| batch_size = TRAINING_CONFIG["batch_size"] |
| h, w = self.image_size |
|
|
| def _decode(path, label): |
| raw = tf.io.read_file(path) |
| img = tf.image.decode_image(raw, channels=3, expand_animations=False) |
| img.set_shape([None, None, 3]) |
| img = tf.image.resize(img, [h, w]) |
| img = tf.cast(img, tf.float32) / 255.0 |
| return img, label |
|
|
| |
| |
| |
| |
| aug_layers = tf.keras.Sequential([ |
| tf.keras.layers.RandomFlip("horizontal_and_vertical"), |
| tf.keras.layers.RandomRotation(30 / 360, fill_mode="nearest"), |
| tf.keras.layers.RandomTranslation(0.2, 0.2, fill_mode="nearest"), |
| tf.keras.layers.RandomZoom(0.3, fill_mode="nearest"), |
| ]) |
|
|
| y_train_cat = tf.keras.utils.to_categorical(y_train, num_classes=num_classes) |
| y_val_cat = tf.keras.utils.to_categorical(y_val, num_classes=num_classes) |
|
|
| train_ds = ( |
| tf.data.Dataset.from_tensor_slices((p_train, y_train_cat)) |
| .shuffle(len(p_train), seed=42, reshuffle_each_iteration=True) |
| .map(_decode, num_parallel_calls=tf.data.AUTOTUNE) |
| .batch(batch_size) |
| ) |
| if augment and TRAINING_CONFIG["augmentation"]: |
| train_ds = train_ds.map( |
| lambda x, y: (aug_layers(x, training=True), y), |
| num_parallel_calls=tf.data.AUTOTUNE, |
| ) |
| train_ds = train_ds.prefetch(tf.data.AUTOTUNE) |
|
|
| val_ds = ( |
| tf.data.Dataset.from_tensor_slices((p_val, y_val_cat)) |
| .map(_decode, num_parallel_calls=tf.data.AUTOTUNE) |
| .batch(batch_size) |
| .prefetch(tf.data.AUTOTUNE) |
| ) |
|
|
| |
| |
| probe_x, _ = next(iter(train_ds)) |
| bmin = float(tf.reduce_min(probe_x)) |
| bmax = float(tf.reduce_max(probe_x)) |
| bmean = float(tf.reduce_mean(probe_x)) |
| print(f" [AUG CHECK] train batch range=[{bmin:.4f},{bmax:.4f}] mean={bmean:.4f}") |
| if bmax <= 1e-6: |
| raise RuntimeError( |
| "Augmented training batch is all-zero — augmentation is destroying " |
| "images (see brightness_range bug). Aborting before wasting a run.") |
| if bmax > 2.0: |
| raise RuntimeError( |
| f"Augmented training batch exceeds [0,1] (max={bmax:.2f}); " |
| "an augmentation is rescaling to [0,255] and will break preprocessing.") |
|
|
| return train_ds, val_ds, y_train |
|
|