diff --git a/setup.py b/setup.py index 336580eef..2ee9c479d 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ # Setup configuration setuptools.setup( name="Simba-UW-tf-dev", - version="2.1.4", + version="2.1.6", author="Simon Nilsson, Jia Jie Choong, Sophia Hwang", author_email="sronilsson@gmail.com", description="Toolkit for computer classification and analysis of behaviors in experimental animals", diff --git a/simba/SimBA.py b/simba/SimBA.py index a6e2bb24f..e262f5545 100644 --- a/simba/SimBA.py +++ b/simba/SimBA.py @@ -624,7 +624,6 @@ def train_multiple_models_from_meta(self, config_path=None): def importBoris(self): ann_folder = askdirectory() boris_appender = BorisAppender(config_path=self.config_path, data_dir=ann_folder) - boris_appender.create_boris_master_file() threading.Thread(target=boris_appender.run).start() def importSolomon(self): diff --git a/simba/assets/lookups/yolo.yaml b/simba/assets/lookups/yolo.yaml new file mode 100644 index 000000000..ef9c2f29a --- /dev/null +++ b/simba/assets/lookups/yolo.yaml @@ -0,0 +1,7 @@ +path: C:\troubleshooting\coco_data # dataset root dir +train: ../images/501_MA142_Gi_CNO_0514 # train images (relative to 'path') 128 images +val: ../images/F0_gq_CNO_0621 # val images (relative to 'path') 128 images +test: ../images/FL_gq_CNO_0625_78 + +names: + 0: animal_1 \ No newline at end of file diff --git a/simba/bounding_box_tools/yolo/__init__.py b/simba/bounding_box_tools/yolo/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/simba/bounding_box_tools/yolo/geometries_to_annotations.py b/simba/bounding_box_tools/yolo/geometries_to_annotations.py new file mode 100644 index 000000000..c14f31ed9 --- /dev/null +++ b/simba/bounding_box_tools/yolo/geometries_to_annotations.py @@ -0,0 +1,180 @@ +import os +from typing import Optional, Union, Dict, Tuple +from pycocotools import mask + +import cv2 +import json +from simba.utils.read_write import read_df, get_video_meta_data, read_frm_of_video +import numpy as np +from simba.mixins.geometry_mixin import GeometryMixin +from shapely.geometry import Polygon +from datetime import datetime +from simba.utils.checks import check_int, check_instance, check_valid_array +from simba.utils.enums import Formats +from skimage.draw import polygon + +def geometry_to_rle(geometry: Union[np.ndarray, Polygon], img_size: Tuple[int, int]): + """ + Converts a geometry (polygon or NumPy array) into a Run-Length Encoding (RLE) mask, suitable for object detection or segmentation tasks. + + :param geometry: The geometry to be converted into an RLE. It can be either a shapely Polygon or a (n, 2) np.ndarray with vertices. + :param img_size: A tuple `(height, width)` representing the size of the image in which the geometry is to be encoded. This defines the dimensions of the output binary mask. + :return: + """ + check_instance(source=geometry_to_rle.__name__, instance=geometry, accepted_types=(Polygon, np.ndarray)) + if isinstance(geometry, (Polygon,)): + geometry = geometry.exterior.coords + else: + check_valid_array(data=geometry, source=geometry_to_rle.__name__, accepted_ndims=(2,), accepted_dtypes=Formats.NUMERIC_DTYPES.value) + binary_mask = np.zeros(img_size, dtype=np.uint8) + rr, cc = polygon(geometry[:, 0].flatten(), geometry[:, 1].flatten(), img_size) + binary_mask[rr, cc] = 1 + rle = mask.encode(np.asfortranarray(binary_mask)) + rle['counts'] = rle['counts'].decode('utf-8') + return rle + +def geometries_to_coco(geometries: Dict[str, np.ndarray], + video_path: Union[str, os.PathLike], + save_dir: Union[str, os.PathLike], + version: Optional[int] = 1, + description: Optional[str] = None, + licences: Optional[str] = None): + """ + :example: + >>> data_path = r"C:\troubleshooting\mitra\project_folder\csv\outlier_corrected_movement_location\FRR_gq_Saline_0624.csv" + >>> animal_data = read_df(file_path=data_path, file_type='csv', usecols=['Nose_x', 'Nose_y', 'Tail_base_x', 'Tail_base_y', 'Left_side_x', 'Left_side_y', 'Right_side_x', 'Right_side_y']).values.reshape(-1, 4, 2)[0:20].astype(np.int32) + >>> animal_polygons = GeometryMixin().bodyparts_to_polygon(data=animal_data) + >>> animal_polygons = GeometryMixin().multiframe_minimum_rotated_rectangle(shapes=animal_polygons) + >>> animal_polygons = GeometryMixin().geometries_to_exterior_keypoints(geometries=animal_polygons) + >>> animal_polygons = GeometryMixin.keypoints_to_axis_aligned_bounding_box(keypoints=animal_polygons) + >>> animal_polygons = {0: animal_polygons} + >>> geometries_to_coco(geometries=animal_polygons, video_path=r'C:\troubleshooting\mitra\project_folder\videos\FRR_gq_Saline_0624.mp4', save_dir=r"C:\troubleshooting\coco_data") + """ + + categories = [] + for cnt, i in enumerate(geometries.keys()): categories.append({'id': i, 'name': i, 'supercategory': i}) + results = {'info': {'year': datetime.now().year, 'version': version, 'description': description}, 'licences': licences, 'categories': categories} + video_data = get_video_meta_data(video_path) + w, h = video_data['width'], video_data['height'] + images = [] + annotations = [] + img_names = [] + if not os.path.isdir(save_dir): os.makedirs(save_dir) + save_img_dir = os.path.join(save_dir, 'img') + if not os.path.isdir(save_img_dir): os.makedirs(save_img_dir) + for category_cnt, (category_id, category_data) in enumerate(geometries.items()): + for img_cnt in range(category_data.shape[0]): + img_geometry = category_data[img_cnt] + img_name = f'{video_data["video_name"]}_{img_cnt}.png' + if img_name not in img_names: + images.append({'id': img_cnt, 'width': w, 'height': h, 'file_name': img_name}) + img = read_frm_of_video(video_path=video_path, frame_index=img_cnt) + img_save_path = os.path.join(save_img_dir, img_name) + cv2.imwrite(img_save_path, img) + img_names.append(img_name) + annotation_id = category_cnt * img_cnt + 1 + d = GeometryMixin().get_shape_lengths_widths(shapes=Polygon(img_geometry)) + a_h, a_w, a_a = d['max_length'], d['max_width'], d['max_area'] + bbox = [int(category_data[img_cnt][0][0]), int(category_data[img_cnt][0][1]), int(a_w), int(a_h)] + rle = geometry_to_rle(geometry=img_geometry, img_size=(h, w)) + annotation = {'id': annotation_id, 'image_id': img_cnt, 'category_id': category_id, 'bbox': bbox, 'area': a_a, 'iscrowd': 0, 'segmentation': rle} + annotations.append(annotation) + results['images'] = images + results['annotations'] = annotations + with open(os.path.join(save_dir, f"annotations.json"), "w") as final: + json.dump(results, final) + + +def geometries_to_yolo(geometries: Dict[Union[str, int], np.ndarray], + video_path: Union[str, os.PathLike], + save_dir: Union[str, os.PathLike], + verbose: Optional[bool] = True, + sample: Optional[int] = None, + obb: Optional[bool] = False) -> None: + """ + Converts geometrical shapes (like polygons) into YOLO format annotations and saves them along with corresponding video frames as images. + + :param Dict[Union[str, int], np.ndarray geometries: A dictionary where the keys represent category IDs (either string or int), and the values are NumPy arrays of shape `(n_frames, n_points, 2)`. Each entry in the array represents the geometry of an object in a particular frame (e.g., keypoints or polygons). + :param Union[str, os.PathLike] video_path: Path to the video file from which frames are extracted. The video is used to extract images corresponding to the geometrical annotations. + :param Union[str, os.PathLike] save_dir: The directory where the output images and YOLO annotation files will be saved. Images will be stored in a subfolder `images/` and annotations in `labels/`. + :param verbose: If `True`, prints progress while processing each frame. This can be useful for monitoring long-running tasks. Default is `True`. + :param sample: If provided, only a random sample of the geometries will be used for annotation. This value represents the number of frames to sample. If `None`, all frames will be processed. Default is `None`. + :param obb: If `True`, uses oriented bounding boxes (OBB) by extracting the four corner points of the geometries. Otherwise, axis-aligned bounding boxes (AABB) are used. Default is `False`. + :return None: + + :example: + >>> data_path = r"C:\troubleshooting\mitra\project_folder\csv\outlier_corrected_movement_location\501_MA142_Gi_CNO_0514.csv" + >>> animal_data = read_df(file_path=data_path, file_type='csv', usecols=['Nose_x', 'Nose_y', 'Tail_base_x', 'Tail_base_y', 'Left_side_x', 'Left_side_y', 'Right_side_x', 'Right_side_y']).values.reshape(-1, 4, 2).astype(np.int32) + >>> animal_polygons = GeometryMixin().bodyparts_to_polygon(data=animal_data) + >>> poygons = GeometryMixin().multiframe_minimum_rotated_rectangle(shapes=animal_polygons) + >>> animal_polygons = GeometryMixin().geometries_to_exterior_keypoints(geometries=poygons) + >>> animal_polygons = {0: animal_polygons} + >>> geometries_to_yolo(geometries=animal_polygons, video_path=r'C:\troubleshooting\mitra\project_folder\videos\501_MA142_Gi_CNO_0514.mp4', save_dir=r"C:\troubleshooting\coco_data", sample=500, obb=True) + """ + + video_data = get_video_meta_data(video_path) + categories = list(geometries.keys()) + w, h = video_data['width'], video_data['height'] + if not os.path.isdir(save_dir): os.makedirs(save_dir) + save_img_dir = os.path.join(save_dir, 'images') + save_labels_dir = os.path.join(save_dir, 'labels') + if not os.path.isdir(save_img_dir): os.makedirs(save_img_dir) + if not os.path.isdir(save_labels_dir): os.makedirs(save_labels_dir) + results, samples = {}, None + if sample is not None: + check_int(name='sample', value=sample, min_value=1, max_value=geometries[categories[0]].shape[0]) + samples = np.random.choice(np.arange(0, geometries[categories[0]].shape[0]-1), sample) + for category_cnt, (category_id, category_data) in enumerate(geometries.items()): + for img_cnt in range(category_data.shape[0]): + if sample is not None and img_cnt not in samples: + continue + else: + if verbose: + print(f'Writing category {category_cnt}, Image: {img_cnt}.') + img_geometry = category_data[img_cnt] + img_name = f'{video_data["video_name"]}_{img_cnt}.png' + if not obb: + shape_stats = GeometryMixin.get_shape_statistics(shapes=Polygon(img_geometry)) + x_center = shape_stats['centers'][0][0] / w + y_center = shape_stats['centers'][0][1] / h + width = shape_stats['widths'][0] / w + height = shape_stats['lengths'][0] / h + img_results = ' '.join([str(category_id), str(x_center), str(y_center), str(width), str(height)]) + else: + img_geometry = img_geometry[1:] + x1, y1 = img_geometry[0][0] / w, img_geometry[0][1] / h + x2, y2 = img_geometry[1][0] / w, img_geometry[1][1] / h + x3, y3 = img_geometry[2][0] / w, img_geometry[2][1] / h + x4, y4 = img_geometry[3][0] / w, img_geometry[3][1] / h + img_results = ' '.join([str(category_id), str(x1), str(y1), str(x2), str(y2), str(x3), str(y3), str(x4), str(y4)]) + if img_name not in results.keys(): + img = read_frm_of_video(video_path=video_path, frame_index=img_cnt) + img_save_path = os.path.join(save_img_dir, img_name) + cv2.imwrite(img_save_path, img) + results[img_name] = [img_results] + else: + results[img_name].append(img_results) + + for k, v in results.items(): + name = k.split(sep='.', maxsplit=2)[0] + file_name = os.path.join(save_labels_dir, f'{name}.txt') + with open(file_name, mode='wt', encoding='utf-8') as myfile: + myfile.write('\n'.join(v)) + + + +#def geometries_to_yolo_obb(geometries: Dict[Union[str, int], np.ndarray]): + + + +# +# +# +data_path = r"C:\troubleshooting\mitra\project_folder\csv\outlier_corrected_movement_location\FL_gq_CNO_0625.csv" +animal_data = read_df(file_path=data_path, file_type='csv', usecols=['Nose_x', 'Nose_y', 'Tail_base_x', 'Tail_base_y', 'Left_side_x', 'Left_side_y', 'Right_side_x', 'Right_side_y']).values.reshape(-1, 4, 2).astype(np.int32) +animal_polygons = GeometryMixin().bodyparts_to_polygon(data=animal_data) +poygons = GeometryMixin().multiframe_minimum_rotated_rectangle(shapes=animal_polygons) +animal_polygons = GeometryMixin().geometries_to_exterior_keypoints(geometries=poygons) +# animal_polygons = GeometryMixin.keypoints_to_axis_aligned_bounding_box(keypoints=animal_polygons) +animal_polygons = {0: animal_polygons} +geometries_to_yolo(geometries=animal_polygons, video_path=r'C:\troubleshooting\mitra\project_folder\videos\FL_gq_CNO_0625.mp4', save_dir=r"C:\troubleshooting\coco_data", sample=500, obb=True) diff --git a/simba/bounding_box_tools/yolo/model.py b/simba/bounding_box_tools/yolo/model.py new file mode 100644 index 000000000..c0c24c92a --- /dev/null +++ b/simba/bounding_box_tools/yolo/model.py @@ -0,0 +1,84 @@ +import os +import numpy as np +import torch +from ultralytics import YOLO +from typing import Union, Optional, Tuple +import multiprocessing +import pandas as pd +import functools +from simba.utils.enums import Defaults +from simba.utils.read_write import get_video_meta_data, read_img_batch_from_video_gpu, find_core_cnt + +def fit_yolo(initial_weights: Union[str, os.PathLike], + data: Union[str, os.PathLike], + project_path: Union[str, os.PathLike], + epochs: Optional[int] = 5, + batch: Optional[Union[int, float]] = 16): + """ + + :param initial_weights: + :param data: + :param project_path: + :param epochs: + :param batch: + :return: + + :example: + >>> fit_yolo(initial_weights=r"C:\troubleshooting\coco_data\weights\yolov8n-obb.pt", data=r"C:\troubleshooting\coco_data\model.yaml", project_path=r"C:\troubleshooting\coco_data\mdl", batch=16) + + """ + + if not torch.cuda.is_available(): + raise ModuleNotFoundError('No GPU detected.') + model = YOLO(initial_weights) + model.train(data=data, epochs=epochs, project=project_path, batch=batch) + +def inference_yolo(weights: Union[str, os.PathLike], + video_path: Union[str, os.PathLike], + batch: Optional[Union[int, float]] = 100, + verbose: Optional[bool] = False, + save_dir: Optional[Union[str, os.PathLike]] = None): + + torch.set_num_threads(8) + model = YOLO(weights, verbose=verbose) + # model.export(format='engine') + # model.to('cuda') + results = [] + out_cols = ['FRAME', 'CLASS', 'CONFIDENCE', 'X1', 'Y1', 'X2', 'Y2', 'X3', 'Y3', 'X4', 'Y4'] + if os.path.isfile(video_path): + _ = get_video_meta_data(video_path=video_path) + video_results = model(video_path) + for frm_cnt, frm in enumerate(video_results): + if frm.obb is not None: + data = np.array(frm.obb.data.cpu()).astype(np.float32) + else: + data = np.array(frm.boxes.data.cpu()).astype(np.float32) + classes = np.unique(data[:, -1]) + for c in classes: + cls_data = data[np.argwhere(data[:, -1] == c)].reshape(-1, data.shape[1]) + cls_data = cls_data[np.argmax(data[:, -2].flatten())] + cord_data = np.array([cls_data[0], cls_data[1], cls_data[0], cls_data[3], cls_data[1], cls_data[3], cls_data[2], cls_data[1]]).astype(np.int32) + results.append([frm_cnt, cls_data[-1], cls_data[-2]] + list(cord_data)) + results = pd.DataFrame(results, columns=out_cols) + + if not save_dir: + return results + + +# r = inference_yolo(weights=r"C:\troubleshooting\coco_data\mdl\train\weights\best.pt", +# video_path=r"C:\troubleshooting\mitra\project_folder\videos\clipped\501_MA142_Gi_CNO_0514_clipped.mp4", +# batch=100) + + +#fit_yolo(initial_weights=r"C:\troubleshooting\coco_data\weights\yolov8n-obb.pt", data=r"C:\troubleshooting\coco_data\model.yaml", project_path=r"C:\troubleshooting\coco_data\mdl", batch=16) + + +# """ +# initial_weights=r"C:\Users\sroni\Downloads\yolov8n.pt", data=r"C:\troubleshooting\coco_data\model.yaml", epochs=30, project_path=r"C:\troubleshooting\coco_data\mdl", batch=16) +# """ +# +# +# + + + diff --git a/simba/data_processors/cuda/circular_statistics.py b/simba/data_processors/cuda/circular_statistics.py index dde3a50e6..8c6dfc80f 100644 --- a/simba/data_processors/cuda/circular_statistics.py +++ b/simba/data_processors/cuda/circular_statistics.py @@ -573,7 +573,7 @@ def sliding_bearing(x: np.ndarray, .. csv-table:: :header: EXPECTED RUNTIMES :file: ../../../docs/tables/sliding_bearing.csv - :widths: 10, 90 + :widths: 10, 45, 45 :align: center :header-rows: 1 @@ -638,7 +638,7 @@ def sliding_angular_diff(x: np.ndarray, .. csv-table:: :header: EXPECTED RUNTIMES :file: ../../../docs/tables/sliding_angular_diff.csv - :widths: 10, 90 + :widths: 10, 45, 45 :align: center :header-rows: 1 @@ -674,4 +674,60 @@ def sliding_angular_diff(x: np.ndarray, results = results.copy_to_host().astype(np.int32) return results +@cuda.jit() +def _rotational_direction(data, stride, results): + r = cuda.grid(1) + l = int(r - stride[0]) + if (r < 0) or (r > data.shape[0] - 1): + return + elif (l < 0): + return + else: + l_val, r_val = data[l], data[r] + angle_diff = r_val - l_val + if angle_diff > np.pi: + angle_diff -= 2 * np.pi + elif angle_diff < -np.pi: + angle_diff += 2 * np.pi + if angle_diff == 0: + results[r] = 0 + elif angle_diff > 0: + results[r] = 1 + else: + results[r] = 2 + +def rotational_direction(data: np.ndarray, stride: Optional[int] = 1) -> np.ndarray: + """ + Computes the rotational direction between consecutive data points in a circular space, where the angles wrap + around at 360 degrees. The function uses GPU acceleration via CUDA to process the data in parallel. + + The result array contains values: + + * `0` where there is no change between points. + * `1` where the angle has increased in the positive direction. + * `2` where the angle has decreased in the negative direction. + + .. seealso:: + :func:`simba.mixins.circular_statistics.CircularStatisticsMixin.rotational_direction` for jitted CPU method. + + :param np.ndarray data: 1D array of angular data (in degrees) to analyze. The data will be internally converted to radians and wrapped between [0, 360) degrees before processing. + :param Optional[int] stride: The stride or gap between data points for which the rotational direction is calculated. Default is 1. + :return: A 1D array of integers of the same length as `data`, where each element indicates the rotational direction between the current and previous point based on the stride. The first `stride` elements in the result will be initialized to -1 since they cannot be compared. + :rtype: np.ndarray + + :example: + >>> data = np.random.randint(0, 365, (100)) + >>> p = rotational_direction(data=data) + """ + data = np.deg2rad(data % 360) + results = np.full((data.shape[0]), fill_value=-1, dtype=np.int16) + results_dev = cuda.to_device(results) + stride = np.array([stride]) + stride_dev = cuda.to_device(stride) + data_dev = cuda.to_device(data) + bpg = (data.shape[0] + (THREADS_PER_BLOCK - 1)) // THREADS_PER_BLOCK + _rotational_direction[bpg, THREADS_PER_BLOCK](data_dev, stride_dev, results_dev) + return results_dev.copy_to_host() + + diff --git a/simba/data_processors/cuda/geometry.py b/simba/data_processors/cuda/geometry.py index 5ab83dffa..8668c0888 100644 --- a/simba/data_processors/cuda/geometry.py +++ b/simba/data_processors/cuda/geometry.py @@ -345,7 +345,7 @@ def find_midpoints(x: np.ndarray, .. csv-table:: :header: EXPECTED RUNTIMES :file: ../../../docs/tables/find_midpoints.csv - :widths: 10, 90 + :widths: 10, 45, 45 :align: center :class: simba-table :header-rows: 1 @@ -390,3 +390,6 @@ def find_midpoints(x: np.ndarray, + + + diff --git a/simba/data_processors/cuda/image.py b/simba/data_processors/cuda/image.py index 46d21ea04..c63ba8d89 100644 --- a/simba/data_processors/cuda/image.py +++ b/simba/data_processors/cuda/image.py @@ -29,16 +29,16 @@ check_nvidea_gpu_available, check_that_hhmmss_start_is_before_end, check_valid_array, check_valid_boolean) +from simba.data_processors.cuda.utils import _cuda_mse from simba.utils.data import find_frame_numbers_from_time_stamp from simba.utils.enums import Formats from simba.utils.errors import FFMPEGCodecGPUError, InvalidInputError from simba.utils.printing import SimbaTimer, stdout_success -from simba.utils.read_write import ( - check_if_hhmmss_timestamp_is_valid_part_of_video, get_fn_ext, - get_video_meta_data, read_img_batch_from_video_gpu) +from simba.utils.read_write import (check_if_hhmmss_timestamp_is_valid_part_of_video, get_fn_ext, get_video_meta_data, read_img_batch_from_video_gpu) PHOTOMETRIC = 'photometric' DIGITAL = 'digital' +THREADS_PER_BLOCK = 20124 def create_average_frm_cupy(video_path: Union[str, os.PathLike], start_frm: Optional[int] = None, @@ -73,6 +73,7 @@ def create_average_frm_cupy(video_path: Union[str, os.PathLike], :example: >>> create_average_frm_cupy(video_path=r"C:\troubleshooting\RAT_NOR\project_folder\videos\2022-06-20_NOB_DOT_4_downsampled.mp4", verbose=True, start_frm=0, end_frm=9000) + """ def average_3d_stack(image_stack: np.ndarray) -> np.ndarray: @@ -443,7 +444,7 @@ def img_stack_to_grayscale_cupy(imgs: np.ndarray, """ Converts a stack of color images to grayscale using GPU acceleration with CuPy. - .. seelalso:: + .. seealso:: For CPU function single images :func:`~simba.mixins.image_mixin.ImageMixin.img_to_greyscale` and :func:`~simba.mixins.image_mixin.ImageMixin.img_stack_to_greyscale` for stack. For CUDA JIT, see :func:`~simba.data_processors.cuda.image.img_stack_to_grayscale_cuda`. @@ -504,7 +505,7 @@ def img_stack_to_grayscale_cuda(x: np.ndarray) -> np.ndarray: """ Convert image stack to grayscale using CUDA. - .. seelalso:: + .. seealso:: For CPU function single images :func:`~simba.mixins.image_mixin.ImageMixin.img_to_greyscale` and :func:`~simba.mixins.image_mixin.ImageMixin.img_stack_to_greyscale` for stack. For CuPy, see :func:`~simba.data_processors.cuda.image.img_stack_to_grayscale_cupy`. @@ -837,3 +838,68 @@ def slice_imgs(video_path: Union[str, os.PathLike], if verbose: stdout_success(msg='Shapes sliced in video.', elapsed_time=timer.elapsed_time_str) return results + + +@cuda.jit() +def _sliding_psnr(data, stride, results): + r = cuda.grid(1) + l = int(r - stride[0]) + if (r < 0) or (r > data.shape[0] -1): + return + if l < 0: + return + else: + img_1, img_2 = data[r], data[l] + mse = _cuda_mse(img_1, img_2) + if mse == 0: + results[r] = 0.0 + else: + results[r] = 20 * math.log10(255 / math.sqrt(mse)) + +def sliding_psnr(data: np.ndarray, + stride_s: int, + sample_rate: float) -> np.ndarray: + """ + Computes the Peak Signal-to-Noise Ratio (PSNR) between pairs of images in a stack using a sliding window approach. + + This function calculates PSNR for each image in a stack compared to another image in the stack that is separated by a specified stride. + The sliding window approach allows for the comparison of image quality over a sequence of images. + + .. note:: + - PSNR values are measured in decibels (dB). + - Higher PSNR values indicate better quality with minimal differences from the reference image. + - Lower PSNR values indicate higher distortion or noise. + + .. math:: + + \text{PSNR} = 20 \cdot \log_{10} \left( \frac{\text{MAX}}{\sqrt{\text{MSE}}} \right) + + where: + - :math:`\text{MAX}` is the maximum possible pixel value (255 for 8-bit images). + - :math:`\text{MSE}` is the Mean Squared Error between the two images. + + :param data: A 4D NumPy array of shape (N, H, W, C) representing a stack of images, where N is the number of images, H is the height, W is the width, and C is the number of color channels. + :param stride_s: The base stride length in terms of the number of images between the images being compared. Determines the separation between images for comparison in the stack. + :param sample_rate: The sample rate to scale the stride length. This allows for adjusting the stride dynamically based on the sample rate. + :return: A 1D NumPy array of PSNR values, where each element represents the PSNR between the image at index `r` and the image at index `l = r - stride`, for all valid indices `r`. + :rtype: np.ndarray + + :example: + >>> data = ImageMixin().read_img_batch_from_video(video_path =r"/mnt/c/troubleshooting/mitra/project_folder/videos/clipped/501_MA142_Gi_CNO_0514_clipped.mp4", start_frm=0, end_frm=299) + >>> data = np.stack(list(data.values()), axis=0).astype(np.uint8) + >>> data = ImageMixin.img_stack_to_greyscale(imgs=data) + >>> p = sliding_psnr(data=data, stride_s=1, sample_rate=1) + """ + + results = np.full(data.shape[0], fill_value=255.0, dtype=np.float32) + stride = np.array([stride_s * sample_rate], dtype=np.int32) + if stride[0] < 1: stride[0] = 1 + stride_dev = cuda.to_device(stride) + results_dev = cuda.to_device(results) + data_dev = cuda.to_device(data) + bpg = (data.shape[0] + (THREADS_PER_BLOCK - 1)) // THREADS_PER_BLOCK + _sliding_psnr[bpg, THREADS_PER_BLOCK](data_dev, stride_dev, results_dev) + return results_dev.copy_to_host() + + + diff --git a/simba/data_processors/cuda/statistics.py b/simba/data_processors/cuda/statistics.py index 81f81c40c..898542e0f 100644 --- a/simba/data_processors/cuda/statistics.py +++ b/simba/data_processors/cuda/statistics.py @@ -3,7 +3,7 @@ import math -from typing import Optional +from typing import Optional, Tuple import numpy as np from numba import cuda @@ -12,10 +12,11 @@ try: import cupy as cp + from cupyx.scipy.spatial.distance import cdist except: import numpy as cp -from simba.utils.checks import check_int, check_valid_array +from simba.utils.checks import check_int, check_valid_array, check_valid_tuple from simba.utils.enums import Formats THREADS_PER_BLOCK = 256 @@ -111,7 +112,7 @@ def count_values_in_ranges(x: np.ndarray, r: np.ndarray) -> np.ndarray: .. csv-table:: :header: EXPECTED RUNTIMES :file: ../../../docs/tables/count_values_in_ranges.csv - :widths: 10, 90 + :widths: 10, 45, 45 :align: center :header-rows: 1 @@ -478,4 +479,34 @@ def sliding_sum(x: np.ndarray, time_window: float, sample_rate: int) -> np.ndarr bpg = (x.shape[0] + (THREADS_PER_BLOCK - 1)) // THREADS_PER_BLOCK _cuda_sliding_sum[bpg, THREADS_PER_BLOCK](x_dev, delta_dev, results) results = results.copy_to_host() - return results \ No newline at end of file + return results + + +def euclidean_distance_to_static_point(data: np.ndarray, + point: Tuple[int, int], + pixels_per_millimeter: Optional[int] = 1, + centimeter: Optional[bool] = False, + batch_size: Optional[int] = int(6.5e+7)) -> np.ndarray: + """ + Computes the Euclidean distance between each point in a given 2D array `data` and a static point using GPU acceleration. + + :param data: A 2D array of shape (N, 2), where N is the number of points, and each point is represented by its (x, y) coordinates. The array can represent pixel coordinates. + :param point: A tuple of two integers representing the static point (x, y) in the same space as `data`. + :param pixels_per_millimeter: A scaling factor that indicates how many pixels correspond to one millimeter. Defaults to 1 if no scaling is necessary. + :param centimeter: A flag to indicate whether the output distances should be converted from millimeters to centimeters. If True, the result is divided by 10. Defaults to False (millimeters). + :param batch_size: The number of points to process in each batch to avoid memory overflow on the GPU. The default batch size is set to 65 million points (6.5e+7). Adjust this parameter based on GPU memory capacity. + :return: A 1D array of distances between each point in `data` and the static `point`, either in millimeters or centimeters depending on the `centimeter` flag. + :rtype: np.ndarray + """ + check_valid_array(data=data, source=euclidean_distance_to_static_point.__name__, accepted_ndims=(2,), accepted_dtypes=Formats.NUMERIC_DTYPES.value, accepted_axis_1_shape=[2,]) + check_valid_tuple(x=point, source=euclidean_distance_to_static_point.__name__, accepted_lengths=(2,), valid_dtypes=Formats.NUMERIC_DTYPES.value) + n = data.shape[0] + results = cp.full((data.shape[0], 1),-1, dtype=np.float32) + point = cp.array(point).reshape(1, 2) + for cnt, l in enumerate(range(0, int(n), int(batch_size))): + r = np.int32(min(l + batch_size, n)) + batch_data = cp.array(data[l:r]) + results[l:r] = cdist(batch_data, point).astype(np.float32) / pixels_per_millimeter + if centimeter: + results = results / 10 + return results.get diff --git a/simba/data_processors/cuda/utils.py b/simba/data_processors/cuda/utils.py index 5d7f89eb8..e921fb475 100644 --- a/simba/data_processors/cuda/utils.py +++ b/simba/data_processors/cuda/utils.py @@ -1,5 +1,6 @@ +from typing import Tuple, Dict, Any import numpy as np -from numba import cuda +from numba import cuda, guvectorize, float64 import math @cuda.jit(device=True) @@ -9,7 +10,6 @@ def _cuda_sum(x: np.ndarray): s += x[i] return s - @cuda.jit(device=True) def _cuda_sin(x, t): for i in range(x.shape[0]): @@ -35,6 +35,10 @@ def _cuda_std(x: np.ndarray, x_hat: float): def _rad2deg(x): return x * (180/math.pi) +@cuda.jit(device=True) +def _deg2rad(x): + return x * (math.pi/180) + @cuda.jit(device=True) def _cross_test(x, y, x1, y1, x2, y2): cross = (x - x1) * (y2 - y1) - (y - y1) * (x2 - x1) @@ -46,4 +50,50 @@ def _cuda_mean(x): s = 0 for i in range(x.shape[0]): s += x[i] - return s / x.shape[0] \ No newline at end of file + return s / x.shape[0] + +@cuda.jit(device=True) +def _cuda_mse(img_1, img_2): + s = 0.0 + for i in range(img_1.shape[0]): + for j in range(img_1.shape[1]): + k = (img_1[i, j] - img_2[i, j]) ** 2 + s += k + return s / (img_1.shape[0] * img_1.shape[1]) + + +# @guvectorize([(float64[:], float64[:])], '(n) -> (n)', target='cuda') +# def _cuda_bubble_sort(arr, out): +# """ +# :example: +# >>> a = np.random.randint(5, 50, (5, 200)).astype('float64') +# >>> d_a = cuda.to_device(a) +# >>> _cuda_bubble_sort(d_a) +# >>> d = d_a.copy_to_host() +# """ +# +# for i in range(len(arr)): +# for j in range(len(arr) - 1 - i): +# if arr[j] > arr[j + 1]: +# arr[j], arr[j + 1] = arr[j + 1], arr[j] +# out = arr + +def _cuda_available() -> Tuple[bool, Dict[int, Any]]: + """ + Check if GPU available. If True, returns the GPUs, the model, physical slots and compute capabilitie(s). + + :return: Two-part tuple with first value indicating with the GPU is available (bool) and the second value denoting GPU attributes (dict). + :rtype: Tuple[bool, Dict[int, Any]] + """ + is_available = cuda.is_available() + devices = None + if is_available: + devices = {} + for gpu_cnt, gpu in enumerate(cuda.gpus): + devices[gpu_cnt] = {'model': gpu.name.decode("utf-8"), + 'compute_capability': float("{}.{}".format(*gpu.compute_capability)), + 'id': gpu.id, + 'PCI_device_id': gpu.PCI_DEVICE_ID, + 'PCI_bus_id': gpu.PCI_BUS_ID} + + return is_available, devices \ No newline at end of file diff --git a/simba/data_processors/spontaneous_alternation_calculator.py b/simba/data_processors/spontaneous_alternation_calculator.py index b2f66124f..53abff366 100644 --- a/simba/data_processors/spontaneous_alternation_calculator.py +++ b/simba/data_processors/spontaneous_alternation_calculator.py @@ -26,7 +26,6 @@ TAIL_END = "tail_end" - class SpontaneousAlternationCalculator(ConfigReader): """ Compute spontaneous alternations based on specified ROIs and animal detection parameters. diff --git a/simba/mixins/circular_statistics.py b/simba/mixins/circular_statistics.py index daf1f44f6..48ebee904 100644 --- a/simba/mixins/circular_statistics.py +++ b/simba/mixins/circular_statistics.py @@ -1136,20 +1136,26 @@ def rotational_direction(data: np.ndarray, stride: int = 1) -> np.ndarray: """ Jitted compute of frame-by-frame rotational direction within a 1D timeseries array of angular data. - :parameter ndarray data: 1D array of size len(frames) representing degrees. - :return numpy.ndarray: An array of directional indicators. - - 0 indicates no rotational change relative to prior frame. - - 1 indicates a clockwise rotational change relative to prior frame. - - 2 indicates a counter-clockwise rotational change relative to prior frame. - .. note:: * For the first frame, no rotation is possible so is populated with -1. * Frame-by-frame rotations of 180° degrees are denoted as clockwise rotations. + .. seealso:: + See :func:`~simba.data_processors.cuda.circular_statistics.rotational_direction` for GPU acceleration. + .. image:: _static/img/rotational_direction.png :width: 600 :align: center + The result array contains values: + - `0` where there is no change between points. + - `1` where the angle has increased in the positive direction. + - `2` where the angle has decreased in the negative direction. + + :param np.ndarray data: 1D array of size len(frames) representing degrees. + :return: An array of directional indicators. + :rtype: numpy.ndarray + :example: >>> data = np.array([45, 50, 35, 50, 80, 350, 350, 0 , 180]).astype(np.float32) >>> CircularStatisticsMixin().rotational_direction(data) diff --git a/simba/mixins/config_reader.py b/simba/mixins/config_reader.py index d8de8d644..658cff282 100644 --- a/simba/mixins/config_reader.py +++ b/simba/mixins/config_reader.py @@ -58,6 +58,7 @@ def __init__( self.config_path = config_path self.config = read_config_file(config_path=config_path) self.datetime = datetime.now().strftime("%Y%m%d%H%M%S") + self.project_path, self.file_type = read_project_path_and_file_type( config=self.config ) diff --git a/simba/mixins/feature_extraction_mixin.py b/simba/mixins/feature_extraction_mixin.py index 65df67035..ac7780588 100644 --- a/simba/mixins/feature_extraction_mixin.py +++ b/simba/mixins/feature_extraction_mixin.py @@ -114,8 +114,7 @@ def angle3pt(ax: float, ay: float, bx: float, by: float, cx: float, cy: float) - :align: center .. seealso:: - :func:`simba.mixins.feature_extraction_mixin.FeatureExtractionMixin.angle3pt_serialized`, - :func: + :func:`simba.mixins.feature_extraction_mixin.FeatureExtractionMixin.angle3pt_serialized` :example: diff --git a/simba/mixins/geometry_mixin.py b/simba/mixins/geometry_mixin.py index 03e4771de..dd94061d8 100644 --- a/simba/mixins/geometry_mixin.py +++ b/simba/mixins/geometry_mixin.py @@ -3602,7 +3602,7 @@ def filter_low_p_bps_for_shapes(x: np.ndarray, p: np.ndarray, threshold: float): return results @staticmethod - def get_shape_lengths_widths(shapes: Union[List[Polygon], Polygon]) -> Dict[str, Any]: + def get_shape_statistics(shapes: Union[List[Polygon], Polygon]) -> Dict[str, Any]: """ Calculate the lengths and widths of the minimum bounding rectangles of polygons. @@ -3616,19 +3616,87 @@ def get_shape_lengths_widths(shapes: Union[List[Polygon], Polygon]) -> Dict[str, - 'min_width': The minimum width found among all polygons. :rtype: Dict[str, Any] """ - widths, lengths, max_length, max_width, min_length, min_width = [], [], -np.inf, -np.inf, np.inf, np.inf + widths, lengths, areas, centers, max_length, max_width, max_area, min_length, min_width, min_area = [], [], [], [], -np.inf, -np.inf, -np.inf, np.inf, np.inf, np.inf if isinstance(shapes, Polygon): shapes = [shapes] for shape in shapes: shape_cords = list(zip(*shape.exterior.coords.xy)) mbr_lengths = [LineString((shape_cords[i], shape_cords[i + 1])).length for i in range(len(shape_cords) - 1)] width, length = min(mbr_lengths), max(mbr_lengths) + area = width * length min_length, max_length = min(min_length, length), max(max_length, length) min_width, max_width = min(min_width, width), max(max_width, width) - lengths.append(length); + min_area, max_area = min(min_area, area), max(max_area, area) + lengths.append(length) widths.append(width) - return {'lengths': lengths, 'widths': widths, 'max_length': max_length, 'min_length': min_length, - 'min_width': min_width, 'max_width': max_width} + areas.append(area) + centers.append(list(np.array(shape.centroid).astype(np.int32))) + + return {'lengths': lengths, 'widths': widths, 'areas': areas, 'centers': centers, 'max_length': max_length, 'min_length': min_length, 'min_width': min_width, 'max_width': max_width, 'min_area': min_area, 'max_area': max_area} + + @staticmethod + def _geometries_to_exterior_keypoints_helper(geometries): + results = [] + for geo in geometries: + results.append(np.array(geo.exterior.coords)) + return results + + @staticmethod + def geometries_to_exterior_keypoints(geometries: List[Polygon], core_cnt: Optional[int] = -1) -> np.ndarray: + """ + Extract exterior keypoints from a list of Polygon geometries in parallel, with optional core count specification for multiprocessing. + + :param List[Polygon] geometries: A list of Shapely `Polygon` objects representing geometries whose exterior keypoints will be extracted. + :param Optional[int] core_cnt: The number of CPU cores to use for multiprocessing. If -1, it uses the maximum number of available cores. + :return: A numpy array of exterior keypoints extracted from the input geometries. + :rtype: np.ndarray + + :example: + >>> data_path = r"C:\troubleshooting\mitra\project_folder\csv\outlier_corrected_movement_location\FRR_gq_Saline_0624.csv" + >>> animal_data = read_df(file_path=data_path, file_type='csv', usecols=['Nose_x', 'Nose_y', 'Tail_base_x', 'Tail_base_y', 'Left_side_x', 'Left_side_y', 'Right_side_x', 'Right_side_y']).values.reshape(-1, 4, 2)[0:20].astype(np.int32) + >>> animal_polygons = GeometryMixin().bodyparts_to_polygon(data=animal_data) + >>> geometries_to_exterior_keypoints(geometries=animal_polygons) + """ + check_int(name="CORE COUNT", value=core_cnt, min_value=-1, max_value=find_core_cnt()[0], raise_error=True) + if core_cnt == -1: core_cnt = find_core_cnt()[0] + check_valid_lst(data=geometries, source=GeometryMixin.geometries_to_exterior_keypoints.__name__, valid_dtypes=(Polygon,), min_len=1) + results = [] + geometries = np.array_split(geometries, 3) + with multiprocessing.Pool(core_cnt, maxtasksperchild=Defaults.LARGE_MAX_TASK_PER_CHILD.value) as pool: + for cnt, mp_return in enumerate( + pool.imap(GeometryMixin._geometries_to_exterior_keypoints_helper, geometries, chunksize=1)): + results.append(mp_return) + results = [i for xs in results for i in xs] + return np.array(results).astype(np.int32) + + @staticmethod + @njit("(int32[:, :, :],)", parallel=True) + def keypoints_to_axis_aligned_bounding_box(keypoints: np.ndarray) -> np.ndarray: + """ + Computes the axis-aligned bounding box for each set of keypoints. + + Each set of keypoints consists of a 2D array of coordinates representing points. The function calculates + the minimum and maximum x and y values from the keypoints to form a rectangle (bounding box) aligned with + the x and y axes. + + :param np.ndarray keypoints: A 3D array of shape (N, M, 2) where N is the number of observations, and each observation contains M points in 2D space (x, y). + :return: A 3D array of shape (N, 4, 2), where each entry represents the four corners of the axis-aligned bounding box corresponding to each set of keypoints. + :rtype: np.ndarray + + :example: + >>> data = np.random.randint(0, 360, (30000, 7, 2)) + >>> results = keypoints_to_axis_aligned_bounding_box(keypoints=data) + """ + results = np.full((keypoints.shape[0], 4, 2), np.nan, dtype=np.int32) + for i in prange(keypoints.shape[0]): + obs = keypoints[i] + min_x, min_y = np.min(obs[:, 0].flatten()), np.min(obs[:, 1].flatten()) + max_x, max_y = np.max(obs[:, 0].flatten()), np.max(obs[:, 1].flatten()) + results[i] = np.array([[min_x, min_y], [max_x, min_y], [max_x, max_y], [min_x, max_y]]) + return results + + + # data = np.array([[[364, 308], [383, 323], [403, 335], [423, 351]], # [[356, 307], [376, 319], [396, 331], [419, 347]], diff --git a/simba/mixins/image_mixin.py b/simba/mixins/image_mixin.py index e723738e4..305a3416f 100644 --- a/simba/mixins/image_mixin.py +++ b/simba/mixins/image_mixin.py @@ -577,8 +577,8 @@ def img_to_bw( :width: 600 :align: center - .. seelalso:: - If converting multiple images from colour to black and white, consider :func:`simba.mixins.image_mixin.ImageMixin.img_stack_to_bw()` or + .. seealso:: + If converting multiple images from colour to black and white, consider :func:`simba.mixins.image_mixin.ImageMixin.img_stack_to_bw` or :func:`simba.data_processors.cuda.image.img_stack_to_bw` :param np.ndarray img: Input image as a NumPy array. diff --git a/simba/mixins/timeseries_features_mixin.py b/simba/mixins/timeseries_features_mixin.py index 39e3773ac..8fbdb2687 100644 --- a/simba/mixins/timeseries_features_mixin.py +++ b/simba/mixins/timeseries_features_mixin.py @@ -1698,9 +1698,7 @@ def granger_tests( @staticmethod @njit("(int32[:,:], float64[:], float64, float64)") - def sliding_displacement( - x: np.ndarray, time_windows: np.ndarray, fps: float, px_per_mm: float - ) -> np.ndarray: + def sliding_displacement(x: np.ndarray, time_windows: np.ndarray, fps: float, px_per_mm: float) -> np.ndarray: """ Calculate sliding Euclidean displacement of a body-part point over time windows. diff --git a/simba/third_party_label_appenders/BORIS_appender.py b/simba/third_party_label_appenders/BORIS_appender.py index e19018f46..686e54a3a 100644 --- a/simba/third_party_label_appenders/BORIS_appender.py +++ b/simba/third_party_label_appenders/BORIS_appender.py @@ -3,22 +3,18 @@ import glob import os from copy import deepcopy - import pandas as pd +from typing import Union from simba.mixins.config_reader import ConfigReader -from simba.third_party_label_appenders.tools import is_new_boris_version -from simba.utils.checks import (check_if_dir_exists, - check_if_filepath_list_is_empty) -from simba.utils.errors import (ThirdPartyAnnotationEventCountError, - ThirdPartyAnnotationOverlapError) -from simba.utils.printing import stdout_success -from simba.utils.read_write import get_fn_ext, read_df, write_df -from simba.utils.warnings import ( - ThirdPartyAnnotationsInvalidFileFormatWarning, - ThirdPartyAnnotationsOutsidePoseEstimationDataWarning) - - +from simba.third_party_label_appenders.tools import is_new_boris_version, read_boris_annotation_files +from simba.utils.checks import (check_if_dir_exists, check_if_filepath_list_is_empty) +from simba.utils.errors import (ThirdPartyAnnotationEventCountError, ThirdPartyAnnotationOverlapError, NoDataError) +from simba.utils.printing import stdout_success, SimbaTimer +from simba.utils.read_write import get_fn_ext, read_df, write_df, find_files_of_filetypes_in_directory +from simba.utils.warnings import (ThirdPartyAnnotationsInvalidFileFormatWarning, ThirdPartyAnnotationsOutsidePoseEstimationDataWarning) + +BEHAVIOR = 'BEHAVIOR' class BorisAppender(ConfigReader): """ Append BORIS human annotations onto featurized pose-estimation data. @@ -35,11 +31,9 @@ class BorisAppender(ConfigReader): :width: 200 :align: center - Examples - ---------- - >>> boris_appender = BorisAppender(config_path='MyProjectConfigPath', data_dir=r'BorisDataFolder') - >>> boris_appender.create_boris_master_file() - >>> boris_appender.run() + :example: + >>> test = BorisAppender(config_path=r"C:\troubleshooting\boris_test\project_folder\project_config.ini", data_dir=r"C:\troubleshooting\boris_test\project_folder\boris_files") + >>> test.run() References ---------- @@ -47,203 +41,67 @@ class BorisAppender(ConfigReader): .. [1] `Behavioral Observation Research Interactive Software (BORIS) user guide `__. """ - def __init__(self, config_path: str, data_dir: str): + def __init__(self, config_path: Union[str, os.PathLike], data_dir: Union[str, os.PathLike]): super().__init__(config_path=config_path) - self.boris_dir = data_dir - self.boris_files_found = glob.glob(self.boris_dir + "/*.csv") check_if_dir_exists(data_dir) - check_if_filepath_list_is_empty( - filepaths=self.boris_files_found, - error_msg=f"SIMBA ERROR: 0 BORIS CSV files found in {data_dir} directory", - ) - print(f"Processing BORIS for {str(len(self.feature_file_paths))} file(s)...") - - def create_boris_master_file(self): - """ - Method to create concatenated dataframe of BORIS annotations. + self.boris_dir = data_dir + self.boris_files_found = find_files_of_filetypes_in_directory(directory=self.boris_dir, extensions=['.csv'], raise_error=True) + print(f"Processing {len(self.boris_files_found)} BORIS annotation file(s) in {data_dir} directory...") + if len(self.feature_file_paths) == 0: + raise NoDataError(f'No data files found in the {self.features_dir} directory.', source=self.__class__.__name__) - Returns - ------- - Attribute: pd.Dataframe - master_boris_df - """ - self.master_boris_df_list = [] - for file_cnt, file_path in enumerate(self.boris_files_found): - try: - _, video_name, _ = get_fn_ext(file_path) - boris_df = pd.read_csv(file_path) - if not is_new_boris_version(boris_df): - index = boris_df[boris_df["Observation id"] == "Time"].index.values - boris_df = pd.read_csv(file_path, skiprows=range(0, int(index + 1))) - boris_df = boris_df.loc[ - :, ~boris_df.columns.str.contains("^Unnamed") - ] - boris_df.drop( - ["Behavioral category", "Comment", "Subject"], - axis=1, - inplace=True, - ) - else: - target_cols = [ - "Time", - "Media file path", - "Total length", - "FPS", - "Behavior", - "Status", - ] - boris_df.rename( - columns={ - "Media file name": "Media file path", - "Media duration (s)": "Total length", - "Behavior type": "Status", - }, - inplace=True, - ) - boris_df = boris_df.reindex(columns=target_cols) - _, video_base_name, _ = get_fn_ext(boris_df.loc[0, "Media file path"]) - boris_df["Media file path"] = video_base_name - self.master_boris_df_list.append(boris_df) - except Exception as e: - print(e.args) - ThirdPartyAnnotationsInvalidFileFormatWarning( - annotation_app="BORIS", file_path=file_path - ) - self.master_boris_df = pd.concat(self.master_boris_df_list, axis=0).reset_index( - drop=True - ) - print( - "Found {} annotated behaviors in {} files with {} directory".format( - str(len(self.master_boris_df["Behavior"].unique())), - len(self.boris_files_found), - self.boris_dir, - ) - ) - print( - "The following behavior annotations where detected in the boris directory:" - ) - for behavior in self.master_boris_df["Behavior"].unique(): - print(behavior) - def __check_non_overlapping_annotations(self): - shifted_annotations = deepcopy(self.clf_annotations) - shifted_annotations["START"] = self.clf_annotations["START"].shift(-1) + def __check_non_overlapping_annotations(self, annotation_df): + shifted_annotations = deepcopy(annotation_df) + shifted_annotations["START"] = annotation_df["START"].shift(-1) shifted_annotations = shifted_annotations.head(-1) - return shifted_annotations.query("START < STOP") + error_rows = shifted_annotations.query("START < STOP") + if len(error_rows) > 0: + raise ThirdPartyAnnotationOverlapError(video_name=self.video_name, clf_name=self.clf) + def run(self): - """ - Method to append BORIS annotations created in :meth:`~simba.BorisAppender.create_boris_master_file` to the - featurized pose-estimation data in the SimBA project. Results (parquets' or CSVs) are saved within the the - project_folder/csv/targets_inserted directory of the SimBA project. - """ + boris_annotation_dict = read_boris_annotation_files(data_paths=self.boris_files_found, video_info_df=self.video_info_df, orient='index') for file_cnt, file_path in enumerate(self.feature_file_paths): - _, self.video_name, _ = get_fn_ext(file_path) - print("Appending BORIS annotations to {} ...".format(self.video_name)) + self.file_name = get_fn_ext(filepath=file_path)[1] + self.video_timer = SimbaTimer(start=True) + print(f'Processing BORIS annotations for feature file {self.file_name}...') + if self.file_name not in boris_annotation_dict.keys(): + raise NoDataError(msg=f'Your SimBA project has a feature file named {self.file_name}, however no annotations exist for this file in the {self.boris_dir} directory.') + else: + video_annot = boris_annotation_dict[self.file_name] data_df = read_df(file_path, self.file_type) - self.out_df = deepcopy(data_df) - vid_annotations = self.master_boris_df.loc[ - self.master_boris_df["Media file path"] == self.video_name - ] - vid_annotation_starts = vid_annotations[(vid_annotations.Status == "START")] - vid_annotation_stops = vid_annotations[(vid_annotations.Status == "STOP")] - vid_annotations = ( - pd.concat( - [vid_annotation_starts, vid_annotation_stops], - axis=0, - join="inner", - copy=True, - ) - .sort_index() - .reset_index(drop=True) - ) - vid_annotations = vid_annotations[ - vid_annotations["Behavior"].isin(self.clf_names) - ] - if len(vid_annotations) == 0: - print( - "SIMBA WARNING: No BORIS annotations detected for SimBA classifier(s) named {} for video {}".format( - str(self.clf_names), self.video_name - ) - ) - continue - video_fps = vid_annotations["FPS"].values[0] - for clf in self.clf_names: - self.clf = clf - clf_annotations = vid_annotations[(vid_annotations["Behavior"] == clf)] - clf_annotations_start = clf_annotations[ - clf_annotations["Status"] == "START" - ].reset_index(drop=True) - clf_annotations_stop = clf_annotations[ - clf_annotations["Status"] == "STOP" - ].reset_index(drop=True) - if len(clf_annotations_start) != len(clf_annotations_stop): - raise ThirdPartyAnnotationEventCountError( - video_name=self.video_name, - clf_name=self.clf, - start_event_cnt=len(clf_annotations_start), - stop_event_cnt=len(clf_annotations_stop), - ) - self.clf_annotations = ( - clf_annotations_start["Time"] - .to_frame() - .rename(columns={"Time": "START"}) - ) - self.clf_annotations["STOP"] = clf_annotations_stop["Time"] - self.clf_annotations = self.clf_annotations.apply(pd.to_numeric) - results = self.__check_non_overlapping_annotations() - if len(results) > 0: - raise ThirdPartyAnnotationOverlapError( - video_name=self.video_name, clf_name=self.clf - ) - self.clf_annotations["START_FRAME"] = ( - self.clf_annotations["START"] * video_fps - ).astype(int) - self.clf_annotations["END_FRAME"] = ( - self.clf_annotations["STOP"] * video_fps - ).astype(int) - if len(self.clf_annotations) == 0: - self.out_df[clf] = 0 - print( - f"SIMBA WARNING: No BORIS annotation detected for video {self.video_name} and behavior {clf}. SimBA will set all frame annotations as absent." - ) + for clf_name in self.clf_names: + data_df[clf_name] = 0 + if clf_name not in video_annot[BEHAVIOR].unique(): + print(f"SIMBA WARNING: No BORIS annotation detected for video {self.file_name} and behavior {clf_name}. SimBA will set all frame annotations as absent.") continue - annotations_idx = list( - self.clf_annotations.apply( - lambda x: list( - range(int(x["START_FRAME"]), int(x["END_FRAME"]) + 1) - ), - 1, - ) - ) + video_clf_annot = video_annot[video_annot[BEHAVIOR] == clf_name].reset_index(drop=True) + self.__check_non_overlapping_annotations(video_clf_annot) + annotations_idx = list(video_clf_annot.apply(lambda x: list(range(int(x["START"]), int(x["STOP"]) + 1)), 1)) annotations_idx = [x for xs in annotations_idx for x in xs] - idx_difference = list(set(annotations_idx) - set(self.out_df.index)) + idx_difference = list(set(annotations_idx) - set(data_df.index)) if len(idx_difference) > 0: - ThirdPartyAnnotationsOutsidePoseEstimationDataWarning( - video_name=self.video_name, - clf_name=clf, - frm_cnt=self.out_df.index[-1], - first_error_frm=idx_difference[0], - ambiguous_cnt=len(idx_difference), - ) - annotations_idx = [ - x for x in annotations_idx if x not in idx_difference - ] - self.out_df[clf] = 0 - self.out_df.loc[annotations_idx, clf] = 1 - self.__save_boris_annotations() + ThirdPartyAnnotationsOutsidePoseEstimationDataWarning(video_name=self.file_name, clf_name=clf_name, frm_cnt=data_df.index[-1], first_error_frm=idx_difference[0], ambiguous_cnt=len(idx_difference)) + annotations_idx = [x for x in annotations_idx if x not in idx_difference] + data_df.loc[annotations_idx, clf_name] = 1 + print(f'Appended {len(annotations_idx)} BORIS behavior {clf_name} annotations for video {self.file_name}...') + self.__save_boris_annotations(df=data_df) self.timer.stop_timer() - stdout_success( - msg="BORIS annotations appended to dataset and saved in project_folder/csv/targets_inserted directory", - elapsed_time=self.timer.elapsed_time_str, - ) + stdout_success(msg=f"BORIS annotations appended to {len(self.feature_file_paths)} data file(s) and saved in {self.targets_folder}", elapsed_time=self.timer.elapsed_time_str) + + def __save_boris_annotations(self, df): + self.save_path = os.path.join(self.targets_folder, f"{self.file_name}.{self.file_type}") + write_df(df, self.file_type, self.save_path) + self.video_timer.stop_timer() + print(f"Saved BORIS annotations for video {self.file_name}... (elapsed time: {self.video_timer.elapsed_time_str})") + +# test = BorisAppender(config_path=r"C:\troubleshooting\boris_test\project_folder\project_config.ini", +# data_dir=r"C:\troubleshooting\boris_test\project_folder\boris_files") +# #test.create_boris_master_file() +# test.run() + - def __save_boris_annotations(self): - self.save_path = os.path.join( - self.targets_folder, self.video_name + "." + self.file_type - ) - write_df(self.out_df, self.file_type, self.save_path) - print("Saved BORIS annotations for video {}...".format(self.video_name)) # test = BorisAppender(config_path='/Users/simon/Desktop/envs/troubleshooting/two_black_animals_14bp/project_folder/project_config.ini', diff --git a/simba/third_party_label_appenders/third_party_appender.py b/simba/third_party_label_appenders/third_party_appender.py index ecee668c9..964bc8e9b 100644 --- a/simba/third_party_label_appenders/third_party_appender.py +++ b/simba/third_party_label_appenders/third_party_appender.py @@ -191,6 +191,8 @@ def run(self): self.timer.stop_timer() stdout_success(msg=f"{self.annotation_app} annotations appended to dataset and saved in {self.targets_folder} directory", elapsed_time=self.timer.elapsed_time_str) + + # log = True # file_format = 'xlsx' # error_settings = {'INVALID annotations file data format': 'WARNING', @@ -200,9 +202,29 @@ def run(self): # 'ZERO third-party video behavior annotations found': 'WARNING', # 'Annotations and pose FRAME COUNT conflict': 'WARNING', # 'Annotations data file NOT FOUND': 'WARNING'} +# +# test = ThirdPartyLabelAppender(config_path=r"C:\troubleshooting\boris_test\project_folder\project_config.ini", +# data_dir=r"C:\troubleshooting\boris_test\project_folder\boris_files", +# app='BORIS', +# file_format='.csv', +# error_settings=error_settings, +# log=log) +# test.run() +# -# test = ThirdPartyLabelAppender(config_path='/Users/simon/Desktop/envs/simba/troubleshooting/two_black_animals_14bp/project_folder/project_config.ini', -# data_dir='/Users/simon/Desktop/envs/simba/troubleshooting/BORIS_EXAMPLE', +# +# log = True +# file_format = 'xlsx' +# error_settings = {'INVALID annotations file data format': 'WARNING', +# 'ADDITIONAL third-party behavior detected': 'NONE', +# 'Annotations EVENT COUNT conflict': 'WARNING', +# 'Annotations OVERLAP inaccuracy': 'WARNING', +# 'ZERO third-party video behavior annotations found': 'WARNING', +# 'Annotations and pose FRAME COUNT conflict': 'WARNING', +# 'Annotations data file NOT FOUND': 'WARNING'} +# +# test = ThirdPartyLabelAppender(config_path=r"C:\troubleshooting\two_black_animals_14bp\project_folder\project_config.ini", +# data_dir=r"C:\troubleshooting\two_black_animals_14bp\BORIS", # app='BORIS', # file_format='.csv', # error_settings=error_settings, diff --git a/simba/third_party_label_appenders/tools.py b/simba/third_party_label_appenders/tools.py index 10015efff..04f6818ee 100644 --- a/simba/third_party_label_appenders/tools.py +++ b/simba/third_party_label_appenders/tools.py @@ -100,12 +100,11 @@ def is_new_boris_version(pd_df: pd.DataFrame): def read_boris_annotation_files(data_paths: Union[List[str], str, os.PathLike], video_info_df: Union[str, os.PathLike, pd.DataFrame], error_setting: Literal[Union[None, Methods.ERROR.value, Methods.WARNING.value]] = None, + orient: Literal['index', 'columns'] = 'columns', log_setting: Optional[bool] = False) -> Dict[str, pd.DataFrame]: """ Reads multiple BORIS behavioral annotation files and compiles the data into a dictionary of dataframes. - TEST! - :param Union[List[str], str, os.PathLike] data_paths: Paths to the BORIS annotation files. This can be a list of file paths, a single directory containing the files, or a single file path. :param Union[str, os.PathLike, pd.DataFrame] video_info_df: The path to a CSV file, an existing dataframe, or a file-like object containing video information (e.g., FPS, video name). This data is used to align the annotation files with their respective videos. :param Literal[Union[None, Methods.ERROR.value, Methods.WARNING.value]] error_setting: Defines the behavior when encountering issues in the files. Options are `Methods.ERROR.value` to raise errors, `Methods.WARNING.value` to log warnings, or `None` for no action. @@ -113,7 +112,7 @@ def read_boris_annotation_files(data_paths: Union[List[str], str, os.PathLike], :return: A dictionary where each key is a video name, and each value is a dataframe containing the compiled behavioral annotations from the corresponding BORIS file. :example: - >>>data = read_boris_annotation_files(data_paths=[r"C:\troubleshooting\boris_test\project_folder\boris_files\c_oxt23_190816_132617_s_trimmcropped.csv"], error_setting='WARNING', log_setting=False, video_info_df=r"C:\troubleshooting\boris_test\project_folder\logs\video_info.csv") + >>> data = read_boris_annotation_files(data_paths=[r"C:\troubleshooting\boris_test\project_folder\boris_files\c_oxt23_190816_132617_s_trimmcropped.csv"], error_setting='WARNING', log_setting=False, video_info_df=r"C:\troubleshooting\boris_test\project_folder\logs\video_info.csv") """ @@ -131,14 +130,13 @@ def read_boris_annotation_files(data_paths: Union[List[str], str, os.PathLike], elif isinstance(data_paths, str): check_if_dir_exists(in_dir=data_paths, source=f'{read_boris_annotation_files.__name__} data_paths') data_paths = find_files_of_filetypes_in_directory(directory=data_paths, extensions=['.csv'], raise_error=True) - check_all_file_names_are_represented_in_video_log(video_info_df=video_info_df, data_paths=data_paths) check_valid_dataframe(df=video_info_df, source=read_boris_annotation_files.__name__) dfs = {} for file_cnt, file_path in enumerate(data_paths): _, video_name, _ = get_fn_ext(file_path) - _, _, fps = read_video_info(vid_info_df=video_info_df, video_name=video_name) - boris_dict = read_boris_file(file_path=file_path, fps=fps, orient='columns', raise_error=raise_error, log_setting=log_setting) - dfs[video_name] = pd.concat(boris_dict.values(), ignore_index=True) + boris_dict = read_boris_file(file_path=file_path, fps=None, orient=orient, raise_error=raise_error, log_setting=log_setting) + for video_name, video_data in boris_dict.items(): + dfs[video_name] = pd.concat(video_data, ignore_index=True) return dfs diff --git a/simba/ui/pop_ups/video_processing_pop_up.py b/simba/ui/pop_ups/video_processing_pop_up.py index 655aa00b4..cd779e8a6 100644 --- a/simba/ui/pop_ups/video_processing_pop_up.py +++ b/simba/ui/pop_ups/video_processing_pop_up.py @@ -597,7 +597,7 @@ def __init__(self): PopUpMixin.__init__(self, title="EXTRACT ALL FRAMES") single_video_frm = CreateLabelFrameWithIcon(parent=self.main_frm,header="SINGLE VIDEO",icon_name=Keys.DOCUMENTATION.value,icon_link=Links.VIDEO_TOOLS.value) video_path = FileSelect( single_video_frm, "VIDEO PATH:", title="Select a video file", file_types=[("VIDEO FILE", Options.ALL_VIDEO_FORMAT_STR_OPTIONS.value)]) - single_video_btn = SimbaButton(parent=single_video_frm, txt="Extract Frames (Single video)", img='rocket', font=Formats.FONT_REGULAR.value, cmd=extract_frames_single_video, cmd_kwargs={'file_path': lambda:video_path.file_path}) + single_video_btn = SimbaButton(parent=single_video_frm, txt="Extract Frames (Single video)", img='rocket', font=Formats.FONT_REGULAR.value, cmd=extract_frames_single_video, cmd_kwargs={'file_path': lambda:video_path.file_path, 'save_dir': None}) multiple_videos_frm = CreateLabelFrameWithIcon( parent=self.main_frm, header="MULTIPLE VIDEOS", icon_name=Keys.DOCUMENTATION.value, icon_link=Links.VIDEO_TOOLS.value) folder_path = FolderSelect(multiple_videos_frm, "DIRECTORY PATH:", title=" Select video folder") diff --git a/simba/utils/read_write.py b/simba/utils/read_write.py index 9cb9f431d..56375981e 100644 --- a/simba/utils/read_write.py +++ b/simba/utils/read_write.py @@ -53,7 +53,7 @@ from simba.utils.printing import SimbaTimer, stdout_success from simba.utils.warnings import ( FileExistWarning, InvalidValueWarning, NoDataFoundWarning, - NoFileFoundWarning, ThirdPartyAnnotationsInvalidFileFormatWarning) + NoFileFoundWarning, ThirdPartyAnnotationsInvalidFileFormatWarning, FrameRangeWarning) # from simba.utils.keyboard_listener import KeyboardListener @@ -2231,12 +2231,21 @@ def _is_new_boris_version(pd_df: pd.DataFrame): """ return "Media file name" in list(pd_df.columns) +def _find_cap_insensitive_name(target: str, values: List[str]) -> Union[None, str]: + check_str(name=f'{_find_cap_insensitive_name.__name__} target', value=target) + check_valid_lst(data=values, source=f'{_find_cap_insensitive_name.__name__} values', valid_dtypes=(str,), min_len=1) + target_lower, values_lower = target.lower(), [x.lower() for x in values] + if target_lower not in values_lower: + return None + else: + return values[values_lower.index(target_lower)] + def read_boris_file(file_path: Union[str, os.PathLike], fps: Optional[Union[int, float]] = None, orient: Optional[Literal['index', 'columns']] = 'index', save_path: Optional[Union[str, os.PathLike]] = None, raise_error: Optional[bool] = False, - log_setting: Optional[bool] = False) -> Union[None, Dict[str, pd.DataFrame]]: + log_setting: Optional[bool] = False) -> Union[None, Dict[str, Dict[str, pd.DataFrame]]]: """ Reads a BORIS behavioral annotation file, processes the data, and optionally saves the results to a file. @@ -2247,7 +2256,7 @@ def read_boris_file(file_path: Union[str, os.PathLike], :param Optional[Union[str, os.PathLike] save_path: The path where the processed results should be saved as a pickle file. If not provided, the results will be returned instead. :param Optional[bool] raise_error: Whether to raise errors if the file format or content is invalid. If False, warnings will be logged instead of raising exceptions. :param Optional[bool] log_setting: Whether to log warnings and errors. This is relevant when `raise_error` is set to False. - :return: If `save_path` is None, returns a dictionary where keys are behaviors and values are dataframes containing start and stop frames for each behavior. If `save_path` is provided, the results are saved and nothing is returned. + :return: If `save_path` is None, returns a dictionary where keys are behaviors and values are dataframes containing start and stop frames for each behavior. If `save_path` is provided, the results are saved and nothing is returned. """ MEDIA_FILE_NAME = "Media file name" @@ -2263,7 +2272,6 @@ def read_boris_file(file_path: Union[str, os.PathLike], STATUS = "Status" MEDIA_FILE_PATH = "Media file path" - results = {} check_file_exist_and_readable(file_path=file_path) if fps is not None: check_int(name=f'{read_boris_file.__name__} fps', min_value=1, value=fps) @@ -2278,71 +2286,80 @@ def read_boris_file(file_path: Union[str, os.PathLike], raise InvalidFileTypeError(msg=f'{file_path} is not a valid BORIS file', source=read_boris_file.__name__) else: ThirdPartyAnnotationsInvalidFileFormatWarning(annotation_app="BORIS", file_path=file_path, source=read_boris_file.__name__, log_status=log_setting) - return results + return {} start_idx = boris_df[boris_df[OBSERVATION_ID] == TIME].index.values if len(start_idx) != 1: if raise_error: raise InvalidFileTypeError(msg=f'{file_path} is not a valid BORIS file', source=read_boris_file.__name__) else: ThirdPartyAnnotationsInvalidFileFormatWarning(annotation_app="BORIS", file_path=file_path, source=read_boris_file.__name__, log_status=log_setting) - return results + return {} df = pd.read_csv(file_path, skiprows=range(0, int(start_idx + 1))) else: MEDIA_FILE_PATH, STATUS = MEDIA_FILE_NAME, BEHAVIOR_TYPE expected_headers = [TIME, MEDIA_FILE_PATH, BEHAVIOR, STATUS] df = pd.read_csv(file_path) check_valid_dataframe(df=df, source=f'{read_boris_file.__name__} {file_path}', required_fields=expected_headers) - _, video_base_name, _ = get_fn_ext(df.loc[0, MEDIA_FILE_PATH]) numeric_check = pd.to_numeric(df[TIME], errors='coerce').notnull().all() if not numeric_check: if raise_error: raise InvalidInputError(msg=f'SimBA found TIME DATA annotation in file {file_path} that could not be interpreted as numeric values (seconds or frame numbers)') else: ThirdPartyAnnotationsInvalidFileFormatWarning(annotation_app="BORIS", file_path=file_path, source=read_boris_file.__name__, log_status=log_setting) - return results + return {} df[TIME] = df[TIME].astype(np.float32) - fps = None + media_file_names_in_file = df[MEDIA_FILE_PATH].unique() if fps is None: + FPS = _find_cap_insensitive_name(target=FPS, values=list(df.columns)) if not FPS in df.columns: if raise_error: - raise FrameRangeError(f'The annotations are in seconds and FPS was not passed. FPS could also not be read from the BORIS file', source=bento_file_reader.__name__) - else: - ThirdPartyAnnotationsInvalidFileFormatWarning(annotation_app="BORIS", file_path=file_path, source=read_boris_file.__name__, log_status=log_setting) - return results - fps = df[FPS].iloc[0] - if not isinstance(fps, (float, int)): - if raise_error: - raise FrameRangeError(f'The annotations are in seconds and FPS was not passed. FPS could also not be read from the BORIS file', source=bento_file_reader.__name__) + raise FrameRangeError(f'The annotations are in seconds and FPS was not passed. FPS could also not be read from the BORIS file', source=read_boris_file.__name__) else: + FrameRangeWarning(msg=f'The annotations are in seconds and FPS was not passed. FPS could also not be read from the BORIS file', source=read_boris_file.__name__) ThirdPartyAnnotationsInvalidFileFormatWarning(annotation_app="BORIS", file_path=file_path, source=read_boris_file.__name__, log_status=log_setting) - return results - df = df[expected_headers] - df['FRAME'] = (df[TIME] * fps).astype(int) - df = df.drop([TIME, MEDIA_FILE_PATH], axis=1) - df = df.rename(columns={BEHAVIOR: 'BEHAVIOR', STATUS: EVENT}) - - for clf in df['BEHAVIOR'].unique(): - clf_df = df[df['BEHAVIOR'] == clf].reset_index(drop=True) - if orient == 'column': - results[clf] = clf_df + return {} + if len(media_file_names_in_file) == 1: + fps = df[FPS].iloc[0] + check_float(name='fps', value=fps, min_value=10e-6, raise_error=True) + fps = [float(fps)] else: - start_clf, stop_clf = clf_df[clf_df[EVENT] == START].reset_index(drop=True), clf_df[clf_df[EVENT] == STOP].reset_index(drop=True) - start_clf = start_clf.rename(columns={FRAME: START}).drop([EVENT, 'BEHAVIOR'], axis=1) - stop_clf = stop_clf.rename(columns={FRAME: STOP}).drop([EVENT], axis=1) - if len(start_clf) != len(stop_clf): - if raise_error: - raise FrameRangeError(f'In file {file_path}, the number of start events ({len(start_clf)}) and stop events ({len(stop_clf)}) for behavior {clf} is not equal', source=bento_file_reader.__name__) - else: - ThirdPartyAnnotationsInvalidFileFormatWarning(annotation_app="BORIS", file_path=file_path, source=read_boris_file.__name__, log_status=log_setting) - return results - clf_df = pd.concat([start_clf, stop_clf], axis=1)[['BEHAVIOR', START, STOP]] - results[clf] = clf_df + fps_lst = df[FPS].iloc[0].split(';') + fps = [] + for fps_value in fps_lst: + check_float(name='fps', value=fps_value, min_value=10e-6, raise_error=True) + fps.append(float(fps_value)) + df = df[expected_headers] + + results = {} + for video_cnt, video_file_name in enumerate(media_file_names_in_file): + video_name = get_fn_ext(filepath=video_file_name)[1] + results[video_name] = {} + video_fps = fps[video_cnt] + video_df = df[df[MEDIA_FILE_PATH] == video_file_name].reset_index(drop=True) + video_df['FRAME'] = (df[TIME] * video_fps).astype(int) + video_df = video_df.drop([TIME, MEDIA_FILE_PATH], axis=1) + video_df = video_df.rename(columns={BEHAVIOR: 'BEHAVIOR', STATUS: EVENT}) + for clf in video_df['BEHAVIOR'].unique(): + video_clf_df = video_df[video_df['BEHAVIOR'] == clf].reset_index(drop=True) + if orient == 'index': + start_clf, stop_clf = video_clf_df[video_clf_df[EVENT] == START].reset_index(drop=True), video_clf_df[video_clf_df[EVENT] == STOP].reset_index(drop=True) + start_clf = start_clf.rename(columns={FRAME: START}).drop([EVENT, 'BEHAVIOR'], axis=1) + stop_clf = stop_clf.rename(columns={FRAME: STOP}).drop([EVENT], axis=1) + if len(start_clf) != len(stop_clf): + if raise_error: + raise FrameRangeError(f'In file {file_path}, the number of start events ({len(start_clf)}) and stop events ({len(stop_clf)}) for behavior {clf} and video {video_name} is not equal', source=read_boris_file.__name__) + else: + FrameRangeWarning(msg=f'In file {file_path}, the number of start events ({len(start_clf)}) and stop events ({len(stop_clf)}) for behavior {clf} and video {video_name} is not equal', source=read_boris_file.__name__) + return results + video_clf_df = pd.concat([start_clf, stop_clf], axis=1)[['BEHAVIOR', START, STOP]] + results[video_name][clf] = video_clf_df if save_path is None: return results else: write_pickle(data=results, save_path=save_path) + def img_stack_to_video(x: np.ndarray, save_path: Union[str, os.PathLike], fps: float, diff --git a/tests/test_thirdparty_appenders.py b/tests/test_thirdparty_appenders.py index 08e74adbb..17d047825 100644 --- a/tests/test_thirdparty_appenders.py +++ b/tests/test_thirdparty_appenders.py @@ -14,7 +14,6 @@ def test_boris_import_use_case(config_path, boris_path): boris_appender = BorisAppender(config_path=config_path, data_dir=boris_path) - boris_appender.create_boris_master_file() boris_appender.run() #assert len(boris_appender.out_df) == 1738 #for f in glob.glob(boris_appender.targets_folder + '/*.csv'): os.remove(f)