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
26 changes: 26 additions & 0 deletions backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,32 @@ Multipart form upload:
| `animal_id` | string | no | `DEMO-001` |
| `breed` | string | no | `default` |

### Predictions

- `POST /api/v1/predictions/weight-estimation`

JSON request example:

```bash
curl -X POST http://localhost:8000/api/v1/predictions/weight-estimation \
-H "Content-Type: application/json" \
-d '{
"species": "cattle",
"breed": "minhota",
"sex": "female",
"age_months": 28,
"measurements": {
"body_length_cm": 152.4,
"withers_height_cm": 126.8,
"thoracic_depth_cm": 65.9,
"rump_width_cm": 50.2,
"chest_girth_cm": 194.3
}
}'
```

This endpoint does not persist any data. It validates morphometric measurements and returns a formula-based approximate weight estimate with conservative diagnostics.

### Organizations

- `POST /api/v1/organizations`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
tags=["members"],
)


@organizations_member_router.get(
"/{organization_id}/members",
response_model=list[OrganizationMemberResponse],
Expand Down
3 changes: 3 additions & 0 deletions backend/api/v1/predictions/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from api.v1.predictions.prediction_routes import predictions_router

__all__ = ["predictions_router"]
73 changes: 73 additions & 0 deletions backend/api/v1/predictions/prediction_routes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from __future__ import annotations

from fastapi import APIRouter, Body, HTTPException, status
from fastapi.exceptions import RequestValidationError
from fastapi.routing import APIRoute

from prediction.model_registry import get_model_registry
from prediction.schemas import WeightEstimationRequest, WeightEstimationResponse


class UnprocessableEntityValidationRoute(APIRoute):
def get_route_handler(self):
original_route_handler = super().get_route_handler()

async def custom_route_handler(request):
try:
return await original_route_handler(request)
except RequestValidationError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=exc.errors(),
) from exc

return custom_route_handler


predictions_router = APIRouter(
prefix="/api/v1/predictions",
tags=["predictions"],
route_class=UnprocessableEntityValidationRoute,
)

weight_estimation_predictor = get_model_registry().get_default()

WEIGHT_ESTIMATION_REQUEST_EXAMPLE = {
"species": "cattle",
"breed": "minhota",
"sex": "female",
"age_months": 28,
"measurements": {
"body_length_cm": 152.4,
"withers_height_cm": 126.8,
"thoracic_depth_cm": 65.9,
"rump_width_cm": 50.2,
"chest_girth_cm": 194.3,
},
}


@predictions_router.post(
"/weight-estimation",
response_model=WeightEstimationResponse,
status_code=status.HTTP_200_OK,
summary="Estimate livestock weight from morphometric measurements",
)
def estimate_weight(
payload: WeightEstimationRequest = Body(
...,
openapi_examples={
"baseline_formula_request": {
"summary": "Formula-based weight estimation request",
"value": WEIGHT_ESTIMATION_REQUEST_EXAMPLE,
},
},
),
) -> WeightEstimationResponse:
try:
return weight_estimation_predictor.predict(payload)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=str(exc),
) from exc
5 changes: 4 additions & 1 deletion backend/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from urllib.parse import quote_plus

Expand Down Expand Up @@ -74,4 +75,6 @@ def _build_database_url() -> str:
).render_as_string(hide_password=False)


settings = Settings(database_url=_build_database_url())
@lru_cache(maxsize=1)
def get_settings() -> Settings:
return Settings(database_url=_build_database_url())
28 changes: 24 additions & 4 deletions backend/core/database.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, declarative_base, sessionmaker

from core.config import settings
from core.config import get_settings

Base = declarative_base()
_engine: Engine | None = None
_session_local: sessionmaker | None = None


def _create_engine() -> Engine:
settings = get_settings()
connect_args = {}
if settings.database_url.startswith("sqlite"):
connect_args["check_same_thread"] = False
Expand All @@ -34,12 +37,27 @@ def _enable_sqlite_foreign_keys(dbapi_connection, connection_record):
cursor.close()


engine = _create_engine()
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
def get_engine() -> Engine:
global _engine
if _engine is None:
_engine = _create_engine()
return _engine


def get_session_local() -> sessionmaker:
global _session_local
if _session_local is None:
_session_local = sessionmaker(
bind=get_engine(),
autoflush=False,
autocommit=False,
future=True,
)
return _session_local


def get_db() -> Generator[Session, None, None]:
db = SessionLocal()
db = get_session_local()()
try:
yield db
finally:
Expand All @@ -48,3 +66,5 @@ def get_db() -> Generator[Session, None, None]:

def initialize_database() -> None:
import models.models # noqa: F401

get_engine()
31 changes: 31 additions & 0 deletions backend/docs/weight_estimation_endpoint_notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# Weight Estimation Endpoint Notes

This endpoint adds a direct way to test formula-based livestock weight estimation without storing any data in the database.

## What was added

- A new route at `POST /api/v1/predictions/weight-estimation`
- Integration tests using FastAPI `TestClient`
- Swagger/OpenAPI request example for manual testing

## Why this endpoint exists

This route is useful as an isolated validation step before connecting weight estimation to persisted scan workflows. It lets the team verify request validation, response shape, diagnostics, and predictor integration without coupling the work to image upload or database persistence.

## Request and response flow

