├── .gitignore ├── .flake8 ├── test ├── run.sh ├── test_import_c3d_files.py ├── test_import_sample01.py ├── zipload.py └── test_import_software_specific.py ├── c3d ├── __init__.py ├── LICENSE ├── dtypes.py ├── utils.py ├── header.py ├── group.py ├── reader.py ├── manager.py ├── parameter.py └── writer.py ├── scripts └── import_c3d.py ├── pyfuncs.py ├── perfmon.py ├── README.md ├── __init__.py ├── c3d_importer.py ├── LICENSE └── c3d_parse_dictionary.py /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | ignore/* 3 | */testfiles/* 4 | c3d/README.* 5 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | exclude = .git, 4 | __pycache__, 5 | ignore, 6 | c3d -------------------------------------------------------------------------------- /test/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | 4 | # Setup 5 | cd "${0%/*}" 6 | BLENDER_EXE=$1 7 | 8 | echo "Running tests using: $BLENDER_EXE" 9 | 10 | STATUS=0 11 | for FILEPATH in ./*.py; do 12 | echo "Test $FILEPATH" 13 | eval $BLENDER_EXE -b -noaudio --python "$FILEPATH" 14 | if [ $? -ne 0 ]; then 15 | # Track failed evaluation 16 | STATUS=1 17 | fi 18 | done 19 | 20 | echo "" 21 | echo "-----------------" 22 | if [ $STATUS -eq 0 ]; then 23 | echo "Success: All Passed!!!" 24 | else 25 | echo "Test(s) Failed..." 26 | fi 27 | echo "-----------------" 28 | 29 | # Terminate with failed status 30 | exit $STATUS -------------------------------------------------------------------------------- /c3d/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | --------------------- 3 | Python C3D Processing 4 | --------------------- 5 | 6 | This package provides pure Python modules for reading, writing, and editing binary 7 | motion-capture files in the [C3D file format]. 8 | 9 | [C3D file format]: https://www.c3d.org/HTML/default.htm 10 | 11 | Installing 12 | ---------- 13 | 14 | See the [main page] or [github page]. 15 | 16 | [main page]: https://mattiasfredriksson.github.io/py-c3d/ 17 | [github page]: https://github.com/EmbodiedCognition/py-c3d 18 | 19 | .. include:: ../docs/examples.md 20 | 21 | """ 22 | from . import dtypes 23 | from . import group 24 | from . import header 25 | from . import manager 26 | from . import parameter 27 | from . import utils 28 | from .reader import Reader 29 | from .writer import Writer 30 | -------------------------------------------------------------------------------- /scripts/import_c3d.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import glob 3 | import os 4 | 5 | # Find import directory relative to __file__ 6 | if '.blend' in __file__: 7 | # Fetch path from the text object in bpy.data.texts 8 | filename = os.path.basename(__file__) 9 | filepath = bpy.data.texts[filename].filepath 10 | else: 11 | filepath = __file__ 12 | 13 | import_dir = os.path.join(os.path.dirname(filepath), '.\\testfiles\\sample01') 14 | 15 | print(import_dir) 16 | os.chdir(import_dir) 17 | files = glob.glob("*.c3d") 18 | 19 | b = 0 20 | e = len(files) 21 | files = files[b:e] 22 | 23 | armature_obj = None 24 | 25 | 26 | print('Matching files: ' + str(len(files))) 27 | if len(files) == 0: 28 | raise Exception('No matching files found') 29 | 30 | # Parse files 31 | for file in files: 32 | # Parse 33 | bpy.ops.import_anim.c3d(filepath=file, print_file=False) 34 | # Fetch loaded objects 35 | obj = bpy.context.selected_objects[0] 36 | action = obj.animation_data.action 37 | -------------------------------------------------------------------------------- /pyfuncs.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # io_anim_c3d is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | # Script copyright (C) Mattias Fredriksson 20 | 21 | # pep8 compliancy: 22 | # flake8 .\c3d_importer.py 23 | 24 | def islist(N): 25 | """ 26 | Check if 'N' object is any type of array 27 | """ 28 | return hasattr(N, '__len__') and (not isinstance(N, str)) 29 | -------------------------------------------------------------------------------- /c3d/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2013-2015 UT Vision, Cognition, and Action Lab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /test/test_import_c3d_files.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | import unittest 4 | 5 | import sys 6 | sys.path.append(os.path.join(os.path.dirname(__file__), "..")) 7 | 8 | 9 | class ImportC3DTestMultipleFiles(unittest.TestCase): 10 | 11 | def setUpClass(): 12 | from test.zipload import Zipload 13 | Zipload.download_and_extract() 14 | objs = [] 15 | actions = [] 16 | 17 | # Parse files 18 | for filepath in Zipload.get_c3d_filenames('sample00'): 19 | # Parse 20 | bpy.ops.import_anim.c3d(filepath=filepath, print_file=False, perf_mon=False) 21 | # Fetch loaded objects 22 | obj = bpy.context.selected_objects[0] 23 | objs.append(obj) 24 | actions.append(obj.animation_data.action) 25 | 26 | ImportC3DTestMultipleFiles.objs = objs 27 | ImportC3DTestMultipleFiles.actions = actions 28 | 29 | def test_A_channel_count(self): 30 | ''' Verify loaded animations has channels 31 | ''' 32 | self.assertGreater(len(self.actions), 0) 33 | for action in self.actions: 34 | self.assertGreater(len(action.fcurves), 0) 35 | 36 | def test_B_keyframe_count(self): 37 | ''' Verify each channel has keyframes 38 | ''' 39 | self.assertGreater(len(self.actions), 0) 40 | for action in self.actions: 41 | for i in range(len(action.fcurves)): 42 | self.assertGreater(len(action.fcurves[i].keyframe_points), 0) 43 | 44 | 45 | if __name__ == '__main__': 46 | import sys 47 | sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) 48 | unittest.main() 49 | -------------------------------------------------------------------------------- /perfmon.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # io_anim_c3d is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | # 20 | 21 | # Script copyright (C) Campbell Barton, Bastien Montagne, Mattias Fredriksson 22 | 23 | 24 | # ##### Performance monitor ##### 25 | import time 26 | 27 | DO_PERFMON = True 28 | 29 | 30 | class PerfMon(): 31 | def __init__(self): 32 | self.level = -1 33 | self.ref_time = [] 34 | 35 | def level_up(self, message="", init_sample=False): 36 | self.level += 1 37 | self.ref_time.append(time.process_time() if init_sample else None) 38 | if message: 39 | print("\t" * self.level, message, sep="") 40 | 41 | def level_down(self, message=""): 42 | if not self.ref_time: 43 | if message: 44 | print(message) 45 | return 46 | ref_time = self.ref_time[self.level] 47 | print("\t" * self.level, 48 | "\tDone (%f sec)\n" % ((time.process_time() - ref_time) if ref_time is not None else 0.0), 49 | sep="") 50 | if message: 51 | print("\t" * self.level, message, sep="") 52 | del self.ref_time[self.level] 53 | self.level -= 1 54 | 55 | def step(self, message=""): 56 | ref_time = self.ref_time[self.level] 57 | curr_time = time.process_time() 58 | if ref_time is not None: 59 | print("\t" * self.level, "\tDone (%f sec)\n" % (curr_time - ref_time), sep="") 60 | self.ref_time[self.level] = curr_time 61 | print("\t" * self.level, message, sep="") 62 | 63 | def message(self, message): 64 | print("\t" * self.level, message, sep="") 65 | 66 | 67 | class NullMon(): 68 | def __init__(self): 69 | pass 70 | 71 | def level_up(self, message="", init_sample=False): 72 | pass 73 | 74 | def level_down(self, message=""): 75 | pass 76 | 77 | def step(self, message=""): 78 | pass 79 | 80 | def message(self, message): 81 | pass 82 | 83 | 84 | def new_monitor(print_output=True) -> PerfMon: 85 | if not DO_PERFMON or not print_output: 86 | return NullMon() 87 | else: 88 | return PerfMon() 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # C3D File Importer for Blender 2 | 3 | [Blender](https://www.blender.org/) addon for importing motion capture data found in .c3d files. 4 | 5 | 6 | 7 | # How to Install 8 | 9 | 1. [Download](https://github.com/MattiasFredriksson/io_anim_c3d/archive/master.zip) the latest version of `io_anim_c3d` from source code (intended for latest LTS release, see [release](https://github.com/MattiasFredriksson/io_anim_c3d/releases) section for stable/specific versions). 10 | 2. Open Blender 11 | 3. Open the Edit->Preferences window. 12 | 4. Go to the 'Add-ons' tab. 13 | 5. Click the 'Install...' button. 14 | 6. Navigate to the folder containing the downloaded .zip file and double-click the file. 15 | 7. Enable the addon 'Import-Export: C3D format' in the addon window. 16 | 8. Not there? Use the refresh button, restart blender, google how to install add-ons, or visit [docs.blender.org](https://docs.blender.org/manual/en/latest/editors/preferences/addons.html). 17 | 18 | 19 | # How to Use 20 | 21 | 1. Make sure the add-on is installed and enabled (see the 'How to Install' section). 22 | 2. Go to File->Import->C3D (.c3d) to open the import window. 23 | 3. Change the import settings (if needed). 24 | 4. Navigate to the folder containing the .c3d file, and... 25 | 1. Double-click to import a single file or 26 | 2. Select file(s) to import and click the 'Import C3D' button. 27 | 28 | # Development 29 | 30 | General guidelines and information how to configure the repository for development. 31 | 32 | Development Tools 33 | ------- 34 | - Visual Studio Code 35 | - [Blender Development Extension](https://marketplace.visualstudio.com/items?itemName=JacquesLucke.blender-development) 36 | 37 | Tests 38 | ------- 39 | Unittests are available under the 'tests/' folder. To run call: 40 | 41 | `tests/run.sh ` 42 | 43 | Running tests require the addon to be installed to the Blender executable, simplest way to do so is to use the Blender development extension with the same executable as it will configure a symlink to the project, ensuring the test will run with the latest changes to the code. For running tests on Windows the console need to be able to handle bash scripts! 44 | 45 | Unittests are minimal and should focus on testing the addon functionality. For functionality testing the importer go to the underlying .c3d parser [project](https://github.com/MattiasFredriksson/py-c3d). 46 | 47 | 48 | Code Style 49 | ------- 50 | - Classes should be in PascalCase 51 | - Variables and functions should be in snake_case 52 | - Code needs to be pep8 compliant (\_\_init\_\_.py has exemptions due to Blender dependencies). 53 | 54 | Addon Tooltip Style 55 | ------- 56 | 57 | - Addon property names should written with the first letter in each word capatilized. 58 | - Don't forget to exclude the dot (.) at the end of the description as it will be added automatically... 59 | 60 | # Further Questions? 61 | 62 | If there are any questions or suggestions please visit the [issue board](https://github.com/MattiasFredriksson/io_anim_c3d/issues) and make a ticket, hopefully I will be able to answer questions within some reasonable time frame 😄. 63 | -------------------------------------------------------------------------------- /test/test_import_sample01.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | import unittest 4 | 5 | import sys 6 | sys.path.append(os.path.join(os.path.dirname(__file__), "..")) 7 | 8 | 9 | class ImportC3DTestSample01(unittest.TestCase): 10 | 11 | ZIP_FOLDER = 'sample01' 12 | ZIP_FILES = \ 13 | [ 14 | 'Eb015pi.c3d', 15 | 'Eb015pr.c3d', 16 | 'Eb015vi.c3d', 17 | 'Eb015vr.c3d', 18 | 'Eb015si.c3d', 19 | 'Eb015sr.c3d' 20 | ] 21 | 22 | def setUpClass(): 23 | from test.zipload import Zipload 24 | Zipload.download_and_extract() 25 | 26 | objs = [] 27 | actions = [] 28 | 29 | for file in ImportC3DTestSample01.ZIP_FILES: 30 | 31 | # Parse 32 | fp = Zipload.get_c3d_path('sample01', file) 33 | bpy.ops.import_anim.c3d(filepath=fp, 34 | print_file=False, 35 | include_empty_labels=False, 36 | perf_mon=False) 37 | # Fetch loaded objects 38 | obj = bpy.context.selected_objects[0] 39 | objs.append(obj) 40 | actions.append(obj.animation_data.action) 41 | 42 | ImportC3DTestSample01.objs = objs 43 | ImportC3DTestSample01.actions = actions 44 | 45 | def test_A_channel_count(self): 46 | ''' Verify number of channels are equal 47 | ''' 48 | a0 = self.actions[0] 49 | for action in self.actions[1:]: 50 | self.assertEqual(len(a0.fcurves), len(action.fcurves)) 51 | 52 | def test_B_tracker_names(self): 53 | ''' Verify labels for each channel group are equal and ordered 54 | ''' 55 | a0 = self.actions[0] 56 | for action in self.actions[1:]: 57 | for i in range(len(a0.fcurves)): 58 | self.assertEqual(a0.fcurves[i].group.name, action.fcurves[i].group.name) 59 | 60 | def test_C_tracker_labels(self): 61 | ''' Verify data labels match assigned channel group names 62 | ''' 63 | LABELS = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3', 'RSK1', 'RSK2', 'RSK3', 'RSK4', 'LSK1', 'LSK2', 64 | 'LSK3', 'LSK4', 'RTH1', 'RTH2', 'RTH3', 'RTH4', 'LTH1', 'LTH2', 'LTH3', 'LTH4', 'PV1', 'PV2', 'PV3', 65 | 'pv4'] # , 'TR2', 'TR3', 'RA', 'LA', 'RK', 'LK', 'RH', 'LH', 'RPP', 'LPP', 'RS', 'LS'] 66 | 67 | for action in self.actions: 68 | names = [fc_grp.name for fc_grp in action.groups] 69 | for label in LABELS: 70 | self.assertIn(label, names) 71 | 72 | def test_D_keyframe_count(self): 73 | ''' Verify number of keyframes are identical 74 | ''' 75 | a0 = self.actions[0] 76 | for action in self.actions[1:]: 77 | for i in range(len(a0.fcurves)): 78 | self.assertEqual(len(a0.fcurves[i].keyframe_points), len(action.fcurves[i].keyframe_points)) 79 | 80 | def test_E_keyframes_equal(self): 81 | ''' Verify keyframes are identical 82 | ''' 83 | a0 = self.actions[0] 84 | for action in self.actions[1:]: 85 | for i in range(len(a0.fcurves)): 86 | for ii in range(len(a0.fcurves[i].keyframe_points)): 87 | # co = (t, axis_co) 88 | self.assertAlmostEqual(a0.fcurves[i].keyframe_points[ii].co, 89 | action.fcurves[i].keyframe_points[ii].co) 90 | 91 | 92 | if __name__ == '__main__': 93 | import sys 94 | sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) 95 | unittest.main() 96 | -------------------------------------------------------------------------------- /test/zipload.py: -------------------------------------------------------------------------------- 1 | import io 2 | import os 3 | import shutil 4 | import tempfile 5 | import urllib.request 6 | import zipfile 7 | 8 | TEST_FOLDER = os.path.join(tempfile.gettempdir(), 'io_anim_c3d', 'testfiles') 9 | ZIPS = ( 10 | ('https://www.c3d.org/data/Sample00.zip', 'sample00.zip'), 11 | ('https://www.c3d.org/data/Sample01.zip', 'sample01.zip'), 12 | ) 13 | 14 | 15 | class Zipload: 16 | 17 | @staticmethod 18 | def download(): 19 | if not os.path.isdir(TEST_FOLDER): 20 | os.makedirs(TEST_FOLDER) 21 | for url, target in ZIPS: 22 | fn = os.path.join(TEST_FOLDER, target) 23 | if not os.path.isfile(fn): 24 | print('Downloading: ', url) 25 | req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0 (X11; Linux x86_64) ' 26 | 'AppleWebKit/537.36 (KHTML, like Gecko) ' 27 | 'Chrome/51.0.2704.103 Safari/537.36', 28 | 'Accept': 'text/html,application/xhtml+xml,application/xml;' 29 | 'q=0.9,*/*;q=0.8'}) 30 | with urllib.request.urlopen(req) as response, open(fn, 'wb') as out_file: 31 | shutil.copyfileobj(response, out_file) 32 | print('... Complete') 33 | 34 | @staticmethod 35 | def extract(zf): 36 | out_path = os.path.join(TEST_FOLDER, os.path.basename(zf)[:-4]) 37 | 38 | zip = zipfile.ZipFile(os.path.join(TEST_FOLDER, zf)) 39 | # Loop equivalent to zip.extractall(out_path) but avoids overwriting files 40 | for zf in zip.namelist(): 41 | fpath = os.path.join(out_path, zf) 42 | # If file already exist, don't extract 43 | if not os.path.isfile(fpath) and not os.path.isdir(fpath): 44 | print('Extracted:', fpath) 45 | zip.extract(zf, path=out_path) 46 | 47 | @staticmethod 48 | def download_and_extract(): 49 | """ Download and extract all test files. 50 | """ 51 | Zipload.download() 52 | for url, target in ZIPS: 53 | Zipload.extract(target) 54 | 55 | @staticmethod 56 | def get_compressed_filenames(zf) -> list[str]: 57 | """ Find all .c3d files in the specified .zip file. 58 | """ 59 | with zipfile.ZipFile(os.path.join(TEST_FOLDER, zf)) as z: 60 | return [i for i in z.filelist 61 | if i.filename.lower().endswith('.c3d')] 62 | 63 | @staticmethod 64 | def get_c3d_filenames(dn: str): 65 | """ Find and iterate all .c3d files in the specified folder. 66 | 67 | Args 68 | ---- 69 | dn: Folder/directory name. 70 | """ 71 | root_folder = os.path.join(TEST_FOLDER, dn) 72 | for root, dirs, files in os.walk(root_folder, topdown=False): 73 | for fn in files: 74 | if fn.endswith('.c3d'): 75 | yield os.path.join(root, fn) 76 | 77 | @staticmethod 78 | def get_c3d_path(*args): 79 | return os.path.join(TEST_FOLDER, *args) 80 | 81 | @staticmethod 82 | def read_c3d(zf: str, fn: str) -> io.BytesIO: 83 | """ Access a IO stream for the specified file in the .zip folder. 84 | 85 | Args 86 | ---- 87 | zf: Name of the zipfile containing the file. 88 | fn: Filename to read within the zipfile. 89 | """ 90 | with zipfile.ZipFile(os.path.join(TEST_FOLDER, zf)) as z: 91 | return io.BytesIO(z.open(fn).read()) 92 | -------------------------------------------------------------------------------- /c3d/dtypes.py: -------------------------------------------------------------------------------- 1 | ''' 2 | State object defining the data types associated with a given .c3d processor format. 3 | ''' 4 | 5 | import sys 6 | import codecs 7 | import numpy as np 8 | 9 | PROCESSOR_INTEL = 84 10 | PROCESSOR_DEC = 85 11 | PROCESSOR_MIPS = 86 12 | 13 | 14 | class DataTypes(object): 15 | ''' Container defining the data types used when parsing byte data. 16 | Data types depend on the processor format the file is stored in. 17 | ''' 18 | def __init__(self, proc_type=PROCESSOR_INTEL): 19 | self._proc_type = proc_type 20 | self._little_endian_sys = sys.byteorder == 'little' 21 | self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \ 22 | (self.is_mips and self.big_endian_sys) 23 | if self.big_endian_sys: 24 | warnings.warn('Systems with native byte order of big-endian are not supported.') 25 | 26 | if self._proc_type == PROCESSOR_MIPS: 27 | # Big-Endian (SGI/MIPS format) 28 | self.float32 = np.dtype(np.float32).newbyteorder('>') 29 | self.float64 = np.dtype(np.float64).newbyteorder('>') 30 | self.uint8 = np.uint8 31 | self.uint16 = np.dtype(np.uint16).newbyteorder('>') 32 | self.uint32 = np.dtype(np.uint32).newbyteorder('>') 33 | self.uint64 = np.dtype(np.uint64).newbyteorder('>') 34 | self.int8 = np.int8 35 | self.int16 = np.dtype(np.int16).newbyteorder('>') 36 | self.int32 = np.dtype(np.int32).newbyteorder('>') 37 | self.int64 = np.dtype(np.int64).newbyteorder('>') 38 | else: 39 | # Little-Endian format (Intel or DEC format) 40 | self.float32 = np.float32 41 | self.float64 = np.float64 42 | self.uint8 = np.uint8 43 | self.uint16 = np.uint16 44 | self.uint32 = np.uint32 45 | self.uint64 = np.uint64 46 | self.int8 = np.int8 47 | self.int16 = np.int16 48 | self.int32 = np.int32 49 | self.int64 = np.int64 50 | 51 | @property 52 | def is_ieee(self) -> bool: 53 | ''' True if the associated file is in the Intel format. 54 | ''' 55 | return self._proc_type == PROCESSOR_INTEL 56 | 57 | @property 58 | def is_dec(self) -> bool: 59 | ''' True if the associated file is in the DEC format. 60 | ''' 61 | return self._proc_type == PROCESSOR_DEC 62 | 63 | @property 64 | def is_mips(self) -> bool: 65 | ''' True if the associated file is in the SGI/MIPS format. 66 | ''' 67 | return self._proc_type == PROCESSOR_MIPS 68 | 69 | @property 70 | def proc_type(self) -> str: 71 | ''' Get the processory type associated with the data format in the file. 72 | ''' 73 | processor_type = ['INTEL', 'DEC', 'MIPS'] 74 | return processor_type[self._proc_type - PROCESSOR_INTEL] 75 | 76 | @property 77 | def processor(self) -> int: 78 | ''' Get the processor number encoded in the .c3d file. 79 | ''' 80 | return self._proc_type 81 | 82 | @property 83 | def native(self) -> bool: 84 | ''' True if the native (system) byte order matches the file byte order. 85 | ''' 86 | return self._native 87 | 88 | @property 89 | def little_endian_sys(self) -> bool: 90 | ''' True if native byte order is little-endian. 91 | ''' 92 | return self._little_endian_sys 93 | 94 | @property 95 | def big_endian_sys(self) -> bool: 96 | ''' True if native byte order is big-endian. 97 | ''' 98 | return not self._little_endian_sys 99 | 100 | def decode_string(self, bytes) -> str: 101 | ''' Decode a byte array to a string. 102 | ''' 103 | # Attempt to decode using different decoders 104 | decoders = ['utf-8', 'latin-1'] 105 | for dec in decoders: 106 | try: 107 | return codecs.decode(bytes, dec) 108 | except UnicodeDecodeError: 109 | continue 110 | # Revert to using default decoder but replace characters 111 | return codecs.decode(bytes, decoders[0], 'replace') 112 | -------------------------------------------------------------------------------- /c3d/utils.py: -------------------------------------------------------------------------------- 1 | ''' Trailing utility functions. 2 | ''' 3 | import numpy as np 4 | import struct 5 | 6 | 7 | def is_integer(value): 8 | '''Check if value input is integer.''' 9 | return isinstance(value, (int, np.int32, np.int64)) 10 | 11 | 12 | def is_iterable(value): 13 | '''Check if value is iterable.''' 14 | return hasattr(value, '__iter__') 15 | 16 | 17 | def type_npy2struct(dtype): 18 | ''' Convert numpy dtype format to a struct package format string. 19 | ''' 20 | return dtype.byteorder + dtype.char 21 | 22 | 23 | def pack_labels(labels): 24 | ''' Static method used to pack and pad the set of `labels` strings before 25 | passing the output into a `c3d.group.Group.add_str`. 26 | 27 | Parameters 28 | ---------- 29 | labels : iterable 30 | List of strings to pack and pad into a single string suitable for encoding in a Parameter entry. 31 | 32 | Example 33 | ------- 34 | >>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3'] 35 | >>> param_str, label_max_size = Writer.pack_labels(labels) 36 | >>> writer.point_group.add_str('LABELS', 37 | 'Point labels.', 38 | label_str, 39 | label_max_size, 40 | len(labels)) 41 | 42 | Returns 43 | ------- 44 | param_str : str 45 | String containing `labels` packed into a single variable where 46 | each string is padded to match the longest `labels` string. 47 | label_max_size : int 48 | Number of bytes associated with the longest `label` string, all strings are padded to this length. 49 | ''' 50 | labels = np.ravel(labels) 51 | # Get longest label name 52 | label_max_size = 0 53 | label_max_size = max(label_max_size, np.max([len(label) for label in labels])) 54 | label_str = ''.join(label.ljust(label_max_size) for label in labels) 55 | return label_str, label_max_size 56 | 57 | 58 | class Decorator(object): 59 | '''Base class for extending (decorating) a python object. 60 | ''' 61 | def __init__(self, decoratee): 62 | self._decoratee = decoratee 63 | 64 | def __getattr__(self, name): 65 | return getattr(self._decoratee, name) 66 | 67 | 68 | def UNPACK_FLOAT_IEEE(uint_32): 69 | '''Unpacks a single 32 bit unsigned int to a IEEE float representation 70 | ''' 71 | return struct.unpack('f', struct.pack("I", uint_32))[0] 78 | 79 | 80 | def DEC_to_IEEE(uint_32): 81 | '''Convert the 32 bit representation of a DEC float to IEEE format. 82 | 83 | Params: 84 | ---- 85 | uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits. 86 | Returns : IEEE formated floating point of the same shape as the input. 87 | ''' 88 | # Follows the bit pattern found: 89 | # http://home.fnal.gov/~yang/Notes/ieee_vs_dec_float.txt 90 | # Further formating descriptions can be found: 91 | # http://www.irig106.org/docs/106-07/appendixO.pdf 92 | # In accodance with the first ref. first & second 16 bit words are placed 93 | # in a big endian 16 bit word representation, and needs to be inverted. 94 | # Second reference describe the DEC->IEEE conversion. 95 | 96 | # Warning! Unsure if NaN numbers are managed appropriately. 97 | 98 | # Shuffle the first two bit words from DEC bit representation to an ordered representation. 99 | # Note that the most significant fraction bits are placed in the first 7 bits. 100 | # 101 | # Below are the DEC layout in accordance with the references: 102 | # ___________________________________________________________________________________ 103 | # | Mantissa (16:0) | SIGN | Exponent (8:0) | Mantissa (23:17) | 104 | # ___________________________________________________________________________________ 105 | # |32- -16| 15 |14- -7|6- -0| 106 | # 107 | # Legend: 108 | # _______________________________________________________ 109 | # | Part (left bit of segment : right bit) | Part | .. 110 | # _______________________________________________________ 111 | # |Bit adress - .. - Bit adress | Bit adress - .. 112 | #### 113 | 114 | # Swap the first and last 16 bits for a consistent alignment of the fraction 115 | reshuffled = ((uint_32 & 0xFFFF0000) >> 16) | ((uint_32 & 0x0000FFFF) << 16) 116 | # After the shuffle each part are in little-endian and ordered as: SIGN-Exponent-Fraction 117 | exp_bits = ((reshuffled & 0xFF000000) - 1) & 0xFF000000 118 | reshuffled = (reshuffled & 0x00FFFFFF) | exp_bits 119 | return UNPACK_FLOAT_IEEE(reshuffled) 120 | 121 | 122 | def DEC_to_IEEE_BYTES(bytes): 123 | '''Convert byte array containing 32 bit DEC floats to IEEE format. 124 | 125 | Params: 126 | ---- 127 | bytes : Byte array where every 4 bytes represent a single precision DEC float. 128 | Returns : IEEE formated floating point of the same shape as the input. 129 | ''' 130 | 131 | # See comments in DEC_to_IEEE() for DEC format definition 132 | 133 | # Reshuffle 134 | bytes = memoryview(bytes) 135 | reshuffled = np.empty(len(bytes), dtype=np.dtype('B')) 136 | reshuffled[::4] = bytes[2::4] 137 | reshuffled[1::4] = bytes[3::4] 138 | reshuffled[2::4] = bytes[::4] 139 | # Decrement exponent by 2, if exp. > 1 140 | reshuffled[3::4] = bytes[1::4] + (np.bitwise_and(bytes[1::4], 0x7f) == 0) - 1 141 | 142 | # There are different ways to adjust for differences in DEC/IEEE representation 143 | # after reshuffle. Two simple methods are: 144 | # 1) Decrement exponent bits by 2, then convert to IEEE. 145 | # 2) Convert to IEEE directly and divide by four. 146 | # 3) Handle edge cases, expensive in python... 147 | # However these are simple methods, and do not accurately convert when: 148 | # 1) Exponent < 2 (without bias), impossible to decrement exponent without adjusting fraction/mantissa. 149 | # 2) Exponent == 0, DEC numbers are then 0 or undefined while IEEE is not. NaN are produced when exponent == 255. 150 | # Here method 1) is used, which mean that only small numbers will be represented incorrectly. 151 | 152 | return np.frombuffer(reshuffled.tobytes(), 153 | dtype=np.float32, 154 | count=int(len(bytes) / 4)) 155 | -------------------------------------------------------------------------------- /test/test_import_software_specific.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | import unittest 4 | 5 | import sys 6 | sys.path.append(os.path.join(os.path.dirname(__file__), "..")) 7 | 8 | 9 | class ImportC3DTestVicon(unittest.TestCase): 10 | 11 | def setUpClass(): 12 | from test.zipload import Zipload 13 | Zipload.download_and_extract() 14 | 15 | FILEPATH = Zipload.get_c3d_path('sample00', 'Vicon Motion Systems', 'TableTennis.c3d') 16 | 17 | # Parse file 18 | bpy.ops.import_anim.c3d(filepath=FILEPATH, print_file=False, perf_mon=False) 19 | # Fetch loaded objects 20 | obj = bpy.context.selected_objects[0] 21 | ImportC3DTestVicon.obj = obj 22 | ImportC3DTestVicon.action = obj.animation_data.action 23 | 24 | def test_A_channel_count(self): 25 | ''' Verify number action has channels 26 | ''' 27 | EXPECTED = 243*3 28 | ch_count = len(self.action.fcurves) 29 | self.assertEqual(ch_count, EXPECTED) 30 | 31 | def test_B_keyframe_count(self): 32 | ''' Verify each channel has keyframes 33 | ''' 34 | for fc in ImportC3DTestVicon.action.fcurves: 35 | self.assertGreater(len(fc.keyframe_points), 0) 36 | 37 | def test_C_tracker_labels(self): 38 | ''' Verify label array matches 39 | ''' 40 | LABELS = ['Table:Table1', 'Table:Table2', 'Table:Table3', 'Table:Table4', 'Table:Table5', 'Table:Table6', 41 | 'Table:Table7', 'Table:Table8', 'Table:Table9', 'Player01:RFHD', 'Player01:RBHD', 'Player01:LFHD', 42 | 'Player01:RFHD', 'Player01:C7', 'Player01:T10', 'Player01:CLAV', 'Player01:STRN', 'Player01:RBAK', 43 | 'Player01:LSHO', 'Player01:LUPA', 'Player01:LELB', 'Player01:LWRA', 'Player01:LFRM', 'Player01:LWRB', 44 | 'Player01:RSHO', 'Player01:RELB', 'Player01:RWRA', 'Player01:RFIN', 'Player01:RASI', 'Player01:RPSI', 45 | 'Player01:LKNE', 'Player01:LANK', 'Player01:LTOE', 'Player01:RKNE', 'Player01:RANK', 'Player01:RTOE', 46 | 'Player01:LFIN', 'Player01:RUPA', 'Player01:RFRM', 'Player01:RWRB', 'Player01:LASI', 'Player01:LPSI', 47 | 'Player01:LTHI', 'Player01:LTIB', 'Player01:LHEE', 'Player01:RTHI', 'Player01:RTIB', 'Player01:RHEE', 48 | 49 | 'Player02:RFHD', 'Player02:RBHD', 'Player02:LFHD', 'Player02:RFHD', 'Player02:C7', 'Player02:T10', 50 | 'Player02:CLAV', 'Player02:STRN', 'Player02:RBAK', 'Player02:LSHO', 'Player02:LUPA', 'Player02:LELB', 51 | 'Player02:LWRA', 'Player02:LFRM', 'Player02:LWRB', 'Player02:RSHO', 'Player02:RELB', 'Player02:RWRA', 52 | 'Player02:RFIN', 'Player02:RASI', 'Player02:RPSI', 'Player02:LKNE', 'Player02:LANK', 'Player02:LTOE', 53 | 'Player02:RKNE', 'Player02:RANK', 'Player02:RTOE', 'Player02:LFIN', 'Player02:RUPA', 'Player02:RFRM', 54 | 'Player02:RWRB', 'Player02:LASI', 'Player02:LPSI', 'Player02:LTHI', 'Player02:LTIB', 'Player02:LHEE', 55 | 'Player02:RTHI', 'Player02:RTIB', 'Player02:RHEE', 56 | 57 | 'Player01:PELA', 'Player01:PELP', 'Player01:LFEA', 'Player01:LFEP', 'Player01:LTIA', 'Player01:LTIP', 58 | 'Player01:LFOA', 'Player01:LFOP', 'Player01:LTOA', 'Player01:LTOP', 'Player01:RFEA', 'Player01:RFEP', 59 | 'Player01:RTIA', 'Player01:RTIP', 'Player01:RFOA', 'Player01:RFOP', 'Player01:RTOA', 'Player01:RTOP', 60 | 'Player01:HEDA', 'Player01:HEDP', 'Player01:LCLA', 'Player01:LCLP', 'Player01:RCLA', 'Player01:RCLP', 61 | 'Player01:TRXA', 'Player01:TRXP', 'Player01:LHUA', 'Player01:LHUP', 'Player01:LRAA', 'Player01:LRAP', 62 | 'Player01:LHNA', 'Player01:LHNP', 'Player01:RHUA', 'Player01:RHUP', 'Player01:RRAA', 'Player01:RRAP', 63 | 'Player01:RHNA', 'Player01:RHNP', 64 | 65 | 'Player01:PELO', 'Player01:PELL', 'Player01:LFEO', 'Player01:LFEL', 'Player01:LTIO', 'Player01:LTIL', 66 | 'Player01:LFOO', 'Player01:LFOL', 'Player01:LTOO', 'Player01:LTOL', 'Player01:RFEO', 'Player01:RFEL', 67 | 'Player01:RTIO', 'Player01:RTIL', 'Player01:RFOO', 'Player01:RFOL', 'Player01:RTOO', 'Player01:RTOL', 68 | 'Player01:HEDO', 'Player01:HEDL', 'Player01:LCLO', 'Player01:LCLL', 'Player01:RCLO', 'Player01:RCLL', 69 | 'Player01:TRXO', 'Player01:TRXL', 'Player01:LHUO', 'Player01:LHUL', 'Player01:LRAO', 'Player01:LRAL', 70 | 'Player01:LHNO', 'Player01:LHNL', 'Player01:RHUO', 'Player01:RHUL', 'Player01:RRAO', 'Player01:RRAL', 71 | 'Player01:RHNO', 'Player01:RHNL', 72 | 73 | 'Player02:PELA', 'Player02:PELP', 'Player02:LFEA', 'Player02:LFEP', 'Player02:LTIA', 'Player02:LTIP', 74 | 'Player02:LFOA', 'Player02:LFOP', 'Player02:LTOA', 'Player02:LTOP', 'Player02:RFEA', 'Player02:RFEP', 75 | 'Player02:RTIA', 'Player02:RTIP', 'Player02:RFOA', 'Player02:RFOP', 'Player02:RTOA', 'Player02:RTOP', 76 | 'Player02:HEDA', 'Player02:HEDP', 'Player02:LCLA', 'Player02:LCLP', 'Player02:RCLA', 'Player02:RCLP', 77 | 'Player02:TRXA', 'Player02:TRXP', 'Player02:LHUA', 'Player02:LHUP', 'Player02:LRAA', 'Player02:LRAP', 78 | 'Player02:LHNA', 'Player02:LHNP', 'Player02:RHUA', 'Player02:RHUP', 'Player02:RRAA', 'Player02:RRAP', 79 | 'Player02:RHNA', 'Player02:RHNP', 80 | 81 | 'Player02:PELO', 'Player02:PELL', 'Player02:LFEO', 'Player02:LFEL', 'Player02:LTIO', 'Player02:LTIL', 82 | 'Player02:LFOO', 'Player02:LFOL', 'Player02:LTOO', 'Player02:LTOL', 'Player02:RFEO', 'Player02:RFEL', 83 | 'Player02:RTIO', 'Player02:RTIL', 'Player02:RFOO', 'Player02:RFOL', 'Player02:RTOO', 'Player02:RTOL', 84 | 'Player02:HEDO', 'Player02:HEDL', 'Player02:LCLO', 'Player02:LCLL', 'Player02:RCLO', 'Player02:RCLL', 85 | 'Player02:TRXO', 'Player02:TRXL', 'Player02:LHUO', 'Player02:LHUL', 'Player02:LRAO', 'Player02:LRAL', 86 | 'Player02:LHNO', 'Player02:LHNL', 'Player02:RHUO', 'Player02:RHUL', 'Player02:RRAO', 'Player02:RRAL', 87 | 'Player02:RHNO', 'Player02:RHNL', 88 | 89 | 'Player01:CentreOfMass', 'Player01:CentreOfMassFloor', 'Player01:CentreOfMass', 90 | 'Player01:CentreOfMassFloor'] 91 | 92 | names = [fc_grp.name for fc_grp in ImportC3DTestVicon.action.groups] 93 | for label in LABELS: 94 | self.assertIn(label, names) 95 | 96 | 97 | if __name__ == '__main__': 98 | import sys 99 | sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []) 100 | unittest.main() 101 | -------------------------------------------------------------------------------- /c3d/header.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Defines the header class used for reading, writing and tracking metadata in the .c3d header. 3 | ''' 4 | import sys 5 | import struct 6 | import numpy as np 7 | from .utils import UNPACK_FLOAT_IEEE, DEC_to_IEEE 8 | 9 | 10 | class Header(object): 11 | '''Header information from a C3D file. 12 | 13 | Attributes 14 | ---------- 15 | event_block : int 16 | Index of the 512-byte block where labels (metadata) are found. 17 | parameter_block : int 18 | Index of the 512-byte block where parameters (metadata) are found. 19 | data_block : int 20 | Index of the 512-byte block where data starts. 21 | point_count : int 22 | Number of motion capture channels recorded in this file. 23 | analog_count : int 24 | Number of analog values recorded per frame of 3D point data. 25 | first_frame : int 26 | Index of the first frame of data. 27 | last_frame : int 28 | Index of the last frame of data. 29 | analog_per_frame : int 30 | Number of analog frames per frame of 3D point data. The analog frame 31 | rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE) 32 | times the analog per frame value. 33 | frame_rate : float 34 | The frame rate of the recording, in frames per second. 35 | scale_factor : float 36 | Multiply values in the file by this scale parameter. 37 | long_event_labels : bool 38 | max_gap : int 39 | 40 | .. note:: 41 | The ``scale_factor`` attribute is not used in Phasespace C3D files; 42 | instead, use the POINT.SCALE parameter. 43 | 44 | .. note:: 45 | The ``first_frame`` and ``last_frame`` header attributes are not used in 46 | C3D files generated by Phasespace. Instead, the first and last 47 | frame numbers are stored in the POINTS:ACTUAL_START_FIELD and 48 | POINTS:ACTUAL_END_FIELD parameters. 49 | ''' 50 | 51 | # Read/Write header formats, read values as unsigned ints rather then floats. 52 | BINARY_FORMAT_WRITE = ' 0 231 | self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong + 4])[0]) 232 | self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong + 4]) 233 | 234 | @property 235 | def events(self): 236 | ''' Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label). 237 | 238 | Note*: 239 | Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter. 240 | Frame 1 therefor has the time 0.0 in relation to the event timing. 241 | ''' 242 | return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags]) 243 | 244 | def encode_events(self, events): 245 | ''' Encode event data in the event block. 246 | 247 | Parameters 248 | ---------- 249 | events : [(float, str), ...] 250 | Event data, iterable of touples containing the event timing and a 4 character label string. 251 | Event timings should be calculated relative to sample 1 with the timing 0.0s, and should 252 | not be relative to the first_frame header parameter. 253 | ''' 254 | endian = '<' 255 | if sys.byteorder == 'big': 256 | endian = '>' 257 | 258 | # Event block format 259 | fmt = '{}{}{}{}{}'.format(endian, 260 | str(18 * 4) + 's', # Timings 261 | str(18) + 's', # Flags 262 | 'H', # __ 263 | str(18 * 4) + 's' # Labels 264 | ) 265 | # Pack bytes 266 | event_timings = np.zeros(18, dtype=np.float32) 267 | event_disp_flags = np.zeros(18, dtype=np.uint8) 268 | event_labels = np.empty(18, dtype=object) 269 | label_bytes = bytearray(18 * 4) 270 | for i, (time, label) in enumerate(events): 271 | if i > 17: 272 | # Don't raise Error, header events are rarely used. 273 | warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.') 274 | break 275 | 276 | event_timings[i] = time 277 | event_labels[i] = label 278 | label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8') 279 | 280 | write_count = min(i + 1, 18) 281 | event_disp_flags[:write_count] = 1 282 | 283 | # Update event headers in self 284 | self.long_event_labels = 0x3039 # Magic number 285 | self.event_count = write_count 286 | # Update event block 287 | self.event_timings = event_timings[:write_count] 288 | self.event_disp_flags = np.ones(write_count, dtype=bool) 289 | self.event_labels = event_labels[:write_count] 290 | self.event_block = struct.pack(fmt, 291 | event_timings.tobytes(), 292 | event_disp_flags.tobytes(), 293 | 0, 294 | label_bytes 295 | ) 296 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | # Script copyright (C) Mattias Fredriksson 20 | 21 | # pep8 compliancy: 22 | # flake8 --ignore E402,F821,F722 .\__init__.py 23 | 24 | bl_info = { 25 | "name": "C3D format", 26 | "author": "Mattias Fredriksson", 27 | "version": (0, 1, 0), 28 | "blender": (2, 83, 0), 29 | "location": "File > Import", 30 | "description": "Imports C3D Optical Motion Capture (.c3d) files, animated Point Cloud data", 31 | "warning": "", 32 | "doc_url": "", 33 | "tracker_url": "https://github.com/MattiasFredriksson/io_anim_c3d/issues", 34 | "category": "Import-Export", 35 | } 36 | 37 | ####################### 38 | # Import & Reload Package 39 | ####################### 40 | if "bpy" in locals(): 41 | import importlib 42 | # Ensure dependency order is correct, to ensure a dependency is updated it must be reloaded first. 43 | # If imports are done in functions the modules seem to be linked correctly however. 44 | # --- 45 | # Reload subdirectory package? 46 | if "c3d" in locals(): 47 | importlib.reload(c3d) 48 | # Reload the sub-pacakge modules. 49 | from .c3d import reload as reload_sub 50 | reload_sub() 51 | # --- 52 | # Reload directory modules. 53 | if "pyfuncs" in locals(): 54 | importlib.reload(pyfuncs) 55 | if "perfmon" in locals(): 56 | importlib.reload(perfmon) 57 | if "c3d_parse_dictionary" in locals(): 58 | importlib.reload(c3d_parse_dictionary) 59 | if "c3d_importer" in locals(): 60 | importlib.reload(c3d_importer) 61 | 62 | import bpy 63 | from bpy.props import ( 64 | StringProperty, 65 | BoolProperty, 66 | IntProperty, 67 | FloatProperty, 68 | EnumProperty, 69 | CollectionProperty, 70 | ) 71 | from bpy_extras.io_utils import ( 72 | ImportHelper, 73 | # ExportHelper, 74 | orientation_helper, 75 | ) 76 | 77 | ####################### 78 | # Operator definition 79 | ####################### 80 | 81 | 82 | @orientation_helper(axis_forward='-Z', axis_up='Y') 83 | class ImportC3D(bpy.types.Operator, ImportHelper): 84 | """Load a C3D file 85 | """ 86 | bl_idname = "import_anim.c3d" 87 | bl_label = "Import C3D" 88 | bl_options = {'UNDO', 'PRESET'} 89 | 90 | # ----- 91 | # Parameters received from the file selection window through the ImportHelper. 92 | # ----- 93 | directory: StringProperty() 94 | 95 | # File extesion specification and filter. 96 | filename_ext = ".c3d" 97 | filter_glob: StringProperty(default='*' + filename_ext, options={'HIDDEN'}) 98 | 99 | # Properties 100 | files: CollectionProperty( 101 | name="File Path", 102 | type=bpy.types.OperatorFileListElement, 103 | ) 104 | 105 | # ----- 106 | # Primary import settings 107 | # ----- 108 | adapt_frame_rate: BoolProperty( 109 | name="Frame Rate", 110 | description="Adjust keyframes to match the sample rate of the current Blender scene. " + 111 | "If False, frames will be inserted in 1 frame increments", 112 | default=True, 113 | ) 114 | 115 | fake_user: BoolProperty( 116 | name="Fake User", 117 | description="Set the fake user flag for imported action sequence(s). " + 118 | "Fake user ensures imported sequences will be saved even if unused", 119 | default=False, 120 | ) 121 | 122 | include_event_markers: BoolProperty( 123 | name="Include Event Markers", 124 | description="Add labeled events as 'pose markers' to the action sequence. Markers are only visible " + 125 | "if the setting: Marker > Show Pose Markers is enabled in the Action Editor", 126 | default=True, 127 | ) 128 | 129 | include_empty_labels: BoolProperty( 130 | name="Include Empty Labels", 131 | description="Include channels for POINT labels without valid keyframes", 132 | default=False, 133 | ) 134 | 135 | # Interpolation settings (link below), there is such thing as to many settings so ignored ones 136 | # seemingly redundant. 137 | # https://docs.blender.org/api/current/bpy.types.Keyframe.html#bpy.types.Keyframe.interpolation 138 | interpolation: EnumProperty(items=( 139 | ('CONSTANT', "Constant", "Constant (or no interpolation)"), 140 | ('LINEAR', "Linear", "Linear interpolation"), 141 | ('BEZIER', "Bezier", "Smooth interpolation with some control over curve shape"), 142 | # ('SINE', "Sinusoidal", "Sinusoidal easing (weakest, almost linear but with a slight curvature)"), 143 | ('QUAD', "Quadratic", "Quadratic easing"), 144 | ('CUBIC', "Cubic", "Cubic easing"), 145 | # ('QUART', "Quartic", "Quartic easing"), 146 | # ('QUINT', "Quintic", "Quintic easing"), 147 | ('CIRC', "Circular", "Circular easing (strongest and most dynamic)"), 148 | # ('BOUNCE', "Bounce", "Exponentially decaying parabolic bounce, like when objects collide"), 149 | # Options with specific settings 150 | # ('BACK', "Back", "Cubic easing with overshoot and settle"), 151 | # ('ELASTIC', "Elastic", "Exponentially decaying sine wave, like an elastic band"), 152 | ), 153 | name="Interpolation", 154 | description="Keyframe interpolation", 155 | default='BEZIER' 156 | ) 157 | 158 | # It should be noted that the standard states two custom representations: 159 | # 0: 'indicates that the 3D point coordinate is the result of modeling 160 | # calculations, interpolation, or filtering' 161 | # -1: 'is used to indicate that a point is invalid' 162 | max_residual: FloatProperty( 163 | name="Max. Residual", default=0.0, 164 | description="Ignore samples with a residual greater then the specified value. If the value is equal " + 165 | "to 0, all valid samples will be included. Not all files record marker residuals", 166 | min=0., max=1000000.0, 167 | soft_min=0., soft_max=100.0, 168 | ) 169 | 170 | # ----- 171 | # Armature settings 172 | # ----- 173 | create_armature: BoolProperty( 174 | name="Create Armature", 175 | description="Create an armature object to display markers in the animated point cloud", 176 | default=True, 177 | ) 178 | 179 | bone_size: FloatProperty( 180 | name="Marker Size", default=0.02, 181 | description="Define the width of each marker bone", 182 | min=0.001, max=10.0, 183 | soft_min=0.01, soft_max=1.0, 184 | ) 185 | 186 | # ----- 187 | # Transformation settings (included to allow manual modification of spatial data in the loading process). 188 | # ----- 189 | global_scale: FloatProperty( 190 | name="Scale", 191 | description="Scaling factor applied to geometric (spatial) data, multiplied with other embedded factors", 192 | min=0.001, max=1000.0, 193 | default=1.0, 194 | ) 195 | 196 | use_manual_orientation: BoolProperty( 197 | name="Manual Orientation", 198 | description="Specify orientation manually rather then use information embedded in the file", 199 | default=False, 200 | ) 201 | 202 | # ----- 203 | # Debug settings. 204 | # ----- 205 | print_file: BoolProperty( 206 | name="Print Metadata", 207 | description="Print file metadata to console", 208 | default=False, 209 | ) 210 | 211 | perf_mon: BoolProperty( 212 | name="Monitor Performance", 213 | description="Print import timings to console", 214 | default=True, 215 | ) 216 | 217 | def draw(self, context): 218 | pass 219 | 220 | def execute(self, context): 221 | keywords = self.as_keywords(ignore=("filter_glob", "directory", "ui_tab", "filepath", "files")) 222 | 223 | from . import c3d_importer 224 | import os 225 | 226 | if self.files: 227 | failed = [] 228 | for file in self.files: 229 | path = os.path.join(self.directory, file.name) 230 | try: 231 | msg = c3d_importer.load(self, context, filepath=path, **keywords) 232 | if msg != {'FINISHED'}: 233 | failed.append(path) 234 | except Exception as e: 235 | import traceback 236 | print('') 237 | traceback.print_exc() 238 | print('') 239 | failed.append(path) 240 | 241 | # Report any file issue(s) 242 | if failed: 243 | failed_files = '' 244 | for path in failed: 245 | failed_files += '\n' + path 246 | if len(failed) == len(self.files): 247 | self.report( 248 | {'ERROR'}, 249 | 'Failed to load any of the .bvh files(s):%s' % failed_files 250 | ) 251 | return {'CANCELLED'} 252 | else: 253 | self.report( 254 | {'WARNING'}, 255 | 'Failed loading .bvh files(s):%s' % failed_files 256 | ) 257 | return {'FINISHED'} 258 | else: 259 | return c3d_importer.load(self, context, filepath=self.filepath, **keywords) 260 | 261 | 262 | ####################### 263 | # Panels 264 | ###################### 265 | 266 | class C3D_PT_action(bpy.types.Panel): 267 | bl_space_type = 'FILE_BROWSER' 268 | bl_region_type = 'TOOL_PROPS' 269 | bl_label = "Import Action" 270 | bl_parent_id = "FILE_PT_operator" 271 | 272 | @classmethod 273 | def poll(cls, context): 274 | sfile = context.space_data 275 | operator = sfile.active_operator 276 | 277 | return operator.bl_idname == "IMPORT_ANIM_OT_c3d" 278 | 279 | def draw(self, context): 280 | layout = self.layout 281 | layout.use_property_split = True 282 | layout.use_property_decorate = False # No animation. 283 | 284 | sfile = context.space_data 285 | operator = sfile.active_operator 286 | 287 | layout.prop(operator, "adapt_frame_rate") 288 | layout.prop(operator, "fake_user") 289 | layout.prop(operator, "include_event_markers") 290 | layout.prop(operator, "include_empty_labels") 291 | layout.prop(operator, "interpolation") 292 | layout.prop(operator, "max_residual") 293 | 294 | 295 | class C3D_PT_marker_armature(bpy.types.Panel): 296 | bl_space_type = 'FILE_BROWSER' 297 | bl_region_type = 'TOOL_PROPS' 298 | bl_label = "Create Armature" 299 | bl_parent_id = "FILE_PT_operator" 300 | bl_options = {'DEFAULT_CLOSED'} 301 | 302 | @classmethod 303 | def poll(cls, context): 304 | sfile = context.space_data 305 | operator = sfile.active_operator 306 | 307 | return operator.bl_idname == "IMPORT_ANIM_OT_c3d" 308 | 309 | def draw_header(self, context): 310 | sfile = context.space_data 311 | operator = sfile.active_operator 312 | 313 | self.layout.prop(operator, "create_armature", text="") 314 | 315 | def draw(self, context): 316 | layout = self.layout 317 | layout.use_property_split = True 318 | layout.use_property_decorate = False # No animation. 319 | 320 | sfile = context.space_data 321 | operator = sfile.active_operator 322 | 323 | layout.enabled = operator.create_armature 324 | 325 | layout.prop(operator, "bone_size") 326 | 327 | 328 | class C3D_PT_import_transform(bpy.types.Panel): 329 | bl_space_type = 'FILE_BROWSER' 330 | bl_region_type = 'TOOL_PROPS' 331 | bl_label = "Transform" 332 | bl_parent_id = "FILE_PT_operator" 333 | 334 | @classmethod 335 | def poll(cls, context): 336 | sfile = context.space_data 337 | operator = sfile.active_operator 338 | 339 | return operator.bl_idname == "IMPORT_ANIM_OT_c3d" 340 | 341 | def draw(self, context): 342 | layout = self.layout 343 | layout.use_property_split = True 344 | layout.use_property_decorate = False # No animation. 345 | 346 | sfile = context.space_data 347 | operator = sfile.active_operator 348 | 349 | layout.prop(operator, "global_scale") 350 | 351 | 352 | class C3D_PT_import_transform_manual_orientation(bpy.types.Panel): 353 | bl_space_type = 'FILE_BROWSER' 354 | bl_region_type = 'TOOL_PROPS' 355 | bl_label = "Manual Orientation" 356 | bl_parent_id = "C3D_PT_import_transform" 357 | 358 | @classmethod 359 | def poll(cls, context): 360 | sfile = context.space_data 361 | operator = sfile.active_operator 362 | 363 | return operator.bl_idname == "IMPORT_ANIM_OT_c3d" 364 | 365 | def draw_header(self, context): 366 | sfile = context.space_data 367 | operator = sfile.active_operator 368 | 369 | self.layout.prop(operator, "use_manual_orientation", text="") 370 | 371 | def draw(self, context): 372 | layout = self.layout 373 | layout.use_property_split = True 374 | layout.use_property_decorate = False # No animation. 375 | 376 | sfile = context.space_data 377 | operator = sfile.active_operator 378 | 379 | layout.enabled = operator.use_manual_orientation 380 | 381 | layout.prop(operator, "axis_forward") 382 | layout.prop(operator, "axis_up") 383 | 384 | 385 | class C3D_PT_debug(bpy.types.Panel): 386 | bl_space_type = 'FILE_BROWSER' 387 | bl_region_type = 'TOOL_PROPS' 388 | bl_label = "Console" 389 | bl_parent_id = "FILE_PT_operator" 390 | bl_options = {'DEFAULT_CLOSED'} 391 | 392 | @classmethod 393 | def poll(cls, context): 394 | sfile = context.space_data 395 | operator = sfile.active_operator 396 | 397 | return operator.bl_idname == "IMPORT_ANIM_OT_c3d" 398 | 399 | def draw(self, context): 400 | layout = self.layout 401 | layout.use_property_split = True 402 | layout.use_property_decorate = False # No animation. 403 | 404 | sfile = context.space_data 405 | operator = sfile.active_operator 406 | 407 | layout.prop(operator, "print_file") 408 | layout.prop(operator, "load_mem_efficient") 409 | 410 | ####################### 411 | # Register Menu Items 412 | ####################### 413 | 414 | 415 | def menu_func_import(self, context): 416 | self.layout.operator(ImportC3D.bl_idname, text="C3D (.c3d)") 417 | 418 | # def menu_func_export(self, context): 419 | # self.layout.operator(ExportC3D.bl_idname, text="C3D (.c3d)") 420 | 421 | ####################### 422 | # Register Operator 423 | ####################### 424 | 425 | 426 | classes = ( 427 | ImportC3D, 428 | C3D_PT_action, 429 | C3D_PT_marker_armature, 430 | C3D_PT_import_transform, 431 | C3D_PT_import_transform_manual_orientation, 432 | C3D_PT_debug, 433 | # ExportC3D, 434 | ) 435 | 436 | 437 | def register(): 438 | for cl in classes: 439 | bpy.utils.register_class(cl) 440 | 441 | bpy.types.TOPBAR_MT_file_import.append(menu_func_import) 442 | # bpy.types.TOPBAR_MT_file_export.append(menu_func_export) 443 | 444 | 445 | def unregister(): 446 | bpy.types.TOPBAR_MT_file_import.remove(menu_func_import) 447 | # bpy.types.TOPBAR_MT_file_export.remove(menu_func_export) 448 | 449 | for cl in classes: 450 | bpy.utils.unregister_class(cl) 451 | 452 | 453 | if __name__ == "__main__": 454 | register() 455 | -------------------------------------------------------------------------------- /c3d_importer.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # io_anim_c3d is is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | # Script copyright (C) Mattias Fredriksson 20 | 21 | # pep8 compliancy: 22 | # flake8 .\c3d_importer.py 23 | 24 | import mathutils 25 | import bpy 26 | import os 27 | import numpy as np 28 | from .pyfuncs import islist 29 | 30 | 31 | def load(operator, context, filepath="", 32 | use_manual_orientation=False, 33 | axis_forward='-Z', 34 | axis_up='Y', 35 | global_scale=1.0, 36 | create_armature=True, 37 | bone_size=0.02, 38 | adapt_frame_rate=True, 39 | fake_user=True, 40 | interpolation='BEZIER', 41 | max_residual=0.0, 42 | include_event_markers=False, 43 | include_empty_labels=False, 44 | apply_label_mask=True, 45 | print_file=False, 46 | perf_mon=True): 47 | 48 | # Load more modules/packages once the importer is used 49 | from bpy_extras.io_utils import axis_conversion 50 | from .c3d_parse_dictionary import C3DParseDictionary 51 | from . import perfmon 52 | 53 | # Define the action id from the filename 54 | file_id = os.path.basename(filepath) 55 | file_name = os.path.splitext(file_id)[0] 56 | 57 | # Monitor performance 58 | perfmon = perfmon.new_monitor(print_output=perf_mon) 59 | perfmon.level_up('Importing: %s ...' % file_id, True) 60 | 61 | # Open file and read .c3d parameter headers 62 | with C3DParseDictionary(filepath) as parser: 63 | if print_file: 64 | parser.print_file() 65 | if parser.reader.point_used == 0: 66 | operator.report({'WARNING'}, 'No POINT data in file: %s' % filepath) 67 | return {'CANCELLED'} 68 | 69 | # Factor converting . 70 | conv_fac_frame_rate = 1.0 71 | if adapt_frame_rate: 72 | conv_fac_frame_rate = bpy.context.scene.render.fps / parser.frame_rate 73 | 74 | # Conversion factor for length measurements. 75 | blend_units = 'm' 76 | conv_fac_spatial_unit = parser.unit_conversion('POINT', sys_unit=blend_units) 77 | 78 | # World orientation adjustment. 79 | scale = global_scale * conv_fac_spatial_unit 80 | if use_manual_orientation: 81 | global_orient = axis_conversion(from_forward=axis_forward, from_up=axis_up) 82 | global_orient = global_orient @ mathutils.Matrix.Scale(scale, 3) 83 | # Convert orientation to a numpy array (3x3 rotation matrix). 84 | global_orient = np.array(global_orient) 85 | else: 86 | global_orient, parsed_screen_param = parser.axis_interpretation([0, 0, 1], [0, 1, 0]) 87 | global_orient *= scale # Uniformly scale the axis. 88 | 89 | if not parsed_screen_param: 90 | operator.report({'INFO'}, 'Unable to parse X/Y_SCREEN information for POINT data, ' + 91 | 'manual adjustment to orientation may be necessary.') 92 | 93 | # Read labels, remove labels matching hard-coded criteria 94 | # regarding the software used to generate the file. 95 | labels = parser.point_labels() 96 | if apply_label_mask: 97 | point_mask = parser.generate_label_mask(labels, 'POINT') 98 | else: 99 | point_mask = np.ones(np.shape(labels), bool) 100 | labels = C3DParseDictionary.make_labels_unique(labels[point_mask]) 101 | # Equivalent to the number of channels used in POINT data. 102 | nlabels = len(labels) 103 | if nlabels == 0: 104 | operator.report({'WARNING'}, 'All POINT data was culled in file: %s' % filepath) 105 | return {'CANCELLED'} 106 | 107 | # Number of frames [first, last] => +1. 108 | # first_frame is the frame index to start parsing from. 109 | # nframes is the number of frames to parse. 110 | first_frame = parser.first_frame 111 | nframes = parser.last_frame - first_frame + 1 112 | perfmon.message('Parsing: %i frames...' % nframes) 113 | 114 | # 1. Create an action to hold keyframe data. 115 | # 2. Generate location (x,y,z) F-Curves for each label. 116 | # 3. Format the curve list in sets of 3, each set associate with the x/y/z channels. 117 | action = create_action(file_name, fake_user=fake_user) 118 | blen_curves_arr = generate_blend_curves(action, labels, 3, 'pose.bones["%s"].location') 119 | blen_curves = np.array(blen_curves_arr).reshape(nlabels, 3) 120 | 121 | # Load 122 | read_data(parser, blen_curves, labels, point_mask, global_orient, 123 | first_frame, nframes, conv_fac_frame_rate, 124 | interpolation, max_residual, 125 | perfmon) 126 | 127 | # Remove labels with no valid keyframes. 128 | if not include_empty_labels: 129 | clean_empty_fcurves(action) 130 | # Since we inserted our keyframes in 'FAST' mode, its best to update the fcurves now. 131 | for fc in action.fcurves: 132 | fc.update() 133 | if action.fcurves == 0: 134 | remove_action(action) 135 | # All samples were either invalid or was previously culled in regard to the channel label. 136 | operator.report({'WARNING'}, 'No valid POINT data in file: %s' % filepath) 137 | return {'CANCELLED'} 138 | 139 | # Parse events in the file (if specified). 140 | if include_event_markers: 141 | read_events(operator, parser, action, conv_fac_frame_rate) 142 | 143 | # Create an armature matching keyframed data (if specified). 144 | arm_obj = None 145 | bone_radius = bone_size * 0.5 146 | if create_armature: 147 | final_labels = [fc_grp.name for fc_grp in action.groups] 148 | arm_obj = create_armature_object(context, file_name, 'BBONE') 149 | add_empty_armature_bones(context, arm_obj, final_labels, bone_size) 150 | # Set the width of the bbones. 151 | for bone in arm_obj.data.bones: 152 | bone.bbone_x = bone_radius 153 | bone.bbone_z = bone_radius 154 | # Set the created action as active for the armature. 155 | set_action(arm_obj, action, replace=False) 156 | 157 | perfmon.level_down("Import finished.") 158 | 159 | bpy.context.view_layer.update() 160 | return {'FINISHED'} 161 | 162 | 163 | def read_events(operator, parser, action, conv_fac_frame_rate): 164 | ''' Read events from the loaded c3d file and add them as 'pose_markers' to the action. 165 | ''' 166 | try: 167 | for (frame, label) in parser.events(): 168 | marker = action.pose_markers.new(label) 169 | marker.frame = int(np.round(frame * conv_fac_frame_rate)) 170 | except ValueError as e: 171 | operator.report({'WARNING'}, str(e)) 172 | except TypeError as e: 173 | operator.report({'WARNING'}, str(e)) 174 | 175 | 176 | def read_data(parser, blen_curves, labels, point_mask, global_orient, 177 | first_frame, nframes, conv_fac_frame_rate, 178 | interpolation, max_residual, 179 | perfmon): 180 | ''' Read valid POINT data from the file and create action keyframes. 181 | ''' 182 | nlabels = len(labels) 183 | 184 | # Generate numpy arrays to store POINT data from each frame before creating keyframes. 185 | point_frames = np.zeros([nframes, 3, nlabels], dtype=np.float32) 186 | valid_samples = np.empty([nframes, nlabels], dtype=bool) 187 | 188 | ## 189 | # Start reading POINT blocks (and analog, but analog signals from force plates etc. are not supported). 190 | perfmon.level_up('Reading POINT data..', True) 191 | for i, points, analog in parser.reader.read_frames(copy=False): 192 | index = i - first_frame 193 | # Apply masked samples. 194 | points = points[point_mask] 195 | # Determine valid samples 196 | valid = points[:, 3] >= 0.0 197 | if max_residual > 0.0: 198 | valid = np.logical_and(points[:, 3] < max_residual, valid) 199 | valid_samples[index] = valid 200 | 201 | # Extract position coordinates from columns 0:3. 202 | point_frames[index] = points[:, :3].T 203 | 204 | # Re-orient and scale the data. 205 | point_frames = np.matmul(global_orient, point_frames) 206 | 207 | perfmon.level_down('Reading Done.') 208 | 209 | ## 210 | # Time to generate keyframes. 211 | perfmon.level_up('Keyframing POINT data..', True) 212 | # Number of valid keys for each label. 213 | nkeys = np.sum(valid_samples, axis=0) 214 | frame_range = np.arange(0, nframes) 215 | # Iterate each group (tracker label). 216 | for label_ind, fc_set in enumerate(blen_curves): 217 | # Create keyframes. 218 | nlabel_keys = nkeys[label_ind] 219 | for fc in fc_set: 220 | fc.keyframe_points.add(nlabel_keys) 221 | 222 | # Iterate valid frames and insert keyframes. 223 | frame_indices = frame_range[valid_samples[:, label_ind]] 224 | 225 | for dim, fc in enumerate(fc_set): 226 | keyframes = np.empty((nlabel_keys, 2), dtype=np.float32) 227 | keyframes[:, 0] = frame_indices * conv_fac_frame_rate 228 | keyframes[:, 1] = point_frames[frame_indices, dim, label_ind] 229 | fc.keyframe_points.foreach_set('co', keyframes.ravel()) 230 | 231 | if interpolation != 'BEZIER': # Bezier is default 232 | for label_ind, fc_set in enumerate(blen_curves): 233 | for fc in fc_set: 234 | for kf in fc.keyframe_points: 235 | kf.interpolation = interpolation 236 | 237 | perfmon.level_down('Keyframing Done.') 238 | 239 | 240 | def create_action(action_name, object=None, fake_user=False): 241 | ''' Create new action. 242 | 243 | Params: 244 | ----- 245 | action_name: Name for the action 246 | object: Set the action as the active animation data for the object. 247 | fake_user: Set the 'Fake User' flag for the action. 248 | ''' 249 | 250 | action = bpy.data.actions.new(action_name) 251 | action.use_fake_user = fake_user 252 | 253 | # If none yet assigned, assign this action to id_data. 254 | if object: 255 | set_action(object, action, replace=False) 256 | return action 257 | 258 | 259 | def remove_action(action): 260 | ''' Delete a specific action. 261 | ''' 262 | bpy.data.actions.remove(action) 263 | 264 | 265 | def set_action(object, action, replace=True): 266 | ''' Set the action associated with the object. 267 | ----- 268 | object: Object for which the animation should be set. 269 | action: Action to set for the object. 270 | replace: If False, existing action set for the object will not be replaced. 271 | ''' 272 | if not object.animation_data: 273 | object.animation_data_create() 274 | if replace or not object.animation_data.action: 275 | object.animation_data.action = action 276 | 277 | 278 | def create_armature_object(context, name, display_type='OCTAHEDRAL'): 279 | ''' Create an 'ARMATURE' object and add to active layer 280 | 281 | Params: 282 | ----- 283 | context: Blender Context 284 | name: Name for the object 285 | display_type: Display type for the armature bones. 286 | ''' 287 | arm_data = bpy.data.armatures.new(name=name) 288 | arm_data.display_type = display_type 289 | 290 | arm_obj = bpy.data.objects.new(name=name, object_data=arm_data) 291 | 292 | # Instance in scene. 293 | context.view_layer.active_layer_collection.collection.objects.link(arm_obj) 294 | return arm_obj 295 | 296 | 297 | def enter_clean_object_mode(): 298 | ''' Enter object mode and clear any selection. 299 | ''' 300 | # Try to enter object mode, polling active object is unreliable since an object can be in edit mode but not active! 301 | try: 302 | bpy.ops.object.mode_set(mode='OBJECT', toggle=False) 303 | except RuntimeError: 304 | pass 305 | # Clear any selection 306 | bpy.ops.object.select_all(action='DESELECT') 307 | 308 | 309 | def add_empty_armature_bones(context, arm_obj, bone_names, length=0.1): 310 | ''' 311 | Generate a set of named bones 312 | 313 | Params: 314 | ---- 315 | context: bpy.context 316 | arm_obj: Armature object 317 | length: Length of each bone. 318 | ''' 319 | 320 | assert arm_obj.type == 'ARMATURE', "Object passed to 'add_empty_armature_bones()' must be an armature." 321 | 322 | # Enter object mode. 323 | enter_clean_object_mode() 324 | # Enter edit mode for the armature. 325 | arm_obj.select_set(True) 326 | bpy.context.view_layer.objects.active = arm_obj 327 | bpy.ops.object.mode_set(mode='EDIT', toggle=False) 328 | 329 | edit_bones = arm_obj.data.edit_bones 330 | 331 | if not islist(bone_names): 332 | bone_names = [bone_names] 333 | 334 | for name in bone_names: 335 | # Create a new bone with name. 336 | b = edit_bones.new(name) 337 | b.head = (0.0, 0.0, 0.0) 338 | b.tail = (0.0, 0.0, length) 339 | 340 | bpy.ops.object.mode_set(mode='OBJECT') 341 | 342 | 343 | def generate_blend_curves(action, labels, grp_channel_count, fc_data_path_str): 344 | ''' 345 | Generate F-Curves for the action. 346 | 347 | Parameters 348 | ---- 349 | action: bpy.types.Action object to generate F-curves for. 350 | labels: String label(s) for generated F-Curves, an action group is generated for each label. 351 | grp_channel_count: Number of channels generated for each label (group). 352 | fc_data_path_str: Unformated data path string used to define the F-Curve data path. If a string format 353 | operator (%s) is contained within the string it will be replaced with the label. 354 | 355 | Valid args are: 356 | ---- 357 | Object anim: 358 | 'location', 'scale', 'rotation_quaternion', 'rotation_axis_angle', 'rotation_euler' 359 | Bone anim: 360 | 'pose.bones["%s"].location' 361 | 'pose.bones["%s"].scale' 362 | 'pose.bones["%s"].rotation_quaternion' 363 | 'pose.bones["%s"].rotation_axis_angle' 364 | 'pose.bones["%s"].rotation_euler' 365 | 366 | ''' 367 | 368 | # Convert a single label to an iterable tuple (list). 369 | if not islist(labels): 370 | labels = (labels) 371 | 372 | # Generate channels for each label to hold location information. 373 | if '%s' not in fc_data_path_str: 374 | # No format operator found in the data_path_str used to define F-curves. 375 | blen_curves = [action.fcurves.new(fc_data_path_str, index=i, action_group=label) 376 | for label in labels for i in range(grp_channel_count)] 377 | else: 378 | # Format operator found, replace it with label associated with the created F-Curve. 379 | blen_curves = [action.fcurves.new(fc_data_path_str % label, index=i, action_group=label) 380 | for label in labels for i in range(grp_channel_count)] 381 | return blen_curves 382 | 383 | 384 | def clean_empty_fcurves(action): 385 | ''' 386 | Remove any F-Curve in the action with no keyframes. 387 | 388 | Parameters 389 | ---- 390 | action: bpy.types.Action object to clean F-curves. 391 | 392 | ''' 393 | empty_curves = [] 394 | for curve in action.fcurves: 395 | if len(curve.keyframe_points) == 0: 396 | empty_curves.append(curve) 397 | 398 | for curve in empty_curves: 399 | action.fcurves.remove(curve) 400 | -------------------------------------------------------------------------------- /c3d/group.py: -------------------------------------------------------------------------------- 1 | ''' Classes used to represent the concept of parameter groups in a .c3d file. 2 | ''' 3 | import struct 4 | import numpy as np 5 | from .parameter import ParamData, Param 6 | from .utils import Decorator 7 | 8 | 9 | class GroupData(object): 10 | '''A group of parameters stored in a C3D file. 11 | 12 | In C3D files, parameters are organized in groups. Each group has a name (key), a 13 | description, and a set of named parameters. Each group is also internally associated 14 | with a numeric key. 15 | 16 | Attributes 17 | ---------- 18 | dtypes : `c3d.dtypes.DataTypes` 19 | Data types object used for parsing. 20 | name : str 21 | Name of this parameter group. 22 | desc : str 23 | Description for this parameter group. 24 | ''' 25 | 26 | def __init__(self, dtypes, name=None, desc=None): 27 | self._params = {} 28 | self._dtypes = dtypes 29 | # Assign through property setters 30 | self.set_name(name) 31 | self.set_desc(desc) 32 | 33 | def __repr__(self): 34 | return ''.format(self.desc) 35 | 36 | def __contains__(self, key): 37 | return key in self._params 38 | 39 | def __getitem__(self, key): 40 | return self._params[key] 41 | 42 | @property 43 | def binary_size(self) -> int: 44 | '''Return the number of bytes to store this group and its parameters.''' 45 | return ( 46 | 1 + # group_id 47 | 1 + len(self.name.encode('utf-8')) + # size of name and name bytes 48 | 2 + # next offset marker 49 | 1 + len(self.desc.encode('utf-8')) + # size of desc and desc bytes 50 | sum(p.binary_size for p in self._params.values())) 51 | 52 | def set_name(self, name): 53 | ''' Set the group name string. ''' 54 | if name is None or isinstance(name, str): 55 | self.name = name 56 | else: 57 | raise TypeError('Expected group name to be string, was %s.' % type(name)) 58 | 59 | def set_desc(self, desc): 60 | ''' Set the Group descriptor. 61 | ''' 62 | if isinstance(desc, bytes): 63 | self.desc = self._dtypes.decode_string(desc) 64 | elif isinstance(desc, str) or desc is None: 65 | self.desc = desc 66 | else: 67 | raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(desc)) 68 | 69 | def add_param(self, name, **kwargs): 70 | '''Add a parameter to this group. 71 | 72 | Parameters 73 | ---------- 74 | name : str 75 | Name of the parameter to add to this group. The name will 76 | automatically be case-normalized. 77 | 78 | See constructor of `c3d.parameter.ParamData` for additional keyword arguments. 79 | 80 | Raises 81 | ------ 82 | TypeError 83 | Input arguments are of the wrong type. 84 | KeyError 85 | Name or numerical key already exist (attempt to overwrite existing data). 86 | ''' 87 | if not isinstance(name, str): 88 | raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name))) 89 | name = name.upper() 90 | if name in self._params: 91 | raise KeyError('Parameter already exists with key {}'.format(name)) 92 | self._params[name] = Param(ParamData(name, self._dtypes, **kwargs)) 93 | 94 | def remove_param(self, name): 95 | '''Remove the specified parameter. 96 | 97 | Parameters 98 | ---------- 99 | name : str 100 | Name for the parameter to remove. 101 | ''' 102 | del self._params[name] 103 | 104 | def rename_param(self, name, new_name): 105 | ''' Rename a specified parameter group. 106 | 107 | Parameters 108 | ---------- 109 | name : str, or `c3d.group.GroupReadonly` 110 | Parameter instance, or name. 111 | new_name : str 112 | New name for the parameter. 113 | Raises 114 | ------ 115 | KeyError 116 | If no parameter with the original name exists. 117 | ValueError 118 | If the new name already exist (attempt to overwrite existing data). 119 | ''' 120 | if new_name in self._params: 121 | raise ValueError("Key {} already exist.".format(new_name)) 122 | if isinstance(name, Param): 123 | param = name 124 | name = param.name 125 | else: 126 | # Aquire instance using id 127 | param = self._params[name] 128 | del self._params[name] 129 | self._params[new_name] = param 130 | 131 | def write(self, group_id, handle): 132 | '''Write this parameter group, with parameters, to a file handle. 133 | 134 | Parameters 135 | ---------- 136 | group_id : int 137 | The numerical ID of the group. 138 | handle : file handle 139 | An open, writable, binary file handle. 140 | ''' 141 | name = self.name.encode('utf-8') 142 | desc = self.desc.encode('utf-8') 143 | handle.write(struct.pack('bb', len(name), -group_id)) 144 | handle.write(name) 145 | handle.write(struct.pack(' str: 166 | ''' Access group name. ''' 167 | return self._data.name 168 | 169 | @property 170 | def desc(self) -> str: 171 | '''Access group descriptor. ''' 172 | return self._data.desc 173 | 174 | def items(self): 175 | ''' Get iterator for paramater key-entry pairs. ''' 176 | return ((k, v.readonly()) for k, v in self._data._params.items()) 177 | 178 | def values(self): 179 | ''' Get iterator for parameter entries. ''' 180 | return (v.readonly() for v in self._data._params.values()) 181 | 182 | def keys(self): 183 | ''' Get iterator for parameter entry keys. ''' 184 | return self._data._params.keys() 185 | 186 | def get(self, key, default=None): 187 | '''Get a readonly parameter by key. 188 | 189 | Parameters 190 | ---------- 191 | key : any 192 | Parameter key to look up in this group. 193 | default : any, optional 194 | Value to return if the key is not found. Defaults to None. 195 | 196 | Returns 197 | ------- 198 | param : :class:`ParamReadable` 199 | A parameter from the current group. 200 | ''' 201 | val = self._data._params.get(key, default) 202 | if val: 203 | return val.readonly() 204 | return default 205 | 206 | def get_int8(self, key): 207 | '''Get the value of the given parameter as an 8-bit signed integer.''' 208 | return self._data[key.upper()].int8_value 209 | 210 | def get_uint8(self, key): 211 | '''Get the value of the given parameter as an 8-bit unsigned integer.''' 212 | return self._data[key.upper()].uint8_value 213 | 214 | def get_int16(self, key): 215 | '''Get the value of the given parameter as a 16-bit signed integer.''' 216 | return self._data[key.upper()].int16_value 217 | 218 | def get_uint16(self, key): 219 | '''Get the value of the given parameter as a 16-bit unsigned integer.''' 220 | return self._data[key.upper()].uint16_value 221 | 222 | def get_int32(self, key): 223 | '''Get the value of the given parameter as a 32-bit signed integer.''' 224 | return self._data[key.upper()].int32_value 225 | 226 | def get_uint32(self, key): 227 | '''Get the value of the given parameter as a 32-bit unsigned integer.''' 228 | return self._data[key.upper()].uint32_value 229 | 230 | def get_float(self, key): 231 | '''Get the value of the given parameter as a 32-bit float.''' 232 | return self._data[key.upper()].float_value 233 | 234 | def get_bytes(self, key): 235 | '''Get the value of the given parameter as a byte array.''' 236 | return self._data[key.upper()].bytes_value 237 | 238 | def get_string(self, key): 239 | '''Get the value of the given parameter as a string.''' 240 | return self._data[key.upper()].string_value 241 | 242 | 243 | class Group(GroupReadonly): 244 | ''' Wrapper exposing readable and writeable attributes of a `c3d.group.GroupData` entry. 245 | ''' 246 | def __init__(self, data): 247 | super(Group, self).__init__(data) 248 | 249 | def readonly(self): 250 | ''' Returns a `c3d.group.GroupReadonly` instance with readonly access. ''' 251 | return GroupReadonly(self._data) 252 | 253 | @property 254 | def name(self) -> str: 255 | ''' Get or set name. ''' 256 | return self._data.name 257 | 258 | @name.setter 259 | def name(self, value) -> str: 260 | self._data.set_name(value) 261 | 262 | @property 263 | def desc(self) -> str: 264 | ''' Get or set descriptor. ''' 265 | return self._data.desc 266 | 267 | @desc.setter 268 | def desc(self, value) -> str: 269 | self._data.set_desc(value) 270 | 271 | def items(self): 272 | ''' Iterator for paramater key-entry pairs. ''' 273 | return ((k, v) for k, v in self._data._params.items()) 274 | 275 | def values(self): 276 | ''' Iterator iterator for parameter entries. ''' 277 | return (v for v in self._data._params.values()) 278 | 279 | def get(self, key, default=None): 280 | '''Get a parameter by key. 281 | 282 | Parameters 283 | ---------- 284 | key : any 285 | Parameter key to look up in this group. 286 | default : any, optional 287 | Value to return if the key is not found. Defaults to None. 288 | 289 | Returns 290 | ------- 291 | param : :class:`ParamReadable` 292 | A parameter from the current group. 293 | ''' 294 | return self._data._params.get(key, default) 295 | 296 | # 297 | # Forward param editing 298 | # 299 | def add_param(self, name, **kwargs): 300 | '''Add a parameter to this group. 301 | 302 | See constructor of `c3d.parameter.ParamData` for additional keyword arguments. 303 | ''' 304 | self._data.add_param(name, **kwargs) 305 | 306 | def remove_param(self, name): 307 | '''Remove the specified parameter. 308 | 309 | Parameters 310 | ---------- 311 | name : str 312 | Name for the parameter to remove. 313 | ''' 314 | self._data.remove_param(name) 315 | 316 | def rename_param(self, name, new_name): 317 | ''' Rename a specified parameter group. 318 | 319 | Parameters 320 | ---------- 321 | See arguments in `c3d.group.GroupData.rename_param`. 322 | ''' 323 | self._data.rename_param(name, new_name) 324 | 325 | # 326 | # Convenience functions for adding parameters. 327 | # 328 | def add(self, name, desc, bpe, format, data, *dimensions): 329 | ''' Add a parameter with `data` package formated in accordance with `format`. 330 | 331 | Convenience function for `c3d.group.GroupData.add_param` calling struct.pack() on `data`. 332 | 333 | Example: 334 | 335 | >>> group.set('RATE', 'Point data sample rate', 4, '>> r = c3d.Reader(open('capture.c3d', 'rb')) 22 | >>> for frame_no, points, analog in r.read_frames(): 23 | ... print('{0.shape} points in this frame'.format(points)) 24 | ''' 25 | 26 | def __init__(self, handle): 27 | '''Initialize this C3D file by reading header and parameter data. 28 | 29 | Parameters 30 | ---------- 31 | handle : file handle 32 | Read metadata and C3D motion frames from the given file handle. This 33 | handle is assumed to be `seek`-able and `read`-able. The handle must 34 | remain open for the life of the `Reader` instance. The `Reader` does 35 | not `close` the handle. 36 | 37 | Raises 38 | ------ 39 | AssertionError 40 | If the metadata in the C3D file is inconsistent. 41 | ''' 42 | super(Reader, self).__init__(Header(handle)) 43 | 44 | self._handle = handle 45 | 46 | def seek_param_section_header(): 47 | ''' Seek to and read the first 4 byte of the parameter header section ''' 48 | self._handle.seek((self._header.parameter_block - 1) * 512) 49 | # metadata header 50 | return self._handle.read(4) 51 | 52 | # Begin by reading the processor type: 53 | buf = seek_param_section_header() 54 | _, _, parameter_blocks, processor = struct.unpack('BBBB', buf) 55 | self._dtypes = DataTypes(processor) 56 | # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) 57 | self._header._processor_convert(self._dtypes, handle) 58 | 59 | # Restart reading the parameter header after parsing processor type 60 | buf = seek_param_section_header() 61 | 62 | start_byte = self._handle.tell() 63 | endbyte = start_byte + 512 * parameter_blocks - 4 64 | while self._handle.tell() < endbyte: 65 | chars_in_name, group_id = struct.unpack('bb', self._handle.read(2)) 66 | if group_id == 0 or chars_in_name == 0: 67 | # we've reached the end of the parameter section. 68 | break 69 | name = self._dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper() 70 | 71 | # Read the byte segment associated with the parameter and create a 72 | # separate binary stream object from the data. 73 | offset_to_next, = struct.unpack(['h'][self._dtypes.is_mips], self._handle.read(2)) 74 | if offset_to_next == 0: 75 | # Last parameter, as number of bytes are unknown, 76 | # read the remaining bytes in the parameter section. 77 | bytes = self._handle.read(endbyte - self._handle.tell()) 78 | else: 79 | bytes = self._handle.read(offset_to_next - 2) 80 | buf = io.BytesIO(bytes) 81 | 82 | if group_id > 0: 83 | # We've just started reading a parameter. If its group doesn't 84 | # exist, create a blank one. add the parameter to the group. 85 | group = super(Reader, self).get(group_id) 86 | if group is None: 87 | group = self._add_group(group_id) 88 | group.add_param(name, handle=buf) 89 | else: 90 | # We've just started reading a group. If a group with the 91 | # appropriate numerical id exists already (because we've 92 | # already created it for a parameter), just set the name of 93 | # the group. Otherwise, add a new group. 94 | group_id = abs(group_id) 95 | size, = struct.unpack('B', buf.read(1)) 96 | desc = size and buf.read(size) or '' 97 | group = super(Reader, self).get(group_id) 98 | if group is not None: 99 | self._rename_group(group, name) # Inserts name key 100 | group.desc = desc 101 | else: 102 | self._add_group(group_id, name, desc) 103 | 104 | self._check_metadata() 105 | 106 | def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False): 107 | '''Iterate over the data frames from our C3D file handle. 108 | 109 | Parameters 110 | ---------- 111 | copy : bool 112 | If False, the reader returns a reference to the same data buffers 113 | for every frame. The default is True, which causes the reader to 114 | return a unique data buffer for each frame. Set this to False if you 115 | consume frames as you iterate over them, or True if you store them 116 | for later. 117 | analog_transform : bool, default=True 118 | If True, ANALOG:SCALE, ANALOG:GEN_SCALE, and ANALOG:OFFSET transforms 119 | available in the file are applied to the analog channels. 120 | check_nan : bool, default=True 121 | If True, point x,y,z coordinates with nan values will be marked invalidated 122 | and residuals will be set to -1. 123 | camera_sum : bool, default=False 124 | Camera flag bits will be summed, converting the fifth column to a camera visibility counter. 125 | 126 | Returns 127 | ------- 128 | frames : sequence of (frame number, points, analog) 129 | This method generates a sequence of (frame number, points, analog) 130 | tuples, one tuple per frame. The first element of each tuple is the 131 | frame number. The second is a numpy array of parsed, 5D point data 132 | and the third element of each tuple is a numpy array of analog 133 | values that were recorded during the frame. (Often the analog data 134 | are sampled at a higher frequency than the 3D point data, resulting 135 | in multiple analog frames per frame of point data.) 136 | 137 | The first three columns in the returned point data are the (x, y, z) 138 | coordinates of the observed motion capture point. The fourth column 139 | is an estimate of the error for this particular point, and the fifth 140 | column is the number of cameras that observed the point in question. 141 | Both the fourth and fifth values are -1 if the point is considered 142 | to be invalid. 143 | ''' 144 | # Point magnitude scalar, if scale parameter is < 0 data is floating point 145 | # (in which case the magnitude is the absolute value) 146 | scale_mag = abs(self.point_scale) 147 | is_float = self.point_scale < 0 148 | 149 | if is_float: 150 | point_word_bytes = 4 151 | else: 152 | point_word_bytes = 2 153 | points = np.zeros((self.point_used, 5), np.float32) 154 | 155 | # TODO: handle ANALOG:BITS parameter here! 156 | p = self.get('ANALOG:FORMAT') 157 | analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED' 158 | if is_float: 159 | analog_dtype = self._dtypes.float32 160 | analog_word_bytes = 4 161 | elif analog_unsigned: 162 | # Note*: Floating point is 'always' defined for both analog and point data, according to the standard. 163 | analog_dtype = self._dtypes.uint16 164 | analog_word_bytes = 2 165 | # Verify BITS parameter for analog 166 | p = self.get('ANALOG:BITS') 167 | if p and p._as_integer_value / 8 != analog_word_bytes: 168 | raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value)) 169 | else: 170 | analog_dtype = self._dtypes.int16 171 | analog_word_bytes = 2 172 | 173 | analog = np.array([], float) 174 | analog_scales, analog_offsets = self.get_analog_transform() 175 | 176 | # Seek to the start point of the data blocks 177 | self._handle.seek((self._header.data_block - 1) * 512) 178 | # Number of values (words) read in regard to POINT/ANALOG data 179 | N_point = 4 * self.point_used 180 | N_analog = self.analog_used * self.analog_per_frame 181 | 182 | # Total bytes per frame 183 | point_bytes = N_point * point_word_bytes 184 | analog_bytes = N_analog * analog_word_bytes 185 | # Parse the data blocks 186 | for frame_no in range(self.first_frame, self.last_frame + 1): 187 | # Read the byte data (used) for the block 188 | raw_bytes = self._handle.read(N_point * point_word_bytes) 189 | raw_analog = self._handle.read(N_analog * analog_word_bytes) 190 | # Verify read pointers (any of the two can be assumed to be 0) 191 | if len(raw_bytes) < point_bytes: 192 | warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {} 193 | and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell())) 194 | return 195 | if len(raw_analog) < analog_bytes: 196 | warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {} 197 | and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell())) 198 | return 199 | 200 | if is_float: 201 | # Convert every 4 byte words to a float-32 reprensentation 202 | # (the fourth column is still not a float32 representation) 203 | if self._dtypes.is_dec: 204 | # Convert each of the first 6 16-bit words from DEC to IEEE float 205 | points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4)) 206 | else: # If IEEE or MIPS: 207 | # Convert each of the first 6 16-bit words to native float 208 | points[:, :4] = np.frombuffer(raw_bytes, 209 | dtype=self._dtypes.float32, 210 | count=N_point).reshape((self.point_used, 4)) 211 | 212 | # Cast last word to signed integer in system endian format 213 | last_word = points[:, 3].astype(np.int32) 214 | 215 | else: 216 | # View the bytes as signed 16-bit integers 217 | raw = np.frombuffer(raw_bytes, 218 | dtype=self._dtypes.int16, 219 | count=N_point).reshape((self.point_used, 4)) 220 | # Read the first six 16-bit words as x, y, z coordinates 221 | points[:, :3] = raw[:, :3] * scale_mag 222 | # Cast last word to signed integer in system endian format 223 | last_word = raw[:, 3].astype(np.int16) 224 | 225 | # Parse camera-observed bits and residuals. 226 | # Notes: 227 | # - Invalid sample if residual is equal to -1 (check if word < 0). 228 | # - A residual of 0.0 represent modeled data (filtered or interpolated). 229 | # - Camera and residual words are always 8-bit (1 byte), never 16-bit. 230 | # - If floating point, the byte words are encoded in an integer cast to a float, 231 | # and are written directly in byte form (see the MLS guide). 232 | ## 233 | # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded). 234 | residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8 235 | 236 | # Fourth value is floating-point (scaled) error estimate (residual) 237 | points[:, 3] = residual_byte * scale_mag 238 | 239 | # Determine invalid samples 240 | invalid = last_word < 0 241 | if check_nan: 242 | is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) 243 | points[is_nan, :3] = 0.0 244 | invalid |= is_nan 245 | # Update discarded - sign 246 | points[invalid, 3] = -1 247 | 248 | # Fifth value is the camera-observation byte 249 | if camera_sum: 250 | # Convert to observation sum 251 | points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7)) 252 | else: 253 | points[:, 4] = camera_byte # .astype(np.float32) 254 | 255 | # Check if analog data exist, and parse if so 256 | if N_analog > 0: 257 | if is_float and self._dtypes.is_dec: 258 | # Convert each of the 16-bit words from DEC to IEEE float 259 | analog = DEC_to_IEEE_BYTES(raw_analog) 260 | else: 261 | # Integer or INTEL/MIPS floating point data can be parsed directly 262 | analog = np.frombuffer(raw_analog, dtype=analog_dtype, count=N_analog) 263 | 264 | # Reformat and convert 265 | analog = analog.reshape((-1, self.analog_used)).T 266 | analog = analog.astype(float) 267 | # Convert analog 268 | analog = (analog - analog_offsets) * analog_scales 269 | 270 | # Output buffers 271 | if copy: 272 | yield frame_no, points.copy(), analog # .copy(), a new array is generated per frame for analog data. 273 | else: 274 | yield frame_no, points, analog 275 | 276 | # Function evaluating EOF, note that data section is written in blocks of 512 277 | final_byte_index = self._handle.tell() 278 | self._handle.seek(0, 2) # os.SEEK_END) 279 | # Check if more then 1 block remain 280 | if self._handle.tell() - final_byte_index >= 512: 281 | warnings.warn('incomplete reading of data blocks. {} bytes remained after all datablocks were read!'.format( 282 | self._handle.tell() - final_byte_index)) 283 | 284 | @property 285 | def proc_type(self) -> int: 286 | '''Get the processory type associated with the data format in the file. 287 | ''' 288 | return self._dtypes.proc_type 289 | 290 | def to_writer(self, conversion=None): 291 | ''' Converts the reader to a `c3d.writer.Writer` instance using the conversion mode. 292 | 293 | See `c3d.writer.Writer.from_reader()` for supported conversion modes. 294 | ''' 295 | from .writer import Writer 296 | return Writer.from_reader(self, conversion=conversion) 297 | 298 | def get(self, key, default=None): 299 | '''Get a readonly group or parameter. 300 | 301 | Parameters 302 | ---------- 303 | key : str 304 | If this string contains a period (.), then the part before the 305 | period will be used to retrieve a group, and the part after the 306 | period will be used to retrieve a parameter from that group. If this 307 | string does not contain a period, then just a group will be 308 | returned. 309 | default : any 310 | Return this value if the named group and parameter are not found. 311 | 312 | Returns 313 | ------- 314 | value : `c3d.group.GroupReadonly` or `c3d.parameter.ParamReadonly` 315 | Either a group or parameter with the specified name(s). If neither 316 | is found, returns the default value. 317 | ''' 318 | val = super(Reader, self).get(key) 319 | if val: 320 | return val.readonly() 321 | return default 322 | 323 | def items(self): 324 | ''' Get iterable over pairs of (str, `c3d.group.GroupReadonly`) entries. 325 | ''' 326 | return ((k, v.readonly()) for k, v in super(Reader, self).items()) 327 | 328 | def values(self): 329 | ''' Get iterable over `c3d.group.GroupReadonly` entries. 330 | ''' 331 | return (v.readonly() for k, v in super(Reader, self).items()) 332 | 333 | def listed(self): 334 | ''' Get iterable over pairs of (int, `c3d.group.GroupReadonly`) entries. 335 | ''' 336 | return ((k, v.readonly()) for k, v in super(Reader, self).listed()) 337 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 2, June 1991 3 | 4 | Copyright (C) 1989, 1991 Free Software Foundation, Inc., 5 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA 6 | Everyone is permitted to copy and distribute verbatim copies 7 | of this license document, but changing it is not allowed. 8 | 9 | Preamble 10 | 11 | The licenses for most software are designed to take away your 12 | freedom to share and change it. By contrast, the GNU General Public 13 | License is intended to guarantee your freedom to share and change free 14 | software--to make sure the software is free for all its users. This 15 | General Public License applies to most of the Free Software 16 | Foundation's software and to any other program whose authors commit to 17 | using it. (Some other Free Software Foundation software is covered by 18 | the GNU Lesser General Public License instead.) You can apply it to 19 | your programs, too. 20 | 21 | When we speak of free software, we are referring to freedom, not 22 | price. Our General Public Licenses are designed to make sure that you 23 | have the freedom to distribute copies of free software (and charge for 24 | this service if you wish), that you receive source code or can get it 25 | if you want it, that you can change the software or use pieces of it 26 | in new free programs; and that you know you can do these things. 27 | 28 | To protect your rights, we need to make restrictions that forbid 29 | anyone to deny you these rights or to ask you to surrender the rights. 30 | These restrictions translate to certain responsibilities for you if you 31 | distribute copies of the software, or if you modify it. 32 | 33 | For example, if you distribute copies of such a program, whether 34 | gratis or for a fee, you must give the recipients all the rights that 35 | you have. You must make sure that they, too, receive or can get the 36 | source code. And you must show them these terms so they know their 37 | rights. 38 | 39 | We protect your rights with two steps: (1) copyright the software, and 40 | (2) offer you this license which gives you legal permission to copy, 41 | distribute and/or modify the software. 42 | 43 | Also, for each author's protection and ours, we want to make certain 44 | that everyone understands that there is no warranty for this free 45 | software. If the software is modified by someone else and passed on, we 46 | want its recipients to know that what they have is not the original, so 47 | that any problems introduced by others will not reflect on the original 48 | authors' reputations. 49 | 50 | Finally, any free program is threatened constantly by software 51 | patents. We wish to avoid the danger that redistributors of a free 52 | program will individually obtain patent licenses, in effect making the 53 | program proprietary. To prevent this, we have made it clear that any 54 | patent must be licensed for everyone's free use or not licensed at all. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | GNU GENERAL PUBLIC LICENSE 60 | TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION 61 | 62 | 0. This License applies to any program or other work which contains 63 | a notice placed by the copyright holder saying it may be distributed 64 | under the terms of this General Public License. The "Program", below, 65 | refers to any such program or work, and a "work based on the Program" 66 | means either the Program or any derivative work under copyright law: 67 | that is to say, a work containing the Program or a portion of it, 68 | either verbatim or with modifications and/or translated into another 69 | language. (Hereinafter, translation is included without limitation in 70 | the term "modification".) Each licensee is addressed as "you". 71 | 72 | Activities other than copying, distribution and modification are not 73 | covered by this License; they are outside its scope. The act of 74 | running the Program is not restricted, and the output from the Program 75 | is covered only if its contents constitute a work based on the 76 | Program (independent of having been made by running the Program). 77 | Whether that is true depends on what the Program does. 78 | 79 | 1. You may copy and distribute verbatim copies of the Program's 80 | source code as you receive it, in any medium, provided that you 81 | conspicuously and appropriately publish on each copy an appropriate 82 | copyright notice and disclaimer of warranty; keep intact all the 83 | notices that refer to this License and to the absence of any warranty; 84 | and give any other recipients of the Program a copy of this License 85 | along with the Program. 86 | 87 | You may charge a fee for the physical act of transferring a copy, and 88 | you may at your option offer warranty protection in exchange for a fee. 89 | 90 | 2. You may modify your copy or copies of the Program or any portion 91 | of it, thus forming a work based on the Program, and copy and 92 | distribute such modifications or work under the terms of Section 1 93 | above, provided that you also meet all of these conditions: 94 | 95 | a) You must cause the modified files to carry prominent notices 96 | stating that you changed the files and the date of any change. 97 | 98 | b) You must cause any work that you distribute or publish, that in 99 | whole or in part contains or is derived from the Program or any 100 | part thereof, to be licensed as a whole at no charge to all third 101 | parties under the terms of this License. 102 | 103 | c) If the modified program normally reads commands interactively 104 | when run, you must cause it, when started running for such 105 | interactive use in the most ordinary way, to print or display an 106 | announcement including an appropriate copyright notice and a 107 | notice that there is no warranty (or else, saying that you provide 108 | a warranty) and that users may redistribute the program under 109 | these conditions, and telling the user how to view a copy of this 110 | License. (Exception: if the Program itself is interactive but 111 | does not normally print such an announcement, your work based on 112 | the Program is not required to print an announcement.) 113 | 114 | These requirements apply to the modified work as a whole. If 115 | identifiable sections of that work are not derived from the Program, 116 | and can be reasonably considered independent and separate works in 117 | themselves, then this License, and its terms, do not apply to those 118 | sections when you distribute them as separate works. But when you 119 | distribute the same sections as part of a whole which is a work based 120 | on the Program, the distribution of the whole must be on the terms of 121 | this License, whose permissions for other licensees extend to the 122 | entire whole, and thus to each and every part regardless of who wrote it. 123 | 124 | Thus, it is not the intent of this section to claim rights or contest 125 | your rights to work written entirely by you; rather, the intent is to 126 | exercise the right to control the distribution of derivative or 127 | collective works based on the Program. 128 | 129 | In addition, mere aggregation of another work not based on the Program 130 | with the Program (or with a work based on the Program) on a volume of 131 | a storage or distribution medium does not bring the other work under 132 | the scope of this License. 133 | 134 | 3. You may copy and distribute the Program (or a work based on it, 135 | under Section 2) in object code or executable form under the terms of 136 | Sections 1 and 2 above provided that you also do one of the following: 137 | 138 | a) Accompany it with the complete corresponding machine-readable 139 | source code, which must be distributed under the terms of Sections 140 | 1 and 2 above on a medium customarily used for software interchange; or, 141 | 142 | b) Accompany it with a written offer, valid for at least three 143 | years, to give any third party, for a charge no more than your 144 | cost of physically performing source distribution, a complete 145 | machine-readable copy of the corresponding source code, to be 146 | distributed under the terms of Sections 1 and 2 above on a medium 147 | customarily used for software interchange; or, 148 | 149 | c) Accompany it with the information you received as to the offer 150 | to distribute corresponding source code. (This alternative is 151 | allowed only for noncommercial distribution and only if you 152 | received the program in object code or executable form with such 153 | an offer, in accord with Subsection b above.) 154 | 155 | The source code for a work means the preferred form of the work for 156 | making modifications to it. For an executable work, complete source 157 | code means all the source code for all modules it contains, plus any 158 | associated interface definition files, plus the scripts used to 159 | control compilation and installation of the executable. However, as a 160 | special exception, the source code distributed need not include 161 | anything that is normally distributed (in either source or binary 162 | form) with the major components (compiler, kernel, and so on) of the 163 | operating system on which the executable runs, unless that component 164 | itself accompanies the executable. 165 | 166 | If distribution of executable or object code is made by offering 167 | access to copy from a designated place, then offering equivalent 168 | access to copy the source code from the same place counts as 169 | distribution of the source code, even though third parties are not 170 | compelled to copy the source along with the object code. 171 | 172 | 4. You may not copy, modify, sublicense, or distribute the Program 173 | except as expressly provided under this License. Any attempt 174 | otherwise to copy, modify, sublicense or distribute the Program is 175 | void, and will automatically terminate your rights under this License. 176 | However, parties who have received copies, or rights, from you under 177 | this License will not have their licenses terminated so long as such 178 | parties remain in full compliance. 179 | 180 | 5. You are not required to accept this License, since you have not 181 | signed it. However, nothing else grants you permission to modify or 182 | distribute the Program or its derivative works. These actions are 183 | prohibited by law if you do not accept this License. Therefore, by 184 | modifying or distributing the Program (or any work based on the 185 | Program), you indicate your acceptance of this License to do so, and 186 | all its terms and conditions for copying, distributing or modifying 187 | the Program or works based on it. 188 | 189 | 6. Each time you redistribute the Program (or any work based on the 190 | Program), the recipient automatically receives a license from the 191 | original licensor to copy, distribute or modify the Program subject to 192 | these terms and conditions. You may not impose any further 193 | restrictions on the recipients' exercise of the rights granted herein. 194 | You are not responsible for enforcing compliance by third parties to 195 | this License. 196 | 197 | 7. If, as a consequence of a court judgment or allegation of patent 198 | infringement or for any other reason (not limited to patent issues), 199 | conditions are imposed on you (whether by court order, agreement or 200 | otherwise) that contradict the conditions of this License, they do not 201 | excuse you from the conditions of this License. If you cannot 202 | distribute so as to satisfy simultaneously your obligations under this 203 | License and any other pertinent obligations, then as a consequence you 204 | may not distribute the Program at all. For example, if a patent 205 | license would not permit royalty-free redistribution of the Program by 206 | all those who receive copies directly or indirectly through you, then 207 | the only way you could satisfy both it and this License would be to 208 | refrain entirely from distribution of the Program. 209 | 210 | If any portion of this section is held invalid or unenforceable under 211 | any particular circumstance, the balance of the section is intended to 212 | apply and the section as a whole is intended to apply in other 213 | circumstances. 214 | 215 | It is not the purpose of this section to induce you to infringe any 216 | patents or other property right claims or to contest validity of any 217 | such claims; this section has the sole purpose of protecting the 218 | integrity of the free software distribution system, which is 219 | implemented by public license practices. Many people have made 220 | generous contributions to the wide range of software distributed 221 | through that system in reliance on consistent application of that 222 | system; it is up to the author/donor to decide if he or she is willing 223 | to distribute software through any other system and a licensee cannot 224 | impose that choice. 225 | 226 | This section is intended to make thoroughly clear what is believed to 227 | be a consequence of the rest of this License. 228 | 229 | 8. If the distribution and/or use of the Program is restricted in 230 | certain countries either by patents or by copyrighted interfaces, the 231 | original copyright holder who places the Program under this License 232 | may add an explicit geographical distribution limitation excluding 233 | those countries, so that distribution is permitted only in or among 234 | countries not thus excluded. In such case, this License incorporates 235 | the limitation as if written in the body of this License. 236 | 237 | 9. The Free Software Foundation may publish revised and/or new versions 238 | of the General Public License from time to time. Such new versions will 239 | be similar in spirit to the present version, but may differ in detail to 240 | address new problems or concerns. 241 | 242 | Each version is given a distinguishing version number. If the Program 243 | specifies a version number of this License which applies to it and "any 244 | later version", you have the option of following the terms and conditions 245 | either of that version or of any later version published by the Free 246 | Software Foundation. If the Program does not specify a version number of 247 | this License, you may choose any version ever published by the Free Software 248 | Foundation. 249 | 250 | 10. If you wish to incorporate parts of the Program into other free 251 | programs whose distribution conditions are different, write to the author 252 | to ask for permission. For software which is copyrighted by the Free 253 | Software Foundation, write to the Free Software Foundation; we sometimes 254 | make exceptions for this. Our decision will be guided by the two goals 255 | of preserving the free status of all derivatives of our free software and 256 | of promoting the sharing and reuse of software generally. 257 | 258 | NO WARRANTY 259 | 260 | 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY 261 | FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN 262 | OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 263 | PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED 264 | OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 265 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS 266 | TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE 267 | PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, 268 | REPAIR OR CORRECTION. 269 | 270 | 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 271 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR 272 | REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, 273 | INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING 274 | OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED 275 | TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY 276 | YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER 277 | PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 278 | POSSIBILITY OF SUCH DAMAGES. 279 | 280 | END OF TERMS AND CONDITIONS 281 | 282 | How to Apply These Terms to Your New Programs 283 | 284 | If you develop a new program, and you want it to be of the greatest 285 | possible use to the public, the best way to achieve this is to make it 286 | free software which everyone can redistribute and change under these terms. 287 | 288 | To do so, attach the following notices to the program. It is safest 289 | to attach them to the start of each source file to most effectively 290 | convey the exclusion of warranty; and each file should have at least 291 | the "copyright" line and a pointer to where the full notice is found. 292 | 293 | {description} 294 | Copyright (C) {year} {fullname} 295 | 296 | This program is free software; you can redistribute it and/or modify 297 | it under the terms of the GNU General Public License as published by 298 | the Free Software Foundation; either version 2 of the License, or 299 | (at your option) any later version. 300 | 301 | This program is distributed in the hope that it will be useful, 302 | but WITHOUT ANY WARRANTY; without even the implied warranty of 303 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 304 | GNU General Public License for more details. 305 | 306 | You should have received a copy of the GNU General Public License along 307 | with this program; if not, write to the Free Software Foundation, Inc., 308 | 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 309 | 310 | Also add information on how to contact you by electronic and paper mail. 311 | 312 | If the program is interactive, make it output a short notice like this 313 | when it starts in an interactive mode: 314 | 315 | Gnomovision version 69, Copyright (C) year name of author 316 | Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 317 | This is free software, and you are welcome to redistribute it 318 | under certain conditions; type `show c' for details. 319 | 320 | The hypothetical commands `show w' and `show c' should show the appropriate 321 | parts of the General Public License. Of course, the commands you use may 322 | be called something other than `show w' and `show c'; they could even be 323 | mouse-clicks or menu items--whatever suits your program. 324 | 325 | You should also get your employer (if you work as a programmer) or your 326 | school, if any, to sign a "copyright disclaimer" for the program, if 327 | necessary. Here is a sample; alter the names: 328 | 329 | Yoyodyne, Inc., hereby disclaims all copyright interest in the program 330 | `Gnomovision' (which makes passes at compilers) written by James Hacker. 331 | 332 | {signature of Ty Coon}, 1 April 1989 333 | Ty Coon, President of Vice 334 | 335 | This General Public License does not permit incorporating your program into 336 | proprietary programs. If your program is a subroutine library, you may 337 | consider it more useful to permit linking proprietary applications with the 338 | library. If this is what you want to do, use the GNU Lesser General 339 | Public License instead of this License. 340 | 341 | -------------------------------------------------------------------------------- /c3d/manager.py: -------------------------------------------------------------------------------- 1 | ''' Manager base class defining common attributes for both Reader and Writer instances. 2 | ''' 3 | import numpy as np 4 | import warnings 5 | from .header import Header 6 | from .group import GroupData, GroupReadonly, Group 7 | from .utils import is_integer, is_iterable 8 | 9 | 10 | class Manager(object): 11 | '''A base class for managing C3D file metadata. 12 | 13 | This class manages a C3D header (which contains some stock metadata fields) 14 | as well as a set of parameter groups. Each group is accessible using its 15 | name. 16 | 17 | Attributes 18 | ---------- 19 | header : `c3d.header.Header` 20 | Header information for the C3D file. 21 | ''' 22 | 23 | def __init__(self, header=None): 24 | '''Set up a new Manager with a Header.''' 25 | self._header = header or Header() 26 | self._groups = {} 27 | 28 | def __contains__(self, key): 29 | return key in self._groups 30 | 31 | def items(self): 32 | ''' Get iterable over pairs of (str, `c3d.group.Group`) entries. 33 | ''' 34 | return ((k, v) for k, v in self._groups.items() if isinstance(k, str)) 35 | 36 | def values(self): 37 | ''' Get iterable over `c3d.group.Group` entries. 38 | ''' 39 | return (v for k, v in self._groups.items() if isinstance(k, str)) 40 | 41 | def keys(self): 42 | ''' Get iterable over parameter name keys. 43 | ''' 44 | return (k for k in self._groups.keys() if isinstance(k, str)) 45 | 46 | def listed(self): 47 | ''' Get iterable over pairs of (int, `c3d.group.Group`) entries. 48 | ''' 49 | return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int)) 50 | 51 | def _check_metadata(self): 52 | ''' Ensure that the metadata in our file is self-consistent. ''' 53 | assert self._header.point_count == self.point_used, ( 54 | 'inconsistent point count! {} header != {} POINT:USED'.format( 55 | self._header.point_count, 56 | self.point_used, 57 | )) 58 | 59 | assert self._header.scale_factor == self.point_scale, ( 60 | 'inconsistent scale factor! {} header != {} POINT:SCALE'.format( 61 | self._header.scale_factor, 62 | self.point_scale, 63 | )) 64 | 65 | assert self._header.frame_rate == self.point_rate, ( 66 | 'inconsistent frame rate! {} header != {} POINT:RATE'.format( 67 | self._header.frame_rate, 68 | self.point_rate, 69 | )) 70 | 71 | if self.point_rate: 72 | ratio = self.analog_rate / self.point_rate 73 | else: 74 | ratio = 0 75 | assert self._header.analog_per_frame == ratio, ( 76 | 'inconsistent analog rate! {} header != {} analog-fps / {} point-fps'.format( 77 | self._header.analog_per_frame, 78 | self.analog_rate, 79 | self.point_rate, 80 | )) 81 | 82 | count = self.analog_used * self._header.analog_per_frame 83 | assert self._header.analog_count == count, ( 84 | 'inconsistent analog count! {} header != {} analog used * {} per-frame'.format( 85 | self._header.analog_count, 86 | self.analog_used, 87 | self._header.analog_per_frame, 88 | )) 89 | 90 | try: 91 | start = self.get('POINT:DATA_START').uint16_value 92 | if self._header.data_block != start: 93 | warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( 94 | self._header.data_block, start)) 95 | except AttributeError: 96 | warnings.warn('''no pointer available in POINT:DATA_START indicating the start of the data block, using 97 | header pointer as fallback''') 98 | 99 | def check_parameters(params): 100 | for name in params: 101 | if self.get(name) is None: 102 | warnings.warn('missing parameter {}'.format(name)) 103 | 104 | if self.point_used > 0: 105 | check_parameters(('POINT:LABELS', 'POINT:DESCRIPTIONS')) 106 | else: 107 | lab = self.get('POINT:LABELS') 108 | if lab is None: 109 | warnings.warn('No point data found in file.') 110 | elif lab.num_elements > 0: 111 | warnings.warn('No point data found in file, but file contains POINT:LABELS entries') 112 | if self.analog_used > 0: 113 | check_parameters(('ANALOG:LABELS', 'ANALOG:DESCRIPTIONS')) 114 | else: 115 | lab = self.get('ANALOG:LABELS') 116 | if lab is None: 117 | warnings.warn('No analog data found in file.') 118 | elif lab.num_elements > 0: 119 | warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries') 120 | 121 | def _add_group(self, group_id, name=None, desc=None): 122 | '''Add a new parameter group. 123 | 124 | Parameters 125 | ---------- 126 | group_id : int 127 | The numeric ID for a group to check or create. 128 | name : str, optional 129 | If a group is created, assign this name to the group. 130 | desc : str, optional 131 | If a group is created, assign this description to the group. 132 | 133 | Returns 134 | ------- 135 | group : :class:`Group` 136 | A group with the given ID, name, and description. 137 | 138 | Raises 139 | ------ 140 | TypeError 141 | Input arguments are of the wrong type. 142 | KeyError 143 | Name or numerical key already exist (attempt to overwrite existing data). 144 | ''' 145 | if not is_integer(group_id): 146 | raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id)) 147 | if not isinstance(name, str): 148 | if name is not None: 149 | raise TypeError('Expected Group name key to be string, was %s.' % type(name)) 150 | else: 151 | name = name.upper() 152 | group_id = int(group_id) # Asserts python int 153 | if group_id in self._groups: 154 | raise KeyError('Group with numerical key {} already exists'.format(group_id)) 155 | if name in self._groups: 156 | raise KeyError('No group matched name key {}'.format(name)) 157 | group = self._groups[name] = self._groups[group_id] = Group(GroupData(self._dtypes, name, desc)) 158 | return group 159 | 160 | def _remove_group(self, group_id): 161 | '''Remove the parameter group. 162 | 163 | Parameters 164 | ---------- 165 | group_id : int, or str 166 | The numeric or name ID key for a group to remove all entries for. 167 | ''' 168 | grp = self._groups.get(group_id, None) 169 | if grp is None: 170 | return 171 | gkeys = [k for (k, v) in self._groups.items() if v == grp] 172 | for k in gkeys: 173 | del self._groups[k] 174 | 175 | def _rename_group(self, group_id, new_group_id): 176 | ''' Rename a specified parameter group. 177 | 178 | Parameters 179 | ---------- 180 | group_id : int, str, or `c3d.group.Group` 181 | Group instance, name, or numerical identifier for the group. 182 | new_group_id : str, or int 183 | If string, it is the new name for the group. If integer, it will replace its numerical group id. 184 | 185 | Raises 186 | ------ 187 | KeyError 188 | If a group with a duplicate ID or name already exists. 189 | ''' 190 | if isinstance(group_id, GroupReadonly): 191 | grp = group_id._data 192 | else: 193 | # Aquire instance using id 194 | grp = self._groups.get(group_id, None) 195 | if grp is None: 196 | raise KeyError('No group found matching the identifier: %s' % str(group_id)) 197 | if new_group_id in self._groups: 198 | if new_group_id == group_id: 199 | return 200 | raise ValueError('Key %s for group %s already exist.' % (str(new_group_id), grp.name)) 201 | 202 | # Clear old id 203 | if isinstance(new_group_id, (str, bytes)): 204 | if grp.name in self._groups: 205 | del self._groups[grp.name] 206 | grp.name = new_group_id 207 | elif is_integer(new_group_id): 208 | new_group_id = int(new_group_id) # Ensure python int 209 | del self._groups[group_id] 210 | else: 211 | raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) 212 | # Update 213 | self._groups[new_group_id] = grp 214 | 215 | def get(self, group, default=None): 216 | '''Get a group or parameter. 217 | 218 | Parameters 219 | ---------- 220 | group : str 221 | If this string contains a period (.), then the part before the 222 | period will be used to retrieve a group, and the part after the 223 | period will be used to retrieve a parameter from that group. If this 224 | string does not contain a period, then just a group will be 225 | returned. 226 | default : any 227 | Return this value if the named group and parameter are not found. 228 | 229 | Returns 230 | ------- 231 | value : `c3d.group.Group` or `c3d.parameter.Param` 232 | Either a group or parameter with the specified name(s). If neither 233 | is found, returns the default value. 234 | ''' 235 | if is_integer(group): 236 | group = self._groups.get(int(group)) 237 | if group is None: 238 | return default 239 | return group 240 | group = group.upper() 241 | param = None 242 | if '.' in group: 243 | group, param = group.split('.', 1) 244 | if ':' in group: 245 | group, param = group.split(':', 1) 246 | if group not in self._groups: 247 | return default 248 | group = self._groups[group] 249 | if param is not None: 250 | return group.get(param, default) 251 | return group 252 | 253 | @property 254 | def header(self) -> '`c3d.header.Header`': 255 | ''' Access to .c3d header data. ''' 256 | return self._header 257 | 258 | def parameter_blocks(self) -> int: 259 | '''Compute the size (in 512B blocks) of the parameter section.''' 260 | bytes = 4. + sum(g._data.binary_size for g in self._groups.values()) 261 | return int(np.ceil(bytes / 512)) 262 | 263 | @property 264 | def point_rate(self) -> float: 265 | ''' Number of sampled 3D coordinates per second. ''' 266 | try: 267 | return self.get_float('POINT:RATE') 268 | except AttributeError: 269 | return self.header.frame_rate 270 | 271 | @property 272 | def point_scale(self) -> float: 273 | ''' Scaling applied to non-float data. ''' 274 | try: 275 | return self.get_float('POINT:SCALE') 276 | except AttributeError: 277 | return self.header.scale_factor 278 | 279 | @property 280 | def point_used(self) -> int: 281 | ''' Number of sampled 3D point coordinates per frame. ''' 282 | try: 283 | return self.get_uint16('POINT:USED') 284 | except AttributeError: 285 | return self.header.point_count 286 | 287 | @property 288 | def analog_used(self) -> int: 289 | ''' Number of analog measurements, or channels, for each analog data sample. ''' 290 | try: 291 | return self.get_uint16('ANALOG:USED') 292 | except AttributeError: 293 | per_frame = self.header.analog_per_frame 294 | if per_frame > 0: 295 | return int(self.header.analog_count / per_frame) 296 | return 0 297 | 298 | @property 299 | def analog_rate(self) -> float: 300 | ''' Number of analog data samples per second. ''' 301 | try: 302 | return self.get_float('ANALOG:RATE') 303 | except AttributeError: 304 | return self.header.analog_per_frame * self.point_rate 305 | 306 | @property 307 | def analog_per_frame(self) -> int: 308 | ''' Number of analog frames per 3D frame (point sample). ''' 309 | return int(self.analog_rate / self.point_rate) 310 | 311 | @property 312 | def analog_sample_count(self) -> int: 313 | ''' Number of analog samples per channel. ''' 314 | has_analog = self.analog_used > 0 315 | return int(self.frame_count * self.analog_per_frame) * has_analog 316 | 317 | @property 318 | def point_labels(self) -> list: 319 | ''' Labels for each POINT data channel. ''' 320 | return self.get('POINT:LABELS').string_array 321 | 322 | @property 323 | def analog_labels(self) -> list: 324 | ''' Labels for each ANALOG data channel. ''' 325 | return self.get('ANALOG:LABELS').string_array 326 | 327 | @property 328 | def frame_count(self) -> int: 329 | ''' Number of frames recorded in the data. ''' 330 | return self.last_frame - self.first_frame + 1 # Add 1 since range is inclusive [first, last] 331 | 332 | @property 333 | def first_frame(self) -> int: 334 | ''' Trial frame corresponding to the first frame recorded in the data. ''' 335 | # Start frame seems to be less of an issue to determine. 336 | # this is a hack for phasespace files ... should put it in a subclass. 337 | param = self.get('TRIAL:ACTUAL_START_FIELD') 338 | if param is not None: 339 | # ACTUAL_START_FIELD is encoded in two 16 byte words... 340 | return param.uint32_value 341 | return self.header.first_frame 342 | 343 | @property 344 | def last_frame(self) -> int: 345 | ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' 346 | # Number of frames can be represented in many formats, first check if valid header values 347 | #if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: 348 | # return self.header.last_frame 349 | 350 | # Try different parameters where the frame can be encoded 351 | hlf = self.header.last_frame 352 | param = self.get('TRIAL:ACTUAL_END_FIELD') 353 | if param is not None: 354 | # Encoded as 2 16 bit words (rather then 1 32 bit word) 355 | # words = param.uint16_array 356 | # end_frame[1] = words[0] + words[1] * 65536 357 | end_frame = param.uint32_value 358 | if hlf <= end_frame: 359 | return end_frame 360 | param = self.get('POINT:LONG_FRAMES') 361 | if param is not None: 362 | # 'Should be' encoded as float 363 | if param.bytes_per_element >= 4: 364 | end_frame = int(param.float_value) 365 | else: 366 | end_frame = param.uint16_value 367 | if hlf <= end_frame: 368 | return end_frame 369 | param = self.get('POINT:FRAMES') 370 | if param is not None: 371 | # Can be encoded either as 32 bit float or 16 bit uint 372 | if param.bytes_per_element == 4: 373 | end_frame = int(param.float_value) 374 | else: 375 | end_frame = param.uint16_value 376 | if hlf <= end_frame: 377 | return end_frame 378 | # Return header value by default 379 | return hlf 380 | 381 | def get_screen_xy_strings(self): 382 | ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. 383 | 384 | See `Manager.get_screen_xy_axis` to get numpy vectors instead. 385 | 386 | Returns 387 | ------- 388 | value : (str, str) or None 389 | Touple containing X_SCREEN and Y_SCREEN strings, or None (if no parameters could be found). 390 | ''' 391 | X = self.get('POINT:X_SCREEN') 392 | Y = self.get('POINT:Y_SCREEN') 393 | if X and Y: 394 | return (X.string_value, Y.string_value) 395 | return None 396 | 397 | def get_screen_xy_axis(self): 398 | ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors. 399 | 400 | Z axis can be computed using the cross product: 401 | 402 | $$ z = x \\times y $$ 403 | 404 | To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: 405 | 406 | $$ p = | x^T y^T z^T |^T p_s $$ 407 | 408 | 409 | See `Manager.get_screen_xy_strings` to get the parameter as string values instead. 410 | 411 | Returns 412 | ------- 413 | value : ([3,], [3,]) or None 414 | Touple $(x, y)$ containing X_SCREEN and Y_SCREEN as row vectors, or None. 415 | ''' 416 | # Axis conversion dictionary. 417 | AXIS_DICT = { 418 | 'X': np.array([1.0, 0, 0]), 419 | '+X': np.array([1.0, 0, 0]), 420 | '-X': np.array([-1.0, 0, 0]), 421 | 'Y': np.array([0, 1.0, 0]), 422 | '+Y': np.array([0, 1.0, 0]), 423 | '-Y': np.array([0, -1.0, 0]), 424 | 'Z': np.array([0, 0, 1.0]), 425 | '+Z': np.array([0, 0, 1.0]), 426 | '-Z': np.array([0, 0, -1.0]), 427 | } 428 | 429 | val = self.get_screen_xy_strings() 430 | if val is None: 431 | return None 432 | axis_x, axis_y = val 433 | 434 | # Interpret using both X/Y_SCREEN 435 | return AXIS_DICT[axis_x], AXIS_DICT[axis_y] 436 | 437 | def get_analog_transform_parameters(self): 438 | ''' Parse analog data transform parameters. ''' 439 | # Offsets 440 | analog_offsets = np.zeros((self.analog_used), int) 441 | param = self.get('ANALOG:OFFSET') 442 | if param is not None and param.num_elements > 0: 443 | analog_offsets[:] = param.int16_array[:self.analog_used] 444 | 445 | # Scale factors 446 | analog_scales = np.ones((self.analog_used), float) 447 | gen_scale = 1. 448 | param = self.get('ANALOG:GEN_SCALE') 449 | if param is not None: 450 | gen_scale = param.float_value 451 | param = self.get('ANALOG:SCALE') 452 | if param is not None and param.num_elements > 0: 453 | analog_scales[:] = param.float_array[:self.analog_used] 454 | 455 | return gen_scale, analog_scales, analog_offsets 456 | 457 | def get_analog_transform(self): 458 | ''' Get broadcastable analog transformation parameters. 459 | ''' 460 | gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters() 461 | analog_scales *= gen_scale 462 | analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) 463 | analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) 464 | return analog_scales, analog_offsets 465 | -------------------------------------------------------------------------------- /c3d/parameter.py: -------------------------------------------------------------------------------- 1 | ''' Classes used to represent the concept of a parameter in a .c3d file. 2 | ''' 3 | import struct 4 | import numpy as np 5 | from .utils import DEC_to_IEEE, DEC_to_IEEE_BYTES 6 | 7 | 8 | class ParamData(object): 9 | '''A class representing a single named parameter from a C3D file. 10 | 11 | Attributes 12 | ---------- 13 | name : str 14 | Name of this parameter. 15 | dtype: DataTypes 16 | Reference to the DataTypes object associated with the file. 17 | desc : str 18 | Brief description of this parameter. 19 | bytes_per_element : int, optional 20 | For array data, this describes the size of each element of data. For 21 | string data (including arrays of strings), this should be -1. 22 | dimensions : list of int 23 | For array data, this describes the dimensions of the array, stored in 24 | column-major (Fortran) order. For arrays of strings, the dimensions here will be 25 | the number of columns (length of each string) followed by the number of 26 | rows (number of strings). 27 | bytes : str 28 | Raw data for this parameter. 29 | ''' 30 | 31 | def __init__(self, 32 | name, 33 | dtype, 34 | desc='', 35 | bytes_per_element=1, 36 | dimensions=None, 37 | bytes=b'', 38 | handle=None): 39 | '''Set up a new parameter, only the name is required.''' 40 | self.name = name 41 | self.dtypes = dtype 42 | self.desc = desc 43 | self.bytes_per_element = bytes_per_element 44 | self.dimensions = dimensions or [] 45 | self.bytes = bytes 46 | if handle: 47 | self.read(handle) 48 | 49 | def __repr__(self): 50 | return ''.format(self.desc) 51 | 52 | @property 53 | def num_elements(self) -> int: 54 | '''Return the number of elements in this parameter's array value.''' 55 | e = 1 56 | for d in self.dimensions: 57 | e *= d 58 | return e 59 | 60 | @property 61 | def total_bytes(self) -> int: 62 | '''Return the number of bytes used for storing this parameter's data.''' 63 | return self.num_elements * abs(self.bytes_per_element) 64 | 65 | @property 66 | def binary_size(self) -> int: 67 | '''Return the number of bytes needed to store this parameter.''' 68 | return ( 69 | 1 + # group_id 70 | 2 + # next offset marker 71 | 1 + len(self.name.encode('utf-8')) + # size of name and name bytes 72 | 1 + # data size 73 | # size of dimensions and dimension bytes 74 | 1 + len(self.dimensions) + 75 | self.total_bytes + # data 76 | 1 + len(self.desc.encode('utf-8')) # size of desc and desc bytes 77 | ) 78 | 79 | def write(self, group_id, handle): 80 | '''Write binary data for this parameter to a file handle. 81 | 82 | Parameters 83 | ---------- 84 | group_id : int 85 | The numerical ID of the group that holds this parameter. 86 | handle : file handle 87 | An open, writable, binary file handle. 88 | ''' 89 | name = self.name.encode('utf-8') 90 | handle.write(struct.pack('bb', len(name), group_id)) 91 | handle.write(name) 92 | handle.write(struct.pack(' 0: 97 | handle.write(self.bytes) 98 | desc = self.desc.encode('utf-8') 99 | handle.write(struct.pack('B', len(desc))) 100 | handle.write(desc) 101 | 102 | def read(self, handle): 103 | '''Read binary data for this parameter from a file handle. 104 | 105 | This reads exactly enough data from the current position in the file to 106 | initialize the parameter. 107 | ''' 108 | self.bytes_per_element, = struct.unpack('b', handle.read(1)) 109 | dims, = struct.unpack('B', handle.read(1)) 110 | self.dimensions = [struct.unpack('B', handle.read(1))[ 111 | 0] for _ in range(dims)] 112 | self.bytes = b'' 113 | if self.total_bytes: 114 | self.bytes = handle.read(self.total_bytes) 115 | desc_size, = struct.unpack('B', handle.read(1)) 116 | self.desc = desc_size and self.dtypes.decode_string(handle.read(desc_size)) or '' 117 | 118 | def _as(self, dtype): 119 | '''Unpack the raw bytes of this param using the given struct format.''' 120 | return np.frombuffer(self.bytes, count=1, dtype=dtype)[0] 121 | 122 | def _as_array(self, dtype, copy=True): 123 | '''Unpack the raw bytes of this param using the given data format.''' 124 | if not self.dimensions: 125 | return [self._as(dtype)] 126 | elems = np.frombuffer(self.bytes, dtype=dtype) 127 | # Reverse shape as the shape is defined in fortran format 128 | view = elems.reshape(self.dimensions[::-1]) 129 | if copy: 130 | return view.copy() 131 | return view 132 | 133 | 134 | class ParamReadonly(object): 135 | ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry. 136 | ''' 137 | 138 | def __init__(self, data): 139 | self._data = data 140 | 141 | def __eq__(self, other): 142 | return self._data is other._data 143 | 144 | @property 145 | def name(self) -> str: 146 | ''' Get the parameter name. ''' 147 | return self._data.name 148 | 149 | @property 150 | def desc(self) -> str: 151 | ''' Get the parameter descriptor. ''' 152 | return self._data.desc 153 | 154 | @property 155 | def dtypes(self): 156 | ''' Convenience accessor to the `c3d.dtypes.DataTypes` instance associated with the parameter. ''' 157 | return self._data.dtypes 158 | 159 | @property 160 | def dimensions(self) -> (int, ...): 161 | ''' Shape of the parameter data (Fortran format). ''' 162 | return self._data.dimensions 163 | 164 | @property 165 | def num_elements(self) -> int: 166 | '''Return the number of elements in this parameter's array value.''' 167 | return self._data.num_elements 168 | 169 | @property 170 | def bytes_per_element(self) -> int: 171 | '''Return the number of bytes used to store each data element.''' 172 | return self._data.bytes_per_element 173 | 174 | @property 175 | def total_bytes(self) -> int: 176 | '''Return the number of bytes used for storing this parameter's data.''' 177 | return self._data.total_bytes 178 | 179 | @property 180 | def binary_size(self) -> int: 181 | '''Return the number of bytes needed to store this parameter.''' 182 | return self._data.binary_size 183 | 184 | @property 185 | def int8_value(self): 186 | '''Get the parameter data as an 8-bit signed integer.''' 187 | return self._data._as(self.dtypes.int8) 188 | 189 | @property 190 | def uint8_value(self): 191 | '''Get the parameter data as an 8-bit unsigned integer.''' 192 | return self._data._as(self.dtypes.uint8) 193 | 194 | @property 195 | def int16_value(self): 196 | '''Get the parameter data as a 16-bit signed integer.''' 197 | return self._data._as(self.dtypes.int16) 198 | 199 | @property 200 | def uint16_value(self): 201 | '''Get the parameter data as a 16-bit unsigned integer.''' 202 | return self._data._as(self.dtypes.uint16) 203 | 204 | @property 205 | def int32_value(self): 206 | '''Get the parameter data as a 32-bit signed integer.''' 207 | return self._data._as(self.dtypes.int32) 208 | 209 | @property 210 | def uint32_value(self): 211 | '''Get the parameter data as a 32-bit unsigned integer.''' 212 | return self._data._as(self.dtypes.uint32) 213 | 214 | @property 215 | def uint_value(self): 216 | ''' Get the parameter data as a unsigned integer of appropriate type. ''' 217 | if self.bytes_per_element >= 4: 218 | return self.uint32_value 219 | elif self.bytes_per_element >= 2: 220 | return self.uint16_value 221 | else: 222 | return self.uint8_value 223 | 224 | @property 225 | def int_value(self): 226 | ''' Get the parameter data as a signed integer of appropriate type. ''' 227 | if self.bytes_per_element >= 4: 228 | return self.int32_value 229 | elif self.bytes_per_element >= 2: 230 | return self.int16_value 231 | else: 232 | return self.int8_value 233 | 234 | @property 235 | def float_value(self): 236 | '''Get the parameter data as a floating point value of appropriate type.''' 237 | if self.bytes_per_element > 4: 238 | if self.dtypes.is_dec: 239 | raise AttributeError("64 bit DEC floating point is not supported.") 240 | # 64-bit floating point is not a standard 241 | return self._data._as(self.dtypes.float64) 242 | elif self.bytes_per_element == 4: 243 | if self.dtypes.is_dec: 244 | return DEC_to_IEEE(self._data._as(np.uint32)) 245 | else: # is_mips or is_ieee 246 | return self._data._as(self.dtypes.float32) 247 | else: 248 | raise AttributeError("Only 32 and 64 bit floating point is supported.") 249 | 250 | @property 251 | def bytes_value(self) -> bytes: 252 | '''Get the raw byte string.''' 253 | return self._data.bytes 254 | 255 | @property 256 | def string_value(self): 257 | '''Get the parameter data as a unicode string.''' 258 | return self.dtypes.decode_string(self._data.bytes) 259 | 260 | @property 261 | def int8_array(self): 262 | '''Get the parameter data as an array of 8-bit signed integers.''' 263 | return self._data._as_array(self.dtypes.int8) 264 | 265 | @property 266 | def uint8_array(self): 267 | '''Get the parameter data as an array of 8-bit unsigned integers.''' 268 | return self._data._as_array(self.dtypes.uint8) 269 | 270 | @property 271 | def int16_array(self): 272 | '''Get the parameter data as an array of 16-bit signed integers.''' 273 | return self._data._as_array(self.dtypes.int16) 274 | 275 | @property 276 | def uint16_array(self): 277 | '''Get the parameter data as an array of 16-bit unsigned integers.''' 278 | return self._data._as_array(self.dtypes.uint16) 279 | 280 | @property 281 | def int32_array(self): 282 | '''Get the parameter data as an array of 32-bit signed integers.''' 283 | return self._data._as_array(self.dtypes.int32) 284 | 285 | @property 286 | def uint32_array(self): 287 | '''Get the parameter data as an array of 32-bit unsigned integers.''' 288 | return self._data._as_array(self.dtypes.uint32) 289 | 290 | @property 291 | def int64_array(self): 292 | '''Get the parameter data as an array of 32-bit signed integers.''' 293 | return self._data._as_array(self.dtypes.int64) 294 | 295 | @property 296 | def uint64_array(self): 297 | '''Get the parameter data as an array of 32-bit unsigned integers.''' 298 | return self._data._as_array(self.dtypes.uint64) 299 | 300 | @property 301 | def float32_array(self): 302 | '''Get the parameter data as an array of 32-bit floats.''' 303 | # Convert float data if not IEEE processor 304 | if self.dtypes.is_dec: 305 | # _as_array but for DEC 306 | if not self.dimensions: 307 | return [self.float_value] 308 | return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format 309 | else: # is_ieee or is_mips 310 | return self._data._as_array(self.dtypes.float32) 311 | 312 | @property 313 | def float64_array(self): 314 | '''Get the parameter data as an array of 64-bit floats.''' 315 | # Convert float data if not IEEE processor 316 | if self.dtypes.is_dec: 317 | raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') 318 | else: # is_ieee or is_mips 319 | return self._data._as_array(self.dtypes.float64) 320 | 321 | @property 322 | def float_array(self): 323 | '''Get the parameter data as an array of 32 or 64 bit floats.''' 324 | # Convert float data if not IEEE processor 325 | if self.bytes_per_element == 4: 326 | return self.float32_array 327 | elif self.bytes_per_element == 8: 328 | return self.float64_array 329 | else: 330 | raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element + 331 | "floating-point precission is not unsupported.") 332 | 333 | @property 334 | def int_array(self): 335 | '''Get the parameter data as an array of integer values.''' 336 | # Convert float data if not IEEE processor 337 | if self.bytes_per_element == 1: 338 | return self.int8_array 339 | elif self.bytes_per_element == 2: 340 | return self.int16_array 341 | elif self.bytes_per_element == 4: 342 | return self.int32_array 343 | elif self.bytes_per_element == 8: 344 | return self.int64_array 345 | else: 346 | raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % 347 | self.bytes_per_element) 348 | 349 | @property 350 | def uint_array(self): 351 | '''Get the parameter data as an array of integer values.''' 352 | # Convert float data if not IEEE processor 353 | if self.bytes_per_element == 1: 354 | return self.uint8_array 355 | elif self.bytes_per_element == 2: 356 | return self.uint16_array 357 | elif self.bytes_per_element == 4: 358 | return self.uint32_array 359 | elif self.bytes_per_element == 8: 360 | return self.uint64_array 361 | else: 362 | raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % 363 | self.bytes_per_element) 364 | 365 | @property 366 | def bytes_array(self): 367 | '''Get the parameter data as an array of raw byte strings.''' 368 | # Decode different dimensions 369 | if len(self.dimensions) == 0: 370 | return np.array([]) 371 | elif len(self.dimensions) == 1: 372 | return np.array(self._data.bytes) 373 | else: 374 | # Convert Fortran shape (data in memory is identical, shape is transposed) 375 | word_len = self.dimensions[0] 376 | dims = self.dimensions[1:][::-1] # Identical to: [:0:-1] 377 | byte_steps = np.cumprod(self.dimensions[:-1])[::-1] 378 | # Generate mult-dimensional array and parse byte words 379 | byte_arr = np.empty(dims, dtype=object) 380 | for i in np.ndindex(*dims): 381 | # Calculate byte offset as sum of each array index times the byte step of each dimension. 382 | off = np.sum(np.multiply(i, byte_steps)) 383 | byte_arr[i] = self._data.bytes[off:off+word_len] 384 | return byte_arr 385 | 386 | @property 387 | def string_array(self): 388 | '''Get the parameter data as a python array of unicode strings.''' 389 | # Decode different dimensions 390 | if len(self.dimensions) == 0: 391 | return np.array([]) 392 | elif len(self.dimensions) == 1: 393 | return np.array([self.string_value]) 394 | else: 395 | # Parse byte sequences 396 | byte_arr = self.bytes_array 397 | # Decode sequences 398 | for i in np.ndindex(byte_arr.shape): 399 | byte_arr[i] = self.dtypes.decode_string(byte_arr[i]) 400 | return byte_arr 401 | 402 | @property 403 | def any_value(self): 404 | ''' Get the parameter data as a value of 'traditional type'. 405 | 406 | Traditional types are defined in the Parameter section in the [user manual]. 407 | 408 | Returns 409 | ------- 410 | value : int, float, or str 411 | Depending on the `bytes_per_element` field, a traditional type can 412 | be a either a signed byte, signed short, 32-bit float, or a string. 413 | 414 | [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf 415 | ''' 416 | if self.bytes_per_element >= 4: 417 | return self.float_value 418 | elif self.bytes_per_element >= 2: 419 | return self.int16_value 420 | elif self.bytes_per_element == -1: 421 | return self.string_value 422 | else: 423 | return self.int8_value 424 | 425 | @property 426 | def any_array(self): 427 | ''' Get the parameter data as an array of 'traditional type'. 428 | 429 | Traditional types are defined in the Parameter section in the [user manual]. 430 | 431 | Returns 432 | ------- 433 | value : array 434 | Depending on the `bytes_per_element` field, a traditional type can 435 | be a either a signed byte, signed short, 32-bit float, or a string. 436 | 437 | [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf 438 | ''' 439 | if self.bytes_per_element >= 4: 440 | return self.float_array 441 | elif self.bytes_per_element >= 2: 442 | return self.int16_array 443 | elif self.bytes_per_element == -1: 444 | return self.string_array 445 | else: 446 | return self.int8_array 447 | 448 | @property 449 | def _as_any_uint(self): 450 | ''' Attempt to parse the parameter data as any unsigned integer format. 451 | Checks if the integer is stored as a floating point value. 452 | 453 | Can be used to read 'POINT:FRAMES' or 'POINT:LONG_FRAMES' 454 | when not accessed through `c3d.manager.Manager.last_frame`. 455 | ''' 456 | if self.bytes_per_element >= 4: 457 | # Check if float value representation is an integer 458 | value = self.float_value 459 | if float(value).is_integer(): 460 | return int(value) 461 | return self.uint32_value 462 | elif self.bytes_per_element >= 2: 463 | return self.uint16_value 464 | else: 465 | return self.uint8_value 466 | 467 | 468 | class Param(ParamReadonly): 469 | ''' Wrapper exposing both readable and writable attributes of a `c3d.parameter.ParamData` entry. 470 | ''' 471 | def __init__(self, data): 472 | super(Param, self).__init__(data) 473 | 474 | def readonly(self): 475 | ''' Returns a readonly `c3d.parameter.ParamReadonly` instance. ''' 476 | return ParamReadonly(self._data) 477 | 478 | @property 479 | def bytes(self) -> bytes: 480 | ''' Get or set the parameter bytes. ''' 481 | return self._data.bytes 482 | 483 | @bytes.setter 484 | def bytes(self, value): 485 | self._data.bytes = value 486 | -------------------------------------------------------------------------------- /c3d/writer.py: -------------------------------------------------------------------------------- 1 | '''Contains the Writer class for writing C3D files.''' 2 | 3 | import copy 4 | import numpy as np 5 | import struct 6 | # import warnings 7 | from . import utils 8 | from .manager import Manager 9 | from .dtypes import DataTypes 10 | 11 | 12 | class Writer(Manager): 13 | '''This class writes metadata and frames to a C3D file. 14 | 15 | For example, to read an existing C3D file, apply some sort of data 16 | processing to the frames, and write out another C3D file:: 17 | 18 | >>> r = c3d.Reader(open('data.c3d', 'rb')) 19 | >>> w = c3d.Writer() 20 | >>> w.add_frames(process_frames_somehow(r.read_frames())) 21 | >>> with open('smoothed.c3d', 'wb') as handle: 22 | >>> w.write(handle) 23 | 24 | Parameters 25 | ---------- 26 | point_rate : float, optional 27 | The frame rate of the data. Defaults to 480. 28 | analog_rate : float, optional 29 | The number of analog samples per frame. Defaults to 0. 30 | point_scale : float, optional 31 | The scale factor for point data. Defaults to -1 (i.e., "check the 32 | POINT:SCALE parameter"). 33 | point_units : str, optional 34 | The units that the point numbers represent. Defaults to ``'mm '``. 35 | gen_scale : float, optional 36 | General scaling factor for analog data. Defaults to 1. 37 | ''' 38 | 39 | def __init__(self, 40 | point_rate=480., 41 | analog_rate=0., 42 | point_scale=-1.): 43 | '''Set minimal metadata for this writer. 44 | 45 | ''' 46 | self._dtypes = DataTypes() # Only support INTEL format from writing 47 | super(Writer, self).__init__() 48 | 49 | # Header properties 50 | self._header.frame_rate = np.float32(point_rate) 51 | self._header.scale_factor = np.float32(point_scale) 52 | self.analog_rate = analog_rate 53 | self._frames = [] 54 | 55 | @staticmethod 56 | def from_reader(reader, conversion=None): 57 | ''' Convert a `c3d.reader.Reader` to a persistent `c3d.writer.Writer` instance. 58 | 59 | Parameters 60 | ---------- 61 | source : `c3d.reader.Reader` 62 | Source to copy data from. 63 | conversion : str 64 | Conversion mode, None is equivalent to the default mode. Supported modes are: 65 | 66 | 'consume' - (Default) Reader object will be 67 | consumed and explicitly deleted. 68 | 69 | 'copy' - Reader objects will be deep copied. 70 | 71 | 'copy_metadata' - Similar to 'copy' but only copies metadata and 72 | not point and analog frame data. 73 | 74 | 'copy_shallow' - Similar to 'copy' but group parameters are 75 | not copied. 76 | 77 | 'copy_header' - Similar to 'copy_shallow' but only the 78 | header is copied (frame data is not copied). 79 | 80 | Returns 81 | ------- 82 | param : `c3d.writer.Writer` 83 | A writeable and persistent representation of the `c3d.reader.Reader` object. 84 | 85 | Raises 86 | ------ 87 | ValueError 88 | If mode string is not equivalent to one of the supported modes. 89 | If attempting to convert non-Intel files using mode other than 'shallow_copy'. 90 | ''' 91 | writer = Writer() 92 | # Modes 93 | is_header_only = conversion == 'copy_header' 94 | is_meta_copy = conversion == 'copy_metadata' 95 | is_meta_only = is_header_only or is_meta_copy 96 | is_consume = conversion == 'consume' or conversion is None 97 | is_shallow_copy = conversion == 'shallow_copy' or is_header_only 98 | is_deep_copy = conversion == 'copy' or is_meta_copy 99 | # Verify mode 100 | if not (is_consume or is_shallow_copy or is_deep_copy): 101 | raise ValueError( 102 | "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format( 103 | conversion 104 | )) 105 | if not reader._dtypes.is_ieee and not is_shallow_copy: 106 | # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. 107 | raise ValueError( 108 | "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( 109 | reader._dtypes.proc_type 110 | )) 111 | 112 | if is_consume: 113 | writer._header = reader._header 114 | writer._groups = reader._groups 115 | elif is_deep_copy: 116 | writer._header = copy.deepcopy(reader._header) 117 | writer._groups = copy.deepcopy(reader._groups) 118 | elif is_shallow_copy: 119 | # Only copy header (no groups) 120 | writer._header = copy.deepcopy(reader._header) 121 | # Reformat header events 122 | writer._header.encode_events(writer._header.events) 123 | 124 | # Transfer a minimal set parameters 125 | writer.set_start_frame(reader.first_frame) 126 | writer.set_point_labels(reader.point_labels) 127 | writer.set_analog_labels(reader.analog_labels) 128 | 129 | gen_scale, analog_scales, analog_offsets = reader.get_analog_transform_parameters() 130 | writer.set_analog_general_scale(gen_scale) 131 | writer.set_analog_scales(analog_scales) 132 | writer.set_analog_offsets(analog_offsets) 133 | 134 | if not is_meta_only: 135 | # Copy frames 136 | for (i, point, analog) in reader.read_frames(copy=True, camera_sum=False): 137 | writer.add_frames((point, analog)) 138 | if is_consume: 139 | # Cleanup 140 | reader._header = None 141 | reader._groups = None 142 | del reader 143 | return writer 144 | 145 | @property 146 | def analog_rate(self): 147 | return super(Writer, self).analog_rate 148 | 149 | @analog_rate.setter 150 | def analog_rate(self, value): 151 | per_frame_rate = value / self.point_rate 152 | assert float(per_frame_rate).is_integer(), "Analog rate must be a multiple of the point rate." 153 | self._header.analog_per_frame = np.uint16(per_frame_rate) 154 | 155 | @property 156 | def numeric_key_max(self): 157 | ''' Get the largest numeric key. 158 | ''' 159 | num = 0 160 | if len(self._groups) > 0: 161 | for i in self._groups.keys(): 162 | if isinstance(i, int): 163 | num = max(i, num) 164 | return num 165 | 166 | @property 167 | def numeric_key_next(self): 168 | ''' Get a new unique numeric group key. 169 | ''' 170 | return self.numeric_key_max + 1 171 | 172 | def get_create(self, label): 173 | ''' Get or create a parameter `c3d.group.Group`.''' 174 | label = label.upper() 175 | group = self.get(label) 176 | if group is None: 177 | group = self.add_group(self.numeric_key_next, label, label + ' group') 178 | return group 179 | 180 | @property 181 | def point_group(self): 182 | ''' Get or create the POINT parameter group.''' 183 | return self.get_create('POINT') 184 | 185 | @property 186 | def analog_group(self): 187 | ''' Get or create the ANALOG parameter group.''' 188 | return self.get_create('ANALOG') 189 | 190 | @property 191 | def trial_group(self): 192 | ''' Get or create the TRIAL parameter group.''' 193 | return self.get_create('TRIAL') 194 | 195 | def add_group(self, group_id, name, desc): 196 | '''Add a new parameter group. See Manager.add_group() for more information. 197 | 198 | Returns 199 | ------- 200 | group : `c3d.group.Group` 201 | An editable group instance. 202 | ''' 203 | return super(Writer, self)._add_group(group_id, name, desc) 204 | 205 | def rename_group(self, *args): 206 | ''' Rename a specified parameter group (see Manager._rename_group for args). ''' 207 | super(Writer, self)._rename_group(*args) 208 | 209 | def remove_group(self, *args): 210 | '''Remove the parameter group. (see Manager._rename_group for args). ''' 211 | super(Writer, self)._remove_group(*args) 212 | 213 | def add_frames(self, frames, index=None): 214 | '''Add frames to this writer instance. 215 | 216 | Parameters 217 | ---------- 218 | frames : Single or sequence of (point, analog) pairs 219 | A sequence or frame of frame data to add to the writer. 220 | index : int or None 221 | Insert the frame or sequence at the index (the first sequence frame will be inserted at the given `index`). 222 | Note that the index should be relative to 0 rather then the frame number provided by read_frames()! 223 | ''' 224 | sh = np.shape(frames) 225 | # Single frame 226 | if len(sh) < 2: 227 | frames = [frames] 228 | sh = np.shape(frames) 229 | 230 | # Check data shapes match 231 | if len(self._frames) > 0: 232 | point0, analog0 = self._frames[0] 233 | psh, ash = np.shape(point0), np.shape(analog0) 234 | for f in frames: 235 | if np.shape(f[0]) != psh: 236 | raise ValueError( 237 | 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( 238 | str(psh), str(np.shape(f[0])) 239 | )) 240 | if np.shape(f[1]) != ash: 241 | raise ValueError( 242 | 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( 243 | str(ash), str(np.shape(f[1])) 244 | )) 245 | 246 | # Sequence of invalid shape 247 | if sh[1] != 2: 248 | raise ValueError( 249 | 'Expected frame input to be sequence of point and analog pairs on form (None, 2). ' + 250 | 'Input was of shape {}.'.format(str(sh))) 251 | 252 | if index is not None: 253 | self._frames[index:index] = frames 254 | else: 255 | self._frames.extend(frames) 256 | 257 | def set_point_labels(self, labels): 258 | ''' Set point data labels. 259 | 260 | Parameters 261 | ---------- 262 | labels : iterable 263 | Set POINT:LABELS parameter entry from a set of string labels. 264 | ''' 265 | grp = self.point_group 266 | if labels is None: 267 | grp.add_empty_array('LABELS', 'Point labels.') 268 | else: 269 | label_str, label_max_size = utils.pack_labels(labels) 270 | grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) 271 | 272 | def set_analog_labels(self, labels): 273 | ''' Set analog data labels. 274 | 275 | Parameters 276 | ---------- 277 | labels : iterable 278 | Set ANALOG:LABELS parameter entry from a set of string labels. 279 | ''' 280 | grp = self.analog_group 281 | if labels is None: 282 | grp.add_empty_array('LABELS', 'Analog labels.') 283 | else: 284 | label_str, label_max_size = utils.pack_labels(labels) 285 | grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) 286 | 287 | def set_analog_general_scale(self, value): 288 | ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor). 289 | ''' 290 | self.analog_group.set('GEN_SCALE', 'Analog general scale factor', 4, '= UINT16_MAX: 393 | # Should be floating point 394 | group.set('LONG_FRAMES', 'Total frame count', 4, '= 0.0 494 | raw[~valid, 3] = -1 495 | raw[valid, :3] = points[valid, :3] / point_scale 496 | raw[valid, 3] = np.bitwise_or(np.rint(points[valid, 3] / scale_mag).astype(np.uint8), 497 | (points[valid, 4].astype(np.uint16) << 8), 498 | dtype=np.uint16) 499 | 500 | # Transform analog data 501 | analog = analog * analog_scales_inv + analog_offsets 502 | analog = analog.T 503 | 504 | # Write 505 | analog = analog.astype(point_dtype) 506 | handle.write(raw.tobytes()) 507 | handle.write(analog.tobytes()) 508 | self._pad_block(handle) 509 | -------------------------------------------------------------------------------- /c3d_parse_dictionary.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # io_anim_c3d is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 2 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | 19 | # Script copyright (C) Mattias Fredriksson 20 | 21 | # pep8 compliancy: 22 | # flake8 .\c3d_parse_dictionary.py 23 | 24 | import sys 25 | import numpy as np 26 | from .c3d import Reader 27 | 28 | ############### 29 | # Standalone module to interface with the parser for the .c3d format 30 | ############### 31 | 32 | 33 | def islist(N): 34 | ''' Check if 'N' object is any type of array 35 | ''' 36 | return hasattr(N, '__len__') and (not isinstance(N, str)) 37 | 38 | 39 | def dim(X): 40 | ''' Get the number of dimensions of the python array X 41 | ''' 42 | if not isinstance(X, np.ndarray) and isinstance(X[0], np.ndarray): 43 | return len(X[0].shape) + 1 44 | return len(np.shape(X)) 45 | 46 | 47 | class C3DParseDictionary: 48 | ''' Simple construct for dynamically varying how .c3d data is parsed. 49 | 50 | Provides: 51 | 52 | Container for an open .c3d file. 53 | Functions for printing metadata. 54 | Dictionary for dynamically managing .c3d files. 55 | 56 | ''' 57 | def __init__(self, file_path, parse_dict='basic'): 58 | ''' Construct a parser for a .c3d file 59 | ''' 60 | self.file_path = file_path 61 | # Set parse dictionary 62 | if parse_dict == 'basic': 63 | self.parse_dict = C3DParseDictionary.define_basic_dictionary() 64 | elif isinstance(parse_dict, dict): 65 | self.parse_dict = parse_dict 66 | else: 67 | self.parse_dict = [] 68 | 69 | def __del__(self): 70 | # Destructor 71 | self.close() 72 | 73 | def __enter__(self): 74 | # Open file handle and create a .c3d reader 75 | self.file_handle = open(self.file_path, 'rb') 76 | self.reader = Reader(self.file_handle) 77 | return self 78 | 79 | def __exit__(self, exc_type, exc_value, tb): 80 | self.close() 81 | 82 | def close(self): 83 | ''' Close an open .c3d file 84 | ''' 85 | if self.file_handle and not self.file_handle.closed: 86 | self.file_handle.close() 87 | 88 | def get_group(self, group_id): 89 | ''' Get a group from a group name id 90 | ''' 91 | return self.reader.get(group_id, None) 92 | 93 | def get_param(self, group_id, param_id): 94 | ''' Fetch a parameter struct from group and param id:s 95 | ''' 96 | return self.reader.get(group_id + ':' + param_id, None) 97 | 98 | """ 99 | -------------------------------------------------------- 100 | Properties 101 | -------------------------------------------------------- 102 | """ 103 | 104 | @property 105 | def first_frame(self): 106 | ''' Get index of the first recorded frame. ''' 107 | return self.reader.first_frame 108 | 109 | @property 110 | def last_frame(self): 111 | ''' Get index of the last recorded frame. ''' 112 | return self.reader.last_frame 113 | 114 | @property 115 | def frame_rate(self): 116 | ''' Get the frame rate for the data sequence. ''' 117 | return max(1.0, self.reader.header.frame_rate) 118 | 119 | """ 120 | -------------------------------------------------------- 121 | Interpret .c3d Data 122 | -------------------------------------------------------- 123 | """ 124 | 125 | def header_events(self): 126 | ''' Get an iterable over header events. Each item is on the form (frame_timing, label) and type (float, string). 127 | ''' 128 | header = self.reader.header 129 | # Convert event timing to a frame index (in floating point). 130 | timings = header.event_timings[header.event_disp_flags] * self.reader.point_rate - self.reader.first_frame 131 | return zip(timings, header.event_labels[header.event_disp_flags]) 132 | 133 | def events(self): 134 | ''' Get an iterable over EVENTS defined in the file. 135 | ''' 136 | if self.get_param('EVENT', 'LABELS') is None: 137 | return self.header_events() 138 | else: 139 | ecount = int(self.parse_param_uint('EVENT', 'USED')) 140 | labels = self.parse_labels('EVENT', 'LABELS') 141 | context = self.parse_labels('EVENT', 'CONTEXTS') 142 | timings = self.parse_multi_parameter('EVENT', 'TIMES', C3DParseDictionary.parse_param_float_array) 143 | 144 | tshape = np.shape(timings) 145 | if ecount > len(labels): 146 | raise ValueError('C3D events could not be parsed. Expected %i labels found %i.' % (ecount, len(labels))) 147 | elif ecount > tshape[0]: 148 | raise ValueError('C3D events could not be parsed. Expected %i timings found %i.' % (ecount, tshape[0])) 149 | 150 | # Parse timing parameter, the parameter can contain two columns tracking 151 | # minutes and seconds separately. If only one column is present it's assumed 152 | # to be recorded in seconds. 153 | if len(tshape) == 2 and tshape[1] == 2: 154 | frame_timings = timings[:, 0] * 60.0 * self.reader.point_rate 155 | frame_timings += timings[:, 1] * self.reader.point_rate - self.reader.first_frame 156 | elif len(tshape) == 1: 157 | frame_timings = timings * self.reader.point_rate - self.reader.first_frame 158 | else: 159 | raise ValueError( 160 | 'C3D events could not be parsed. Shape %s for the EVENT.TIMES parameter is not supported.' % 161 | str(np.shape(timings))) 162 | 163 | # Combine label array with label context and return. 164 | if context is not None: 165 | labels = labels + '_' + context 166 | return zip(frame_timings, labels) 167 | 168 | def axis_interpretation(self, sys_axis_up=[0, 0, 1], sys_axis_forw=[0, 1, 0]): 169 | ''' Interpret X_SCREEN and Y_SCREEN parameters as the axis orientation for the system. 170 | 171 | Params: 172 | ---- 173 | sys_axis_up: Up axis vector defining convention used for the system (normal to the horizontal ground plane). 174 | sys_axis_forw: Forward axis vector defining the full system convention (forward orientation on ground plane). 175 | Returns: (3x3 orientation matrix for converting 3D data points, True if POINT.?_SCREEN param was parsed). 176 | ''' 177 | val = self.reader.get_screen_xy_axis() 178 | if val: 179 | axis_x, axis_y = val 180 | parsed_screen_param = True 181 | else: 182 | # If both X/Y_SCREEN axis can't be parsed, use default case: 183 | axis_x = np.array([1, 0, 0]) 184 | axis_y = np.array([0, 0, 1]) 185 | parsed_screen_param = False 186 | 187 | # Compute POINT:SCREEN transform 188 | O_data = np.identity(3) 189 | O_data[:, 0] = axis_x 190 | O_data[:, 1] = axis_y 191 | O_data[:, 2] = np.cross(axis_x, axis_y) 192 | 193 | # Define the system's third axis as the cross product: 194 | O_sys = np.empty((3, 3)) 195 | O_sys[:, 1] = sys_axis_forw / np.linalg.norm(sys_axis_forw) 196 | O_sys[:, 2] = sys_axis_up / np.linalg.norm(sys_axis_up) 197 | O_sys[:, 0] = np.cross(O_sys[:, 1], O_sys[:, 2]) 198 | # Orient from data basis -> system basis. 199 | return np.matmul(O_sys, O_data.T), parsed_screen_param 200 | 201 | def unit_conversion(self, group_id, param_id='UNITS', sys_unit=None): 202 | ''' Interpret unit conversion available for a parameter. 203 | 204 | Params: 205 | ---- 206 | group_id: Parameter group ID (e.g. 'POINTS'). 207 | param_id: ID for the parameter itself, default is set to 'UNITS' as it's the standard parameter for recording 208 | measurement units used. 209 | sys_unit: The unit used within the system to which the units should be converted to. Leave as None if it 210 | should be converted to default SI unit. 211 | 212 | Warning! Currently only supports units of length. 213 | ''' 214 | # Unit conversion dictionary. 215 | unit_dict = { 216 | # Metric 217 | 'm': 1.0, 218 | 'meter': 1.0, 219 | 'cm': 1e-2, 220 | 'centimeter': 1e-2, 221 | 'mm': 1e-3, 222 | 'millimeter': 1e-3, 223 | # Imperial 224 | 'in': 254e-4, 225 | 'inch': 254e-4, 226 | 'ft': 0.3048, 227 | 'foot': 0.3048, 228 | 'yd': 0.9144, 229 | 'yard': 0.9144, 230 | # Default 231 | None: 1.0 232 | } 233 | # Conversion factor (scale). 234 | conv_fac = 1.0 235 | # Adjust conversion factor from unit defined in 'GROUP.UNITS': 236 | data_unit = self.parse_param_string(group_id, param_id) 237 | if data_unit is not None: 238 | if islist(data_unit): 239 | # Convert a list of units: 240 | conv_fac = np.ones(len(data_unit)) 241 | for i, u in enumerate(data_unit): 242 | u = u.lower() 243 | if u in unit_dict: 244 | conv_fac = unit_dict[u] 245 | else: 246 | # Convert a single unit string: 247 | data_unit = data_unit.lower() 248 | if data_unit in unit_dict: 249 | conv_fac = unit_dict[data_unit] 250 | else: 251 | print("No unit of length found for %s data." % group_id) 252 | 253 | # Convert data to a specific unit (does not support conversion of different SI units). 254 | if type(sys_unit) is str: 255 | conv2unit = unit_dict[sys_unit.lower()] 256 | conv_fac = conv_fac / conv2unit 257 | 258 | # Return the conversion factor. 259 | return conv_fac 260 | 261 | def parse_multi_parameter(self, group_id, param_ids, pfunction='C3DParseDictionary.parse_param_any'): 262 | ''' Get concatenated list of values for a group parameter stored in multiple entries. 263 | 264 | Parameters with multiple entries are parameters such as label entries which can't be stored in a single 265 | parameter. Instead the parameter is stored as: POINT.LABELS, POINT.LABELS2, ..., POINT.LABELSN. 266 | 267 | Params: 268 | ---- 269 | group_id: Group from which the labels should be parsed. 270 | param_ids: List of parameter identifiers for which label information is stored, e.g. ['LABELS']. 271 | pfunction: Function used to parse the group parameter, default is parse_param_any(...). 272 | Returns: Numpy array containing parsed values. 273 | ''' 274 | if not islist(param_ids): 275 | param_ids = [param_ids] 276 | 277 | def parseParam(pid): 278 | pitems = pfunction(self, group_id, pid) 279 | if islist(pitems): 280 | return pitems 281 | elif pitems is not None: 282 | return [pitems] 283 | return None 284 | 285 | items = [] 286 | for pid in param_ids: 287 | # Base case, first label parameter. 288 | pitems = parseParam(pid) 289 | # Repeat checking for extended label parameters until none is found. 290 | i = 2 291 | while pitems is not None: 292 | # If any values were parsed, append. 293 | items.append(pitems) 294 | pitems = parseParam("%s%i" % (pid, i)) 295 | i += 1 296 | 297 | if len(items) > 0: 298 | return np.concatenate(items) 299 | else: 300 | return np.array([]) 301 | 302 | def parse_labels(self, group_id, param_ids=['LABELS']): 303 | ''' Get a list of labels from a group. 304 | 305 | Params: 306 | ---- 307 | group_id: Group from which the labels should be parsed. 308 | param_ids: List of parameter identifiers for which label information is stored, default is: ['LABELS']. 309 | Note that all label parameters will be checked for extended formats such as 310 | Returns: Numpy array of label strings. 311 | ''' 312 | return self.parse_multi_parameter(group_id, param_ids, C3DParseDictionary.parse_param_string) 313 | 314 | def point_labels(self, empty_label_prefix='EMPTY', missing_label_prefix='UNKNOWN'): 315 | ''' Determine a set of unique labels for POINT data. 316 | ''' 317 | labels = self.parse_labels('POINT') 318 | 319 | used_label_count = self.reader.point_used 320 | if used_label_count == 0: 321 | return [] 322 | 323 | if len(labels) >= used_label_count: 324 | # Return only labels associated with POINT data. 325 | return labels[:used_label_count] 326 | else: 327 | # Generate labels if the number of parsed count is less then POINT samples. 328 | unknown = ['%s_%00i' % (missing_label_prefix, i) for i in range(used_label_count - len(labels))] 329 | return np.concatenate((labels, unknown)) 330 | 331 | @staticmethod 332 | def make_labels_unique(labels, empty_label_prefix='EMPTY'): 333 | ''' Convert a list of string labels to an unique set of label strings on form 'LABEL_XX'. 334 | 335 | Params: 336 | ---- 337 | labels: List of label strings. 338 | empty_label_prefix: Empty label strings will be replaced with the prefix. 339 | Returns: Numpy list of label strings. 340 | ''' 341 | 342 | # Count duplicate labels. 343 | unique_labels, indices, count = np.unique(labels, return_inverse=True, return_counts=True) 344 | out_list = [None] * len(labels) 345 | counter = np.zeros(len(indices), np.int32) 346 | for i in range(len(indices)): 347 | index = indices[i] 348 | label = labels[i] if labels[i] != '' else empty_label_prefix 349 | # If duplicate labels exist, make unique. 350 | if count[index] > 1: 351 | counter[index] += 1 352 | # Generate unique label for repeated labels (if empty use prefix). 353 | label = '%s_%02i' % (label, counter[index]) 354 | out_list[i] = label 355 | return np.array(out_list) 356 | 357 | def generate_label_mask(self, labels, group='POINT'): 358 | ''' Generate a mask for specified labels in accordance with common/software specific rules. 359 | 360 | Parameters: 361 | ----- 362 | labels: Labels for which the mask should be generated. 363 | group: Group labels are associated with, should be 'POINT' or 'ANALOG'. 364 | Return: Mask defined using a numpy bool array of equal shape to label argument. 365 | ''' 366 | soft_dict = self.software_dictionary() 367 | if soft_dict is not None: 368 | return self.generate_software_label_mask(soft_dict, labels, group) 369 | return np.ones(np.shape(labels), dtype=bool) 370 | 371 | def generate_software_label_mask(self, soft_dict, labels, group='POINT'): 372 | ''' Generate a label mask in regard to the software used to generate the file. 373 | Parameters are defined by software_dictionary(). 374 | 375 | Parameters: 376 | ----- 377 | soft_dict: Python dict as fetched using software_dictionary(). 378 | labels: Labels for which the mask should be generated. 379 | group: Group labels are associated with, should be 'POINT' or 'ANALOG'. 380 | Return: Mask defined using a numpy bool array of equal shape to label argument. 381 | ''' 382 | mask = np.ones(np.shape(labels), dtype=bool) 383 | equal, contain, param = soft_dict['%s_EXCLUDE' % group] 384 | 385 | def contains_seq(item, words): 386 | ''' Checks if any word in words matches a sequence in the item. ''' 387 | for word in words: 388 | if word in item: 389 | return True 390 | return False 391 | 392 | # Remove labels equivalent to words defined in the parameter and words defined in the dict: 393 | equal = np.concatenate((equal, self.parse_labels(group, param))) 394 | if len(equal) > 0: 395 | for i, l in enumerate(labels): 396 | if l in equal: 397 | mask[i] = False 398 | # Remove labels with matching sub-sequences: 399 | if len(contain) > 0: 400 | for i, l in enumerate(labels): 401 | if contains_seq(l, contain): 402 | mask[i] = False 403 | 404 | return mask 405 | 406 | def software_dictionary(self): 407 | ''' Fetch software specific dictionaries defining parameters used to 408 | manage specific software implementations. 409 | ''' 410 | # Concept of a software specific dictionary may not be an optimal solution. 411 | # The approach do however provide some modularity when there is a necessity to 412 | # vary the approach used when parsing files generated from specific exporters. 413 | # Changes and adaptations are welcome if relevant. 414 | # 415 | software = self.parse_param_string('MANUFACTURER', 'SOFTWARE') 416 | 417 | if software is not None: 418 | if 'vicon' in software.lower(): 419 | return C3DParseDictionary.vicon_dictionary() 420 | # No specific software matched. 421 | return None 422 | 423 | @staticmethod 424 | def vicon_dictionary(): 425 | return { 426 | 'POINT_EXCLUDE': [[], [], ['ANGLES', 'FORCES', 'POWERS', 'MOMENTS']] # Equal, contain, parameter. 427 | } 428 | 429 | """ 430 | -------------------------------------------------------- 431 | Parameter dictionary 432 | -------------------------------------------------------- 433 | """ 434 | 435 | def define_parse_function(self, param_id, function): 436 | ''' Append a parsing method to the dictionary 437 | ''' 438 | self.parse_dict[param_id] = function 439 | 440 | @staticmethod 441 | def define_basic_dictionary(): 442 | ''' Basic dictionary 443 | ''' 444 | return { 445 | 'USED': C3DParseDictionary.parse_param_int_array, 446 | 'FRAMES': C3DParseDictionary.parse_param_any_integer, # Try to convert to integer in any way. 447 | 'DATA_START': C3DParseDictionary.parse_param_int_array, 448 | 'SCALE': C3DParseDictionary.parse_param_float_array, 449 | 'RATE': C3DParseDictionary.parse_param_float_array, 450 | # 'MOVIE_DELAY':C3DParseDictionary.parse_param_int_array, 451 | 'MOVIE_ID': C3DParseDictionary.parse_param_string, 452 | 'X_SCREEN': C3DParseDictionary.parse_param_string, 453 | 'Y_SCREEN': C3DParseDictionary.parse_param_string, 454 | 'UNITS': C3DParseDictionary.parse_param_string, 455 | 'LABELS': C3DParseDictionary.parse_param_string, 456 | 'DESCRIPTIONS': C3DParseDictionary.parse_param_string, 457 | # Test cases stored START/END fields as as uint32 but in 2 16 bit words.. 458 | 'ACTUAL_START_FIELD': C3DParseDictionary.parse_param_uint, 459 | 'ACTUAL_END_FIELD': C3DParseDictionary.parse_param_uint, 460 | # or the same parameter as both a 32 bit floating point and 32 bit unsigned integer (in different files)! 461 | 'LONG_FRAMES': C3DParseDictionary.parse_param_any_integer, 462 | } 463 | 464 | """ 465 | -------------------------------------------------------- 466 | Parameter parsing functions 467 | -------------------------------------------------------- 468 | """ 469 | 470 | def try_parse_param(self, group_id, param_id): 471 | ''' Try parse a specified parameter, if parsing is not specified through the 472 | parse dictionary it will attempt to guess the appropriate format. 473 | ''' 474 | value = self.parse_known_param(group_id, param_id) 475 | if value is None: # Verify fetch. 476 | return self.parse_param_any(group_id, param_id) 477 | return value 478 | 479 | def parse_known_param(self, group_id, param_id): 480 | ''' Parse attributes defined in the parsing dictionary 481 | ''' 482 | func = self.parse_dict.get(param_id) 483 | if func is None: 484 | return None 485 | return func(self, group_id, param_id) 486 | 487 | def parse_param_any(self, group_id, param_id): 488 | ''' 489 | Parse param as either a 32 bit floating point value or an integer unsigned integer representation 490 | (including string representations). 491 | 492 | Params: 493 | ---- 494 | group_id: Parameter group id. 495 | Param_id: Parameter id. 496 | Returns: Value(s) of interpreted type either as a single element of the type or array. 497 | ''' 498 | param = self.get_param(group_id, param_id) 499 | if param is None: 500 | return None 501 | if param.bytes_per_element == 4: 502 | return self.parse_param_float_array(group_id, param_id) 503 | else: 504 | return self.parse_param_uint_array(group_id, param_id) 505 | 506 | def parse_param_string(self, group_id, param_id): 507 | ''' Get a string or list of strings from the specified parameter. 508 | 509 | Params: 510 | ---- 511 | group_id: Parameter group id. 512 | Param_id: Parameter id. 513 | Returns: String or array of strings. 514 | ''' 515 | param = self.get_param(group_id, param_id) 516 | if param is None: 517 | return None 518 | strarr = param.string_array 519 | # Strip 520 | if np.ndim(strarr) == 1 and len(strarr) == 1: 521 | return strarr[0].strip() 522 | for i, v in np.ndenumerate(strarr): 523 | strarr[i] = v.strip() 524 | return strarr 525 | 526 | def parse_param_float_array(self, group_id, param_id): 527 | ''' Get a ndarray of integers from a group parameter. 528 | 529 | Params: 530 | ---- 531 | group_id: Parameter group id. 532 | Param_id: Parameter id. 533 | Returns: Float value or an array of float values. 534 | ''' 535 | param = self.get_param(group_id, param_id) 536 | if(param is None): 537 | return 538 | return param.float_array 539 | 540 | def parse_param_int_array(self, group_id, param_id): 541 | ''' Get a ndarray of integers from a group parameter. 542 | 543 | Params: 544 | ---- 545 | group_id: Parameter group id. 546 | Param_id: Parameter id. 547 | Returns: Integer value or an array of int values. 548 | ''' 549 | param = self.get_param(group_id, param_id) 550 | if(param is None): 551 | return None 552 | if(param.bytes_per_element == -1): 553 | return self.parse_param_string(group_id, param_id) 554 | return param.int_array 555 | 556 | def parse_param_uint_array(self, group_id, param_id): 557 | ''' Get a ndarray of integers from a group parameter. 558 | 559 | Params: 560 | ---- 561 | group_id: Parameter group id. 562 | Param_id: Parameter id. 563 | Returns: Unsigned integer value or an array of uint values. 564 | ''' 565 | param = self.get_param(group_id, param_id) 566 | if(param is None): 567 | return None 568 | if(param.bytes_per_element == -1): 569 | return self.parse_param_string(group_id, param_id) 570 | return param.uint_array 571 | 572 | def parse_param_any_integer(self, group_id, param_id): 573 | ''' Evaluate any reasonable conversion of the parameter to a 32 bit unsigned integer representation. 574 | 575 | Params: 576 | ---- 577 | group_id: Parameter group id. 578 | Param_id: Parameter id. 579 | Returns: Integer or float value (representing an integer). 580 | ''' 581 | param = self.get_param(group_id, param_id) 582 | if(param is None): 583 | return None 584 | return param._as_any_uint 585 | 586 | def parse_param_int(self, group_id, param_id): 587 | ''' Get a single signed integers from a parameter group. 588 | 589 | Params: 590 | ---- 591 | group_id: Parameter group id. 592 | Param_id: Parameter id. 593 | Returns: First 32 bits interpreted as an unsigned integer value. 594 | ''' 595 | param = self.get_param(group_id, param_id) 596 | if(param is None): 597 | return None 598 | return param.int_value 599 | 600 | def parse_param_uint(self, group_id, param_id): 601 | ''' Get a single unsigned integers from a parameter group. 602 | 603 | Params: 604 | ---- 605 | group_id: Parameter group id. 606 | Param_id: Parameter id. 607 | Returns: First 32 bits interpreted as an unsigned integer value. 608 | ''' 609 | param = self.get_param(group_id, param_id) 610 | if(param is None): 611 | return None 612 | return param.uint_value 613 | 614 | def parse_param_float(self, group_id, param_id): 615 | ''' Get a single floating point value from a parameter group. 616 | 617 | Params: 618 | ---- 619 | group_id: Parameter group id. 620 | Param_id: Parameter id. 621 | Returns: First 32 bits interpreted as an floating point value. 622 | ''' 623 | param = self.get_param(group_id, param_id) 624 | if(param is None): 625 | return None 626 | return param.float_value 627 | 628 | """ 629 | -------------------------------------------------------- 630 | Print functions 631 | -------------------------------------------------------- 632 | """ 633 | 634 | def print_header_info(self): 635 | ''' Print header info (partial) for the loaded file 636 | ''' 637 | print("Frames (start,end):\t", self.reader.header.first_frame, self.reader.header.last_frame) 638 | print("POINT Channels:\t\t", self.reader.header.point_count) 639 | print("ANALOG Channels:\t", self.reader.header.analog_count) 640 | print("Frame rate:\t\t", self.reader.header.frame_rate) 641 | print("Analog rate:\t\t", self.reader.header.frame_rate * self.reader.header.analog_per_frame) 642 | print("Data Scalar:\t\t", self.reader.header.scale_factor, " [negative if float representation is used]") 643 | print("Data format:\t\t", self.reader.proc_type) 644 | 645 | def print_param_header(self, group_id, param_id): 646 | ''' Print parameter header information. Prints name, dimension, and byte 647 | information for the parameter struct. 648 | ''' 649 | param = self.get_param(group_id, param_id) 650 | print("Parameter Name: ", param.name) 651 | print("Dimensions: ", dim(param)) 652 | print("Bytes per elem: ", param.bytes_per_element) # , " |-1 indicates string data|") 653 | print("Total Bytes: ", sys.getsizeof(param.bytes)) 654 | 655 | def print_data(self, group_id, param_id): 656 | ''' Print the binary data struct for the specified parameter 657 | ''' 658 | param = self.get_param(group_id, param_id) 659 | if(param is None): 660 | raise RuntimeError("Param ", param_id, " not Found in group ", group_id) 661 | print(param.bytes) 662 | 663 | def print_parameters(self, group_id): 664 | ''' Try parse all parameters in a group and print the result. 665 | ''' 666 | group = self.get_group(group_id) 667 | if group is None: # Verify fetch. 668 | return 669 | for pid in group.keys(): 670 | print('\'' + pid + '\': ', self.try_parse_param(group_id, pid)) 671 | 672 | def print_file(self): 673 | ''' Combination of print_header_info() and print_parameters() over all groups 674 | ''' 675 | print(''), print(''), print("------------------------------") 676 | print("Header:") 677 | print("------------------------------"), print('') 678 | # Header 679 | self.print_header_info() 680 | print(''), print(''), print("------------------------------") 681 | print("Paramaters:") 682 | print("------------------------------") 683 | # All group parameters. 684 | for num, group in self.reader.listed(): 685 | print('') 686 | print('') 687 | print("'" + group.name + "':") 688 | print("------------------------------") 689 | self.print_parameters(group.name) 690 | print("------------------------------") 691 | # end 692 | --------------------------------------------------------------------------------