Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
cd61d64
Introduced poses and its fk (nullable before migration)
fe51 Dec 12, 2025
1faf81e
Created poses schema
fe51 Dec 12, 2025
ac1119c
Updated camaras and detections schemas
fe51 Dec 12, 2025
c6e081d
Created crud_pose
fe51 Dec 12, 2025
737fa0b
Updated crud Init.py
fe51 Dec 12, 2025
152d596
updated api router and dependencies
fe51 Dec 12, 2025
8773cc3
Created endpoints for poses
fe51 Dec 12, 2025
88d3e7f
updated endpoints detections
fe51 Dec 12, 2025
c7e7005
Updated camera endpoints
fe51 Dec 12, 2025
889c9f6
Updated conftest
fe51 Dec 12, 2025
013cb7f
Added tests for poses
fe51 Dec 12, 2025
e181849
Updates detections and cameras tests
fe51 Dec 12, 2025
fe7df71
Updated client
fe51 Dec 12, 2025
f3ec020
Updates db diagram
fe51 Dec 12, 2025
8377f01
update test end to end
fe51 Dec 12, 2025
c243e2a
mypy fixes
fe51 Dec 12, 2025
293c730
fix codacy
fe51 Dec 12, 2025
03e2c01
fix mypy
fe51 Dec 12, 2025
c38e738
fix codacy and mypy with more explicit import
fe51 Dec 12, 2025
693b4e1
quick fix test client
fe51 Dec 12, 2025
729b4b6
revert client updates
fe51 Dec 12, 2025
99a3ef6
Added Occlusion Mask table in model
fe51 Dec 15, 2025
5d8a2be
Added occlusion mask crud and schema
fe51 Dec 15, 2025
400f0a8
Added occlusion mask endpoint
fe51 Dec 15, 2025
db5fd1a
added occlusion mask to router and dependencies
fe51 Dec 15, 2025
b11bd73
Update poses endpoint to list occlusion mask from a pose
fe51 Dec 15, 2025
254aba7
Update tests and e2e tests
fe51 Dec 15, 2025
9828392
updates db diagram
fe51 Dec 15, 2025
8db4b5d
fix mypy
fe51 Dec 15, 2025
de99b2d
Revert "fix mypy"
fe51 Dec 15, 2025
8cf3d46
mypy
fe51 Dec 15, 2025
6d55aab
fix typos db diagram docs
fe51 Dec 19, 2025
4cb14ff
updates patrol_id type from str to int
fe51 Dec 19, 2025
7a0e806
updated tests patrol_id str to int
fe51 Dec 19, 2025
4e22cec
style
fe51 Dec 19, 2025
834cd8c
fix to respect code convention
fe51 Dec 19, 2025
02ff40c
wip updates client
fe51 Dec 19, 2025
4718795
Merge branch 'main' into poses
fe51 Dec 19, 2025
6c7d46f
typo
fe51 Dec 19, 2025
1feee8f
Specify None
fe51 Dec 19, 2025
bbcb127
optional pose id
fe51 Dec 19, 2025
e98ab4d
Merge branch 'poses' into occlusion_masks
fe51 Dec 19, 2025
4c207ec
Merge branch 'main' into poses
fe51 Dec 19, 2025
588ec27
resolve merge conflict merging main
fe51 Dec 19, 2025
d5d0753
client: add pose_id param optionnal in create_detection
fe51 Dec 19, 2025
af955a8
Merge branch 'poses' into occlusion_masks
fe51 Dec 19, 2025
b5f1b36
Merge branch 'main' into occlusion_masks
fe51 Dec 20, 2025
2d7aa51
typo
fe51 Dec 20, 2025
d5edfb7
ruff
MateoLostanlen Dec 31, 2025
bae4dea
Added scope check
fe51 Jan 6, 2026
d8f4365
added get occlusion mask route
fe51 Jan 6, 2026
457ea2a
delete conf in occlusion mask format
fe51 Jan 6, 2026
02c15e5
Updates tests
fe51 Jan 6, 2026
e6e7037
Updates client and tests
fe51 Jan 6, 2026
ac1d806
Merge branch 'main' into occlusion_masks
fe51 Jan 6, 2026
cdf9722
headers
fe51 Jan 6, 2026
4c7e88e
fix headers
fe51 Jan 6, 2026
7d5074d
fix header
fe51 Jan 6, 2026
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
81 changes: 81 additions & 0 deletions client/pyroclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ class ClientRoute(str, Enum):
# POSES
POSES_CREATE = "poses/"
POSES_BY_ID = "poses/{pose_id}"
POSES_MASKS_BY_ID = "poses/{pose_id}/occlusion_masks"
# OCCLUSION MASKS
OCCLUSION_MASKS_CREATE = "occlusion_masks"
OCCLUSION_MASKS_BY_ID = "occlusion_masks/{mask_id}"
# DETECTIONS
DETECTIONS_CREATE = "detections/"
DETECTIONS_FETCH = "detections"
Expand Down Expand Up @@ -210,6 +214,83 @@ def delete_pose(self, pose_id: int) -> Response:
timeout=self.timeout,
)

