Skip to content

Commit 27c5970

Browse files
authored
feat: migrate occlusion masks to backend API (#184)
* feat: add poseId to SequenceWithCameraInfoType Needed for the occlusion mask API migration which uses pose_id to fetch and create masks. * feat: migrate occlusion masks from localStorage to backend API Replace localStorage-based occlusion masks with backend API calls: - GET/POST/DELETE via /api/v1/poses/{id}/occlusion_masks - New parseApiMask/formatBboxToApiMask utils for API mask format - Round coordinates to 3 decimal places (API validation constraint) - OcclusionMaskModal now uses poseId instead of camera name/angle
1 parent cfce8bc commit 27c5970

6 files changed

Lines changed: 106 additions & 177 deletions

File tree

src/components/Alerts/OcclusionMaskModal/OcclusionMaskModal.tsx

Lines changed: 19 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,15 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
1313

1414
import { getDetectionsBySequence } from '@/services/alerts';
1515
import {
16-
addOcclusionMask,
17-
clearOcclusionMasks,
18-
getOcclusionMasks,
16+
createOcclusionMask,
17+
deleteAllOcclusionMasksByPose,
18+
getOcclusionMasksByPose,
1919
} from '@/services/occlusionMasks';
2020
import type { SequenceWithCameraInfoType } from '@/utils/alerts';
2121
import {
2222
type BboxType,
2323
enlargeBbox,
24+
formatBboxToApiMask,
2425
getHighestConfidenceBbox,
2526
getHighestConfidenceDetection,
2627
getNonOverlappingMasks,
@@ -81,72 +82,34 @@ export const OcclusionMaskModal = ({
8182

8283
// Query for existing occlusion masks
8384
const { data: existingMasks = [], isLoading: isLoadingMasks } = useQuery({
84-
queryKey: [
85-
'occlusionMasks',
86-
sequence.camera?.name,
87-
sequence.camera?.angle_of_view,
88-
],
89-
queryFn: () => {
90-
if (!sequence.camera?.name || sequence.camera.angle_of_view === null) {
91-
return [];
92-
}
93-
const masks = getOcclusionMasks(
94-
sequence.camera.name,
95-
sequence.camera.angle_of_view
96-
);
85+
queryKey: ['occlusionMasks', sequence.poseId],
86+
queryFn: async () => {
87+
if (!sequence.poseId) return [];
88+
const masks = await getOcclusionMasksByPose(sequence.poseId);
9789
return getNonOverlappingMasks(masks);
9890
},
99-
enabled:
100-
open && !!sequence.camera?.name && sequence.camera.angle_of_view !== null,
91+
enabled: open && !!sequence.poseId,
10192
});
10293

10394
// Mutation for adding occlusion mask
10495
const addMaskMutation = useMutation({
105-
mutationFn: ({
106-
cameraName,
107-
angleOfView,
108-
bbox,
109-
}: {
110-
cameraName: string;
111-
angleOfView: number;
112-
bbox: BboxType;
113-
}) => {
114-
addOcclusionMask(cameraName, angleOfView, bbox);
115-
return Promise.resolve();
116-
},
96+
mutationFn: ({ poseId, bbox }: { poseId: number; bbox: BboxType }) =>
97+
createOcclusionMask(poseId, formatBboxToApiMask(bbox)),
11798
onSuccess: () => {
118-
// Invalidate and refetch occlusion masks
11999
void queryClient.invalidateQueries({
120-
queryKey: [
121-
'occlusionMasks',
122-
sequence.camera?.name,
123-
sequence.camera?.angle_of_view,
124-
],
100+
queryKey: ['occlusionMasks', sequence.poseId],
125101
});
126102
onClose();
127103
},
128104
});
129105

130106
// Mutation for clearing all masks
131107
const clearMasksMutation = useMutation({
132-
mutationFn: ({
133-
cameraName,
134-
angleOfView,
135-
}: {
136-
cameraName: string;
137-
angleOfView: number;
138-
}) => {
139-
clearOcclusionMasks(cameraName, angleOfView);
140-
return Promise.resolve();
141-
},
108+
mutationFn: ({ poseId }: { poseId: number }) =>
109+
deleteAllOcclusionMasksByPose(poseId),
142110
onSuccess: () => {
143-
// Invalidate and refetch occlusion masks
144111
void queryClient.invalidateQueries({
145-
queryKey: [
146-
'occlusionMasks',
147-
sequence.camera?.name,
148-
sequence.camera?.angle_of_view,
149-
],
112+
queryKey: ['occlusionMasks', sequence.poseId],
150113
});
151114
},
152115
});
@@ -161,29 +124,19 @@ export const OcclusionMaskModal = ({
161124
: null;
162125

163126
const handleConfirmSelection = () => {
164-
if (
165-
!proposedBbox ||
166-
!sequence.camera?.name ||
167-
sequence.camera.angle_of_view === null
168-
) {
169-
return;
170-
}
127+
if (!proposedBbox || !sequence.poseId) return;
171128

172129
addMaskMutation.mutate({
173-
cameraName: sequence.camera.name,
174-
angleOfView: sequence.camera.angle_of_view,
130+
poseId: sequence.poseId,
175131
bbox: proposedBbox,
176132
});
177133
};
178134

179135
const handleDeleteAll = () => {
180-
if (!sequence.camera?.name || sequence.camera.angle_of_view === null) {
181-
return;
182-
}
136+
if (!sequence.poseId) return;
183137

184138
clearMasksMutation.mutate({
185-
cameraName: sequence.camera.name,
186-
angleOfView: sequence.camera.angle_of_view,
139+
poseId: sequence.poseId,
187140
});
188141
};
189142

src/services/occlusionMasks.ts

Lines changed: 53 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,87 +1,66 @@
1-
import type { BboxType, OcclusionMask } from '../utils/occlusionMasks';
2-
import { getOcclusionMaskKey } from '../utils/occlusionMasks';
1+
import type { AxiosResponse } from 'axios';
2+
import * as z from 'zod/v4';
33

4-
/**
5-
* Get occlusion masks from localStorage for a specific camera and angle of view
6-
*/
7-
export const getOcclusionMasks = (
8-
cameraName: string,
9-
angleOfView: number
10-
): OcclusionMask => {
11-
const key = getOcclusionMaskKey(cameraName, angleOfView);
12-
const stored = localStorage.getItem(`occlusion_mask_${key}`);
4+
import { apiInstance } from './axios';
135

14-
if (!stored) {
15-
return {};
16-
}
6+
const occlusionMaskReadSchema = z.object({
7+
id: z.number(),
8+
pose_id: z.number(),
9+
mask: z.string(),
10+
created_at: z.iso.datetime({ local: true }),
11+
});
1712

18-
try {
19-
const masks = JSON.parse(stored) as OcclusionMask;
13+
export type OcclusionMaskApiType = z.infer<typeof occlusionMaskReadSchema>;
2014

21-
// Filter out masks older than 120 days
22-
const cutoffDate = new Date();
23-
cutoffDate.setDate(cutoffDate.getDate() - 120);
15+
const occlusionMaskListResponseSchema = z.array(occlusionMaskReadSchema);
2416

25-
const filteredMasks: OcclusionMask = {};
26-
Object.entries(masks).forEach(([timestamp, bbox]) => {
27-
const maskDate = new Date(timestamp);
28-
if (maskDate >= cutoffDate) {
29-
filteredMasks[timestamp] = bbox;
30-
}
17+
export const getOcclusionMasksByPose = async (
18+
poseId: number
19+
): Promise<OcclusionMaskApiType[]> => {
20+
return apiInstance
21+
.get(`/api/v1/poses/${poseId.toString()}/occlusion_masks`)
22+
.then((response: AxiosResponse) => {
23+
const result = occlusionMaskListResponseSchema.safeParse(response.data);
24+
return result.data ?? [];
25+
})
26+
.catch((err: unknown) => {
27+
console.error(err);
28+
throw err;
3129
});
32-
33-
return filteredMasks;
34-
} catch (error) {
35-
console.error('Error parsing occlusion masks from localStorage:', error);
36-
return {};
37-
}
3830
};
3931

40-
/**
41-
* Save occlusion masks to localStorage for a specific camera and angle of view
42-
*/
43-
export const saveOcclusionMasks = (
44-
cameraName: string,
45-
angleOfView: number,
46-
masks: OcclusionMask
47-
): void => {
48-
const key = getOcclusionMaskKey(cameraName, angleOfView);
49-
50-
try {
51-
localStorage.setItem(`occlusion_mask_${key}`, JSON.stringify(masks));
52-
} catch (error) {
53-
console.error('Error saving occlusion masks to localStorage:', error);
54-
}
32+
export const createOcclusionMask = async (
33+
poseId: number,
34+
mask: string
35+
): Promise<OcclusionMaskApiType> => {
36+
return apiInstance
37+
.post('/api/v1/occlusion_masks/', { pose_id: poseId, mask })
38+
.then((response: AxiosResponse) => {
39+
const result = occlusionMaskReadSchema.safeParse(response.data);
40+
if (!result.success) {
41+
throw new Error('INVALID_API_RESPONSE');
42+
}
43+
return result.data;
44+
})
45+
.catch((err: unknown) => {
46+
console.error(err);
47+
throw err;
48+
});
5549
};
5650

57-
/**
58-
* Add a new occlusion mask
59-
*/
60-
export const addOcclusionMask = (
61-
cameraName: string,
62-
angleOfView: number,
63-
bbox: BboxType
64-
): void => {
65-
const currentMasks = getOcclusionMasks(cameraName, angleOfView);
66-
const timestamp = new Date().toISOString().replace('T', ' ').substring(0, 19);
67-
68-
currentMasks[timestamp] = [
69-
bbox.xmin,
70-
bbox.ymin,
71-
bbox.xmax,
72-
bbox.ymax,
73-
bbox.confidence,
74-
];
75-
76-
saveOcclusionMasks(cameraName, angleOfView, currentMasks);
51+
export const deleteOcclusionMask = async (maskId: number): Promise<void> => {
52+
return apiInstance
53+
.delete(`/api/v1/occlusion_masks/${maskId.toString()}`)
54+
.then(() => undefined)
55+
.catch((err: unknown) => {
56+
console.error(err);
57+
throw err;
58+
});
7759
};
7860

79-
/**
80-
* Clear all occlusion masks for a specific camera and angle of view
81-
*/
82-
export const clearOcclusionMasks = (
83-
cameraName: string,
84-
angleOfView: number
85-
): void => {
86-
saveOcclusionMasks(cameraName, angleOfView, {});
61+
export const deleteAllOcclusionMasksByPose = async (
62+
poseId: number
63+
): Promise<void> => {
64+
const masks = await getOcclusionMasksByPose(poseId);
65+
await Promise.all(masks.map((mask) => deleteOcclusionMask(mask.id)));
8766
};

src/utils/alerts.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,16 +84,17 @@ describe('extractCameraListFromAlert', () => {
8484
sequences: [
8585
{
8686
id: 1,
87+
poseId: null,
8788
camera: camera1,
8889
lastSeenAt: null,
8990
azimuth: 0,
90-
9191
coneAngle: 0,
9292
labelWildfire: null,
9393
startedAt: null,
9494
},
9595
{
9696
id: 2,
97+
poseId: null,
9798
camera: camera2,
9899
lastSeenAt: null,
99100
azimuth: 0,
@@ -144,7 +145,6 @@ describe('hasNewSequenceSince', () => {
144145
[
145146
{
146147
id: 1,
147-
148148
started_at: '2025-02-25T05:37:03',
149149
sequences: [],
150150
organization_id: 0,

src/utils/alerts.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export type LabelWildfireValues =
1919

2020
export interface SequenceWithCameraInfoType {
2121
id: number;
22+
poseId: number | null;
2223
camera: CameraType | null;
2324
startedAt: string | null;
2425
lastSeenAt: string | null;
@@ -46,6 +47,7 @@ export const mapAlertTypeApiToAlertType = (
4647
.sort((s1, s2) => (getDateOrNowNb(s1) > getDateOrNowNb(s2) ? 1 : -1))
4748
.map((sequence) => ({
4849
id: sequence.id,
50+
poseId: sequence.pose_id,
4951
camera:
5052
camerasList.find((camera) => camera.id == sequence.camera_id) ??
5153
null,

src/utils/live.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ describe('getMoveToAzimuthFromAlert', () => {
9090
startedAt: null,
9191
lastSeenAt: null,
9292
azimuth: 0,
93+
poseId: null,
9394
coneAngle: 0,
9495
labelWildfire: null,
9596
},
@@ -113,6 +114,7 @@ describe('getMoveToAzimuthFromAlert', () => {
113114
startedAt: null,
114115
lastSeenAt: null,
115116
azimuth: 5,
117+
poseId: null,
116118
coneAngle: 0,
117119
labelWildfire: null,
118120
},

0 commit comments

Comments
 (0)