Skip to content

Commit 9593f79

Browse files
ThbltLmrm-julio
authored andcommitted
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 15e1cf7 commit 9593f79

File tree

3 files changed

+100
-175
lines changed

3 files changed

+100
-175
lines changed

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/occlusionMasks.ts

Lines changed: 28 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import type { DetectionType } from '../services/alerts';
2-
3-
export type OcclusionMask = Record<string, number[]>; // [xmin, ymin, xmax, ymax, confidence]
2+
import type { OcclusionMaskApiType } from '../services/occlusionMasks';
43

54
export interface BboxType {
65
xmin: number;
@@ -10,28 +9,28 @@ export interface BboxType {
109
confidence: number;
1110
}
1211

13-
/**
14-
* Get the camera prefix name from the camera name
15-
* Example: "nemours-01" -> "nemours"
16-
*/
17-
export const getCameraPrefixName = (cameraName: string): string => {
18-
const lastDashIndex = cameraName.lastIndexOf('-');
19-
if (lastDashIndex === -1) {
20-
return cameraName;
21-
}
22-
return cameraName.substring(0, lastDashIndex);
12+
export const parseApiMask = (mask: string): BboxType | null => {
13+
const match = /^\(([^)]+)\)$/.exec(mask);
14+
if (!match) return null;
15+
16+
const values = match[1].split(',').map((v) => parseFloat(v.trim()));
17+
if (values.length !== 4 || values.some(isNaN)) return null;
18+
19+
return {
20+
xmin: values[0],
21+
ymin: values[1],
22+
xmax: values[2],
23+
ymax: values[3],
24+
confidence: 0,
25+
};
2326
};
2427

2528
/**
26-
* Generate the occlusion mask file key for localStorage
27-
* Format: "CAMERA_PREFIX_NAME" + "_" + "CAMERA_ANGLE_OF_VIEW"
29+
* API validates with a regex that only allows 3 decimals
2830
*/
29-
export const getOcclusionMaskKey = (
30-
cameraName: string,
31-
angleOfView: number
32-
): string => {
33-
const prefixName = getCameraPrefixName(cameraName);
34-
return `${prefixName}_${angleOfView}`;
31+
export const formatBboxToApiMask = (bbox: BboxType): string => {
32+
const round = (v: number) => parseFloat(v.toFixed(3));
33+
return `(${round(bbox.xmin)},${round(bbox.ymin)},${round(bbox.xmax)},${round(bbox.ymax)})`;
3534
};
3635

3736
/**
@@ -153,25 +152,19 @@ export const doBboxesOverlap = (bbox1: BboxType, bbox2: BboxType): boolean => {
153152
);
154153
};
155154

156-
/**
157-
* Get non-overlapping occlusion masks (most recent takes precedence)
158-
*/
159-
export const getNonOverlappingMasks = (masks: OcclusionMask): BboxType[] => {
160-
const maskEntries = Object.entries(masks).sort(
161-
([timestampA], [timestampB]) =>
162-
new Date(timestampB).getTime() - new Date(timestampA).getTime()
155+
export const getNonOverlappingMasks = (
156+
masks: OcclusionMaskApiType[]
157+
): BboxType[] => {
158+
const sorted = [...masks].sort(
159+
(a, b) =>
160+
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
163161
);
164162

165163
const result: BboxType[] = [];
166164

167-
maskEntries.forEach(([, bboxArray]) => {
168-
const bbox: BboxType = {
169-
xmin: bboxArray[0],
170-
ymin: bboxArray[1],
171-
xmax: bboxArray[2],
172-
ymax: bboxArray[3],
173-
confidence: bboxArray[4],
174-
};
165+
sorted.forEach((apiMask) => {
166+
const bbox = parseApiMask(apiMask.mask);
167+
if (!bbox) return;
175168

176169
const overlaps = result.some((existingBbox) =>
177170
doBboxesOverlap(bbox, existingBbox)

0 commit comments

Comments
 (0)