Skip to content
Snippets Groups Projects
Commit 95362f07 authored by Andi Gerken's avatar Andi Gerken
Browse files

Merge branch 'master' into 'develop'

# Conflicts:
#   .gitignore
parents 705cc264 f8ef18cd
Branches
Tags
1 merge request!9Fixed calendar points to save time in microseconds according to track format
Pipeline #37383 passed
...@@ -9,7 +9,6 @@ dist ...@@ -9,7 +9,6 @@ dist
report.xml report.xml
htmlcov htmlcov
html html
docs
env env
!tests/resources/*.hdf5 !tests/resources/*.hdf5
......
...@@ -9,59 +9,60 @@ ...@@ -9,59 +9,60 @@
[![pipeline status](https://git.imp.fu-berlin.de/bioroboticslab/robofish/io/badges/master/pipeline.svg)](https://git.imp.fu-berlin.de/bioroboticslab/robofish/io/commits/master) [![pipeline status](https://git.imp.fu-berlin.de/bioroboticslab/robofish/io/badges/master/pipeline.svg)](https://git.imp.fu-berlin.de/bioroboticslab/robofish/io/commits/master)
# Robofish IO # Robofish IO
This repository implements an easy to use interface, to create, save, load, and work [specification-compliant](https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format) hdf5 files, containing 2D swarm data. This repository should be used by the different swarm projects to generate comparable standardized files. This repository implements an easy to use interface, to create, save, load, and work with [specification-compliant](https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format) hdf5 files, containing 2D swarm data. This repository should be used by the different swarm projects to generate comparable standardized files.
### <a href="http://agerken.de/io/index.html">Documentation</a>
## Installation ## Installation
Quick variant: Add our [Artifacts repository](https://git.imp.fu-berlin.de/bioroboticslab/robofish/artifacts) to your pip config and install the packagage.
```
pip3 install robofish-trackviewer robofish-io --extra-index-url https://git.imp.fu-berlin.de/api/v4/projects/6392/packages/pypi/simple
```
Better variant:
- Follow instructions at [Artifacts repository](https://git.imp.fu-berlin.de/bioroboticslab/robofish/artifacts)
- ```pip3 install robofish-io```
```bash
python3 -m pip config set global.extra-index-url https://git.imp.fu-berlin.de/api/v4/projects/6392/packages/pypi/simple
python3 -m pip install robofish-io
```
## Usage ## Usage
We show a simple example below. More examples can be found in ```examples/``` We show a simple example below. More examples can be found in ```examples/```
```python ```python
import robofish.io
import numpy as np
# Create a new robofish io file # Create a new robofish io file
f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)
f.attrs["experiment_setup"] = "This is a simple example with made up data." f.attrs["experiment_setup"] = "This is a simple example with made up data."
# Create a new robot entity. Positions and orientations are passed # Create a new robot entity with 10 timesteps.
# separately in this example. Since the orientations have two columns, # Positions and orientations are passed separately in this example.
# unit vectors are assumed (orientation_x, orientation_y) # Since the orientations have two columns, unit vectors are assumed
# (orientation_x, orientation_y)
f.create_entity( f.create_entity(
category="robot", category="robot",
name="robot", name="robot",
positions=np.zeros((100, 2)), positions=np.zeros((10, 2)),
orientations=np.ones((100, 2)) * [0, 1], orientations=np.ones((10, 2)) * [0, 1],
) )
# Create a new fish entity. # Create a new fish entity with 10 timesteps.
# In this case, we pass positions and orientations together (x, y, rad). # In this case, we pass positions and orientations together (x, y, rad).
# Since it is a 3 column array, orientations in radiants are assumed. # Since it is a 3 column array, orientations in radiants are assumed.
poses = np.zeros((100, 3)) poses = np.zeros((10, 3))
poses[:, 0] = np.arange(-50, 50) poses[:, 0] = np.arange(-5, 5)
poses[:, 1] = np.arange(-50, 50) poses[:, 1] = np.arange(-5, 5)
poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 100) poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 10)
fish = f.create_entity("fish", poses=poses) fish = f.create_entity("fish", poses=poses)
fish.attrs["species"] = "My rotating spaghetti fish" fish.attrs["species"] = "My rotating spaghetti fish"
fish.attrs["fish_standard_length_cm"] = 10 fish.attrs["fish_standard_length_cm"] = 10
# Show and save the file # Some possibilities to access the data
print(f) print(f"The file:\n{f}")
print("Poses Shape: ", f.entity_poses.shape) print(
f"Poses Shape:\t{f.entity_poses_rad.shape}.\t"
+ "Representing(entities, timesteps, pose dimensions (x, y, ori)"
)
print(f"The actions of one Fish, (timesteps, (speed, turn)):\n{fish.speed_turn}")
print(f"Fish poses with calculated orientations:\n{fish.poses_calc_ori_rad}")
f.save_as(path) # Save the file
f.save_as("example.hdf5")
``` ```
### Evaluation ### Evaluation
...@@ -72,13 +73,3 @@ Current modes are: ...@@ -72,13 +73,3 @@ Current modes are:
- tank_positions - tank_positions
- trajectories - trajectories
- follow_iid - follow_iid
\ No newline at end of file
## LICENSE
This work is licensed under LGPL 3.0 (or any later version).
Individual files contain the following tag instead of the full license text:
`SPDX-License-Identifier: LGPL-3.0-or-later`
This enables machine processing of license information based on the SPDX License Identifiers available here: https://spdx.org/licenses/
This module defines the command line interface.
All commands have a ``--help`` option to get more info about them in the command line.
## Print
```bash
robofish-io-print example.hdf5
```
Checking out the content of files.
<small>
```
usage: robofish-io-print [-h] [--output_format {shape,full}] path
This function can be used to print hdf5 files from the command line
positional arguments:
path The path to a hdf5 file
optional arguments:
-h, --help show this help message and exit
--output_format {shape,full}
Choose how datasets are printed, either the shapes or the full content is printed
```
</small>
## Evaluate
```bash
robofish-io-evaluate *analysis_type* example.hdf5
```
Show some property of a file.
<small>
```
usage: robofish-io-evaluate [-h] [--names NAMES [NAMES ...]] [--save_path SAVE_PATH]
{speed,turn,orientation,relative_orientation,distance_to_wall,tank_positions,trajectories,evaluate_positionVec,follow_iid} paths [paths ...]
This function can be called from the commandline to evaluate files.
Different evaluation methods can be called, which generate graphs from the given files.
With the first argument 'analysis_type', the type of analysis is chosen.
positional arguments:
{speed,turn,orientation,relative_orientation,distance_to_wall,tank_positions,trajectories,evaluate_positionVec,follow_iid}
The type of analysis.
speed - Evaluate the speed of the entities as histogram.
turn - Evaluate the turn angles of the entities as histogram.
orientation - Evaluate the orientations of the entities on a 2d grid.
relative_orientation - Evaluate the relative orientations of the entities as a histogram.
distance_to_wall - Evaluate the distances of the entities to the walls as a histogram.
tank_positions - Evaluate the positions of the entities as a heatmap.
trajectories - Evaluate the trajectories of the entities.
evaluate_positionVec - Evaluate the vectors pointing from the focal fish to the conspecifics as heatmap.
follow_iid - Evaluate the follow metric in respect to the inter individual distance (iid).
paths The paths to files or folders. Multiple paths can be given to compare experiments.
optional arguments:
-h, --help show this help message and exit
--names NAMES [NAMES ...]
Names, that should be used in the graphs instead of the pahts.
--save_path SAVE_PATH
Filename for saving resulting graphics.
```
</small>
## Trackviewer
```bash
robofish-trackviewer example.hdf5
```
The trackviewer is from a different repository. It was included in the install instructions.
<small>
```
usage: robofish-trackviewer [-h] [--draw-labels] [--draw-view-vectors] [--far-plane FAR_PLANE] [--view-of-agents field of perception number of bins] [--view-of-walls field of perception number of bins]
[--view-of-walls-matches]
[trackset_file]
View RoboFish tracks in a GUI.
positional arguments:
trackset_file Path to HDF5 file containing the tracks to view
optional arguments:
-h, --help show this help message and exit
--draw-labels Whether to draw labels inside the agents' outlines
--draw-view-vectors Whether to draw view vectors to the right of / below the trackfile
--far-plane FAR_PLANE
Maximum distance an agent can see
--view-of-agents field of perception number of bins
--view-of-walls field of perception number of bins
--view-of-walls-matches
```
</small>
\ No newline at end of file
Tracks of entities are stored in `robofish.io.entity.Entity` objects. They are created by the `robofish.io.file.File` object. They require a category and can have a name. If no name is given, it will be created, using the category and an id.
```python
f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)
nemo = f.create_entity(category="fish", name="nemo")
```
The function `create_entity()` returns an entity object. It can be stored but it's not neccessary. The entity is automatically stored in the file.
## Pose options
The poses of an entity can be passed in multiple ways. Poses are divided into `positions` (x, y) and `orientations`.
Orientations are internally represented in unit vectors but can also be passed in rads.
The shape of the passed orientation array defines the meaning.
```python
# Create dummy poses for 100 timesteps
pos = np.zeros((100, 2))
ori_vec = np.zeros((100,2)) * [0, 1]
ori_rad = np.zeros((100,1))
# Creating an entity without orientations. Here we keep the entity object, to use it later.
f.create_entity(category="fish", positions=pos)
# Creating an entity using orientation vectors. Keeping the entity object is not neccessary, it is saved in the file.
f.create_entity(category="fish", positions=pos, orientations=ori_vec)
# Creating an entity using radiant orientations.
f.create_entity(category="fish", positions=pos, orientations=ori_rad)
```
The poses can be also passed in an combined array.
```python
# Create an entity using orientation vectors.
f.create_entity(category="fish", poses=np.ones((100,4)) * np.sqrt(2))
# Create an entity using radiant orientations
f.create_entity(category="fish", poses=np.zeros((100,3)))
```
## Creating multiple entities at once
Multiple entities can be created at once.
```python
# Here we create 4 fishes from poses with radiant orientation
f.create_multiple_entities(category="fish", poses=np.zeros((4,100,3)))
```
## Attributes
Entities can have attributes to describe them.
The attributes can be set like this:
```python
nemo.attrs["species"] = "Clownfish"
nemo.attrs["fish_standard_length_cm"] = 10
```
Any attribute is allowed, but some cannonical attributes are prepared:<br>
`species`:`str`, `sex`:`str`, `fish_standard_length_cm`:`float`
## Properties
As described in `robofish.io`, Files and Entities have useful properties.
<small>
| Entity function | Description |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------- |
| `robofish.io.entity.Entity.positions` | Positions as a `(timesteps, 2 (x, y))` arary. |
| `robofish.io.entity.Entity.orientations` | Orientations as a `(timesteps, 2 (ori_x, ori_y))` arary. |
| `robofish.io.entity.Entity.orientations_rad` | Orientations as a `(timesteps, 1 (ori_rad))` arary. |
| `robofish.io.entity.Entity.poses` | Poses as a `(timesteps, 4 (x, y, x_ori, y_ori))` array. |
| `robofish.io.entity.Entity.poses_rad` | Poses as a `(timesteps, 3(x, y, ori_rad))` array. |
| `robofish.io.entity.Entity.poses_calc_ori_rad` | Poses with calculated orientations as a<br>`(timesteps - 1, 3 (x, y, calc_ori_rad))` array. |
| `robofish.io.entity.Entity.speed_turn` | Speed and turn as a `(timesteps - 2, 2 (speed_cm/s, turn_rad/s))` array. |
</small>
### Calculated orientations and speeds.
![Calculated orientation, speeds and turns](data:image/png;base64,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)
The image shows, how the orientations are calculated (`robofish.io.entity.Entity.poses_calc_ori_rad`), the orientations always show away from the last position. In this way, the first position does not have an orientation and the shape of the resulting array is `(timesteps - 1, 3 (x, y, calc_ori_rad))`.
The meaning of the `robofish.io.entity.Entity.speed_turn` can also be explained with the image. The turn is the angle, the agent has to turn, to orientate towards the next position. The speed is the calculated, using the distance between two positions. The resulting turn and speed is converted to `[rad/s]` and `[cm/s]`. The first and last position don't have any action which results in an array shape of `(timesteps - 2, 2 (speed, turn))`.
---
⚠️ Try this out by extending the example of the main doc, so that a new teleporting fish with random positions [-50, 50] is generated. How does that change the output and speed histogram `robofish-io-evaluate speed example.hdf5`? Try writing some attributes.
`robofish.io.file.File` objects are the root of the project. The object contains all information about the environment, entities, and time.
In the simplest form we define a new File with a world size in cm and a frequency in hz. Afterwards, we can save it with a path.
```python
import robofish.io
# Create a new robofish io file
f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)
f.save_as("test.hdf5")
```
The File object can also be generated, with a given path. In this case, we work on the file directly. The `with` block ensures, that the file is validated after the block.
```python
with robofish.io.File(
"test.hdf5", mode="x", world_size_cm=[100, 100], frequency_hz=25.0
) as f:
# Use file f here
```
When opening a file with a path, a mode should be specified to describe how the file should be opened.
| Mode | Description |
|------ |---------------------------------------- |
| r | Readonly, file must exist (default) |
| r+ | Read/write, file must exist |
| w | Create file, truncate if exists |
| x | Create file, fail if exists |
| a | Read/write if exists, create otherwise |
## Attributes
Attributes of the file can be added, to describe the contents.
The attributes can be set like this:
```python
f.attrs["experiment_setup"] = "This file comes from the tutorial."
f.attrs["experiment_issues"] = "All data in this file is made up."
```
Any attribute is allowed, but some cannonical attributes are prepared:<br>
`publication_url, video_url, tracking_software_name, tracking_software_version, tracking_software_url, experiment_setup, experiment_issues`
## Properties
As described in `robofish.io`, all properties of `robofish.io.entity`s can be accessed by adding the prefix `entity_` to the function.
\ No newline at end of file
docs/img/calc_ori_speed_turn.png

22.8 KiB

This package provides you:
- Creation, storing, loading, modifying, inspecting of io-files.
- Preprocessing (orientation calculation, action calculation, raycasting, ...)
- Quick and easy evaluation of behavior
- Data, which is interchangable between labs and tools. No conversions required, since units and coordinate systems are standardized.
- No custom data import, just `include robofish.io`
- This package is tested extensively
Features coming up:
- Interface for unified behavior models
- Pytorch Datasets directly from `robofish.io` files.
<a href="https://git.imp.fu-berlin.de/bioroboticslab/robofish/io" class="myButton">Code</a>
## Installation
Add our [Artifacts repository](https://git.imp.fu-berlin.de/bioroboticslab/robofish/artifacts) to your pip config and install the packagage.
```bash
python3 -m pip config set global.extra-index-url https://git.imp.fu-berlin.de/api/v4/projects/6392/packages/pypi/simple
python3 -m pip install robofish-io robofish-trackviewer
```
## Usage
This documentation is structured with increasing complexity.
First we'll execute the example from the readme. More examples can be found in ```examples/```.
```python
# Create a new robofish io file
f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)
f.attrs["experiment_setup"] = "This is a simple example with made up data."
# Create a new robot entity with 10 timesteps.
# Positions and orientations are passed separately in this example.
# Since the orientations have two columns, unit vectors are assumed
# (orientation_x, orientation_y)
f.create_entity(
category="robot",
name="robot",
positions=np.zeros((10, 2)),
orientations=np.ones((10, 2)) * [0, 1],
)
# Create a new fish entity with 10 timesteps.
# In this case, we pass positions and orientations together (x, y, rad).
# Since it is a 3 column array, orientations in radiants are assumed.
poses = np.zeros((10, 3))
poses[:, 0] = np.arange(-5, 5)
poses[:, 1] = np.arange(-5, 5)
poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 10)
fish = f.create_entity("fish", poses=poses)
fish.attrs["species"] = "My rotating spaghetti fish"
fish.attrs["fish_standard_length_cm"] = 10
# Some possibilities to access the data
print(f"The file:\n{f}")
print(
f"Poses Shape:\t{f.entity_poses_rad.shape}.\t"
+ "Representing(entities, timesteps, pose dimensions (x, y, ori)"
)
print(f"The actions of one Fish, (timesteps, (speed, turn)):\n{fish.speed_turn}")
print(f"Fish poses with calculated orientations:\n{fish.poses_calc_ori_rad}")
# Save the file
f.save_as("example.hdf5")
```
⚠️ Please try out the example on your computer and read the output.
We created an `robofish.io.file.File` object, then we added two `robofish.io.entity.Entity` objects.
Afterwards we read some properties of the file and printed them *(more info in [Reading Properties](#reading-properties))*.
Lastly, we saved the file to `example.hdf5`.
Congrats, you created your first io file. We'll continue working with it.
---
We can examine the file now by using commandline tools. These are some examples, more details in [Commandline Tools](## Commandline Tools)
```bash
robofish-io-print example.hdf5
```
Checking out the file content.
```bash
robofish-io-evaluate speed example.hdf5
```
Show a histogram of speeds in the file. For more evaluation options check `robofish-io-evaluate --help`
```bash
robofish-trackviewer example.hdf5
```
View a video of the track in an interactive window.
Further details about the commandline tools can be found in `robofish.io.app`.
## Accessing real data
Until now, we only worked with dummy data. Data from different sources is available in the Trackdb. It is currently stored at the FU Box.
⚠️ If you don't have access to the Trackdb yet, please text Andi by Mail or Mattermost (andi.gerken@gmail.com)
## Reading properties
Files and entities have usefull properties to access their content. In this way, positions, orientations, speeds, and turns can be accessed easily.
All shown property functions can be called from a file or on one entity.
The function names are identical but have a `entity_` prefix.
```python
f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)
nemo = f.create_entity(category='fish', name='nemo', poses=np.zeros((10,3)))
dori = f.create_entity(category='fish', name='dori', poses=np.zeros((10,3)))
# Get the poses of nemo. Resulting in a (10,3) array
print(nemo.poses_rad)
# Get the poses of all entities. Resulting in a (2,10,3) array.
print(f.entity_poses_rad)
```
In the same scheme the following properties are available:
<small>
| File/ Entity function | Description |
| --------------------------- | ------------------------------------------------------------------------------------------------------- |
| *entity_*positions | Positions as a `(*entities*, timesteps, 2 (x, y))` arary. |
| *entity_*orientations | Orientations as a `(*entities*, timesteps, 2 (ori_x, ori_y))` arary. |
| *entity_*orientations_rad | Orientations as a `(*entities*, timesteps, 1 (ori_rad))` arary. |
| *entity_*poses | Poses as a `(*entities*, timesteps, 4 (x, y, x_ori, y_ori))` array. |
| *entity_*poses_rad | Poses as a `(*entities*, timesteps, 3(x, y, ori_rad))` array. |
| *entity_*poses_calc_ori_rad | Poses with calculated orientations as a<br>`(*entities*, timesteps - 1, 3 (x, y, calc_ori_rad))` array. |
| *entity_*speed_turn | Speed and turn as a `(*entities*, timesteps - 2, 2 (speed_cm/s, turn_rad/s))` array. |
</small>
All these functions are described in more detail in `robofish.io.entity`.
## Where to continue?
We recommend continuing to read advanced options for `robofish.io.file`s and `robofish.io.entity`s.
Create some files, validate them, look at them in the trackviewer, evaluate them.
If you find bugs or get stuck somewhere, please text `Andi` on Mattermost or by mail ([andi.gerken@gmail.com](mailto:andi.gerken@gmail.com))
---
## Extended functions
In the [Track Format Specification](https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format/uploads/f76d86e7a629ca38f472b8f23234dbb4/RoboFish_Track_Format_-_1.0.pdf) and in this package there are more possibilities, we don't describe explicitly here. If you want to use any of the following features, please ask Andi:
Built in robofish.io and not described:
- Multiple sampling frequencies
- Timestamps per timestep
- Calendar points
- Outlines
- Obstacles
Planned in Track Format but not implemented:
- 3D Tracks
<style>
.myButton {
background:#3971cc;
background-color:#3971cc;
border-radius:9px;
display:inline-block;
cursor:pointer;
color:#ffffff;
font-family:Arial;
font-size:20px;
padding:8px 16px;
text-decoration:none;
}
.myButton:hover {
background:#396bd1;
background-color:#396bd1;
color: #e0e0e0;
text-decoration: none;
}
.myButton:active {
position:relative;
top:1px;
}
</style>
...@@ -7,32 +7,38 @@ def create_example_file(path): ...@@ -7,32 +7,38 @@ def create_example_file(path):
f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0) f = robofish.io.File(world_size_cm=[100, 100], frequency_hz=25.0)
f.attrs["experiment_setup"] = "This is a simple example with made up data." f.attrs["experiment_setup"] = "This is a simple example with made up data."
# Create a new robot entity. Positions and orientations are passed # Create a new robot entity with 10 timesteps.
# separately in this example. Since the orientations have two columns, # Positions and orientations are passed separately in this example.
# unit vectors are assumed (orientation_x, orientation_y) # Since the orientations have two columns, unit vectors are assumed
circle_rad = np.linspace(0, 2 * np.pi, num=100) # (orientation_x, orientation_y)
f.create_entity( f.create_entity(
category="robot", category="robot",
name="robot", name="robot",
positions=np.stack((np.cos(circle_rad), np.sin(circle_rad))).T * 40, positions=np.zeros((10, 2)),
orientations=np.stack((-np.sin(circle_rad), np.cos(circle_rad))).T, orientations=np.ones((10, 2)) * [0, 1],
) )
# Create a new fish entity. # Create a new fish entity with 10 timesteps.
# In this case, we pass positions and orientations together (x, y, rad). # In this case, we pass positions and orientations together (x, y, rad).
# Since it is a 3 column array, orientations in radiants are assumed. # Since it is a 3 column array, orientations in radiants are assumed.
poses = np.zeros((100, 3)) poses = np.zeros((10, 3))
poses[:, 0] = np.arange(-50, 50) poses[:, 0] = np.arange(-5, 5)
poses[:, 1] = np.arange(-50, 50) poses[:, 1] = np.arange(-5, 5)
poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 100) poses[:, 2] = np.arange(0, 2 * np.pi, step=2 * np.pi / 10)
fish = f.create_entity("fish", poses=poses) fish = f.create_entity("fish", poses=poses)
fish.attrs["species"] = "My rotating spaghetti fish" fish.attrs["species"] = "My rotating spaghetti fish"
fish.attrs["fish_standard_length_cm"] = 10 fish.attrs["fish_standard_length_cm"] = 10
# Show and save the file # Some possibilities to access the data
print(f) print(f"The file:\n{f}")
print("Poses Shape: ", f.entity_poses.shape) print(
f"Poses Shape:\t{f.entity_poses_rad.shape}.\t"
+ "Representing(entities, timesteps, pose dimensions (x, y, ori)"
)
print(f"The actions of one Fish, (timesteps, (speed, turn)):\n{fish.speed_turn}")
print(f"Fish poses with calculated orientations:\n{fish.poses_calc_ori_rad}")
# Save the file
f.save_as(path) f.save_as(path)
......
...@@ -48,7 +48,7 @@ setup( ...@@ -48,7 +48,7 @@ setup(
version=source_version(), version=source_version(),
author="", author="",
author_email="", author_email="",
install_requires=["h5py>=2.10.0", "numpy", "seaborn", "pandas", "deprecation"], install_requires=["h5py>=3.2.1", "numpy", "seaborn", "pandas", "deprecation"],
classifiers=[ classifiers=[
"Development Status :: 3 - Alpha", "Development Status :: 3 - Alpha",
"Intended Audience :: Science/Research", "Intended Audience :: Science/Research",
......
...@@ -11,6 +11,7 @@ Functions available to be used in the commandline to evaluate robofish.io files. ...@@ -11,6 +11,7 @@ Functions available to be used in the commandline to evaluate robofish.io files.
import robofish.evaluate import robofish.evaluate
import argparse import argparse
import string
def function_dict(): def function_dict():
...@@ -41,24 +42,26 @@ def evaluate(args=None): ...@@ -41,24 +42,26 @@ def evaluate(args=None):
fdict = function_dict() fdict = function_dict()
longest_name = max([len(k) for k in fdict.keys()])
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description="This function can be called from the commandline to evaluate files.\ description="This function can be called from the commandline to evaluate files.\n"
Different evaluation methods can be called, which generate graphs from the given files. \ + "Different evaluation methods can be called, which generate graphs from the given files.\n"
\ + "With the first argument 'analysis_type', the type of analysis is chosen.",
With the first argument 'analysis_type', the type of analysis is chosen." formatter_class=argparse.RawTextHelpFormatter,
) )
parser.add_argument( parser.add_argument(
"analysis_type", "analysis_type",
type=str, type=str,
choices=fdict.keys(), choices=fdict.keys(),
help="The type of analysis.\ help="The type of analysis.\n"
speed - A histogram of speeds\ + "\n".join(
turn - A histogram of angular velocities\ [
tank_positions - A heatmap of the positions in the tank\ f"{key}{' ' * (longest_name - len(key))} - {func.__doc__.splitlines()[0]}"
trajectories - A plot of all the trajectories\ for key, func in fdict.items()
follow_iid - A plot of the follow metric in relation to iid (inter individual distance)\ ]
", ),
) )
parser.add_argument( parser.add_argument(
"paths", "paths",
......
...@@ -123,6 +123,7 @@ def evaluate_turn( ...@@ -123,6 +123,7 @@ def evaluate_turn(
for k, files in enumerate(files_per_path): for k, files in enumerate(files_per_path):
path_turns = [] path_turns = []
for p, file in files.items(): for p, file in files.items():
# TODO: Use new io functions (entity_poses_calc_ori_rad)
poses = file.select_entity_poses( poses = file.select_entity_poses(
None if predicate is None else predicate[k] None if predicate is None else predicate[k]
) )
......
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
.. include:: ../../../docs/app.md
"""
# ----------------------------------------------------------- # -----------------------------------------------------------
# Functions available to be used in the commandline to show and validate hdf5
# according to the Robofish track format (1.0 Draft 7). The standard is
# available at https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format
#
# Dec 2020 Andreas Gerken, Berlin, Germany # Dec 2020 Andreas Gerken, Berlin, Germany
# Released under GNU 3.0 License # Released under GNU 3.0 License
# email andi.gerken@gmail.com # email andi.gerken@gmail.com
......
"""
.. include:: ../../../docs/entity.md
"""
import robofish.io import robofish.io
import robofish.io.utils as utils import robofish.io.utils as utils
...@@ -137,21 +141,42 @@ class Entity(h5py.Group): ...@@ -137,21 +141,42 @@ class Entity(h5py.Group):
return np.tile([1, 0], (self.positions.shape[0], 1)) return np.tile([1, 0], (self.positions.shape[0], 1))
return self["orientations"] return self["orientations"]
@property
def orientations_rad(self):
ori_rad = utils.limit_angle_range(
np.arctan2(self.orientations[:, 1], self.orientations[:, 0]),
_range=(0, 2 * np.pi),
)
return ori_rad[:, np.newaxis]
@property
def poses_calc_ori_rad(self):
# Diff between positions [t - 1, 2]
diff = np.diff(self.positions, axis=0)
# angles [t - 1]
angles = utils.limit_angle_range(
np.arctan2(diff[:, 1], diff[:, 0]), _range=(0, 2 * np.pi)
)
# Positions with angles. The first position is cut of, as it does not have an orientation.
poses_with_calculated_orientation = np.concatenate(
[self.positions[1:], angles[:, np.newaxis]], axis=1
)
return poses_with_calculated_orientation
@property @property
def poses(self): def poses(self):
return np.concatenate([self.positions, self.orientations], axis=1) return np.concatenate([self.positions, self.orientations], axis=1)
@property @property
def poses_rad(self): def poses_rad(self):
poses = self.poses return np.concatenate([self.positions, self.orientations_rad], axis=1)
# calculate the angles from the orientation vectors, write them to the third row and delete the fourth row
poses[:, 2] = np.arctan2(poses[:, 3], poses[:, 2])
poses = poses[:, :3]
return poses
@property @property
def speed_turn_angle(self): def speed_turn(self):
"""Get the speed, turn and angles from the positions. """Get the speed, turn and from the positions.
The vectors pointing from each position to the next are computed. The vectors pointing from each position to the next are computed.
The output of the function describe these vectors. The output of the function describe these vectors.
...@@ -160,13 +185,14 @@ class Entity(h5py.Group): ...@@ -160,13 +185,14 @@ class Entity(h5py.Group):
The first column is the length of the vectors. The first column is the length of the vectors.
The second column is the turning angle, required to get from one vector to the next. The second column is the turning angle, required to get from one vector to the next.
We assume, that the entity is oriented "correctly" in the first pose. So the first turn angle is 0. We assume, that the entity is oriented "correctly" in the first pose. So the first turn angle is 0.
The third column is the orientation of each vector.
""" """
diff = np.diff(self.positions, axis=0) # poses with calulated orientation have first position cut of as it does not have an orientation
speed = np.linalg.norm(diff, axis=1) # (t - 1, (x ,y, ori))
angles = np.arctan2(diff[:, 1], diff[:, 0]) poses_calc_ori = self.poses_calc_ori_rad
turn = np.zeros_like(angles)
turn[0] = 0 # Differences cuts of last item (t - 2, (dx, dy, d ori))
turn[1:] = utils.limit_angle_range(np.diff(angles)) diff = np.diff(poses_calc_ori, axis=0)
return np.stack([speed, turn, angles], axis=-1) speed = np.linalg.norm(diff[:, :2], axis=1)
\ No newline at end of file turn = utils.limit_angle_range(diff[:, 2], _range=(-np.pi, np.pi))
return np.stack([speed, turn], axis=-1)
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
.. include:: ../../../docs/file.md
"""
# ----------------------------------------------------------- # -----------------------------------------------------------
# Utils functions for reading, validating and writing hdf5 files according to # Utils functions for reading, validating and writing hdf5 files according to
# Robofish track format (1.0 Draft 7). The standard is available at # Robofish track format (1.0 Draft 7). The standard is available at
# https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format # https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format
#
# The term track is used to describe a dictionary, describing the track in a dict.
# To distinguish between attributes, dictionaries and groups, a prefix is used
# (a_ for attribute, d_ for dictionary, and g_ for groups).
# #
# Dec 2020 Andreas Gerken, Berlin, Germany # Dec 2020 Andreas Gerken, Berlin, Germany
# Released under GNU 3.0 License # Released under GNU 3.0 License
...@@ -30,6 +31,7 @@ import uuid ...@@ -30,6 +31,7 @@ import uuid
import deprecation import deprecation
import types import types
# Remember: Update docstring when updating these two global variables
default_format_version = np.array([1, 0], dtype=np.int32) default_format_version = np.array([1, 0], dtype=np.int32)
default_format_url = ( default_format_url = (
...@@ -61,26 +63,64 @@ class File(h5py.File): ...@@ -61,26 +63,64 @@ class File(h5py.File):
): ):
"""Create a new RoboFish Track Format object. """Create a new RoboFish Track Format object.
When called with a path, it is loaded, otherwise a new temporary file is created. When called with a path, it is loaded, otherwise a new temporary
file is created. File contents can be validated against the
track format specification.
Parameters Parameters
---------- ----------
path : str or Path, optional path : str or Path, optional
Location of file to be opened. If not provided, mode is ignored. Location of file to be opened. If not provided, mode is ignored.
mode : str, default='r'
mode : str 'r' Readonly, file must exist
r Readonly, file must exist (default) 'r+' Read/write, file must exist
r+ Read/write, file must exist 'w' Create file, truncate if exists
w Create file, truncate if exists 'x' Create file, fail if exists
x Create file, fail if exists 'a' Read/write if exists, create otherwise
a Read/write if exists, create otherwise world_size_cm : [int, int] , optional
side lengths [x, y] of the world in cm.
world_size_cm rectangular world shape is assumed.
optional integer array of the world size in cm TODO: Cuboid world is also possible in track format
strict_validate strict_validate : bool, default=False
optional boolean, if the file should be strictly validated, when loaded from a path. The default is False. if the file should be strictly validated against the track
format_version format specification, when loaded from a path.
optional version [major, minor] of the trackformat specification TODO: Should this validate against the version sepcified in
format_version or just against the most recent version?
format_version : [int, int], default=[1,0]
version [major, minor] of track format specification
format_url : str, default="https://git.imp.fu-berlin.de/bioroboticslab/robofish/track_format/-/releases/1.0"
location of track format specification.
should fit `format_version`.
sampling_name : str, optional
How to specify your sampling:
1. (optional)
provide text description of your sampling in `sampling_name`
2.a (mandatory, if you have a constant sampling frequency)
specify `frequency_hz` with your sampling frequency in Hz
2.b (mandatory, if you do NOT have a constant sampling frequency)
specify `monotonic_time_points_us` with a list[1] of time
points in microseconds on a montonic clock, one for each
sample in your dataset.
3. (optional)
specify `calendar_time_points` with a list[2] of time points
in the ISO 8601 extended format with microsecond precision
and time zone designator[3], one for each sample in your
dataset.
[1] any Iterable of int
[2] any Iterable of str
[3] example: "2020-11-18T13:21:34.117015+01:00"
frequency_hz: int, optional
refer to explanation of `sampling_name`
monotonic_time_points_us: Iterable of int, optional
refer to explanation of `sampling_name`
calendar_time_points: Iterable of str, optional
refer to explanation of `sampling_name`
""" """
if path is None: if path is None:
...@@ -143,6 +183,8 @@ class File(h5py.File): ...@@ -143,6 +183,8 @@ class File(h5py.File):
Args: Args:
path: path to a io file as a string or path object. If no path is specified, the last known path (from loading or saving) is used. path: path to a io file as a string or path object. If no path is specified, the last known path (from loading or saving) is used.
strict_validate: optional boolean, if the file should be strictly validated, before saving. The default is True. strict_validate: optional boolean, if the file should be strictly validated, before saving. The default is True.
Returns:
The file itself, so something like f = robofish.io.File().save_as("file.hdf5") works
""" """
self.validate(strict_validate=strict_validate) self.validate(strict_validate=strict_validate)
...@@ -155,6 +197,8 @@ class File(h5py.File): ...@@ -155,6 +197,8 @@ class File(h5py.File):
shutil.copyfile(Path(self.filename).resolve(), path) shutil.copyfile(Path(self.filename).resolve(), path)
return self
def create_sampling( def create_sampling(
self, self,
name: str = None, name: str = None,
...@@ -319,7 +363,6 @@ class File(h5py.File): ...@@ -319,7 +363,6 @@ class File(h5py.File):
assert poses.ndim == 3 assert poses.ndim == 3
assert poses.shape[2] in [3, 4] assert poses.shape[2] in [3, 4]
agents = poses.shape[0] agents = poses.shape[0]
timesteps = poses.shape[1]
entity_names = [] entity_names = []
for i in range(agents): for i in range(agents):
...@@ -355,20 +398,38 @@ class File(h5py.File): ...@@ -355,20 +398,38 @@ class File(h5py.File):
for name in self.entity_names for name in self.entity_names
] ]
@property
def entity_positions(self):
return self.select_entity_property(None, entity_property=Entity.positions)
@property
def entity_orientations(self):
return self.select_entity_property(None, entity_property=Entity.orientations)
@property
def entity_orientations_rad(self):
return self.select_entity_property(
None, entity_property=Entity.orientations_rad
)
@property @property
def entity_poses(self): def entity_poses(self):
return self.select_entity_property(None) return self.select_entity_property(None, entity_property=Entity.poses)
@property @property
def entity_poses_rad(self): def entity_poses_rad(self):
return self.select_entity_property(None, entity_property=Entity.poses_rad) return self.select_entity_property(None, entity_property=Entity.poses_rad)
@property @property
def speeds_turns_angles(self): def entity_poses_calc_ori_rad(self):
return self.select_entity_property( return self.select_entity_property(
None, entity_property=Entity.speed_turn_angle None, entity_property=Entity.poses_calc_ori_rad
) )
@property
def entity_speeds_turns(self):
return self.select_entity_property(None, entity_property=Entity.speed_turn)
def select_entity_poses(self, *args, ori_rad=False, **kwargs): def select_entity_poses(self, *args, ori_rad=False, **kwargs):
entity_property = Entity.poses_rad if ori_rad else Entity.poses entity_property = Entity.poses_rad if ori_rad else Entity.poses
return self.select_entity_property( return self.select_entity_property(
......
...@@ -71,28 +71,34 @@ def read_multiple_files( ...@@ -71,28 +71,34 @@ def read_multiple_files(
return sf_dict return sf_dict
def read_poses_from_multiple_files( def read_property_from_multiple_files(
paths: Union[Path, str, Iterable[Path], Iterable[str]], paths: Union[Path, str, Iterable[Path], Iterable[str]],
entity_property: property = None,
*,
strict_validate: bool = False, strict_validate: bool = False,
max_files: int = None, max_files: int = None,
shuffle: bool = False, shuffle: bool = False,
ori_rad: bool = False, predicate: callable = None,
): ):
"""Load hdf5 files from a given path and return the entity poses. """Load hdf5 files from a given path and return the property of the entities.
The function can be given the path to a single single hdf5 file, to a folder, The function can be given the path to a single single hdf5 file, to a folder,
containing hdf5 files, or an array of multiple files or folders. containing hdf5 files, or an array of multiple files or folders.
Args: Args:
path: The path to a hdf5 file or folder. path: The path to a hdf5 file or folder.
entity_property: A property of robofish.io.Entity default is Entity.poses_rad
strict_validate: Choice between error and warning in case of invalidity strict_validate: Choice between error and warning in case of invalidity
max_files: Maximum number of files to be read max_files: Maximum number of files to be read
shuffle: Shuffle the order of files shuffle: Shuffle the order of files
ori_rad: Return the orientations as radiants instead of unit vectors predicate:
Returns: Returns:
An array of all entity poses arrays An array of all entity properties arrays
""" """
assert (
entity_property is not None
), "Please select an entity property e.g. 'Entity.poses_rad'"
logging.info(f"Reading files from path {paths}") logging.info(f"Reading files from path {paths}")
list_types = (list, np.ndarray, pandas.core.series.Series) list_types = (list, np.ndarray, pandas.core.series.Series)
...@@ -122,12 +128,12 @@ def read_poses_from_multiple_files( ...@@ -122,12 +128,12 @@ def read_poses_from_multiple_files(
with robofish.io.File( with robofish.io.File(
path=file, strict_validate=strict_validate path=file, strict_validate=strict_validate
) as f: ) as f:
p = f.entity_poses_rad if ori_rad else f.entity_poses p = f.select_entity_property(predicate, entity_property)
poses_array.append(p) poses_array.append(p)
elif path is not None and path.exists(): elif path is not None and path.exists():
logging.info("found file %s" % path) logging.info("found file %s" % path)
with robofish.io.File(path=path, strict_validate=strict_validate) as f: with robofish.io.File(path=path, strict_validate=strict_validate) as f:
p = f.entity_poses_rad if ori_rad else f.entity_poses p = f.select_entity_property(predicate, entity_property)
poses_array.append(p) poses_array.append(p)
return poses_array return poses_array
\ No newline at end of file
...@@ -39,21 +39,20 @@ def test_entity_turn_speed(): ...@@ -39,21 +39,20 @@ def test_entity_turn_speed():
[np.cos(circle_rad) * circle_size, np.sin(circle_rad) * circle_size], axis=-1 [np.cos(circle_rad) * circle_size, np.sin(circle_rad) * circle_size], axis=-1
) )
e = f.create_entity("fish", positions=positions) e = f.create_entity("fish", positions=positions)
speed_turn_angle = e.speed_turn_angle speed_turn = e.speed_turn
assert speed_turn_angle.shape == (99, 3) assert speed_turn.shape == (98, 2)
# No turn in the first timestep, since initialization turns it the right way # Turns and speeds shoud be all the same afterwards, since the fish swims with constant velocity and angular velocity.
assert speed_turn_angle[0, 1] == 0 assert (np.std(speed_turn, axis=0) < 0.0001).all()
# Turns and speeds shoud afterwards be all the same afterwards, since the fish swims with constant velocity and angular velocity. # Cut off the first position as it cannot be simulated
assert (np.std(speed_turn_angle[1:, :2], axis=0) < 0.0001).all() positions = positions[1:]
# Use turn_speed to generate positions # Use turn_speed to generate positions
gen_positions = np.zeros((positions.shape[0], 3)) gen_positions = np.zeros((positions.shape[0], 3))
gen_positions[0, :2] = positions[0] gen_positions[0] = e.poses_calc_ori_rad[0]
gen_positions[0, 2] = speed_turn_angle[0, 2]
for i, (speed, turn, angle) in enumerate(speed_turn_angle): for i, (speed, turn) in enumerate(speed_turn):
new_angle = gen_positions[i, 2] + turn new_angle = gen_positions[i, 2] + turn
gen_positions[i + 1] = [ gen_positions[i + 1] = [
gen_positions[i, 0] + np.cos(new_angle) * speed, gen_positions[i, 0] + np.cos(new_angle) * speed,
......
...@@ -143,7 +143,9 @@ def test_speeds_turns_angles(): ...@@ -143,7 +143,9 @@ def test_speeds_turns_angles():
with robofish.io.File(world_size_cm=[100, 100], frequency_hz=25) as f: with robofish.io.File(world_size_cm=[100, 100], frequency_hz=25) as f:
poses = np.zeros((10, 100, 3)) poses = np.zeros((10, 100, 3))
f.create_multiple_entities("fish", poses=poses) f.create_multiple_entities("fish", poses=poses)
assert (f.speeds_turns_angles == 0).all()
# Stationary fish has no speed or turn
assert (f.entity_speeds_turns == 0).all()
def test_broken_sampling(caplog): def test_broken_sampling(caplog):
...@@ -185,6 +187,9 @@ def test_entity_positions_no_orientation(): ...@@ -185,6 +187,9 @@ def test_entity_positions_no_orientation():
assert f.entity_poses.shape == (1, 100, 4) assert f.entity_poses.shape == (1, 100, 4)
assert (f.entity_poses[:, :] == np.array([1, 1, 1, 0])).all() assert (f.entity_poses[:, :] == np.array([1, 1, 1, 0])).all()
# Calculate the orientation
assert f.entity_poses_calc_ori_rad.shape == (1, 99, 3)
def test_load_validate(): def test_load_validate():
sf = robofish.io.File(path=valid_file_path) sf = robofish.io.File(path=valid_file_path)
......
...@@ -45,8 +45,10 @@ path = utils.full_path(__file__, "../../resources/valid.hdf5") ...@@ -45,8 +45,10 @@ path = utils.full_path(__file__, "../../resources/valid.hdf5")
# TODO read from folder of valid files # TODO read from folder of valid files
@pytest.mark.parametrize("_path", [path, str(path)]) @pytest.mark.parametrize("_path", [path, str(path)])
def test_read_poses_from_multiple_folder(_path): def test_read_poses_rad_from_multiple_folder(_path):
poses = robofish.io.read_poses_from_multiple_files([_path, _path]) poses = robofish.io.read_property_from_multiple_files(
[_path, _path], robofish.io.entity.Entity.poses_rad
)
# Should find the 3 presaved hdf5 files # Should find the 3 presaved hdf5 files
assert len(poses) == 2 assert len(poses) == 2
for p in poses: for p in poses:
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment