motionbids 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
motionbids/__init__.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ motionbids: Lightweight converter for motion data to BIDS format.
3
+
4
+ This package provides tools to define, validate, and export BIDS-compliant
5
+ motion capture data.
6
+
7
+ Example usage:
8
+ >>> from motionbids import MotionData, validate_motion_data, export_bids_motion
9
+ >>> import numpy as np
10
+ >>>
11
+ >>> # Create motion data object
12
+ >>> motion = MotionData(
13
+ ... subject_id="01",
14
+ ... task_name="rest",
15
+ ... sampling_frequency=100,
16
+ ... tracked_points_count=10,
17
+ ... manufacturer="Vicon",
18
+ ... data=np.random.randn(1000, 10),
19
+ ... columns=[f"marker{i}" for i in range(10)],
20
+ ... units=["mm"] * 10
21
+ ... )
22
+ >>>
23
+ >>> # Validate
24
+ >>> validate_motion_data(motion)
25
+ True
26
+ >>>
27
+ >>> # Export to BIDS format
28
+ >>> files = export_bids_motion(motion, out_dir="bids_out/")
29
+ >>> print(files)
30
+ {'json': ..., 'tsv': ..., 'channels': ...}
31
+ """
32
+
33
+ __version__ = "0.1.0"
34
+ __author__ = "Julius Welzel"
35
+ __email__ = "julius.welzel@gmail.com"
36
+
37
+ # Import main classes and functions
38
+ # Using dynamic schema-based MotionData class
39
+ from .datamodel_dynamic import MotionData
40
+ from .channel import Channel
41
+ from .validator import (
42
+ validate_motion_data,
43
+ validate_bids_compliance,
44
+ ValidationError,
45
+ ValidationWarning,
46
+ check_required_fields,
47
+ check_recommended_fields,
48
+ )
49
+ from .exporter import (
50
+ export_bids_motion,
51
+ export_json_metadata,
52
+ export_tsv_data,
53
+ export_channels_tsv,
54
+ export_scans_tsv,
55
+ create_bids_directory_structure,
56
+ export_dataset_description,
57
+ )
58
+
59
+ # Optional: Schema utilities (users typically won't need these directly)
60
+ from . import schema_utils
61
+
62
+ # Define public API
63
+ __all__ = [
64
+ # Version info
65
+ "__version__",
66
+ "__author__",
67
+ "__email__",
68
+
69
+ # Core data model
70
+ "MotionData",
71
+ "Channel",
72
+
73
+ # Validation
74
+ "validate_motion_data",
75
+ "validate_bids_compliance",
76
+ "ValidationError",
77
+ "ValidationWarning",
78
+ "check_required_fields",
79
+ "check_recommended_fields",
80
+
81
+ # Export functions
82
+ "export_bids_motion",
83
+ "export_json_metadata",
84
+ "export_tsv_data",
85
+ "export_channels_tsv",
86
+ "export_scans_tsv",
87
+ "create_bids_directory_structure",
88
+ "export_dataset_description",
89
+
90
+ # Schema utilities module (advanced usage)
91
+ "schema_utils",
92
+ ]
@@ -0,0 +1,46 @@
1
+ """
2
+ BIDS schema constants for motion data validation.
3
+
4
+ This module defines the allowed values for channel components and types
5
+ according to the BIDS specification.
6
+ """
7
+
8
+ # Allowed channel components (from BIDS schema)
9
+ ALLOWED_CHANNEL_COMPONENTS = {
10
+ 'x', # Position along X-axis, rotation about X-axis, or magnetic field strength along X-axis
11
+ 'y', # Position along Y-axis, rotation about Y-axis, or magnetic field strength along Y-axis
12
+ 'z', # Position along Z-axis, rotation about Z-axis, or magnetic field strength along Z-axis
13
+ 'quat_x', # Quaternion component associated with X-axis
14
+ 'quat_y', # Quaternion component associated with Y-axis
15
+ 'quat_z', # Quaternion component associated with Z-axis
16
+ 'quat_w', # Non-axial quaternion component
17
+ 'n/a', # Channels with no corresponding spatial axis
18
+ }
19
+
20
+ # Allowed channel types (from BIDS schema - uppercase required)
21
+ ALLOWED_CHANNEL_TYPES = {
22
+ 'POS', # Position in space
23
+ 'ORNT', # Orientation
24
+ 'VEL', # Velocity
25
+ 'ACCEL', # Accelerometer
26
+ 'GYRO', # Gyrometer
27
+ 'ANGACCEL', # Angular acceleration
28
+ 'MAGN', # Magnetic field strength
29
+ 'JNTANG', # Joint angle between bodyparts
30
+ 'LATENCY', # Sample latency from recording onset
31
+ 'MISC', # Miscellaneous channels
32
+ }
33
+
34
+ # Component requirements for each channel type
35
+ CHANNEL_TYPE_COMPONENT_REQUIREMENTS = {
36
+ 'POS': {'x', 'y', 'z'}, # Requires spatial axes
37
+ 'ORNT': {'x', 'y', 'z', 'quat_x', 'quat_y', 'quat_z', 'quat_w'}, # Requires axes or quaternions
38
+ 'VEL': {'x', 'y', 'z'}, # Requires spatial axes
39
+ 'ACCEL': {'x', 'y', 'z'}, # Requires spatial axes
40
+ 'GYRO': {'x', 'y', 'z'}, # Requires spatial axes
41
+ 'ANGACCEL': {'x', 'y', 'z'}, # Requires spatial axes
42
+ 'MAGN': {'x', 'y', 'z'}, # Requires spatial axes
43
+ 'JNTANG': ALLOWED_CHANNEL_COMPONENTS, # Can use any component
44
+ 'LATENCY': ALLOWED_CHANNEL_COMPONENTS, # Can use any component
45
+ 'MISC': ALLOWED_CHANNEL_COMPONENTS, # Can use any component
46
+ }
motionbids/channel.py ADDED
@@ -0,0 +1,135 @@
1
+ """
2
+ Channel model for BIDS-compliant motion data.
3
+
4
+ This module defines the Channel dataclass that represents a single channel
5
+ in motion capture data, following the BIDS specification.
6
+ """
7
+ from dataclasses import dataclass
8
+ from typing import Optional
9
+ from .bids_constants import (
10
+ ALLOWED_CHANNEL_COMPONENTS,
11
+ ALLOWED_CHANNEL_TYPES,
12
+ CHANNEL_TYPE_COMPONENT_REQUIREMENTS
13
+ )
14
+
15
+
16
+ @dataclass
17
+ class Channel:
18
+ """
19
+ Dataclass representing a single channel in BIDS motion data.
20
+
21
+ This follows the BIDS specification for channels.tsv files:
22
+ https://bids-specification.readthedocs.io/en/stable/modality-specific-files/motion.html#channels-description-_channelstsv
23
+
24
+ Required Fields (MUST appear in this order in channels.tsv):
25
+ name: Label of the channel (e.g., "marker0_x", "imu1_acc_x")
26
+ component: Spatial axis or quaternion component
27
+ Must be one of: x, y, z, quat_x, quat_y, quat_z, quat_w, n/a
28
+ type: Channel type (MUST be uppercase)
29
+ Must be one of: POS, ORNT, VEL, ACCEL, GYRO, ANGACCEL, MAGN, JNTANG, LATENCY, MISC
30
+ tracked_point: Label of the tracked point (e.g., "LeftFoot", "RightWrist")
31
+ units: Physical or virtual unit (e.g., "m", "rad", "m/s^2", "deg")
32
+
33
+ Recommended Fields:
34
+ placement: Placement on body (e.g., "torso", "left arm")
35
+ reference_frame: Reference frame specification (links to channels.json)
36
+ description: Brief description or other info
37
+ sampling_frequency: Sampling rate in Hz (if different from main rate)
38
+ status: Data quality - "good", "bad", or "n/a"
39
+ status_description: Explanation if status is "bad"
40
+
41
+ Example:
42
+ >>> channel = Channel(
43
+ ... channel_name="marker0_x",
44
+ ... channel_component="x",
45
+ ... channel_type="POS",
46
+ ... channel_tracked_point="marker0",
47
+ ... channel_units="mm"
48
+ ... )
49
+
50
+ Component-Type Compatibility:
51
+ - Quaternion components (quat_*) can only be used with ORNT type
52
+ - Spatial components (x, y, z) can be used with POS, VEL, ACCEL, GYRO, MAGN, ANGACCEL
53
+ - n/a can be used with JNTANG, LATENCY, MISC
54
+ """
55
+
56
+ # Required fields (MUST be in this order)
57
+ channel_name: str
58
+ channel_component: str
59
+ channel_type: str
60
+ channel_tracked_point: str
61
+ channel_units: str
62
+
63
+ # Recommended fields
64
+ channel_placement: Optional[str] = None
65
+ channel_reference_frame: Optional[str] = None
66
+ channel_description: Optional[str] = None
67
+ channel_sampling_frequency: Optional[float] = None
68
+ channel_status: Optional[str] = None
69
+ channel_status_description: Optional[str] = None
70
+
71
+ def __post_init__(self):
72
+ """Validate channel fields against BIDS schema."""
73
+ # Validate component
74
+ if self.channel_component not in ALLOWED_CHANNEL_COMPONENTS:
75
+ raise ValueError(
76
+ f"Invalid component '{self.channel_component}' for channel '{self.channel_name}'. "
77
+ f"Must be one of: {sorted(ALLOWED_CHANNEL_COMPONENTS)}"
78
+ )
79
+
80
+ # Validate type (must be uppercase)
81
+ if self.channel_type not in ALLOWED_CHANNEL_TYPES:
82
+ raise ValueError(
83
+ f"Invalid type '{self.channel_type}' for channel '{self.channel_name}'. "
84
+ f"Must be one of (uppercase required): {sorted(ALLOWED_CHANNEL_TYPES)}"
85
+ )
86
+
87
+ # Validate component-type compatibility
88
+ if self.channel_type in CHANNEL_TYPE_COMPONENT_REQUIREMENTS:
89
+ allowed_components = CHANNEL_TYPE_COMPONENT_REQUIREMENTS[self.channel_type]
90
+ if self.channel_component not in allowed_components:
91
+ raise ValueError(
92
+ f"Component '{self.channel_component}' is not allowed for type '{self.channel_type}' "
93
+ f"in channel '{self.channel_name}'. "
94
+ f"Allowed components for {self.channel_type}: {sorted(allowed_components)}"
95
+ )
96
+
97
+ # Validate status if provided
98
+ if self.channel_status is not None and self.channel_status not in ["good", "bad", "n/a"]:
99
+ raise ValueError(
100
+ f"Invalid status '{self.channel_status}' for channel '{self.channel_name}'. "
101
+ f"Must be one of: 'good', 'bad', 'n/a'"
102
+ )
103
+
104
+ def to_tsv_row(self) -> dict:
105
+ """
106
+ Convert channel to dictionary for TSV row export.
107
+
108
+ Returns only non-None fields in the correct BIDS order.
109
+
110
+ Returns:
111
+ Dictionary with channel data for TSV export
112
+ """
113
+ row = {
114
+ 'name': self.channel_name,
115
+ 'component': self.channel_component,
116
+ 'type': self.channel_type,
117
+ 'tracked_point': self.channel_tracked_point,
118
+ 'units': self.channel_units,
119
+ }
120
+
121
+ # Add optional fields if present
122
+ if self.channel_placement is not None:
123
+ row['placement'] = self.channel_placement
124
+ if self.channel_reference_frame is not None:
125
+ row['reference_frame'] = self.channel_reference_frame
126
+ if self.channel_description is not None:
127
+ row['description'] = self.channel_description
128
+ if self.channel_sampling_frequency is not None:
129
+ row['sampling_frequency'] = self.channel_sampling_frequency
130
+ if self.channel_status is not None:
131
+ row['status'] = self.channel_status
132
+ if self.channel_status_description is not None:
133
+ row['status_description'] = self.channel_status_description
134
+
135
+ return row
@@ -0,0 +1,230 @@
1
+ """
2
+ Data model for BIDS-compliant motion data.
3
+
4
+ This module defines the MotionData dataclass that represents motion capture
5
+ data with BIDS-compliant metadata fields.
6
+ """
7
+ from dataclasses import dataclass, field
8
+ from typing import Optional, List, Dict, Any
9
+ import numpy as np
10
+ from dataclasses_json import dataclass_json
11
+ from .channel import Channel
12
+
13
+
14
+ @dataclass_json
15
+ @dataclass
16
+ class MotionData:
17
+ """
18
+ Dataclass representing BIDS-compliant motion capture data.
19
+
20
+ This class includes both required and optional metadata fields for motion data,
21
+ along with the actual motion time series data.
22
+
23
+ **Data Format:**
24
+ - `data`: NumPy array where **rows = timepoints** and **columns = channels** (REQUIRED)
25
+ - `channels`: List of Channel objects defining channel metadata (REQUIRED)
26
+
27
+ The `channels` list follows the BIDS specification for channels.tsv files.
28
+ Each Channel object contains: name, component, type, tracked_point, units (all required),
29
+ plus optional fields like placement, reference_frame, description, sampling_frequency, status.
30
+
31
+ The number of Channel objects MUST match the number of columns in the data array.
32
+ Channel metadata is validated against BIDS schema during Channel construction.
33
+
34
+ Required Fields (must be provided):
35
+ subject_id: Subject identifier (BIDS entity 'sub')
36
+ task_name: Name of the task (BIDS metadata 'TaskName')
37
+ sampling_frequency: Sampling frequency in Hz (BIDS metadata 'SamplingFrequency')
38
+ tracked_points_count: Number of tracked points (BIDS metadata 'TrackedPointsCount')
39
+ tracksys: Tracking system label (BIDS entity 'tracksys') - REQUIRED for filenames
40
+
41
+ Recommended Fields (should be provided when available):
42
+ manufacturer: Manufacturer of the motion capture system
43
+ manufacturers_model_name: Model name of the motion capture system
44
+ software_versions: Software version used for acquisition
45
+ motion_channel_count: Total number of motion channels
46
+ recording_duration: Duration of the recording in seconds
47
+ recording_type: Type of recording (e.g., 'continuous')
48
+
49
+ Optional Fields:
50
+ session_id: Session identifier (BIDS entity 'ses')
51
+ acquisition: Acquisition label (BIDS entity 'acq')
52
+ run: Run index (BIDS entity 'run')
53
+ acq_time: Acquisition time in ISO 8601 format with optional fractional seconds
54
+ (e.g., '2023-06-15T14:30:00' or '2023-06-15T14:30:00.123456')
55
+ Supports sub-millisecond precision. If provided, a scans.tsv file will be generated
56
+ additional_metadata: Dictionary for any additional custom metadata
57
+
58
+ Example:
59
+ >>> import numpy as np
60
+ >>> from motionbids.channel import Channel
61
+ >>> data = np.random.randn(1000, 30) # 1000 timepoints, 30 channels
62
+ >>> channels = [
63
+ ... Channel(channel_name=f"marker{i//3}_{ax}", channel_component=ax, channel_type="POS",
64
+ ... tracked_point=f"marker{i//3}", channel_units="mm")
65
+ ... for i in range(30) for ax in ['x', 'y', 'z'] if i % 3 == ['x', 'y', 'z'].index(ax)
66
+ ... ]
67
+ >>> motion = MotionData(
68
+ ... subject_id="01",
69
+ ... task_name="walk",
70
+ ... sampling_frequency=120.0,
71
+ ... tracked_points_count=10,
72
+ ... tracksys="optical",
73
+ ... data=data,
74
+ ... channels=channels
75
+ ... )
76
+ """
77
+
78
+ # Required fields
79
+ subject_id: str
80
+ task_name: str
81
+ sampling_frequency: float
82
+ tracked_points_count: int
83
+ tracksys: str # REQUIRED for BIDS motion data filenames
84
+
85
+ # Recommended fields
86
+ manufacturer: Optional[str] = None
87
+ manufacturers_model_name: Optional[str] = None
88
+ software_versions: Optional[str] = None
89
+ motion_channel_count: Optional[int] = None
90
+ recording_duration: Optional[float] = None
91
+ recording_type: Optional[str] = "continuous"
92
+
93
+ # Optional BIDS entities
94
+ session_id: Optional[str] = None
95
+ acquisition: Optional[str] = None
96
+ run: Optional[int] = None
97
+ acq_time: Optional[str] = None # ISO 8601 with optional fractional seconds (e.g., '2023-06-15T14:30:00.123456')
98
+
99
+ # Motion data (REQUIRED - have defaults to avoid field ordering issues, validated in __post_init__)
100
+ data: Optional[np.ndarray] = field(default=None, repr=False)
101
+ channels: Optional[List[Channel]] = None
102
+
103
+ # Additional metadata
104
+ additional_metadata: Optional[Dict[str, Any]] = field(default_factory=dict)
105
+
106
+ def __post_init__(self):
107
+ """Validate basic field types and constraints."""
108
+ if self.sampling_frequency <= 0:
109
+ raise ValueError("sampling_frequency must be positive")
110
+
111
+ if self.tracked_points_count <= 0:
112
+ raise ValueError("tracked_points_count must be positive")
113
+
114
+ if self.run is not None and self.run < 1:
115
+ raise ValueError("run must be >= 1")
116
+
117
+ # Validate data and channels together - both required if either is provided
118
+ if self.data is not None or self.channels is not None:
119
+ if self.data is None:
120
+ raise ValueError("data is required when channels are provided")
121
+
122
+ if self.channels is None:
123
+ raise ValueError("channels is required when data is provided")
124
+
125
+ if not isinstance(self.data, np.ndarray):
126
+ raise TypeError("data must be a numpy array")
127
+
128
+ if self.data.ndim not in [1, 2]:
129
+ raise ValueError("data must be 1D or 2D array")
130
+
131
+ # Determine expected number of channels
132
+ if self.data.ndim == 1:
133
+ expected_channels = 1
134
+ else:
135
+ expected_channels = self.data.shape[1]
136
+
137
+ # Validate channels list length matches data dimensions
138
+ if len(self.channels) != expected_channels:
139
+ raise ValueError(
140
+ f"Number of channels ({len(self.channels)}) must match "
141
+ f"data columns ({expected_channels})"
142
+ )
143
+
144
+ # Channel validation happens in Channel.__post_init__
145
+
146
+ def get_bids_filename(self, suffix: str = "motion", extension: str = "json") -> str:
147
+ """
148
+ Generate BIDS-compliant filename for this motion data.
149
+
150
+ Args:
151
+ suffix: BIDS suffix (default: 'motion')
152
+ extension: File extension without dot (default: 'json')
153
+
154
+ Returns:
155
+ BIDS-compliant filename string
156
+
157
+ Raises:
158
+ ValueError: If tracksys is not provided (required for motion data)
159
+
160
+ Example:
161
+ >>> motion = MotionData(subject_id="01", task_name="rest", tracksys="optical", ...)
162
+ >>> motion.get_bids_filename()
163
+ 'sub-01_task-rest_tracksys-optical_motion.json'
164
+
165
+ Note:
166
+ Entity order follows BIDS specification:
167
+ sub-<label>[_ses-<label>]_task-<label>_tracksys-<label>[_acq-<label>][_run-<index>]
168
+ The tracksys entity is REQUIRED for motion data.
169
+ """
170
+ if not self.tracksys:
171
+ raise ValueError(
172
+ "tracksys entity is required for BIDS motion data filenames. "
173
+ "Please provide a tracking system label (e.g., 'optical', 'imu', 'video')."
174
+ )
175
+
176
+ parts = [f"sub-{self.subject_id}"]
177
+
178
+ if self.session_id:
179
+ parts.append(f"ses-{self.session_id}")
180
+
181
+ parts.append(f"task-{self.task_name}")
182
+
183
+ # tracksys is REQUIRED and comes before acq
184
+ parts.append(f"tracksys-{self.tracksys}")
185
+
186
+ if self.acquisition:
187
+ parts.append(f"acq-{self.acquisition}")
188
+
189
+ if self.run:
190
+ parts.append(f"run-{self.run:02d}")
191
+
192
+ parts.append(suffix)
193
+
194
+ filename = "_".join(parts) + f".{extension}"
195
+ return filename
196
+
197
+ def to_metadata_dict(self) -> Dict[str, Any]:
198
+ """
199
+ Convert MotionData to a dictionary suitable for JSON export.
200
+
201
+ Returns:
202
+ Dictionary containing all metadata fields (excluding data array, columns, and units)
203
+ """
204
+ metadata = {
205
+ "TaskName": self.task_name,
206
+ "SamplingFrequency": self.sampling_frequency,
207
+ "TrackedPointsCount": self.tracked_points_count,
208
+ }
209
+
210
+ # Add recommended fields if present
211
+ if self.manufacturer:
212
+ metadata["Manufacturer"] = self.manufacturer
213
+ if self.manufacturers_model_name:
214
+ metadata["ManufacturersModelName"] = self.manufacturers_model_name
215
+ if self.software_versions:
216
+ metadata["SoftwareVersions"] = self.software_versions
217
+ if self.motion_channel_count:
218
+ metadata["MotionChannelCount"] = self.motion_channel_count
219
+ if self.recording_duration:
220
+ metadata["RecordingDuration"] = self.recording_duration
221
+ if self.recording_type:
222
+ metadata["RecordingType"] = self.recording_type
223
+
224
+ # Note: columns and units are NOT exported to JSON - they go in channels.tsv
225
+
226
+ # Add any additional metadata
227
+ if self.additional_metadata:
228
+ metadata.update(self.additional_metadata)
229
+
230
+ return metadata