__author__ = "Simon Nilsson; sronilsson@gmail.com"
import os
import platform
from copy import copy
from typing import Tuple, Union
import matplotlib.pyplot as plt
import simba
from simba.mixins.config_reader import ConfigReader
from simba.plotting.tools.tkinter_tools import InteractiveVideoPlotterWindow
from simba.utils.checks import (check_file_exist_and_readable,
check_if_valid_rgb_tuple, check_int,
check_valid_boolean, check_valid_dataframe)
from simba.utils.enums import OS, Formats, Paths
from simba.utils.errors import ColumnNotFoundError, InvalidInputError
from simba.utils.read_write import get_fn_ext, get_video_meta_data, read_df
from simba.utils.warnings import FrameRangeWarning
ICON_WINDOWS = os.path.join(os.path.dirname(simba.__file__), Paths.LOGO_ICON_WINDOWS_PATH.value)
ICON_DARWIN = os.path.join(os.path.dirname(simba.__file__), Paths.LOGO_ICON_DARWIN_PATH.value)
[docs]class InteractiveProbabilityGrapher(ConfigReader):
"""
Launch interactive GUI for inspecting classifier probabilities with synchronized video playback.
Displays probability plot with interactive navigation. Double-click plot to jump to frame, use arrow keys to navigate, space to play/pause.
.. note::
`Validation tutorial <https://github.com/sgoldenlab/simba/blob/master/docs/Scenario1.md#critical-validation-step-before-running-machine-model-on-new-data>`__.
.. image:: _static/img/interactive_probability_plot.png
:alt: Interactive probability plot
:width: 450
:align: center
:param Union[str, os.PathLike] config_path: Path to SimBA project config file.
:param Union[str, os.PathLike] file_path: Path to CSV file with classification probability data.
:param Union[str, os.PathLike] model_path: Path to classifier pickle file (.sav) used to generate probabilities.
:param int lbl_font_size: Font size for axis labels. Default: 16.
:param Tuple[int, int, int] data_clr: RGB color for probability line (0-255). Default: (0, 0, 255) [blue].
:param Tuple[int, int, int] line_clr: RGB color for current frame marker line (0-255). Default: (255, 0, 0) [red].
:param bool show_thresholds: If True, displays threshold lines at 0.25, 0.5, and 0.75. Default: True.
:param bool show_statistics_legend: If True, displays statistics box (max, mean, frame count). Default: True.
:example:
>>> interactive_plotter = InteractiveProbabilityGrapher(config_path='project_config.ini', file_path='features.csv', model_path='classifier.sav')
>>> interactive_plotter.run()
"""
def __init__(self,
config_path: Union[str, os.PathLike],
file_path: Union[str, os.PathLike],
model_path: Union[str, os.PathLike],
lbl_font_size: int = 16,
data_clr: Tuple[int, int, int] = (0, 0, 255),
line_clr: Tuple[int, int, int] = (255, 0, 0),
show_thresholds: bool = True,
show_statistics_legend: bool = True):
ConfigReader.__init__(self, config_path=config_path, read_video_info=False, create_logger=False)
check_file_exist_and_readable(file_path=file_path)
check_file_exist_and_readable(file_path=model_path)
check_int(name=f'{self.__class__.__name__} lbl_font_size', value=lbl_font_size, min_value=1, raise_error=True)
check_if_valid_rgb_tuple(data=data_clr, raise_error=True, source=f'{self.__class__.__name__} data_clr')
check_if_valid_rgb_tuple(data=line_clr, raise_error=True, source=f'{self.__class__.__name__} line_clr')
check_valid_boolean(value=show_thresholds, source=f'{check_valid_boolean.__name__} show_thresholds', raise_error=True)
check_valid_boolean(value=show_statistics_legend, source=f'{check_valid_boolean.__name__} show_statistics_legend', raise_error=True)
self.file_path, self.model_path, self.lbl_font_size = file_path, model_path, lbl_font_size
self.click_counter, self.is_playing = 0, False
_, self.clf_name, _ = get_fn_ext(filepath=self.model_path)
if self.clf_name not in self.clf_names:
raise InvalidInputError(msg=f"The classifier name {self.clf_name} is not a classifier in the SimBA project. Accepted model names: {self.clf_names}. Try re-naming the classifier name or add the classifier name to the SImBA project", source=self.__class__.__name__)
self.data_path = os.path.join(self.project_path, Paths.CLF_DATA_VALIDATION_DIR.value, os.path.basename(self.file_path))
check_file_exist_and_readable(self.data_path)
_, video_name, _ = get_fn_ext(filepath=file_path)
self.data_df = read_df(self.data_path, self.file_type)
p_col = f"Probability_{self.clf_name}"
check_valid_dataframe(df=self.data_df, source=f'{self.__class__.__name__} {self.data_path}', valid_dtypes=Formats.NUMERIC_DTYPES.value, required_fields=[p_col])
self.p_arr = self.data_df[["Probability_{}".format(self.clf_name)]].to_numpy()
current_video_file_path = self.find_video_of_file(video_dir=self.video_dir, filename=video_name, raise_error=True)
self.video_meta_data = get_video_meta_data(video_path=current_video_file_path)
self.data_clr, self.line_clr = tuple([x/255 for x in data_clr]), tuple([x/255 for x in line_clr])
self.show_thresholds, self.show_statistics_legend = show_thresholds, show_statistics_legend
self.play_speed = self.video_meta_data['fps'] / 1000
if self.video_meta_data['frame_count'] != len(self.data_df):
FrameRangeWarning(msg=f'The video {current_video_file_path} contains {self.video_meta_data["frame_count"]} frames, while the data file {self.data_path} contains {len(self.data_df)} frames.', source=self.__class__.__name__)
self.video_frm = InteractiveVideoPlotterWindow(video_path=current_video_file_path, p_arr=self.p_arr)
self.video_frm.main_frm.protocol("WM_DELETE_WINDOW", self._close_windows)
@staticmethod
def __click_event(event):
global current_x_cord
if (event.dblclick) and (event.button == 1) and (type(event.xdata) != None):
current_x_cord = int(event.xdata)
def __key_press_event(self, event):
global current_x_cord
if event.key == ' ':
self.is_playing = not self.is_playing
if event.key == 'left' and current_x_cord is not None and current_x_cord > 0:
current_x_cord -= 1
elif event.key == 'right' and current_x_cord is not None and current_x_cord < len(self.p_arr) - 1:
current_x_cord += 1
def run(self):
import matplotlib
matplotlib.use("TkAgg")
global current_x_cord
prob_val_txt = round(float(self.p_arr[0][0]), 8)
probability_txt = (f"Selected frame: {str(0)}, {self.clf_name} probability: {prob_val_txt}")
plt_title = f"Click on the points of the graph to display the corresponding video frame. \n {probability_txt}"
current_x_cord, prior_x_cord = None, None
fig, ax = plt.subplots(figsize=(12, 6), dpi=100)
fig.patch.set_facecolor('white')
ax.spines['top'].set_visible(False)
ax.spines['right'].set_visible(False)
ax.spines['left'].set_color('#666666')
ax.spines['bottom'].set_color('#666666')
ax.set_facecolor('#f8f9fa')
ax.grid(True, linestyle='--', alpha=0.3, color='gray', linewidth=0.5)
ax.tick_params(axis='both', which='major', labelsize=11, colors='#333333', length=6, width=1.5, direction='out')
ax.plot(self.p_arr, color='black', linewidth=2, alpha=0.1, zorder=1) # shadow
ax.plot(self.p_arr, color=self.data_clr, linewidth=1.5, alpha=0.9, zorder=2, label='Probability')
if self.show_thresholds:
ax.axhline(y=0.75, color='#ec4899', linestyle=(0, (3, 1, 1, 1)), linewidth=1.5, alpha=0.9, label='Threshold: 75%')
ax.axhline(y=0.5, color='#3b82f6', linestyle=(0, (3, 1, 1, 1)), linewidth=1.5, alpha=0.9, label='Threshold: 50%')
ax.axhline(y=0.25, color='#8b5cf6', linestyle=(0, (3, 1, 1, 1)), linewidth=1.5, alpha=0.9, label='Threshold: 25%')
ax.legend(loc='upper right', frameon=True, fancybox=True, framealpha=0.95, edgecolor='#cccccc', fontsize=10)
if self.show_statistics_legend:
stats_text = f"max: {self.p_arr.max():.2f}\nmean: {self.p_arr.mean():.2f}\nframes: {len(self.p_arr)}"
plt.text(0.98, 0.02, stats_text, transform=ax.transAxes, fontsize=max(1, self.lbl_font_size - 6), verticalalignment='bottom', horizontalalignment='right', bbox=dict(boxstyle='round', facecolor='white', alpha=0.8, edgecolor='gray'))
fig.canvas.manager.set_window_title(f"SimBA - {self.clf_name} Probability - {get_fn_ext(filepath=self.file_path)[1]}")
if (platform.system() == OS.WINDOWS.value) and os.path.isfile(ICON_WINDOWS):
fig.canvas.manager.window.iconbitmap(ICON_WINDOWS)
if (platform.system() == OS.MAC.value) and os.path.isfile(ICON_DARWIN):
fig.canvas.manager.window.iconbitmap(ICON_DARWIN)
plt.xlabel("Frame #", fontsize=self.lbl_font_size, fontweight='500')
plt.ylabel(f"{self.clf_name} Probability", fontsize=self.lbl_font_size, fontweight='500')
plt.title(plt_title, fontsize=self.lbl_font_size - 2, pad=20)
line, marker = None, None
ax.text(0.5, 1.20, "Double-click: jump to frame | ← →: navigate | Space: play/pause", transform=ax.transAxes, ha='center', va='bottom', fontsize=10, bbox=dict(boxstyle='round,pad=0.5', facecolor='#f0f0f0', edgecolor='#cccccc', alpha=0.9, linewidth=1.5))
ax.set_ylim(0, 1)
ax.set_yticks([0, 0.25, 0.5, 0.75, 1])
fig.tight_layout()
fig.canvas.draw()
fig.canvas.flush_events()
fig.canvas.mpl_connect("button_press_event", lambda event: self.__click_event(event)) # ADD THIS - it's missing!
fig.canvas.mpl_connect("key_press_event", self.__key_press_event)
plt.show(block=False)
while plt.fignum_exists(fig.number):
if current_x_cord != prior_x_cord and current_x_cord <= self.p_arr.shape[0]:
prior_x_cord = copy(current_x_cord)
xlim = ax.get_xlim()
prob_val_txt = round(float(self.p_arr[current_x_cord][0]), 8)
probability_txt = f"Selected frame: {current_x_cord}, {self.clf_name} probability: {prob_val_txt}"
plt_title = f"Click on the points of the graph to display the corresponding video frame. \n {probability_txt}"
self.video_frm.load_new_frame(frm_cnt=int(current_x_cord))
if line is not None: line.remove()
if marker is not None: marker.pop(0).remove()
plt.title(plt_title)
line = plt.axvline(x=current_x_cord, color=self.line_clr, alpha=0.8, linewidth=2)
marker = ax.plot(current_x_cord, self.p_arr[current_x_cord][0], 'o', markersize=8, color=self.line_clr, markeredgecolor='white', markeredgewidth=2, zorder=5)
ax.set_xlim(xlim)
fig.canvas.draw()
fig.canvas.flush_events()
if self.is_playing and current_x_cord is not None and current_x_cord < len(self.p_arr) - 1:
current_x_cord += 1
plt.ion()
plt.pause(self.play_speed)
self.video_frm.main_frm.destroy()
def _close_windows(self):
try:
self.video_frm.main_frm.destroy()
except:
pass
plt.close('all')
#
# test = InteractiveProbabilityGrapher(config_path=r"C:\troubleshooting\mitra\project_folder\project_config.ini",
# file_path=r"C:\troubleshooting\mitra\project_folder\csv\features_extracted\501_MA142_Gi_CNO_0521.csv",
# model_path=r"C:\troubleshooting\mitra\models\generated_models\straub_tail.sav")
# test.run()
# test = InteractiveProbabilityGrapher(config_path=r"/Users/simon/Desktop/envs/simba/troubleshooting/mitra/project_folder/project_config.ini",
# file_path=r"/Users/simon/Desktop/envs/simba/troubleshooting/mitra/project_folder/csv/validation/704_MA115_Gi_CNO_0521.csv",
# model_path=r"/Users/simon/Desktop/envs/simba/troubleshooting/mitra/models/generated_models/grooming.sav",
# show_statistics_legend=True)
# test.run()
#