def list_pose_masks(self, pose_id: int) -> Response:
"""List occlusion masks for a pose (given its pose_id)

>>> api_client.list_pose_masks(pose_id=1)
"""
return requests.get(
urljoin(self._route_prefix, ClientRoute.POSES_MASKS_BY_ID.format(pose_id=pose_id)),
headers=self.headers,
timeout=self.timeout,
)

# OCCLUSION MASKS

def create_occlusion_mask(
self,
pose_id: int,
mask: str,
) -> Response:
"""Create a create occlusion_mask for a pose

>>> api_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)")
"""
payload = {
"pose_id": pose_id,
"mask": mask,
}

return requests.post(
urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_CREATE),
headers=self.headers,
json=payload,
timeout=self.timeout,
)

def patch_occlusion_mask(
self,
mask_id: int,
mask: str,
) -> Response:
"""Update an occlusion mask

>>> api_client.patch_occlusion_mask(mask_id=1, mask="(0.1,0.2,0.9,0.3)")
"""
payload = {"mask": mask}

return requests.patch(
urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_BY_ID.format(mask_id=mask_id)),
headers=self.headers,
json=payload,
timeout=self.timeout,
)

def get_occlusion_mask(
self,
mask_id: int,
) -> Response:
"""get mask from occlusion mask

>>> api_client.get_occlusion_mask(mask_id=1")
"""
return requests.get(
urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_BY_ID.format(mask_id=mask_id)),
headers=self.headers,
timeout=self.timeout,
)

def delete_occlusion_mask(self, mask_id: int) -> Response:
"""Delete an occlusion mask

>>> api_client.delete_occlusion_mask(mask_id=1)
"""
return requests.delete(
urljoin(self._route_prefix, ClientRoute.OCCLUSION_MASKS_BY_ID.format(mask_id=mask_id)),
headers=self.headers,
timeout=self.timeout,
)

# DETECTIONS

def create_detection(
Expand Down
6 changes: 6 additions & 0 deletions client/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,12 @@ def test_agent_workflow(test_cam_workflow, agent_token):
assert len(response) == 1
response = agent_client.label_sequence(response[0]["id"], "wildfire_smoke")
assert response.status_code == 200, response.__dict__
response = agent_client.create_occlusion_mask(pose_id=1, mask="(0.1,0.1,0.9,0.9)") # occlusion mask creation
assert response.status_code == 201, response.__dict__
print("reponseeee creation du mask")
print(response.json())
response = agent_client.delete_occlusion_mask(mask_id=response.json()["id"]) # occlusion mask deletion
assert response.status_code == 200, response.__dict__


def test_user_workflow(test_cam_workflow, user_token):
Expand Down
172 changes: 172 additions & 0 deletions src/app/api/api_v1/endpoints/occlusion_masks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# Copyright (C) 2025-2026, Pyronear.

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.
import re
from typing import cast

from fastapi import APIRouter, Body, Depends, HTTPException, Path, Security, status

from app.api.dependencies import (
get_camera_crud,
get_jwt,
get_occlusion_mask_crud,
get_pose_crud,
)
from app.crud import CameraCRUD
from app.crud.crud_occlusion_mask import OcclusionMaskCRUD
from app.crud.crud_pose import PoseCRUD
from app.models import Camera, OcclusionMask, Pose, UserRole
from app.schemas.login import TokenPayload
from app.schemas.occlusion_masks import (
OcclusionMaskCreate,
OcclusionMaskRead,
OcclusionMaskUpdate,
)
from app.services.telemetry import telemetry_client

router = APIRouter()

FLOAT_PATTERN = r"(0?\.[0-9]{1,3}|0|1)"
MASK_PATTERN = rf"^\({FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN},{FLOAT_PATTERN}\)$"
mask_regex = re.compile(MASK_PATTERN)


def validate_mask(mask: str) -> None:
if not mask_regex.match(mask):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=("Invalid mask format. Expected: (xmin, ymin, xmax, ymax) with float values in [0,1]."),
)


