simba-uw-tf-dev 4.7.2__py3-none-any.whl → 4.7.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. simba/SimBA.py +1178 -1171
  2. simba/assets/icons/left_arrow_green.png +0 -0
  3. simba/assets/icons/left_arrow_red.png +0 -0
  4. simba/assets/icons/right_arrow_green.png +0 -0
  5. simba/assets/icons/right_arrow_red.png +0 -0
  6. simba/assets/lookups/yolo_schematics/yolo_mitra.csv +1 -1
  7. simba/mixins/image_mixin.py +129 -4
  8. simba/model/yolo_fit.py +22 -15
  9. simba/model/yolo_pose_inference.py +7 -2
  10. simba/roi_tools/roi_utils.py +2 -2
  11. simba/sandbox/convert_h264_to_mp4_lossless.py +129 -0
  12. simba/sandbox/extract_and_convert_videos.py +257 -0
  13. simba/sandbox/remove_end_of_video.py +80 -0
  14. simba/sandbox/video_timelaps.py +291 -0
  15. simba/ui/pop_ups/run_machine_models_popup.py +2 -2
  16. simba/ui/pop_ups/video_processing_pop_up.py +3637 -3469
  17. simba/ui/tkinter_functions.py +3 -1
  18. simba/ui/video_timelaps.py +332 -0
  19. simba/utils/lookups.py +67 -1
  20. simba/utils/read_write.py +10 -3
  21. simba/video_processors/batch_process_create_ffmpeg_commands.py +0 -1
  22. simba/video_processors/video_processing.py +5385 -5264
  23. {simba_uw_tf_dev-4.7.2.dist-info → simba_uw_tf_dev-4.7.4.dist-info}/METADATA +1 -1
  24. {simba_uw_tf_dev-4.7.2.dist-info → simba_uw_tf_dev-4.7.4.dist-info}/RECORD +28 -19
  25. {simba_uw_tf_dev-4.7.2.dist-info → simba_uw_tf_dev-4.7.4.dist-info}/LICENSE +0 -0
  26. {simba_uw_tf_dev-4.7.2.dist-info → simba_uw_tf_dev-4.7.4.dist-info}/WHEEL +0 -0
  27. {simba_uw_tf_dev-4.7.2.dist-info → simba_uw_tf_dev-4.7.4.dist-info}/entry_points.txt +0 -0
  28. {simba_uw_tf_dev-4.7.2.dist-info → simba_uw_tf_dev-4.7.4.dist-info}/top_level.txt +0 -0
@@ -170,6 +170,7 @@ class SimBAScaleBar(Frame):
170
170
  to: int = 100,
171
171
  tickinterval: Optional[int] = None,
172
172
  troughcolor: Optional[str] = None,
173
+ activebackground: Optional[str] = None,
173
174
  sliderrelief: Literal["raised", "sunken", "flat", "ridge", "solid", "groove"] = 'flat'):
174
175
 
175
176
  super().__init__(master=parent)
@@ -192,7 +193,8 @@ class SimBAScaleBar(Frame):
192
193
  troughcolor=troughcolor,
193
194
  tickinterval=tickinterval,
194
195
  resolution=resolution,
195
- showvalue=showvalue)
196
+ showvalue=showvalue,
197
+ activebackground=activebackground)
196
198
 
197
199
  if label is not None:
198
200
  self.lbl = SimBALabel(parent=self, txt=label, font=lbl_font, txt_clr=label_clr, width=label_width)
