Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 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
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
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
c321694
update client tests
fe51 Dec 19, 2025
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
80 changes: 74 additions & 6 deletions client/pyroclient/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# See LICENSE or go to <https://opensource.org/licenses/Apache-2.0> for full license details.

from enum import Enum
from typing import Dict, List, Tuple
from typing import Dict, List, Optional, Tuple
from urllib.parse import urljoin

import requests
Expand All @@ -22,6 +22,9 @@ class ClientRoute(str, Enum):
CAMERAS_HEARTBEAT = "cameras/heartbeat"
CAMERAS_IMAGE = "cameras/image"
CAMERAS_FETCH = "cameras/"
# POSES
POSES_CREATE = "poses/"
POSES_BY_ID = "poses/{pose_id}"
# DETECTIONS
DETECTIONS_CREATE = "detections/"
DETECTIONS_FETCH = "detections"
Expand Down Expand Up @@ -148,37 +151,102 @@ def update_last_image(self, media: bytes) -> Response:
timeout=self.timeout,
)

# POSES
def create_pose(
self,
camera_id: int,
azimuth: float,
patrol_id: int | None = None,
) -> Response:
"""Create a pose for a camera

>>> api_client.create_pose(camera_id=1, azimuth=120.5, patrol_id=3)
"""
payload = {
"camera_id": camera_id,
"azimuth": azimuth,
}
if patrol_id is not None:
payload["patrol_id"] = patrol_id

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

def patch_pose(
self,
pose_id: int,
azimuth: float | None = None,
patrol_id: int | None = None,
) -> Response:
"""Update a pose

>>> api_client.patch_pose(pose_id=1, azimuth=90.0)
"""
payload = {}
if azimuth is not None:
payload["azimuth"] = azimuth
if patrol_id is not None:
payload["patrol_id"] = patrol_id

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

def delete_pose(self, pose_id: int) -> Response:
"""Delete a pose

>>> api_client.delete_pose(pose_id=1)
"""
return requests.delete(
urljoin(self._route_prefix, ClientRoute.POSES_BY_ID.format(pose_id=pose_id)),
headers=self.headers,
timeout=self.timeout,
)

# DETECTIONS

def create_detection(
self,
media: bytes,
azimuth: float,
bboxes: List[Tuple[float, float, float, float, float]],
pose_id: Optional[int] = None,
) -> Response:
"""Notify the detection of a wildfire on the picture taken by a camera.

>>> from pyroclient import Client
>>> api_client = Client("MY_CAM_TOKEN")
>>> with open("path/to/my/file.ext", "rb") as f: data = f.read()
>>> response = api_client.create_detection(data, azimuth=124.2, bboxes=[(.1,.1,.5,.8,.5)])
>>> response = api_client.create_detection(data, azimuth=124.2, bboxes=[(.1,.1,.5,.8,.5)], pose_id=12)

Args:
media: byte data of the picture
azimuth: the azimuth of the camera when the picture was taken
bboxes: list of tuples where each tuple is a relative coordinate in order xmin, ymin, xmax, ymax, conf
pose_id: optional, pose_id of the detection

Returns:
HTTP response
"""
if not isinstance(bboxes, (list, tuple)) or len(bboxes) == 0 or len(bboxes) > 5:
raise ValueError("bboxes must be a non-empty list of tuples with a maximum of 5 boxes")
data = {
"azimuth": azimuth,
"bboxes": _dump_bbox_to_json(bboxes),
}
if pose_id is not None:
data["pose_id"] = pose_id
return requests.post(
urljoin(self._route_prefix, ClientRoute.DETECTIONS_CREATE),
headers=self.headers,
data={
"azimuth": azimuth,
"bboxes": _dump_bbox_to_json(bboxes),
},
data=data,
timeout=self.timeout,
files={"file": ("logo.png", media, "image/png")},
)
Expand Down
5 changes: 5 additions & 0 deletions client/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,11 @@ def cam_token():
response = requests.post(urljoin(API_URL, "cameras"), json=payload, headers=admin_headers, timeout=5)
assert response.status_code == 201
cam_id = response.json()["id"]
# create a pose related to the cam
payload = {"azimuth": 359, "patrol_id": 1, "camera_id": cam_id}
response = requests.post(urljoin(API_URL, "poses"), json=payload, headers=admin_headers, timeout=5)
assert response.status_code == 201

# Create a cam token
return requests.post(urljoin(API_URL, f"cameras/{cam_id}/token"), headers=admin_headers, timeout=5).json()[
"access_token"
Expand Down
2 changes: 1 addition & 1 deletion client/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def test_cam_workflow(cam_token, mock_img):
cam_client.create_detection(mock_img, 123.2, None)
with pytest.raises(ValueError, match="bboxes must be a non-empty list of tuples"):
cam_client.create_detection(mock_img, 123.2, [])
response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)])
response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5)], pose_id=1)
assert response.status_code == 201, response.__dict__
response = cam_client.create_detection(mock_img, 123.2, [(0, 0, 1.0, 0.9, 0.5), (0.2, 0.2, 0.7, 0.7, 0.8)])
assert response.status_code == 201, response.__dict__
Expand Down
55 changes: 40 additions & 15 deletions scripts/dbdiagram.txt
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,37 @@ Enum "userrole" {
"user"
}