1. FastAPI receives the HTTP request body.
2. The `WeightEstimationRequest` Pydantic schema validates and parses the payload.
3. The route calls the existing formula-based predictor from `backend/prediction/`.
4. The predictor returns a `WeightEstimationResponse` with an estimated weight, conservative confidence score, and diagnostics.
5. FastAPI serializes the response into JSON and exposes it in Swagger.

## Validation behavior

- Valid payloads return `200 OK`.
- Invalid payloads for this route return `422 Unprocessable Entity`.
- The route uses a route-specific validation handler so the new endpoint can follow standard FastAPI `422` behavior without changing the validation behavior of unrelated legacy endpoints.

## Important implementation detail

The repository uses a shared Pydantic base model with camelCase aliases for JSON serialization. The prediction module keeps snake_case field names in Python code, while the API can still accept the snake_case example payload and serialize responses consistently with the existing project conventions.
14 changes: 8 additions & 6 deletions backend/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@

from api.v1.organizations.organizations_routes import organizations_router
from api.v1.users.users_routes import users_router
from api.v1.organizations_members.organizations_members_routes import organizations_member_router
from api.v1.organizations_members.organizations_members_routes import (
organizations_member_router,
)
from api.v1.predictions.prediction_routes import predictions_router
from core.database import initialize_database
from core.errors import register_exception_handlers

Expand All @@ -30,12 +33,10 @@ def startup_event():
def health():
return {"status": "ok", "service": "PondiFarm API v0.1 — Phase 0"}


@app.get("/health")
def health_check():
return {"status": "ok"}


@app.post("/api/v1/scan")
async def scan(
file: UploadFile = File(...),
Expand Down Expand Up @@ -90,15 +91,16 @@ async def scan(
"chest_girth_cm": measurements["chest_girth_cm"],
},
"result": {
"estimated_weight_kg": peso_kg,
"confidence_pct": confianca,
"accuracy_note": "Estimativa por visão computacional 2D · Precisão aumentada com LiDAR na versão final",
"estimated_weight_kg": peso_kg,
"confidence_pct": confianca,
"accuracy_note": "Estimativa por visão computacional 2D · Precisão aumentada com LiDAR na versão final",
},
}

app.include_router(organizations_router)
app.include_router(users_router)
app.include_router(organizations_member_router)
app.include_router(predictions_router)
return app


Expand Down
18 changes: 18 additions & 0 deletions backend/prediction/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from prediction.model_registry import DEFAULT_MODEL_VERSION, get_model_registry
from prediction.predictor import FormulaBasedWeightPredictor
from prediction.schemas import (
AnimalMeasurements,
PredictionDiagnostics,
WeightEstimationRequest,
WeightEstimationResponse,
)

__all__ = [
"AnimalMeasurements",
"DEFAULT_MODEL_VERSION",
"FormulaBasedWeightPredictor",
"PredictionDiagnostics",
"WeightEstimationRequest",
"WeightEstimationResponse",
"get_model_registry",
]
56 changes: 56 additions & 0 deletions backend/prediction/confidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
from __future__ import annotations

from prediction.feature_builder import FormulaFeatures
from prediction.measurement_validator import MeasurementValidationResult


def calculate_confidence_score(
*,
validation_result: MeasurementValidationResult,
features: FormulaFeatures,
) -> float:
average_boundary_centrality = sum(
validation_result.boundary_centrality.values(),
) / len(validation_result.boundary_centrality)

ratio_alignment = _average(
[
_ratio_alignment(
value=features.chest_girth_to_length_ratio,
target=1.28,
tolerance=0.35,
),
_ratio_alignment(
value=features.withers_height_to_depth_ratio,
target=1.95,
tolerance=0.55,
),
_ratio_alignment(
value=features.rump_width_to_girth_ratio,
target=0.29,
tolerance=0.12,
),
],
)

warning_penalty = min(0.20, 0.04 * len(validation_result.warnings))
score = (
0.55
+ 0.08 * (average_boundary_centrality - 0.5)
+ 0.06 * (ratio_alignment - 0.5)
- warning_penalty
)

bounded_score = max(0.20, min(0.70, score))
return round(bounded_score, 2)


def _ratio_alignment(*, value: float, target: float, tolerance: float) -> float:
deviation = abs(value - target)
if deviation >= tolerance:
return 0.0
return 1.0 - (deviation / tolerance)


def _average(values: list[float]) -> float:
return sum(values) / len(values)
33 changes: 33 additions & 0 deletions backend/prediction/feature_builder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
from __future__ import annotations

from dataclasses import dataclass

from prediction.schemas import AnimalMeasurements


CENTIMETERS_PER_INCH = 2.54


@dataclass(frozen=True)
class FormulaFeatures:
heart_girth_in: float
body_length_in: float
chest_girth_to_length_ratio: float
withers_height_to_depth_ratio: float
rump_width_to_girth_ratio: float


def build_formula_features(measurements: AnimalMeasurements) -> FormulaFeatures:
return FormulaFeatures(
heart_girth_in=measurements.chest_girth_cm / CENTIMETERS_PER_INCH,
body_length_in=measurements.body_length_cm / CENTIMETERS_PER_INCH,
chest_girth_to_length_ratio=(
measurements.chest_girth_cm / measurements.body_length_cm
),
withers_height_to_depth_ratio=(
measurements.withers_height_cm / measurements.thoracic_depth_cm
),
rump_width_to_girth_ratio=(
measurements.rump_width_cm / measurements.chest_girth_cm
),
)
Loading
Loading