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 files

Co-authored-by: Anamitra-Sarkar <202972126+Anamitra-Sarkar@users.noreply.github.com>

Files changed (8) hide show
  1. .gitignore +16 -0
  2. Dockerfile +37 -0
  3. main.py +99 -0
  4. processing.py +187 -0
  5. requirements.txt +13 -0
  6. sitrep.py +78 -0
  7. tests/__init__.py +1 -0
  8. 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()