Skip to content
12 changes: 9 additions & 3 deletions projects/web-backend/src/vision/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
get_archive_object,
get_archive_records_for_date,
get_odlc_session,
patch_odlc_record,
get_odlc_session_index,
odlc_record,
post_odlc_record,
)

Expand All @@ -20,15 +21,20 @@
urlpatterns = [
path("", include(router.urls)),
path("odlc-session/<str:session_id>/", get_odlc_session, name="get_odlc_session"),
path(
"odlc-session/<str:session_id>/index/",
get_odlc_session_index,
name="get_odlc_session_index",
),
path(
"odlc-session/<str:session_id>/records/",
post_odlc_record,
name="post_odlc_record",
),
path(
"odlc-session/<str:session_id>/records/<str:record_id>/",
patch_odlc_record,
name="patch_odlc_record",
odlc_record,
name="odlc_record",
),
path("deproject-pixel/", deproject_pixel, name="deproject_pixel"),
path("archive/dates/", get_archive_dates, name="get_archive_dates"),
Expand Down
162 changes: 121 additions & 41 deletions projects/web-backend/src/vision/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,6 @@
download_archive_object,
list_archive_dates,
list_archive_records,
upload_odlc_depth,
upload_odlc_image,
upload_odlc_metadata,
)

ODLC_SESSIONS_DIR = Path(__file__).resolve().parent.parent.parent / "odlc_sessions"
Expand All @@ -29,6 +26,26 @@ def _session_path(session_id: str) -> Path:
return ODLC_SESSIONS_DIR / f"{session_id}.json"


def _index_path(session_id: str) -> Path:
return ODLC_SESSIONS_DIR / f"{session_id}.index.json"


def _read_index(session_id: str) -> list[dict] | None:
path = _index_path(session_id)
if not path.exists():
return None
return json.loads(path.read_text())


def _write_index(session_id: str, records: list[dict]) -> None:
entries = [
{"id": r["id"], "receivedAt": r.get("receivedAt", 0)}
for r in records
if r.get("id") and r.get("receivedAt")
]
_index_path(session_id).write_text(json.dumps(entries))


def _read_session(session_id: str) -> dict | None:
path = _session_path(session_id)
if not path.exists():
Expand All @@ -39,6 +56,7 @@ def _read_session(session_id: str) -> dict | None:
def _write_session(session_id: str, data: dict) -> None:
ODLC_SESSIONS_DIR.mkdir(exist_ok=True)
_session_path(session_id).write_text(json.dumps(data))
_write_index(session_id, data.get("records", []))


def _empty_session(session_id: str) -> dict:
Expand Down Expand Up @@ -97,6 +115,20 @@ def get_odlc_session(request, session_id: str):
data = _read_session(session_id)
if data is None:
return JsonResponse({"error": "Session not found"}, status=404)
since_raw = request.GET.get("since")
if since_raw is not None:
try:
since = int(since_raw)
data = {
**data,
"records": [
r
for r in data.get("records", [])
if r.get("receivedAt", 0) > since
],
}
except ValueError:
return JsonResponse({"error": "Invalid 'since' parameter"}, status=400)
return JsonResponse(data)
except Exception as e:
print(f"Error reading ODLC session: {e}")
Expand All @@ -105,6 +137,38 @@ def get_odlc_session(request, session_id: str):
)


@csrf_exempt
@require_http_methods(["GET"])
def get_odlc_session_index(request, session_id: str):
try:
with _SESSION_LOCK:
index = _read_index(session_id)
if index is None:
data = _read_session(session_id)
if data is None:
return JsonResponse({"error": "Session not found"}, status=404)
records = data.get("records", [])
_write_index(session_id, records)
index = [
{"id": r["id"], "receivedAt": r.get("receivedAt", 0)}
for r in records
if r.get("id") and r.get("receivedAt")
]
since_raw = request.GET.get("since")
if since_raw is not None:
try:
since = int(since_raw)
index = [e for e in index if e.get("receivedAt", 0) > since]
except ValueError:
return JsonResponse({"error": "Invalid 'since' parameter"}, status=400)
return JsonResponse({"sessionId": session_id, "records": index})
except Exception as e:
print(f"Error reading ODLC session index: {e}")
return JsonResponse(
{"error": "Internal server error", "details": str(e)}, status=500
)


@csrf_exempt
@require_http_methods(["POST"])
def post_odlc_record(request, session_id: str):
Expand All @@ -113,39 +177,39 @@ def post_odlc_record(request, session_id: str):
if not isinstance(record, dict) or not record.get("id"):
return JsonResponse({"error": "Invalid record"}, status=400)

