2222from viam .errors import ViamError , NotSupportedError
2323
2424import os
25- import io
25+ import io
2626from datetime import datetime , timezone
2727
28- import re
28+ import re
2929
3030LOGGER = getLogger (__name__ )
3131
32- class imageDir (Camera , Reconfigurable ):
3332
33+ class imageDir (Camera , Reconfigurable ):
3434 class Properties (NamedTuple ):
3535 supports_pcd : bool = False
3636 intrinsic_parameters = None
3737 distortion_parameters = None
3838
3939 MODEL : ClassVar [Model ] = Model (ModelFamily ("viam-labs" , "camera" ), "image-dir" )
40-
40+
4141 camera_properties : Camera .Properties = Properties ()
4242 # will store current get_image index for a given directory here
4343 directory_index : dict
44- root_dir : str = ' /tmp'
45- ext : str = ' jpg'
44+ root_dir : str = " /tmp"
45+ ext : str = " jpg"
4646 dir : str
4747
4848 # Constructor
4949 @classmethod
50- def new (cls , config : ComponentConfig , dependencies : Mapping [ResourceName , ResourceBase ]) -> Self :
50+ def new (
51+ cls , config : ComponentConfig , dependencies : Mapping [ResourceName , ResourceBase ]
52+ ) -> Self :
5153 my_class = cls (config .name )
5254 my_class .reconfigure (config , dependencies )
5355 return my_class
5456
5557 # Validates JSON Configuration
5658 @classmethod
5759 def validate (cls , config : ComponentConfig ):
58- root_dir = config .attributes .fields ["root_dir" ].string_value or ' /tmp'
60+ root_dir = config .attributes .fields ["root_dir" ].string_value or " /tmp"
5961 if not os .path .isdir (root_dir ):
6062 raise Exception ("specified 'root_dir' does not exist" )
6163 return
6264
6365 # Handles attribute reconfiguration
64- def reconfigure (self , config : ComponentConfig , dependencies : Mapping [ResourceName , ResourceBase ]):
66+ def reconfigure (
67+ self , config : ComponentConfig , dependencies : Mapping [ResourceName , ResourceBase ]
68+ ):
6569 self .directory_index = {}
66- self .root_dir = config .attributes .fields ["root_dir" ].string_value or ' /tmp'
67- self .ext = config .attributes .fields ["ext" ].string_value or ' jpg'
70+ self .root_dir = config .attributes .fields ["root_dir" ].string_value or " /tmp"
71+ self .ext = config .attributes .fields ["ext" ].string_value or " jpg"
6872 self .dir = config .attributes .fields ["dir" ].string_value
6973
7074 return
71-
72- async def get_image (self , mime_type : str = "image/jpeg" , * ,
73- timeout : Optional [float ] = None ,
74- extra : Optional [Mapping [str , Any ]] = None ,
75- metadata : Optional [Mapping [str , Any ]] = None ,
76- ** kwargs ) -> ViamImage :
75+
76+ async def get_image (
77+ self ,
78+ mime_type : str = "image/jpeg" ,
79+ * ,
80+ timeout : Optional [float ] = None ,
81+ extra : Optional [Mapping [str , Any ]] = None ,
82+ metadata : Optional [Mapping [str , Any ]] = None ,
83+ ** kwargs ,
84+ ) -> ViamImage :
7785 if extra is None :
7886 extra = {}
7987
80- if extra .get (' dir' ) == None :
88+ if extra .get (" dir" ) == None :
8189 if self .dir == None :
82- raise ViamError ("'dir' must be passed in with 'extra', specifying image directory relative to the configured 'root_dir'" )
90+ raise ViamError (
91+ "'dir' must be passed in with 'extra', specifying image directory relative to the configured 'root_dir'"
92+ )
8393 else :
84- extra [' dir' ] = self .dir
85- requested_dir = os .path .join (self .root_dir , extra [' dir' ])
86-
94+ extra [" dir" ] = self .dir
95+ requested_dir = os .path .join (self .root_dir , extra [" dir" ])
96+
8797 if not os .path .isdir (requested_dir ):
8898 raise ViamError ("requested 'dir' not found within configured 'root_dir'" )
89-
99+
90100 image_index : int
91- if extra .get (' index' ) != None :
92- image_index = extra [' index' ]
93-
94- if extra .get (' index_reset' ) != None :
95- if extra [' index_reset' ] == True :
101+ if extra .get (" index" ) != None :
102+ image_index = extra [" index" ]
103+
104+ if extra .get (" index_reset" ) != None :
105+ if extra [" index_reset" ] == True :
96106 # reset
97107 image_index = self ._get_oldest_image_index (requested_dir )
98-
99- if extra .get (' index_jog' ) != None :
100- image_index = self ._jog_index (extra [' index_jog' ], requested_dir )
108+
109+ if extra .get (" index_jog" ) != None :
110+ image_index = self ._jog_index (extra [" index_jog" ], requested_dir )
101111 elif self .directory_index .get (requested_dir ) != None :
102112 image_index = self .directory_index [requested_dir ]
103113 else :
104114 image_index = self ._get_oldest_image_index (requested_dir )
105-
115+
106116 ext = self .ext
107- if extra .get (' ext' ) != None :
108- if extra [' ext' ] in [' jpg' , ' jpeg' , ' png' , ' gif' ]:
109- ext = extra [' ext' ]
110-
117+ if extra .get (" ext" ) != None :
118+ if extra [" ext" ] in [" jpg" , " jpeg" , " png" , " gif" ]:
119+ ext = extra [" ext" ]
120+
111121 # Get max index to handle wraparound
112122 max_index = self ._get_greatest_image_index (requested_dir )
113-
123+
114124 # Wrap around if we've gone past the end
115125 if image_index > max_index :
116126 image_index = 0
117127 LOGGER .info (f"Reached end of directory, wrapping to index 0" )
118-
128+
119129 file_path = self ._get_file_path (requested_dir , image_index , ext )
120130 if not os .path .isfile (file_path ):
121131 if extra .get ("index" ):
@@ -124,66 +134,70 @@ async def get_image(self, mime_type: str = "image/jpeg", *,
124134 else :
125135 # loop back to 0 index, we might be at the last image in dir
126136 image_index = 0
127- file_path = os .path .join (requested_dir , str (image_index ) + '.' + ext )
137+ file_path = os .path .join (requested_dir , str (image_index ) + "." + ext )
128138 if not os .path .isfile (file_path ):
129139 raise ViamError ("No image at 0 index for " + file_path )
130-
140+
131141 img = Image .open (file_path )
132142 # LOGGER.info(f"Serving image {file_path} for index {image_index}")
133143
134144 # increment for next get_image() call
135145 self .directory_index [requested_dir ] = image_index + 1
136-
137- return pil_to_viam_image (img .convert (' RGB' ), CameraMimeType .from_string (mime_type ))
146+
147+ return pil_to_viam_image (img .convert (" RGB" ), CameraMimeType .from_string (mime_type ))
138148
139149 def _parse_timestamp_from_filename (self , filename : str ) -> Optional [datetime ]:
140150 """
141151 Parse timestamp from filename format:
142152 2025-10-09T15_27_01.690Z_<hash>.jpeg
143-
153+
144154 Returns None if no timestamp can be parsed.
145155 """
146156 # Remove extension
147157 name_without_ext = os .path .splitext (filename )[0 ]
148-
158+
149159 # Pattern: YYYY-MM-DDTHH_MM_SS.mmmZ
150160 # Example: 2025-10-09T15_27_01.690Z
151- pattern = r' ^(\d{4})-(\d{2})-(\d{2})T(\d{2})_(\d{2})_(\d{2})\.(\d{3})Z'
161+ pattern = r" ^(\d{4})-(\d{2})-(\d{2})T(\d{2})_(\d{2})_(\d{2})\.(\d{3})Z"
152162 match = re .match (pattern , name_without_ext )
153-
163+
154164 if match :
155165 year , month , day , hour , minute , second , millisecond = match .groups ()
156166 try :
157167 dt = datetime (
158- int (year ), int (month ), int (day ),
159- int (hour ), int (minute ), int (second ),
168+ int (year ),
169+ int (month ),
170+ int (day ),
171+ int (hour ),
172+ int (minute ),
173+ int (second ),
160174 int (millisecond ) * 1000 , # Convert milliseconds to microseconds
161- tzinfo = timezone .utc
175+ tzinfo = timezone .utc ,
162176 )
163177 return dt
164178 except ValueError as e :
165179 LOGGER .warning (f"Failed to parse timestamp from { filename } : { e } " )
166180 return None
167-
181+
168182 return None
169183
170184 def _get_sorted_files (self , dir , ext ):
171185 """
172186 Get all files in directory, sorted by timestamp (if parseable) or mtime.
173187 Returns list of filenames in chronological order.
174188 """
175- files = [f for f in os .listdir (dir ) if f .endswith (f' .{ ext } ' )]
189+ files = [f for f in os .listdir (dir ) if f .endswith (f" .{ ext } " )]
176190 if not files :
177191 return []
178-
192+
179193 # Sort by parsed timestamp, fall back to mtime
180194 def sort_key (f ):
181195 parsed_time = self ._parse_timestamp_from_filename (f )
182196 if parsed_time :
183197 return parsed_time .timestamp ()
184198 # Fall back to file modification time
185199 return os .stat (os .path .join (dir , f )).st_mtime
186-
200+
187201 files .sort (key = sort_key )
188202 return files
189203
@@ -193,93 +207,91 @@ def _get_file_path(self, dir, index, ext):
193207 if not sorted_files or index >= len (sorted_files ):
194208 return None
195209 return os .path .join (dir , sorted_files [index ])
196-
210+
197211 def _get_oldest_image_index (self , requested_dir ):
198212 """Return index 0 (oldest file after sorting)."""
199213 return 0
200-
214+
201215 def _get_greatest_image_index (self , requested_dir ):
202216 """Get the maximum valid index (count of files - 1)."""
203217 sorted_files = self ._get_sorted_files (requested_dir , self .ext )
204218 if not sorted_files :
205219 return 0
206220 return len (sorted_files ) - 1
207-
221+
208222 def _jog_index (self , index_jog , requested_dir ):
209223 """Move index forward or backward, wrapping around."""
210224 current_index = self .directory_index .get (requested_dir , 0 )
211225 requested_index = current_index + index_jog
212226 max_index = self ._get_greatest_image_index (requested_dir )
213-
227+
214228 if max_index == 0 :
215229 return 0
216-
230+
217231 # Wrap around if out of bounds
218- return requested_index % (max_index + 1 )
219-
220- async def get_images (self , * , timeout : Optional [float ] = None ,
221- metadata : Optional [Mapping [str , Any ]] = None ,
222- extra : Optional [Mapping [str , Any ]] = None ,
223- filter_source_names : Optional [List [str ]] = None ,
224- ** kwargs ) -> Tuple [List [NamedImage ], ResponseMetadata ]:
225-
232+ return requested_index % (max_index + 1 )
233+
234+ async def get_images (
235+ self ,
236+ * ,
237+ timeout : Optional [float ] = None ,
238+ metadata : Optional [Mapping [str , Any ]] = None ,
239+ extra : Optional [Mapping [str , Any ]] = None ,
240+ filter_source_names : Optional [List [str ]] = None ,
241+ ** kwargs ,
242+ ) -> Tuple [List [NamedImage ], ResponseMetadata ]:
226243 if extra is None :
227244 extra = {}
228-
245+
229246 # Determine source name
230247 source_name = self .dir or ""
231-
248+
232249 # Apply filtering if specified
233250 if filter_source_names is not None and len (filter_source_names ) > 0 :
234251 # If filtering is requested and our source isn't in the list, return empty
235252 if source_name not in filter_source_names :
236253 return [], ResponseMetadata ()
237-
254+
238255 # Get the image
239256 image = await self .get_image (extra = extra , timeout = timeout )
240-
257+
241258 # Create NamedImage
242- named_image = NamedImage (
243- name = source_name ,
244- data = image .data ,
245- mime_type = image .mime_type
246- )
247-
259+ named_image = NamedImage (name = source_name , data = image .data , mime_type = image .mime_type )
260+
248261 # Return with metadata
249262 return [named_image ], ResponseMetadata ()
250-
263+
251264 async def get_point_cloud (
252265 self , * , extra : Optional [Dict [str , Any ]] = None , timeout : Optional [float ] = None , ** kwargs
253266 ) -> Tuple [bytes , str ]:
254267 raise NotImplementedError ()
255268
256269 # Implements the do_command which will respond to a map with key "request"
257- async def do_command (self , command : Mapping [ str , ValueTypes ], * ,
258- timeout : Optional [float ] = None ,
259- ** kwargs ) -> Mapping [str , ValueTypes ]:
270+ async def do_command (
271+ self , command : Mapping [ str , ValueTypes ], * , timeout : Optional [float ] = None , ** kwargs
272+ ) -> Mapping [str , ValueTypes ]:
260273 ret = {}
261- if command .get (' set' ) != None :
262- setDict = command .get (' set' )
263- if setDict .get (' dir' ) != None :
264- self .dir = setDict .get (' dir' )
274+ if command .get (" set" ) != None :
275+ setDict = command .get (" set" )
276+ if setDict .get (" dir" ) != None :
277+ self .dir = setDict .get (" dir" )
265278 requested_dir = os .path .join (self .root_dir , self .dir )
266- if setDict .get (' ext' ) != None :
267- self .ext = setDict .get (' ext' )
268- if setDict .get (' index' ) != None :
269- if isinstance (setDict [' index' ], int ):
270- self .directory_index [requested_dir ] = setDict [' index' ]
271- if setDict .get (' index_reset' ) != None :
272- if setDict [' index_reset' ] == True :
279+ if setDict .get (" ext" ) != None :
280+ self .ext = setDict .get (" ext" )
281+ if setDict .get (" index" ) != None :
282+ if isinstance (setDict [" index" ], int ):
283+ self .directory_index [requested_dir ] = setDict [" index" ]
284+ if setDict .get (" index_reset" ) != None :
285+ if setDict [" index_reset" ] == True :
273286 # reset
274287 index = self ._get_oldest_image_index (requested_dir )
275- self .directory_index [requested_dir ] = index
276- ret = { "index" : index }
277- if setDict .get (' index_jog' ) != None :
278- index = self ._jog_index (setDict [' index_jog' ], requested_dir )
288+ self .directory_index [requested_dir ] = index
289+ ret = {"index" : index }
290+ if setDict .get (" index_jog" ) != None :
291+ index = self ._jog_index (setDict [" index_jog" ], requested_dir )
279292 self .directory_index [requested_dir ] = index
280- ret = { "index" : index }
293+ ret = {"index" : index }
281294 return ret
282-
283- async def get_properties (self , * , timeout : Optional [float ] = None , ** kwargs ) -> Properties :
284- return self .camera_properties
285295
296+ async def get_properties (self , * , timeout : Optional [float ] = None , ** kwargs ) -> Properties :
297+ return self .camera_properties
0 commit comments