Source code for simba.roi_tools.roi_ruler

__author__ = "Simon Nilsson; sronilsson@gmail.com"

import math
from tkinter import *
from typing import Optional, Tuple

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

from simba.mixins.plotting_mixin import PlottingMixin
from simba.roi_tools.roi_utils import get_image_from_label
from simba.ui.tkinter_functions import SimBALabel
from simba.utils.checks import (check_float, check_if_valid_rgb_tuple,
                                check_instance, check_int)
from simba.utils.enums import TkBinds

DRAW_FRAME_NAME = "DEFINE SHAPE"

[docs]class ROIRuler(object): """ Interactive Tkinter-based ruler tool for measuring distances on ROI images. .. video:: _static/img/ROIRuler.webm :width: 800 :autoplay: :loop: :muted: :align: center :param Toplevel img_window: Tkinter Toplevel window containing an image label named 'img_lbl'. :param Optional[int] thickness: Thickness of the main line in pixels. If None, automatically calculated based on image size using optimal circle size ratio. Default None. :param Optional[int] second_thickness: Thickness of the outline line in pixels. If None, set to 2x the main line thickness. Default None. :param Optional[Tuple[int, int, int]] clr: RGB color tuple (R, G, B) for the main line. If None, uses default text color from TextOptions. Default None. :param Optional[Tuple[int, int, int]] second_clr: RGB color tuple (R, G, B) for the outline line. If None, no outline is drawn. Default None. :param int tolerance: Maximum distance in pixels from a line endpoint to detect a click for moving it. Also minimum line length required to register the line. Default 10. :param Optional[float] px_per_mm: Pixels per millimeter conversion factor. If provided, calculates line length in millimeters. Must be > 0. Default None. :param Optional[SimBALabel] info_label: Optional Tkinter label to display line length information. If provided, automatically updates with "RULER LENGTH: X mm, Y pixels" when line is drawn. Default None. """ def __init__(self, img_window: Toplevel, thickness: Optional[int] = None, second_thickness: Optional[int] = None, clr: Tuple[int, int, int] = None, second_clr: Tuple[int, int, int] = None, tolerance: int = 10, px_per_mm: Optional[float] = None, info_label: Optional[SimBALabel] = None, img_scale_factor: float = 1.0) -> None: check_instance(source=self.__class__.__name__, instance=img_window, accepted_types=(Toplevel,)) if thickness is not None: check_int(name=f'{self.__class__.__name__} thickness', value=thickness, min_value=1) if second_thickness is not None: check_int(name=f'{self.__class__.__name__} second_thickness', value=second_thickness, min_value=1) if px_per_mm is not None: check_float(name=f'{self.__class__.__name__} px_per_mm', value=px_per_mm, min_value=10e-16) check_float(name=f'{self.__class__.__name__} img_scale_factor', value=img_scale_factor, allow_negative=False, allow_zero=False, raise_error=True) check_int(name=f'{self.__class__.__name__} tolerance', value=tolerance, min_value=1) #if info_label is not None: check_instance(source=f'{self.__class__.__name__} info_label', instance=info_label, accepted_types=type(SimBALabel), raise_error=True, warning=False) if clr is not None: check_if_valid_rgb_tuple(data=clr, raise_error=True, source=f'{self.__class__.__name__} clr') else: clr = (0, 255, 255) if second_clr is not None: check_if_valid_rgb_tuple(data=second_clr, raise_error=True, source=f'{self.__class__.__name__} second_clr') self.thickness, self.clr, self.img_window = thickness, clr, img_window self.drawing, self.clr, self.thickness, self.second_thickness, self.tolerance = False, clr, thickness, second_thickness, tolerance self.img_lbl = img_window.nametowidget("img_lbl") self.img, self.info_lbl = get_image_from_label(self.img_lbl), info_label self.click_locs, self.px_per_mm, self.img_scale_factor = {'start': None, 'end': None}, px_per_mm, img_scale_factor self.move_tag, self.second_clr = None, second_clr self.auto_size = PlottingMixin().get_optimal_circle_size(frame_size=tuple(self.img.shape[0:2]), circle_frame_ratio=200) if thickness is None: self.thickness = self.auto_size if second_thickness is None: self.second_thickness = int(self.thickness * 2.0) self.img_cpy, self.got_attributes = self.img.copy(), False self.w, self.h, self.drawing = self.img.shape[1], self.img.shape[0], False self._bind_mouse() def _bind_mouse(self): self.img_window.bind(TkBinds.B1_PRESS.value, self._mouse_press) self.img_window.bind(TkBinds.B1_MOTION.value, self._mouse_move) self.img_window.bind(TkBinds.B1_RELEASE.value, self._mouse_release) def unbind_mouse(self): self.img_window.unbind(TkBinds.B1_PRESS.value) self.img_window.unbind(TkBinds.B1_MOTION.value) self.img_window.unbind(TkBinds.B1_RELEASE.value) def _find_proximal_tag(self, click_coordinate: Tuple[int, int]): proximal_loc, proximal_name = None, None for loc_name, loc_cord in self.click_locs.items(): if loc_cord is not None: distance = math.sqrt((loc_cord[0] - click_coordinate[0]) ** 2 + (loc_cord[1] - click_coordinate[1]) ** 2) if distance <= self.tolerance: proximal_loc, proximal_name = loc_cord, loc_name return proximal_loc, proximal_name def _mouse_press(self, event): if not self.drawing: self.click_locs['start'], self.drawing = (event.x, event.y), True self._update_image(self.img_cpy) else: self.proximal_loc, self.proximal_name = self._find_proximal_tag(click_coordinate=(event.x, event.y)) if self.proximal_loc is not None: self.move_tag = self.proximal_name def _mouse_move(self, event): if self.move_tag is not None: self.click_locs[self.move_tag] = (event.x, event.y) if self.click_locs['start'] is not None and self.click_locs['end'] is not None: self.img_cpy = self.img.copy() if self.second_clr is not None: self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['start']), tuple(self.click_locs['end']), self.second_clr, self.second_thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['end']), tuple(self.click_locs['start']), self.second_clr, self.second_thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['start']), tuple(self.click_locs['end']), self.clr, self.thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['end']), tuple(self.click_locs['start']), self.clr, self.thickness, tipLength=0.2) self._update_image(self.img_cpy) elif self.drawing and self.click_locs['start'] is not None: self.click_locs['end'] = (event.x, event.y) if self.click_locs['end'] is not None: distance = np.linalg.norm(np.array(self.click_locs['start']) - np.array(self.click_locs['end'])) if distance >= self.tolerance: self.img_cpy = self.img.copy() if self.second_clr is not None: self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['start']), tuple(self.click_locs['end']), self.second_clr, self.second_thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['end']), tuple(self.click_locs['start']), self.second_clr, self.second_thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['start']), tuple(self.click_locs['end']), self.clr, self.thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['end']), tuple(self.click_locs['start']), self.clr, self.thickness, tipLength=0.2) self._update_image(self.img_cpy) def _update_image(self, img): img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pil_image = Image.fromarray(img_rgb) tk_image = ImageTk.PhotoImage(pil_image) self.img_lbl.configure(image=tk_image) self.img_lbl.image = tk_image def _mouse_release(self, event): if self.move_tag is not None: self.click_locs[self.move_tag] = (event.x, event.y) self.move_tag = None else: self.click_locs['end'] = (event.x, event.y) distance = np.linalg.norm(np.array(self.click_locs['start']) - np.array(self.click_locs['end'])) if distance >= self.tolerance: self.img_cpy = self.img.copy() if self.second_clr is not None: self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['start']), tuple(self.click_locs['end']), self.second_clr, self.second_thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['end']), tuple(self.click_locs['start']), self.second_clr, self.second_thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['start']), tuple(self.click_locs['end']), self.clr, self.thickness, tipLength=0.2) self.img_cpy = cv2.arrowedLine(self.img_cpy, tuple(self.click_locs['end']), tuple(self.click_locs['start']), self.clr, self.thickness, tipLength=0.2) self._update_image(self.img_cpy) self._get_attributes() def _get_attributes(self): self.start_pos, self.end_pos = self.click_locs['start'], self.click_locs['end'] self.length_px_display = round(np.linalg.norm(np.array(self.click_locs['start']) - np.array(self.click_locs['end'])), 4) self.length_px = round(self.length_px_display / self.img_scale_factor, 4) if self.px_per_mm is not None: self.length_mm = round((self.length_px / self.px_per_mm), 4) else: self.length_mm = None self.got_attributes = True if self.info_lbl is not None: scale_pct = round(100 * self.img_scale_factor) if self.length_mm is not None: line1 = f'Real world: {self.length_mm} mm | Video: {self.length_px} px | On-screen: {self.length_px_display} px' else: line1 = f'Video: {self.length_px} px | On-screen: {self.length_px_display} px' line2_parts = [f'ROI sizes and positions use full-frame coordinates (the Video value above)', f'Image at {scale_pct}% of video size'] if self.px_per_mm is not None: line2_parts.append(f'px/mm = {self.px_per_mm}') else: line2_parts.append('no mm conversion set') line2 = ' • '.join(line2_parts) self.info_lbl.configure(text=f'{line1}\n{line2}', fg='blue') self.info_lbl.update_idletasks()
# # img = cv2.imread(r"C:\Users\sroni\OneDrive\Desktop\webp_20251218114745\BlobTrackingUI.webp") # root = Toplevel() # root.title(DRAW_FRAME_NAME) # img_lbl = Label(root, name='img_lbl') # img_lbl.pack() # # img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # pil_image = Image.fromarray(img_rgb) # tk_image = ImageTk.PhotoImage(pil_image) # img_lbl.configure(image=tk_image) # img_lbl.image = tk_image # # _ = ROIRuler(img_window=root, second_clr=(0, 0, 0), px_per_mm=1.5) # root.mainloop() #