image = record.get("image") or {}
received_at = record.get("receivedAt")
image_b64 = image.get("image_data")
depth_b64 = image.get("depth_data")
if image_b64 and not image.get("image_url"):
try:
record["image"]["image_url"] = upload_odlc_image(
record["id"], image_b64, received_at
)
except Exception as e:
print(f"S3 image upload failed for record {record['id']}: {e}")
if depth_b64 and not image.get("depth_url"):
try:
record["image"]["depth_url"] = upload_odlc_depth(
record["id"], depth_b64, received_at
)
except Exception as e:
print(f"S3 depth upload failed for record {record['id']}: {e}")
if image:
try:
upload_odlc_metadata(
record["id"],
received_at,
{
"boundingBox": image.get("bounding_box"),
"confidenceLevel": image.get("confidence_level"),
"yawDeg": image.get("yaw_deg"),
"colorDetection": image.get("color_detection"),
"heading": record.get("metadata"),
},
)
except Exception as e:
print(f"S3 metadata upload failed for record {record['id']}: {e}")
# image = record.get("image") or {}
# received_at = record.get("receivedAt")
# image_b64 = image.get("image_data")
# depth_b64 = image.get("depth_data")
# if image_b64 and not image.get("image_url"):
# try:
# record["image"]["image_url"] = upload_odlc_image(
# record["id"], image_b64, received_at
# )
# except Exception as e:
# print(f"S3 image upload failed for record {record['id']}: {e}")
# if depth_b64 and not image.get("depth_url"):
# try:
# record["image"]["depth_url"] = upload_odlc_depth(
# record["id"], depth_b64, received_at
# )
# except Exception as e:
# print(f"S3 depth upload failed for record {record['id']}: {e}")
# if image:
# try:
# upload_odlc_metadata(
# record["id"],
# received_at,
# {
# "boundingBox": image.get("bounding_box"),
# "confidenceLevel": image.get("confidence_level"),
# "yawDeg": image.get("yaw_deg"),
# "colorDetection": image.get("color_detection"),
# "heading": record.get("metadata"),
# },
# )
# except Exception as e:
# print(f"S3 metadata upload failed for record {record['id']}: {e}")

