From ce5ffe9bffeffb338ca5616fad98d7d4223e127d Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Tue, 29 Aug 2023 13:10:36 -0400 Subject: [PATCH 01/19] Adding read functions for jabs pose files --- sleap_io/io/jabs.py | 145 ++++++++++++++++++++++++++++++++++++++++++++ sleap_io/io/main.py | 14 ++++- 2 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 sleap_io/io/jabs.py diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py new file mode 100644 index 00000000..2c164e7b --- /dev/null +++ b/sleap_io/io/jabs.py @@ -0,0 +1,145 @@ +"""This module handles direct I/O operations for working with JABS files. + +""" + +import h5py +import re +import numpy as np +from typing import Dict, Iterable, List, Tuple, Optional, Union + +from sleap_io import Instance, LabeledFrame, Labels, Node, Edge, Symmetry, Point, Video, Skeleton, Track + +JABS_DEFAULT_KEYPOINTS = [ + Node('NOSE'), + Node('LEFT_EAR'), + Node('RIGHT_EAR'), + Node('BASE_NECK'), + Node('LEFT_FRONT_PAW'), + Node('RIGHT_FRONT_PAW'), + Node('CENTER_SPINE'), + Node('LEFT_REAR_PAW'), + Node('RIGHT_REAR_PAW'), + Node('BASE_TAIL'), + Node('MID_TAIL'), + Node('TIP_TAIL') +] + +# Root node is base neck (3) +JABS_DEFAULT_EDGES = [ + # Spine + Edge(JABS_DEFAULT_KEYPOINTS[3], JABS_DEFAULT_KEYPOINTS[0]), + Edge(JABS_DEFAULT_KEYPOINTS[3], JABS_DEFAULT_KEYPOINTS[6]), + Edge(JABS_DEFAULT_KEYPOINTS[6], JABS_DEFAULT_KEYPOINTS[9]), + Edge(JABS_DEFAULT_KEYPOINTS[9], JABS_DEFAULT_KEYPOINTS[10]), + Edge(JABS_DEFAULT_KEYPOINTS[10], JABS_DEFAULT_KEYPOINTS[11]), + # Ears + Edge(JABS_DEFAULT_KEYPOINTS[0], JABS_DEFAULT_KEYPOINTS[1]), + Edge(JABS_DEFAULT_KEYPOINTS[0], JABS_DEFAULT_KEYPOINTS[2]), + # Front paws + Edge(JABS_DEFAULT_KEYPOINTS[6], JABS_DEFAULT_KEYPOINTS[4]), + Edge(JABS_DEFAULT_KEYPOINTS[6], JABS_DEFAULT_KEYPOINTS[5]), + # Rear paws + Edge(JABS_DEFAULT_KEYPOINTS[9], JABS_DEFAULT_KEYPOINTS[7]), + Edge(JABS_DEFAULT_KEYPOINTS[9], JABS_DEFAULT_KEYPOINTS[8]), +] + +JABS_DEFAULT_SYMMETRIES = [ + Symmetry([JABS_DEFAULT_KEYPOINTS[1], JABS_DEFAULT_KEYPOINTS[2]]), + Symmetry([JABS_DEFAULT_KEYPOINTS[4], JABS_DEFAULT_KEYPOINTS[5]]), + Symmetry([JABS_DEFAULT_KEYPOINTS[7], JABS_DEFAULT_KEYPOINTS[8]]), +] + +JABS_DEFAULT_SKELETON = Skeleton(JABS_DEFAULT_KEYPOINTS, JABS_DEFAULT_EDGES, JABS_DEFAULT_SYMMETRIES, name='Mouse') + +def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON) -> Labels: + """Read JABS style pose from a file and return a `Labels` object. + + Args: + labels_path: Path to the JABS pose file. + skeleton: An optional `Skeleton` object. Defaults to JABS pose version 2-6. + + Returns: + Parsed labels as a `Labels` instance. + """ + frames: List[LabeledFrame] = [] + # Video name is the pose file minus the suffix + video_name = re.sub(r'(_pose_est_v[2-6])?\.h5', '.avi', labels_path) + if not skeleton: + skeleton = JABS_DEFAULT_SKELETON + tracks = {} + + with h5py.File(labels_path, "r") as pose_file: + num_frames = pose_file['poseest/points'].shape[0] + try: + pose_version = pose_file['poseest'].attrs['version'][0] + except: + pose_version = 2 + data_shape = pose_file['poseest/points'].shape + assert len(data_shape)==3, f'Pose version not present and shape does not match single mouse: shape of {data_shape} for {labels_path}' + # Change field name for newer pose formats + if pose_version == 3: + id_key = 'instance_track_id' + tracks[1] = Track('Mouse 1') + elif pose_version > 3: + id_key = 'instance_embed_id' + max_ids = pose_file['poseest/points'].shape[1] + + for frame_idx in range(num_frames): + instances = [] + pose_data = pose_file['poseest/points'][frame_idx, ...] + pose_conf = pose_file['poseest/confidence'][frame_idx, ...] + # single animal case + if pose_version == 2: + new_instance = prediction_to_instance(pose_data, pose_conf, skeleton, tracks[1]) + instances.append(new_instance) + # multi-animal case + if pose_version > 2: + pose_ids = pose_file['poseest/' + id_key][frame_idx, ...] + # pose_v3 uses another field to describe the number of valid poses + if pose_version == 3: + max_ids = pose_file['poseest/instance_count'][frame_idx] + for cur_id in range(max_ids): + # v4+ uses reserved values for invalid/unused poses + # note that we're ignoring 'poseest/id_mask' to keep predictions that were not assigned an id + if pose_version > 3 and pose_ids[cur_id] <= 0: + continue + if cur_id not in tracks.keys(): + tracks[cur_id] = Track(f'Mouse {pose_ids[cur_id]}') + new_instance = prediction_to_instance(pose_data[cur_id], pose_conf[cur_id], skeleton, tracks[cur_id]) + if new_instance: + instances.append(new_instance) + frame_label = LabeledFrame(Video(video_name), frame_idx, instances) + frames.append(frame_label) + return Labels(frames) + + +def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.float32]], confidence: np.ndarray[np.float32], skeleton: Skeleton, track: Track = None) -> Instance: + """Create an `Instance` from prediction data. + + Args: + points: keypoint locations + confidence: confidence for keypoints + + Returns: + Parsed `Instance`. + """ + assert len(skeleton.nodes) == data.shape[0], f'Skeleton ({len(skeleton.nodes)}) does not match number of keypoints ({data.shape[0]})' + + points = {} + for i, cur_node in enumerate(skeleton.nodes): + # confidence of 0 indicates no keypoint predicted for instance + if confidence[i] > 0.001: + points[cur_node] = Point( + data[i,1], + data[i,0], + visible=True, + ) + + if not points: + return None + else: + return Instance(points, skeleton=skeleton) + # Tracks aren't saving correctly... + #return Instance(points, skeleton=skeleton, track=Track) + + diff --git a/sleap_io/io/main.py b/sleap_io/io/main.py index e839d9d9..2fb174b9 100644 --- a/sleap_io/io/main.py +++ b/sleap_io/io/main.py @@ -2,7 +2,7 @@ from __future__ import annotations from sleap_io import Labels, Skeleton -from sleap_io.io import slp, nwb, labelstudio +from sleap_io.io import slp, nwb, labelstudio, jabs from typing import Optional, Union from pathlib import Path @@ -79,3 +79,15 @@ def load_labelstudio( def save_labelstudio(labels: Labels, filename: str): """Save a SLEAP dataset to Label Studio format.""" labelstudio.write_labels(labels, filename) + +def load_jabs(filename: str, skeleton: Optional[Skeleton] = None) -> Labels: + """Read JABS-style predictions from a file and return a `Labels` object. + + Args: + filename: Path to the jabs h5 pose file. + skeleton: An optional `Skeleton` object. + + Returns: + Parsed labels as a `Labels` instance. + """ + return jabs.read_labels(filename, skeleton=skeleton) From ece51ae74ba30e6a7d75c670bffe2528150673d0 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Tue, 29 Aug 2023 13:35:06 -0400 Subject: [PATCH 02/19] Figured out track and adding function into init --- sleap_io/__init__.py | 1 + sleap_io/io/jabs.py | 4 +--- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/sleap_io/__init__.py b/sleap_io/__init__.py index 2cecc38a..4cf0b545 100644 --- a/sleap_io/__init__.py +++ b/sleap_io/__init__.py @@ -22,4 +22,5 @@ save_nwb, load_labelstudio, save_labelstudio, + load_jabs, ) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 2c164e7b..9bce473f 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -138,8 +138,6 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa if not points: return None else: - return Instance(points, skeleton=skeleton) - # Tracks aren't saving correctly... - #return Instance(points, skeleton=skeleton, track=Track) + return Instance(points, skeleton=skeleton, track=track) From f579d24ac1655bbf67dcd60439f81cca254b2a04 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Wed, 30 Aug 2023 11:01:48 -0400 Subject: [PATCH 03/19] pose_v3 export mostly working. Adding some structure for other pose versions --- sleap_io/io/jabs.py | 136 +++++++++++++++++++++++++++++++++++++++++++- sleap_io/io/main.py | 4 ++ 2 files changed, 137 insertions(+), 3 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 9bce473f..a06e6c91 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -4,6 +4,7 @@ import h5py import re +import os import numpy as np from typing import Dict, Iterable, List, Tuple, Optional, Union @@ -53,6 +54,7 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON) -> Labels: """Read JABS style pose from a file and return a `Labels` object. + TODO: Currently only reads in pose data. v5 static objects are currently ignored Args: labels_path: Path to the JABS pose file. @@ -79,7 +81,7 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK # Change field name for newer pose formats if pose_version == 3: id_key = 'instance_track_id' - tracks[1] = Track('Mouse 1') + tracks[1] = Track('1') elif pose_version > 3: id_key = 'instance_embed_id' max_ids = pose_file['poseest/points'].shape[1] @@ -100,11 +102,11 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK max_ids = pose_file['poseest/instance_count'][frame_idx] for cur_id in range(max_ids): # v4+ uses reserved values for invalid/unused poses - # note that we're ignoring 'poseest/id_mask' to keep predictions that were not assigned an id + # Note: ignores 'poseest/id_mask' to keep predictions that were not assigned an id if pose_version > 3 and pose_ids[cur_id] <= 0: continue if cur_id not in tracks.keys(): - tracks[cur_id] = Track(f'Mouse {pose_ids[cur_id]}') + tracks[cur_id] = Track(str(pose_ids[cur_id])) new_instance = prediction_to_instance(pose_data[cur_id], pose_conf[cur_id], skeleton, tracks[cur_id]) if new_instance: instances.append(new_instance) @@ -140,4 +142,132 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa else: return Instance(points, skeleton=skeleton, track=track) +def get_max_ids_in_video(labels: Labels) -> int: + """Determine the maximum number of identities that exist at the same time + + Args: + labels: SLEAP `Labels` to count + """ + max_labels = 0 + for label in labels.labeled_frames: + n_labels = len(label.instances) + max_labels = max(max_labels, n_labels) + + return max_labels + +def convert_labels(labels: Labels) -> dict: + """Convert a `Labels` object into JABS-formatted annotations. + TODO: Currently assumes all data is mouse + TODO: Identity is an unsafe str -> cast. Convert to factorize op + + Args: + labels: SLEAP `Labels` to be converted to JABS format. + + Returns: + Dictionary of JABS data of the `Labels` data. + """ + # Determine shape of output + num_frames = labels.video.shape[0] + num_keypoints = len(labels.skeleton.nodes) + num_mice = get_max_ids_in_video(labels) + + keypoint_mat = np.zeros([num_frames, num_mice, num_keypoints, 2], dtype=np.uint16) + confidence_mat = np.zeros([num_frames, num_mice, num_keypoints], dtype=np.float32) + identity_mat = np.zeros([num_frames, num_mice], dtype=np.uint32) + instance_vector = np.zeros([num_frames], dtype=np.uint8) + + # Populate the matrices with data + for label in labels.labeled_frames: + assigned_instances = 0 + for instance_idx, instance in enumerate(label.instances): + pose = instance.numpy() + missing_points = np.isnan(pose[:,0]) + pose[np.isnan(pose)] = 0 + # JABS stores y,x + pose = pose.astype(np.uint16)[:,::-1] + keypoint_mat[label.frame_idx, instance_idx] = pose + confidence_mat[label.frame_idx, instance_idx, ~missing_points] = 1.0 + identity_mat[label.frame_idx, instance_idx] = np.uint32(instance.track.name) + assigned_instances += 1 + instance_vector[label.frame_idx] = assigned_instances + + # Return the data as a dict + return {'keypoints': keypoint_mat, 'confidence': confidence_mat, 'identity': identity_mat, 'num_identities': instance_vector} + +def write_labels(labels: Labels, filename: str, pose_version: int): + """Convert and save a SLEAP `Labels` object to a JABS pose file. + Only supports pose version 2 (single mouse) and 3-5 (multi mouse). + + Args: + labels: SLEAP `Labels` to be converted to JABS pose format. + filename: Path to save JABS annotations (`.h5`). + pose_version: JABS pose version to use when writing data. + """ + + for video in labels.videos: + video_labels = labels.find(video=video) + converted_labels = convert_labels(video_labels) + out_filename = re.sub('\.avi', f'_pose_est_v{pose_version}.h5', video.filename) + if os.path.exists(out_filename): + pass +def write_jabs_v2(data: dict, filename: str): + """ Write JABS pose file v2 data to file. + Writes single mouse pose data. + + Args: + data: Dictionary of JABS data generated from convert_labels + filename: Filename to write data to + """ + # Check that we're trying to write single mouse data + assert data['keypoints'].shape[1] == 1 + out_keypoints = np.squeeze(data['keypoints'], axis=1) + out_confidences = np.squeeze(data['confidence'], axis=1) + + with h5py.File(filename, 'w') as h5: + pose_grp = h5.require_group('poseest') + pose_grp.attrs.update({'version':[2,0]}) + pose_dataset = pose_grp.require_dataset('points', out_keypoints.shape, out_keypoints.dtype, data = out_keypoints) + + +def write_jabs_v3(data: dict, filename: str): + """ Write JABS pose file v3 data to file. + Writes multi-mouse pose data. + + Args: + data: Dictionary of JABS data generated from convert_labels + filename: Filename to write data to + """ + with h5py.File(filename, 'w') as h5: + pose_grp = h5.require_group('poseest') + pose_grp.attrs.update({'version':[3,0]}) + # keypoint field + pose_dataset = pose_grp.require_dataset('points', data['keypoints'].shape, data['keypoints'].dtype, data = data['keypoints']) + # confidence field + conf_dataset = pose_grp.require_dataset('confidence', data['confidence'].shape, data['confidence'].dtype, data = data['confidence']) + # id field + id_dataset = pose_grp.require_dataset('instance_track_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) + # instance count field + count_dataset = pose_grp.require_dataset('instance_count', data['num_identities'].shape, data['num_identities'].dtype, data = data['num_identities']) + # extra field where we don't have data + kp_embedding_dataset = pose_grp.require_dataset('instance_embedding', data['confidence'].shape, data['confidence'].dtype, data = np.zeros_like(data['confidence'])) + +def write_jabs_v4(data: dict, filename: str): + """ Write JABS pose file v4 data to file. + Writes multi-mouse pose and longterm identity object data. + + Args: + data: Dictionary of JABS data generated from convert_labels + filename: Filename to write data to + """ + pass + +def write_jabs_v5(data: dict, filename: str): + """ Write JABS pose file v5 data to file. + Writes multi-mouse pose, longterm identity, and static object data. + + Args: + data: Dictionary of JABS data generated from convert_labels + filename: Filename to write data to + """ + pass diff --git a/sleap_io/io/main.py b/sleap_io/io/main.py index 2fb174b9..23c297a8 100644 --- a/sleap_io/io/main.py +++ b/sleap_io/io/main.py @@ -91,3 +91,7 @@ def load_jabs(filename: str, skeleton: Optional[Skeleton] = None) -> Labels: Parsed labels as a `Labels` instance. """ return jabs.read_labels(filename, skeleton=skeleton) + +def save_jabs(labels: Labels, filename: str, pose_version: int): + """Save a SLEAP dataset to JABS pose file format.""" + jabs.write_labels(labels, filename, pose_version) From ff91485029c8d2b9027f20c63dcbb0d6dfc5aaaf Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Wed, 30 Aug 2023 11:54:03 -0400 Subject: [PATCH 04/19] Bugfix on sorting out tracks --- sleap_io/io/jabs.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index a06e6c91..384a03a2 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -76,12 +76,12 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK pose_version = pose_file['poseest'].attrs['version'][0] except: pose_version = 2 + tracks[1] = Track('1') data_shape = pose_file['poseest/points'].shape assert len(data_shape)==3, f'Pose version not present and shape does not match single mouse: shape of {data_shape} for {labels_path}' # Change field name for newer pose formats if pose_version == 3: id_key = 'instance_track_id' - tracks[1] = Track('1') elif pose_version > 3: id_key = 'instance_embed_id' max_ids = pose_file['poseest/points'].shape[1] @@ -105,9 +105,9 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK # Note: ignores 'poseest/id_mask' to keep predictions that were not assigned an id if pose_version > 3 and pose_ids[cur_id] <= 0: continue - if cur_id not in tracks.keys(): - tracks[cur_id] = Track(str(pose_ids[cur_id])) - new_instance = prediction_to_instance(pose_data[cur_id], pose_conf[cur_id], skeleton, tracks[cur_id]) + if pose_ids[cur_id] not in tracks.keys(): + tracks[pose_ids[cur_id]] = Track(str(pose_ids[cur_id])) + new_instance = prediction_to_instance(pose_data[cur_id], pose_conf[cur_id], skeleton, tracks[pose_ids[cur_id]]) if new_instance: instances.append(new_instance) frame_label = LabeledFrame(Video(video_name), frame_idx, instances) From 52451f73570ad4b13685b0783d717d3f38da426e Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Thu, 31 Aug 2023 11:29:40 -0400 Subject: [PATCH 05/19] Adding in writing out JABS pose versions --- sleap_io/__init__.py | 1 + sleap_io/io/jabs.py | 61 +++++++++++++++++++++++++++++++++++--------- sleap_io/io/main.py | 11 +++++--- 3 files changed, 58 insertions(+), 15 deletions(-) diff --git a/sleap_io/__init__.py b/sleap_io/__init__.py index 4cf0b545..e58abaaf 100644 --- a/sleap_io/__init__.py +++ b/sleap_io/__init__.py @@ -23,4 +23,5 @@ load_labelstudio, save_labelstudio, load_jabs, + save_jabs, ) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 384a03a2..3c06377e 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -55,6 +55,8 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON) -> Labels: """Read JABS style pose from a file and return a `Labels` object. TODO: Currently only reads in pose data. v5 static objects are currently ignored + TODO: Attributes are ignored. Is there a way to keep them in SLEAP format? + TODO: px_to_cm field is ignored. Args: labels_path: Path to the JABS pose file. @@ -142,33 +144,34 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa else: return Instance(points, skeleton=skeleton, track=track) -def get_max_ids_in_video(labels: Labels) -> int: +def get_max_ids_in_video(labels: List[Labels]) -> int: """Determine the maximum number of identities that exist at the same time Args: labels: SLEAP `Labels` to count """ max_labels = 0 - for label in labels.labeled_frames: + for label in labels: n_labels = len(label.instances) max_labels = max(max_labels, n_labels) return max_labels -def convert_labels(labels: Labels) -> dict: +def convert_labels(labels: List[Labels]) -> dict: """Convert a `Labels` object into JABS-formatted annotations. TODO: Currently assumes all data is mouse TODO: Identity is an unsafe str -> cast. Convert to factorize op + TODO: See ignored fields in `read_labels` Args: - labels: SLEAP `Labels` to be converted to JABS format. + labels: list of SLEAP `Labels` belonging to a single video to be converted to JABS format. Returns: Dictionary of JABS data of the `Labels` data. """ # Determine shape of output - num_frames = labels.video.shape[0] - num_keypoints = len(labels.skeleton.nodes) + num_frames = len(labels) + num_keypoints = len(labels[0][0].skeleton.nodes) num_mice = get_max_ids_in_video(labels) keypoint_mat = np.zeros([num_frames, num_mice, num_keypoints, 2], dtype=np.uint16) @@ -177,7 +180,7 @@ def convert_labels(labels: Labels) -> dict: instance_vector = np.zeros([num_frames], dtype=np.uint8) # Populate the matrices with data - for label in labels.labeled_frames: + for label in labels: assigned_instances = 0 for instance_idx, instance in enumerate(label.instances): pose = instance.numpy() @@ -194,13 +197,12 @@ def convert_labels(labels: Labels) -> dict: # Return the data as a dict return {'keypoints': keypoint_mat, 'confidence': confidence_mat, 'identity': identity_mat, 'num_identities': instance_vector} -def write_labels(labels: Labels, filename: str, pose_version: int): +def write_labels(labels: Labels, pose_version: int): """Convert and save a SLEAP `Labels` object to a JABS pose file. Only supports pose version 2 (single mouse) and 3-5 (multi mouse). Args: labels: SLEAP `Labels` to be converted to JABS pose format. - filename: Path to save JABS annotations (`.h5`). pose_version: JABS pose version to use when writing data. """ @@ -208,8 +210,19 @@ def write_labels(labels: Labels, filename: str, pose_version: int): video_labels = labels.find(video=video) converted_labels = convert_labels(video_labels) out_filename = re.sub('\.avi', f'_pose_est_v{pose_version}.h5', video.filename) + # Do we want to overwrite? if os.path.exists(out_filename): pass + if pose_version == 2: + write_jabs_v2(converted_labels, out_filename) + elif pose_version == 3: + write_jabs_v3(converted_labels, out_filename) + elif pose_version == 4: + write_jabs_v4(converted_labels, out_filename) + elif pose_version == 5: + write_jabs_v5(converted_labels, out_filename) + else: + raise NotImplementedError(f"Pose format {pose_version} not supported.") def write_jabs_v2(data: dict, filename: str): """ Write JABS pose file v2 data to file. @@ -228,6 +241,7 @@ def write_jabs_v2(data: dict, filename: str): pose_grp = h5.require_group('poseest') pose_grp.attrs.update({'version':[2,0]}) pose_dataset = pose_grp.require_dataset('points', out_keypoints.shape, out_keypoints.dtype, data = out_keypoints) + conf_dataset = pose_grp.require_dataset('confidence', out_confidences.shape, out_confidences.dtype, data = out_confidences) def write_jabs_v3(data: dict, filename: str): @@ -249,7 +263,7 @@ def write_jabs_v3(data: dict, filename: str): id_dataset = pose_grp.require_dataset('instance_track_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) # instance count field count_dataset = pose_grp.require_dataset('instance_count', data['num_identities'].shape, data['num_identities'].dtype, data = data['num_identities']) - # extra field where we don't have data + # extra field where we don't have data, so fill with default data kp_embedding_dataset = pose_grp.require_dataset('instance_embedding', data['confidence'].shape, data['confidence'].dtype, data = np.zeros_like(data['confidence'])) def write_jabs_v4(data: dict, filename: str): @@ -260,14 +274,37 @@ def write_jabs_v4(data: dict, filename: str): data: Dictionary of JABS data generated from convert_labels filename: Filename to write data to """ - pass + # v4 extends v3 + write_jabs_v3(data, filename) + with h5py.File(filename, 'a') as h5: + pose_grp = h5.require_group('poseest') + pose_grp.attrs.update({'version':[4,0]}) + # new fields on top of v4 + identity_mask_mat = np.all(data['confidence']==0, axis=-1) + mask_dataset = pose_grp.require_dataset('id_mask', identity_mask_mat.shape, identity_mask_mat.dtype, data = identity_mask_mat) + # No identity embedding data + # Note that since the identity information doesn't exist, this will break any functionality that relies on it + default_id_embeds = np.zeros(list(identity_mask_mat.shape) + [0], dtype = np.float32) + id_embed_dataset = pose_grp.require_dataset('identity_embeds', default_id_embeds.shape, default_id_embeds.dtype, data = default_id_embeds) + default_id_centers = np.zeros(default_id_embeds.shape[1:], dtype=np.float32) + id_centers = pose_grp.require_dataset('instance_id_center', default_id_centers.shape, default_id_centers.dtype, data = default_id_centers) + # v4 uses a new id field + pose_grp.require_dataset('instance_embed_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) + def write_jabs_v5(data: dict, filename: str): """ Write JABS pose file v5 data to file. Writes multi-mouse pose, longterm identity, and static object data. + # TODO: Add in static objects Args: data: Dictionary of JABS data generated from convert_labels filename: Filename to write data to """ - pass + # v5 extends v4 + write_jabs_v4(data, filename) + with h5py.File(filename, 'a') as h5: + pose_grp = h5.require_group('poseest') + pose_grp.attrs.update({'version':[5,0]}) + object_grp = h5.require_group('static_objects') + # Static objects aren't in the data dict yet... diff --git a/sleap_io/io/main.py b/sleap_io/io/main.py index 23c297a8..793008c5 100644 --- a/sleap_io/io/main.py +++ b/sleap_io/io/main.py @@ -92,6 +92,11 @@ def load_jabs(filename: str, skeleton: Optional[Skeleton] = None) -> Labels: """ return jabs.read_labels(filename, skeleton=skeleton) -def save_jabs(labels: Labels, filename: str, pose_version: int): - """Save a SLEAP dataset to JABS pose file format.""" - jabs.write_labels(labels, filename, pose_version) +def save_jabs(labels: Labels, pose_version: int): + """Save a SLEAP dataset to JABS pose file format. Filenames for JABS poses are based on video filenames. + + Args: + labels: SLEAP `Labels` object + pose_version: The JABS pose version to write data out + """ + jabs.write_labels(labels, pose_version) From abaa5a635f7b9d2f765deb0a9237f0627ce9869c Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Tue, 5 Sep 2023 17:27:04 -0400 Subject: [PATCH 06/19] Fixing a pose_v4 field and beginning to address some todos --- sleap_io/io/jabs.py | 51 +++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 3c06377e..0c99c3b6 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -7,6 +7,7 @@ import os import numpy as np from typing import Dict, Iterable, List, Tuple, Optional, Union +import warnings from sleap_io import Instance, LabeledFrame, Labels, Node, Edge, Symmetry, Point, Video, Skeleton, Track @@ -144,7 +145,7 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa else: return Instance(points, skeleton=skeleton, track=track) -def get_max_ids_in_video(labels: List[Labels]) -> int: +def get_max_ids_in_video(labels: List[Labels], key: str = 'Mouse') -> int: """Determine the maximum number of identities that exist at the same time Args: @@ -152,50 +153,67 @@ def get_max_ids_in_video(labels: List[Labels]) -> int: """ max_labels = 0 for label in labels: - n_labels = len(label.instances) + n_labels = sum([x.skeleton.name == key for x in label.instances]) max_labels = max(max_labels, n_labels) return max_labels -def convert_labels(labels: List[Labels]) -> dict: +def convert_labels(all_labels: Labels, video: str) -> dict: """Convert a `Labels` object into JABS-formatted annotations. TODO: Currently assumes all data is mouse TODO: Identity is an unsafe str -> cast. Convert to factorize op TODO: See ignored fields in `read_labels` Args: - labels: list of SLEAP `Labels` belonging to a single video to be converted to JABS format. + all_labels: SLEAP `Labels` to be converted to JABS format. + video: name of video to be converted Returns: Dictionary of JABS data of the `Labels` data. """ # Determine shape of output - num_frames = len(labels) - num_keypoints = len(labels[0][0].skeleton.nodes) - num_mice = get_max_ids_in_video(labels) + labels = all_labels.find(video=video) + + num_frames = [x.shape[0] for x in all_labels.videos if x == video][0] + num_keypoints = [len(x.nodes) for x in all_labels.skeletons if x.name == 'Mouse'][0] + num_mice = get_max_ids_in_video(labels, key = 'Mouse') + track_2_idx = {key:val for key,val in zip(all_labels.tracks, range(len(all_labels.tracks)))} + last_unassigned_id = num_mice keypoint_mat = np.zeros([num_frames, num_mice, num_keypoints, 2], dtype=np.uint16) confidence_mat = np.zeros([num_frames, num_mice, num_keypoints], dtype=np.float32) identity_mat = np.zeros([num_frames, num_mice], dtype=np.uint32) instance_vector = np.zeros([num_frames], dtype=np.uint8) + static_objects = {} # Populate the matrices with data for label in labels: assigned_instances = 0 + tracks = [x.track for x in label.instances if x.track] + track_ids = [track_2_idx[track] for track in tracks] for instance_idx, instance in enumerate(label.instances): + # Handle non-mouse annotations differently + if not instance.skeleton and instance.skeleton.name is not 'Mouse': + if not instance.skeleton: + static_objects[instance.skeleton.name] = instance.numpy() pose = instance.numpy() missing_points = np.isnan(pose[:,0]) pose[np.isnan(pose)] = 0 # JABS stores y,x pose = pose.astype(np.uint16)[:,::-1] - keypoint_mat[label.frame_idx, instance_idx] = pose + keypoint_mat[label.frame_idx, instance_idx, :, :] = pose confidence_mat[label.frame_idx, instance_idx, ~missing_points] = 1.0 - identity_mat[label.frame_idx, instance_idx] = np.uint32(instance.track.name) + if instance.track: + identity_mat[label.frame_idx, instance_idx] = track_2_idx[instance.track] + else: + warnings.warn(f"Pose with unassigned track found on {label.video.filename} frame {label.frame_idx} instance {instance_idx}. Assigning ID {last_unassigned_id}.") + identity_mat[label.frame_idx, instance_idx] = last_unassigned_id + last_unassigned_id += 1 assigned_instances += 1 instance_vector[label.frame_idx] = assigned_instances # Return the data as a dict - return {'keypoints': keypoint_mat, 'confidence': confidence_mat, 'identity': identity_mat, 'num_identities': instance_vector} + return {'keypoints': keypoint_mat.astype(np.uint16), 'confidence': confidence_mat.astype(np.float32), 'identity': identity_mat.astype(np.uint32), 'num_identities': instance_vector.astype(np.uint16), 'static_objects': static_objects} def write_labels(labels: Labels, pose_version: int): """Convert and save a SLEAP `Labels` object to a JABS pose file. @@ -207,9 +225,8 @@ def write_labels(labels: Labels, pose_version: int): """ for video in labels.videos: - video_labels = labels.find(video=video) - converted_labels = convert_labels(video_labels) - out_filename = re.sub('\.avi', f'_pose_est_v{pose_version}.h5', video.filename) + converted_labels = convert_labels(labels, video) + out_filename = os.path.splitext(video.filename)[0] + f'_pose_est_v{pose_version}.h5' # Do we want to overwrite? if os.path.exists(out_filename): pass @@ -280,7 +297,7 @@ def write_jabs_v4(data: dict, filename: str): pose_grp = h5.require_group('poseest') pose_grp.attrs.update({'version':[4,0]}) # new fields on top of v4 - identity_mask_mat = np.all(data['confidence']==0, axis=-1) + identity_mask_mat = np.all(data['confidence']==0, axis=-1).astype(bool) mask_dataset = pose_grp.require_dataset('id_mask', identity_mask_mat.shape, identity_mask_mat.dtype, data = identity_mask_mat) # No identity embedding data # Note that since the identity information doesn't exist, this will break any functionality that relies on it @@ -288,8 +305,10 @@ def write_jabs_v4(data: dict, filename: str): id_embed_dataset = pose_grp.require_dataset('identity_embeds', default_id_embeds.shape, default_id_embeds.dtype, data = default_id_embeds) default_id_centers = np.zeros(default_id_embeds.shape[1:], dtype=np.float32) id_centers = pose_grp.require_dataset('instance_id_center', default_id_centers.shape, default_id_centers.dtype, data = default_id_centers) - # v4 uses a new id field - pose_grp.require_dataset('instance_embed_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) + # v4 uses an id field that is 1-indexed + identities_1_indexed = np.copy(data['identity']) + 1 + identities_1_indexed[identity_mask_mat] = 0 + pose_grp.require_dataset('instance_embed_id', identities_1_indexed.shape, identities_1_indexed.dtype, data = identities_1_indexed) def write_jabs_v5(data: dict, filename: str): From 82141bd594d299e05aacc5b88d42db764f0b35e7 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Wed, 6 Sep 2023 09:57:51 -0400 Subject: [PATCH 07/19] updating todos --- sleap_io/io/jabs.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 0c99c3b6..f1d4750d 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -117,7 +117,6 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK frames.append(frame_label) return Labels(frames) - def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.float32]], confidence: np.ndarray[np.float32], skeleton: Skeleton, track: Track = None) -> Instance: """Create an `Instance` from prediction data. @@ -160,8 +159,6 @@ def get_max_ids_in_video(labels: List[Labels], key: str = 'Mouse') -> int: def convert_labels(all_labels: Labels, video: str) -> dict: """Convert a `Labels` object into JABS-formatted annotations. - TODO: Currently assumes all data is mouse - TODO: Identity is an unsafe str -> cast. Convert to factorize op TODO: See ignored fields in `read_labels` Args: @@ -264,6 +261,7 @@ def write_jabs_v2(data: dict, filename: str): def write_jabs_v3(data: dict, filename: str): """ Write JABS pose file v3 data to file. Writes multi-mouse pose data. + TODO: v3 requires continuous tracklets (eg no gaps) IDs need to be incremented for this field Args: data: Dictionary of JABS data generated from convert_labels From 5a4ff1da69540c2dd134701c3577d3980a09b214 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Thu, 7 Sep 2023 10:19:59 -0400 Subject: [PATCH 08/19] Adding static object support --- sleap_io/io/jabs.py | 48 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 9 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index f1d4750d..a9979b36 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -92,6 +92,8 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK for frame_idx in range(num_frames): instances = [] pose_data = pose_file['poseest/points'][frame_idx, ...] + # JABS stores y,x + pose_data = np.flip(pose_data, axis=-1) pose_conf = pose_file['poseest/confidence'][frame_idx, ...] # single animal case if pose_version == 2: @@ -113,10 +115,34 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK new_instance = prediction_to_instance(pose_data[cur_id], pose_conf[cur_id], skeleton, tracks[pose_ids[cur_id]]) if new_instance: instances.append(new_instance) + # Static objects + if frame_idx == 0 and pose_version >= 5 and 'static_objects' in pose_file.keys(): + present_objects = pose_file['static_objects'].keys() + for cur_object in present_objects: + object_keypoints = pose_file['static_objects/' + cur_object][:] + object_skeleton = make_simple_skeleton(cur_object, object_keypoints.shape[0]) + new_instance = prediction_to_instance(object_keypoints, np.ones(object_keypoints.shape[:-1]), object_skeleton) + if new_instance: + instances.append(new_instance) frame_label = LabeledFrame(Video(video_name), frame_idx, instances) frames.append(frame_label) return Labels(frames) +def make_simple_skeleton(name: str, num_points: int) -> Skeleton: + """Create a `Skeleton` with a requested number of nodes attached in a line + + Args: + name: name of the skeleton and prefix to nodes + num_points: number of points to use in the skeleton + + Returns: + Generated `Skeleton`. + + """ + nodes = [Node(name + '_kp' + str(i)) for i in range(num_points)] + edges = [Edge(nodes[i], nodes[i+1]) for i in range(num_points-1)] + return Skeleton(nodes, edges, name=name) + def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.float32]], confidence: np.ndarray[np.float32], skeleton: Skeleton, track: Track = None) -> Instance: """Create an `Instance` from prediction data. @@ -134,8 +160,8 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa # confidence of 0 indicates no keypoint predicted for instance if confidence[i] > 0.001: points[cur_node] = Point( - data[i,1], data[i,0], + data[i,1], visible=True, ) @@ -189,15 +215,18 @@ def convert_labels(all_labels: Labels, video: str) -> dict: tracks = [x.track for x in label.instances if x.track] track_ids = [track_2_idx[track] for track in tracks] for instance_idx, instance in enumerate(label.instances): - # Handle non-mouse annotations differently - if not instance.skeleton and instance.skeleton.name is not 'Mouse': - if not instance.skeleton: - static_objects[instance.skeleton.name] = instance.numpy() + # Don't handle instances without skeletons + if not instance.skeleton: + continue + # Static objects just get added to the object dict + elif instance.skeleton.name != 'Mouse': + static_objects[instance.skeleton.name] = instance.numpy() + continue pose = instance.numpy() missing_points = np.isnan(pose[:,0]) pose[np.isnan(pose)] = 0 # JABS stores y,x - pose = pose.astype(np.uint16)[:,::-1] + pose = np.flip(pose.astype(np.uint16), axis=-1) keypoint_mat[label.frame_idx, instance_idx, :, :] = pose confidence_mat[label.frame_idx, instance_idx, ~missing_points] = 1.0 if instance.track: @@ -312,7 +341,6 @@ def write_jabs_v4(data: dict, filename: str): def write_jabs_v5(data: dict, filename: str): """ Write JABS pose file v5 data to file. Writes multi-mouse pose, longterm identity, and static object data. - # TODO: Add in static objects Args: data: Dictionary of JABS data generated from convert_labels @@ -323,5 +351,7 @@ def write_jabs_v5(data: dict, filename: str): with h5py.File(filename, 'a') as h5: pose_grp = h5.require_group('poseest') pose_grp.attrs.update({'version':[5,0]}) - object_grp = h5.require_group('static_objects') - # Static objects aren't in the data dict yet... + if 'static_objects' in data.keys(): + object_grp = h5.require_group('static_objects') + for object_key, object_keypoints in data['static_objects'].items(): + object_grp.require_dataset(object_key, object_keypoints.shape, np.uint16, data = object_keypoints.astype(np.uint16)) From e9de2a2ab71d7dbb43185beb65525cea79919be2 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Thu, 7 Sep 2023 10:31:02 -0400 Subject: [PATCH 09/19] Cleanup of todos and removing some unused variables when writing h5 files --- sleap_io/io/jabs.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index a9979b36..9c9713f4 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -55,9 +55,9 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON) -> Labels: """Read JABS style pose from a file and return a `Labels` object. - TODO: Currently only reads in pose data. v5 static objects are currently ignored - TODO: Attributes are ignored. Is there a way to keep them in SLEAP format? - TODO: px_to_cm field is ignored. + TODO: Attributes are ignored, including px_to_cm field. + TODO: Segmentation data ignored in v6, but will read in pose. + TODO: Lixit static objects currently stored as n_lixit,2 (eg 1 object). Should be converted to multiple objects Args: labels_path: Path to the JABS pose file. @@ -92,7 +92,7 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK for frame_idx in range(num_frames): instances = [] pose_data = pose_file['poseest/points'][frame_idx, ...] - # JABS stores y,x + # JABS stores y,x for poses pose_data = np.flip(pose_data, axis=-1) pose_conf = pose_file['poseest/confidence'][frame_idx, ...] # single animal case @@ -185,7 +185,6 @@ def get_max_ids_in_video(labels: List[Labels], key: str = 'Mouse') -> int: def convert_labels(all_labels: Labels, video: str) -> dict: """Convert a `Labels` object into JABS-formatted annotations. - TODO: See ignored fields in `read_labels` Args: all_labels: SLEAP `Labels` to be converted to JABS format. @@ -194,9 +193,9 @@ def convert_labels(all_labels: Labels, video: str) -> dict: Returns: Dictionary of JABS data of the `Labels` data. """ - # Determine shape of output labels = all_labels.find(video=video) + # Determine shape of output num_frames = [x.shape[0] for x in all_labels.videos if x == video][0] num_keypoints = [len(x.nodes) for x in all_labels.skeletons if x.name == 'Mouse'][0] num_mice = get_max_ids_in_video(labels, key = 'Mouse') @@ -219,13 +218,14 @@ def convert_labels(all_labels: Labels, video: str) -> dict: if not instance.skeleton: continue # Static objects just get added to the object dict + # This will clobber data if more than one frame is annotated elif instance.skeleton.name != 'Mouse': static_objects[instance.skeleton.name] = instance.numpy() continue pose = instance.numpy() missing_points = np.isnan(pose[:,0]) pose[np.isnan(pose)] = 0 - # JABS stores y,x + # JABS stores y,x for poses pose = np.flip(pose.astype(np.uint16), axis=-1) keypoint_mat[label.frame_idx, instance_idx, :, :] = pose confidence_mat[label.frame_idx, instance_idx, ~missing_points] = 1.0 @@ -283,8 +283,8 @@ def write_jabs_v2(data: dict, filename: str): with h5py.File(filename, 'w') as h5: pose_grp = h5.require_group('poseest') pose_grp.attrs.update({'version':[2,0]}) - pose_dataset = pose_grp.require_dataset('points', out_keypoints.shape, out_keypoints.dtype, data = out_keypoints) - conf_dataset = pose_grp.require_dataset('confidence', out_confidences.shape, out_confidences.dtype, data = out_confidences) + pose_grp.require_dataset('points', out_keypoints.shape, out_keypoints.dtype, data = out_keypoints) + pose_grp.require_dataset('confidence', out_confidences.shape, out_confidences.dtype, data = out_confidences) def write_jabs_v3(data: dict, filename: str): @@ -300,15 +300,15 @@ def write_jabs_v3(data: dict, filename: str): pose_grp = h5.require_group('poseest') pose_grp.attrs.update({'version':[3,0]}) # keypoint field - pose_dataset = pose_grp.require_dataset('points', data['keypoints'].shape, data['keypoints'].dtype, data = data['keypoints']) + pose_grp.require_dataset('points', data['keypoints'].shape, data['keypoints'].dtype, data = data['keypoints']) # confidence field - conf_dataset = pose_grp.require_dataset('confidence', data['confidence'].shape, data['confidence'].dtype, data = data['confidence']) + pose_grp.require_dataset('confidence', data['confidence'].shape, data['confidence'].dtype, data = data['confidence']) # id field - id_dataset = pose_grp.require_dataset('instance_track_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) + pose_grp.require_dataset('instance_track_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) # instance count field - count_dataset = pose_grp.require_dataset('instance_count', data['num_identities'].shape, data['num_identities'].dtype, data = data['num_identities']) + pose_grp.require_dataset('instance_count', data['num_identities'].shape, data['num_identities'].dtype, data = data['num_identities']) # extra field where we don't have data, so fill with default data - kp_embedding_dataset = pose_grp.require_dataset('instance_embedding', data['confidence'].shape, data['confidence'].dtype, data = np.zeros_like(data['confidence'])) + pose_grp.require_dataset('instance_embedding', data['confidence'].shape, data['confidence'].dtype, data = np.zeros_like(data['confidence'])) def write_jabs_v4(data: dict, filename: str): """ Write JABS pose file v4 data to file. @@ -325,13 +325,13 @@ def write_jabs_v4(data: dict, filename: str): pose_grp.attrs.update({'version':[4,0]}) # new fields on top of v4 identity_mask_mat = np.all(data['confidence']==0, axis=-1).astype(bool) - mask_dataset = pose_grp.require_dataset('id_mask', identity_mask_mat.shape, identity_mask_mat.dtype, data = identity_mask_mat) + pose_grp.require_dataset('id_mask', identity_mask_mat.shape, identity_mask_mat.dtype, data = identity_mask_mat) # No identity embedding data # Note that since the identity information doesn't exist, this will break any functionality that relies on it default_id_embeds = np.zeros(list(identity_mask_mat.shape) + [0], dtype = np.float32) - id_embed_dataset = pose_grp.require_dataset('identity_embeds', default_id_embeds.shape, default_id_embeds.dtype, data = default_id_embeds) + pose_grp.require_dataset('identity_embeds', default_id_embeds.shape, default_id_embeds.dtype, data = default_id_embeds) default_id_centers = np.zeros(default_id_embeds.shape[1:], dtype=np.float32) - id_centers = pose_grp.require_dataset('instance_id_center', default_id_centers.shape, default_id_centers.dtype, data = default_id_centers) + pose_grp.require_dataset('instance_id_center', default_id_centers.shape, default_id_centers.dtype, data = default_id_centers) # v4 uses an id field that is 1-indexed identities_1_indexed = np.copy(data['identity']) + 1 identities_1_indexed[identity_mask_mat] = 0 From 0639606d248ab9e023b34f7868a6e7073abb2507 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Thu, 7 Sep 2023 10:39:36 -0400 Subject: [PATCH 10/19] Ran black --- sleap_io/io/jabs.py | 306 +++++++++++++++++++++++++++++++------------- sleap_io/io/main.py | 2 + 2 files changed, 216 insertions(+), 92 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 9c9713f4..b8e56c74 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -9,21 +9,32 @@ from typing import Dict, Iterable, List, Tuple, Optional, Union import warnings -from sleap_io import Instance, LabeledFrame, Labels, Node, Edge, Symmetry, Point, Video, Skeleton, Track +from sleap_io import ( + Instance, + LabeledFrame, + Labels, + Node, + Edge, + Symmetry, + Point, + Video, + Skeleton, + Track, +) JABS_DEFAULT_KEYPOINTS = [ - Node('NOSE'), - Node('LEFT_EAR'), - Node('RIGHT_EAR'), - Node('BASE_NECK'), - Node('LEFT_FRONT_PAW'), - Node('RIGHT_FRONT_PAW'), - Node('CENTER_SPINE'), - Node('LEFT_REAR_PAW'), - Node('RIGHT_REAR_PAW'), - Node('BASE_TAIL'), - Node('MID_TAIL'), - Node('TIP_TAIL') + Node("NOSE"), + Node("LEFT_EAR"), + Node("RIGHT_EAR"), + Node("BASE_NECK"), + Node("LEFT_FRONT_PAW"), + Node("RIGHT_FRONT_PAW"), + Node("CENTER_SPINE"), + Node("LEFT_REAR_PAW"), + Node("RIGHT_REAR_PAW"), + Node("BASE_TAIL"), + Node("MID_TAIL"), + Node("TIP_TAIL"), ] # Root node is base neck (3) @@ -51,9 +62,14 @@ Symmetry([JABS_DEFAULT_KEYPOINTS[7], JABS_DEFAULT_KEYPOINTS[8]]), ] -JABS_DEFAULT_SKELETON = Skeleton(JABS_DEFAULT_KEYPOINTS, JABS_DEFAULT_EDGES, JABS_DEFAULT_SYMMETRIES, name='Mouse') +JABS_DEFAULT_SKELETON = Skeleton( + JABS_DEFAULT_KEYPOINTS, JABS_DEFAULT_EDGES, JABS_DEFAULT_SYMMETRIES, name="Mouse" +) -def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON) -> Labels: + +def read_labels( + labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON +) -> Labels: """Read JABS style pose from a file and return a `Labels` object. TODO: Attributes are ignored, including px_to_cm field. TODO: Segmentation data ignored in v6, but will read in pose. @@ -61,50 +77,54 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK Args: labels_path: Path to the JABS pose file. - skeleton: An optional `Skeleton` object. Defaults to JABS pose version 2-6. + skeleton: An optional `Skeleton` object. Defaults to JABS pose version 2-6. Returns: Parsed labels as a `Labels` instance. """ frames: List[LabeledFrame] = [] # Video name is the pose file minus the suffix - video_name = re.sub(r'(_pose_est_v[2-6])?\.h5', '.avi', labels_path) + video_name = re.sub(r"(_pose_est_v[2-6])?\.h5", ".avi", labels_path) if not skeleton: skeleton = JABS_DEFAULT_SKELETON tracks = {} with h5py.File(labels_path, "r") as pose_file: - num_frames = pose_file['poseest/points'].shape[0] + num_frames = pose_file["poseest/points"].shape[0] try: - pose_version = pose_file['poseest'].attrs['version'][0] + pose_version = pose_file["poseest"].attrs["version"][0] except: pose_version = 2 - tracks[1] = Track('1') - data_shape = pose_file['poseest/points'].shape - assert len(data_shape)==3, f'Pose version not present and shape does not match single mouse: shape of {data_shape} for {labels_path}' + tracks[1] = Track("1") + data_shape = pose_file["poseest/points"].shape + assert ( + len(data_shape) == 3 + ), f"Pose version not present and shape does not match single mouse: shape of {data_shape} for {labels_path}" # Change field name for newer pose formats if pose_version == 3: - id_key = 'instance_track_id' + id_key = "instance_track_id" elif pose_version > 3: - id_key = 'instance_embed_id' - max_ids = pose_file['poseest/points'].shape[1] + id_key = "instance_embed_id" + max_ids = pose_file["poseest/points"].shape[1] for frame_idx in range(num_frames): instances = [] - pose_data = pose_file['poseest/points'][frame_idx, ...] + pose_data = pose_file["poseest/points"][frame_idx, ...] # JABS stores y,x for poses pose_data = np.flip(pose_data, axis=-1) - pose_conf = pose_file['poseest/confidence'][frame_idx, ...] + pose_conf = pose_file["poseest/confidence"][frame_idx, ...] # single animal case if pose_version == 2: - new_instance = prediction_to_instance(pose_data, pose_conf, skeleton, tracks[1]) + new_instance = prediction_to_instance( + pose_data, pose_conf, skeleton, tracks[1] + ) instances.append(new_instance) # multi-animal case if pose_version > 2: - pose_ids = pose_file['poseest/' + id_key][frame_idx, ...] + pose_ids = pose_file["poseest/" + id_key][frame_idx, ...] # pose_v3 uses another field to describe the number of valid poses if pose_version == 3: - max_ids = pose_file['poseest/instance_count'][frame_idx] + max_ids = pose_file["poseest/instance_count"][frame_idx] for cur_id in range(max_ids): # v4+ uses reserved values for invalid/unused poses # Note: ignores 'poseest/id_mask' to keep predictions that were not assigned an id @@ -112,22 +132,38 @@ def read_labels(labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SK continue if pose_ids[cur_id] not in tracks.keys(): tracks[pose_ids[cur_id]] = Track(str(pose_ids[cur_id])) - new_instance = prediction_to_instance(pose_data[cur_id], pose_conf[cur_id], skeleton, tracks[pose_ids[cur_id]]) + new_instance = prediction_to_instance( + pose_data[cur_id], + pose_conf[cur_id], + skeleton, + tracks[pose_ids[cur_id]], + ) if new_instance: instances.append(new_instance) # Static objects - if frame_idx == 0 and pose_version >= 5 and 'static_objects' in pose_file.keys(): - present_objects = pose_file['static_objects'].keys() + if ( + frame_idx == 0 + and pose_version >= 5 + and "static_objects" in pose_file.keys() + ): + present_objects = pose_file["static_objects"].keys() for cur_object in present_objects: - object_keypoints = pose_file['static_objects/' + cur_object][:] - object_skeleton = make_simple_skeleton(cur_object, object_keypoints.shape[0]) - new_instance = prediction_to_instance(object_keypoints, np.ones(object_keypoints.shape[:-1]), object_skeleton) + object_keypoints = pose_file["static_objects/" + cur_object][:] + object_skeleton = make_simple_skeleton( + cur_object, object_keypoints.shape[0] + ) + new_instance = prediction_to_instance( + object_keypoints, + np.ones(object_keypoints.shape[:-1]), + object_skeleton, + ) if new_instance: instances.append(new_instance) frame_label = LabeledFrame(Video(video_name), frame_idx, instances) frames.append(frame_label) return Labels(frames) + def make_simple_skeleton(name: str, num_points: int) -> Skeleton: """Create a `Skeleton` with a requested number of nodes attached in a line @@ -139,11 +175,17 @@ def make_simple_skeleton(name: str, num_points: int) -> Skeleton: Generated `Skeleton`. """ - nodes = [Node(name + '_kp' + str(i)) for i in range(num_points)] - edges = [Edge(nodes[i], nodes[i+1]) for i in range(num_points-1)] + nodes = [Node(name + "_kp" + str(i)) for i in range(num_points)] + edges = [Edge(nodes[i], nodes[i + 1]) for i in range(num_points - 1)] return Skeleton(nodes, edges, name=name) -def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.float32]], confidence: np.ndarray[np.float32], skeleton: Skeleton, track: Track = None) -> Instance: + +def prediction_to_instance( + data: Union[np.ndarray[np.uint16], np.ndarray[np.float32]], + confidence: np.ndarray[np.float32], + skeleton: Skeleton, + track: Track = None, +) -> Instance: """Create an `Instance` from prediction data. Args: @@ -153,15 +195,17 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa Returns: Parsed `Instance`. """ - assert len(skeleton.nodes) == data.shape[0], f'Skeleton ({len(skeleton.nodes)}) does not match number of keypoints ({data.shape[0]})' + assert ( + len(skeleton.nodes) == data.shape[0] + ), f"Skeleton ({len(skeleton.nodes)}) does not match number of keypoints ({data.shape[0]})" points = {} for i, cur_node in enumerate(skeleton.nodes): # confidence of 0 indicates no keypoint predicted for instance if confidence[i] > 0.001: points[cur_node] = Point( - data[i,0], - data[i,1], + data[i, 0], + data[i, 1], visible=True, ) @@ -170,11 +214,12 @@ def prediction_to_instance(data: Union[np.ndarray[np.uint16], np.ndarray[np.floa else: return Instance(points, skeleton=skeleton, track=track) -def get_max_ids_in_video(labels: List[Labels], key: str = 'Mouse') -> int: + +def get_max_ids_in_video(labels: List[Labels], key: str = "Mouse") -> int: """Determine the maximum number of identities that exist at the same time - + Args: - labels: SLEAP `Labels` to count + labels: SLEAP `Labels` to count """ max_labels = 0 for label in labels: @@ -183,6 +228,7 @@ def get_max_ids_in_video(labels: List[Labels], key: str = 'Mouse') -> int: return max_labels + def convert_labels(all_labels: Labels, video: str) -> dict: """Convert a `Labels` object into JABS-formatted annotations. @@ -197,9 +243,11 @@ def convert_labels(all_labels: Labels, video: str) -> dict: # Determine shape of output num_frames = [x.shape[0] for x in all_labels.videos if x == video][0] - num_keypoints = [len(x.nodes) for x in all_labels.skeletons if x.name == 'Mouse'][0] - num_mice = get_max_ids_in_video(labels, key = 'Mouse') - track_2_idx = {key:val for key,val in zip(all_labels.tracks, range(len(all_labels.tracks)))} + num_keypoints = [len(x.nodes) for x in all_labels.skeletons if x.name == "Mouse"][0] + num_mice = get_max_ids_in_video(labels, key="Mouse") + track_2_idx = { + key: val for key, val in zip(all_labels.tracks, range(len(all_labels.tracks))) + } last_unassigned_id = num_mice keypoint_mat = np.zeros([num_frames, num_mice, num_keypoints, 2], dtype=np.uint16) @@ -219,27 +267,38 @@ def convert_labels(all_labels: Labels, video: str) -> dict: continue # Static objects just get added to the object dict # This will clobber data if more than one frame is annotated - elif instance.skeleton.name != 'Mouse': + elif instance.skeleton.name != "Mouse": static_objects[instance.skeleton.name] = instance.numpy() continue pose = instance.numpy() - missing_points = np.isnan(pose[:,0]) + missing_points = np.isnan(pose[:, 0]) pose[np.isnan(pose)] = 0 # JABS stores y,x for poses pose = np.flip(pose.astype(np.uint16), axis=-1) keypoint_mat[label.frame_idx, instance_idx, :, :] = pose confidence_mat[label.frame_idx, instance_idx, ~missing_points] = 1.0 if instance.track: - identity_mat[label.frame_idx, instance_idx] = track_2_idx[instance.track] + identity_mat[label.frame_idx, instance_idx] = track_2_idx[ + instance.track + ] else: - warnings.warn(f"Pose with unassigned track found on {label.video.filename} frame {label.frame_idx} instance {instance_idx}. Assigning ID {last_unassigned_id}.") + warnings.warn( + f"Pose with unassigned track found on {label.video.filename} frame {label.frame_idx} instance {instance_idx}. Assigning ID {last_unassigned_id}." + ) identity_mat[label.frame_idx, instance_idx] = last_unassigned_id last_unassigned_id += 1 assigned_instances += 1 instance_vector[label.frame_idx] = assigned_instances # Return the data as a dict - return {'keypoints': keypoint_mat.astype(np.uint16), 'confidence': confidence_mat.astype(np.float32), 'identity': identity_mat.astype(np.uint32), 'num_identities': instance_vector.astype(np.uint16), 'static_objects': static_objects} + return { + "keypoints": keypoint_mat.astype(np.uint16), + "confidence": confidence_mat.astype(np.float32), + "identity": identity_mat.astype(np.uint32), + "num_identities": instance_vector.astype(np.uint16), + "static_objects": static_objects, + } + def write_labels(labels: Labels, pose_version: int): """Convert and save a SLEAP `Labels` object to a JABS pose file. @@ -252,7 +311,9 @@ def write_labels(labels: Labels, pose_version: int): for video in labels.videos: converted_labels = convert_labels(labels, video) - out_filename = os.path.splitext(video.filename)[0] + f'_pose_est_v{pose_version}.h5' + out_filename = ( + os.path.splitext(video.filename)[0] + f"_pose_est_v{pose_version}.h5" + ) # Do we want to overwrite? if os.path.exists(out_filename): pass @@ -267,8 +328,9 @@ def write_labels(labels: Labels, pose_version: int): else: raise NotImplementedError(f"Pose format {pose_version} not supported.") + def write_jabs_v2(data: dict, filename: str): - """ Write JABS pose file v2 data to file. + """Write JABS pose file v2 data to file. Writes single mouse pose data. Args: @@ -276,19 +338,26 @@ def write_jabs_v2(data: dict, filename: str): filename: Filename to write data to """ # Check that we're trying to write single mouse data - assert data['keypoints'].shape[1] == 1 - out_keypoints = np.squeeze(data['keypoints'], axis=1) - out_confidences = np.squeeze(data['confidence'], axis=1) - - with h5py.File(filename, 'w') as h5: - pose_grp = h5.require_group('poseest') - pose_grp.attrs.update({'version':[2,0]}) - pose_grp.require_dataset('points', out_keypoints.shape, out_keypoints.dtype, data = out_keypoints) - pose_grp.require_dataset('confidence', out_confidences.shape, out_confidences.dtype, data = out_confidences) + assert data["keypoints"].shape[1] == 1 + out_keypoints = np.squeeze(data["keypoints"], axis=1) + out_confidences = np.squeeze(data["confidence"], axis=1) + + with h5py.File(filename, "w") as h5: + pose_grp = h5.require_group("poseest") + pose_grp.attrs.update({"version": [2, 0]}) + pose_grp.require_dataset( + "points", out_keypoints.shape, out_keypoints.dtype, data=out_keypoints + ) + pose_grp.require_dataset( + "confidence", + out_confidences.shape, + out_confidences.dtype, + data=out_confidences, + ) def write_jabs_v3(data: dict, filename: str): - """ Write JABS pose file v3 data to file. + """Write JABS pose file v3 data to file. Writes multi-mouse pose data. TODO: v3 requires continuous tracklets (eg no gaps) IDs need to be incremented for this field @@ -296,22 +365,48 @@ def write_jabs_v3(data: dict, filename: str): data: Dictionary of JABS data generated from convert_labels filename: Filename to write data to """ - with h5py.File(filename, 'w') as h5: - pose_grp = h5.require_group('poseest') - pose_grp.attrs.update({'version':[3,0]}) + with h5py.File(filename, "w") as h5: + pose_grp = h5.require_group("poseest") + pose_grp.attrs.update({"version": [3, 0]}) # keypoint field - pose_grp.require_dataset('points', data['keypoints'].shape, data['keypoints'].dtype, data = data['keypoints']) + pose_grp.require_dataset( + "points", + data["keypoints"].shape, + data["keypoints"].dtype, + data=data["keypoints"], + ) # confidence field - pose_grp.require_dataset('confidence', data['confidence'].shape, data['confidence'].dtype, data = data['confidence']) + pose_grp.require_dataset( + "confidence", + data["confidence"].shape, + data["confidence"].dtype, + data=data["confidence"], + ) # id field - pose_grp.require_dataset('instance_track_id', data['identity'].shape, data['identity'].dtype, data = data['identity']) + pose_grp.require_dataset( + "instance_track_id", + data["identity"].shape, + data["identity"].dtype, + data=data["identity"], + ) # instance count field - pose_grp.require_dataset('instance_count', data['num_identities'].shape, data['num_identities'].dtype, data = data['num_identities']) + pose_grp.require_dataset( + "instance_count", + data["num_identities"].shape, + data["num_identities"].dtype, + data=data["num_identities"], + ) # extra field where we don't have data, so fill with default data - pose_grp.require_dataset('instance_embedding', data['confidence'].shape, data['confidence'].dtype, data = np.zeros_like(data['confidence'])) + pose_grp.require_dataset( + "instance_embedding", + data["confidence"].shape, + data["confidence"].dtype, + data=np.zeros_like(data["confidence"]), + ) + def write_jabs_v4(data: dict, filename: str): - """ Write JABS pose file v4 data to file. + """Write JABS pose file v4 data to file. Writes multi-mouse pose and longterm identity object data. Args: @@ -320,26 +415,48 @@ def write_jabs_v4(data: dict, filename: str): """ # v4 extends v3 write_jabs_v3(data, filename) - with h5py.File(filename, 'a') as h5: - pose_grp = h5.require_group('poseest') - pose_grp.attrs.update({'version':[4,0]}) + with h5py.File(filename, "a") as h5: + pose_grp = h5.require_group("poseest") + pose_grp.attrs.update({"version": [4, 0]}) # new fields on top of v4 - identity_mask_mat = np.all(data['confidence']==0, axis=-1).astype(bool) - pose_grp.require_dataset('id_mask', identity_mask_mat.shape, identity_mask_mat.dtype, data = identity_mask_mat) + identity_mask_mat = np.all(data["confidence"] == 0, axis=-1).astype(bool) + pose_grp.require_dataset( + "id_mask", + identity_mask_mat.shape, + identity_mask_mat.dtype, + data=identity_mask_mat, + ) # No identity embedding data # Note that since the identity information doesn't exist, this will break any functionality that relies on it - default_id_embeds = np.zeros(list(identity_mask_mat.shape) + [0], dtype = np.float32) - pose_grp.require_dataset('identity_embeds', default_id_embeds.shape, default_id_embeds.dtype, data = default_id_embeds) + default_id_embeds = np.zeros( + list(identity_mask_mat.shape) + [0], dtype=np.float32 + ) + pose_grp.require_dataset( + "identity_embeds", + default_id_embeds.shape, + default_id_embeds.dtype, + data=default_id_embeds, + ) default_id_centers = np.zeros(default_id_embeds.shape[1:], dtype=np.float32) - pose_grp.require_dataset('instance_id_center', default_id_centers.shape, default_id_centers.dtype, data = default_id_centers) + pose_grp.require_dataset( + "instance_id_center", + default_id_centers.shape, + default_id_centers.dtype, + data=default_id_centers, + ) # v4 uses an id field that is 1-indexed - identities_1_indexed = np.copy(data['identity']) + 1 + identities_1_indexed = np.copy(data["identity"]) + 1 identities_1_indexed[identity_mask_mat] = 0 - pose_grp.require_dataset('instance_embed_id', identities_1_indexed.shape, identities_1_indexed.dtype, data = identities_1_indexed) + pose_grp.require_dataset( + "instance_embed_id", + identities_1_indexed.shape, + identities_1_indexed.dtype, + data=identities_1_indexed, + ) def write_jabs_v5(data: dict, filename: str): - """ Write JABS pose file v5 data to file. + """Write JABS pose file v5 data to file. Writes multi-mouse pose, longterm identity, and static object data. Args: @@ -348,10 +465,15 @@ def write_jabs_v5(data: dict, filename: str): """ # v5 extends v4 write_jabs_v4(data, filename) - with h5py.File(filename, 'a') as h5: - pose_grp = h5.require_group('poseest') - pose_grp.attrs.update({'version':[5,0]}) - if 'static_objects' in data.keys(): - object_grp = h5.require_group('static_objects') - for object_key, object_keypoints in data['static_objects'].items(): - object_grp.require_dataset(object_key, object_keypoints.shape, np.uint16, data = object_keypoints.astype(np.uint16)) + with h5py.File(filename, "a") as h5: + pose_grp = h5.require_group("poseest") + pose_grp.attrs.update({"version": [5, 0]}) + if "static_objects" in data.keys(): + object_grp = h5.require_group("static_objects") + for object_key, object_keypoints in data["static_objects"].items(): + object_grp.require_dataset( + object_key, + object_keypoints.shape, + np.uint16, + data=object_keypoints.astype(np.uint16), + ) diff --git a/sleap_io/io/main.py b/sleap_io/io/main.py index 793008c5..f24a0b53 100644 --- a/sleap_io/io/main.py +++ b/sleap_io/io/main.py @@ -80,6 +80,7 @@ def save_labelstudio(labels: Labels, filename: str): """Save a SLEAP dataset to Label Studio format.""" labelstudio.write_labels(labels, filename) + def load_jabs(filename: str, skeleton: Optional[Skeleton] = None) -> Labels: """Read JABS-style predictions from a file and return a `Labels` object. @@ -92,6 +93,7 @@ def load_jabs(filename: str, skeleton: Optional[Skeleton] = None) -> Labels: """ return jabs.read_labels(filename, skeleton=skeleton) + def save_jabs(labels: Labels, pose_version: int): """Save a SLEAP dataset to JABS pose file format. Filenames for JABS poses are based on video filenames. From 66f6d1031b94df9df781ceebf6231c7776c164ef Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Tue, 12 Sep 2023 11:17:53 -0400 Subject: [PATCH 11/19] conforming to linter --- sleap_io/io/jabs.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index b8e56c74..3681c496 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -1,6 +1,4 @@ -"""This module handles direct I/O operations for working with JABS files. - -""" +"""This module handles direct I/O operations for working with JABS files.""" import h5py import re @@ -71,6 +69,7 @@ def read_labels( labels_path: str, skeleton: Optional[Skeleton] = JABS_DEFAULT_SKELETON ) -> Labels: """Read JABS style pose from a file and return a `Labels` object. + TODO: Attributes are ignored, including px_to_cm field. TODO: Segmentation data ignored in v6, but will read in pose. TODO: Lixit static objects currently stored as n_lixit,2 (eg 1 object). Should be converted to multiple objects @@ -165,7 +164,7 @@ def read_labels( def make_simple_skeleton(name: str, num_points: int) -> Skeleton: - """Create a `Skeleton` with a requested number of nodes attached in a line + """Create a `Skeleton` with a requested number of nodes attached in a line. Args: name: name of the skeleton and prefix to nodes @@ -173,7 +172,6 @@ def make_simple_skeleton(name: str, num_points: int) -> Skeleton: Returns: Generated `Skeleton`. - """ nodes = [Node(name + "_kp" + str(i)) for i in range(num_points)] edges = [Edge(nodes[i], nodes[i + 1]) for i in range(num_points - 1)] @@ -216,10 +214,14 @@ def prediction_to_instance( def get_max_ids_in_video(labels: List[Labels], key: str = "Mouse") -> int: - """Determine the maximum number of identities that exist at the same time + """Determine the maximum number of identities that exist at the same time. Args: labels: SLEAP `Labels` to count + key: Name of the skeleton to select for identities + + Returns: + Count of the maximum concurrent identities in a single frame """ max_labels = 0 for label in labels: @@ -302,13 +304,13 @@ def convert_labels(all_labels: Labels, video: str) -> dict: def write_labels(labels: Labels, pose_version: int): """Convert and save a SLEAP `Labels` object to a JABS pose file. + Only supports pose version 2 (single mouse) and 3-5 (multi mouse). Args: labels: SLEAP `Labels` to be converted to JABS pose format. pose_version: JABS pose version to use when writing data. """ - for video in labels.videos: converted_labels = convert_labels(labels, video) out_filename = ( @@ -331,6 +333,7 @@ def write_labels(labels: Labels, pose_version: int): def write_jabs_v2(data: dict, filename: str): """Write JABS pose file v2 data to file. + Writes single mouse pose data. Args: @@ -358,6 +361,7 @@ def write_jabs_v2(data: dict, filename: str): def write_jabs_v3(data: dict, filename: str): """Write JABS pose file v3 data to file. + Writes multi-mouse pose data. TODO: v3 requires continuous tracklets (eg no gaps) IDs need to be incremented for this field @@ -407,6 +411,7 @@ def write_jabs_v3(data: dict, filename: str): def write_jabs_v4(data: dict, filename: str): """Write JABS pose file v4 data to file. + Writes multi-mouse pose and longterm identity object data. Args: @@ -457,6 +462,7 @@ def write_jabs_v4(data: dict, filename: str): def write_jabs_v5(data: dict, filename: str): """Write JABS pose file v5 data to file. + Writes multi-mouse pose, longterm identity, and static object data. Args: From 66c64ebcf8c1c23e19dcfc79bad1dac353071655 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Tue, 12 Sep 2023 13:25:26 -0400 Subject: [PATCH 12/19] pep8 linting --- sleap_io/io/jabs.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 3681c496..94735542 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -4,7 +4,7 @@ import re import os import numpy as np -from typing import Dict, Iterable, List, Tuple, Optional, Union +from typing import List, Optional, Union import warnings from sleap_io import ( @@ -92,7 +92,7 @@ def read_labels( num_frames = pose_file["poseest/points"].shape[0] try: pose_version = pose_file["poseest"].attrs["version"][0] - except: + except Exception: pose_version = 2 tracks[1] = Track("1") data_shape = pose_file["poseest/points"].shape @@ -261,8 +261,6 @@ def convert_labels(all_labels: Labels, video: str) -> dict: # Populate the matrices with data for label in labels: assigned_instances = 0 - tracks = [x.track for x in label.instances if x.track] - track_ids = [track_2_idx[track] for track in tracks] for instance_idx, instance in enumerate(label.instances): # Don't handle instances without skeletons if not instance.skeleton: @@ -462,7 +460,7 @@ def write_jabs_v4(data: dict, filename: str): def write_jabs_v5(data: dict, filename: str): """Write JABS pose file v5 data to file. - + Writes multi-mouse pose, longterm identity, and static object data. Args: From 946e5110e2558b845f36524f89bc934966d13107 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Wed, 13 Sep 2023 16:37:03 -0400 Subject: [PATCH 13/19] Tentative solution for jabs v3 complaince. Needs testing... --- sleap_io/io/jabs.py | 59 +++++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 94735542..bd6b4d24 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -247,8 +247,10 @@ def convert_labels(all_labels: Labels, video: str) -> dict: num_frames = [x.shape[0] for x in all_labels.videos if x == video][0] num_keypoints = [len(x.nodes) for x in all_labels.skeletons if x.name == "Mouse"][0] num_mice = get_max_ids_in_video(labels, key="Mouse") + # Note that this 1-indexes identities track_2_idx = { - key: val for key, val in zip(all_labels.tracks, range(len(all_labels.tracks))) + key: val + 1 + for key, val in zip(all_labels.tracks, range(len(all_labels.tracks))) } last_unassigned_id = num_mice @@ -329,6 +331,45 @@ def write_labels(labels: Labels, pose_version: int): raise NotImplementedError(f"Pose format {pose_version} not supported.") +def tracklets_to_v3(tracklet_matrix: np.ndarray) -> np.ndarray: + """Changes identity tracklets to the v3 format specifications. + + v3 specifications require: + (a) tracklets are 0-indexed + (b) tracklets appear in ascending order + (c) tracklets exist for continuous blocks of time + + Args: + tracklet_matrix: Numpy array of shape (frame, n_animals) that contains identity values. Identities are assumed to be 1-indexed. + + Returns: + A corrected numpy array of the same shape as input + """ + assert tracklet_matrix.ndim == 2 + + # Fragment the tracklets based on gaps + valid_ids = np.unique(tracklet_matrix) + valid_ids = valid_ids[valid_ids != 0] + track_fragments = {} + for cur_id in valid_ids: + frame_idx, column_idx = np.where(tracklet_matrix == cur_id) + gaps = np.nonzero(np.diff(frame_idx) - 1)[0] + for sliced_frame, sliced_column in zip( + np.split(frame_idx, gaps), np.split(column_idx, gaps) + ): + # The keys used here are (first frame, first column) such that sorting can be used for ascending order + track_fragments[sliced_frame[0], sliced_column[0]] = sliced_column + + return_mat = np.zeros_like(tracklet_matrix) + for next_id, key in enumerate(sorted(track_fragments.keys())): + columns_to_assign = track_fragments[key] + return_mat[ + range(key[0], key[0] + len(columns_to_assign)), columns_to_assign + ] = next_id + + return return_mat + + def write_jabs_v2(data: dict, filename: str): """Write JABS pose file v2 data to file. @@ -361,12 +402,12 @@ def write_jabs_v3(data: dict, filename: str): """Write JABS pose file v3 data to file. Writes multi-mouse pose data. - TODO: v3 requires continuous tracklets (eg no gaps) IDs need to be incremented for this field Args: data: Dictionary of JABS data generated from convert_labels filename: Filename to write data to """ + v3_tracklets = tracklets_to_v3(data["identity"]) with h5py.File(filename, "w") as h5: pose_grp = h5.require_group("poseest") pose_grp.attrs.update({"version": [3, 0]}) @@ -387,9 +428,9 @@ def write_jabs_v3(data: dict, filename: str): # id field pose_grp.require_dataset( "instance_track_id", - data["identity"].shape, - data["identity"].dtype, - data=data["identity"], + v3_tracklets.shape, + v3_tracklets.dtype, + data=v3_tracklets, ) # instance count field pose_grp.require_dataset( @@ -448,13 +489,11 @@ def write_jabs_v4(data: dict, filename: str): data=default_id_centers, ) # v4 uses an id field that is 1-indexed - identities_1_indexed = np.copy(data["identity"]) + 1 - identities_1_indexed[identity_mask_mat] = 0 pose_grp.require_dataset( "instance_embed_id", - identities_1_indexed.shape, - identities_1_indexed.dtype, - data=identities_1_indexed, + data["identity"].shape, + data["identity"].dtype, + data=data["identity"], ) From 79999e39a161b4147b3c71c6ffcd350860dfe378 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Thu, 14 Sep 2023 17:09:47 -0400 Subject: [PATCH 14/19] Starting to write some tests --- tests/conftest.py | 1 + tests/data/jabs/example_pose_est_v2.h5 | Bin 0 -> 14080 bytes tests/data/jabs/example_pose_est_v5.h5 | Bin 0 -> 222380 bytes tests/fixtures/jabs.py | 30 +++++++++ tests/io/test_jabs.py | 82 +++++++++++++++++++++++++ 5 files changed, 113 insertions(+) create mode 100644 tests/data/jabs/example_pose_est_v2.h5 create mode 100644 tests/data/jabs/example_pose_est_v5.h5 create mode 100644 tests/fixtures/jabs.py create mode 100644 tests/io/test_jabs.py diff --git a/tests/conftest.py b/tests/conftest.py index f0cdb631..a78a8793 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,3 +4,4 @@ from tests.fixtures.labels import * from tests.fixtures.labelstudio import * from tests.fixtures.videos import * +from tests.fixtures.jabs import * diff --git a/tests/data/jabs/example_pose_est_v2.h5 b/tests/data/jabs/example_pose_est_v2.h5 new file mode 100644 index 0000000000000000000000000000000000000000..bf40500c1862c4cdd8118d5dca6f8a478d544a4a GIT binary patch literal 14080 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

