Skip to content

Commit 076c83a

Browse files
authored
Update CameraMimeType to allow for custom mimes (#995)
1 parent c13e0ea commit 076c83a

File tree

12 files changed

+218
-57
lines changed

12 files changed

+218
-57
lines changed

src/viam/components/camera/camera.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import abc
22
import sys
3-
from typing import Any, Dict, Final, List, Optional, Tuple
3+
from typing import Any, Dict, Final, Optional, Sequence, Tuple
44

55
from viam.media.video import NamedImage, ViamImage
66
from viam.proto.common import ResponseMetadata
@@ -66,11 +66,11 @@ async def get_image(
6666
async def get_images(
6767
self,
6868
*,
69-
filter_source_names: Optional[List[str]] = None,
69+
filter_source_names: Optional[Sequence[str]] = None,
7070
extra: Optional[Dict[str, Any]] = None,
7171
timeout: Optional[float] = None,
7272
**kwargs,
73-
) -> Tuple[List[NamedImage], ResponseMetadata]:
73+
) -> Tuple[Sequence[NamedImage], ResponseMetadata]:
7474
"""Get simultaneous images from different imagers, along with associated metadata.
7575
This should not be used for getting a time series of images from the same imager.
7676
@@ -82,9 +82,13 @@ async def get_images(
8282
first_image = images[0]
8383
timestamp = metadata.captured_at
8484
85+
Args:
86+
filter_source_names (Sequence[str]): The filter_source_names parameter can be used to filter only the images from the specified
87+
source names. When unspecified, all images are returned.
88+
8589
Returns:
86-
Tuple[List[NamedImage], ResponseMetadata]: A tuple containing two values; the first [0] a list of images
87-
returned from the camera system, and the second [1] the metadata associated with this response.
90+
Tuple[Sequence[NamedImage], ResponseMetadata]: A tuple containing two values; the first [0] a list of images
91+
returned from the camera system, and the second [1] the metadata associated with this response.
8892
8993
For more information, see `Camera component <https://docs.viam.com/dev/reference/apis/components/camera/#getimages>`_.
9094
"""

src/viam/components/camera/client.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Any, Dict, List, Mapping, Optional, Tuple
1+
from typing import Any, Dict, Mapping, Optional, Sequence, Tuple
22

33
from grpclib.client import Channel
44

@@ -41,26 +41,26 @@ async def get_image(
4141
md = kwargs.get("metadata", self.Metadata()).proto
4242
request = GetImageRequest(name=self.name, mime_type=mime_type, extra=dict_to_struct(extra))
4343
response: GetImageResponse = await self.client.GetImage(request, timeout=timeout, metadata=md)
44-
return ViamImage(response.image, response.mime_type)
44+
return ViamImage(response.image, CameraMimeType.from_string(response.mime_type))
4545

4646
async def get_images(
4747
self,
4848
*,
49-
filter_source_names: Optional[List[str]] = None,
49+
filter_source_names: Optional[Sequence[str]] = None,
5050
extra: Optional[Dict[str, Any]] = None,
5151
timeout: Optional[float] = None,
5252
**kwargs,
53-
) -> Tuple[List[NamedImage], ResponseMetadata]:
53+
) -> Tuple[Sequence[NamedImage], ResponseMetadata]:
5454
md = kwargs.get("metadata", self.Metadata()).proto
5555
request = GetImagesRequest(name=self.name, extra=dict_to_struct(extra), filter_source_names=filter_source_names)
5656
response: GetImagesResponse = await self.client.GetImages(request, timeout=timeout, metadata=md)
5757
imgs = []
5858
for img_data in response.images:
5959
if img_data.mime_type:
60-
mime_type = img_data.mime_type
60+
mime_type = CameraMimeType.from_string(img_data.mime_type)
6161
else:
6262
# TODO(RSDK-11728): remove this once we deleted the format field
63-
mime_type = str(CameraMimeType.from_proto(img_data.format))
63+
mime_type = CameraMimeType.from_proto(img_data.format)
6464
img = NamedImage(img_data.source_name, img_data.image, mime_type)
6565
imgs.append(img)
6666
resp_metadata: ResponseMetadata = response.response_metadata
@@ -99,6 +99,8 @@ async def do_command(
9999
response: DoCommandResponse = await self.client.DoCommand(request, timeout=timeout, metadata=md)
100100
return struct_to_dict(response.result)
101101

102-
async def get_geometries(self, *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, **kwargs) -> List[Geometry]:
102+
async def get_geometries(
103+
self, *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None, **kwargs
104+
) -> Sequence[Geometry]:
103105
md = kwargs.get("metadata", self.Metadata())
104106
return await get_geometries(self.client, self.name, extra, timeout, md)

src/viam/components/camera/service.py

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
from viam.proto.common import DoCommandRequest, DoCommandResponse, GetGeometriesRequest, GetGeometriesResponse
88
from viam.proto.component.camera import (
99
CameraServiceBase,
10-
Format,
1110
GetImageRequest,
1211
GetImageResponse,
1312
GetImagesRequest,
@@ -54,16 +53,13 @@ async def GetImages(self, stream: Stream[GetImagesRequest, GetImagesResponse]) -
5453
timeout=timeout,
5554
metadata=stream.metadata,
5655
extra=struct_to_dict(request.extra),
57-
filter_source_names=list(request.filter_source_names),
56+
filter_source_names=request.filter_source_names,
5857
)
5958
img_bytes_lst = []
6059
for img in images:
61-
try:
62-
mime_type = CameraMimeType.from_string(img.mime_type) # this can ValueError if the mime_type is not a CameraMimeType
63-
fmt = mime_type.to_proto()
64-
except ValueError:
65-
# TODO(RSDK-11728): remove this once we deleted the format field
66-
fmt = Format.FORMAT_UNSPECIFIED
60+
mime_type = CameraMimeType.from_string(img.mime_type)
61+
# TODO(RSDK-11728): remove this fmt logic once we deleted the format field
62+
fmt = mime_type.to_proto() # Will be Format.FORMAT_UNSPECIFIED if an unsupported/custom mime type is set
6763

6864
img_bytes = img.data
6965
img_bytes_lst.append(Image(source_name=name, mime_type=img.mime_type, format=fmt, image=img_bytes))

src/viam/components/component_base.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import abc
22
from logging import Logger
3-
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Mapping, Optional, SupportsBytes, SupportsFloat, Union, cast
3+
from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Mapping, Optional, Sequence, SupportsBytes, SupportsFloat, Union, cast
44

55
from typing_extensions import Self
66

@@ -46,7 +46,7 @@ def from_robot(cls, robot: "RobotClient", name: str) -> Self:
4646
async def do_command(self, command: Mapping[str, ValueTypes], *, timeout: Optional[float] = None, **kwargs) -> Mapping[str, ValueTypes]:
4747
raise NotImplementedError()
4848

49-
async def get_geometries(self, *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> List[Geometry]:
49+
async def get_geometries(self, *, extra: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Sequence[Geometry]:
5050
"""
5151
Get all geometries associated with the component, in their current configuration, in the
5252
`frame <https://docs.viam.com/operate/mobility/define-geometry/>`__ of the component.

src/viam/media/utils/pil/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,13 +39,17 @@ def pil_to_viam_image(image: Image.Image, mime_type: CameraMimeType) -> ViamImag
3939
Returns:
4040
ViamImage: The resulting ViamImage
4141
"""
42+
# Make sure at runtime the mime_type string is actually a CameraMimeType
43+
if not isinstance(mime_type, CameraMimeType):
44+
raise ValueError(f"Cannot encode to unsupported mimetype: {mime_type}")
45+
4246
if mime_type.name in LIBRARY_SUPPORTED_FORMATS:
4347
buf = BytesIO()
4448
if image.mode == "RGBA" and mime_type == CameraMimeType.JPEG:
4549
image = image.convert("RGB")
4650
image.save(buf, format=mime_type.name)
4751
data = buf.getvalue()
4852
else:
49-
raise ValueError(f"Cannot encode image to {mime_type}")
53+
raise ValueError(f"Cannot encode to unsupported mimetype: {mime_type}")
5054

5155
return ViamImage(data, mime_type)

src/viam/media/video.py

Lines changed: 76 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,61 @@
11
from array import array
2-
from enum import Enum
3-
from typing import List, Optional, Tuple
2+
from typing import Any, List, Optional, Tuple
43

5-
from typing_extensions import Self
4+
from typing_extensions import ClassVar, Self
65

76
from viam.errors import NotSupportedError
87
from viam.proto.component.camera import Format
98

109
from .viam_rgba import RGBA_HEADER_LENGTH, RGBA_MAGIC_NUMBER
1110

1211

13-
class CameraMimeType(str, Enum):
14-
VIAM_RGBA = "image/vnd.viam.rgba"
15-
VIAM_RAW_DEPTH = "image/vnd.viam.dep"
16-
JPEG = "image/jpeg"
17-
PNG = "image/png"
18-
PCD = "pointcloud/pcd"
12+
class _FrozenClassAttributesMeta(type):
13+
"""
14+
A metaclass that prevents the reassignment of existing class attributes.
15+
"""
16+
17+
def __setattr__(cls, name: str, value: Any):
18+
# Check if the attribute `name` already exists on the class
19+
if name in cls.__dict__:
20+
# If it exists, raise an error to prevent overwriting
21+
raise AttributeError(f"Cannot reassign constant '{name}'")
22+
# If it's a new attribute, allow it to be set
23+
super().__setattr__(name, value)
24+
25+
26+
class CameraMimeType(str, metaclass=_FrozenClassAttributesMeta):
27+
"""
28+
The compatible mime-types for cameras and vision services.
29+
30+
You can use the `CameraMimeType.CUSTOM(...)` method to use an unlisted mime-type.
31+
"""
32+
33+
VIAM_RGBA: ClassVar[Self]
34+
VIAM_RAW_DEPTH: ClassVar[Self]
35+
JPEG: ClassVar[Self]
36+
PNG: ClassVar[Self]
37+
PCD: ClassVar[Self]
38+
39+
@property
40+
def name(self) -> str:
41+
for key, value in self.__class__.__dict__.items():
42+
if value == self:
43+
return key
44+
return "CUSTOM"
45+
46+
@property
47+
def value(self) -> str:
48+
return self
49+
50+
@classmethod
51+
def CUSTOM(cls, mime_type: str) -> Self:
52+
"""
53+
Create a custom mime type.
54+
55+
Args:
56+
mime_type (str): The mimetype as a string
57+
"""
58+
return cls.from_string(mime_type)
1959

2060
@classmethod
2161
def from_string(cls, value: str) -> Self:
@@ -28,13 +68,10 @@ def from_string(cls, value: str) -> Self:
2868
Self: The mimetype
2969
"""
3070
value_mime = value[:-5] if value.endswith("+lazy") else value # ViamImage lazy encodes by default
31-
try:
32-
return cls(value_mime)
33-
except ValueError:
34-
raise ValueError(f"Invalid mimetype: {value}")
71+
return cls(value_mime)
3572

3673
@classmethod
37-
def from_proto(cls, format: Format.ValueType) -> "CameraMimeType":
74+
def from_proto(cls, format: Format.ValueType) -> Self:
3875
"""Returns the mimetype from a proto enum.
3976
4077
Args:
@@ -44,14 +81,15 @@ def from_proto(cls, format: Format.ValueType) -> "CameraMimeType":
4481
Self: The mimetype.
4582
"""
4683
mimetypes = {
47-
Format.FORMAT_RAW_RGBA: CameraMimeType.VIAM_RGBA,
48-
Format.FORMAT_RAW_DEPTH: CameraMimeType.VIAM_RAW_DEPTH,
49-
Format.FORMAT_JPEG: CameraMimeType.JPEG,
50-
Format.FORMAT_PNG: CameraMimeType.PNG,
84+
Format.FORMAT_RAW_RGBA: cls.VIAM_RGBA,
85+
Format.FORMAT_RAW_DEPTH: cls.VIAM_RAW_DEPTH,
86+
Format.FORMAT_JPEG: cls.JPEG,
87+
Format.FORMAT_PNG: cls.PNG,
5188
}
52-
return mimetypes.get(format, CameraMimeType.JPEG)
89+
return cls(mimetypes.get(format, cls.JPEG))
5390

54-
def to_proto(self) -> Format.ValueType:
91+
@property
92+
def proto(self) -> Format.ValueType:
5593
"""Returns the mimetype in a proto enum.
5694
5795
Returns:
@@ -65,6 +103,19 @@ def to_proto(self) -> Format.ValueType:
65103
}
66104
return formats.get(self, Format.FORMAT_UNSPECIFIED)
67105

106+
def to_proto(self) -> Format.ValueType:
107+
"""
108+
DEPRECATED: Use `CameraMimeType.proto`
109+
"""
110+
return self.proto
111+
112+
113+
CameraMimeType.VIAM_RGBA = CameraMimeType.from_string("image/vnd.viam.rgba")
114+
CameraMimeType.VIAM_RAW_DEPTH = CameraMimeType.from_string("image/vnd.viam.dep")
115+
CameraMimeType.JPEG = CameraMimeType.from_string("image/jpeg")
116+
CameraMimeType.PNG = CameraMimeType.from_string("image/png")
117+
CameraMimeType.PCD = CameraMimeType.from_string("pointcloud/pcd")
118+
68119

69120
class ViamImage:
70121
"""A native implementation of an image.
@@ -73,11 +124,11 @@ class ViamImage:
73124
"""
74125

75126
_data: bytes
76-
_mime_type: str
127+
_mime_type: CameraMimeType
77128
_height: Optional[int] = None
78129
_width: Optional[int] = None
79130

80-
def __init__(self, data: bytes, mime_type: str) -> None:
131+
def __init__(self, data: bytes, mime_type: CameraMimeType) -> None:
81132
self._data = data
82133
self._mime_type = mime_type
83134
self._width, self._height = _getDimensions(data, mime_type)
@@ -88,7 +139,7 @@ def data(self) -> bytes:
88139
return self._data
89140

90141
@property
91-
def mime_type(self) -> str:
142+
def mime_type(self) -> CameraMimeType:
92143
"""The mime type of the image"""
93144
return self._mime_type
94145

@@ -131,12 +182,12 @@ class NamedImage(ViamImage):
131182
"""The name of the image
132183
"""
133184

134-
def __init__(self, name: str, data: bytes, mime_type: str) -> None:
185+
def __init__(self, name: str, data: bytes, mime_type: CameraMimeType) -> None:
135186
self.name = name
136187
super().__init__(data, mime_type)
137188

138189

139-
def _getDimensions(image: bytes, mime_type: str) -> Tuple[Optional[int], Optional[int]]:
190+
def _getDimensions(image: bytes, mime_type: CameraMimeType) -> Tuple[Optional[int], Optional[int]]:
140191
try:
141192
if mime_type == CameraMimeType.JPEG:
142193
return _getDimensionsFromJPEG(image)

src/viam/services/vision/client.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,11 @@ async def capture_all_from_camera(
6969
result = CaptureAllResult()
7070
result.extra = struct_to_dict(response.extra)
7171
if return_image:
72-
mime_type = CameraMimeType.from_proto(response.image.format)
72+
# TODO(RSDK-11728): remove this branching logic once we deleted the format field
73+
if response.image.mime_type:
74+
mime_type = CameraMimeType.from_string(response.image.mime_type)
75+
else:
76+
mime_type = CameraMimeType.from_proto(response.image.format)
7377
img = ViamImage(response.image.image, mime_type)
7478
result.image = img
7579
if return_classifications:

src/viam/services/vision/service.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
from .vision import Vision
2727

2828

29-
class VisionRPCService(UnimplementedVisionServiceBase, ResourceRPCServiceBase):
29+
class VisionRPCService(UnimplementedVisionServiceBase, ResourceRPCServiceBase[Vision]):
3030
"""
3131
gRPC service for a Vision service
3232
"""
@@ -50,9 +50,11 @@ async def CaptureAllFromCamera(self, stream: Stream[CaptureAllFromCameraRequest,
5050
)
5151
img = None
5252
if result.image is not None:
53-
fmt = result.image.mime_type.to_proto()
53+
mime_type = CameraMimeType.from_string(result.image.mime_type)
54+
# TODO(RSDK-11728): remove this fmt logic once we deleted the format field
55+
fmt = mime_type.to_proto() # Will be Format.FORMAT_UNSPECIFIED if an unsupported/custom mime type is set
5456
img_bytes = result.image.data
55-
img = Image(source_name=request.camera_name, format=fmt, image=img_bytes)
57+
img = Image(source_name=request.camera_name, mime_type=mime_type, format=fmt, image=img_bytes)
5658
response = CaptureAllFromCameraResponse(
5759
image=img,
5860
detections=result.detections,
@@ -79,7 +81,7 @@ async def GetDetections(self, stream: Stream[GetDetectionsRequest, GetDetections
7981
extra = struct_to_dict(request.extra)
8082
timeout = stream.deadline.time_remaining() if stream.deadline else None
8183

82-
image = ViamImage(request.image, request.mime_type)
84+
image = ViamImage(request.image, CameraMimeType.from_string(request.mime_type))
8385

8486
result = await vision.get_detections(image, extra=extra, timeout=timeout)
8587
response = GetDetectionsResponse(detections=result)
@@ -104,7 +106,7 @@ async def GetClassifications(self, stream: Stream[GetClassificationsRequest, Get
104106
extra = struct_to_dict(request.extra)
105107
timeout = stream.deadline.time_remaining() if stream.deadline else None
106108

107-
image = ViamImage(request.image, request.mime_type)
109+
image = ViamImage(request.image, CameraMimeType.from_string(request.mime_type))
108110

109111
result = await vision.get_classifications(image, request.n, extra=extra, timeout=timeout)
110112
response = GetClassificationsResponse(classifications=result)
@@ -117,7 +119,7 @@ async def GetObjectPointClouds(self, stream: Stream[GetObjectPointCloudsRequest,
117119
extra = struct_to_dict(request.extra)
118120
timeout = stream.deadline.time_remaining() if stream.deadline else None
119121
result = await vision.get_object_point_clouds(request.camera_name, extra=extra, timeout=timeout)
120-
response = GetObjectPointCloudsResponse(mime_type=CameraMimeType.PCD.value, objects=result)
122+
response = GetObjectPointCloudsResponse(mime_type=CameraMimeType.PCD, objects=result)
121123
await stream.send_message(response)
122124

123125
async def GetProperties(self, stream: Stream[GetPropertiesRequest, GetPropertiesResponse]) -> None:

src/viam/services/worldstatestore/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1+
from viam.proto.service.worldstatestore import StreamTransformChangesResponse, TransformChangeType
12
from viam.resource.registry import Registry, ResourceRegistration
23

34
from .client import WorldStateStoreClient
45
from .service import WorldStateStoreService
56
from .worldstatestore import WorldStateStore
6-
from viam.proto.service.worldstatestore import StreamTransformChangesResponse, TransformChangeType
77

88
__all__ = [
99
"WorldStateStore",

0 commit comments

Comments
 (0)