with _SESSION_LOCK:
data = _read_session(session_id) or _empty_session(session_id)
Expand Down Expand Up @@ -221,9 +285,7 @@ def get_archive_object(request):
"""
key = (request.GET.get("key") or "").strip()
if not key:
return JsonResponse(
{"error": "Missing 'key' query parameter"}, status=400
)
return JsonResponse({"error": "Missing 'key' query parameter"}, status=400)
try:
body, content_type = download_archive_object(key)
except Exception as e:
Expand All @@ -238,8 +300,26 @@ def get_archive_object(request):


@csrf_exempt
@require_http_methods(["PATCH"])
def patch_odlc_record(request, session_id: str, record_id: str):
@require_http_methods(["GET", "PATCH"])
def odlc_record(request, session_id: str, record_id: str):
if request.method == "GET":
try:
with _SESSION_LOCK:
data = _read_session(session_id)
if data is None:
return JsonResponse({"error": "Session not found"}, status=404)
record = next(
(r for r in data.get("records", []) if r.get("id") == record_id), None
)
if record is None:
return JsonResponse({"error": "Record not found"}, status=404)
return JsonResponse(record)
except Exception as e:
print(f"Error reading ODLC record: {e}")
return JsonResponse(
{"error": "Internal server error", "details": str(e)}, status=500
)

try:
updates = json.loads(request.body)
if not isinstance(updates, dict):
Expand Down
29 changes: 26 additions & 3 deletions projects/web-frontend/src/api/endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Waypoint } from "../types/Waypoint";
import { Route } from "../types/Route";
import api from "./api";
import { WaypointSchema, RouteSchema, PartialWaypointSchema } from "../schemas/waypoint";
import { OdlcSessionSchema } from "../schemas/odlc";
import { OdlcSessionSchema, OdlcImageRecordSchema } from "../schemas/odlc";
import type { OdlcImageRecord } from "../store/slices/dataSlice";
import type { z } from "zod";

Expand Down Expand Up @@ -105,9 +105,13 @@ export const getDroneParameter = async (

export type OdlcSessionPayload = z.infer<typeof OdlcSessionSchema>;

export const getOdlcSession = async (sessionId: string): Promise<OdlcSessionPayload | null> => {
export const getOdlcSession = async (
sessionId: string,
options?: { since?: number },
): Promise<OdlcSessionPayload | null> => {
try {
const response = await api.get(`/vision/odlc-session/${sessionId}/`);
const params = options?.since !== undefined ? { since: options.since } : undefined;
const response = await api.get(`/vision/odlc-session/${sessionId}/`, { params });
return OdlcSessionSchema.parse(response.data);
} catch (err: unknown) {
if (typeof err === "object" && err !== null && "response" in err) {
Expand All @@ -130,6 +134,25 @@ export const patchOdlcRecord = async (
await api.patch(`/vision/odlc-session/${sessionId}/records/${recordId}/`, updates);
};

export const getNewOdlcRecordIds = async (sessionId: string, since: number): Promise<string[]> => {
const response = await api.get(`/vision/odlc-session/${sessionId}/index/`, { params: { since } });
const data = response.data as { records: { id: string }[] };
return data.records.map((r) => r.id);
};

export const getOdlcRecord = async (sessionId: string, recordId: string): Promise<OdlcImageRecord | null> => {
try {
const response = await api.get(`/vision/odlc-session/${sessionId}/records/${recordId}/`);
return OdlcImageRecordSchema.parse(response.data);
} catch (err: unknown) {
if (typeof err === "object" && err !== null && "response" in err) {
const status = (err as { response?: { status?: number } }).response?.status;
if (status === 404) return null;
}
throw err;
}
};

/**
* Deprojects a 2D pixel coordinate and depth value into a 3D point in camera space.
* Calls the backend which implements the RealSense deprojection formula with
Expand Down
1 change: 0 additions & 1 deletion projects/web-frontend/src/hooks/useWebRTCConnection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,6 @@ export function useWebRTCConnection(): UseWebRTCConnectionResult {
return;
}

console.log("📸 ODLC Image Message Parsed:", parsedMessage);
const result = OdlcImageSchema.safeParse(parsedMessage);
console.log("📸 ODLC Image Validation Result:", result);
if (!result.success) {
Expand Down
34 changes: 33 additions & 1 deletion projects/web-frontend/src/routes/OdlcImages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ import {
} from "../store/slices/dataSlice";
import ImageAnnotationOverlay from "../components/ImageAnnotationOverlay";
import SessionIdDisplay from "../components/SessionIdDisplay";
import { syncOdlcImageToBackend, appendOdlcImageFromAircraft } from "../store/thunks/odlcSessionThunks";
import {
syncOdlcImageToBackend,
appendOdlcImageFromAircraft,
pollOdlcSession,
} from "../store/thunks/odlcSessionThunks";
import type { OdlcImageRecord } from "../store/slices/dataSlice";
import { classifyOdlcColor, ODLC_COLORS, ODLC_COLOR_LABELS } from "../utils/odlcColorGroup";
import type { OdlcColor, RGB } from "../utils/odlcColorGroup";
Expand Down Expand Up @@ -147,6 +151,7 @@ export default function OdlcImages() {
const [flaggedOnly, setFlaggedOnly] = useState(false);
const [sortBy, setSortBy] = useState<SortBy>("recency");
const [colorFilter, setColorFilter] = useState<ColorFilter>("all");
const [autoRefresh, setAutoRefresh] = useState(false);
const [highlightedAnnotationId, setHighlightedAnnotationId] = useState<string | null>(null);

const dispatch = useAppDispatch();
Expand All @@ -164,6 +169,27 @@ export default function OdlcImages() {
// Callback ref: fires on attach/detach so we measure correctly even when
// the list container isn't in the DOM on first render (e.g. empty state
// then session restore populates records and mounts the main layout).
const pollIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

useEffect(() => {
if (autoRefresh) {
pollIntervalRef.current = setInterval(() => {
dispatch(pollOdlcSession());
}, 1000);
} else {
if (pollIntervalRef.current !== null) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
}
return () => {
if (pollIntervalRef.current !== null) {
clearInterval(pollIntervalRef.current);
pollIntervalRef.current = null;
}
};
}, [autoRefresh, dispatch]);

const resizeObserverRef = useRef<ResizeObserver | null>(null);
const listContainerCallbackRef = useCallback((el: HTMLDivElement | null) => {
resizeObserverRef.current?.disconnect();
Expand Down Expand Up @@ -341,6 +367,12 @@ export default function OdlcImages() {
Filters
</Typography>
<Stack spacing={1} sx={{ mb: 2 }}>
<FormControlLabel
control={
<Switch size="small" checked={autoRefresh} onChange={(_, v) => setAutoRefresh(v)} />
}
label={<Typography variant="body2">Auto-refresh</Typography>}
/>
<FormControlLabel
control={
<Switch size="small" checked={flaggedOnly} onChange={(_, v) => setFlaggedOnly(v)} />
Expand Down
9 changes: 9 additions & 0 deletions projects/web-frontend/src/store/slices/dataSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ const dataSlice = createSlice({
state.odlcImageRecords = [];
state.selectedOdlcImageId = null;
},
mergeOdlcImageRecords: (state, action: PayloadAction<OdlcImageRecord[]>) => {
const existingIds = new Set(state.odlcImageRecords.map((r) => r.id));
for (const record of action.payload) {
if (!existingIds.has(record.id)) {
state.odlcImageRecords.push(record);
}
}
},
setSelectedOdlcImage: (state, action: PayloadAction<string | null>) => {
state.selectedOdlcImageId = action.payload;
},
Expand Down Expand Up @@ -247,6 +255,7 @@ export const {
appendOdlcImage,
loadOdlcImageRecords,
clearOdlcImages,
mergeOdlcImageRecords,
setSelectedOdlcImage,
updateOdlcImageFlag,
updateOdlcImageTextInput,
Expand Down
Loading