"""Standalone MobileNetV3-FPN string segmentation model.""" from __future__ import annotations from pathlib import Path from typing import Any import torch from torch import nn import torch.nn.functional as F CHECKPOINT_FORMATS = {"yoyo_string_semantic_transfer_v1", "yoyo_string_semantic_unet_v1"} def _group_count(channels: int) -> int: for groups in (8, 4, 2, 1): if channels % groups == 0: return groups return 1 class _FPNRefine(nn.Module): def __init__(self, channels: int): super().__init__() self.layers = nn.Sequential( nn.Conv2d(channels, channels, 3, padding=1, groups=channels, bias=False), nn.GroupNorm(_group_count(channels), channels), nn.SiLU(inplace=True), nn.Conv2d(channels, channels, 1, bias=False), nn.GroupNorm(_group_count(channels), channels), nn.SiLU(inplace=True), ) def forward(self, value: torch.Tensor) -> torch.Tensor: return self.layers(value) class MobileNetV3FPNString(nn.Module): """MobileNetV3-Large encoder with a high-resolution FPN decoder.""" _FEATURE_INDICES = (1, 3, 6, 12, 16) _FEATURE_CHANNELS = (16, 24, 40, 112, 960) def __init__(self, decoder_channels: int = 32): super().__init__() from torchvision.models import mobilenet_v3_large self.encoder = mobilenet_v3_large(weights=None).features self.lateral = nn.ModuleList( nn.Sequential( nn.Conv2d(input_channels, decoder_channels, 1, bias=False), nn.GroupNorm(_group_count(decoder_channels), decoder_channels), ) for input_channels in self._FEATURE_CHANNELS ) self.refine = nn.ModuleList(_FPNRefine(decoder_channels) for _ in range(4)) self.classifier = nn.Sequential( _FPNRefine(decoder_channels), nn.Conv2d(decoder_channels, 1, 1), ) def forward(self, value: torch.Tensor) -> torch.Tensor: input_size = value.shape[-2:] features = [] selected = set(self._FEATURE_INDICES) for index, layer in enumerate(self.encoder): value = layer(value) if index in selected: features.append(value) pyramid = self.lateral[-1](features[-1]) for level in range(len(features) - 2, -1, -1): pyramid = F.interpolate(pyramid, size=features[level].shape[-2:], mode="bilinear", align_corners=False) pyramid = self.refine[level](pyramid + self.lateral[level](features[level])) logits = self.classifier(pyramid) return F.interpolate(logits, size=input_size, mode="bilinear", align_corners=False) def load_model(checkpoint: str | Path, device: str | torch.device = "cpu") -> tuple[nn.Module, dict[str, Any]]: """Load a StrandSeg-Lite checkpoint without importing the training repo.""" target = torch.device(device) payload = torch.load(Path(checkpoint), map_location=target, weights_only=True) if not isinstance(payload, dict) or payload.get("format") not in CHECKPOINT_FORMATS: raise ValueError(f"Unsupported StrandSeg-Lite checkpoint: {checkpoint}") config = dict(payload.get("model_config") or {}) architecture = str(config.get("architecture", "mobilenet_v3_fpn")).lower() if architecture != "mobilenet_v3_fpn": raise ValueError(f"This package supports mobilenet_v3_fpn, got {architecture!r}") model = MobileNetV3FPNString(decoder_channels=max(16, int(config.get("base_channels", 16)) * 2)) model.load_state_dict(payload["state_dict"]) model.to(target).eval() return model, payload