"""Embedded CNN image classifier from the sketch-model project.""" from __future__ import annotations import base64 import json from functools import lru_cache from io import BytesIO from pathlib import Path from typing import Any import numpy as np import torch from fastapi import HTTPException from PIL import Image, UnidentifiedImageError from torch import nn from torch.nn import functional as F from torchvision.models import ResNet18_Weights, resnet18 APP_DIR = Path(__file__).resolve().parent MODEL_DIR = APP_DIR / "models" / "cnn_resnet18_20cls" MODEL_PATH = MODEL_DIR / "best_model.pt" LABEL_MAP_PATH = MODEL_DIR / "label_map.json" QUICKDRAW100_MODEL_DIR = APP_DIR / "models" / "cnn_residual_100cls_64" QUICKDRAW100_MODEL_PATH = QUICKDRAW100_MODEL_DIR / "best_model.pt" QUICKDRAW100_LABEL_MAP_PATH = QUICKDRAW100_MODEL_DIR / "label_map.json" class ResidualBlock(nn.Module): def __init__(self, channels: int, dropout: float = 0.0): super().__init__() self.block = nn.Sequential( nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(channels), nn.ReLU(), nn.Dropout2d(dropout), nn.Conv2d(channels, channels, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(channels), ) self.activation = nn.ReLU() def forward(self, x: torch.Tensor) -> torch.Tensor: return self.activation(x + self.block(x)) class ResidualCNN(nn.Module): def __init__(self, num_classes: int): super().__init__() self.features = nn.Sequential( nn.Conv2d(1, 48, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(48), nn.ReLU(), ResidualBlock(48, dropout=0.05), nn.MaxPool2d(2), nn.Conv2d(48, 96, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(96), nn.ReLU(), ResidualBlock(96, dropout=0.05), nn.MaxPool2d(2), nn.Conv2d(96, 192, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(192), nn.ReLU(), ResidualBlock(192, dropout=0.08), nn.MaxPool2d(2), nn.Conv2d(192, 256, kernel_size=3, padding=1, bias=False), nn.BatchNorm2d(256), nn.ReLU(), ResidualBlock(256, dropout=0.08), nn.AdaptiveAvgPool2d((1, 1)), ) self.classifier = nn.Sequential( nn.Flatten(), nn.Dropout(0.35), nn.Linear(256, num_classes), ) def forward(self, x: torch.Tensor) -> torch.Tensor: return self.classifier(self.features(x)) def build_model( model_name: str, num_classes: int, pretrained: bool = False, freeze_backbone: bool = False, ) -> nn.Module: if model_name == "resnet18": weights = ResNet18_Weights.DEFAULT if pretrained else None model = resnet18(weights=weights) model.conv1 = nn.Conv2d(1, 64, kernel_size=3, stride=1, padding=1, bias=False) model.maxpool = nn.Identity() model.fc = nn.Linear(model.fc.in_features, num_classes) if freeze_backbone: for name, param in model.named_parameters(): param.requires_grad = name.startswith("fc.") return model if model_name == "residual-cnn": return ResidualCNN(num_classes=num_classes) raise ValueError(f"Unsupported embedded CNN model: {model_name}") def pick_cnn_device() -> torch.device: if torch.backends.mps.is_available(): return torch.device("mps") if torch.cuda.is_available(): return torch.device("cuda") return torch.device("cpu") def load_label_map(label_map_path: Path = LABEL_MAP_PATH) -> dict[str, int]: if not label_map_path.exists(): raise FileNotFoundError(f"Missing CNN label map: {label_map_path}") return json.loads(label_map_path.read_text(encoding="utf-8")) def labels_from_map(label_map: dict[str, int]) -> list[str]: return [name for name, _ in sorted(label_map.items(), key=lambda item: item[1])] @lru_cache(maxsize=1) def image_predictor() -> tuple[nn.Module, list[str], torch.device, int]: if not MODEL_PATH.exists(): raise FileNotFoundError(f"Missing CNN model: {MODEL_PATH}") return load_predictor(MODEL_PATH, LABEL_MAP_PATH) @lru_cache(maxsize=1) def quickdraw100_predictor() -> tuple[nn.Module, list[str], torch.device, int]: return load_predictor(QUICKDRAW100_MODEL_PATH, QUICKDRAW100_LABEL_MAP_PATH) def load_predictor(model_path: Path, label_map_path: Path) -> tuple[nn.Module, list[str], torch.device, int]: if not model_path.exists(): raise FileNotFoundError(f"Missing CNN model: {model_path}") label_map = load_label_map(label_map_path) labels = labels_from_map(label_map) device = pick_cnn_device() checkpoint = torch.load(model_path, map_location=device, weights_only=False) config = checkpoint.get("config", {}) image_size = int(config.get("image_size", 96)) model = build_model( config.get("model", "resnet18"), num_classes=len(labels), pretrained=bool(config.get("pretrained", False)), freeze_backbone=bool(config.get("freeze_backbone", False)), ).to(device) model.load_state_dict(checkpoint["model_state"]) model.eval() return model, labels, device, image_size def decode_data_url(data_url: str) -> bytes: if "," in data_url: _, encoded = data_url.split(",", 1) else: encoded = data_url try: return base64.b64decode(encoded, validate=True) except ValueError as exc: raise HTTPException(status_code=400, detail="image must be a base64 PNG data URL.") from exc def uploaded_image_to_tensor(image_bytes: bytes, image_size: int) -> torch.Tensor: try: image = Image.open(BytesIO(image_bytes)).convert("L") except UnidentifiedImageError as exc: raise HTTPException(status_code=400, detail="Unsupported image file. Use PNG, JPG, or WEBP.") from exc array = np.asarray(image, dtype=np.float32) / 255.0 if array.mean() > 0.5: array = 1.0 - array ink = array > 0.15 if not np.any(ink): raise HTTPException(status_code=400, detail="No visible sketch stroke detected in the image.") ys, xs = np.where(ink) y0, y1 = int(ys.min()), int(ys.max()) + 1 x0, x1 = int(xs.min()), int(xs.max()) + 1 crop = array[y0:y1, x0:x1] side = max(crop.shape) pad = max(2, int(side * 0.12)) canvas = np.zeros((side + pad * 2, side + pad * 2), dtype=np.float32) offset_y = (canvas.shape[0] - crop.shape[0]) // 2 offset_x = (canvas.shape[1] - crop.shape[1]) // 2 canvas[offset_y : offset_y + crop.shape[0], offset_x : offset_x + crop.shape[1]] = crop resample = Image.Resampling.BILINEAR if hasattr(Image, "Resampling") else Image.BILINEAR resized = Image.fromarray(np.uint8(np.clip(canvas, 0.0, 1.0) * 255)).resize( (image_size, image_size), resample, ) tensor_array = np.asarray(resized, dtype=np.float32) / 255.0 return torch.from_numpy(tensor_array[None, :, :]).unsqueeze(0) def tensor_image_to_input(image: torch.Tensor, image_size: int) -> torch.Tensor: if image.ndim == 2: image = image[None, :, :] if image.ndim == 3: image = image.unsqueeze(0) if image.shape[1] != 1: image = image.mean(dim=1, keepdim=True) image = image.float().clamp(0, 1) if image.shape[-2:] != (image_size, image_size): image = F.interpolate(image, size=(image_size, image_size), mode="bilinear", align_corners=False) return image def top_predictions(probs: torch.Tensor, labels: list[str], top_k: int) -> list[dict[str, Any]]: top_k = max(1, min(int(top_k), len(labels))) scores, indices = torch.topk(probs, k=top_k) predictions = [] for score, idx in zip(scores.tolist(), indices.tolist()): label = labels[idx] predictions.append( { "label": label, "confidence": float(score), "reference": f"/api/reference/{label}.svg", } ) return predictions def classes(predictor: str = "legacy20") -> list[str]: if predictor == "quickdraw100": return labels_from_map(load_label_map(QUICKDRAW100_LABEL_MAP_PATH)) return labels_from_map(load_label_map(LABEL_MAP_PATH)) def get_predictor(predictor: str) -> tuple[nn.Module, list[str], torch.device, int]: if predictor == "quickdraw100": return quickdraw100_predictor() return image_predictor() def predict_tensor(image: torch.Tensor, top_k: int = 5, predictor: str = "legacy20") -> dict[str, Any]: model, labels, device, image_size = get_predictor(predictor) tensor = tensor_image_to_input(image, image_size=image_size).to(device) with torch.no_grad(): logits = model(tensor) probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu() predictions = top_predictions(probs, labels, top_k) return { "model": "cnn", "predictor": predictor, "input": "tensor", "image_size": image_size, "prediction": predictions[0], "top": predictions, } def predict_image(image_bytes: bytes, top_k: int = 5, predictor: str = "legacy20") -> dict[str, Any]: model, labels, device, image_size = get_predictor(predictor) tensor = uploaded_image_to_tensor(image_bytes, image_size=image_size).to(device) with torch.no_grad(): logits = model(tensor) probs = torch.softmax(logits, dim=1).squeeze(0).detach().cpu() predictions = top_predictions(probs, labels, top_k) return { "model": "cnn", "predictor": predictor, "input": "image", "image_size": image_size, "prediction": predictions[0], "top": predictions, }