copilot-swe-agent[bot] Anamitra-Sarkar commited on
Commit ·
6e1c8c8
0
Parent(s):
Add Maritime monorepo scaffold: backend FastAPI + frontend Next.js + CI/CD workflow
Browse filesCo-authored-by: Anamitra-Sarkar <202972126+Anamitra-Sarkar@users.noreply.github.com>
- .gitignore +16 -0
- Dockerfile +37 -0
- main.py +99 -0
- processing.py +187 -0
- requirements.txt +13 -0
- sitrep.py +78 -0
- tests/__init__.py +1 -0
- tests/test_api.py +163 -0
.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.env
|
| 5 |
+
.venv/
|
| 6 |
+
venv/
|
| 7 |
+
env/
|
| 8 |
+
.pytest_cache/
|
| 9 |
+
htmlcov/
|
| 10 |
+
.coverage
|
| 11 |
+
|
| 12 |
+
# Model weights (download at runtime)
|
| 13 |
+
weights/
|
| 14 |
+
*.pt
|
| 15 |
+
*.pth
|
| 16 |
+
*.onnx
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SUB-SENTINEL backend – Hugging Face Spaces Docker image
|
| 2 |
+
#
|
| 3 |
+
# Build: docker build -t sub-sentinel-backend .
|
| 4 |
+
# Run: docker run -p 7860:7860 sub-sentinel-backend
|
| 5 |
+
|
| 6 |
+
FROM python:3.11-slim
|
| 7 |
+
|
| 8 |
+
# HF Spaces requires port 7860
|
| 9 |
+
EXPOSE 7860
|
| 10 |
+
|
| 11 |
+
# Install system dependencies for OpenCV
|
| 12 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 13 |
+
libglib2.0-0 \
|
| 14 |
+
libgl1-mesa-glx \
|
| 15 |
+
libsm6 \
|
| 16 |
+
libxext6 \
|
| 17 |
+
libxrender-dev \
|
| 18 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 19 |
+
|
| 20 |
+
WORKDIR /app
|
| 21 |
+
|
| 22 |
+
# Install Python deps first (cache layer)
|
| 23 |
+
COPY requirements.txt .
|
| 24 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 25 |
+
|
| 26 |
+
# Copy application source
|
| 27 |
+
COPY . .
|
| 28 |
+
|
| 29 |
+
# Create weights directory (populated at runtime via env / volume)
|
| 30 |
+
RUN mkdir -p weights
|
| 31 |
+
|
| 32 |
+
# Non-root user for HF Spaces compatibility
|
| 33 |
+
RUN useradd -m appuser && chown -R appuser:appuser /app
|
| 34 |
+
USER appuser
|
| 35 |
+
|
| 36 |
+
# Start the server on port 7860
|
| 37 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
main.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SUB-SENTINEL backend – FastAPI application.
|
| 3 |
+
|
| 4 |
+
Endpoints:
|
| 5 |
+
POST /process – accepts an image upload and returns:
|
| 6 |
+
{
|
| 7 |
+
enhanced_image_base64 : str,
|
| 8 |
+
heatmap_base64 : str,
|
| 9 |
+
detections : [{class, mapped_label, confidence, bbox:[x1,y1,x2,y2]}],
|
| 10 |
+
sitrep_text : str
|
| 11 |
+
}
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
import logging
|
| 16 |
+
|
| 17 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 18 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 19 |
+
|
| 20 |
+
from processing import enhance_image, run_detection, build_heatmap
|
| 21 |
+
from sitrep import generate_sitrep
|
| 22 |
+
|
| 23 |
+
logging.basicConfig(level=logging.INFO)
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
# ---------------------------------------------------------------------------
|
| 27 |
+
# App setup
|
| 28 |
+
# ---------------------------------------------------------------------------
|
| 29 |
+
|
| 30 |
+
app = FastAPI(
|
| 31 |
+
title="SUB-SENTINEL API",
|
| 32 |
+
description="Acoustic-Visual Forensics & Threat Relay",
|
| 33 |
+
version="1.0.0",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
# CORS – allow Vercel production domain + localhost development
|
| 37 |
+
_ORIGINS = [
|
| 38 |
+
os.getenv("FRONTEND_ORIGIN", "https://sub-sentinel.vercel.app"),
|
| 39 |
+
"http://localhost:3000",
|
| 40 |
+
"http://127.0.0.1:3000",
|
| 41 |
+
]
|
| 42 |
+
|
| 43 |
+
app.add_middleware(
|
| 44 |
+
CORSMiddleware,
|
| 45 |
+
allow_origins=_ORIGINS,
|
| 46 |
+
allow_credentials=True,
|
| 47 |
+
allow_methods=["*"],
|
| 48 |
+
allow_headers=["*"],
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
# ---------------------------------------------------------------------------
|
| 53 |
+
# Routes
|
| 54 |
+
# ---------------------------------------------------------------------------
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@app.get("/health")
|
| 58 |
+
async def health() -> dict:
|
| 59 |
+
"""Liveness probe."""
|
| 60 |
+
return {"status": "ok"}
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
@app.post("/process")
|
| 64 |
+
async def process_image(file: UploadFile = File(...)) -> dict:
|
| 65 |
+
"""
|
| 66 |
+
Accept an image upload and run the full forensic pipeline:
|
| 67 |
+
1. Underwater image enhancement (FUnIE-GAN fallback → CLAHE)
|
| 68 |
+
2. YOLOv8n object detection with maritime label mapping
|
| 69 |
+
3. SSIM-based forensic heatmap generation
|
| 70 |
+
4. Groq SITREP generation
|
| 71 |
+
"""
|
| 72 |
+
# Validate content type
|
| 73 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 74 |
+
raise HTTPException(status_code=400, detail="File must be an image.")
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
raw_bytes = await file.read()
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
logger.error("Failed to read upload: %s", exc)
|
| 80 |
+
raise HTTPException(status_code=400, detail="Could not read uploaded file.")
|
| 81 |
+
|
| 82 |
+
if not raw_bytes:
|
| 83 |
+
raise HTTPException(status_code=400, detail="Empty file received.")
|
| 84 |
+
|
| 85 |
+
try:
|
| 86 |
+
enhanced_b64, original_array = enhance_image(raw_bytes)
|
| 87 |
+
detections = run_detection(original_array)
|
| 88 |
+
heatmap_b64 = build_heatmap(original_array)
|
| 89 |
+
sitrep = generate_sitrep(detections)
|
| 90 |
+
except Exception as exc:
|
| 91 |
+
logger.exception("Pipeline error: %s", exc)
|
| 92 |
+
raise HTTPException(status_code=500, detail=f"Processing pipeline failed: {exc}")
|
| 93 |
+
|
| 94 |
+
return {
|
| 95 |
+
"enhanced_image_base64": enhanced_b64,
|
| 96 |
+
"heatmap_base64": heatmap_b64,
|
| 97 |
+
"detections": detections,
|
| 98 |
+
"sitrep_text": sitrep,
|
| 99 |
+
}
|
processing.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Image processing pipeline for SUB-SENTINEL.
|
| 3 |
+
|
| 4 |
+
Provides three functions:
|
| 5 |
+
enhance_image(raw_bytes) → (base64_str, numpy_array)
|
| 6 |
+
run_detection(image_array) → list[dict]
|
| 7 |
+
build_heatmap(image_array) → base64_str
|
| 8 |
+
|
| 9 |
+
All heavy-weight model paths gracefully fall back to CPU-friendly alternatives
|
| 10 |
+
when model weights are absent.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import base64
|
| 14 |
+
import io
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
import cv2
|
| 19 |
+
import numpy as np
|
| 20 |
+
from PIL import Image
|
| 21 |
+
from skimage.metrics import structural_similarity as ssim
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Maritime label mapping for YOLOv8 COCO classes
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
_LABEL_MAP: dict[str, str] = {
|
| 29 |
+
"person": "Diver/Swimmer",
|
| 30 |
+
"boat": "Surface/Sub Threat",
|
| 31 |
+
"ship": "Surface/Sub Threat",
|
| 32 |
+
"submarine": "Surface/Sub Threat",
|
| 33 |
+
"surfboard": "Surface/Sub Threat",
|
| 34 |
+
# extend as needed
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _array_to_base64(img_array: np.ndarray, fmt: str = "JPEG") -> str:
|
| 39 |
+
"""Convert a uint8 numpy array (H×W×C, RGB) to a base-64 data-URI string."""
|
| 40 |
+
pil_img = Image.fromarray(img_array.astype(np.uint8))
|
| 41 |
+
buf = io.BytesIO()
|
| 42 |
+
pil_img.save(buf, format=fmt, quality=90)
|
| 43 |
+
encoded = base64.b64encode(buf.getvalue()).decode("utf-8")
|
| 44 |
+
mime = "image/jpeg" if fmt == "JPEG" else "image/png"
|
| 45 |
+
return f"data:{mime};base64,{encoded}"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _bytes_to_array(raw_bytes: bytes) -> np.ndarray:
|
| 49 |
+
"""Decode raw image bytes to a uint8 RGB numpy array."""
|
| 50 |
+
nparr = np.frombuffer(raw_bytes, np.uint8)
|
| 51 |
+
bgr = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 52 |
+
if bgr is None:
|
| 53 |
+
raise ValueError("OpenCV could not decode the image.")
|
| 54 |
+
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
# ---------------------------------------------------------------------------
|
| 58 |
+
# 1. Underwater image enhancement
|
| 59 |
+
# ---------------------------------------------------------------------------
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _clahe_enhance(rgb: np.ndarray) -> np.ndarray:
|
| 63 |
+
"""
|
| 64 |
+
CPU-friendly underwater enhancement using CLAHE on LAB colour space.
|
| 65 |
+
Used when FUnIE-GAN weights are unavailable.
|
| 66 |
+
"""
|
| 67 |
+
lab = cv2.cvtColor(rgb, cv2.COLOR_RGB2LAB)
|
| 68 |
+
l_channel, a_channel, b_channel = cv2.split(lab)
|
| 69 |
+
clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8))
|
| 70 |
+
l_channel = clahe.apply(l_channel)
|
| 71 |
+
# Slight blue-green colour correction typical for underwater footage
|
| 72 |
+
a_channel = np.clip(a_channel.astype(np.int16) - 5, 0, 255).astype(np.uint8)
|
| 73 |
+
b_channel = np.clip(b_channel.astype(np.int16) + 10, 0, 255).astype(np.uint8)
|
| 74 |
+
enhanced_lab = cv2.merge([l_channel, a_channel, b_channel])
|
| 75 |
+
return cv2.cvtColor(enhanced_lab, cv2.COLOR_LAB2RGB)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _funiegan_enhance(rgb: np.ndarray) -> Optional[np.ndarray]:
|
| 79 |
+
"""
|
| 80 |
+
Attempt FUnIE-GAN inference via a local ONNX weight file.
|
| 81 |
+
Returns None if weights are missing so the caller can fall back.
|
| 82 |
+
"""
|
| 83 |
+
weights_path = "weights/funiegan.onnx"
|
| 84 |
+
try:
|
| 85 |
+
import os
|
| 86 |
+
if not os.path.exists(weights_path):
|
| 87 |
+
return None
|
| 88 |
+
net = cv2.dnn.readNetFromONNX(weights_path)
|
| 89 |
+
h, w = rgb.shape[:2]
|
| 90 |
+
target_h, target_w = 256, 256
|
| 91 |
+
resized = cv2.resize(rgb, (target_w, target_h)).astype(np.float32) / 127.5 - 1.0
|
| 92 |
+
blob = cv2.dnn.blobFromImage(resized)
|
| 93 |
+
net.setInput(blob)
|
| 94 |
+
out = net.forward()
|
| 95 |
+
out_img = ((out[0].transpose(1, 2, 0) + 1.0) * 127.5).clip(0, 255).astype(np.uint8)
|
| 96 |
+
return cv2.resize(out_img, (w, h))
|
| 97 |
+
except Exception as exc:
|
| 98 |
+
logger.warning("FUnIE-GAN inference failed (%s); using CLAHE fallback.", exc)
|
| 99 |
+
return None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def enhance_image(raw_bytes: bytes) -> tuple[str, np.ndarray]:
|
| 103 |
+
"""
|
| 104 |
+
Enhance an underwater image.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
(base64_enhanced, original_rgb_array)
|
| 108 |
+
The original array is returned unchanged for use in downstream steps.
|
| 109 |
+
"""
|
| 110 |
+
rgb = _bytes_to_array(raw_bytes)
|
| 111 |
+
enhanced = _funiegan_enhance(rgb)
|
| 112 |
+
if enhanced is None:
|
| 113 |
+
enhanced = _clahe_enhance(rgb)
|
| 114 |
+
return _array_to_base64(enhanced), rgb
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
# ---------------------------------------------------------------------------
|
| 118 |
+
# 2. Object detection (YOLOv8n)
|
| 119 |
+
# ---------------------------------------------------------------------------
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def run_detection(rgb: np.ndarray) -> list[dict]:
|
| 123 |
+
"""
|
| 124 |
+
Run YOLOv8n COCO detection and map labels to maritime terminology.
|
| 125 |
+
|
| 126 |
+
Returns a list of detection dicts:
|
| 127 |
+
{class, mapped_label, confidence, bbox: [x1, y1, x2, y2]}
|
| 128 |
+
"""
|
| 129 |
+
try:
|
| 130 |
+
from ultralytics import YOLO # lazy import – large package
|
| 131 |
+
model = YOLO("yolov8n.pt") # downloads automatically on first run
|
| 132 |
+
results = model(rgb, verbose=False)
|
| 133 |
+
except Exception as exc:
|
| 134 |
+
logger.warning("YOLOv8n detection failed (%s); returning empty detections.", exc)
|
| 135 |
+
return []
|
| 136 |
+
|
| 137 |
+
detections = []
|
| 138 |
+
for result in results:
|
| 139 |
+
if result.boxes is None:
|
| 140 |
+
continue
|
| 141 |
+
for box in result.boxes:
|
| 142 |
+
cls_id = int(box.cls[0])
|
| 143 |
+
cls_name = model.names.get(cls_id, str(cls_id))
|
| 144 |
+
conf = float(box.conf[0])
|
| 145 |
+
x1, y1, x2, y2 = (float(v) for v in box.xyxy[0])
|
| 146 |
+
detections.append(
|
| 147 |
+
{
|
| 148 |
+
"class": cls_name,
|
| 149 |
+
"mapped_label": _LABEL_MAP.get(cls_name, cls_name),
|
| 150 |
+
"confidence": round(conf, 4),
|
| 151 |
+
"bbox": [round(x1), round(y1), round(x2), round(y2)],
|
| 152 |
+
}
|
| 153 |
+
)
|
| 154 |
+
return detections
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
# ---------------------------------------------------------------------------
|
| 158 |
+
# 3. SSIM-based forensic heatmap
|
| 159 |
+
# ---------------------------------------------------------------------------
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def build_heatmap(rgb: np.ndarray) -> str:
|
| 163 |
+
"""
|
| 164 |
+
Generate a forensic heatmap by comparing the original image against a
|
| 165 |
+
Gaussian-blurred reference. High SSIM → green; low SSIM → red.
|
| 166 |
+
|
| 167 |
+
Returns a base64-encoded PNG heatmap.
|
| 168 |
+
"""
|
| 169 |
+
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
| 170 |
+
# Reference: gently blurred version of the same frame
|
| 171 |
+
blurred = cv2.GaussianBlur(gray, (15, 15), 0)
|
| 172 |
+
|
| 173 |
+
# Compute SSIM score map (window-level scores)
|
| 174 |
+
_, ssim_map = ssim(gray, blurred, full=True, data_range=255)
|
| 175 |
+
|
| 176 |
+
# Normalise to [0, 255]
|
| 177 |
+
ssim_norm = ((ssim_map + 1.0) / 2.0 * 255).clip(0, 255).astype(np.uint8)
|
| 178 |
+
|
| 179 |
+
# Map to BGR: low similarity → red (forensic interest), high → green
|
| 180 |
+
heatmap_bgr = cv2.applyColorMap(ssim_norm, cv2.COLORMAP_RdYlGn if hasattr(cv2, "COLORMAP_RdYlGn") else cv2.COLORMAP_JET)
|
| 181 |
+
|
| 182 |
+
# Blend with original for context
|
| 183 |
+
rgb_bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
| 184 |
+
overlay = cv2.addWeighted(rgb_bgr, 0.55, heatmap_bgr, 0.45, 0)
|
| 185 |
+
overlay_rgb = cv2.cvtColor(overlay, cv2.COLOR_BGR2RGB)
|
| 186 |
+
|
| 187 |
+
return _array_to_base64(overlay_rgb, fmt="PNG")
|
requirements.txt
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111.0
|
| 2 |
+
uvicorn[standard]>=0.29.0
|
| 3 |
+
python-multipart>=0.0.9
|
| 4 |
+
Pillow>=10.3.0
|
| 5 |
+
numpy>=1.26.0
|
| 6 |
+
opencv-python-headless>=4.9.0
|
| 7 |
+
scikit-image>=0.23.0
|
| 8 |
+
ultralytics>=8.2.0
|
| 9 |
+
groq>=0.9.0
|
| 10 |
+
httpx>=0.27.0
|
| 11 |
+
pytest>=8.2.0
|
| 12 |
+
pytest-asyncio>=0.23.0
|
| 13 |
+
httpx>=0.27.0
|
sitrep.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
SITREP generation using the Groq API (llama-3.1-8b-instant).
|
| 3 |
+
|
| 4 |
+
Falls back to a static template when GROQ_API_KEY is not set or when
|
| 5 |
+
the API call fails, so the system remains functional without credentials.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _build_prompt(detections: list[dict]) -> str:
|
| 15 |
+
"""Compose the SITREP prompt from detection results."""
|
| 16 |
+
if not detections:
|
| 17 |
+
det_summary = "No objects detected in the current frame."
|
| 18 |
+
else:
|
| 19 |
+
lines = []
|
| 20 |
+
for d in detections:
|
| 21 |
+
x1, y1, x2, y2 = d["bbox"]
|
| 22 |
+
lines.append(
|
| 23 |
+
f" - {d['mapped_label']} (conf {d['confidence']:.0%}) "
|
| 24 |
+
f"at pixel coords ({x1},{y1})→({x2},{y2})"
|
| 25 |
+
)
|
| 26 |
+
det_summary = "\n".join(lines)
|
| 27 |
+
|
| 28 |
+
return (
|
| 29 |
+
"You are the AI analyst for SUB-SENTINEL, an underwater forensics system.\n"
|
| 30 |
+
"Generate a concise, military-style SITREP (≤120 words) based on these detections:\n\n"
|
| 31 |
+
f"{det_summary}\n\n"
|
| 32 |
+
"Format: SITUATION / ASSESSMENT / RECOMMENDATION. Use clear, direct language."
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _static_sitrep(detections: list[dict]) -> str:
|
| 37 |
+
"""Minimal static fallback when Groq is unavailable."""
|
| 38 |
+
if not detections:
|
| 39 |
+
return (
|
| 40 |
+
"SITUATION: Sensor sweep complete – no contacts.\n"
|
| 41 |
+
"ASSESSMENT: Area clear.\n"
|
| 42 |
+
"RECOMMENDATION: Continue routine patrol."
|
| 43 |
+
)
|
| 44 |
+
labels = ", ".join({d["mapped_label"] for d in detections})
|
| 45 |
+
count = len(detections)
|
| 46 |
+
return (
|
| 47 |
+
f"SITUATION: {count} contact(s) detected – {labels}.\n"
|
| 48 |
+
"ASSESSMENT: Requires manual review.\n"
|
| 49 |
+
"RECOMMENDATION: Dispatch response team and maintain sensor lock."
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def generate_sitrep(detections: list[dict]) -> str:
|
| 54 |
+
"""
|
| 55 |
+
Call Groq llama-3.1-8b-instant to generate a SITREP.
|
| 56 |
+
Falls back to _static_sitrep if GROQ_API_KEY is absent or on any error.
|
| 57 |
+
"""
|
| 58 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 59 |
+
if not api_key:
|
| 60 |
+
logger.info("GROQ_API_KEY not set; using static SITREP fallback.")
|
| 61 |
+
return _static_sitrep(detections)
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
from groq import Groq # lazy import
|
| 65 |
+
|
| 66 |
+
client = Groq(api_key=api_key)
|
| 67 |
+
response = client.chat.completions.create(
|
| 68 |
+
model="llama-3.1-8b-instant",
|
| 69 |
+
messages=[
|
| 70 |
+
{"role": "user", "content": _build_prompt(detections)},
|
| 71 |
+
],
|
| 72 |
+
max_tokens=200,
|
| 73 |
+
temperature=0.4,
|
| 74 |
+
)
|
| 75 |
+
return response.choices[0].message.content.strip()
|
| 76 |
+
except Exception as exc:
|
| 77 |
+
logger.warning("Groq SITREP generation failed (%s); using static fallback.", exc)
|
| 78 |
+
return _static_sitrep(detections)
|
tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# backend/tests/__init__.py
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
pytest tests for the SUB-SENTINEL FastAPI backend.
|
| 3 |
+
|
| 4 |
+
Run:
|
| 5 |
+
cd backend
|
| 6 |
+
pytest tests/ -v
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import base64
|
| 10 |
+
import io
|
| 11 |
+
import os
|
| 12 |
+
|
| 13 |
+
import pytest
|
| 14 |
+
from fastapi.testclient import TestClient
|
| 15 |
+
from PIL import Image
|
| 16 |
+
|
| 17 |
+
# Ensure we do NOT need a real GROQ_API_KEY for tests
|
| 18 |
+
os.environ.setdefault("GROQ_API_KEY", "")
|
| 19 |
+
|
| 20 |
+
from main import app # noqa: E402 – import after env setup
|
| 21 |
+
|
| 22 |
+
client = TestClient(app)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Helpers
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _make_png_bytes(width: int = 64, height: int = 64, color=(30, 80, 120)) -> bytes:
|
| 31 |
+
"""Create a small solid-colour PNG in memory."""
|
| 32 |
+
img = Image.new("RGB", (width, height), color)
|
| 33 |
+
buf = io.BytesIO()
|
| 34 |
+
img.save(buf, format="PNG")
|
| 35 |
+
return buf.getvalue()
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# /health
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def test_health_ok():
|
| 44 |
+
response = client.get("/health")
|
| 45 |
+
assert response.status_code == 200
|
| 46 |
+
assert response.json() == {"status": "ok"}
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# ---------------------------------------------------------------------------
|
| 50 |
+
# /process – happy path
|
| 51 |
+
# ---------------------------------------------------------------------------
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def test_process_returns_required_keys():
|
| 55 |
+
png = _make_png_bytes()
|
| 56 |
+
response = client.post(
|
| 57 |
+
"/process",
|
| 58 |
+
files={"file": ("test.png", png, "image/png")},
|
| 59 |
+
)
|
| 60 |
+
assert response.status_code == 200
|
| 61 |
+
data = response.json()
|
| 62 |
+
assert "enhanced_image_base64" in data
|
| 63 |
+
assert "heatmap_base64" in data
|
| 64 |
+
assert "detections" in data
|
| 65 |
+
assert "sitrep_text" in data
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_process_enhanced_image_is_valid_base64():
|
| 69 |
+
png = _make_png_bytes()
|
| 70 |
+
response = client.post(
|
| 71 |
+
"/process",
|
| 72 |
+
files={"file": ("test.png", png, "image/png")},
|
| 73 |
+
)
|
| 74 |
+
assert response.status_code == 200
|
| 75 |
+
b64_str = response.json()["enhanced_image_base64"]
|
| 76 |
+
# Should be a data-URI
|
| 77 |
+
assert b64_str.startswith("data:image/")
|
| 78 |
+
# Extract the raw base64 portion and verify it decodes
|
| 79 |
+
raw = b64_str.split(",", 1)[1]
|
| 80 |
+
decoded = base64.b64decode(raw)
|
| 81 |
+
img = Image.open(io.BytesIO(decoded))
|
| 82 |
+
assert img.size[0] > 0
|
| 83 |
+
assert img.size[1] > 0
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def test_process_heatmap_is_valid_base64():
|
| 87 |
+
png = _make_png_bytes()
|
| 88 |
+
response = client.post(
|
| 89 |
+
"/process",
|
| 90 |
+
files={"file": ("test.png", png, "image/png")},
|
| 91 |
+
)
|
| 92 |
+
assert response.status_code == 200
|
| 93 |
+
b64_str = response.json()["heatmap_base64"]
|
| 94 |
+
assert b64_str.startswith("data:image/")
|
| 95 |
+
raw = b64_str.split(",", 1)[1]
|
| 96 |
+
decoded = base64.b64decode(raw)
|
| 97 |
+
img = Image.open(io.BytesIO(decoded))
|
| 98 |
+
assert img.size[0] > 0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def test_process_detections_structure():
|
| 102 |
+
"""Each detection must have the required keys with correct types."""
|
| 103 |
+
png = _make_png_bytes()
|
| 104 |
+
response = client.post(
|
| 105 |
+
"/process",
|
| 106 |
+
files={"file": ("test.png", png, "image/png")},
|
| 107 |
+
)
|
| 108 |
+
assert response.status_code == 200
|
| 109 |
+
detections = response.json()["detections"]
|
| 110 |
+
assert isinstance(detections, list)
|
| 111 |
+
for det in detections:
|
| 112 |
+
assert "class" in det
|
| 113 |
+
assert "mapped_label" in det
|
| 114 |
+
assert "confidence" in det
|
| 115 |
+
assert "bbox" in det
|
| 116 |
+
assert isinstance(det["confidence"], float)
|
| 117 |
+
assert len(det["bbox"]) == 4
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_process_sitrep_is_string():
|
| 121 |
+
png = _make_png_bytes()
|
| 122 |
+
response = client.post(
|
| 123 |
+
"/process",
|
| 124 |
+
files={"file": ("test.png", png, "image/png")},
|
| 125 |
+
)
|
| 126 |
+
assert response.status_code == 200
|
| 127 |
+
sitrep = response.json()["sitrep_text"]
|
| 128 |
+
assert isinstance(sitrep, str)
|
| 129 |
+
assert len(sitrep) > 0
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
# ---------------------------------------------------------------------------
|
| 133 |
+
# /process – error cases
|
| 134 |
+
# ---------------------------------------------------------------------------
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_process_rejects_non_image():
|
| 138 |
+
response = client.post(
|
| 139 |
+
"/process",
|
| 140 |
+
files={"file": ("data.txt", b"hello world", "text/plain")},
|
| 141 |
+
)
|
| 142 |
+
assert response.status_code == 400
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def test_process_rejects_empty_file():
|
| 146 |
+
response = client.post(
|
| 147 |
+
"/process",
|
| 148 |
+
files={"file": ("empty.png", b"", "image/png")},
|
| 149 |
+
)
|
| 150 |
+
assert response.status_code == 400
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def test_process_jpeg_image():
|
| 154 |
+
"""Pipeline should work with JPEG input."""
|
| 155 |
+
img = Image.new("RGB", (80, 80), (50, 100, 150))
|
| 156 |
+
buf = io.BytesIO()
|
| 157 |
+
img.save(buf, format="JPEG")
|
| 158 |
+
response = client.post(
|
| 159 |
+
"/process",
|
| 160 |
+
files={"file": ("test.jpg", buf.getvalue(), "image/jpeg")},
|
| 161 |
+
)
|
| 162 |
+
assert response.status_code == 200
|
| 163 |
+
assert "enhanced_image_base64" in response.json()
|