| """ |
| utils/visualization.py |
| |
| Plotting functions for dental panoramic X-ray images. |
| Supports FDI multi-class notation (tooth-11 … tooth-85): adult + deciduous teeth. |
| """ |
|
|
| import os |
| import sys |
| import numpy as np |
| import cv2 |
| import matplotlib.pyplot as plt |
| import matplotlib.patches as patches |
| import matplotlib.cm as cm |
| import random |
| import math |
| from pathlib import Path |
|
|
| parent_dir = os.path.abspath("..") |
| sys.path.append(parent_dir) |
|
|
| import utils.preprocessing |
| import importlib |
| importlib.reload(utils.preprocessing) |
|
|
| from utils.preprocessing import ( |
| count_teeth_per_image, |
| class_frequency, |
| load_image, |
| build_masks, |
| enhance_contrast, |
| images_missing_annotations, |
| split_summary, |
| ) |
|
|
|
|
| |
|
|
| QUADRANT_COLORS = { |
| "UR": "#4A90D9", |
| "UL": "#E87040", |
| "LL": "#2ECC71", |
| "LR": "#9B59B6", |
| "DU": "#F1C40F", |
| "DL": "#E74C3C", |
| } |
|
|
| LEGEND_PATCHES = [ |
| patches.Patch(facecolor=QUADRANT_COLORS["UR"], label="UR 11-18"), |
| patches.Patch(facecolor=QUADRANT_COLORS["UL"], label="UL 21-28"), |
| patches.Patch(facecolor=QUADRANT_COLORS["LL"], label="LL 31-38"), |
| patches.Patch(facecolor=QUADRANT_COLORS["LR"], label="LR 41-48"), |
| patches.Patch(facecolor=QUADRANT_COLORS["DU"], label="Deciduous U 51-65"), |
| patches.Patch(facecolor=QUADRANT_COLORS["DL"], label="Deciduous L 71-85"), |
| ] |
|
|
|
|
| |
|
|
| def _quadrant_color(class_name): |
| if class_name == 'tooth': |
| return "#4A90D9" |
| try: |
| fdi = int(class_name.split('-')[1]) |
| if 11 <= fdi <= 18: return QUADRANT_COLORS["UR"] |
| if 21 <= fdi <= 28: return QUADRANT_COLORS["UL"] |
| if 31 <= fdi <= 38: return QUADRANT_COLORS["LL"] |
| if 41 <= fdi <= 48: return QUADRANT_COLORS["LR"] |
| if 51 <= fdi <= 65: return QUADRANT_COLORS["DU"] |
| if 71 <= fdi <= 85: return QUADRANT_COLORS["DL"] |
| except (ValueError, IndexError): |
| pass |
| return "#AAAAAA" |
|
|
|
|
| def apply_masks(image, masks, class_names = None, alpha = 0.45): |
| |
| """ |
| Draw semi-transparent colored masks on X-ray image. |
| |
| Args: |
| image: RGB uint8 (H, W, 3). |
| masks: Bool array (H, W, N) — one per tooth. |
| class_names: Category name per mask for color |
| alpha: 0 = invisible, 1 = solid |
| |
| Returns: |
| Annotated RGB uint8 image. |
| """ |
|
|
| output = image.copy().astype(np.float32) |
| n = masks.shape[-1] |
|
|
| for i in range(n): |
| if class_names and i < len(class_names): |
| hex_col = _quadrant_color(class_names[i]) |
| else: |
| cmap = cm.get_cmap("tab20", max(1, n)) |
| hex_col = "#{:02x}{:02x}{:02x}".format( |
| *[int(v * 255) for v in cmap(i)[:3]]) |
|
|
| r = int(hex_col[1:3], 16) |
| g = int(hex_col[3:5], 16) |
| b = int(hex_col[5:7], 16) |
| colour = np.array([r, g, b], dtype=np.float32) |
|
|
| for c in range(3): |
| output[:, :, c] = np.where( |
| masks[:, :, i], |
| output[:, :, c] * (1 - alpha) + colour[c] * alpha, |
| output[:, :, c], |
| ) |
| return output.astype(np.uint8) |
|
|
|
|
| def draw_bounding_boxes(image, rois, class_ids, scores, class_names): |
|
|
| """ |
| Draw bounding boxes with FDI label and confidence score on image. |
| |
| Args: |
| image: (H, W, 3) uint8. |
| rois: (N, 4) — [y1, x1, y2, x2] per detection. |
| class_ids: (N,) — index into class_names. |
| scores: (N,) — confidence in [0, 1]. |
| class_names: List including background at index 0. |
| """ |
|
|
| out = image.copy() |
| for i, roi in enumerate(rois): |
| y1, x1, y2, x2 = roi |
| name = class_names[class_ids[i]] if class_ids[i] < len(class_names) else "unknown" |
| hex_col = _quadrant_color(name) |
| color = (int(hex_col[1:3], 16), int(hex_col[3:5], 16), int(hex_col[5:7], 16)) |
| label = f"{name} {scores[i]:.0%}" |
| cv2.rectangle(out, (x1, y1), (x2, y2), color, 2) |
| cv2.putText(out, label, (x1, max(y1 - 5, 12)), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.38, color, 1, cv2.LINE_AA) |
| return out |
|
|
|
|
| def visualize_prediction(image, result, class_names): |
| |
| """ |
| Side-by-side view of the original X-ray and the model's predictions. |
| Draws both masks and b.boxes on the right panel. |
| |
| Args: |
| image: Original X-ray (H, W, 3) uint8. |
| result: Dict with keys: masks, rois, class_ids, scores. |
| class_names: Class list — index 0 is background. |
| """ |
|
|
| masks = result.get("masks", np.zeros((*image.shape[:2], 0), dtype=bool)) |
| rois = result.get("rois", np.zeros((0, 4), dtype=int)) |
| class_ids = result.get("class_ids", np.array([], dtype=int)) |
| scores = result.get("scores", np.array([], dtype=float)) |
|
|
| det_names = [class_names[cid] for cid in class_ids if cid < len(class_names)] |
| annotated = apply_masks(image, masks, det_names) |
| annotated = draw_bounding_boxes(annotated, rois, class_ids, scores, class_names) |
|
|
| fig, axes = plt.subplots(1, 2, figsize=(16, 5)) |
| axes[0].imshow(image, cmap="gray") |
| axes[0].set_title("Original X-ray") |
| axes[0].axis("off") |
| axes[1].imshow(annotated, cmap="gray") |
| axes[1].set_title(f"{masks.shape[-1]} teeth detected") |
| axes[1].axis("off") |
| axes[1].legend(handles=LEGEND_PATCHES, loc="lower right", fontsize=8, framealpha=0.8) |
|
|
| plt.tight_layout() |
| plt.show() |
| plt.close(fig) |
| return annotated |
|
|
|
|
| def plot_annotation_distribution(train_coco, val_coco): |
| |
| """ |
| Two histograms side by side: |
| Left -> train annotation count distribution with mean & median lines. |
| Right -> train vs val overlay to confirm both splits look similar. |
| """ |
|
|
| train_vals = list(count_teeth_per_image(train_coco).values()) |
| val_vals = list(count_teeth_per_image(val_coco).values()) |
|
|
| fig, axes = plt.subplots(1, 2, figsize=(14, 4)) |
|
|
| axes[0].hist(train_vals, bins=25, color="#185FA5", edgecolor="white") |
| axes[0].axvline(np.mean(train_vals), color="#D85A30", linestyle="--", linewidth=1.5, |
| label=f"Mean = {np.mean(train_vals):.1f}") |
| axes[0].axvline(np.median(train_vals), color="#2ECC71", linestyle="--", linewidth=1.5, |
| label=f"Median = {np.median(train_vals):.1f}") |
| axes[0].set_xlabel("Instances per image"); axes[0].set_ylabel("Images") |
| axes[0].set_title(f"Train annotation distribution ({len(train_coco['images'])} images)") |
| axes[0].legend(); axes[0].grid(axis="y", alpha=0.3) |
|
|
| axes[1].hist(train_vals, bins=25, color="#185FA5", edgecolor="white", alpha=0.7, label="Train") |
| axes[1].hist(val_vals, bins=25, color="#E87040", edgecolor="white", alpha=0.7, label="Val") |
| axes[1].set_xlabel("Instances per image"); axes[1].set_ylabel("Images") |
| axes[1].set_title("Train vs Val distribution") |
| axes[1].legend(); axes[1].grid(axis="y", alpha=0.3) |
|
|
| plt.tight_layout() |
| plt.show() |
| plt.close(fig) |
|
|
|
|
| def plot_category_frequency(coco): |
|
|
| """ |
| 2×3 grid of bar charts, one panel per anatomical group |
| Makes it easy to spot under-represented teeth within each quadrant |
| """ |
|
|
| freq = class_frequency(coco) |
|
|
| fdi_counts = {} |
| for cat in coco["categories"]: |
| try: |
| fdi = int(cat["name"].split('-')[1]) |
| fdi_counts[fdi] = freq.get(cat["id"], 0) |
| except (ValueError, IndexError): |
| pass |
|
|
| groups = { |
| "UR (11-18)": (list(range(11, 19)), QUADRANT_COLORS["UR"]), |
| "UL (21-28)": (list(range(21, 29)), QUADRANT_COLORS["UL"]), |
| "LL (31-38)": (list(range(31, 39)), QUADRANT_COLORS["LL"]), |
| "LR (41-48)": (list(range(41, 49)), QUADRANT_COLORS["LR"]), |
| "Deciduous U (51-65)": (list(range(51, 56)) + list(range(61, 66)), QUADRANT_COLORS["DU"]), |
| "Deciduous L (71-85)": (list(range(71, 76)) + list(range(81, 86)), QUADRANT_COLORS["DL"]), |
| } |
|
|
| fig, axes = plt.subplots(2, 3, figsize=(18, 8)) |
| axes = axes.flatten() |
| fig.suptitle(f"Annotation Frequency by FDI Group (train, {len(coco['images'])} images)", fontsize=13) |
|
|
| for ax, (label, (fdi_range, color)) in zip(axes, groups.items()): |
| counts = [fdi_counts.get(fdi, 0) for fdi in fdi_range] |
| ax.bar(range(len(fdi_range)), counts, color=color, edgecolor="white") |
| ax.set_title(label, fontsize=10) |
| ax.set_xticks(range(len(fdi_range))) |
| ax.set_xticklabels([str(f) for f in fdi_range], rotation=45, fontsize=8) |
| ax.set_xlabel("FDI number"); ax.grid(axis="y", alpha=0.3) |
|
|
| axes[0].set_ylabel("Annotation count") |
| axes[3].set_ylabel("Annotation count") |
| plt.tight_layout() |
| plt.show() |
| plt.close(fig) |
|
|
| all_counts = list(fdi_counts.values()) |
| print(f"Most common: tooth-{max(fdi_counts, key=fdi_counts.get)} ({max(all_counts)} annotations)") |
| print(f"Least common: tooth-{min(fdi_counts, key=fdi_counts.get)} ({min(all_counts)} annotations)") |
| print(f"Imbalance ratio: {max(all_counts) / max(min(all_counts), 1):.1f}x") |
|
|
|
|
| def plot_sample_annotations(coco, img_dir, n = 4, seed = 42): |
| |
| """ |
| n randomly sampled images with polygon colored by FDI quadrant. |
| Only samples images that have annotations. |
| """ |
| random.seed(seed) |
|
|
| cat_map = {c["id"]: c["name"] for c in coco["categories"]} |
| anns_by_image = {} |
| for ann in coco["annotations"]: |
| anns_by_image.setdefault(ann["image_id"], []).append(ann) |
|
|
| imgs_with_anns = [img for img in coco["images"] if img["id"] in anns_by_image] |
| sample_imgs = random.sample(imgs_with_anns, min(n, len(imgs_with_anns))) |
|
|
| fig, axes = plt.subplots(1, len(sample_imgs), figsize=(5 * len(sample_imgs), 5)) |
| if len(sample_imgs) == 1: |
| axes = [axes] |
| fig.suptitle("Sample Annotations — polygon overlays by FDI quadrant", fontsize=12) |
|
|
| for ax, img_info in zip(axes, sample_imgs): |
| from pathlib import Path |
| img = load_image(str(Path(img_dir) / img_info["file_name"])) |
| anns = anns_by_image.get(img_info["id"], []) |
| ax.imshow(img, cmap="gray") |
| for ann in anns: |
| seg = ann["segmentation"][0] |
| xs = seg[0::2] + [seg[0]] |
| ys = seg[1::2] + [seg[1]] |
| color = _quadrant_color(cat_map.get(ann["category_id"], "")) |
| ax.plot(xs, ys, "-", color=color, linewidth=1.2, alpha=0.85) |
| ax.set_title(f"{img_info['file_name']}\n({len(anns)} instances)", fontsize=9) |
| ax.axis("off") |
|
|
| fig.legend(handles=LEGEND_PATCHES, loc="lower center", ncol=6, fontsize=9) |
| plt.tight_layout(rect=[0, 0.06, 1, 1]) |
| plt.show() |
| plt.close(fig) |
|
|
|
|
| def plot_mask_verification(coco, img_dir): |
| |
| """ |
| Render pixel-level masks for one image — confirms polygon → mask |
| conversion works correctly end to end. |
| """ |
|
|
| annotated_ids = {ann["image_id"] for ann in coco["annotations"]} |
| img_info = next(img for img in coco["images"] if img["id"] in annotated_ids) |
| img = load_image(str(Path(img_dir) / img_info["file_name"])) |
| H, W = img.shape[:2] |
|
|
| masks, class_ids = build_masks(coco, img_info["file_name"], H, W) |
| cat_map = {c["id"]: c["name"] for c in coco["categories"]} |
| class_names = [cat_map.get(cid, "unknown") for cid in class_ids] |
| |
| annotated = apply_masks(img, masks, class_names) |
|
|
| print(f"Image: {img_info['file_name']} ({W}×{H}px)") |
| print(f"Masks: {masks.shape[2]} | any filled: {masks.any()}") |
| print(f"Sample classes: {class_names[:6]}...") |
|
|
| fig, axes = plt.subplots(1, 2, figsize=(18, 5)) |
| axes[0].imshow(img, cmap="gray"); |
| axes[0].set_title("Original"); |
| axes[0].axis("off") |
| axes[1].imshow(annotated, cmap="gray"); |
| axes[1].set_title(f"Masks ({masks.shape[2]} teeth)"); |
| axes[1].axis("off") |
| fig.legend(handles=LEGEND_PATCHES, loc="lower center", ncol=6, fontsize=9) |
| plt.tight_layout(rect=[0, 0.06, 1, 1]) |
| plt.show() |
| plt.close(fig) |
|
|
|
|
| def plot_contrast_comparison(coco, img_dir): |
| |
| """ |
| Show Original | CLAHE | Histogram EQ for 3 images |
| """ |
|
|
| sample_files = [ |
| coco["images"][0]["file_name"], |
| coco["images"][len(coco["images"]) // 2]["file_name"], |
| coco["images"][-1]["file_name"], |
| ] |
|
|
| fig, axes = plt.subplots(len(sample_files), 3, figsize=(18, 4 * len(sample_files))) |
| fig.suptitle("Contrast Enhancement: Original | CLAHE | Histogram EQ", fontsize=13) |
|
|
| for row, fname in enumerate(sample_files): |
| orig = load_image(str(Path(img_dir) / fname)) |
| clahe = enhance_contrast(orig, method="clahe") |
| heq = enhance_contrast(orig, method="histogram_eq") |
| for col, (img, title) in enumerate([ |
| (orig, f"{fname} — Original"), |
| (clahe, "CLAHE"), |
| (heq, "Histogram EQ"), |
| ]): |
| axes[row, col].imshow(img, cmap="gray") |
| axes[row, col].set_title(title, fontsize=10) |
| axes[row, col].axis("off") |
|
|
| plt.tight_layout() |
| plt.show() |
| plt.close(fig) |
|
|
|
|
| def plot_tooth_sizes(coco, mean_image_height, target_short_side = 800): |
| |
| """ |
| Histogram of tooth width, height, and diagonal in molded image space. |
| Also prints recommended anchor scales derived from the data. |
| """ |
|
|
| scale = target_short_side / mean_image_height |
| ws = [ann["bbox"][2] * scale for ann in coco["annotations"]] |
| hs = [ann["bbox"][3] * scale for ann in coco["annotations"]] |
| diags = [np.sqrt(w**2 + h**2) for w, h in zip(ws, hs)] |
|
|
| print(f"Resize scale: {scale:.3f}") |
| print(f"Width: mean={np.mean(ws):.0f} median={np.median(ws):.0f}px") |
| print(f"Height: mean={np.mean(hs):.0f} median={np.median(hs):.0f}px") |
| print(f"Diagonal: min={min(diags):.0f} mean={np.mean(diags):.0f} max={max(diags):.0f}px") |
|
|
| s = max(4, 2 ** (math.floor(math.log2(min(ws + hs))) - 1)) |
| scales = [] |
| while s < max(diags) * 1.5: |
| scales.append(int(s)); s *= 2 |
| print(f"Recommended anchor scales: {tuple(scales)}") |
|
|
| fig, axes = plt.subplots(1, 3, figsize=(14, 4)) |
| for ax, data, title, color in zip( |
| axes, |
| [ws, hs, diags], |
| ["Tooth width (px)", "Tooth height (px)", "Tooth diagonal (px)"], |
| ["#4A90D9", "#E87040", "#2ECC71"] |
| ): |
| ax.hist(data, bins=25, color=color, edgecolor="white") |
| ax.set_title(title); ax.set_xlabel("pixels"); ax.grid(axis="y", alpha=0.3) |
|
|
| plt.suptitle(f"Tooth sizes in molded space (short side={target_short_side}px)") |
| plt.tight_layout() |
| plt.show() |
| plt.close(fig) |