Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 57 additions & 19 deletions src/server/communication/http_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
from fastapi import FastAPI, HTTPException, Request
from starlette.middleware.gzip import GZipMiddleware
from server.communication.inference_protocol import InvalidInferencePayload
from server.commons import ModelFiles
from server.logger.log import logger
import asyncio
import ntplib
import re
import threading
import time
from pathlib import Path
Expand Down Expand Up @@ -298,16 +300,13 @@ async def close_simulation_csv():
},
)

SERVER_MODELS_DIR = Path("src/server/models/test")


@self.app.post("/api/split_inference")
async def split_inference(request: Request):
try:
import tensorflow as tf

body = await request.body()

# --- 1. PARSING DEL PAYLOAD BINARIO ---
offset = 0
timestamp = struct.unpack_from("<d", body, offset)[0]; offset += 8
Expand All @@ -316,27 +315,34 @@ async def split_inference(request: Request):
message_id = body[offset : offset + 4]; offset += 4
layer_index = struct.unpack_from("<i", body, offset)[0]; offset += 4
acq_time = struct.unpack_from("<f", body, offset)[0]; offset += 4

# ESTRAZIONE DELLA SOGLIA DAL CLIENT
soglia_client = struct.unpack_from("<f", body, offset)[0]; offset += 4

tensor_size = struct.unpack_from("<I", body, offset)[0]; offset += 4
tensor_bytes = body[offset : offset + tensor_size]; offset += tensor_size

# --- 2. RECUPERO CARTELLA MODELLI ---
size = device_id.split("_")[1].split("x")[0]
nomi_cartelle = {
"48": "FOMO_48_CUT",
"96": "FOMO_96_CUT",
"192": "FOMO_192_CUT",
"224": "FOMO_224_CUT",
"300": "FOMO_300_CUT",
}
nome_cartella = nomi_cartelle.get(size)
# Derive model_dir from settings.yaml's "model" section (the same
# source RequestHandler/ModelManager use), instead of a hardcoded
# {size: folder_name} dict, so there's a single source of truth.
size = self._parse_device_size(device_id)
if size is None:
return JSONResponse(
status_code=400,
content={
"error": (
f"device_id '{device_id}' non valido: atteso "
"formato '..._<size>x<size>'."
)
},
)

nome_cartella = self._resolve_model_dir(size)
if not nome_cartella:
return JSONResponse(status_code=400, content={"error": f"Modello per size {size} non trovato."})
tflite_dir = Path("src/server/models/test") / nome_cartella / "layers" / "tflite"

tflite_dir = Path(ModelFiles.model_save_path) / "test" / nome_cartella / "layers" / "tflite"

tflite_files = sorted(
tflite_dir.glob("*.tflite"),
Expand All @@ -363,7 +369,11 @@ async def split_inference(request: Request):
input_data = np.frombuffer(tensor_bytes, dtype=prev_out["dtype"]).copy()
input_data = input_data.reshape(tuple(prev_out["shape"]))

# --- 4. COMPLETAMENTO INFERENZA (layer rimanenti) ---
# --- 4. COMPLETAMENTO INFERENZA (layer rimanenti) ---
# These indices are inherent to the FOMO submodel's network
# topology (fixed at export time), not a runtime/deployment
# setting -- so they intentionally stay a local constant rather
# than moving into settings.yaml alongside model_dir.
skip_connections = {25: [16, 24], 43: [34, 42], 52: [43, 51]}

# Inizializziamo la memoria del server con l'ultimo output noto
Expand Down Expand Up @@ -461,6 +471,34 @@ async def split_inference(request: Request):
},
)

_DEVICE_SIZE_RE = re.compile(r"(\d+)x\1(?:$|[^\d])")

@classmethod
def _parse_device_size(cls, device_id: str) -> str | None:
"""Extract the square input size (e.g. "48") from a device_id.

Expects a `..._<size>x<size>...` pattern (e.g. "device_48x48"). Returns
None instead of raising when the id doesn't match, so callers can
surface a clean 400 response rather than an unhandled IndexError from
the previous `device_id.split("_")[1].split("x")[0]` parsing.
"""
match = cls._DEVICE_SIZE_RE.search(device_id)
return match.group(1) if match else None

def _resolve_model_dir(self, size: str) -> str | None:
"""Look up the model folder name for a given size from settings.yaml.

Uses the same "model" section (keyed by e.g. "fomo_48x48" ->
{"model_dir": "FOMO_48_CUT", ...}) that RequestHandler/ModelManager use
for the main pipeline, instead of a hardcoded {size: folder} dict.
"""
models_config = self._settings().get("model", {})
model_key = f"fomo_{size}x{size}"
model_cfg = models_config.get(model_key)
if not model_cfg:
return None
return model_cfg.get("model_dir")

def _load_verbose_config(self):
"""Load verbose configuration from cached settings."""
return self._settings().get("verbose", False)
Expand Down
71 changes: 71 additions & 0 deletions tests/integration/test_split_inference_validation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
"""Fast, artifact-free coverage for /api/split_inference's validation paths.

These tests exercise only the request parsing / model-folder lookup logic
(bad device_id, unknown size) and do not require TFLite model artifacts or a
running external server, so they run in normal CI. The accuracy-focused,
artifact-backed exercise of this endpoint lives in
tests/test_accuracy/test_ImageWithBox.py, gated behind
SCIOT_RUN_ACCURACY_TESTS=1.
"""

import struct

import pytest

from server.communication.http_server import HttpServer


def _build_split_payload(device_id: str) -> bytes:
"""Build a minimal (but well-formed) split_inference binary payload."""
device_id_bytes = device_id.encode("utf-8")
tensor_bytes = b"\x00" * 4 # single float32 element, contents irrelevant
return struct.pack(
f"<di{len(device_id_bytes)}s4siffI",
0.0,
len(device_id_bytes),
device_id_bytes,
b"TEST",
0,
0.0,
0.8,
len(tensor_bytes),
) + tensor_bytes


@pytest.mark.parametrize(
"device_id",
["not-a-device-id", "device_", "device_48", "device_48x", ""],
ids=["no-size", "trailing-underscore", "no-x", "missing-second-dim", "empty"],
)
def test_split_inference_rejects_unparseable_device_id(tiny_http_server, device_id):
_server, _handler, test_client, _session = tiny_http_server

response = test_client.post(
"/api/split_inference",
content=_build_split_payload(device_id),
headers={"Content-Type": "application/octet-stream"},
)

assert response.status_code == 400
assert "non valido" in response.json()["error"]


def test_split_inference_rejects_unknown_model_size(tiny_http_server):
_server, _handler, test_client, _session = tiny_http_server

# Well-formed device_id, but no fomo_9999x9999 entry exists in settings.
response = test_client.post(
"/api/split_inference",
content=_build_split_payload("device_9999x9999"),
headers={"Content-Type": "application/octet-stream"},
)

assert response.status_code == 400
assert "non trovato" in response.json()["error"]


def test_parse_device_size_helper():
assert HttpServer._parse_device_size("device_48x48") == "48"
assert HttpServer._parse_device_size("device_300x300_extra") == "300"
assert HttpServer._parse_device_size("garbage") is None
assert HttpServer._parse_device_size("device_48x50") is None
Loading