| """ |
| utils/preprocessing.py |
| |
| Image preprocessing pipeline for dental panoramic X-ray images. |
| |
| """ |
|
|
| import os |
| import json |
| import numpy as np |
| import cv2 |
| import skimage.io |
| import skimage.draw |
| import random |
| import copy |
| from pathlib import Path |
|
|
|
|
| |
|
|
| def load_dicom(image_path): |
| """ |
| Loads DICOM files and returns RGB uint8. |
| |
| """ |
| try: |
| import pydicom |
| except ImportError: |
| raise ImportError( |
| "requires pydicom library." \ |
| "Install it using: pip install pydicom" |
| ) |
| |
| dicom_img = pydicom.dcmread(image_path) |
| array = dicom_img.pixel_array.astype(np.float32) |
| a_min, a_max = array.min(), array.max() |
| if a_max > a_min: |
| array = (array - a_min)/ (a_max - a_min) * 255.0 |
| array = array.astype(np.uint8) |
| if array.ndim == 2: |
| array = np.stack([array] * 3, axis = -1) |
| elif array.ndim == 3 and array.shape[-1] == 1: |
| array = np.concatenate([array]*3, axis = -1) |
|
|
| return array |
|
|
|
|
| |
|
|
| def load_image(path): |
| """ |
| Load a dental image and return as RGB uint8. |
| Handles grayscale X-rays (→ 3-channel), RGBA (alpha dropped), TIF, PNG, JPEG and DICOM |
| |
| JPEG, PNG, TIFF are handled via skimage |
| DICOM via pydicom |
| Greyscale is converted to 3-channel (acting like RGB) |
| RGBA - alpha channel dropped |
| |
| """ |
| suffix = Path(path).suffix.lower() |
| if suffix == ".dcm": |
| return load_dicom(path) |
| image = skimage.io.imread(path) |
| if image.ndim == 2: |
| image = np.stack([image] * 3, axis=-1) |
| elif image.ndim == 3 and image.shape[-1] == 4: |
| image = image[:,:,:3] |
| elif image.ndim == 3 and image.shape[-1] == 1: |
| image = np.concatenate([image]*3, axis = -1) |
| return image.astype(np.uint8) |
|
|
|
|
| |
|
|
| def enhance_contrast(image, method = "clahe"): |
| """ |
| Enhance contrast of a dental X-ray. |
| |
| Args: |
| image: RGB uint8 image. |
| method: 'clahe' (default) or 'histogram_eq' — global. |
| |
| CLAHE is preferred for panoramic X-rays |
| """ |
|
|
| if method == "clahe": |
| lab = cv2.cvtColor(image, cv2.COLOR_RGB2LAB) |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) |
| lab[:, :, 0] = clahe.apply(lab[:, :, 0]) |
| return cv2.cvtColor(lab, cv2.COLOR_LAB2RGB) |
| elif method == "histogram_eq": |
| channels = [cv2.equalizeHist(image[:, :, c]) for c in range(3)] |
| return np.stack(channels, axis=-1) |
| else: |
| raise ValueError(f"Unknown method '{method}'. Use 'clahe' or 'histogram_eq'.") |
|
|
|
|
| def normalize_image(image): |
| """Normalize pixel values to [0, 1] float32.""" |
| return image.astype(np.float32) / 255.0 |
|
|
|
|
| |
|
|
| def load_coco_annotations(json_path): |
| """ |
| Load a COCO-format annotation file. |
| |
| Returns the full dict with keys: info, images, annotations, categories. |
| """ |
| with open(json_path) as f: |
| return json.load(f) |
|
|
|
|
| def get_image_annotations(coco, filename): |
| """ |
| Return all annotation dicts for a given image filename. |
| |
| Args: |
| coco: Loaded COCO dict. (coco annotation json file) |
| filename: Image filename, e.g. '001.jpg'. |
| |
| Returns: |
| a list that contains annotation dictionaries |
| """ |
| img_map = {img["file_name"]: img["id"] for img in coco["images"]} |
| if filename not in img_map: |
| return [] |
| image_id = img_map[filename] |
| return [a for a in coco["annotations"] if a["image_id"] == image_id] |
|
|
|
|
| def coco_seg_to_mask(segmentation, height, width): |
| |
| """ |
| Convert a COCO segmentation polygon to a binary mask. |
| |
| Args: |
| segmentation: COCO segmentation — list of flat [x1,y1,x2,y2,...] arrays. |
| call get_image_annotations to get annots. |
| segmentation = annots[i]['segmentation'][0], where i is the |
| mask of the i-th tooth. |
| height, width: Image dimensions. |
| |
| Returns: |
| Boolean mask [H, W] showing just one tooth. |
| Call it in a loop to get all the masks |
| """ |
| |
| mask = np.zeros((height, width), dtype=bool) |
| for poly in segmentation: |
| xs = np.array(poly[0::2]) |
| ys = np.array(poly[1::2]) |
| rr, cc = skimage.draw.polygon(ys, xs) |
| rr = np.clip(rr, 0, height - 1) |
| cc = np.clip(cc, 0, width - 1) |
| mask[rr, cc] = True |
| return mask |
|
|
|
|
| def build_masks(coco, image_filename, height, width): |
| |
| """ |
| (H, W, N) boolean mask array for all annotated teeth in one image. |
| |
| Args: |
| coco: Loaded COCO annotation dict. |
| image_filename: Image filename e.g. '001.jpg' |
| height: Image height in pixels. |
| width: Image width in pixels. |
| |
| Returns: |
| masks: Boolean array of shape (H, W, N) where N = number of teeth. |
| class_ids - list of N category_ids(FDI numbers) |
| """ |
| |
| |
| anns = get_image_annotations(coco, image_filename) |
|
|
| if not anns: |
| |
| return np.zeros((height, width, 0), dtype=bool) |
|
|
| masks = [] |
| class_ids = [] |
| for ann in anns: |
| seg = ann.get("segmentation", []) |
| if not seg: |
| continue |
| mask = coco_seg_to_mask(seg, height, width) |
| masks.append(mask) |
| class_ids.append(ann['category_id']) |
| |
| if not masks: |
| return np.zeros((height,width,0),dtype=bool), [] |
|
|
| return np.stack(masks, axis=-1), class_ids |
|
|
|
|
| def count_teeth_per_image(coco): |
| """ |
| Return a dict mapping filename → number of annotated teeth. |
| |
| Args: |
| coco - annotaiton.json file |
| """ |
| img_map = {img["id"]: img["file_name"] for img in coco["images"]} |
| counts = {} |
| for ann in coco["annotations"]: |
| fname = img_map.get(ann["image_id"], "unknown") |
| counts[fname] = counts.get(fname, 0) + 1 |
| return counts |
|
|
|
|
| def class_frequency(coco): |
| """ |
| Return a dict mapping category_id → annotation count. |
| |
| Args: |
| annotation json file |
| |
| Returns: |
| Dictionary containing the tooth category and the number of |
| time that tooth mask appears (Dict[int,int]) |
| |
| """ |
| freq= {} |
| for ann in coco["annotations"]: |
| cat_id = ann["category_id"] |
| freq[cat_id] = freq.get(cat_id, 0) + 1 |
| return freq |
|
|
| def images_missing_annotations(coco): |
| |
| annotated_ids = {ann["image_id"] for ann in coco["annotations"]} |
| return [img for img in coco["images"] if img["id"] not in annotated_ids] |
|
|
| def split_summary(train_coco, val_coco): |
| |
| train_files = {img["file_name"] for img in train_coco["images"]} |
| val_files = {img["file_name"] for img in val_coco["images"]} |
| overlap = train_files & val_files |
|
|
| train_cats = {ann["category_id"] for ann in train_coco["annotations"]} |
| val_cats = {ann["category_id"] for ann in val_coco["annotations"]} |
| all_cats = {c["id"] for c in train_coco["categories"]} |
|
|
| return { |
| "train_images": len(train_coco["images"]), |
| "train_annotations": len(train_coco["annotations"]), |
| "val_images": len(val_coco["images"]), |
| "val_annotations": len(val_coco["annotations"]), |
| "overlap_files": overlap, |
| "leakage": len(overlap) > 0, |
| "train_categories": len(train_cats), |
| "val_categories": len(val_cats), |
| "missing_from_val": sorted(all_cats - val_cats), |
| } |