JQ>Z9S&Jvs1|!?q)4*^+NRM& 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

7Qk~V_9|Bdym&l!RTbX5h|wip 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 232 do not get assigned a longterm ID, but still contain pose data + + """ + return "tests/data/jabs/example_pose_est_v5.h5" diff --git a/tests/io/test_jabs.py b/tests/io/test_jabs.py new file mode 100644 index 00000000..d8af57ee --- /dev/null +++ b/tests/io/test_jabs.py @@ -0,0 +1,82 @@ +"""Tests for functions in the sleap_io.io.jabs file.""" +import numpy as np +import h5py + +from sleap_io import Labels, Instance +from sleap_io.io.jabs import ( + read_labels, + convert_labels, + tracklets_to_v3, + get_max_ids_in_video, + prediction_to_instance, + make_simple_skeleton, + JABS_DEFAULT_SKELETON, +) + + +def test_label_conversions(jabs_real_data_v5): + """Tests that `read_labels` and `convert_labels` can run on test data. + + Does not validate data was transformed correctly. + + """ + slp_labels = read_labels(jabs_real_data_v5) + assert isinstance(slp_labels, Labels) + + jabs_labels = convert_labels(slp_labels, slp_labels.videos[0]) + assert isinstance(jabs_labels, dict) + + +def test_tracklets_to_v3(jabs_real_data_v5): + """Test `tracklets_to_v3` meets v3 criteria. + + Args: + jabs_real_data_v5: A JABs v4+ file + """ + with h5py.File(jabs_real_data_v5, "r") as f: + original_tracklets = f["poseest/instance_embed_id"][...] + + adjusted_tracklets = tracklets_to_v3(original_tracklets) + + # Criteria 1: tracklets are 0-indexed + valid_ids = original_tracklets != 0 + id_values = np.unique(adjusted_tracklets[valid_ids]) + assert np.all(id_values == range(len(id_values))) + + last_id_first_frame = 0 + for current_identity in id_values: + frames_detected, _ = np.where(adjusted_tracklets == current_identity) + first_frame_detected = frames_detected[0] + # Criteria 2: tracklets appear in ascending order + assert last_id_first_frame <= first_frame_detected + last_id_first_frame = first_frame_detected + + # Criteria 3: tracklets are all continuous in time + assert len(frames_detected) == frames_detected[-1] - frames_detected[0] + + +def test_get_max_ids_in_video(jabs_real_data_v5): + """Test `get_max_ids_in_video`""" + with h5py.File(jabs_real_data_v5, "r") as f: + max_ids = max(f["poseest/instance_count"][:]) + + labels = read_labels(jabs_real_data_v5) + + found_max_ids = get_max_ids_in_video(labels.labeled_frames) + assert max_ids == found_max_ids + + +def test_prediction_to_instance(jabs_real_data_v5): + with h5py.File(jabs_real_data_v5, "r") as f: + pose = f["poseest/points"][0, 0, :, :] + confidence = f["poseest/confidence"][0, 0, :] + + label = prediction_to_instance(pose, confidence, JABS_DEFAULT_SKELETON) + assert type(label) == Instance + + +def test_make_simple_skeleton(): + skeleton = make_simple_skeleton("test", 2) + assert len(skeleton.nodes) == 2 + assert len(skeleton.edges) == 1 + assert skeleton.name == "test" From 6a4cb0403df7a21fc892d1c11833d8cb8bff6508 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Fri, 15 Sep 2023 09:42:25 -0400 Subject: [PATCH 15/19] Making tests actually correct and fixing a bug discovered with the a test! --- sleap_io/io/jabs.py | 10 +++++++--- tests/io/test_jabs.py | 8 +++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index bd6b4d24..663cb3e1 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -231,7 +231,7 @@ def get_max_ids_in_video(labels: List[Labels], key: str = "Mouse") -> int: return max_labels -def convert_labels(all_labels: Labels, video: str) -> dict: +def convert_labels(all_labels: Labels, video: Video) -> dict: """Convert a `Labels` object into JABS-formatted annotations. Args: @@ -244,7 +244,11 @@ def convert_labels(all_labels: Labels, video: str) -> dict: labels = all_labels.find(video=video) # Determine shape of output - num_frames = [x.shape[0] for x in all_labels.videos if x == video][0] + # Low estimate of last frame labeled + num_frames = max([x.frame_idx for x in labels]) + 1 + # If there is metadata available for the video, use that + if video.shape: + num_frames = max(num_frames, video.shape[0]) num_keypoints = [len(x.nodes) for x in all_labels.skeletons if x.name == "Mouse"][0] num_mice = get_max_ids_in_video(labels, key="Mouse") # Note that this 1-indexes identities @@ -355,7 +359,7 @@ def tracklets_to_v3(tracklet_matrix: np.ndarray) -> np.ndarray: frame_idx, column_idx = np.where(tracklet_matrix == cur_id) gaps = np.nonzero(np.diff(frame_idx) - 1)[0] for sliced_frame, sliced_column in zip( - np.split(frame_idx, gaps), np.split(column_idx, gaps) + np.split(frame_idx, gaps + 1), np.split(column_idx, gaps + 1) ): # The keys used here are (first frame, first column) such that sorting can be used for ascending order track_fragments[sliced_frame[0], sliced_column[0]] = sliced_column diff --git a/tests/io/test_jabs.py b/tests/io/test_jabs.py index d8af57ee..60e369ff 100644 --- a/tests/io/test_jabs.py +++ b/tests/io/test_jabs.py @@ -40,19 +40,21 @@ def test_tracklets_to_v3(jabs_real_data_v5): # Criteria 1: tracklets are 0-indexed valid_ids = original_tracklets != 0 - id_values = np.unique(adjusted_tracklets[valid_ids]) + masked_ids = np.ma.array(adjusted_tracklets, mask=~valid_ids) + id_values = np.unique(masked_ids.compressed()) assert np.all(id_values == range(len(id_values))) last_id_first_frame = 0 for current_identity in id_values: - frames_detected, _ = np.where(adjusted_tracklets == current_identity) + print(current_identity) + frames_detected, _ = np.where(masked_ids == current_identity) first_frame_detected = frames_detected[0] # Criteria 2: tracklets appear in ascending order assert last_id_first_frame <= first_frame_detected last_id_first_frame = first_frame_detected # Criteria 3: tracklets are all continuous in time - assert len(frames_detected) == frames_detected[-1] - frames_detected[0] + assert len(frames_detected) == (frames_detected[-1] - frames_detected[0] + 1) def test_get_max_ids_in_video(jabs_real_data_v5): From abeab8c9af8bd8b1cc9c137eebf6b69578b253b9 Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Fri, 15 Sep 2023 10:04:29 -0400 Subject: [PATCH 16/19] Adding tests for reading/writing jabs files from main --- sleap_io/io/jabs.py | 6 +++++- sleap_io/io/main.py | 5 +++-- tests/io/test_main.py | 14 ++++++++++++++ 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 663cb3e1..c7c3d41d 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -306,7 +306,7 @@ def convert_labels(all_labels: Labels, video: Video) -> dict: } -def write_labels(labels: Labels, pose_version: int): +def write_labels(labels: Labels, pose_version: int, root_folder: str): """Convert and save a SLEAP `Labels` object to a JABS pose file. Only supports pose version 2 (single mouse) and 3-5 (multi mouse). @@ -314,12 +314,16 @@ def write_labels(labels: Labels, pose_version: int): Args: labels: SLEAP `Labels` to be converted to JABS pose format. pose_version: JABS pose version to use when writing data. + root_folder: Root folder where the jabs files should be written """ for video in labels.videos: converted_labels = convert_labels(labels, video) out_filename = ( os.path.splitext(video.filename)[0] + f"_pose_est_v{pose_version}.h5" ) + if root_folder: + out_filename = os.path.join(root_folder, out_filename) + os.makedirs(os.path.dirname(out_filename), exist_ok=True) # Do we want to overwrite? if os.path.exists(out_filename): pass diff --git a/sleap_io/io/main.py b/sleap_io/io/main.py index f24a0b53..433457df 100644 --- a/sleap_io/io/main.py +++ b/sleap_io/io/main.py @@ -94,11 +94,12 @@ def load_jabs(filename: str, skeleton: Optional[Skeleton] = None) -> Labels: return jabs.read_labels(filename, skeleton=skeleton) -def save_jabs(labels: Labels, pose_version: int): +def save_jabs(labels: Labels, pose_version: int, root_folder: Optional[str] = None): """Save a SLEAP dataset to JABS pose file format. Filenames for JABS poses are based on video filenames. Args: labels: SLEAP `Labels` object pose_version: The JABS pose version to write data out + root_folder: Optional root folder where the files should be saved """ - jabs.write_labels(labels, pose_version) + jabs.write_labels(labels, pose_version, root_folder) diff --git a/tests/io/test_main.py b/tests/io/test_main.py index d03abec0..4ad14d95 100644 --- a/tests/io/test_main.py +++ b/tests/io/test_main.py @@ -6,6 +6,8 @@ save_nwb, load_labelstudio, save_labelstudio, + load_jabs, + save_jabs, ) @@ -36,3 +38,15 @@ def test_labelstudio(tmp_path, slp_typical): loaded_labels = load_labelstudio(tmp_path / "test_labelstudio.json") assert type(loaded_labels) == Labels assert len(loaded_labels) == len(labels) + + +def test_jabs(tmp_path, jabs_real_data_v2, jabs_real_data_v5): + labels_single = load_jabs(jabs_real_data_v2) + assert isinstance(labels_single, Labels) + save_jabs(labels_single, 2, tmp_path) + + labels_multi = load_jabs(jabs_real_data_v5) + assert isinstance(labels_multi, Labels) + save_jabs(labels_multi, 3, tmp_path) + save_jabs(labels_multi, 4, tmp_path) + save_jabs(labels_multi, 5, tmp_path) From 70042d395590e4bac06ea3b200887af08484ccac Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Mon, 18 Sep 2023 09:44:01 -0400 Subject: [PATCH 17/19] Enforcing 12 keypoints with a warning. Removing an unecessary check (all Instances have skeletons) --- sleap_io/io/jabs.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index c7c3d41d..0a8836e7 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -268,15 +268,17 @@ def convert_labels(all_labels: Labels, video: Video) -> dict: for label in labels: assigned_instances = 0 for instance_idx, instance in enumerate(label.instances): - # Don't handle instances without skeletons - if not instance.skeleton: - continue # Static objects just get added to the object dict # This will clobber data if more than one frame is annotated - elif instance.skeleton.name != "Mouse": + if instance.skeleton.name != "Mouse": static_objects[instance.skeleton.name] = instance.numpy() continue pose = instance.numpy() + if pose.shape[0] != len(JABS_DEFAULT_KEYPOINTS): + warnings.warn( + f"JABS format only supports 12 keypoints for mice. Skipping storage of instance on frame {label.frame_idx} with {len(instance.points)} keypoints." + ) + continue missing_points = np.isnan(pose[:, 0]) pose[np.isnan(pose)] = 0 # JABS stores y,x for poses From 31ec9e3e476d6bd381c4758e7970c94e6b88dcdd Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Wed, 20 Sep 2023 10:20:25 -0400 Subject: [PATCH 18/19] Being more explicit with some checks. One bugfix with reading v2 files. --- sleap_io/io/jabs.py | 16 +++++++++++----- tests/io/test_main.py | 7 +++++++ 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 0a8836e7..952b1a14 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -88,17 +88,23 @@ def read_labels( skeleton = JABS_DEFAULT_SKELETON tracks = {} + if not os.access(labels_path, os.F_OK): + raise PermissionError(f"{labels_path} cannot be accessed.") + if not os.access(labels_path, os.R_OK): + raise FileNotFoundError(f"{labels_path} doesn't exist.") + with h5py.File(labels_path, "r") as pose_file: num_frames = pose_file["poseest/points"].shape[0] try: pose_version = pose_file["poseest"].attrs["version"][0] - except Exception: + except (KeyError, IndexError): pose_version = 2 - tracks[1] = Track("1") data_shape = pose_file["poseest/points"].shape assert ( len(data_shape) == 3 ), f"Pose version not present and shape does not match single mouse: shape of {data_shape} for {labels_path}" + if pose_version == 2: + tracks[1] = Track("1") # Change field name for newer pose formats if pose_version == 3: id_key = "instance_track_id" @@ -200,7 +206,7 @@ def prediction_to_instance( points = {} for i, cur_node in enumerate(skeleton.nodes): # confidence of 0 indicates no keypoint predicted for instance - if confidence[i] > 0.001: + if confidence[i] > 0: points[cur_node] = Point( data[i, 0], data[i, 1], @@ -326,9 +332,9 @@ def write_labels(labels: Labels, pose_version: int, root_folder: str): if root_folder: out_filename = os.path.join(root_folder, out_filename) os.makedirs(os.path.dirname(out_filename), exist_ok=True) - # Do we want to overwrite? if os.path.exists(out_filename): - pass + warnings.warn(f"Skipping {out_filename} because it already exists.") + continue if pose_version == 2: write_jabs_v2(converted_labels, out_filename) elif pose_version == 3: diff --git a/tests/io/test_main.py b/tests/io/test_main.py index 4ad14d95..2cdf2963 100644 --- a/tests/io/test_main.py +++ b/tests/io/test_main.py @@ -44,9 +44,16 @@ def test_jabs(tmp_path, jabs_real_data_v2, jabs_real_data_v5): labels_single = load_jabs(jabs_real_data_v2) assert isinstance(labels_single, Labels) save_jabs(labels_single, 2, tmp_path) + labels_single_written = load_jabs(str(tmp_path / jabs_real_data_v2)) + # Confidence field is not preserved, so just check number of labels + assert len(labels_single) == len(labels_single_written) labels_multi = load_jabs(jabs_real_data_v5) assert isinstance(labels_multi, Labels) save_jabs(labels_multi, 3, tmp_path) save_jabs(labels_multi, 4, tmp_path) save_jabs(labels_multi, 5, tmp_path) + labels_v5_written = load_jabs(str(tmp_path / jabs_real_data_v5)) + # v5 contains all v4 and v3 data, so only need to check v5 + # Confidence field and ordering of identities is not preserved, so just check number of labels + assert len(labels_v5_written) == len(labels_multi) From 3fea3bd0acb11538855839b599ae4ebbb2c2ddaa Mon Sep 17 00:00:00 2001 From: Brian Geuther Date: Wed, 20 Sep 2023 10:33:56 -0400 Subject: [PATCH 19/19] Bot caught this one - checks were in correct order, but raised errors were not. --- sleap_io/io/jabs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/sleap_io/io/jabs.py b/sleap_io/io/jabs.py index 952b1a14..12515ef5 100644 --- a/sleap_io/io/jabs.py +++ b/sleap_io/io/jabs.py @@ -89,9 +89,9 @@ def read_labels( tracks = {} if not os.access(labels_path, os.F_OK): - raise PermissionError(f"{labels_path} cannot be accessed.") - if not os.access(labels_path, os.R_OK): raise FileNotFoundError(f"{labels_path} doesn't exist.") + if not os.access(labels_path, os.R_OK): + raise PermissionError(f"{labels_path} cannot be accessed.") with h5py.File(labels_path, "r") as pose_file: num_frames = pose_file["poseest/points"].shape[0]