Skip to content

Commit c46946f

Browse files
maxxgxCopilot
andauthored
feat(inspect): true source frame capture (#3137)
* capture direct feed Signed-off-by: Ma, Xiangxiang <xiangxiang.ma@intel.com> * /capture -> /images Signed-off-by: Ma, Xiangxiang <xiangxiang.ma@intel.com> * Update application/backend/src/api/endpoints/media_endpoints.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Max Xiang <maxx.rift@gmail.com> * Update application/backend/src/api/endpoints/media_endpoints.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Max Xiang <maxx.rift@gmail.com> * Update application/backend/src/services/media_service.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Max Xiang <maxx.rift@gmail.com> * fix Signed-off-by: Ma, Xiangxiang <xiangxiang.ma@intel.com> * fix style and add missing test Signed-off-by: Ma, Xiangxiang <xiangxiang.ma@intel.com> * fix style Signed-off-by: Ma, Xiangxiang <xiangxiang.ma@intel.com> --------- Signed-off-by: Ma, Xiangxiang <xiangxiang.ma@intel.com> Signed-off-by: Max Xiang <maxx.rift@gmail.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent afe74e1 commit c46946f

File tree

9 files changed

+340
-24
lines changed

9 files changed

+340
-24
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# Copyright (C) 2025 Intel Corporation
2+
# SPDX-License-Identifier: Apache-2.0
3+
import asyncio
4+
import queue
5+
from typing import Annotated
6+
from uuid import UUID
7+
8+
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, status
9+
10+
from api.dependencies import get_media_service, get_project_id, get_scheduler
11+
from api.endpoints import API_PREFIX
12+
from core import Scheduler
13+
from pydantic_models import Media
14+
from services.media_service import MediaService
15+
16+
capture_api_prefix_url = API_PREFIX + "/projects/{project_id}"
17+
router = APIRouter(
18+
prefix=capture_api_prefix_url,
19+
tags=["capture"],
20+
)
21+
22+
23+
@router.get(
24+
"/capture",
25+
response_model_exclude_none=True,
26+
status_code=status.HTTP_201_CREATED,
27+
responses={
28+
status.HTTP_201_CREATED: {"description": "Image captured successfully"},
29+
status.HTTP_400_BAD_REQUEST: {"description": "Invalid image file"},
30+
status.HTTP_404_NOT_FOUND: {"description": "Source or project not found"},
31+
},
32+
)
33+
async def capture(
34+
media_service: Annotated[MediaService, Depends(get_media_service)],
35+
project_id: Annotated[UUID, Depends(get_project_id)],
36+
scheduler: Annotated[Scheduler, Depends(get_scheduler)],
37+
background_tasks: BackgroundTasks,
38+
) -> Media:
39+
"""Endpoint to capture an image"""
40+
try:
41+
stream_data = await asyncio.to_thread(lambda: scheduler.frame_queue.get(timeout=1))
42+
except queue.Empty as e:
43+
raise HTTPException(
44+
status_code=status.HTTP_400_BAD_REQUEST,
45+
detail="No frame available to capture. Please make sure the stream is active.",
46+
) from e
47+
48+
image = stream_data.frame_data
49+
# Convert BGR to RGB if needed
50+
if image.ndim == 3 and image.shape[2] == 3:
51+
image = image[..., ::-1]
52+
53+
media = await media_service.upload_image(project_id=project_id, image=image, is_anomalous=False, extension=".png")
54+
55+
background_tasks.add_task(
56+
media_service.generate_thumbnail,
57+
project_id=project_id,
58+
media_id=media.id,
59+
image=image,
60+
)
61+
return media

application/backend/src/api/endpoints/media_endpoints.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
# Copyright (C) 2025 Intel Corporation
22
# SPDX-License-Identifier: Apache-2.0
3-
3+
import asyncio
44
from typing import Annotated
55
from uuid import UUID
66

7+
import anyio
78
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, UploadFile, status
89
from fastapi.responses import FileResponse
910

@@ -73,7 +74,17 @@ async def get_media_thumbnail(
7374
) -> FileResponse:
7475
"""Return a PNG thumbnail for the requested image."""
7576
try:
76-
return FileResponse(await media_service.get_thumbnail_file_path(project_id=project_id, media_id=media_id))
77+
thumbnail_path = await media_service.get_thumbnail_file_path(project_id=project_id, media_id=media_id)
78+
# Wait for thumbnail with timeout
79+
max_retries = 10 # 0.5 seconds
80+
for _ in range(max_retries):
81+
if await anyio.Path(thumbnail_path).exists():
82+
return FileResponse(thumbnail_path)
83+
await asyncio.sleep(0.05)
84+
raise HTTPException(
85+
status_code=status.HTTP_404_NOT_FOUND,
86+
detail=f"Thumbnail for media with ID {media_id} not found",
87+
)
7788
except FileNotFoundError as e:
7889
raise HTTPException(
7990
status_code=status.HTTP_404_NOT_FOUND,
@@ -102,7 +113,7 @@ async def delete_media(
102113

103114

104115
@media_router.post(
105-
"/capture",
116+
"/images",
106117
response_model_exclude_none=True,
107118
status_code=status.HTTP_201_CREATED,
108119
responses={
@@ -118,14 +129,17 @@ async def capture_image(
118129
) -> Media:
119130
"""Endpoint to capture an image"""
120131
image_bytes = await file.read()
132+
if not file.filename or "." not in file.filename:
133+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Uploaded file must have an extension.")
134+
extension = "." + file.filename.rsplit(".", maxsplit=1)[-1]
121135
media = await media_service.upload_image(
122-
project_id=project_id, file=file, image_bytes=image_bytes, is_anomalous=False
136+
project_id=project_id, image=image_bytes, is_anomalous=False, extension=extension, size=file.size
123137
)
124138

125139
background_tasks.add_task(
126140
media_service.generate_thumbnail,
127141
project_id=project_id,
128142
media_id=media.id,
129-
image_bytes=image_bytes,
143+
image=image_bytes,
130144
)
131145
return media

application/backend/src/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from fastapi.middleware.cors import CORSMiddleware
99

1010
from api.endpoints.active_pipeline_endpoints import router as active_pipeline_router
11+
from api.endpoints.capture_endpoints import router as capture_router
1112
from api.endpoints.devices_endpoints import device_router
1213
from api.endpoints.job_endpoints import job_router
1314
from api.endpoints.media_endpoints import media_router
@@ -56,6 +57,7 @@
5657
app.include_router(webrtc_router)
5758
app.include_router(trainable_model_router)
5859
app.include_router(device_router)
60+
app.include_router(capture_router)
5961

6062

6163
if __name__ == "__main__":

application/backend/src/services/media_service.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,10 @@
11
# Copyright (C) 2025 Intel Corporation
22
# SPDX-License-Identifier: Apache-2.0
33
import asyncio
4-
import os
54
from io import BytesIO
65
from uuid import UUID, uuid4
76

8-
from fastapi import UploadFile
7+
import numpy as np
98
from loguru import logger
109
from PIL import Image
1110

@@ -56,14 +55,33 @@ async def get_thumbnail_file_path(cls, project_id: UUID, media_id: UUID) -> str:
5655
return bin_repo.get_full_path(filename=thumbnail_filename)
5756

5857
@classmethod
59-
async def upload_image(cls, project_id: UUID, file: UploadFile, image_bytes: bytes, is_anomalous: bool) -> Media:
58+
async def upload_image(
59+
cls,
60+
project_id: UUID,
61+
image: np.ndarray | bytes,
62+
is_anomalous: bool,
63+
extension: str | None = None,
64+
size: int | None = None,
65+
) -> Media:
6066
# Generate unique filename and media ID
6167
media_id = uuid4()
6268

63-
if file.filename is None or file.size is None:
64-
raise ValueError("File must have a filename and size")
69+
if not extension or not extension.lstrip("."):
70+
raise ValueError("File extension must be provided")
71+
72+
if isinstance(image, np.ndarray):
73+
74+
def _encode_image() -> bytes:
75+
with BytesIO() as output:
76+
# Determine format from extension, default to PNG if unknown or generic
77+
fmt = extension.lstrip(".").upper()
78+
Image.fromarray(image).save(output, format=fmt)
79+
return output.getvalue()
80+
81+
image_bytes = await asyncio.to_thread(_encode_image)
82+
else:
83+
image_bytes = image
6584

66-
extension = list(os.path.splitext(file.filename)).pop().lower()
6785
filename = f"{media_id}{extension}"
6886
bin_repo = ImageBinaryRepository(project_id=project_id)
6987
saved_media: Media | None = None
@@ -90,7 +108,7 @@ def _get_image_size() -> tuple[int, int]:
90108
id=media_id,
91109
project_id=project_id,
92110
filename=filename,
93-
size=file.size,
111+
size=size or len(image_bytes),
94112
is_anomalous=is_anomalous,
95113
width=width,
96114
height=height,
@@ -137,7 +155,7 @@ async def generate_thumbnail(
137155
cls,
138156
project_id: UUID,
139157
media_id: UUID,
140-
image_bytes: bytes,
158+
image: np.ndarray | bytes,
141159
height_px: int = THUMBNAIL_SIZE,
142160
width_px: int = THUMBNAIL_SIZE,
143161
) -> None:
@@ -150,7 +168,7 @@ async def generate_thumbnail(
150168
Args:
151169
project_id: Identifier of the owning project.
152170
media_id: Identifier of the media item the thumbnail belongs to.
153-
image_bytes: Original image bytes used to create the thumbnail.
171+
image: Original image (bytes or numpy array) used to create the thumbnail.
154172
height_px: Maximum thumbnail height in pixels. Defaults to
155173
``THUMBNAIL_SIZE``.
156174
width_px: Maximum thumbnail width in pixels. Defaults to
@@ -164,7 +182,12 @@ async def generate_thumbnail(
164182
"""
165183

166184
def _create() -> bytes:
167-
with Image.open(BytesIO(image_bytes)) as img:
185+
if isinstance(image, np.ndarray):
186+
img = Image.fromarray(image)
187+
else:
188+
img = Image.open(BytesIO(image))
189+
190+
with img:
168191
# Preserve aspect ratio while fitting within the box
169192
img.thumbnail((width_px, height_px))
170193
with BytesIO() as buf:

application/backend/src/workers/dispatching.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ async def run_loop(self) -> None:
6969

7070
passthrough_mode = not self._active_pipeline_service.is_running
7171
if passthrough_mode:
72-
logger.debug("Passthrough mode; only dispatching to WebRTC stream")
72+
logger.trace("Passthrough mode; only dispatching to WebRTC stream")
7373
# Only dispatch to WebRTC stream
7474
try:
7575
self._rtc_stream_queue.put(stream_data.frame_data, block=False)

application/backend/src/workers/inference.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ async def _get_active_model(self) -> LoadedModel | None:
7171
self._is_passthrough_mode = pipeline is None or (
7272
pipeline.status.is_active and not pipeline.status.is_running
7373
)
74+
logger.info(f"Passthrough mode {'activated' if self._is_passthrough_mode else 'disabled'}.")
7475
if pipeline is None or pipeline.model is None:
7576
return None
7677

@@ -160,7 +161,6 @@ async def _get_next_frame(self) -> StreamData | None:
160161

161162
async def _handle_passthrough_mode(self, stream_data: StreamData) -> None:
162163
"""Handle frame in passthrough mode (no model loaded)."""
163-
logger.debug("No active model configured; frame passthrough mode")
164164
try:
165165
self._pred_queue.put(stream_data, timeout=1)
166166
except std_queue.Full:

application/backend/tests/unit/services/test_media_service.py

Lines changed: 38 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest.mock import AsyncMock, MagicMock, patch
66
from uuid import uuid4
77

8+
import numpy as np
89
import pytest
910
from PIL import Image
1011

@@ -43,6 +44,16 @@ def mock_db_context():
4344

4445

4546
class TestMediaService:
47+
@pytest.fixture(autouse=True)
48+
def mock_asyncio_to_thread(self):
49+
"""Mock asyncio.to_thread to run synchronously in tests."""
50+
51+
async def _mock_to_thread(func, *args, **kwargs):
52+
return func(*args, **kwargs)
53+
54+
with patch("asyncio.to_thread", side_effect=_mock_to_thread):
55+
yield
56+
4657
def test_get_media_list(self, fxt_media_service, fxt_media_repository, fxt_media_list, fxt_project):
4758
"""Test getting media list."""
4859
fxt_media_repository.get_all.return_value = fxt_media_list.media
@@ -116,13 +127,34 @@ def test_upload_image_success(self, fxt_media_service, fxt_upload_file, fxt_proj
116127
patch("services.media_service.MediaRepository", return_value=mock_media_repo),
117128
):
118129
result = asyncio.run(
119-
fxt_media_service.upload_image(fxt_project.id, fxt_upload_file, fxt_image_bytes, False)
130+
fxt_media_service.upload_image(fxt_project.id, fxt_image_bytes, False, extension=".jpg")
120131
)
121132

122133
assert result is not None
123134
assert mock_bin_repo.save_file.call_count == 1 # only original saved
124135
mock_media_repo.save.assert_called_once()
125136

137+
def test_upload_image_numpy_success(self, fxt_media_service, fxt_project):
138+
"""Test successful image upload with numpy array."""
139+
# Create a random numpy image (100x100 RGB)
140+
numpy_image = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
141+
142+
mock_bin_repo = MagicMock()
143+
mock_bin_repo.save_file = AsyncMock(return_value="/path/to/file.png")
144+
145+
mock_media_repo = MagicMock()
146+
mock_media_repo.save = AsyncMock(return_value=MagicMock())
147+
148+
with (
149+
patch("services.media_service.ImageBinaryRepository", return_value=mock_bin_repo),
150+
patch("services.media_service.MediaRepository", return_value=mock_media_repo),
151+
):
152+
result = asyncio.run(fxt_media_service.upload_image(fxt_project.id, numpy_image, False, extension=".png"))
153+
154+
assert result is not None
155+
assert mock_bin_repo.save_file.call_count == 1
156+
mock_media_repo.save.assert_called_once()
157+
126158
def test_upload_image_rollback_on_error(self, fxt_media_service, fxt_upload_file, fxt_project, fxt_image_bytes):
127159
"""Test image upload rollback on error."""
128160
mock_bin_repo = MagicMock()
@@ -137,7 +169,7 @@ def test_upload_image_rollback_on_error(self, fxt_media_service, fxt_upload_file
137169
patch("services.media_service.MediaRepository", return_value=mock_media_repo),
138170
pytest.raises(Exception, match="Database error"),
139171
):
140-
asyncio.run(fxt_media_service.upload_image(fxt_project.id, fxt_upload_file, fxt_image_bytes, False))
172+
asyncio.run(fxt_media_service.upload_image(fxt_project.id, fxt_image_bytes, False, extension=".jpg"))
141173

142174
# Verify rollback deletes both files
143175
assert mock_bin_repo.delete_file.call_count == 1
@@ -158,7 +190,7 @@ def test_upload_image_rollback_file_not_found(
158190
patch("services.media_service.MediaRepository", return_value=mock_media_repo),
159191
pytest.raises(Exception, match="Database error"),
160192
):
161-
asyncio.run(fxt_media_service.upload_image(fxt_project.id, fxt_upload_file, fxt_image_bytes, False))
193+
asyncio.run(fxt_media_service.upload_image(fxt_project.id, fxt_image_bytes, False, extension=".jpg"))
162194

163195
# Verify rollback attempted to delete both files
164196
assert mock_bin_repo.delete_file.call_count == 1
@@ -263,7 +295,7 @@ async def _save_file(filename: str, content: bytes) -> str: # type: ignore[over
263295
fxt_media_service.generate_thumbnail(
264296
project_id=fxt_project.id,
265297
media_id=media_id,
266-
image_bytes=image_bytes,
298+
image=image_bytes,
267299
)
268300
)
269301

@@ -290,7 +322,7 @@ def test_thumbnail_upload_and_deletion(self, fxt_media_service, fxt_upload_file,
290322
patch("services.media_service.MediaRepository", return_value=mock_media_repo),
291323
):
292324
# Upload saves only original
293-
result = asyncio.run(fxt_media_service.upload_image(fxt_project.id, fxt_upload_file, image_bytes, False))
325+
result = asyncio.run(fxt_media_service.upload_image(fxt_project.id, image_bytes, False, extension=".jpg"))
294326
assert result is not None
295327
assert mock_bin_repo.save_file.call_count == 1
296328

@@ -299,7 +331,7 @@ def test_thumbnail_upload_and_deletion(self, fxt_media_service, fxt_upload_file,
299331
fxt_media_service.generate_thumbnail(
300332
project_id=fxt_project.id,
301333
media_id=saved_media.id,
302-
image_bytes=image_bytes,
334+
image=image_bytes,
303335
)
304336
)
305337

0 commit comments

Comments
 (0)