Table "User" as U {
Enum "annotationtype" {
"wildfire_smoke"
"other_smoke"
"other"
}

Table "organizations" as O {
"id" int [not null]
"name" varchar [not null]
"telegram_id" varchar
"slack_hook" varchar

Indexes {
(id) [pk]
}
}

Table "users" as U {
"id" int [not null]
"organization_id" int [ref: > O.id, not null]
"role" userrole [not null]
"login" varchar [not null]
"hashed_password" varchar [not null]
"created_at" timestamp [not null]

Indexes {
(id, login) [pk]
}
}

Table "Camera" as C {
Table "cameras" as C {
"id" int [not null]
"organization_id" int [ref: > O.id, not null]
"name" varchar [not null]
Expand All @@ -28,49 +46,56 @@ Table "Camera" as C {
"created_at" timestamp [not null]
"last_active_at" timestamp
"last_image" varchar

Indexes {
(id) [pk]
}
}

Table "poses" as P {
"id" int [not null]
"camera_id" int [ref: > C.id, not null]
"azimuth" float [not null]
"patrol_id" int

Indexes {
(id) [pk]
}
}

Table "Sequence" as S {
Table "sequences" as S {
"id" int [not null]
"camera_id" int [ref: > C.id, not null]
"pose_id" int [ref: > P.id]
"azimuth" float [not null]
"is_wildfire" AnnotationType
"is_wildfire" annotationtype
"started_at" timestamp [not null]
"last_seen_at" timestamp [not null]

Indexes {
(id) [pk]
}
}

Table "Detection" as D {
Table "detections" as D {
"id" int [not null]
"camera_id" int [ref: > C.id, not null]
"pose_id" int [ref: > P.id]
"sequence_id" int [ref: > S.id]
"azimuth" float [not null]
"bucket_key" varchar [not null]
"bboxes" varchar [not null]
"created_at" timestamp [not null]
Indexes {
(id) [pk]
}
}

Table "Organization" as O {
"id" int [not null]
"name" varchar [not null]
"telegram_id" varchar
Indexes {
(id) [pk]
}
}


Table "Webhook" as W {
Table "webhooks" as W {
"id" int [not null]
"url" varchar [not null]

Indexes {
(id) [pk]
}
Expand Down
14 changes: 11 additions & 3 deletions scripts/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ def main(args):

cam_auth = {"Authorization": f"Bearer {cam_token}"}

# Create a camera pose
payload = {
"camera_id": cam_id,
"azimuth": 45,
}
pose_id = api_request("post", f"{args.endpoint}/poses/", agent_auth, payload)["id"]

# Take a picture
file_bytes = requests.get("https://pyronear.org/img/logo.png", timeout=5).content
# Update cam last image
Expand All @@ -110,7 +117,7 @@ def main(args):
response = requests.post(
f"{args.endpoint}/detections",
headers=cam_auth,
data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]"},
data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]", "pose_id": pose_id},
files={"file": ("logo.png", file_bytes, "image/png")},
timeout=5,
)
Expand All @@ -126,14 +133,14 @@ def main(args):
det_id_2 = requests.post(
f"{args.endpoint}/detections",
headers=cam_auth,
data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]"},
data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]", "pose_id": pose_id},
files={"file": ("logo.png", file_bytes, "image/png")},
timeout=5,
).json()["id"]
det_id_3 = requests.post(
f"{args.endpoint}/detections",
headers=cam_auth,
data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]"},
data={"azimuth": 45.6, "bboxes": "[(0.1,0.1,0.8,0.8,0.5)]", "pose_id": pose_id},
files={"file": ("logo.png", file_bytes, "image/png")},
timeout=5,
).json()["id"]
Expand Down Expand Up @@ -173,6 +180,7 @@ def main(args):
api_request("delete", f"{args.endpoint}/detections/{det_id_2}/", superuser_auth)
api_request("delete", f"{args.endpoint}/detections/{det_id_3}/", superuser_auth)
api_request("delete", f"{args.endpoint}/sequences/{sequence['id']}/", superuser_auth)
api_request("delete", f"{args.endpoint}/poses/{pose_id}/", superuser_auth)
api_request("delete", f"{args.endpoint}/cameras/{cam_id}/", superuser_auth)
api_request("delete", f"{args.endpoint}/users/{user_id}/", superuser_auth)
api_request("delete", f"{args.endpoint}/organizations/{org_id}/", superuser_auth)
Expand Down
Loading
Loading