Source code for simba.ui.video_timelaps

import os
from tkinter import *
from typing import Optional, Union

import cv2
import numpy as np
from PIL import Image, ImageTk

from simba.mixins.image_mixin import ImageMixin
from simba.ui.tkinter_functions import SimbaButton, SimBALabel, SimBAScaleBar
from simba.utils.checks import (check_file_exist_and_readable, check_int,
                                check_valid_boolean)
from simba.utils.enums import Formats, TkBinds
from simba.utils.lookups import get_icons_paths, get_monitor_info
from simba.utils.read_write import (get_video_meta_data, read_frm_of_video,
                                    seconds_to_timestamp)


[docs]class TimelapseSlider(): """ Interactive timelapse viewer with segment selection sliders. Creates a Tkinter GUI window displaying a timelapse composite image generated from evenly-spaced frames across a video. Includes interactive sliders to select start and end times for video segments, with visual highlighting of the selected segment and frame previews. .. image:: _static/img/TimelapseSlider.png :alt: Timelapse Slider :width: 600 :align: center :param Union[str, os.PathLike] video_path: Path to video file to create timelapse from. :param int frame_cnt: Number of frames to include in timelapse composite. Default 25. :param Optional[int] ruler_width: Width per frame in pixels. If None, calculated to match video width. Default None. :param Optional[int] crop_ratio: Percentage of frame width to keep (0-100). Default 50. :param int padding: Padding in pixels added to timelapse when ruler is shown. Default 60. :param int ruler_divisions: Number of major divisions on time ruler. Default 6. :param bool show_ruler: If True, display time ruler below timelapse. Default True. :param int ruler_height: Height of ruler in pixels. Default 60. :param bool use_timestamps: If True, display timestamps (HH:MM:SS) in labels and ruler. If False, display frame numbers. Default True. :example: >>> slider = TimelapseSlider(video_path='path/to/video.mp4', frame_cnt=25, crop_ratio=75) >>> slider.run() >>> # Use sliders to select segment, then access selected times and frames: >>> start_time = slider.get_start_time() # seconds (float) >>> end_time = slider.get_end_time() # seconds (float) >>> start_time_str = slider.get_start_time_str() # "HH:MM:SS" string >>> end_time_str = slider.get_end_time_str() # "HH:MM:SS" string >>> start_frame = slider.get_start_frame() # frame number (int) >>> end_frame = slider.get_end_frame() # frame number (int) >>> slider.close() """ def __init__(self, video_path: Union[str, os.PathLike], frame_cnt: int = 25, crop_ratio: Optional[int] = 50, padding: int = 60, ruler_divisions: int = 6, show_ruler: bool = True, ruler_height: Optional[int] = None, ruler_width: Optional[int] = None, img_width: Optional[int] = None, img_height: Optional[int] = None, use_timestamps: bool = True): check_file_exist_and_readable(file_path=video_path) check_int(name='frame_cnt', value=frame_cnt, min_value=1, raise_error=True) _, (self.monitor_width, self.monitor_height) = get_monitor_info() if ruler_width is not None: check_int(name='size', value=ruler_width, min_value=1, raise_error=True) else: ruler_width = int(self.monitor_width * 0.5) if ruler_height is not None: check_int(name='ruler_height', value=ruler_height, min_value=1, raise_error=True) else: ruler_height = int(self.monitor_height * 0.05) if img_width is not None: check_int(name='img_width', value=img_width, min_value=1, raise_error=True) else: img_width = int(self.monitor_width * 0.5) if img_height is not None: check_int(name='img_height', value=img_height, min_value=1, raise_error=True) else: img_height = int(self.monitor_height * 0.5) check_int(name='padding', value=padding, min_value=1, raise_error=True) check_valid_boolean(value=show_ruler, source=f'{self.__class__.__name__} show_ruler', raise_error=True) self.video_meta = get_video_meta_data(video_path=video_path, raise_error=True) if show_ruler: check_int(name='ruler_divisions', value=ruler_divisions, min_value=1, raise_error=True) check_valid_boolean(value=use_timestamps, source=f'{self.__class__.__name__} use_timestamps', raise_error=True) self.size, self.padding, self.crop_ratio, self.frame_cnt = ruler_width, padding, crop_ratio, frame_cnt self.ruler_height, self.video_path, self.show_ruler, self.ruler_divisions = ruler_height, video_path, show_ruler, ruler_divisions self.img_width, self.img_height = img_width, img_height self.use_timestamps = use_timestamps self.frm_name = f'{self.video_meta["video_name"]} - TIMELAPSE VIEWER - hit "X" or ESC to close' self.video_capture, self._pending_frame_update, self._frame_debounce_ms = None, None, 50 def _draw_img(self, img: np.ndarray, lbl: SimBALabel): img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) self.pil_image = Image.fromarray(img_rgb) self.tk_image = ImageTk.PhotoImage(self.pil_image) lbl.configure(image=self.tk_image) lbl.image = self.tk_image def _update_selection(self, slider_type: str): start_sec = self.start_scale.get_value() end_sec = self.end_scale.get_value() max_sec = int(self.video_meta['video_length_s']) # Convert to int for timestamp mode, keep float for frame mode to preserve precision if self.use_timestamps: start_sec = int(start_sec) end_sec = int(end_sec) else: # In frame mode, use float precision but ensure we're within bounds start_sec = max(0.0, min(float(start_sec), float(max_sec))) end_sec = max(0.0, min(float(end_sec), float(max_sec))) if slider_type == 'start': if start_sec >= end_sec: end_sec = min(start_sec + (1.0 if self.use_timestamps else 1.0/self.video_meta['fps']), max_sec) self.end_scale.set_value(end_sec) else: if end_sec <= start_sec: start_sec = max(end_sec - (1.0 if self.use_timestamps else 1.0/self.video_meta['fps']), 0) self.start_scale.set_value(start_sec) self.selected_start[0] = start_sec self.selected_end[0] = end_sec start_frame = int(start_sec * self.video_meta['fps']) end_frame = int(end_sec * self.video_meta['fps']) if start_frame >= self.video_meta['frame_count']: start_frame = self.video_meta['frame_count'] - 1 if end_frame >= self.video_meta['frame_count']: end_frame = self.video_meta['frame_count'] - 1 if start_frame < 0: start_frame = 0 if end_frame < 0: end_frame = 0 self.selected_start_frame[0] = start_frame self.selected_end_frame[0] = end_frame if self.use_timestamps: self.start_time_label.config(text=seconds_to_timestamp(start_sec), fg='green') self.end_time_label.config(text=seconds_to_timestamp(end_sec), fg='red') else: start_frame = int(start_sec * self.video_meta['fps']) end_frame = int(end_sec * self.video_meta['fps']) self.start_time_label.config(text=str(start_frame), fg='green') self.end_time_label.config(text=str(end_frame), fg='red') if self.video_meta['video_length_s'] > 0: self._highlight_segment(start_sec, end_sec) self._schedule_frame_update(slider_type=slider_type) def _move_start_frame(self, direction: int): # Temporarily unbind the callback to avoid conflicts original_cmd = self.start_scale.scale.cget('command') self.start_scale.scale.config(command='') try: if self.use_timestamps: current_seconds = self.selected_start[0] new_seconds = current_seconds + direction # Ensure start doesn't exceed end end_seconds = self.selected_end[0] new_seconds = max(0, min(new_seconds, int(self.video_meta['video_length_s']), end_seconds - 1)) self.start_scale.set_value(int(new_seconds)) self.selected_start[0] = int(new_seconds) # Recalculate frame from seconds new_frame = int(new_seconds * self.video_meta['fps']) new_frame = max(0, min(new_frame, self.video_meta['frame_count'] - 1)) self.selected_start_frame[0] = new_frame else: # Get current frame from the selected_start_frame current_frame = self.selected_start_frame[0] new_frame = current_frame + direction # Ensure start doesn't exceed end end_frame = self.selected_end_frame[0] new_frame = max(0, min(new_frame, self.video_meta['frame_count'] - 1, end_frame - 1)) # Convert frame to seconds for the slider new_seconds = new_frame / self.video_meta['fps'] # Update the slider value self.start_scale.set_value(new_seconds) # Directly update both frame and seconds self.selected_start_frame[0] = new_frame self.selected_start[0] = new_seconds # Update the display labels and highlights if self.use_timestamps: self.start_time_label.config(text=seconds_to_timestamp(self.selected_start[0]), fg='green') else: self.start_time_label.config(text=str(self.selected_start_frame[0]), fg='green') # Update highlights and frame preview if self.video_meta['video_length_s'] > 0: self._highlight_segment(self.selected_start[0], self.selected_end[0]) if self._pending_frame_update is not None: if hasattr(self, 'img_window') and self.img_window.winfo_exists(): self.img_window.after_cancel(self._pending_frame_update) self._update_frame_display(slider_type='start') finally: # Rebind the callback self.start_scale.scale.config(command=original_cmd) def _move_end_frame(self, direction: int): # Temporarily unbind the callback to avoid conflicts original_cmd = self.end_scale.scale.cget('command') self.end_scale.scale.config(command='') try: if self.use_timestamps: current_seconds = self.selected_end[0] new_seconds = current_seconds + direction # Ensure end doesn't go below start start_seconds = self.selected_start[0] new_seconds = max(start_seconds + 1, min(new_seconds, int(self.video_meta['video_length_s']))) self.end_scale.set_value(int(new_seconds)) self.selected_end[0] = int(new_seconds) # Recalculate frame from seconds new_frame = int(new_seconds * self.video_meta['fps']) new_frame = max(0, min(new_frame, self.video_meta['frame_count'] - 1)) self.selected_end_frame[0] = new_frame else: # Get current frame from the selected_end_frame current_frame = self.selected_end_frame[0] new_frame = current_frame + direction # Ensure end doesn't go below start start_frame = self.selected_start_frame[0] new_frame = max(start_frame + 1, min(new_frame, self.video_meta['frame_count'] - 1)) # Convert frame to seconds for the slider new_seconds = new_frame / self.video_meta['fps'] # Update the slider value self.end_scale.set_value(new_seconds) # Directly update both frame and seconds self.selected_end_frame[0] = new_frame self.selected_end[0] = new_seconds # Update the display labels and highlights if self.use_timestamps: self.end_time_label.config(text=seconds_to_timestamp(self.selected_end[0]), fg='red') else: self.end_time_label.config(text=str(self.selected_end_frame[0]), fg='red') # Update highlights and frame preview if self.video_meta['video_length_s'] > 0: self._highlight_segment(self.selected_start[0], self.selected_end[0]) if self._pending_frame_update is not None: if hasattr(self, 'img_window') and self.img_window.winfo_exists(): self.img_window.after_cancel(self._pending_frame_update) self._update_frame_display(slider_type='end') finally: # Rebind the callback self.end_scale.scale.config(command=original_cmd) def _schedule_frame_update(self, slider_type: str): """Schedule frame preview update with debouncing. Cancels any pending frame update and schedules a new one. If the slider moves again before the delay expires, the update is cancelled and rescheduled. This prevents expensive frame reads during fast slider dragging. """ if not hasattr(self, 'img_window') or not self.img_window.winfo_exists(): return if self._pending_frame_update is not None: self.img_window.after_cancel(self._pending_frame_update) self._pending_frame_update = self.img_window.after(self._frame_debounce_ms, lambda: self._update_frame_display(slider_type=slider_type)) def _update_frame_display(self, slider_type: str): if slider_type == 'start': seconds = self.selected_start[0] if self.use_timestamps: label_text = f"Start Frame Preview ({seconds_to_timestamp(seconds)})" else: frame_num = int(seconds * self.video_meta['fps']) label_text = f"Start Frame Preview (Frame {frame_num})" self.frame_label.config(text=label_text, font=Formats.FONT_LARGE_BOLD.value, fg='green') else: seconds = self.selected_end[0] if self.use_timestamps: label_text = f"End Frame Preview ({seconds_to_timestamp(seconds)})" else: frame_num = int(seconds * self.video_meta['fps']) label_text = f"End Frame Preview (Frame {frame_num})" self.frame_label.config(text=label_text, font=Formats.FONT_LARGE_BOLD.value, fg='red') frame_index = int(seconds * self.video_meta['fps']) if frame_index >= self.video_meta['frame_count']: frame_index = self.video_meta['frame_count'] - 1 if frame_index < 0: frame_index = 0 if self.video_capture is not None and self.video_capture.isOpened(): self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index) ret, frame = self.video_capture.read() if ret and frame is not None: h, w = frame.shape[:2] target_w, target_h = self.img_width, self.img_height scale = min(target_w / w, target_h / h) new_w, new_h = int(w * scale), int(h * scale) frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_LINEAR) self._draw_img(img=frame, lbl=self.frame_display_lbl) def _highlight_segment(self, start_sec: int, end_sec: int): timelapse_width = self.original_timelapse.shape[1] start_x = int((start_sec / self.video_meta['video_length_s']) * timelapse_width) end_x = int((end_sec / self.video_meta['video_length_s']) * timelapse_width) highlighted = self.original_timelapse.copy() mask = np.ones(highlighted.shape[:2], dtype=np.uint8) * 128 mask[:, start_x:end_x] = 255 mask = cv2.merge([mask, mask, mask]) highlighted = cv2.multiply(highlighted, mask.astype(np.uint8), scale=1/255.0) cv2.line(highlighted, (start_x, 0), (start_x, highlighted.shape[0]), (0, 255, 0), 2) cv2.line(highlighted, (end_x, 0), (end_x, highlighted.shape[0]), (0, 255, 0), 2) self._draw_img(img=highlighted, lbl=self.img_lbl)
[docs] def run(self): self.video_capture = cv2.VideoCapture(self.video_path) if not self.video_capture.isOpened(): raise ValueError(f"Failed to open video file: {self.video_path}") 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) if self.show_ruler: timelapse_height, timelapse_width = self.timelapse_img.shape[0], self.timelapse_img.shape[1] padded_timelapse = np.zeros((timelapse_height, timelapse_width + (2 * self.padding), 3), dtype=np.uint8) padded_timelapse[:, self.padding:self.padding + timelapse_width] = self.timelapse_img ruler = ImageMixin.create_time_ruler(video_path=self.video_path, width=timelapse_width, height=self.ruler_height, num_divisions=self.ruler_divisions, show_time=self.use_timestamps) self.timelapse_img = cv2.vconcat([padded_timelapse, ruler]) self.original_timelapse = self.timelapse_img.copy() self.img_window = Toplevel() self.img_window.resizable(True, True) self.img_window.title(self.frm_name) self.img_window.protocol("WM_DELETE_WINDOW", self.close) # Bind Escape key to close window self.img_window.bind(TkBinds.ESCAPE.value, lambda event: self.close()) self.img_lbl = SimBALabel(parent=self.img_window, txt='') self.img_lbl.pack() self._draw_img(img=self.timelapse_img, lbl=self.img_lbl) self.frame_display_frame = Frame(self.img_window) self.frame_display_frame.pack(pady=10, padx=10, fill=BOTH, expand=True) self.frame_label = SimBALabel(parent=self.frame_display_frame, txt="Frame Preview", font=Formats.FONT_REGULAR_BOLD.value) self.frame_label.pack() self.frame_display_lbl = SimBALabel(parent=self.frame_display_frame, txt='', bg_clr='black') self.frame_display_lbl.pack(pady=5) self.slider_frame = Frame(self.img_window) self.slider_frame.pack(pady=10, padx=10, fill=X) self.slider_frame.columnconfigure(index=0, weight=1) self.slider_frame.columnconfigure(index=1, weight=0) self.slider_frame.columnconfigure(index=2, weight=0) self.slider_frame.columnconfigure(index=3, weight=0) self.slider_frame.columnconfigure(index=4, weight=0) self.slider_frame.columnconfigure(index=5, weight=1) 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) self.start_scale.grid(row=0, column=1, padx=5) self.start_scale.scale.config(command=lambda x: self._update_selection(slider_type='start')) initial_start_text = "00:00:00" if self.use_timestamps else "0" self.start_time_label = SimBALabel(parent=self.slider_frame, txt=initial_start_text, font=Formats.FONT_LARGE_BOLD.value, width=10, txt_clr='green') self.start_time_label.grid(row=0, column=2, padx=5) start_btn_txt = "Previous second" if self.use_timestamps else "Previous frame" start_btn_tooltip = "Move start time back by 1 second" if self.use_timestamps else "Move start frame back by 1 frame" self.start_frame_left_btn = SimbaButton(parent=self.slider_frame, txt=start_btn_txt, tooltip_txt=start_btn_tooltip, cmd=self._move_start_frame, cmd_kwargs={'direction': -1}, font=Formats.FONT_REGULAR_BOLD.value, img='left_arrow_green') self.start_frame_left_btn.grid(row=0, column=3, padx=2) start_btn_txt_right = "Next second" if self.use_timestamps else "Next frame" start_btn_tooltip_right = "Move start time forward by 1 second" if self.use_timestamps else "Move start frame forward by 1 frame" self.start_frame_right_btn = SimbaButton(parent=self.slider_frame, txt=start_btn_txt_right, tooltip_txt=start_btn_tooltip_right, cmd=self._move_start_frame, cmd_kwargs={'direction': 1}, font=Formats.FONT_REGULAR_BOLD.value, img='right_arrow_green') self.start_frame_right_btn.grid(row=0, column=4, padx=2) 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) self.end_scale.grid(row=1, column=1, padx=5) self.end_scale.scale.config(command=lambda x: self._update_selection(slider_type='end')) if self.use_timestamps: initial_end_text = seconds_to_timestamp(int(self.video_meta['video_length_s'])) else: initial_end_text = str(self.video_meta['frame_count'] - 1) self.end_time_label = SimBALabel(parent=self.slider_frame, txt=initial_end_text, font=Formats.FONT_LARGE_BOLD.value, width=10, txt_clr='red') self.end_time_label.grid(row=1, column=2, padx=5) end_btn_txt = "Previous second" if self.use_timestamps else "Previous frame" end_btn_tooltip = "Move end time back by 1 second" if self.use_timestamps else "Move end frame back by 1 frame" self.end_frame_left_btn = SimbaButton(parent=self.slider_frame, txt=end_btn_txt, tooltip_txt=end_btn_tooltip, cmd=self._move_end_frame, cmd_kwargs={'direction': -1}, font=Formats.FONT_REGULAR_BOLD.value, img='left_arrow_red') self.end_frame_left_btn.grid(row=1, column=3, padx=2) end_btn_txt_right = "Next second" if self.use_timestamps else "Next frame" end_btn_tooltip_right = "Move end time forward by 1 second" if self.use_timestamps else "Move end frame forward by 1 frame" self.end_frame_right_btn = SimbaButton(parent=self.slider_frame, txt=end_btn_txt_right, tooltip_txt=end_btn_tooltip_right, cmd=self._move_end_frame, cmd_kwargs={'direction': 1}, font=Formats.FONT_REGULAR_BOLD.value, img='right_arrow_red') self.end_frame_right_btn.grid(row=1, column=4, padx=2) self.selected_start = [0] self.selected_end = [int(self.video_meta['video_length_s'])] self.selected_start_frame = [0] end_frame = int(self.video_meta['frame_count']) - 1 if end_frame < 0: end_frame = 0 self.selected_end_frame = [end_frame] self.img_window.update_idletasks() self.img_window.update() req_width, req_height = self.img_window.winfo_reqwidth(), self.img_window.winfo_reqheight() min_width = max(self.timelapse_img.shape[1] + 60, req_width + 20) timelapse_height = self.timelapse_img.shape[0] frame_preview_height = self.img_height slider_height, padding_total = 150, 50 calculated_min_height = timelapse_height + frame_preview_height + slider_height + padding_total min_height = max(calculated_min_height, req_height + 50, timelapse_height + 400) max_height = int(self.monitor_height * 0.95) if min_height > max_height: min_height = max_height self.img_window.minsize(min_width, min_height) self.img_window.geometry(f"{min_width}x{min_height}") self._update_frame_display(slider_type='start')
[docs] def get_start_time(self) -> float: return self.selected_start[0]
[docs] def get_end_time(self) -> float: return self.selected_end[0]
[docs] def get_start_time_str(self) -> str: return seconds_to_timestamp(self.selected_start[0])
[docs] def get_end_time_str(self) -> str: return seconds_to_timestamp(self.selected_end[0])
[docs] def get_start_frame(self) -> int: return self.selected_start_frame[0]
[docs] def get_end_frame(self) -> int: return self.selected_end_frame[0]
[docs] def close(self): if self._pending_frame_update is not None: if hasattr(self, 'img_window') and self.img_window.winfo_exists(): self.img_window.after_cancel(self._pending_frame_update) self._pending_frame_update = None # Unbind Escape key if window still exists if hasattr(self, 'img_window') and self.img_window.winfo_exists(): try: self.img_window.unbind(TkBinds.ESCAPE.value) except: pass if self.video_capture is not None: self.video_capture.release() self.video_capture = None if hasattr(self, 'img_window') and self.img_window.winfo_exists(): self.img_window.destroy()
# x = TimelapseSlider(video_path=r"E:\troubleshooting\mitra_emergence\project_folder\clip_test\Box1_180mISOcontrol_Females_clipped_progress_bar.mp4", # frame_cnt=25, # crop_ratio=75, # use_timestamps=False) # x.run()