diff --git a/MousePositionTracker.py b/MousePositionTracker.py index 34fb16c..6303bae 100644 --- a/MousePositionTracker.py +++ b/MousePositionTracker.py @@ -13,80 +13,119 @@ import json from tkinter import simpledialog from SelectionObject import SelectionObject -class MousePositionTracker(tk.Frame): - """ Tkinter Canvas mouse position widget. """ +import tqdm + +from tkinter import ttk # Import ttk + +class MousePositionTracker(ttk.Frame): # Change to ttk.Frame + """Tkinter Canvas mouse position tracker and ROI management widget.""" - def __init__(self, canvas,imwidth,imheight,text,my_imo, fps): + def __init__(self, canvas, imwidth, imheight, text, my_imo, fps, shape_type_var=None, selection_obj=None, image_offset_x=0, image_offset_y=0, displayed_im_width=None, displayed_im_height=None): self.text = text self.fps = fps self.canvas = canvas + self.shape_type_var = shape_type_var + self.selection_obj = selection_obj + self.image_offset_x = image_offset_x + self.image_offset_y = image_offset_y + self.displayed_im_width = displayed_im_width if displayed_im_width is not None else 640 + self.displayed_im_height = displayed_im_height if displayed_im_height is not None else 420 self.reset() - self.canv_width = self.canvas.cget('width') - self.canv_height = self.canvas.cget('height') - self.im_height=imheight - self.im_width=imwidth - print(self.im_width) + + # Initialize canvas dimensions and cross-hair lines if a canvas is provided. + if self.canvas is not None: + self.canv_width = self.canvas.cget('width') + self.canv_height = self.canvas.cget('height') + xhair_opts = dict(dash=(3, 2), fill='white', state=tk.HIDDEN) + self.lines = (self.canvas.create_line(0, 0, 0, self.canv_height, **xhair_opts), + self.canvas.create_line(0, 0, self.canv_width, 0, **xhair_opts)) + else: + # Handle cases where MousePositionTracker might be instantiated without a canvas + # (e.g., for batch processing where GUI elements are not needed). + self.canv_width = None + self.canv_height = None + self.lines = (None, None) + + self.im_height = imheight + self.im_width = imwidth self.my_imo = my_imo - self.im_list=[] + self.im_list = [] # Stores PhotoImage objects to prevent garbage collection self.SELECT_OPTS = dict(dash=(2, 2), stipple='gray25', fill='red', outline='') - # Create canvas cross-hair lines. - xhair_opts = dict(dash=(3, 2), fill='white', state=tk.HIDDEN) - self.lines = (self.canvas.create_line(0, 0, 0, self.canv_height, **xhair_opts), - self.canvas.create_line(0, 0, self.canv_width, 0, **xhair_opts)) + self.is_dragging = False # Flag to track mouse drag state def cur_selection(self): + """Returns the start and end coordinates of the current selection.""" return (self.start, self.end) + def track(self): - self.posn_tracker = MousePositionTracker(self.canvas, self.im_width, self.im_height,self.text,self.my_imo, self.fps) - self.selection_obj = SelectionObject(self.canvas, self.SELECT_OPTS) + """Re-initializes the mouse position tracker.""" + # Note: In the original code, this also re-initialized SelectionObject. + # In the refactored code, SelectionObject is passed via __init__ and managed externally. + # Update this to pass displayed_im_width and displayed_im_height + self.posn_tracker = MousePositionTracker(self.canvas, self.im_width, self.im_height,self.text,self.my_imo, self.fps, displayed_im_width=self.displayed_im_width, displayed_im_height=self.displayed_im_height) def begin(self, event): + """Starts a new selection by recording the initial mouse position.""" self.hide() - self.start = (event.x, event.y)# Remember position (no drawing). - self.top_left_X=(event.x) - self.top_left_Y=(event.y) - print("top_left_X",self.top_left_X) - print("im_width",self.im_width) - self.TLX=self.top_left_X*(self.im_width/640) - self.TLY=self.top_left_Y*(self.im_height/420) + self.reset() # Reset previous selection + if self.selection_obj: + self.selection_obj.reset_shape() # Clear any active interactive selection shape + self.start = (event.x, event.y) + # Adjust event coordinates by image offset before storing + adjusted_x = event.x - self.image_offset_x + adjusted_y = event.y - self.image_offset_y + self.top_left_X = adjusted_x + self.top_left_Y = adjusted_y + # Scale coordinates to original image dimensions using displayed image dimensions + self.TLX = self.top_left_X * (self.im_width / self.displayed_im_width) + self.TLY = self.top_left_Y * (self.im_height / self.displayed_im_height) + self.is_dragging = True + def endclick(self, event): + """Records the final mouse position upon button release.""" self.hide() - self.bottom_right_X=(event.x) - self.bottom_right_Y=(event.y) - self.BRX=self.bottom_right_X*(self.im_width/640) - self.BRY=self.bottom_right_Y*(self.im_height/420) + # Adjust event coordinates by image offset before storing + adjusted_x = event.x - self.image_offset_x + adjusted_y = event.y - self.image_offset_y + + self.bottom_right_X = adjusted_x + self.bottom_right_Y = adjusted_y + # Scale coordinates to original image dimensions using displayed image dimensions + self.BRX = self.bottom_right_X * (self.im_width / self.displayed_im_width) + self.BRY = self.bottom_right_Y * (self.im_height / self.displayed_im_height) + def update(self, event): + """Updates the interactive selection shape and cross-hairs during a drag.""" self.end = (event.x, event.y) self._update(event) - self._command(self.start, (event.x, event.y)) # User callback. - + self._command(self.start, (event.x, event.y)) # User callback for selection updates def _update(self, event): - # Update cross-hair lines. + """Internal method to update cross-hair lines.""" self.canvas.coords(self.lines[0], event.x, 0, event.x, self.canv_height) self.canvas.coords(self.lines[1], 0, event.y, self.canv_width, event.y) self.show() - - - def reset(self): + """Resets the stored start and end coordinates.""" self.start = self.end = None def hide(self): + """Hides the cross-hair lines.""" self.canvas.itemconfigure(self.lines[0], state=tk.HIDDEN) self.canvas.itemconfigure(self.lines[1], state=tk.HIDDEN) def show(self): + """Shows the cross-hair lines.""" self.canvas.itemconfigure(self.lines[0], state=tk.NORMAL) self.canvas.itemconfigure(self.lines[1], state=tk.NORMAL) def autodraw(self,command=lambda *args: None): - """Setup automatic drawing; supports command option""" + """Sets up automatic drawing for ROI selection.""" self.reset() - self.ALL_ROIs=pd.DataFrame(columns=['ROI','TLX','TLY','BRX','BRY','FPS']) + self.ALL_ROIs=pd.DataFrame(columns=['ROI','TLX','TLY','BRX','BRY','ShapeType','Color']) # Added ShapeType and Color to initial columns self._command = command self.canvas.bind("", self.begin) self.canvas.bind("", self.update) @@ -94,142 +133,521 @@ def autodraw(self,command=lambda *args: None): self.canvas.bind("", self.endclick) def set_and_name(self): + """Sets the selected ROI, assigns a name and color, and draws it permanently.""" with open('COLOURS.json') as json_file: COLOURS = json.load(json_file) - colour=random.choice(COLOURS) + colour = random.choice(COLOURS) # Select a random color USER_INP = simpledialog.askstring(title="ROI name", prompt="ROI name:") + if not USER_INP: # Handle case where user cancels the dialog + self.text.insert(tk.INSERT, "\nROI naming cancelled.") + if self.selection_obj: + self.selection_obj.reset_shape() + return + self.text.insert(tk.INSERT,("\nThe ROI {} is set and coloured {}".format(USER_INP, colour))) - Current_ROI=pd.DataFrame({'ROI':USER_INP,'TLX':min(self.TLX,self.BRX), - 'TLY':min(self.TLY,self.BRY),'BRX':max(self.TLX,self.BRX), - 'BRY':max(self.TLY,self.BRY)},index=[0]) - self.ALL_ROIs=self.ALL_ROIs.append(Current_ROI) -# img2 = img.crop([ left, upper, right, lower]) - self.canvas.create_rectangle(self.top_left_X,self.top_left_Y,self.bottom_right_X,self.bottom_right_Y,outline=colour,width=5) - img = ImageTk.PhotoImage(self.my_imo.crop((min(self.top_left_X,self.bottom_right_X),min(self.top_left_Y,self.bottom_right_Y),max(self.top_left_X,self.bottom_right_X),max(self.top_left_Y,self.bottom_right_Y)))) - self.im_list.append(img) - self.image_on_canvas=self.canvas.create_image(min(self.top_left_X,self.bottom_right_X),min(self.top_left_Y,self.bottom_right_Y), image=img, anchor=tk.NW) - self.track() + + # Get shape type from the associated Tkinter variable + shape_type = self.shape_type_var.get() if self.shape_type_var else "Rectangle" + + # Get the current coordinates from the SelectionObject, which represents the drawn shape + current_x1 = self.selection_obj.current_x1 + current_y1 = self.selection_obj.current_y1 + current_x2 = self.selection_obj.current_x2 + current_y2 = self.selection_obj.current_y2 + + # Adjust coordinates to be relative to the image's top-left corner + # before scaling for storage. + adjusted_x1 = current_x1 - self.image_offset_x + adjusted_y1 = current_y1 - self.image_offset_y + adjusted_x2 = current_x2 - self.image_offset_x + adjusted_y2 = current_y2 - self.image_offset_y + + # Scale adjusted coordinates back to original image dimensions for data storage + scaled_tlx = min(adjusted_x1, adjusted_x2) * (self.im_width / self.displayed_im_width) + scaled_tly = min(adjusted_y1, adjusted_y2) * (self.im_height / self.displayed_im_height) + scaled_brx = max(adjusted_x1, adjusted_x2) * (self.im_width / self.displayed_im_width) + scaled_bry = max(adjusted_y1, adjusted_y2) * (self.im_height / self.displayed_im_height) + # Create a DataFrame row for the new ROI, including its shape type and color + Current_ROI=pd.DataFrame({'ROI':USER_INP, + 'TLX':scaled_tlx, + 'TLY':scaled_tly, + 'BRX':scaled_brx, + 'BRY':scaled_bry, + 'ShapeType': shape_type, + 'Color': colour},index=[0]) + + # Use _append for future compatibility + self.ALL_ROIs = self.ALL_ROIs._append(Current_ROI, ignore_index=True) + + # Draw the permanent shape on the canvas based on the selected type + if self.canvas is not None and self.shape_type_var is not None: + # Use the raw canvas coordinates directly, as SelectionObject already provides them + # and they are relative to the canvas, not the image's top-left. + display_tlx = min(current_x1, current_x2) + display_tly = min(current_y1, current_y2) + display_brx = max(current_x1, current_x2) + display_bry = max(current_y1, current_y2) + + if shape_type == "Rectangle": + shape_id = self.canvas.create_rectangle(display_tlx, display_tly, display_brx, display_bry, outline=colour, width=5) + elif shape_type == "Circle": + # For circle, the stored coords are the bounding box of the circle + shape_id = self.canvas.create_oval(display_tlx, display_tly, display_brx, display_bry, outline=colour, width=5) + + # Bring the newly created permanent shape to the front + self.canvas.lift(shape_id) + + # After setting and naming, reset the interactive selection object for the next draw + if self.selection_obj: + self.selection_obj.reset_shape() + + def _calculate_bodyparts_to_ROI_data(self): + """ + Calculates which ROI each bodypart is in for every frame. + This method performs the core logic without GUI interaction, making it reusable for batch processing. + Handles both Rectangle and Circle ROI types. + """ + # Get unique body part names from the DataFrame columns + body_part_names = list(dict.fromkeys(self.data.columns.get_level_values(0))) + + # Extract X and Y coordinate data for all body parts + X_data_list = [self.data[(bp_name, 'x')] for bp_name in body_part_names] + Y_data_list = [self.data[(bp_name, 'y')] for bp_name in body_part_names] + + X_data = np.array(pd.concat(X_data_list, axis=1)) + Y_data = np.array(pd.concat(Y_data_list, axis=1)) + + # Apply cropping parameters if the video was cropped in DLC + if self.cropping: + X_data += self.crop_params[0] # Add x-offset + Y_data += self.crop_params[2] # Add y-offset + + # Initialize DataFrame to store ROI assignments for each body part + # Initialize with "Nothing" and object dtype to allow both numbers and strings + My_ROI_df = pd.DataFrame("Nothing", index=self.data.index, columns=body_part_names, dtype=object) + + # Iterate through each defined ROI to check body part containment + for index, ROI in self.ALL_ROIs.iterrows(): + roi_name = ROI['ROI'] + tly = ROI['TLY'] + bry = ROI['BRY'] + tlx = ROI['TLX'] + brx = ROI['BRX'] + # Get ShapeType, defaulting to 'Rectangle' for older ROI files + shape_type = ROI.get('ShapeType', 'Rectangle') + + truth_array = np.zeros_like(X_data, dtype=bool) # Initialize truth array for current ROI + + if shape_type == "Rectangle": + # Check if body part coordinates are within the rectangular bounding box + truth_array = ((Y_data > tly) & (Y_data < bry) & (X_data > tlx) & (X_data < brx)) + + elif shape_type == "Circle": + # Calculate center and radius from the bounding box for a circle + center_x = (tlx + brx) / 2 + center_y = (tly + bry) / 2 + radius = (brx - tlx) / 2 # Assumes bounding box is square for a circle + + # Calculate Euclidean distance from the circle's center for all body parts + distance_from_center = np.sqrt((X_data - center_x)**2 + (Y_data - center_y)**2) + + # Check if distance is within the radius + truth_array = (distance_from_center <= radius) + + else: + print(f"Warning: Unknown shape type '{shape_type}' for ROI '{roi_name}'. Skipping analysis for this ROI.") + + # Assign the ROI name to body parts that fall within this ROI + # Use .loc for boolean indexing and assignment + for i, col_name in enumerate(body_part_names): + My_ROI_df.loc[truth_array[:, i], col_name] = roi_name + + # Determine the majority ROI for each frame across all body parts + My_ROI_df['Majority'] = My_ROI_df.mode(axis=1).iloc[:,0] + + # Calculate entries into regions + # Shift region names down one value and check difference to original region names + entries = (My_ROI_df.Majority.ne(My_ROI_df.Majority.shift())).astype(int) + entries.iloc[0] = 0 # Set first entry to zero as it's not a region entry from a previous one + + # Multiply region entries by ROI names to get the region being entered + # Ensure My_ROI_df.Majority is string for multiplication if entries is 1 + My_ROI_df['entries'] = (entries * My_ROI_df.Majority.astype(str)).astype(str) + + # Determine the region entered from + My_ROI_df['enteredFrom'] = My_ROI_df['entries'] + ' from ' + My_ROI_df['Majority'].shift().astype(str) + My_ROI_df['enteredFrom'] *= entries # Keep only entries where an actual entry occurred + My_ROI_df.fillna('', inplace=True) # Fill NaN values, typically from shift(), with empty strings + + return My_ROI_df def bodyparts_to_ROI(self): - # get the index of X columns - ind_X=['x' in i for i in self.data.columns] - X_data=self.data[self.data.columns[ind_X]] - # get the index of Y columns - ind_Y=['y' in i for i in self.data.columns] - Y_data=self.data[self.data.columns[ind_Y]] - X_data=np.array(X_data) - Y_data=np.array(Y_data) - if self.cropping == True: - X_data += self.crop_params[0] - Y_data += self.crop_params[2] - mylist=self.data.columns.get_level_values(0) - mylist = list( dict.fromkeys(mylist) ) - My_ROI_df=pd.DataFrame(np.zeros(X_data.shape),columns=mylist) - for ROI in self.ALL_ROIs.iterrows(): - truth_array=((Y_data>ROI[1]['TLY'])&(Y_dataROI[1]['TLX'])&(X_dataframes*(np.random.randint(99)*0.01): - self.my_im=Image.fromarray(self.my_im) - self.im_height=self.my_im.height - self.im_width=self.my_im.width - print('test{}'.format(self.im_width)) - height_factor=420/self.my_im.height - width_factor=640/self.my_im.width - self.my_imo=self.my_im.resize((int(self.my_im.width*width_factor),int(self.my_im.height*height_factor))) - self.my_im = ImageTk.PhotoImage(self.my_imo) - self.posn_tracker = MousePositionTracker(self.canvas, self.im_width, self.im_height, self.text,self.my_imo, self.fps) - self.canvas.create_image(0, 0, image=self.my_im, anchor=tk.NW) - self.canvas.img = self.my_im - self.selection_obj = SelectionObject(self.canvas, self.SELECT_OPTS) - - - break - cv2.destroyAllWindows() - vidcap.release() - - - + # Main layout frame + main_frame = ttk.Frame(self, padding="10 10 10 10") + main_frame.pack(fill="both", expand=True) - - self.text = tk.Text(root,height=14) + # Introductory Text Section + text_frame = ttk.LabelFrame(main_frame, text="Instructions", padding="10 10 10 10", style='Section1.TLabelframe') + text_frame.pack(side='bottom', fill='x', padx=10, pady=10) + self.text = ScrolledText(text_frame, height=8, wrap=tk.WORD, bg=SECTION_BG_COLOR_SOBER, fg='white', insertbackground='white') + self.text.insert(tk.INSERT, 'First, load the video you want to draw an ROI on with \"Load Video Frame for ROI selection\"\nThen, Drag the box around your first ROI \n once you are happy with this ROI click \"set and name\" \n To Create a new ROI repeat this process. \n Once you have set and named all of your ROIs. click \"Save ROIs to File\"\n if you have Saved your ROIs in the future you can load them and skip the \n previous steps. next load a deeplabcut h5 or csv coordinate file \n with the \"Load DeepLabCut File\" button, Clicking \"Bodypart to ROI\" will \n output a csv of the region for each frame which you can quickly analyse \n with \"detect entries and time spent\"') + self.text.config(state=tk.DISABLED) + self.text.pack(expand=True, fill='both') + # Create a frame to hold all control sections + controls_frame = ttk.Frame(main_frame, padding="0 0 0 0") # No extra padding here, sections will have their own + controls_frame.pack(side='bottom', fill='x', padx=10, pady=5) # Pack controls above the text frame - self.text.insert(tk.INSERT, 'First, load the video you want to draw an ROI on with \"Load Video Frame for ROI selection\"\nThen, Drag the box around your first ROI \n once you are happy with this ROI click \"set and name\" \n To Create a new ROI repeat this process. \n Once you have set and named all of your ROIs. click \"Save ROIs to File\"\n if you have Saved your ROIs in the future you can load them and skip the \n previous steps. next load a deeplabcut h5 or csv coordinate file \n with the \"Load DeepLabCut File\" button, Clicking \"Bodypart to ROI\" will \n output a csv of the region for each frame which you can quickly analyse \n with \"detect entries and time spent\"') - self.text.pack(side='bottom',expand=True) - - self.canvas = tk.Canvas(root, width=640, height=420, - borderwidth=0, highlightthickness=0) - frame_from_video() - self.posn_tracker = MousePositionTracker(self.canvas, self.im_width, self.im_height, self.text, self.my_imo, self.fps) - button_frame = tk.Frame(root) - button_frame.place(relx=0.5, rely=0.6, anchor='center') - button_frame.columnconfigure(0, weight=1) - button_frame.columnconfigure(1, weight=1) - button_frame.columnconfigure(2, weight=1) - button_frame.columnconfigure(3, weight=1) - button_frame.columnconfigure(4, weight=1) - button_frame.columnconfigure(5, weight=1) - button_frame.columnconfigure(6, weight=1) - button_frame.columnconfigure(7, weight=1) - self.canvas.pack(expand=True, side='top') - self.SetandNameButton = Button(button_frame, text="Set & Name",height=1,command=self.posn_tracker.set_and_name) - self.SaveROItoFile = Button(button_frame, text="Save ROIs to File",height=1,command=self.posn_tracker.save_All_ROIs) - self.LoadROIfromFile = Button(button_frame, text="Load ROI from File",height=1,command=self.posn_tracker.load_ROI_file) - self.LoadDeepLabfromFile = Button(button_frame, text="Load DeepLabCut File",height=1,command=self.posn_tracker.load_deeplab_Coords) - self.LoadVid = Button(button_frame, text="Load Video Frame for ROI selection",height=1,command=frame_from_video) - self.bodyparts_to_ROI_button= Button(button_frame, text="Bodypart to ROI",height=1,command=self.posn_tracker.bodyparts_to_ROI) - self.detect_entries_button= Button(button_frame, text="detect entries and time spent",height=1,command=self.posn_tracker.detect_entries) -# self.time_spent_button= Button(button_frame, text="time spent",height=1,command=self.posn_tracker.time_spent) - # self.quitButton.pack(expand=True) -# self.SetandNameButton.pack(expand=True) + # --- Load Video Frame --- + video_load_frame = ttk.LabelFrame(controls_frame, text="Video Loading", padding="10 10 10 10", style='Section2.TLabelframe') + video_load_frame.pack(fill='x', padx=0, pady=5) # padx=0 as controls_frame has padx=10 + self.LoadVid = ttk.Button(video_load_frame, text="Load Video Frame for ROI selection", command=self.frame_from_video) + self.LoadVid.pack(pady=5, fill='x', expand=True) + + # --- Shape Options --- + shape_options_frame = ttk.LabelFrame(controls_frame, text="Shape Options", padding="10 10 10 10", style='Section3.TLabelframe') + shape_options_frame.pack(fill='x', padx=0, pady=5) - self.canvas.create_image(0, 0, image=self.my_im, anchor=tk.NW) - self.canvas.img = self.my_im # Keep reference. - self.LoadVid.grid(column=0,row=4) - self.SetandNameButton.grid(column=1,row=4) - self.LoadROIfromFile.grid(column=2,row=4) - self.SaveROItoFile.grid(column=3,row=4) - self.LoadDeepLabfromFile.grid(column=0,row=5) - self.bodyparts_to_ROI_button.grid(column=1,row=5) - self.detect_entries_button.grid(column=2, row=5) -# self.time_spent_button.grid(column=7, row=4) + shape_options_inner_frame = ttk.Frame(shape_options_frame) + shape_options_inner_frame.pack(pady=5) + shape_options_inner_frame.columnconfigure(0, weight=1) + shape_options_inner_frame.columnconfigure(1, weight=1) + shape_options_inner_frame.columnconfigure(2, weight=1) + shape_options_inner_frame.columnconfigure(3, weight=1) + shape_options_inner_frame.columnconfigure(4, weight=1) + shape_options_inner_frame.columnconfigure(5, weight=1) + + ttk.Label(shape_options_inner_frame, text="Shape:").grid(column=0, row=0, padx=5, pady=5, sticky='w') + self.shape_type = tk.StringVar(root) + self.shape_type.set("Rectangle") + self.shape_menu = ttk.Combobox(shape_options_inner_frame, textvariable=self.shape_type, values=["Rectangle", "Circle"], state="readonly", width=12) + self.shape_menu.grid(column=1, row=0, padx=5, pady=5, sticky='ew') + + self.dim1_label = ttk.Label(shape_options_inner_frame, text="Width/Diameter (px):") + self.dim1_label.grid(column=2, row=0, padx=5, pady=5, sticky='w') + self.dim1_entry = ttk.Entry(shape_options_inner_frame, width=10) + self.dim1_entry.grid(column=3, row=0, padx=5, pady=5, sticky='ew') + self.dim2_label = ttk.Label(shape_options_inner_frame, text="Height (px):") + self.dim2_label.grid(column=4, row=0, padx=5, pady=5, sticky='w') + self.dim2_entry = ttk.Entry(shape_options_inner_frame, width=10) + self.dim2_entry.grid(column=5, row=0, padx=5, pady=5, sticky='ew') + + # --- ROI Management --- + roi_management_frame = ttk.LabelFrame(controls_frame, text="ROI Management", padding="10 10 10 10", style='Section4.TLabelframe') + roi_management_frame.pack(fill='x', padx=0, pady=5) + roi_management_frame.columnconfigure(0, weight=1) + roi_management_frame.columnconfigure(1, weight=1) + roi_management_frame.columnconfigure(2, weight=1) + + self.SetandNameButton = ttk.Button(roi_management_frame, text="Set & Name ROI", command=lambda: self.posn_tracker.set_and_name()) + self.SetandNameButton.grid(column=0, row=0, padx=5, pady=5, sticky="ew") + self.SaveROItoFile = ttk.Button(roi_management_frame, text="Save ROIs to File", command=lambda: self.posn_tracker.save_All_ROIs()) + self.SaveROItoFile.grid(column=1, row=0, padx=5, pady=5, sticky="ew") + self.LoadROIfromFile = ttk.Button(roi_management_frame, text="Load ROIs from File", command=lambda: self.posn_tracker.load_ROI_file()) + self.LoadROIfromFile.grid(column=2, row=0, padx=5, pady=5, sticky="ew") + + # --- DeepLabCut Analysis --- + dlc_analysis_frame = ttk.LabelFrame(controls_frame, text="DeepLabCut Analysis", padding="10 10 10 10", style='Section5.TLabelframe') + dlc_analysis_frame.pack(fill='x', padx=0, pady=5) + dlc_analysis_frame.columnconfigure(0, weight=1) + dlc_analysis_frame.columnconfigure(1, weight=1) + dlc_analysis_frame.columnconfigure(2, weight=1) + + self.LoadDeepLabfromFile = ttk.Button(dlc_analysis_frame, text="Load DeepLabCut File", command=lambda: self.posn_tracker.load_deeplab_Coords()) + self.LoadDeepLabfromFile.grid(column=0, row=0, padx=5, pady=5, sticky="ew") + self.bodyparts_to_ROI_button= ttk.Button(dlc_analysis_frame, text="Bodypart to ROI", command=lambda: self.posn_tracker.bodyparts_to_ROI()) + self.bodyparts_to_ROI_button.grid(column=1, row=0, padx=5, pady=5, sticky="ew") + self.detect_entries_button= ttk.Button(dlc_analysis_frame, text="Detect Entries and Time Spent", command=lambda: self.posn_tracker.detect_entries()) + self.detect_entries_button.grid(column=2, row=0, padx=5, pady=5, sticky="ew") + + # --- Batch Processing --- + batch_processing_frame = ttk.LabelFrame(controls_frame, text="Batch Processing", padding="10 10 10 10", style='Section1.TLabelframe') # Reusing Section1 style for consistency + batch_processing_frame.pack(fill='x', padx=0, pady=5) + self.ProcessBatchButton = ttk.Button(batch_processing_frame, text="Process Batch", command=self.process_batch_gui) + self.ProcessBatchButton.pack(pady=5, fill='x', expand=True) + + # Canvas Section (packed last, takes remaining space) + canvas_frame = ttk.Frame(main_frame, padding="10 10 10 10") + canvas_frame.pack(side='top', fill='both', expand=True, padx=10, pady=10) + self.canvas = tk.Canvas(canvas_frame, width=640, height=420, + borderwidth=0, highlightthickness=0, bg='black') # Darker canvas background + self.canvas.pack(expand=True, fill='both') + + # Bind the canvas configure event to handle resizing + self.canvas.bind("", self.on_resize) + # Bind dimension entry fields to update shape + self.dim1_entry.bind("", self.update_shape_from_dimensions) + self.dim2_entry.bind("", self.update_shape_from_dimensions) - # Create selection object to show current selection boundaries. - self.selection_obj = SelectionObject(self.canvas, self.SELECT_OPTS) + def frame_from_video(self): + video=filedialog.askopenfilename() + vidcap = cv2.VideoCapture(video) + count = 0 + frames= int(vidcap.get(cv2.CAP_PROP_FRAME_COUNT)) + self.fps = vidcap.get(cv2.CAP_PROP_FPS) + self.text.config(state=tk.NORMAL) + self.text.insert(tk.INSERT,"\nVideo was recorded at {} FPS!".format(self.fps)) + self.text.config(state=tk.DISABLED) + self.SELECT_OPTS = dict(dash=(2, 2), stipple='gray25', fill='red', + outline='') + while vidcap.isOpened(): + success, self.my_im = vidcap.read() + if success: + count += 1 - # Callback function to update it given two points of its diagonal. - def on_drag(start, end, **kwarg): # Must accept these arguments. - self.selection_obj.update(start, end) + if count>frames*(np.random.randint(99)*0.01): + self.my_im=Image.fromarray(self.my_im) + self.im_height=self.my_im.height + self.im_width=self.my_im.width + self.my_imo_original = self.my_im.copy() # Store original PIL image for resizing + print('test{}'.format(self.im_width)) + + # Initial resize to fit canvas + canvas_width = self.canvas.winfo_width() + canvas_height = self.canvas.winfo_height() + + original_aspect_ratio = self.im_width / self.im_height + canvas_aspect_ratio = canvas_width / canvas_height - # Create mouse position tracker that uses the function. + if original_aspect_ratio > canvas_aspect_ratio: + displayed_width = canvas_width + displayed_height = int(canvas_width / original_aspect_ratio) + else: + displayed_height = canvas_height + displayed_width = int(canvas_height * original_aspect_ratio) + + # Ensure dimensions are at least 1 pixel to avoid errors + displayed_width = max(1, displayed_width) + displayed_height = max(1, displayed_height) + + self.my_imo = self.my_imo_original.resize((displayed_width, displayed_height), Image.LANCZOS) + self.my_im = ImageTk.PhotoImage(self.my_imo) + self.selection_obj = SelectionObject(self.canvas, self.SELECT_OPTS, self.shape_type, self.dim1_entry, self.dim2_entry) + + # Center the image on the canvas + image_x = (canvas_width - displayed_width) / 2 + image_y = (canvas_height - displayed_height) / 2 + self.canvas.create_image(image_x, image_y, image=self.my_im, anchor=tk.NW) + self.canvas.img = self.my_im + + # Pass image offsets and displayed dimensions to MousePositionTracker + self.posn_tracker = MousePositionTracker(self.canvas, self.im_width, self.im_height, self.text, self.my_imo, self.fps, shape_type_var=self.shape_type, selection_obj=self.selection_obj, image_offset_x=image_x, image_offset_y=image_y, displayed_im_width=displayed_width, displayed_im_height=displayed_height) + + def on_drag(start, end, **kwarg): + self.selection_obj.update(start, end, self.shape_type.get()) + + break + cv2.destroyAllWindows() + vidcap.release() + + self.posn_tracker.autodraw(command=on_drag) + + def on_resize(self, event=None): # event=None for initial call if needed + # Only proceed if a video has been loaded and original image is available + if not hasattr(self, 'my_imo_original') or self.my_imo_original is None: + return + + # Get current canvas dimensions + new_canvas_width = self.canvas.winfo_width() + new_canvas_height = self.canvas.winfo_height() + + # Avoid division by zero if canvas is too small + if new_canvas_width <= 0 or new_canvas_height <= 0: + return + + # Calculate scaling factors based on original image dimensions + original_aspect_ratio = self.im_width / self.im_height + canvas_aspect_ratio = new_canvas_width / new_canvas_height + + if original_aspect_ratio > canvas_aspect_ratio: + # Image is wider than canvas, scale by width + displayed_width = new_canvas_width + displayed_height = int(new_canvas_width / original_aspect_ratio) + else: + # Image is taller than canvas, scale by height + displayed_height = new_canvas_height + displayed_width = int(new_canvas_height * original_aspect_ratio) + + # Ensure dimensions are at least 1 pixel to avoid errors + displayed_width = max(1, displayed_width) + displayed_height = max(1, displayed_height) + + # Resize the PIL image + self.my_imo = self.my_imo_original.resize((displayed_width, displayed_height), Image.LANCZOS) + self.my_im = ImageTk.PhotoImage(self.my_imo) + + # Clear existing image and draw the new one, centered + self.canvas.delete("all") # Clear everything, including old ROIs and image - self.posn_tracker.autodraw(command=on_drag) # Enable callbacks. + image_x = (new_canvas_width - displayed_width) / 2 + image_y = (new_canvas_height - displayed_height) / 2 + self.canvas.create_image(image_x, image_y, image=self.my_im, anchor=tk.NW) + self.canvas.img = self.my_im # Keep a reference to prevent garbage collection + + # Update MousePositionTracker with new displayed dimensions and offsets + if hasattr(self, 'posn_tracker') and self.posn_tracker is not None: + self.posn_tracker.displayed_im_width = displayed_width + self.posn_tracker.displayed_im_height = displayed_height + self.posn_tracker.image_offset_x = image_x + self.posn_tracker.image_offset_y = image_y + self.posn_tracker.canv_width = new_canvas_width # Update canvas dimensions for crosshairs + self.posn_tracker.canv_height = new_canvas_height + self.posn_tracker.draw_all_loaded_ROIs() # Redraw ROIs + def update_shape_from_dimensions(self, event): + if not hasattr(self, 'selection_obj') or self.selection_obj is None: + print("Please load a video first.") + return + + shape_type = self.shape_type.get() + try: + dim1 = float(self.dim1_entry.get()) + dim2 = float(self.dim2_entry.get()) if self.dim2_entry.get() else None + self.selection_obj.update_from_dimensions(dim1, dim2, shape_type) + except ValueError: + print("Invalid dimension input.") + + def process_batch_gui(self): + """ Prompts user for batch file and triggers batch processing. """ + batch_file_path = filedialog.askopenfilename(filetypes=[("CSV files", "*.csv")]) + if batch_file_path: + batch_tracker = MousePositionTracker( + canvas=None, + imwidth=None, + imheight=None, + text=self.text, + my_imo=None, + fps=None, + shape_type_var=self.shape_type + ) + batch_tracker.process_batch(batch_file_path) + if __name__ == '__main__': WIDTH, HEIGHT = 900, 900 - BACKGROUND = 'grey' TITLE = 'ROI tool' root = tk.Tk() root.title(TITLE) root.geometry('%sx%s' % (WIDTH, HEIGHT)) - root.configure(background=BACKGROUND) - app = Application(root, background=BACKGROUND) + # Initialize ttk.Style for a modern look + style = ttk.Style() + style.theme_use('clam') + root.configure(bg='#2e2e2e') + + app = Application(root) app.pack(side=tk.TOP, fill=tk.BOTH, expand=tk.TRUE) app.mainloop() diff --git a/SelectionObject.py b/SelectionObject.py index 23951bd..798e5fe 100644 --- a/SelectionObject.py +++ b/SelectionObject.py @@ -1,5 +1,3 @@ - - import tkinter as tk from tkinter import * from PIL import Image, ImageTk @@ -12,50 +10,93 @@ import random import glob class SelectionObject: - """ Widget to display a rectangular area on given canvas defined by two points - representing its diagonal. + """ Widget to display a rectangular or circular area on given canvas defined by two points + representing its diagonal (for rectangle) or defining the bounding box (for circle). """ - def __init__(self, canvas, select_opts): + def __init__(self, canvas, select_opts, shape_type_var, dim1_entry, dim2_entry): # Create a selection objects for updating. self.canvas = canvas self.select_opts1 = select_opts - self.width = self.canvas.cget('width') - self.height = self.canvas.cget('height') + self.width = int(self.canvas.cget('width')) + self.height = int(self.canvas.cget('height')) + self.shape_type_var = shape_type_var + self.current_shape = None + self.dimension_text = None + self.dim1_entry = dim1_entry + self.dim2_entry = dim2_entry + + # Store current shape coordinates for resizing + self.current_x1 = None + self.current_y1 = None + self.current_x2 = None + self.current_y2 = None + - # Options for areas outside rectanglar selection. + # Options for areas outside selection. select_opts1 = self.select_opts1.copy() select_opts1.update({'state': tk.HIDDEN}) # Hide initially. - # Separate options for area inside rectanglar selection. + # Separate options for area inside selection. select_opts2 = dict(dash=(2, 2), fill='', outline='white', state=tk.HIDDEN) - # Initial extrema of inner and outer rectangles. - imin_x, imin_y, imax_x, imax_y = 0, 0, 1, 1 - omin_x, omin_y, omax_x, omax_y = 0, 0, self.width, self.height - - self.rects = ( - # Area *outside* selection (inner) rectangle. - self.canvas.create_rectangle(omin_x, omin_y, omax_x, imin_y, **select_opts1), - self.canvas.create_rectangle(omin_x, imin_y, imin_x, imax_y, **select_opts1), - self.canvas.create_rectangle(imax_x, imin_y, omax_x, imax_y, **select_opts1), - self.canvas.create_rectangle(omin_x, imax_y, omax_x, omax_y, **select_opts1), - # Inner rectangle. - self.canvas.create_rectangle(imin_x, imin_y, imax_x, imax_y, **select_opts2) + self.outer_rects = ( + self.canvas.create_rectangle(0, 0, self.width, 1, **select_opts1), + self.canvas.create_rectangle(0, 0, 1, self.height, **select_opts1), + self.canvas.create_rectangle(self.width-1, 0, self.width, self.height, **select_opts1), + self.canvas.create_rectangle(0, self.height-1, self.width, self.height, **select_opts1) ) + self.inner_shape = None # Initialize inner_shape to None + + def update(self, start, end, shape_type): + self.hide() # Hide previous shape + + # Delete previous shape if it exists + if self.inner_shape: + self.canvas.delete(self.inner_shape) + self.inner_shape = None + + # Options for the inner shape - include fill and stipple + select_opts2 = dict(dash=(2, 2), fill='red', outline='white', stipple='gray25') + + if shape_type == "Rectangle": + imin_x, imin_y, imax_x, imax_y = self._get_coords(start, end) + self.inner_shape = self.canvas.create_rectangle(imin_x, imin_y, imax_x, imax_y, **select_opts2) + + # Store the coordinates + self.current_x1 = imin_x + self.current_y1 = imin_y + self.current_x2 = imax_x + self.current_y2 = imax_y + + # Hide outer rectangles for rectangle as fill is now on inner shape + for rect in self.outer_rects: + self.canvas.itemconfigure(rect, state=tk.HIDDEN) + + self._display_dimensions(imin_x, imin_y, imax_x, imax_y, shape_type) + + elif shape_type == "Circle": + center_x = (start[0] + end[0]) / 2 + center_y = (start[1] + end[1]) / 2 + radius = max(abs(start[0] - end[0]), abs(start[1] - end[1])) / 2 + x1 = center_x - radius + y1 = center_y - radius + x2 = center_x + radius + y2 = center_y + radius + self.inner_shape = self.canvas.create_oval(x1, y1, x2, y2, **select_opts2) + + # Store the coordinates (bounding box) + self.current_x1 = x1 + self.current_y1 = y1 + self.current_x2 = x2 + self.current_y2 = y2 - def update(self, start, end): - # Current extrema of inner and outer rectangles. - imin_x, imin_y, imax_x, imax_y = self._get_coords(start, end) - omin_x, omin_y, omax_x, omax_y = 0, 0, self.width, self.height + # Hide outer rectangles for circle (already done, but keep for clarity) + for rect in self.outer_rects: + self.canvas.itemconfigure(rect, state=tk.HIDDEN) - # Update coords of all rectangles based on these extrema. - self.canvas.coords(self.rects[0], omin_x, omin_y, omax_x, imin_y), - self.canvas.coords(self.rects[1], omin_x, imin_y, imin_x, imax_y), - self.canvas.coords(self.rects[2], imax_x, imin_y, omax_x, imax_y), - self.canvas.coords(self.rects[3], omin_x, imax_y, omax_x, omax_y), - self.canvas.coords(self.rects[4], imin_x, imin_y, imax_x, imax_y), + self._display_dimensions(x1, y1, x2, y2, shape_type) + + self.canvas.update_idletasks() # Force canvas update - for rect in self.rects: # Make sure all are now visible. - self.canvas.itemconfigure(rect, state=tk.NORMAL) def _get_coords(self, start, end): """ Determine coords of a polygon defined by the start and @@ -64,6 +105,78 @@ def _get_coords(self, start, end): return (min((start[0], end[0])), min((start[1], end[1])), max((start[0], end[0])), max((start[1], end[1]))) + def _display_dimensions(self, x1, y1, x2, y2, shape_type): + self.dim1_entry.delete(0, tk.END) + self.dim2_entry.delete(0, tk.END) + if shape_type == "Rectangle": + width = abs(x2 - x1) + height = abs(y2 - y1) + self.dim1_entry.insert(0, f"{width:.2f}") + self.dim2_entry.insert(0, f"{height:.2f}") + elif shape_type == "Circle": + diameter = max(abs(x2 - x1), abs(y2 - y1)) + self.dim1_entry.insert(0, f"{diameter:.2f}") + self.dim2_entry.insert(0, f"{diameter:.2f}") # Set height to diameter for circle + + def hide(self): - for rect in self.rects: - self.canvas.itemconfigure(rect, state=tk.NORMAL) \ No newline at end of file + if self.inner_shape: # Check if inner_shape exists before hiding + self.canvas.itemconfigure(self.inner_shape, state=tk.HIDDEN) + for rect in self.outer_rects: + self.canvas.itemconfigure(rect, state=tk.HIDDEN) + + def reset_shape(self): + self.hide() + if self.inner_shape: # Delete shape on reset + self.canvas.delete(self.inner_shape) + self.inner_shape = None + self.dim1_entry.delete(0, tk.END) + self.dim2_entry.delete(0, tk.END) + # Reset stored coordinates on shape reset + self.current_x1 = None + self.current_y1 = None + self.current_x2 = None + self.current_y2 = None + + + def update_from_dimensions(self, dim1, dim2, shape_type): + # Delete previous shape if it exists + if self.inner_shape: + self.canvas.delete(self.inner_shape) + self.inner_shape = None + + # Options for the inner shape - include fill and stipple + select_opts2 = dict(dash=(2, 2), fill='red', outline='white', stipple='gray25') + + # Use stored top-left coordinates as the anchor point + new_x1 = self.current_x1 if self.current_x1 is not None else 0 + new_y1 = self.current_y1 if self.current_y1 is not None else 0 + + if shape_type == "Rectangle": + width = dim1 + height = dim2 + new_x2 = new_x1 + width + new_y2 = new_y1 + height + self.inner_shape = self.canvas.create_rectangle(new_x1, new_y1, new_x2, new_y2, **select_opts2) + + # Hide outer rectangles for rectangle as fill is now on inner shape + for rect in self.outer_rects: + self.canvas.itemconfigure(rect, state=tk.HIDDEN) + + elif shape_type == "Circle": + diameter = dim1 + # For circle, dim2 should be equal to dim1 (diameter) + if dim2 is None or dim2 == "": # If dim2 is not provided or empty, use dim1 + dim2 = dim1 + new_x2 = new_x1 + diameter + new_y2 = new_y1 + diameter + self.inner_shape = self.canvas.create_oval(new_x1, new_y1, new_x2, new_y2, **select_opts2) + + # Update stored coordinates after resizing + self.current_x1 = new_x1 + self.current_y1 = new_y1 + self.current_x2 = new_x2 + self.current_y2 = new_y2 + + self._display_dimensions(new_x1, new_y1, new_x2, new_y2, shape_type) + self.canvas.update_idletasks() # Force canvas update diff --git a/example_batchinput.csv b/example_batchinput.csv new file mode 100644 index 0000000..f911dcd --- /dev/null +++ b/example_batchinput.csv @@ -0,0 +1,2 @@ +shape_file_path,h5_file_path +shapes/circ_right_1.csv,h5data/random_mouse.h5 diff --git a/h5data/random_mouse.h5 b/h5data/random_mouse.h5 new file mode 100644 index 0000000..cc32f09 Binary files /dev/null and b/h5data/random_mouse.h5 differ diff --git a/shapes/circ_right_1.csv b/shapes/circ_right_1.csv new file mode 100644 index 0000000..00418f0 --- /dev/null +++ b/shapes/circ_right_1.csv @@ -0,0 +1,2 @@ +ROI,TLX,TLY,BRX,BRY,FPS,ShapeType +Circ_right_1,460.125,66.28571428571428,614.25,222.85714285714283,30.0,Circle diff --git a/static/ROI.jpg b/static/ROI.jpg deleted file mode 100644 index 5b45e15..0000000 Binary files a/static/ROI.jpg and /dev/null differ diff --git a/static/ROI_tool.jpg b/static/ROI_tool.jpg new file mode 100644 index 0000000..db26390 Binary files /dev/null and b/static/ROI_tool.jpg differ diff --git a/static/jump.PNG b/static/jump.PNG deleted file mode 100644 index 66e888c..0000000 Binary files a/static/jump.PNG and /dev/null differ