| """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, |
| } |
|
|