@router.post(
"/",
status_code=status.HTTP_201_CREATED,
summary="Create an occlusion mask",
)
async def create_mask(
payload: OcclusionMaskCreate = Body(...),
poses: PoseCRUD = Depends(get_pose_crud),
cameras: CameraCRUD = Depends(get_camera_crud),
masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud),
token_payload: TokenPayload = Security(
get_jwt,
scopes=[UserRole.ADMIN, UserRole.AGENT],
),
) -> OcclusionMaskRead:
# Validate mask format
validate_mask(payload.mask)

pose = cast(Pose, await poses.get(payload.pose_id, strict=True))
camera = cast(Camera, await cameras.get(pose.camera_id, strict=True))

if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")

telemetry_client.capture(
token_payload.sub,
event="occlusion_masks-create",
properties={"pose_id": payload.pose_id},
)

db_obj = await masks.create(payload)
return OcclusionMaskRead(**db_obj.model_dump())


@router.get(
"/{mask_id}",
status_code=status.HTTP_200_OK,
summary="Get info about an occlusion mask",
)
async def get_mask(
mask_id: int = Path(..., gt=0),
masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud),
poses: PoseCRUD = Depends(get_pose_crud),
cameras: CameraCRUD = Depends(get_camera_crud),
token_payload: TokenPayload = Security(
get_jwt,
scopes=[UserRole.ADMIN, UserRole.AGENT],
),
) -> OcclusionMaskRead:
mask = cast(OcclusionMask, await masks.get(mask_id, strict=True))
pose = cast(Pose, await poses.get(mask.pose_id, strict=True))
camera = cast(Camera, await cameras.get(pose.camera_id, strict=True))

if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")

telemetry_client.capture(
token_payload.sub,
event="occlusion_masks-get",
properties={"mask_id": mask_id},
)

return OcclusionMaskRead(**mask.model_dump())


@router.patch(
"/{mask_id}",
status_code=status.HTTP_200_OK,
summary="Update an occlusion mask",
)
async def update_mask(
mask_id: int = Path(..., gt=0),
payload: OcclusionMaskUpdate = Body(...),
masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud),
poses: PoseCRUD = Depends(get_pose_crud),
cameras: CameraCRUD = Depends(get_camera_crud),
token_payload: TokenPayload = Security(
get_jwt,
scopes=[UserRole.ADMIN, UserRole.AGENT],
),
) -> OcclusionMaskRead:
# Validate mask format
validate_mask(payload.mask)

mask = cast(OcclusionMask, await masks.get(mask_id, strict=True))
pose = cast(Pose, await poses.get(mask.pose_id, strict=True))
camera = cast(Camera, await cameras.get(pose.camera_id, strict=True))

if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")

telemetry_client.capture(
token_payload.sub,
event="occlusion_masks-update",
properties={"mask_id": mask_id},
)

db_obj = await masks.update(mask_id, payload)
return OcclusionMaskRead(**db_obj.model_dump())


@router.delete(
"/{mask_id}",
status_code=status.HTTP_200_OK,
summary="Delete an occlusion mask",
)
async def delete_mask(
mask_id: int = Path(..., gt=0),
masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud),
poses: PoseCRUD = Depends(get_pose_crud),
cameras: CameraCRUD = Depends(get_camera_crud),
token_payload: TokenPayload = Security(
get_jwt,
scopes=[UserRole.ADMIN, UserRole.AGENT],
),
) -> None:
mask = cast(OcclusionMask, await masks.get(mask_id, strict=True))
pose = cast(Pose, await poses.get(mask.pose_id, strict=True))
camera = cast(Camera, await cameras.get(pose.camera_id, strict=True))

if UserRole.ADMIN not in token_payload.scopes and token_payload.organization_id != camera.organization_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")

telemetry_client.capture(
token_payload.sub,
event="occlusion_masks-delete",
properties={"mask_id": mask_id},
)

await masks.delete(mask_id)
33 changes: 30 additions & 3 deletions src/app/api/api_v1/endpoints/poses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@