@@ -0,0 +1,332 @@
1
+ import os
2
+ from tkinter import *
3
+ from typing import Optional, Union
4
+
5
+ import cv2
6
+ import numpy as np
7
+ from PIL import Image, ImageTk
8
+
9
+ from simba.mixins.image_mixin import ImageMixin
10
+ from simba.ui.tkinter_functions import SimbaButton, SimBALabel, SimBAScaleBar
11
+ from simba.utils.checks import (check_file_exist_and_readable, check_int,
12
+ check_valid_boolean)
13
+ from simba.utils.enums import Formats, TkBinds
14
+ from simba.utils.lookups import get_icons_paths, get_monitor_info
15
+ from simba.utils.read_write import (get_video_meta_data, read_frm_of_video,
16
+ seconds_to_timestamp)
17
+
18
+
19
+ class TimelapseSlider():
20
+
21
+ """
22
+ Interactive timelapse viewer with segment selection sliders.
23
+
24
+ Creates a Tkinter GUI window displaying a timelapse composite image generated from evenly-spaced frames
25
+ across a video. Includes interactive sliders to select start and end times for video segments, with
26
+ visual highlighting of the selected segment and frame previews.
27
+
28
+ .. image:: _static/img/TimelapseSlider.png
29
+ :width: 600
30
+ :align: center
31
+
32
+ :param Union[str, os.PathLike] video_path: Path to video file to create timelapse from.
33
+ :param int frame_cnt: Number of frames to include in timelapse composite. Default 25.
34
+ :param Optional[int] ruler_width: Width per frame in pixels. If None, calculated to match video width. Default None.
35
+ :param Optional[int] crop_ratio: Percentage of frame width to keep (0-100). Default 50.
36
+ :param int padding: Padding in pixels added to timelapse when ruler is shown. Default 60.
37
+ :param int ruler_divisions: Number of major divisions on time ruler. Default 6.
38
+ :param bool show_ruler: If True, display time ruler below timelapse. Default True.
39
+ :param int ruler_height: Height of ruler in pixels. Default 60.
40
+
41
+ :example:
42
+ >>> slider = TimelapseSlider(video_path='path/to/video.mp4', frame_cnt=25, crop_ratio=75)
43
+ >>> slider.run()
44
+ >>> # Use sliders to select segment, then access selected times and frames:
45
+ >>> start_time = slider.get_start_time() # seconds (float)
46
+ >>> end_time = slider.get_end_time() # seconds (float)
47
+ >>> start_time_str = slider.get_start_time_str() # "HH:MM:SS" string
48
+ >>> end_time_str = slider.get_end_time_str() # "HH:MM:SS" string
49
+ >>> start_frame = slider.get_start_frame() # frame number (int)
50
+ >>> end_frame = slider.get_end_frame() # frame number (int)
51
+ >>> slider.close()
52
+ """
53
+
54
+ def __init__(self,
55
+ video_path: Union[str, os.PathLike],
56
+ frame_cnt: int = 25,
57
+ crop_ratio: Optional[int] = 50,
58
+ padding: int = 60,
59
+ ruler_divisions: int = 6,
60
+ show_ruler: bool = True,
61
+ ruler_height: Optional[int] = None,
62
+ ruler_width: Optional[int] = None,
63
+ img_width: Optional[int] = None,
64
+ img_height: Optional[int] = None):
65
+
66
+ check_file_exist_and_readable(file_path=video_path)
67
+ check_int(name='frame_cnt', value=frame_cnt, min_value=1, raise_error=True)
68
+ _, (self.monitor_width, self.monitor_height) = get_monitor_info()
69
+ if ruler_width is not None: check_int(name='size', value=ruler_width, min_value=1, raise_error=True)
70
+ else: ruler_width = int(self.monitor_width * 0.5)
71
+ if ruler_height is not None: check_int(name='ruler_height', value=ruler_height, min_value=1, raise_error=True)
72
+ else: ruler_height = int(self.monitor_height * 0.05)
73
+ if img_width is not None: check_int(name='img_width', value=img_width, min_value=1, raise_error=True)
74
+ else: img_width = int(self.monitor_width * 0.5)
75
+ if img_height is not None: check_int(name='img_height', value=img_height, min_value=1, raise_error=True)
76
+ else: img_height = int(self.monitor_height * 0.5)
77
+
78
+
79
+ check_int(name='padding', value=padding, min_value=1, raise_error=True)
80
+ check_valid_boolean(value=show_ruler, source=f'{self.__class__.__name__} show_ruler', raise_error=True)
81
+ self.video_meta = get_video_meta_data(video_path=video_path, raise_error=True)
82
+ if show_ruler: check_int(name='ruler_divisions', value=ruler_divisions, min_value=1, raise_error=True)
83
+ self.size, self.padding, self.crop_ratio, self.frame_cnt = ruler_width, padding, crop_ratio, frame_cnt
84
+ self.ruler_height, self.video_path, self.show_ruler, self.ruler_divisions = ruler_height, video_path, show_ruler, ruler_divisions
85
+ self.img_width, self.img_height = img_width, img_height
86
+ self.frm_name = f'{self.video_meta["video_name"]} - TIMELAPSE VIEWER - hit "X" or ESC to close'
87
+ self.video_capture, self._pending_frame_update, self._frame_debounce_ms = None, None, 50
88
+
89
+ def _draw_img(self, img: np.ndarray, lbl: SimBALabel):
90
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
91
+ self.pil_image = Image.fromarray(img_rgb)
92
+ self.tk_image = ImageTk.PhotoImage(self.pil_image)
93
+ lbl.configure(image=self.tk_image)
94
+ lbl.image = self.tk_image
95
+
96
+ def _update_selection(self, slider_type: str):
97
+ start_sec = int(self.start_scale.get_value())
98
+ end_sec = int(self.end_scale.get_value())
99
+ max_sec = int(self.video_meta['video_length_s'])
100
+ if slider_type == 'start':
101
+ if start_sec >= end_sec:
102
+ end_sec = min(start_sec + 1, max_sec)
103
+ self.end_scale.set_value(end_sec)
104
+ else:
105
+ if end_sec <= start_sec:
106
+ start_sec = max(end_sec - 1, 0)
107
+ self.start_scale.set_value(start_sec)
108
+
109
+ self.selected_start[0] = start_sec
110
+ self.selected_end[0] = end_sec
111
+
112
+ start_frame = int(start_sec * self.video_meta['fps'])
113
+ end_frame = int(end_sec * self.video_meta['fps'])
114
+ if start_frame >= self.video_meta['frame_count']: start_frame = self.video_meta['frame_count'] - 1
115
+ if end_frame >= self.video_meta['frame_count']: end_frame = self.video_meta['frame_count'] - 1
116
+ if start_frame < 0: start_frame = 0
117
+ if end_frame < 0: end_frame = 0
118
+ self.selected_start_frame[0] = start_frame
119
+ self.selected_end_frame[0] = end_frame
120
+
121
+ self.start_time_label.config(text=seconds_to_timestamp(start_sec), fg='green')
122
+ self.end_time_label.config(text=seconds_to_timestamp(end_sec), fg='red')
123
+
124
+ if self.video_meta['video_length_s'] > 0:
125
+ self._highlight_segment(start_sec, end_sec)
126
+ self._schedule_frame_update(slider_type=slider_type)
127
+
128
+ def _move_start_frame(self, direction: int):
129
+ current_seconds = self.selected_start[0]
130
+ new_seconds = current_seconds + direction
131
+ new_seconds = max(0, min(new_seconds, int(self.video_meta['video_length_s'])))
132
+ self.start_scale.set_value(int(new_seconds))
133
+ self._update_selection(slider_type='start')
134
+ if self._pending_frame_update is not None:
135
+ if hasattr(self, 'img_window') and self.img_window.winfo_exists():
136
+ self.img_window.after_cancel(self._pending_frame_update)
137
+ self._update_frame_display(slider_type='start')
138
+
139
+ def _move_end_frame(self, direction: int):
140
+ current_seconds = self.selected_end[0]
141
+ new_seconds = current_seconds + direction
142
+ new_seconds = max(0, min(new_seconds, int(self.video_meta['video_length_s'])))
143
+ self.end_scale.set_value(int(new_seconds))
144
+ self._update_selection(slider_type='end')
145
+ if self._pending_frame_update is not None:
146
+ if hasattr(self, 'img_window') and self.img_window.winfo_exists():
147
+ self.img_window.after_cancel(self._pending_frame_update)
148
+ self._update_frame_display(slider_type='end')
149
+
150
+ def _schedule_frame_update(self, slider_type: str):
151
+ """Schedule frame preview update with debouncing.
152
+
153
+ Cancels any pending frame update and schedules a new one. If the slider
154
+ moves again before the delay expires, the update is cancelled and rescheduled.
155
+ This prevents expensive frame reads during fast slider dragging.
156
+ """
157
+ if not hasattr(self, 'img_window') or not self.img_window.winfo_exists():
158
+ return
159
+
160
+ if self._pending_frame_update is not None: self.img_window.after_cancel(self._pending_frame_update)
161
+
162
+ self._pending_frame_update = self.img_window.after(self._frame_debounce_ms, lambda: self._update_frame_display(slider_type=slider_type))
163
+
164
+ def _update_frame_display(self, slider_type: str):
165
+ if slider_type == 'start':
166
+ seconds = self.selected_start[0]
167
+ self.frame_label.config(text=f"Start Frame Preview ({seconds_to_timestamp(seconds)})", font=Formats.FONT_LARGE_BOLD.value, fg='green')
168
+ else:
169
+ seconds = self.selected_end[0]
170
+ self.frame_label.config(text=f"End Frame Preview ({seconds_to_timestamp(seconds)})", font=Formats.FONT_LARGE_BOLD.value, fg='red')
171
+
172
+ frame_index = int(seconds * self.video_meta['fps'])
173
+ if frame_index >= self.video_meta['frame_count']: frame_index = self.video_meta['frame_count'] - 1
174
+ if frame_index < 0: frame_index = 0
175
+
176
+ if self.video_capture is not None and self.video_capture.isOpened():
177
+ self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
178
+ ret, frame = self.video_capture.read()
179
+ if ret and frame is not None:
180
+ h, w = frame.shape[:2]
181
+ target_w, target_h = self.img_width, self.img_height
182
+ scale = min(target_w / w, target_h / h)
183
+ new_w, new_h = int(w * scale), int(h * scale)
184
+ frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
185
+ self._draw_img(img=frame, lbl=self.frame_display_lbl)
186
+
187
+ def _highlight_segment(self, start_sec: int, end_sec: int):
188
+ timelapse_width = self.original_timelapse.shape[1]
189
+ start_x = int((start_sec / self.video_meta['video_length_s']) * timelapse_width)
190
+ end_x = int((end_sec / self.video_meta['video_length_s']) * timelapse_width)
191
+ highlighted = self.original_timelapse.copy()
192
+ mask = np.ones(highlighted.shape[:2], dtype=np.uint8) * 128
193
+ mask[:, start_x:end_x] = 255
194
+ mask = cv2.merge([mask, mask, mask])
195
+ highlighted = cv2.multiply(highlighted, mask.astype(np.uint8), scale=1/255.0)
196
+ cv2.line(highlighted, (start_x, 0), (start_x, highlighted.shape[0]), (0, 255, 0), 2)
197
+ cv2.line(highlighted, (end_x, 0), (end_x, highlighted.shape[0]), (0, 255, 0), 2)
198
+ self._draw_img(img=highlighted, lbl=self.img_lbl)
199
+
200
+ def run(self):
201
+ self.video_capture = cv2.VideoCapture(self.video_path)
202
+ if not self.video_capture.isOpened():
203
+ raise ValueError(f"Failed to open video file: {self.video_path}")
204
+
205
+ self.timelapse_img = ImageMixin.get_timelapse_img(video_path=self.video_path, frame_cnt=self.frame_cnt, size=self.size, crop_ratio=self.crop_ratio)
206
+ if self.show_ruler:
207
+ timelapse_height, timelapse_width = self.timelapse_img.shape[0], self.timelapse_img.shape[1]
208
+ padded_timelapse = np.zeros((timelapse_height, timelapse_width + (2 * self.padding), 3), dtype=np.uint8)
209
+ padded_timelapse[:, self.padding:self.padding + timelapse_width] = self.timelapse_img
210
+ ruler = ImageMixin.create_time_ruler(video_path=self.video_path, width=timelapse_width, height=self.ruler_height, num_divisions=self.ruler_divisions)
211
+ self.timelapse_img = cv2.vconcat([padded_timelapse, ruler])
212
+
213
+ self.original_timelapse = self.timelapse_img.copy()
214
+ self.img_window = Toplevel()
215
+ self.img_window.resizable(True, True)
216
+ self.img_window.title(self.frm_name)
217
+ self.img_window.protocol("WM_DELETE_WINDOW", self.close)
218
+ # Bind Escape key to close window
219
+ self.img_window.bind(TkBinds.ESCAPE.value, lambda event: self.close())
220
+
221
+
222
+ self.img_lbl = SimBALabel(parent=self.img_window, txt='')
223
+ self.img_lbl.pack()
224
+ self._draw_img(img=self.timelapse_img, lbl=self.img_lbl)
225
+ self.frame_display_frame = Frame(self.img_window)
226
+ self.frame_display_frame.pack(pady=10, padx=10, fill=BOTH, expand=True)
227
+ self.frame_label = SimBALabel(parent=self.frame_display_frame, txt="Frame Preview", font=Formats.FONT_REGULAR_BOLD.value)
228
+ self.frame_label.pack()
229
+ self.frame_display_lbl = SimBALabel(parent=self.frame_display_frame, txt='', bg_clr='black')
230
+ self.frame_display_lbl.pack(pady=5)
231
+ self.slider_frame = Frame(self.img_window)
232
+ self.slider_frame.pack(pady=10, padx=10, fill=X)
233
+ self.slider_frame.columnconfigure(index=0, weight=1)
234
+ self.slider_frame.columnconfigure(index=1, weight=0)
235
+ self.slider_frame.columnconfigure(index=2, weight=0)
236
+ self.slider_frame.columnconfigure(index=3, weight=0)
237
+ self.slider_frame.columnconfigure(index=4, weight=0)
238
+ self.slider_frame.columnconfigure(index=5, weight=1)
239
+
240
+ self.start_scale = SimBAScaleBar(parent=self.slider_frame, label="START TIME:", from_=0, to=self.video_meta['video_length_s'], orient=HORIZONTAL, length=400, resolution=1, value=0, showvalue=False, label_width=15, sliderrelief='raised', troughcolor='white', activebackground='green', lbl_font=Formats.FONT_LARGE_BOLD.value)
241
+ self.start_scale.grid(row=0, column=1, padx=5)
242
+ self.start_scale.scale.config(command=lambda x: self._update_selection(slider_type='start'))
243
+
244
+ self.start_time_label = SimBALabel(parent=self.slider_frame, txt="00:00:00", font=Formats.FONT_LARGE_BOLD.value, width=10, txt_clr='green')
245
+ self.start_time_label.grid(row=0, column=2, padx=5)
246
+
247
+ self.start_frame_left_btn = SimbaButton(parent=self.slider_frame, txt="-1s", tooltip_txt="Previous second", cmd=self._move_start_frame, cmd_kwargs={'direction': -1}, font=Formats.FONT_REGULAR_BOLD.value, img='left_arrow_green')
248
+ self.start_frame_left_btn.grid(row=0, column=3, padx=2)
249
+ self.start_frame_right_btn = SimbaButton(parent=self.slider_frame, txt="+1s", tooltip_txt="Next second", cmd=self._move_start_frame, cmd_kwargs={'direction': 1}, font=Formats.FONT_REGULAR_BOLD.value, img='right_arrow_green')
250
+ self.start_frame_right_btn.grid(row=0, column=4, padx=2)
251
+
252
+ self.end_scale = SimBAScaleBar(parent=self.slider_frame, label="END TIME:", from_=0, to=int(self.video_meta['video_length_s']), orient=HORIZONTAL, length=400, resolution=1, value=int(self.video_meta['video_length_s']), showvalue=False, label_width=15, sliderrelief='raised', troughcolor='white', activebackground='red', lbl_font=Formats.FONT_LARGE_BOLD.value)
253
+ self.end_scale.grid(row=1, column=1, padx=5)
254
+ self.end_scale.scale.config(command=lambda x: self._update_selection(slider_type='end'))
255
+
256
+ self.end_time_label = SimBALabel(parent=self.slider_frame, txt=seconds_to_timestamp(int(self.video_meta['video_length_s'])), font=Formats.FONT_LARGE_BOLD.value, width=10, txt_clr='red')
257
+ self.end_time_label.grid(row=1, column=2, padx=5)
258
+
259
+ self.end_frame_left_btn = SimbaButton(parent=self.slider_frame, txt="-1s", tooltip_txt="Previous second", cmd=self._move_end_frame, cmd_kwargs={'direction': -1}, font=Formats.FONT_REGULAR_BOLD.value, img='left_arrow_red')
260
+ self.end_frame_left_btn.grid(row=1, column=3, padx=2)
261
+ self.end_frame_right_btn = SimbaButton(parent=self.slider_frame, txt="+1s", tooltip_txt="Next second", cmd=self._move_end_frame, cmd_kwargs={'direction': 1}, font=Formats.FONT_REGULAR_BOLD.value, img='right_arrow_red')
262
+ self.end_frame_right_btn.grid(row=1, column=4, padx=2)
263
+
264
+ self.selected_start = [0]
265
+ self.selected_end = [int(self.video_meta['video_length_s'])]
266
+ self.selected_start_frame = [0]
267
+ end_frame = int(self.video_meta['frame_count']) - 1
268
+ if end_frame < 0: end_frame = 0
269
+ self.selected_end_frame = [end_frame]
270
+
271
+ self.img_window.update_idletasks()
272
+ self.img_window.update()
273
+
274
+ req_width, req_height = self.img_window.winfo_reqwidth(), self.img_window.winfo_reqheight()
275
+ min_width = max(self.timelapse_img.shape[1] + 60, req_width + 20)
276
+ timelapse_height = self.timelapse_img.shape[0]
277
+ frame_preview_height = self.img_height
278
+ slider_height, padding_total = 150, 50
279
+ calculated_min_height = timelapse_height + frame_preview_height + slider_height + padding_total
280
+ min_height = max(calculated_min_height, req_height + 50, timelapse_height + 400)
281
+ max_height = int(self.monitor_height * 0.95)
282
+ if min_height > max_height: min_height = max_height
283
+
284
+ self.img_window.minsize(min_width, min_height)
285
+ self.img_window.geometry(f"{min_width}x{min_height}")
286
+ self._update_frame_display(slider_type='start')
287
+
288
+ def get_start_time(self) -> float:
289
+ return self.selected_start[0]
290
+
291
+ def get_end_time(self) -> float:
292
+ return self.selected_end[0]
293
+
294
+ def get_start_time_str(self) -> str:
295
+ return seconds_to_timestamp(self.selected_start[0])
296
+
297
+ def get_end_time_str(self) -> str:
298
+ return seconds_to_timestamp(self.selected_end[0])
299
+
300
+ def get_start_frame(self) -> int:
301
+ return self.selected_start_frame[0]
302
+
303
+ def get_end_frame(self) -> int:
304
+ return self.selected_end_frame[0]
305
+
306
+ def close(self):
307
+ if self._pending_frame_update is not None:
308
+ if hasattr(self, 'img_window') and self.img_window.winfo_exists():
309
+ self.img_window.after_cancel(self._pending_frame_update)
310
+ self._pending_frame_update = None
311
+
312
+ # Unbind Escape key if window still exists
313
+ if hasattr(self, 'img_window') and self.img_window.winfo_exists():
314
+ try:
315
+ self.img_window.unbind(TkBinds.ESCAPE.value)
316
+ except:
317
+ pass
318
+
319
+ if self.video_capture is not None:
320
+ self.video_capture.release()
321
+ self.video_capture = None
322
+
323
+ if hasattr(self, 'img_window') and self.img_window.winfo_exists():
324
+ self.img_window.destroy()
325
+
326
+
327
+
328
+ #
329
+ # x = TimelapseSlider(video_path=r"E:\troubleshooting\mitra_emergence\project_folder\clip_test\Box1_180mISOcontrol_Females_clipped_progress_bar.mp4",
330
+ # frame_cnt=25,
331
+ # crop_ratio=75)
332
+ # x.run()
simba/utils/lookups.py CHANGED
@@ -1201,4 +1201,70 @@ def check_for_updates(time_out: int = 2):
1201
1201
  else:
