StrandSeg-Lite / inference.py
EvanZhongg's picture
Publish StrandSeg-Lite model and standalone inference
ae709c1 verified
Raw History Blame Contribute Delete
4.54 kB
"""Standalone inference API and CLI for StrandSeg-Lite."""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from pathlib import Path
import cv2
import numpy as np
import torch
from model import load_model
IMAGE_MEAN = np.asarray([0.485, 0.456, 0.406], dtype=np.float32)
IMAGE_STD = np.asarray([0.229, 0.224, 0.225], dtype=np.float32)
@dataclass(frozen=True)
class Prediction:
probability: np.ndarray
mask: np.ndarray
threshold: float
def _letterbox(image: np.ndarray, width: int, height: int) -> tuple[np.ndarray, tuple[int, int, int, int]]:
source_height, source_width = image.shape[:2]
scale = min(width / source_width, height / source_height)
resized_width = max(1, int(round(source_width * scale)))
resized_height = max(1, int(round(source_height * scale)))
pad_x = (width - resized_width) // 2
pad_y = (height - resized_height) // 2
resized = cv2.resize(image, (resized_width, resized_height), interpolation=cv2.INTER_AREA)
canvas = np.zeros((height, width, 3), dtype=np.uint8)
canvas[pad_y : pad_y + resized_height, pad_x : pad_x + resized_width] = resized
return canvas, (pad_x, pad_y, resized_width, resized_height)
class StrandSegLite:
def __init__(self, checkpoint: str | Path = "model.pt", device: str | torch.device = "cpu"):
self.device = torch.device(device)
self.model, self.metadata = load_model(checkpoint, self.device)
config = dict(self.metadata.get("model_config") or {})
self.input_width = int(config.get("input_width", 960))
self.input_height = int(config.get("input_height", 544))
self.threshold = float(self.metadata.get("threshold", 0.9204))
@torch.inference_mode()
def predict(self, image: np.ndarray, *, color: str = "rgb", threshold: float | None = None) -> Prediction:
if image.ndim != 3 or image.shape[2] != 3:
raise ValueError("image must be an HxWx3 uint8 array")
if image.dtype != np.uint8:
raise ValueError("image must have dtype uint8")
color = color.lower()
if color == "rgb":
bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
elif color == "bgr":
bgr = image
else:
raise ValueError("color must be 'rgb' or 'bgr'")
original_height, original_width = bgr.shape[:2]
prepared, (pad_x, pad_y, resized_width, resized_height) = _letterbox(
bgr, self.input_width, self.input_height
)
rgb = cv2.cvtColor(prepared, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
rgb = (rgb - IMAGE_MEAN) / IMAGE_STD
tensor = torch.from_numpy(np.transpose(rgb, (2, 0, 1))).unsqueeze(0).to(self.device)
probability = torch.sigmoid(self.model(tensor))[0, 0].cpu().numpy()
probability = probability[pad_y : pad_y + resized_height, pad_x : pad_x + resized_width]
probability = cv2.resize(probability, (original_width, original_height), interpolation=cv2.INTER_LINEAR)
selected_threshold = self.threshold if threshold is None else float(threshold)
mask = (probability >= selected_threshold).astype(np.uint8)
return Prediction(probability=probability.astype(np.float32), mask=mask, threshold=selected_threshold)
def predict_file(self, path: str | Path, *, threshold: float | None = None) -> Prediction:
encoded = np.fromfile(str(path), dtype=np.uint8)
image = cv2.imdecode(encoded, cv2.IMREAD_COLOR) if encoded.size else None
if image is None:
raise ValueError(f"Could not read image: {path}")
return self.predict(image, color="bgr", threshold=threshold)
def main() -> None:
parser = argparse.ArgumentParser(description="Run StrandSeg-Lite on one image")
parser.add_argument("image")
parser.add_argument("--checkpoint", default="model.pt")
parser.add_argument("--device", default="cpu")
parser.add_argument("--threshold", type=float)
parser.add_argument("--output-mask", default="mask.png")
parser.add_argument("--output-probability", default="probability.png")
args = parser.parse_args()
result = StrandSegLite(args.checkpoint, args.device).predict_file(args.image, threshold=args.threshold)
cv2.imwrite(args.output_mask, result.mask * 255)
cv2.imwrite(args.output_probability, np.clip(result.probability * 255.0, 0, 255).astype(np.uint8))
print(f"threshold={result.threshold:.4f} foreground_pixels={int(result.mask.sum())}")
if __name__ == "__main__":
main()