# This program is licensed under the Apache License 2.0.
# See LICENSE or go to <https://www.apache.org/licenses/LICENSE-2.0> for full license details.
from typing import cast
from typing import List, cast

from fastapi import APIRouter, Body, Depends, HTTPException, Path, Security, status

from app.api.dependencies import get_camera_crud, get_jwt, get_pose_crud
from app.api.dependencies import get_camera_crud, get_jwt, get_occlusion_mask_crud, get_pose_crud
from app.crud import CameraCRUD
from app.crud.crud_occlusion_mask import OcclusionMaskCRUD
from app.crud.crud_pose import PoseCRUD
from app.models import Camera, Pose, UserRole
from app.models import Camera, Pose, Role, UserRole
from app.schemas.login import TokenPayload
from app.schemas.occlusion_masks import OcclusionMaskRead
from app.schemas.poses import PoseCreate, PoseRead, PoseUpdate
from app.services.telemetry import telemetry_client

Expand Down Expand Up @@ -83,3 +85,28 @@ async def delete_pose(
) -> None:
telemetry_client.capture(token_payload.sub, event="poses-deletion", properties={"pose_id": pose_id})
await poses.delete(pose_id)


@router.get(
"/{pose_id}/occlusion_masks",
status_code=status.HTTP_200_OK,
summary="List occlusion masks for a pose",
)
async def list_pose_masks(
pose_id: int = Path(..., gt=0),
masks: OcclusionMaskCRUD = Depends(get_occlusion_mask_crud),
poses: PoseCRUD = Depends(get_pose_crud),
cameras: CameraCRUD = Depends(get_camera_crud),
token_payload: TokenPayload = Security(
get_jwt, scopes=[UserRole.ADMIN, UserRole.AGENT, UserRole.USER, Role.CAMERA]
),
) -> List[OcclusionMaskRead]:
telemetry_client.capture(token_payload.sub, event="occlusion_masks-list", properties={"pose_id": pose_id})
pose = cast(Pose, await poses.get(pose_id, strict=True))
camera = cast(Camera, await cameras.get(pose.camera_id, strict=True))

if token_payload.organization_id != camera.organization_id and UserRole.ADMIN not in token_payload.scopes:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access forbidden.")

rows = await masks.get_by_pose(pose_id)
return [OcclusionMaskRead(**row.model_dump()) for row in rows]
13 changes: 12 additions & 1 deletion src/app/api/api_v1/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,24 @@

from fastapi import APIRouter

from app.api.api_v1.endpoints import cameras, detections, login, organizations, poses, sequences, users, webhooks
from app.api.api_v1.endpoints import (
cameras,
detections,
login,
occlusion_masks,
organizations,
poses,
sequences,
users,
webhooks,
)

api_router = APIRouter(redirect_slashes=True)
api_router.include_router(login.router, prefix="/login", tags=["login"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(cameras.router, prefix="/cameras", tags=["cameras"])
api_router.include_router(poses.router, prefix="/poses", tags=["poses"])
api_router.include_router(occlusion_masks.router, prefix="/occlusion_masks", tags=["occlusion_masks"])
api_router.include_router(detections.router, prefix="/detections", tags=["detections"])
api_router.include_router(sequences.router, prefix="/sequences", tags=["sequences"])
api_router.include_router(organizations.router, prefix="/organizations", tags=["organizations"])
Expand Down
5 changes: 5 additions & 0 deletions src/app/api/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

from app.core.config import settings
from app.crud import CameraCRUD, DetectionCRUD, OrganizationCRUD, SequenceCRUD, UserCRUD, WebhookCRUD
from app.crud.crud_occlusion_mask import OcclusionMaskCRUD
from app.crud.crud_pose import PoseCRUD
from app.db import get_session
from app.models import User, UserRole
Expand Down Expand Up @@ -49,6 +50,10 @@ def get_pose_crud(session: AsyncSession = Depends(get_session)) -> PoseCRUD:
return PoseCRUD(session=session)


def get_occlusion_mask_crud(session: AsyncSession = Depends(get_session)) -> OcclusionMaskCRUD:
return OcclusionMaskCRUD(session=session)


def get_detection_crud(session: AsyncSession = Depends(get_session)) -> DetectionCRUD:
return DetectionCRUD(session=session)

Expand Down
Loading
Loading