1202
1202
  msg = (f'NEW SimBA VERSION AVAILABLE. \nYou have SimBA version {env_simba_version}. \nThe latest version is {latest_simba_version}. '
1203
1203
  f'\nYou can update using "pip install simba-uw-tf-dev --upgrade"')
1204
- stdout_information(msg=msg, source=check_for_updates.__name__)
1204
+ stdout_information(msg=msg, source=check_for_updates.__name__)
1205
+
1206
+
1207
+ def get_ext_codec_map() -> Dict[str, str]:
1208
+ """
1209
+ Get a dictionary mapping video file extensions to their recommended FFmpeg codecs.
1210
+ Automatically falls back to alternative codecs if the preferred codec is not available.
1211
+
1212
+ :return: Dictionary mapping file extensions (without leading dot) to codec names.
1213
+ :rtype: Dict[str, str]
1214
+
1215
+ :example:
1216
+ >>> codec_map = get_ext_codec_map()
1217
+ >>> codec = codec_map.get('webm', 'libx264') # Returns 'libvpx-vp9' or fallback
1218
+ """
1219
+ codecs_available = get_ffmpeg_encoders(raise_error=False)
1220
+ if not codecs_available: codecs_available = []
1221
+
1222
+ common_codecs = ['libx264', 'mpeg4', 'h264', 'mjpeg', 'libx265']
1223
+ fallback_codec = None
1224
+ for codec in common_codecs:
1225
+ if codec in codecs_available:
1226
+ fallback_codec = codec
1227
+ break
1228
+
1229
+ # If no common codec found, use first available or default to mpeg4 (most universal)
1230
+ if fallback_codec is None:
1231
+ fallback_codec = codecs_available[0] if codecs_available else 'mpeg4'
1232
+
1233
+ def get_codec(preferred: str, alternative: str = None) -> str:
1234
+ if preferred in codecs_available:
1235
+ return preferred
1236
+ alt = alternative if alternative else fallback_codec
1237
+ return alt if alt in codecs_available else preferred
1238
+
1239
+ return {
1240
+ 'webm': get_codec(preferred='libvpx-vp9', alternative='libvpx'),
1241
+ 'avi': get_codec(preferred='mpeg4', alternative='libx264'),
1242
+ 'mp4': get_codec(preferred='libx264', alternative='mpeg4'),
1243
+ 'mov': get_codec(preferred='libx264', alternative='mpeg4'),
1244
+ 'mkv': get_codec(preferred='libx264', alternative='mpeg4'),
1245
+ 'flv': get_codec(preferred='libx264', alternative='mpeg4'),
1246
+ 'm4v': get_codec(preferred='libx264', alternative='mpeg4'),
1247
+ 'h264': get_codec(preferred='libx264', alternative='h264'),
1248
+ }
1249
+
1250
+ def get_ffmpeg_codec(file_name: Union[str, os.PathLike],
1251
+ fallback: str = 'mpeg4') -> str:
1252
+ """
1253
+ Get the recommended FFmpeg codec for a video file based on its extension.
1254
+
1255
+ :param Union[str, os.PathLike] file_name: Path to video file or file extension.
1256
+ :param str fallback: Codec to return if file extension is not recognized. Default: 'mpeg4'.
1257
+ :return: Recommended FFmpeg codec name for the video file.
1258
+ :rtype: str
1259
+
1260
+ :example:
1261
+ >>> codec = get_ffmpeg_codec(file_name='video.mp4')
1262
+ >>> codec = get_ffmpeg_codec(file_name='video.webm', fallback='libx264')
1263
+ >>> codec = get_ffmpeg_codec(file_name=r'C:/videos/my_video.avi')
1264
+ """
1265
+ codec_map = get_ext_codec_map()
1266
+ _, file_name, ext = get_fn_ext(filepath=file_name)
1267
+ if ext[1:] in codec_map.keys():
1268
+ return codec_map[ext[1:]]
1269
+ else:
1270
+ return fallback
simba/utils/read_write.py CHANGED
@@ -814,6 +814,7 @@ def read_frm_of_video(video_path: Union[str, os.PathLike, cv2.VideoCapture],
814
814
  frame_index: Optional[int] = 0,
815
815
  opacity: Optional[float] = None,
816
816
  size: Optional[Tuple[int, int]] = None,
817
+ keep_aspect_ratio: bool = False,
817
818
  greyscale: Optional[bool] = False,
818
819
  black_and_white: Optional[bool] = False,
819
820
  clahe: Optional[bool] = False,
@@ -831,7 +832,8 @@ def read_frm_of_video(video_path: Union[str, os.PathLike, cv2.VideoCapture],
831
832
  :param Union[str, os.PathLike, cv2.VideoCapture] video_path: Path to video file, or cv2.VideoCapture object.
832
833
  :param Optional[int] frame_index: The frame index to return (0-based). Default: 0. If -1 is passed, the last frame of the video is read.
833
834
  :param Optional[float] opacity: Value between 0 and 100 or None. If float value, returns image with opacity. 100 fully opaque. 0.0 fully transparent.
834
- :param Optional[Tuple[int, int]] size: If tuple, resizes the image to size. Else, returns original image size.
835
+ :param Optional[Tuple[int, int]] size: If tuple (width, height), resizes the image. If None, returns original image size. When used with keep_aspect_ratio=True, the image is resized to fit within the target size while maintaining aspect ratio.
836
+ :param bool keep_aspect_ratio: If True and size is provided, resizes the image to fit within the target size while maintaining aspect ratio. If False, resizes to exact size (may distort aspect ratio). Default False.
835
837
  :param Optional[bool] greyscale: If True, returns the greyscale image. Default False.
836
838
  :param Optional[bool] black_and_white: If True, returns black and white image at threshold 127. Default False.
837
839
  :param Optional[bool] clahe: If True, returns CLAHE enhanced image. Default False.
@@ -906,8 +908,14 @@ def read_frm_of_video(video_path: Union[str, os.PathLike, cv2.VideoCapture],
906
908
  h, w, clr = img.shape[:3]
907
909
  opacity_image = np.ones((h, w, clr), dtype=np.uint8) * int(255 * opacity)
908
910
  img = cv2.addWeighted( img.astype(np.uint8), 1 - opacity, opacity_image.astype(np.uint8), opacity, 0)
909
- if size:
911
+ if size is not None and not keep_aspect_ratio:
910
912
  img = cv2.resize(img, size, interpolation=cv2.INTER_LINEAR)
913
+ elif size is not None and keep_aspect_ratio:
914
+ target_w, target_h = size
915
+ h, w = img.shape[:2]
916
+ scale = min(target_w / w, target_h / h)
917
+ new_w, new_h = int(w * scale), int(h * scale)
918
+ img = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_LINEAR)
911
919
  if greyscale or black_and_white:
912
920
  if len(img.shape) > 2:
913
921
  img = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
@@ -3761,7 +3769,6 @@ def find_closest_readable_frame(video_path: Union[str, os.PathLike],
3761
3769
  return None, None
3762
3770
 
3763
3771
 
3764
-
3765
3772
  #copy_multiple_videos_to_project(config_path=r"C:\troubleshooting\multi_animal_dlc_two_c57\project_folder\project_config.ini", source=r'E:\maplight_videos\video_test', file_type='mp4', recursive_search=False)
3766
3773
 
3767
3774
 
@@ -12,7 +12,6 @@ import shutil
12
12
 
13
13
  from simba.utils.checks import (check_ffmpeg_available,
14
14
  check_file_exist_and_readable, check_str)
15
- from simba.utils.enums import Formats
16
15
  from simba.utils.errors import CropError, FFMPEGNotFoundError, PermissionError
17
16
  from simba.utils.lookups import (get_current_time, get_ffmpeg_encoders,
18
17
  gpu_quality_to_cpu_quality_lk)