From e849fb1b1ca6a242b7ce54659b98884062bee11b Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 14 Jun 2021 14:48:38 +0200 Subject: [PATCH 001/120] Minor PEP8 cleanup --- c3d.py | 2 +- test/base.py | 1 + test/zipload.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/c3d.py b/c3d.py index 61dac5d..11b17b9 100644 --- a/c3d.py +++ b/c3d.py @@ -1367,7 +1367,7 @@ def proc_type(self): Get the processory type associated with the data format in the file. """ processor_type = ['PROCESSOR_INTEL', 'PROCESSOR_DEC', 'PROCESSOR_MIPS'] - return processor_type[self.processor-PROCESSOR_INTEL] + return processor_type[self.processor - PROCESSOR_INTEL] class Writer(Manager): diff --git a/test/base.py b/test/base.py index b409c22..afc3ab5 100644 --- a/test/base.py +++ b/test/base.py @@ -4,6 +4,7 @@ VERBOSE = False + class Base(unittest.TestCase): def setUp(self): Zipload.download() diff --git a/test/zipload.py b/test/zipload.py index 0bf5482..3e32f00 100644 --- a/test/zipload.py +++ b/test/zipload.py @@ -37,10 +37,10 @@ def extract(zf): out_path = os.path.join(TEMP, os.path.basename(zf)[:-4]) zip = zipfile.ZipFile(os.path.join(TEMP, zf)) + # Loop equivalent to zip.extractall(out_path) but avoids overwriting files for zf in zip.namelist(): - #zip.extractall(out_path) but avoids overwriting files fpath = os.path.join(out_path, zf) - # If file already exist, don' extract + # If file already exist, don't extract if not os.path.isfile(fpath) and not os.path.isdir(fpath): print('Extracted:', fpath) zip.extract(zf, path=out_path) From 33539ecf1dc28de05d52a9b3260d39793bb6694d Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 17:51:54 +0200 Subject: [PATCH 002/120] check_metadata() -> _check_metadata() processor_convert() -> _processor_convert() --- c3d.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/c3d.py b/c3d.py index 11b17b9..d813887 100644 --- a/c3d.py +++ b/c3d.py @@ -342,7 +342,7 @@ def read(self, handle, fmt=BINARY_FORMAT_READ): # Check long event key self.long_event_labels = self.long_event_labels == 0x3039 - def processor_convert(self, dtypes, handle): + def _processor_convert(self, dtypes, handle): ''' Function interpreting the header once processor type has been determined. ''' @@ -825,7 +825,7 @@ def __init__(self, header=None): self.header = header or Header() self.groups = {} - def check_metadata(self): + def _check_metadata(self): '''Ensure that the metadata in our file is self-consistent.''' assert self.header.point_count == self.point_used, ( 'inconsistent point count! {} header != {} POINT:USED'.format( @@ -1137,7 +1137,7 @@ def seek_param_section_header(): _, _, parameter_blocks, self.processor = struct.unpack('BBBB', buf) self.dtypes = DataTypes(self.processor) # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) - self.header.processor_convert(self.dtypes, handle) + self.header._processor_convert(self.dtypes, handle) # Restart reading the parameter header after parsing processor type buf = seek_param_section_header() @@ -1184,7 +1184,7 @@ def seek_param_section_header(): else: self.add_group(group_id, name, desc) - self.check_metadata() + self._check_metadata() def read_frames(self, copy=True): '''Iterate over the data frames from our C3D file handle. @@ -1440,7 +1440,7 @@ def _write_metadata(self, handle): Write metadata and C3D motion frames to the given file handle. The writer does not close the handle. ''' - self.check_metadata() + self._check_metadata() # header self.header.write(handle) From 23e9f76d262ddfb35735ebb08bacb9d61eadfde2 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 17:52:53 +0200 Subject: [PATCH 003/120] Reader.dtype -> Reader._dtype --- c3d.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/c3d.py b/c3d.py index d813887..54ce83e 100644 --- a/c3d.py +++ b/c3d.py @@ -1135,9 +1135,9 @@ def seek_param_section_header(): # Begin by reading the processor type: buf = seek_param_section_header() _, _, parameter_blocks, self.processor = struct.unpack('BBBB', buf) - self.dtypes = DataTypes(self.processor) + self._dtypes = DataTypes(self.processor) # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) - self.header._processor_convert(self.dtypes, handle) + self.header._processor_convert(self._dtypes, handle) # Restart reading the parameter header after parsing processor type buf = seek_param_section_header() @@ -1150,7 +1150,7 @@ def seek_param_section_header(): if group_id == 0 or chars_in_name == 0: # we've reached the end of the parameter section. break - name = self.dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper() + name = self._dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper() # Read the byte segment associated with the parameter and create a # separate binary stream object from the data. @@ -1167,7 +1167,7 @@ def seek_param_section_header(): # we've just started reading a parameter. if its group doesn't # exist, create a blank one. add the parameter to the group. self.groups.setdefault( - group_id, Group()).add_param(name, self.dtypes, handle=buf) + group_id, Group()).add_param(name, self._dtypes, handle=buf) else: # we've just started reading a group. if a group with the # appropriate id exists already (because we've already created @@ -1223,28 +1223,28 @@ def read_frames(self, copy=True): if is_float: point_word_bytes = 4 - point_dtype = self.dtypes.uint32 + point_dtype = self._dtypes.uint32 else: point_word_bytes = 2 - point_dtype = self.dtypes.int16 + point_dtype = self._dtypes.int16 points = np.zeros((self.point_used, 5), np.float32) # TODO: handle ANALOG:BITS parameter here! p = self.get('ANALOG:FORMAT') analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED' if is_float: - analog_dtype = self.dtypes.float32 + analog_dtype = self._dtypes.float32 analog_word_bytes = 4 elif analog_unsigned: # Note*: Floating point is 'always' defined for both analog and point data, according to the standard. - analog_dtype = self.dtypes.uint16 + analog_dtype = self._dtypes.uint16 analog_word_bytes = 2 # Verify BITS parameter for analog p = self.get('ANALOG:BITS') if p and p._as_integer_value / 8 != analog_word_bytes: raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value)) else: - analog_dtype = self.dtypes.int16 + analog_dtype = self._dtypes.int16 analog_word_bytes = 2 analog = np.array([], float) @@ -1295,7 +1295,7 @@ def read_frames(self, copy=True): else: # If IEEE or MIPS: # Re-read the raw byte representation directly points[:, :4] = np.frombuffer(raw_bytes, - dtype=self.dtypes.float32, + dtype=self._dtypes.float32, count=N_point).reshape((int(self.point_used), 4)) # Parse the camera-observed bits and residuals. @@ -1322,7 +1322,7 @@ def read_frames(self, copy=True): # Parse last 16-bit word as two 8-bit words valid = raw[:, 3] > -1 points[~valid, 3:5] = -1 - c = raw[valid, 3].astype(self.dtypes.uint16) + c = raw[valid, 3].astype(self._dtypes.uint16) # Convert coordinate data # fourth value is floating-point (scaled) error estimate (residual) From 1cf5b503eef2a8cd1aabad0833c58f548a5eba67 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 17:59:33 +0200 Subject: [PATCH 004/120] Manager.header -> Manager._header + @property Manager.header --- c3d.py | 65 +++++++++++++++++++++++++++++++--------------------------- 1 file changed, 35 insertions(+), 30 deletions(-) diff --git a/c3d.py b/c3d.py index 54ce83e..e03cb9a 100644 --- a/c3d.py +++ b/c3d.py @@ -822,26 +822,31 @@ class Manager(object): def __init__(self, header=None): '''Set up a new Manager with a Header.''' - self.header = header or Header() + self._header = header or Header() self.groups = {} + + @property + def header(self): + return self._header + def _check_metadata(self): '''Ensure that the metadata in our file is self-consistent.''' - assert self.header.point_count == self.point_used, ( + assert self._header.point_count == self.point_used, ( 'inconsistent point count! {} header != {} POINT:USED'.format( - self.header.point_count, + self._header.point_count, self.point_used, )) - assert self.header.scale_factor == self.point_scale, ( + assert self._header.scale_factor == self.point_scale, ( 'inconsistent scale factor! {} header != {} POINT:SCALE'.format( - self.header.scale_factor, + self._header.scale_factor, self.point_scale, )) - assert self.header.frame_rate == self.point_rate, ( + assert self._header.frame_rate == self.point_rate, ( 'inconsistent frame rate! {} header != {} POINT:RATE'.format( - self.header.frame_rate, + self._header.frame_rate, self.point_rate, )) @@ -849,26 +854,26 @@ def _check_metadata(self): ratio = self.analog_rate / self.point_rate else: ratio = 0 - assert self.header.analog_per_frame == ratio, ( + assert self._header.analog_per_frame == ratio, ( 'inconsistent analog rate! {} header != {} analog-fps / {} point-fps'.format( - self.header.analog_per_frame, + self._header.analog_per_frame, self.analog_rate, self.point_rate, )) - count = self.analog_used * self.header.analog_per_frame - assert self.header.analog_count == count, ( + count = self.analog_used * self._header.analog_per_frame + assert self._header.analog_count == count, ( 'inconsistent analog count! {} header != {} analog used * {} per-frame'.format( - self.header.analog_count, + self._header.analog_count, self.analog_used, - self.header.analog_per_frame, + self._header.analog_per_frame, )) try: start = self.get_uint16('POINT:DATA_START') - if self.header.data_block != start: + if self._header.data_block != start: warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( - self.header.data_block, start)) + self._header.data_block, start)) except AttributeError: warnings.warn('''no pointer available in POINT:DATA_START indicating the start of the data block, using header pointer as fallback''') @@ -1128,7 +1133,7 @@ def __init__(self, handle): def seek_param_section_header(): ''' Seek to and read the first 4 byte of the parameter header section ''' - self._handle.seek((self.header.parameter_block - 1) * 512) + self._handle.seek((self._header.parameter_block - 1) * 512) # metadata header return self._handle.read(4) @@ -1137,7 +1142,7 @@ def seek_param_section_header(): _, _, parameter_blocks, self.processor = struct.unpack('BBBB', buf) self._dtypes = DataTypes(self.processor) # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) - self.header._processor_convert(self._dtypes, handle) + self._header._processor_convert(self._dtypes, handle) # Restart reading the parameter header after parsing processor type buf = seek_param_section_header() @@ -1264,7 +1269,7 @@ def read_frames(self, copy=True): gen_scale = param.float_value # Seek to the start point of the data blocks - self._handle.seek((self.header.data_block - 1) * 512) + self._handle.seek((self._header.data_block - 1) * 512) # Number of values (words) read in regard to POINT/ANALOG data N_point = 4 * self.point_used N_analog = self.analog_used * self.analog_per_frame @@ -1442,12 +1447,12 @@ def _write_metadata(self, handle): ''' self._check_metadata() - # header - self.header.write(handle) + # Header + self._header.write(handle) self._pad_block(handle) assert handle.tell() == 512 - # groups + # Groups handle.write(struct.pack( 'BBBB', 0, 0, self.parameter_blocks(), PROCESSOR_INTEL)) id_groups = sorted( @@ -1455,7 +1460,7 @@ def _write_metadata(self, handle): for group_id, group in id_groups: group.write(group_id, handle) - # padding + # Padding self._pad_block(handle) while handle.tell() != 512 * (self.header.data_block - 1): handle.write(b'\x00' * 512) @@ -1469,7 +1474,7 @@ def _write_frames(self, handle): Write metadata and C3D motion frames to the given file handle. The writer does not close the handle. ''' - assert handle.tell() == 512 * (self.header.data_block - 1) + assert handle.tell() == 512 * (self._header.data_block - 1) scale = abs(self.point_scale) is_float = self.point_scale < 0 if is_float: @@ -1573,13 +1578,13 @@ def add_empty_array(name, desc, bpe): blocks = self.parameter_blocks() self.get('POINT:DATA_START').bytes = struct.pack(' Date: Sun, 27 Jun 2021 18:02:46 +0200 Subject: [PATCH 005/120] Header property descriptor --- c3d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c3d.py b/c3d.py index e03cb9a..ca2bd37 100644 --- a/c3d.py +++ b/c3d.py @@ -824,12 +824,12 @@ def __init__(self, header=None): '''Set up a new Manager with a Header.''' self._header = header or Header() self.groups = {} - + @property def header(self): + ''' Access the parsed c3d header. ''' return self._header - def _check_metadata(self): '''Ensure that the metadata in our file is self-consistent.''' assert self._header.point_count == self.point_used, ( From 5f43a05023ed790fb51bccc6f5c35d5e580e9763 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 19:43:13 +0200 Subject: [PATCH 006/120] Manager.group -> Manager._group + Manager.group_items + Manager.group_listed --- c3d.py | 46 +++++++++++++++++++++++++++++++------------- scripts/c3d-metadata | 9 ++++----- 2 files changed, 37 insertions(+), 18 deletions(-) diff --git a/c3d.py b/c3d.py index ca2bd37..51eb2d3 100644 --- a/c3d.py +++ b/c3d.py @@ -823,13 +823,35 @@ class Manager(object): def __init__(self, header=None): '''Set up a new Manager with a Header.''' self._header = header or Header() - self.groups = {} + self._groups = {} @property def header(self): ''' Access the parsed c3d header. ''' return self._header + def group(self, key): + ''' Access a paramater group from a group key. + + Attributes + ---------- + key : int or str + Index or name for the parameter group. + ''' + return self._groups[key] + + @property + def group_items(self): + ''' Acquire iterable over group parameter pairs (str key, group). + ''' + return ((k, v) for k, v in self._groups if isinstance(k, str)) + + @property + def group_listed(self): + ''' Acquire iterable over sorted group parameter pairs (int key, group). + ''' + return sorted((i, g) for i, g in self._groups if isinstance(i, int)) + def _check_metadata(self): '''Ensure that the metadata in our file is self-consistent.''' assert self._header.point_count == self.point_used, ( @@ -914,12 +936,12 @@ def add_group(self, group_id, name, desc): KeyError If a group with a duplicate ID or name already exists. ''' - if group_id in self.groups: + if group_id in self._groups: raise KeyError(group_id) name = name.upper() - if name in self.groups: + if name in self._groups: raise KeyError(name) - group = self.groups[name] = self.groups[group_id] = Group(name, desc) + group = self._groups[name] = self._groups[group_id] = Group(name, desc) return group def get(self, group, default=None): @@ -943,16 +965,16 @@ def get(self, group, default=None): is found, returns the default value. ''' if isinstance(group, int): - return self.groups.get(group, default) + return self._groups.get(group, default) group = group.upper() param = None if '.' in group: group, param = group.split('.', 1) if ':' in group: group, param = group.split(':', 1) - if group not in self.groups: + if group not in self._groups: return default - group = self.groups[group] + group = self._groups[group] if param is not None: return group.get(param, default) return group @@ -995,7 +1017,7 @@ def get_string(self, key): def parameter_blocks(self): '''Compute the size (in 512B blocks) of the parameter section.''' - bytes = 4. + sum(g.binary_size() for g in self.groups.values()) + bytes = 4. + sum(g.binary_size() for g in self._groups.values()) return int(np.ceil(bytes / 512)) @property @@ -1171,7 +1193,7 @@ def seek_param_section_header(): if group_id > 0: # we've just started reading a parameter. if its group doesn't # exist, create a blank one. add the parameter to the group. - self.groups.setdefault( + self._groups.setdefault( group_id, Group()).add_param(name, self._dtypes, handle=buf) else: # we've just started reading a group. if a group with the @@ -1185,7 +1207,7 @@ def seek_param_section_header(): if group is not None: group.name = name group.desc = desc - self.groups[name] = group + self._groups[name] = group else: self.add_group(group_id, name, desc) @@ -1455,9 +1477,7 @@ def _write_metadata(self, handle): # Groups handle.write(struct.pack( 'BBBB', 0, 0, self.parameter_blocks(), PROCESSOR_INTEL)) - id_groups = sorted( - (i, g) for i, g in self.groups.items() if isinstance(i, int)) - for group_id, group in id_groups: + for group_id, group in self.group_listed(): group.write(group_id, handle) # Padding diff --git a/scripts/c3d-metadata b/scripts/c3d-metadata index 2cec59b..14d4c88 100755 --- a/scripts/c3d-metadata +++ b/scripts/c3d-metadata @@ -15,12 +15,11 @@ parser.add_argument('input', default='-', metavar='FILE', nargs='+', def print_metadata(reader): print('Header information:\n{}'.format(reader.header)) - groups = ((k, v) for k, v in reader.groups.items() if isinstance(k, str)) + groups = reader.group_items() for key, g in sorted(groups): - if not isinstance(key, int): - print('') - for key, p in sorted(g.params.items()): - print_param(g, p) + print('') + for key, p in sorted(g.params.items()): + print_param(g, p) def print_param(g, p): From 437de96ccc1e6859d2e161c1d4c2f29aae933776 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 19:51:53 +0200 Subject: [PATCH 007/120] Replaced Reader.processor to use DataTypes.proc_type instead. --- c3d.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/c3d.py b/c3d.py index 51eb2d3..3b16379 100644 --- a/c3d.py +++ b/c3d.py @@ -1161,14 +1161,13 @@ def seek_param_section_header(): # Begin by reading the processor type: buf = seek_param_section_header() - _, _, parameter_blocks, self.processor = struct.unpack('BBBB', buf) - self._dtypes = DataTypes(self.processor) + _, _, parameter_blocks, processor = struct.unpack('BBBB', buf) + self._dtypes = DataTypes(processor) # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) self._header._processor_convert(self._dtypes, handle) # Restart reading the parameter header after parsing processor type buf = seek_param_section_header() - is_mips = self.processor == PROCESSOR_MIPS start_byte = self._handle.tell() endbyte = start_byte + 512 * parameter_blocks - 4 @@ -1181,7 +1180,7 @@ def seek_param_section_header(): # Read the byte segment associated with the parameter and create a # separate binary stream object from the data. - offset_to_next, = struct.unpack(['h'][is_mips], self._handle.read(2)) + offset_to_next, = struct.unpack(['h'][self._dtypes.is_mips], self._handle.read(2)) if offset_to_next == 0: # Last parameter, as number of bytes are unknown, # read the remaining bytes in the parameter section. @@ -1316,7 +1315,7 @@ def read_frames(self, copy=True): if is_float: # Convert every 4 byte words to a float-32 reprensentation # (the fourth column is still not a float32 representation) - if self.processor == PROCESSOR_DEC: + if self._dtypes.is_dec: # Convert each of the first 6 16-bit words from DEC to IEEE float points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4)) else: # If IEEE or MIPS: @@ -1361,7 +1360,7 @@ def read_frames(self, copy=True): # Check if analog data exist, and parse if so if N_analog > 0: - if is_float and self.processor == PROCESSOR_DEC: + if is_float and self._dtypes.is_dec: # Convert each of the 16-bit words from DEC to IEEE float analog = DEC_to_IEEE_BYTES(raw_analog) else: @@ -1394,7 +1393,7 @@ def proc_type(self): Get the processory type associated with the data format in the file. """ processor_type = ['PROCESSOR_INTEL', 'PROCESSOR_DEC', 'PROCESSOR_MIPS'] - return processor_type[self.processor - PROCESSOR_INTEL] + return processor_type[self._dtypes.proc_type - PROCESSOR_INTEL] class Writer(Manager): From 18315669f0fa12612b2895df419bebcc40cc4c84 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 20:13:55 +0200 Subject: [PATCH 008/120] Header.interpret_events() -> Header._parse_events() --- c3d.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/c3d.py b/c3d.py index 3b16379..eb8bcde 100644 --- a/c3d.py +++ b/c3d.py @@ -362,10 +362,10 @@ def _processor_convert(self, dtypes, handle): self.frame_rate = UNPACK_FLOAT_IEEE(self.frame_rate) float_unpack = UNPACK_FLOAT_IEEE - self.interpret_events(dtypes, float_unpack) + self._parse_events(dtypes, float_unpack) - def interpret_events(self, dtypes, float_unpack): - ''' Function interpreting the event section of the header. + def _parse_events(self, dtypes, float_unpack): + ''' Parse the event section of the header. ''' # Event section byte blocks From ca797297f237b43396870800912afe5baa02ec20 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 21:12:14 +0200 Subject: [PATCH 009/120] Added Param properties for: + int_array, uint_array, int64_array, uint64_array float32_array, float64_array +- float_array now returns either float32 or float64 arrays. + int_array/uint_array return either 1/2/4/8 bit integer arrays. --- c3d.py | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 2 deletions(-) diff --git a/c3d.py b/c3d.py index eb8bcde..723e8b8 100644 --- a/c3d.py +++ b/c3d.py @@ -336,7 +336,7 @@ def read(self, handle, fmt=BINARY_FORMAT_READ): self.event_block, _) = struct.unpack(fmt, raw) - # Check magic number if reading in little endian + # Check magic number assert magic == 80, 'C3D magic {} != 80 !'.format(magic) # Check long event key @@ -638,7 +638,17 @@ def uint32_array(self): return self._as_array(self.dtype.uint32) @property - def float_array(self): + def int64_array(self): + '''Get the param as an array of 32-bit signed integers.''' + return self._as_array(self.dtype.int64) + + @property + def uint64_array(self): + '''Get the param as an array of 32-bit unsigned integers.''' + return self._as_array(self.dtype.uint64) + + @property + def float32_array(self): '''Get the param as an array of 32-bit floats.''' # Convert float data if not IEEE processor if self.dtype.is_dec: @@ -649,6 +659,59 @@ def float_array(self): else: # is_ieee or is_mips return self._as_array(self.dtype.float32) + @property + def float64_array(self): + '''Get the param as an array of 64-bit floats.''' + # Convert float data if not IEEE processor + if self.dtype.is_dec: + raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') + else: # is_ieee or is_mips + return self._as_array(self.dtype.float64) + + @property + def float_array(self): + '''Get the param as an array of 32 or 64 bit floats.''' + # Convert float data if not IEEE processor + if self.bytes_per_element == 4: + return self.float32_array + elif self.bytes_per_element == 8: + return self.float64_array + else: + raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element + + "floating-point precission is not unsupported." %) + + @property + def int_array(self): + '''Get the param as an array of integer values.''' + # Convert float data if not IEEE processor + if self.bytes_per_element == 1: + return self.int8_array + elif self.bytes_per_element == 2: + return self.int16_array + elif self.bytes_per_element == 4: + return self.int32_array + elif self.bytes_per_element == 8: + return self.int64_array + else: + raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % + self.bytes_per_element) + + @property + def uint_array(self): + '''Get the param as an array of integer values.''' + # Convert float data if not IEEE processor + if self.bytes_per_element == 1: + return self.uint8_array + elif self.bytes_per_element == 2: + return self.uint16_array + elif self.bytes_per_element == 4: + return self.uint32_array + elif self.bytes_per_element == 8: + return self.uint64_array + else: + raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % + self.bytes_per_element) + @property def bytes_array(self): '''Get the param as an array of raw byte strings.''' From 1c41d8bb720b0e1685afcab55b2cb091977e2853 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 21:12:45 +0200 Subject: [PATCH 010/120] Tailing % --- c3d.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/c3d.py b/c3d.py index 723e8b8..dbd0f4a 100644 --- a/c3d.py +++ b/c3d.py @@ -678,7 +678,7 @@ def float_array(self): return self.float64_array else: raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element + - "floating-point precission is not unsupported." %) + "floating-point precission is not unsupported.") @property def int_array(self): From 73ef1c65ac5f69f389f35f880c70325867171987 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 22:08:06 +0200 Subject: [PATCH 011/120] - Manager.group(), Manager.get() should be used. --- c3d.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/c3d.py b/c3d.py index dbd0f4a..891277a 100644 --- a/c3d.py +++ b/c3d.py @@ -893,30 +893,18 @@ def header(self): ''' Access the parsed c3d header. ''' return self._header - def group(self, key): - ''' Access a paramater group from a group key. - - Attributes - ---------- - key : int or str - Index or name for the parameter group. - ''' - return self._groups[key] - @property def group_items(self): - ''' Acquire iterable over group parameter pairs (str key, group). - ''' + ''' Acquire iterable over group parameter pairs (str key, group). ''' return ((k, v) for k, v in self._groups if isinstance(k, str)) @property def group_listed(self): - ''' Acquire iterable over sorted group parameter pairs (int key, group). - ''' + ''' Acquire iterable over sorted group parameter pairs (int key, group). ''' return sorted((i, g) for i, g in self._groups if isinstance(i, int)) def _check_metadata(self): - '''Ensure that the metadata in our file is self-consistent.''' + ''' Ensure that the metadata in our file is self-consistent. ''' assert self._header.point_count == self.point_used, ( 'inconsistent point count! {} header != {} POINT:USED'.format( self._header.point_count, From dbd962c8f06c95ed3d2c6fd226132b6b15da335f Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 23:51:36 +0200 Subject: [PATCH 012/120] Changes ---- Param.dtype -> Param._dtypes Group.name, Group.desc -> Group._name, Group._desc Additions ---- + Group._dtypes + rename_group(), rename_param(), remove_group(), remove_param() --- c3d.py | 221 ++++++++++++++++++++++++++++++++----------- scripts/c3d-metadata | 5 +- 2 files changed, 170 insertions(+), 56 deletions(-) diff --git a/c3d.py b/c3d.py index 891277a..62acbd7 100644 --- a/c3d.py +++ b/c3d.py @@ -435,7 +435,7 @@ def __init__(self, handle=None): '''Set up a new parameter, only the name is required.''' self.name = name - self.dtype = dtype + self._dtypes = dtype self.desc = desc self.bytes_per_element = bytes_per_element self.dimensions = dimensions or [] @@ -509,7 +509,7 @@ def read(self, handle): if self.total_bytes: self.bytes = handle.read(self.total_bytes) desc_size, = struct.unpack('B', handle.read(1)) - self.desc = desc_size and self.dtype.decode_string(handle.read(desc_size)) or '' + self.desc = desc_size and self._dtypes.decode_string(handle.read(desc_size)) or '' def _as(self, dtype): '''Unpack the raw bytes of this param using the given struct format.''' @@ -562,40 +562,40 @@ def _as_integer_value(self): @property def int8_value(self): '''Get the param as an 8-bit signed integer.''' - return self._as(self.dtype.int8) + return self._as(self._dtypes.int8) @property def uint8_value(self): '''Get the param as an 8-bit unsigned integer.''' - return self._as(self.dtype.uint8) + return self._as(self._dtypes.uint8) @property def int16_value(self): '''Get the param as a 16-bit signed integer.''' - return self._as(self.dtype.int16) + return self._as(self._dtypes.int16) @property def uint16_value(self): '''Get the param as a 16-bit unsigned integer.''' - return self._as(self.dtype.uint16) + return self._as(self._dtypes.uint16) @property def int32_value(self): '''Get the param as a 32-bit signed integer.''' - return self._as(self.dtype.int32) + return self._as(self._dtypes.int32) @property def uint32_value(self): '''Get the param as a 32-bit unsigned integer.''' - return self._as(self.dtype.uint32) + return self._as(self._dtypes.uint32) @property def float_value(self): '''Get the param as a 32-bit float.''' - if self.dtype.is_dec: + if self._dtypes.is_dec: return DEC_to_IEEE(self._as(np.uint32)) else: # is_mips or is_ieee - return self._as(self.dtype.float32) + return self._as(self._dtypes.float32) @property def bytes_value(self): @@ -605,68 +605,68 @@ def bytes_value(self): @property def string_value(self): '''Get the param as a unicode string.''' - return self.dtype.decode_string(self.bytes) + return self._dtypes.decode_string(self.bytes) @property def int8_array(self): '''Get the param as an array of 8-bit signed integers.''' - return self._as_array(self.dtype.int8) + return self._as_array(self._dtypes.int8) @property def uint8_array(self): '''Get the param as an array of 8-bit unsigned integers.''' - return self._as_array(self.dtype.uint8) + return self._as_array(self._dtypes.uint8) @property def int16_array(self): '''Get the param as an array of 16-bit signed integers.''' - return self._as_array(self.dtype.int16) + return self._as_array(self._dtypes.int16) @property def uint16_array(self): '''Get the param as an array of 16-bit unsigned integers.''' - return self._as_array(self.dtype.uint16) + return self._as_array(self._dtypes.uint16) @property def int32_array(self): '''Get the param as an array of 32-bit signed integers.''' - return self._as_array(self.dtype.int32) + return self._as_array(self._dtypes.int32) @property def uint32_array(self): '''Get the param as an array of 32-bit unsigned integers.''' - return self._as_array(self.dtype.uint32) + return self._as_array(self._dtypes.uint32) @property def int64_array(self): '''Get the param as an array of 32-bit signed integers.''' - return self._as_array(self.dtype.int64) + return self._as_array(self._dtypes.int64) @property def uint64_array(self): '''Get the param as an array of 32-bit unsigned integers.''' - return self._as_array(self.dtype.uint64) + return self._as_array(self._dtypes.uint64) @property def float32_array(self): '''Get the param as an array of 32-bit floats.''' # Convert float data if not IEEE processor - if self.dtype.is_dec: + if self._dtypes.is_dec: # _as_array but for DEC assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, self.dtype.float32) + '{}: cannot get value as {} array!'.format(self.name, self._dtypes.float32) return DEC_to_IEEE_BYTES(self.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format else: # is_ieee or is_mips - return self._as_array(self.dtype.float32) + return self._as_array(self._dtypes.float32) @property def float64_array(self): '''Get the param as an array of 64-bit floats.''' # Convert float data if not IEEE processor - if self.dtype.is_dec: + if self._dtypes.is_dec: raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') else: # is_ieee or is_mips - return self._as_array(self.dtype.float64) + return self._as_array(self._dtypes.float64) @property def float_array(self): @@ -746,7 +746,7 @@ def string_array(self): byte_arr = self.bytes_array # Decode sequences for i in np.ndindex(byte_arr.shape): - byte_arr[i] = self.dtype.decode_string(byte_arr[i]) + byte_arr[i] = self._dtypes.decode_string(byte_arr[i]) return byte_arr @@ -758,20 +758,67 @@ class Group(object): Attributes ---------- + dtypes : 'DataTypes' + Data types object used for parsing. name : str Name of this parameter group. desc : str Description for this parameter group. ''' - def __init__(self, name=None, desc=None): + def __init__(self, dtypes, name=None, desc=None): + self._params = {} + self._dtypes = dtypes self.name = name self.desc = desc - self.params = {} def __repr__(self): return ''.format(self.desc) + @property + def name(self): + ''' Group name. ''' + return self._name + + @name.setter + def name(self, value): + ''' Group name string. + + Parameters + ---------- + value : str + New name for the group. + ''' + if value is None or isinstance(value, str): + self._name = value + else: + raise TypeError('Expected group name to be string, was %s.' % type(value)) + + @property + def desc(self): + ''' Group descriptor. ''' + if isinstance(self._desc, bytes): + self._dtypes.decode_string(self._desc) + return self._desc + + @desc.setter + def desc(self, value): + ''' Group descriptor. + + Parameters + ---------- + value : str, or bytes + New description for this parameter group. + ''' + if value is not None and not isinstance(value, (str, bytes)): + raise TypeError('Expected descriptor to be byte string or python string, was %s.' % type(value)) + self._desc = value + + @property + def param_items(self): + ''' Acquie group parameter iterator for key-value pairs. ''' + return params.items() + def get(self, key, default=None): '''Get a parameter by key. @@ -787,7 +834,7 @@ def get(self, key, default=None): param : :class:`Param` A parameter from the current group. ''' - return self.params.get(key, default) + return self._params.get(key, default) def add_param(self, name, dtypes, **kwargs): '''Add a parameter to this group. @@ -802,16 +849,46 @@ def add_param(self, name, dtypes, **kwargs): Additional keyword arguments will be passed to the `Param` constructor. ''' - self.params[name.upper()] = Param(name.upper(), dtypes, **kwargs) + self._params[name.upper()] = Param(name.upper(), dtypes, **kwargs) + + def remove_param(self, name): + '''Remove the specified parameter. + + Parameters + ---------- + name : str + Name for the parameter to remove. + ''' + del self._params[group_id] + + def rename_param(self, name, new_name): + ''' Rename a specified parameter group. + + Parameters + ---------- + name : str, or 'Param' + Parameter instance, or name. + new_name : str + New name for the parameter. + ''' + if isinstance(name, Param): + param = name + else: + # Aquire instance using id + param = self._params.get(name, None) + if param is None: + raise ValueError('No parameter found matching the identifier: %s' % str(name)) + del self._params[name] + self._params[new_name] = param def binary_size(self): '''Return the number of bytes to store this group and its parameters.''' return ( 1 + # group_id - 1 + len(self.name.encode('utf-8')) + # size of name and name bytes + 1 + len(self._name.encode('utf-8')) + # size of name and name bytes 2 + # next offset marker - 1 + len(self.desc.encode('utf-8')) + # size of desc and desc bytes - sum(p.binary_size() for p in self.params.values())) + 1 + len(self._desc.encode('utf-8')) + # size of desc and desc bytes + sum(p.binary_size() for p in self._params.values())) def write(self, group_id, handle): '''Write this parameter group, with parameters, to a file handle. @@ -823,51 +900,51 @@ def write(self, group_id, handle): handle : file handle An open, writable, binary file handle. ''' - name = self.name.encode('utf-8') - desc = self.desc.encode('utf-8') + name = self._name.encode('utf-8') + desc = self._desc.encode('utf-8') handle.write(struct.pack('bb', len(name), -group_id)) handle.write(name) handle.write(struct.pack(' 0: - # we've just started reading a parameter. if its group doesn't + # We've just started reading a parameter. If its group doesn't # exist, create a blank one. add the parameter to the group. self._groups.setdefault( - group_id, Group()).add_param(name, self._dtypes, handle=buf) + group_id, Group(self._dtypes)).add_param(name, self._dtypes, handle=buf) else: - # we've just started reading a group. if a group with the - # appropriate id exists already (because we've already created - # it for a parameter), just set the name of the group. - # otherwise, add a new group. + # We've just started reading a group. If a group with the + # appropriate numerical id exists already (because we've + # already created it for a parameter), just set the name of + # the group. Otherwise, add a new group. group_id = abs(group_id) size, = struct.unpack('B', buf.read(1)) desc = size and buf.read(size) or '' group = self.get(group_id) if group is not None: - group.name = name + self.rename_group(group, name) group.desc = desc - self._groups[name] = group else: self.add_group(group_id, name, desc) diff --git a/scripts/c3d-metadata b/scripts/c3d-metadata index 14d4c88..5b25eac 100755 --- a/scripts/c3d-metadata +++ b/scripts/c3d-metadata @@ -15,10 +15,9 @@ parser.add_argument('input', default='-', metavar='FILE', nargs='+', def print_metadata(reader): print('Header information:\n{}'.format(reader.header)) - groups = reader.group_items() - for key, g in sorted(groups): + for key, g in sorted(reader.group_items()): print('') - for key, p in sorted(g.params.items()): + for key, p in sorted(g.param_items()): print_param(g, p) From 67ea346fac4d12b0c360225d39ca21bfb064ea1f Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 27 Jun 2021 23:54:40 +0200 Subject: [PATCH 013/120] Decode when setting descriptor. --- c3d.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/c3d.py b/c3d.py index 62acbd7..88b37ee 100644 --- a/c3d.py +++ b/c3d.py @@ -769,6 +769,7 @@ class Group(object): def __init__(self, dtypes, name=None, desc=None): self._params = {} self._dtypes = dtypes + # Assign through property setters self.name = name self.desc = desc @@ -797,8 +798,6 @@ def name(self, value): @property def desc(self): ''' Group descriptor. ''' - if isinstance(self._desc, bytes): - self._dtypes.decode_string(self._desc) return self._desc @desc.setter @@ -810,7 +809,9 @@ def desc(self, value): value : str, or bytes New description for this parameter group. ''' - if value is not None and not isinstance(value, (str, bytes)): + if isinstance(value, bytes): + self._desc = self._dtypes.decode_string(value) + elif value is not None and not isinstance(value, str): raise TypeError('Expected descriptor to be byte string or python string, was %s.' % type(value)) self._desc = value From f33c720e78c4eb393224ae7f062dec2c46bf67b7 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 00:04:33 +0200 Subject: [PATCH 014/120] Assert numeric group id replacement doesnt exist --- c3d.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/c3d.py b/c3d.py index 88b37ee..0c63bad 100644 --- a/c3d.py +++ b/c3d.py @@ -1090,7 +1090,7 @@ def rename_group(self, group_id, new_group_id): ---------- group_id : int, str, or 'Group' Group instance, name, or numerical identifier for the group. - name : str, or int + new_group_id : str, or int If string, it is the new name for the group. If integer, it will replace its numerical group id. ''' if isinstance(group_id, Group): @@ -1100,6 +1100,11 @@ def rename_group(self, group_id, new_group_id): grp = self._groups.get(group_id, None) if grp is None: raise ValueError('No group found matching the identifier: %s' % str(group_id)) + if isinstance(new_group_id, int) and new_group_id in self._groups: + if new_group_id == group_id: + return + raise ValueError('New numeric group identifier %i for group %s already exist.' % (new_group_id, grp.name)) + # Clear old id if isinstance(new_group_id, str): if grp.name in _groups: From 16398154589bad5cc2ccc7cbdd51c8e489840bb5 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 16:56:00 +0200 Subject: [PATCH 015/120] + is_integer() + Comments + Code corrections --- c3d.py | 67 ++++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 18 deletions(-) diff --git a/c3d.py b/c3d.py index 0c63bad..d1c09a7 100644 --- a/c3d.py +++ b/c3d.py @@ -164,6 +164,9 @@ def DEC_to_IEEE_BYTES(bytes): dtype=np.float32, count=int(len(bytes) / 4)) +def is_integer(value): + '''Check if value input is integer.''' + return isinstance(value, (int, np.int32, np.int64)) class Header(object): '''Header information from a C3D file. @@ -973,13 +976,25 @@ def header(self): @property def group_items(self): - ''' Acquire iterable over group parameter pairs (str key, group). ''' - return ((k, v) for k, v in self._groups if isinstance(k, str)) + ''' Acquire iterable over parameter group pairs. + + Returns + ------- + items : Touple of ((str, :class:`Group`), ...) + Python touple containing pairs of name keys and parameter group entries. + ''' + return ((k, v) for k, v in self._groups.items() if isinstance(k, str)) @property def group_listed(self): - ''' Acquire iterable over sorted group parameter pairs (int key, group). ''' - return sorted((i, g) for i, g in self._groups if isinstance(i, int)) + ''' Acquire iterable over sorted numerical parameter group pairs. + + Returns + ------- + items : Touple of ((int, :class:`Group`), ...) + Sorted python touple containing pairs of numerical keys and parameter group entries. + ''' + return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int)) def _check_metadata(self): ''' Ensure that the metadata in our file is self-consistent. ''' @@ -1062,9 +1077,14 @@ def add_group(self, group_id, name, desc): Raises ------ - KeyError - If a group with a duplicate ID or name already exists. + TypeError + Input arguments are of the wrong type. ''' + if not is_integer(group_id): + raise ValueError('Expected Group numerical key to be integer, was %s.' % type(group_id)) + if not isinstance(name, str): + raise ValueError('Expected Group name key to be string, was %s.' % type(name) + group_id = int(group_id) # Assert python int if group_id in self._groups: raise KeyError(group_id) name = name.upper() @@ -1079,9 +1099,14 @@ def remove_group(self, group_id): Parameters ---------- group_id : int, or str - The numeric or name ID for a group to remove. + The numeric or name ID key for a group to remove all entries for. ''' - del self._groups[group_id] + grp = self._groups.get(group_id, None) + if grp is None: + return + gkeys = [k for (k, v) in self._groups.items() if v == grp] + for k in gkeys: + del self._groups[k] def rename_group(self, group_id, new_group_id): ''' Rename a specified parameter group. @@ -1092,6 +1117,11 @@ def rename_group(self, group_id, new_group_id): Group instance, name, or numerical identifier for the group. new_group_id : str, or int If string, it is the new name for the group. If integer, it will replace its numerical group id. + + Raises + ------ + KeyError + If a group with a duplicate ID or name already exists. ''' if isinstance(group_id, Group): grp = group_id @@ -1099,21 +1129,22 @@ def rename_group(self, group_id, new_group_id): # Aquire instance using id grp = self._groups.get(group_id, None) if grp is None: - raise ValueError('No group found matching the identifier: %s' % str(group_id)) - if isinstance(new_group_id, int) and new_group_id in self._groups: + raise KeyError('No group found matching the identifier: %s' % str(group_id)) + if new_group_id in self._groups: if new_group_id == group_id: return - raise ValueError('New numeric group identifier %i for group %s already exist.' % (new_group_id, grp.name)) + raise KeyError('New group identifier %s for group %s already exist.' % (str(new_group_id), grp.name)) # Clear old id - if isinstance(new_group_id, str): - if grp.name in _groups: + if isinstance(new_group_id, (str, bytes)): + if grp.name in self._groups: del self._groups[grp.name] - grp.name = new_group_id - elif isinstance(new_group_id, int): + grp._name = new_group_id + elif is_integer(new_group_id): + new_group_id = int(new_group_id) # Ensure python int del self._groups[group_id] else: - raise ValueError('Invalid group identifier of type: %s' % str(type(new_group_id))) + raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) # Update self._groups[new_group_id] = grp @@ -1137,8 +1168,8 @@ def get(self, group, default=None): Either a group or parameter with the specified name(s). If neither is found, returns the default value. ''' - if isinstance(group, int): - return self._groups.get(group, default) + if is_integer(group): + return self._groups.get(int(group), default) group = group.upper() param = None if '.' in group: From 2a46ef6ee498f950da67f9daf9e762722aeed623 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 16:57:29 +0200 Subject: [PATCH 016/120] Tests for managing Group functions --- test/test_group_functionality.py | 129 +++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 test/test_group_functionality.py diff --git a/test/test_group_functionality.py b/test/test_group_functionality.py new file mode 100644 index 0000000..fb4d46f --- /dev/null +++ b/test/test_group_functionality.py @@ -0,0 +1,129 @@ +import unittest +import c3d +import numpy as np +import test.verify as verify +from test.zipload import Zipload +from test.base import Base + +class GroupSample(): + ''' Verify groups persist. ''' + def __init__(self, manager): + self.manager = manager + self.sample() + + @property + def group_items(self): + '''Helper to access group items. ''' + return [(k, g) for (k, g) in self.manager.group_items] + + @property + def group_list(self): + '''Helper to access group numerical key-value pairs. ''' + return [(k, g) for (k, g) in self.manager.group_listed] + + @property + def fetch_groups(self): + return self.group_items, self.group_list + + def sample(self): + '''Call before applying changes. ''' + self.s_grp_items, self.s_grp_list = self.fetch_groups + + def assert_entry_count(self): + '''Assert all values in group still exist. ''' + grp_items, grp_list = self.fetch_groups + + assert len(self.s_grp_items) == len(grp_items),\ + 'Rename added item entry. Had %i entries, now has %i.' % (len(self.s_grp_items), len(grp_items)) + assert len(self.s_grp_list) == len(grp_list),\ + 'Rename added list entry. Had %i entries, now has %i.' % (len(self.s_grp_list), len(grp_list)) + assert len(grp_items) == len(grp_list),\ + 'Mismatch in the number of numerical and name keys. Had %i entries, now has %i.' %\ + (len(grp_items), len(grp_list)) + + def assert_group_items(self): + '''Assert all named (str, Group) pairs persisted after change.''' + enumerator = range(len(self.s_grp_items)) + for i, (n, g), (n2, g2) in zip(enumerator, sorted(self.s_grp_items), sorted(self.group_items)): + assert n == n2, 'Group numeric id missmatch after changes for entry %i. ' % i +\ + 'Initially %i, after change entry was %i' % (n, n2) + assert g == g2, 'Group listed order changed for entry %i.' % i + + def assert_group_list(self): + '''Assert all numerical (int, Group) pairs persisted after change.''' + enumerator = range(len(self.s_grp_list)) + for i, (n, g), (n2, g2) in zip(enumerator, self.s_grp_list, self.group_list): + assert n == n2, 'Group string id missmatch after changes for entry %i. ' % i +\ + 'Initially %i, after change entry was %i' % (n, n2) + assert g == g2, 'Group listed order changed for entry %i.' % i + + +class Sample00(Base): + ZIP = 'sample01.zip' + INTEL_INT = 'Eb015pi.c3d' + INTEL_REAL = 'Eb015pr.c3d' + + def test_Group_group_items(self): + '''Test Group.group_items''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + grp_keys = [k for (k, g) in reader.group_items] + assert len(grp_keys) > 0, 'No group items in file or Group.group_items failed' + + def test_Group_group_listed(self): + '''Test Group.group_listed''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + grp_list = [k for (k, g) in reader.group_listed] + assert len(grp_list) > 0, 'No group items in file or Group.group_listed failed' + + def test_Group_rename_group(self): + '''Test if renaming groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + grp_keys = [k for (k, g) in reader.group_items] + ref = GroupSample(reader) + + for i, key in enumerate(grp_keys): + test_name = 'TEST_NAME' + str(i) + + grp = reader.get(key) + reader.rename_group(key, test_name) + grp2 = reader.get(test_name) + + assert grp2 is not None, "Rename failed, group with name '%s' does not exist." + assert grp == grp2, 'Rename failed, group acquired from new name is not identical.' + + ref.assert_entry_count() + ref.assert_group_list() + + def test_Group_renumber_group(self): + '''Test if renaming (renumbering) groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + grp_ids = [k for (k, g) in reader.group_listed] + ref = GroupSample(reader) + + max_key = np.max(grp_ids) + + for i, key in enumerate(grp_ids): + test_num = max_key + i + 1 + + grp = reader.get(key) + reader.rename_group(key, test_num) + grp2 = reader.get(test_num) + + assert grp2 is not None, "Rename failed, group with name '%s' does not exist." + assert grp == grp2, 'Rename failed, group acquired from new name is not identical.' + + ref.assert_entry_count() + ref.assert_group_items() + + try: + reader.rename_group(max_key + 1, max_key + 2) + raise RuntimeError('Overwriting existing numerical ID should raise a ValueError.') + except KeyError as e: + pass # Correct + + + + + +if __name__ == '__main__': + unittest.main() From 3fcc9e46d5197814ece7e3e215b12ba627a5e8a6 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 17:09:45 +0200 Subject: [PATCH 017/120] Type + added test to verify renaming string key throws if it exists. --- c3d.py | 2 +- test/test_group_functionality.py | 44 ++++++++++++++++++++++---------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/c3d.py b/c3d.py index d1c09a7..3b6924b 100644 --- a/c3d.py +++ b/c3d.py @@ -1083,7 +1083,7 @@ def add_group(self, group_id, name, desc): if not is_integer(group_id): raise ValueError('Expected Group numerical key to be integer, was %s.' % type(group_id)) if not isinstance(name, str): - raise ValueError('Expected Group name key to be string, was %s.' % type(name) + raise ValueError('Expected Group name key to be string, was %s.' % type(name)) group_id = int(group_id) # Assert python int if group_id in self._groups: raise KeyError(group_id) diff --git a/test/test_group_functionality.py b/test/test_group_functionality.py index fb4d46f..81e0430 100644 --- a/test/test_group_functionality.py +++ b/test/test_group_functionality.py @@ -6,7 +6,7 @@ from test.base import Base class GroupSample(): - ''' Verify groups persist. ''' + ''' Helper object to verify groups entries persist. ''' def __init__(self, manager): self.manager = manager self.sample() @@ -23,22 +23,30 @@ def group_list(self): @property def fetch_groups(self): + '''Acquire both group sets. ''' return self.group_items, self.group_list def sample(self): '''Call before applying changes. ''' self.s_grp_items, self.s_grp_list = self.fetch_groups - def assert_entry_count(self): - '''Assert all values in group still exist. ''' + def assert_entry_count(self, delta=0): + '''Assert all values in group still exist. + + Arguments + --------- + delta: Number of entries added (+) or removed (-) since last sample. + ''' grp_items, grp_list = self.fetch_groups - assert len(self.s_grp_items) == len(grp_items),\ - 'Rename added item entry. Had %i entries, now has %i.' % (len(self.s_grp_items), len(grp_items)) - assert len(self.s_grp_list) == len(grp_list),\ - 'Rename added list entry. Had %i entries, now has %i.' % (len(self.s_grp_list), len(grp_list)) + assert len(self.s_grp_items) + delta == len(grp_items),\ + 'Rename added item entry. Expected %i entries, now has %i.' %\ + (len(self.s_grp_items), len(grp_items)) + assert len(self.s_grp_list) + delta == len(grp_list),\ + 'Rename added list entry. Expected %i entries, now has %i.' %\ + (len(self.s_grp_list) + delta, len(grp_list)) assert len(grp_items) == len(grp_list),\ - 'Mismatch in the number of numerical and name keys. Had %i entries, now has %i.' %\ + 'Mismatch in the number of numerical and name keys. Has %i numerical entries and %i name entries.' %\ (len(grp_items), len(grp_list)) def assert_group_items(self): @@ -58,19 +66,21 @@ def assert_group_list(self): assert g == g2, 'Group listed order changed for entry %i.' % i -class Sample00(Base): +class ManagerGroupTests(Base): + ''' Tests functionality associated with editing Group entries in the Manager class. + ''' ZIP = 'sample01.zip' INTEL_INT = 'Eb015pi.c3d' INTEL_REAL = 'Eb015pr.c3d' def test_Group_group_items(self): - '''Test Group.group_items''' + '''Test Manager.group_items''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) grp_keys = [k for (k, g) in reader.group_items] assert len(grp_keys) > 0, 'No group items in file or Group.group_items failed' def test_Group_group_listed(self): - '''Test Group.group_listed''' + '''Test Manager.group_listed''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) grp_list = [k for (k, g) in reader.group_listed] assert len(grp_list) > 0, 'No group items in file or Group.group_listed failed' @@ -81,9 +91,9 @@ def test_Group_rename_group(self): grp_keys = [k for (k, g) in reader.group_items] ref = GroupSample(reader) - for i, key in enumerate(grp_keys): - test_name = 'TEST_NAME' + str(i) + new_names = ['TEST_NAME' + str(i) for i in range(len(grp_keys))] + for key, test_name in zip(grp_keys, new_names): grp = reader.get(key) reader.rename_group(key, test_name) grp2 = reader.get(test_name) @@ -94,6 +104,12 @@ def test_Group_rename_group(self): ref.assert_entry_count() ref.assert_group_list() + try: + reader.rename_group(new_names[0], new_names[1]) + raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') + except KeyError as e: + pass # Correct + def test_Group_renumber_group(self): '''Test if renaming (renumbering) groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) @@ -117,7 +133,7 @@ def test_Group_renumber_group(self): try: reader.rename_group(max_key + 1, max_key + 2) - raise RuntimeError('Overwriting existing numerical ID should raise a ValueError.') + raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') except KeyError as e: pass # Correct From 36d24485d4e112e50a14319e009ad4de8835afcb Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 17:39:56 +0200 Subject: [PATCH 018/120] Added tests: ManagerGroupTests.test_Manager_add_group ManagerGroupTests.test_Manager_remove_group_from_numeric ManagerGroupTests.test_Manager_remove_group_from_name --- c3d.py | 2 +- test/test_group_functionality.py | 75 +++++++++++++++++++++++++++++--- 2 files changed, 70 insertions(+), 7 deletions(-) diff --git a/c3d.py b/c3d.py index 3b6924b..01e1634 100644 --- a/c3d.py +++ b/c3d.py @@ -1082,7 +1082,7 @@ def add_group(self, group_id, name, desc): ''' if not is_integer(group_id): raise ValueError('Expected Group numerical key to be integer, was %s.' % type(group_id)) - if not isinstance(name, str): + if not (isinstance(name, str) or name is None): raise ValueError('Expected Group name key to be string, was %s.' % type(name)) group_id = int(group_id) # Assert python int if group_id in self._groups: diff --git a/test/test_group_functionality.py b/test/test_group_functionality.py index 81e0430..8472a5c 100644 --- a/test/test_group_functionality.py +++ b/test/test_group_functionality.py @@ -17,19 +17,26 @@ def group_items(self): return [(k, g) for (k, g) in self.manager.group_items] @property - def group_list(self): + def group_listed(self): '''Helper to access group numerical key-value pairs. ''' return [(k, g) for (k, g) in self.manager.group_listed] @property def fetch_groups(self): '''Acquire both group sets. ''' - return self.group_items, self.group_list + return self.group_items, self.group_listed + + @property + def max_key(self): + if len(self.group_items) > 0: + return np.max([k for (k, g) in self.group_listed]) + return 0 def sample(self): '''Call before applying changes. ''' self.s_grp_items, self.s_grp_list = self.fetch_groups + def assert_entry_count(self, delta=0): '''Assert all values in group still exist. @@ -60,11 +67,45 @@ def assert_group_items(self): def assert_group_list(self): '''Assert all numerical (int, Group) pairs persisted after change.''' enumerator = range(len(self.s_grp_list)) - for i, (n, g), (n2, g2) in zip(enumerator, self.s_grp_list, self.group_list): + for i, (n, g), (n2, g2) in zip(enumerator, self.s_grp_list, self.group_listed): assert n == n2, 'Group string id missmatch after changes for entry %i. ' % i +\ 'Initially %i, after change entry was %i' % (n, n2) assert g == g2, 'Group listed order changed for entry %i.' % i + def verify_add_groups(self, N): + '''Add N groups and verify count at each iteration.''' + self.sample() + max_key = self.max_key + for i in range(1, N): + test_name = 'TEST_ADD_GROUP_%i' % i + self.manager.add_group(max_key + i, test_name, '') + assert self.manager.get(test_name) is not None, 'Added group does not exist.' + self.assert_entry_count(delta=i) + + def verify_remove_all_using_numeric(self): + '''Remove all groups using numeric key and verify count at each iteration.''' + self.sample() + keys = [k for (k, g) in self.group_listed] + for i, key in enumerate(keys): + grp = self.manager.get(key) + assert grp is not None, 'Expected group to exist.' + self.manager.remove_group(key) + assert self.manager.get(key) is None, 'Removed group persisted.' + assert self.manager.get(grp.name) is None, 'Removed group persisted.' + self.assert_entry_count(delta=-1 - i) + + def verify_remove_all_using_name(self): + '''Remove all groups using name key and verify count at each iteration.''' + self.sample() + keys = [k for (k, g) in self.group_items] + for i, key in enumerate(keys): + grp = self.manager.get(key) + assert grp is not None, 'Expected group to exist.' + self.manager.remove_group(key) + assert self.manager.get(key) is None, 'Removed group persisted.' + assert self.manager.get(grp.name) is None, 'Removed group persisted.' + self.assert_entry_count(delta=-1 - i) + class ManagerGroupTests(Base): ''' Tests functionality associated with editing Group entries in the Manager class. @@ -85,7 +126,29 @@ def test_Group_group_listed(self): grp_list = [k for (k, g) in reader.group_listed] assert len(grp_list) > 0, 'No group items in file or Group.group_listed failed' - def test_Group_rename_group(self): + + def test_Manager_add_group(self): + '''Test if renaming groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + ref = GroupSample(reader) + ref.verify_add_groups(100) + ref.verify_remove_all_using_numeric() + + def test_Manager_removing_group_from_numeric(self): + '''Test if removing groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + ref = GroupSample(reader) + ref.verify_remove_all_using_numeric() + ref.verify_add_groups(100) + + def test_Manager_removing_group_from_name(self): + '''Test if removing groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + ref = GroupSample(reader) + ref.verify_remove_all_using_name() + ref.verify_add_groups(100) + + def test_Manager_rename_group(self): '''Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) grp_keys = [k for (k, g) in reader.group_items] @@ -110,13 +173,13 @@ def test_Group_rename_group(self): except KeyError as e: pass # Correct - def test_Group_renumber_group(self): + def test_Manager_renumber_group(self): '''Test if renaming (renumbering) groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) grp_ids = [k for (k, g) in reader.group_listed] ref = GroupSample(reader) - max_key = np.max(grp_ids) + max_key = ref.max_key for i, key in enumerate(grp_ids): test_num = max_key + i + 1 From 538506d85e2edb7da376dbfcde30a7c31c000756 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 17:44:38 +0200 Subject: [PATCH 019/120] test_group_functionality -> test_manager_group_functions --- ...est_group_functionality.py => test_manager_group_functions.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename test/{test_group_functionality.py => test_manager_group_functions.py} (100%) diff --git a/test/test_group_functionality.py b/test/test_manager_group_functions.py similarity index 100% rename from test/test_group_functionality.py rename to test/test_manager_group_functions.py From c351882662abb9c9a1ccf5681418b975fb878c7a Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 18:33:54 +0200 Subject: [PATCH 020/120] + Removed property tag from Manager._groups accessors + Added Group.values() accessor + Writer._dtypes --- c3d.py | 41 ++++++++++++++++------------ test/test_c3d.py | 4 +-- test/test_manager_group_functions.py | 16 ++++++----- 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/c3d.py b/c3d.py index 01e1634..9cd52c2 100644 --- a/c3d.py +++ b/c3d.py @@ -346,7 +346,7 @@ def read(self, handle, fmt=BINARY_FORMAT_READ): self.long_event_labels = self.long_event_labels == 0x3039 def _processor_convert(self, dtypes, handle): - ''' Function interpreting the header once processor type has been determined. + ''' Function interpreting the header once a processor type has been determined. ''' if dtypes.is_dec: @@ -818,10 +818,13 @@ def desc(self, value): raise TypeError('Expected descriptor to be byte string or python string, was %s.' % type(value)) self._desc = value - @property - def param_items(self): - ''' Acquie group parameter iterator for key-value pairs. ''' - return params.items() + def items(self): + ''' Acquire iterator for paramater key-entry pairs. ''' + return self._params.items() + + def values(self): + ''' Acquire iterator for parameter entries. ''' + return self._params.values() def get(self, key, default=None): '''Get a parameter by key. @@ -840,7 +843,7 @@ def get(self, key, default=None): ''' return self._params.get(key, default) - def add_param(self, name, dtypes, **kwargs): + def add_param(self, name, **kwargs): '''Add a parameter to this group. Parameters @@ -848,12 +851,10 @@ def add_param(self, name, dtypes, **kwargs): name : str Name of the parameter to add to this group. The name will automatically be case-normalized. - dtypes : DataTypes - Object struct containing the data types used for reading parameter data. Additional keyword arguments will be passed to the `Param` constructor. ''' - self._params[name.upper()] = Param(name.upper(), dtypes, **kwargs) + self._params[name.upper()] = Param(name.upper(), self._dtypes, **kwargs) def remove_param(self, name): '''Remove the specified parameter. @@ -974,7 +975,6 @@ def header(self): ''' Access the parsed c3d header. ''' return self._header - @property def group_items(self): ''' Acquire iterable over parameter group pairs. @@ -985,7 +985,16 @@ def group_items(self): ''' return ((k, v) for k, v in self._groups.items() if isinstance(k, str)) - @property + def group_values(self): + ''' Acquire iterable over parameter group entries. + + Returns + ------- + values : Touple of (:class:`Group`, ...) + Python touple containing unique parameter group entries. + ''' + return (v for k, v in self._groups.items() if isinstance(k, str)) + def group_listed(self): ''' Acquire iterable over sorted numerical parameter group pairs. @@ -1397,7 +1406,7 @@ def seek_param_section_header(): # We've just started reading a parameter. If its group doesn't # exist, create a blank one. add the parameter to the group. self._groups.setdefault( - group_id, Group(self._dtypes)).add_param(name, self._dtypes, handle=buf) + group_id, Group(self._dtypes)).add_param(name, handle=buf) else: # We've just started reading a group. If a group with the # appropriate numerical id exists already (because we've @@ -1635,6 +1644,8 @@ def __init__(self, '''Set metadata for this writer. ''' + # Always write INTEL format + self._dtypes = DataTypes(PROCESSOR_INTEL) super(Writer, self).__init__() self._point_rate = point_rate self._analog_rate = analog_rate @@ -1736,11 +1747,8 @@ def write(self, handle, labels): if not self._frames: return - dtypes = DataTypes(PROCESSOR_INTEL) - def add(name, desc, bpe, format, bytes, *dimensions): group.add_param(name, - dtypes, desc=desc, bytes_per_element=bpe, bytes=struct.pack(format, bytes), @@ -1748,14 +1756,13 @@ def add(name, desc, bpe, format, bytes, *dimensions): def add_str(name, desc, bytes, *dimensions): group.add_param(name, - dtypes, desc=desc, bytes_per_element=-1, bytes=bytes.encode('utf-8'), dimensions=list(dimensions)) def add_empty_array(name, desc, bpe): - group.add_param(name, dtypes, desc=desc, + group.add_param(name, desc=desc, bytes_per_element=bpe, dimensions=[0]) points, analog = self._frames[0] diff --git a/test/test_c3d.py b/test/test_c3d.py index 119e650..5738bb0 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -36,8 +36,8 @@ def test_paramsa(self): def test_paramsb(self): r = c3d.Reader(Zipload._get('sample08.zip', 'TESTBPI.c3d')) self._log(r) - for g in r.groups.values(): - for p in g.params.values(): + for g in r.group_values(): + for p in g.values(): if len(p.dimensions) == 0: val = None width = len(p.bytes) diff --git a/test/test_manager_group_functions.py b/test/test_manager_group_functions.py index 8472a5c..539df3e 100644 --- a/test/test_manager_group_functions.py +++ b/test/test_manager_group_functions.py @@ -1,3 +1,5 @@ +''' Purpose for this file is to verify functions associated with Manager._groups dictionary. +''' import unittest import c3d import numpy as np @@ -6,7 +8,7 @@ from test.base import Base class GroupSample(): - ''' Helper object to verify groups entries persist. ''' + ''' Helper object to verify group entries persist or terminate properly. ''' def __init__(self, manager): self.manager = manager self.sample() @@ -14,12 +16,12 @@ def __init__(self, manager): @property def group_items(self): '''Helper to access group items. ''' - return [(k, g) for (k, g) in self.manager.group_items] + return [(k, g) for (k, g) in self.manager.group_items()] @property def group_listed(self): '''Helper to access group numerical key-value pairs. ''' - return [(k, g) for (k, g) in self.manager.group_listed] + return [(k, g) for (k, g) in self.manager.group_listed()] @property def fetch_groups(self): @@ -117,13 +119,13 @@ class ManagerGroupTests(Base): def test_Group_group_items(self): '''Test Manager.group_items''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - grp_keys = [k for (k, g) in reader.group_items] + grp_keys = [k for (k, g) in reader.group_items()] assert len(grp_keys) > 0, 'No group items in file or Group.group_items failed' def test_Group_group_listed(self): '''Test Manager.group_listed''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - grp_list = [k for (k, g) in reader.group_listed] + grp_list = [k for (k, g) in reader.group_listed()] assert len(grp_list) > 0, 'No group items in file or Group.group_listed failed' @@ -151,8 +153,8 @@ def test_Manager_removing_group_from_name(self): def test_Manager_rename_group(self): '''Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - grp_keys = [k for (k, g) in reader.group_items] ref = GroupSample(reader) + grp_keys = [k for (k, g) in ref.group_items] new_names = ['TEST_NAME' + str(i) for i in range(len(grp_keys))] @@ -176,8 +178,8 @@ def test_Manager_rename_group(self): def test_Manager_renumber_group(self): '''Test if renaming (renumbering) groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - grp_ids = [k for (k, g) in reader.group_listed] ref = GroupSample(reader) + grp_ids = [k for (k, g) in ref.group_listed] max_key = ref.max_key From f286bcadfb3d254352a9902d168d45df23ae2f5a Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 28 Jun 2021 18:34:16 +0200 Subject: [PATCH 021/120] Rename of parameter test file --- ....py => test_parameter_bytes_conversion.py} | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) rename test/{test_parameter_functions.py => test_parameter_bytes_conversion.py} (93%) diff --git a/test/test_parameter_functions.py b/test/test_parameter_bytes_conversion.py similarity index 93% rename from test/test_parameter_functions.py rename to test/test_parameter_bytes_conversion.py index 18fc76b..14d02c4 100644 --- a/test/test_parameter_functions.py +++ b/test/test_parameter_bytes_conversion.py @@ -144,11 +144,23 @@ def test_a_parse_float32_array(self): for shape in ParameterArrayTest.SHAPES: arr = self.rnd.uniform(flt_range[0], flt_range[1], size=shape).astype(np.float32) P = c3d.Param('FLOAT_TEST', self.dtypes, bytes_per_element=4, dimensions=arr.shape, bytes=arr.T.tobytes()) - arr_out = P.float_array + arr_out = P.float32_array assert arr.T.shape == arr_out.shape, "Mismatch in 'float_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading float array' - def test_b_parse_int32_array(self): + def test_b_parse_float64_array(self): + ''' Verify array of 64 bit floating point values are parsed correctly + ''' + flt_range = (-1e6, 1e6) + + for shape in ParameterArrayTest.SHAPES: + arr = self.rnd.uniform(flt_range[0], flt_range[1], size=shape).astype(np.float64) + P = c3d.Param('FLOAT_TEST', self.dtypes, bytes_per_element=4, dimensions=arr.shape, bytes=arr.T.tobytes()) + arr_out = P.float64_array + assert arr.T.shape == arr_out.shape, "Mismatch in 'float_array' converted shape" + assert np.all(arr.T == arr_out), 'Value mismatch when reading float array' + + def test_c_parse_int32_array(self): ''' Verify array of 32 bit integer values are parsed correctly ''' flt_range = (-1e6, 1e6) @@ -160,7 +172,7 @@ def test_b_parse_int32_array(self): assert arr.T.shape == arr_out.shape, "Mismatch in 'int32_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading int32 array' - def test_c_parse_uint32_array(self): + def test_d_parse_uint32_array(self): ''' Verify array of 32 bit unsigned integer values are parsed correctly ''' flt_range = (0, 1e6) @@ -172,7 +184,7 @@ def test_c_parse_uint32_array(self): assert arr.T.shape == arr_out.shape, "Mismatch in 'uint32_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading uint32 array' - def test_d_parse_int16_array(self): + def test_e_parse_int16_array(self): ''' Verify array of 16 bit integer values are parsed correctly ''' flt_range = (-1e4, 1e4) @@ -184,7 +196,7 @@ def test_d_parse_int16_array(self): assert arr.T.shape == arr_out.shape, "Mismatch in 'int32_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading int32 array' - def test_e_parse_uint16_array(self): + def test_f_parse_uint16_array(self): ''' Verify array of 16 bit unsigned integer values are parsed correctly ''' flt_range = (0, 1e4) @@ -196,7 +208,7 @@ def test_e_parse_uint16_array(self): assert arr.T.shape == arr_out.shape, "Mismatch in 'uint32_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading uint32 array' - def test_e_parse_int8_array(self): + def test_g_parse_int8_array(self): ''' Verify array of 8 bit integer values are parsed correctly ''' flt_range = (-127, 127) @@ -208,7 +220,7 @@ def test_e_parse_int8_array(self): assert arr.T.shape == arr_out.shape, "Mismatch in 'int32_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading int32 array' - def test_f_parse_uint8_array(self): + def test_h_parse_uint8_array(self): ''' Verify array of 8 bit unsigned integer values are parsed correctly ''' flt_range = (0, 255) @@ -220,7 +232,7 @@ def test_f_parse_uint8_array(self): assert arr.T.shape == arr_out.shape, "Mismatch in 'uint32_array' converted shape" assert np.all(arr.T == arr_out), 'Value mismatch when reading uint32 array' - def test_g_parse_byte_array(self): + def test_i_parse_byte_array(self): ''' Verify byte arrays are parsed correctly ''' word = b'WRIST' @@ -252,7 +264,7 @@ def test_g_parse_byte_array(self): for i in np.ndindex(arr_out.shape): assert np.all(arr[i[::-1]] == arr_out[i]), "Mismatch in 'bytes_array' converted value at index %s" % str(i) - def test_h_parse_string_array(self): + def test_j_parse_string_array(self): ''' Verify repeated word arrays are parsed correctly ''' word = b'ANCLE' @@ -290,7 +302,7 @@ def test_h_parse_string_array(self): assert self.dtypes.decode_string(arr[i[::-1]]) == arr_out[i],\ "Mismatch in 'string_array' converted value at index %s" % str(i) - def test_i_parse_random_string_array(self): + def test_k_parse_random_string_array(self): ''' Verify random word arrays are parsed correctly ''' ## From 9e0dcb9ce785f40e79a3fc53ea9387e762845fc1 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Tue, 29 Jun 2021 00:44:32 +0200 Subject: [PATCH 022/120] Added --- + Group.param_keys() + Manager.group_keys() Changed ---- Group.values() -> Group.param_values() Group.items() -> Group.param_items() KeyError -> ValueError for existing keys --- c3d.py | 25 +++- test/test_c3d.py | 2 +- ...p_functions.py => test_group_accessors.py} | 22 +-- test/test_parameter_accessors.py | 139 ++++++++++++++++++ 4 files changed, 172 insertions(+), 16 deletions(-) rename test/{test_manager_group_functions.py => test_group_accessors.py} (94%) create mode 100644 test/test_parameter_accessors.py diff --git a/c3d.py b/c3d.py index 9cd52c2..d359216 100644 --- a/c3d.py +++ b/c3d.py @@ -818,14 +818,18 @@ def desc(self, value): raise TypeError('Expected descriptor to be byte string or python string, was %s.' % type(value)) self._desc = value - def items(self): + def param_items(self): ''' Acquire iterator for paramater key-entry pairs. ''' return self._params.items() - def values(self): + def param_values(self): ''' Acquire iterator for parameter entries. ''' return self._params.values() + def param_keys(self): + ''' Acquire iterator for parameter entry keys. ''' + return self._params.keys() + def get(self, key, default=None): '''Get a parameter by key. @@ -864,7 +868,7 @@ def remove_param(self, name): name : str Name for the parameter to remove. ''' - del self._params[group_id] + del self._params[name] def rename_param(self, name, new_name): ''' Rename a specified parameter group. @@ -876,8 +880,11 @@ def rename_param(self, name, new_name): new_name : str New name for the parameter. ''' + if new_name in self._params: + raise ValueError("Key %s already exist." % new_name) if isinstance(name, Param): param = name + name = param.name else: # Aquire instance using id param = self._params.get(name, None) @@ -995,6 +1002,16 @@ def group_values(self): ''' return (v for k, v in self._groups.items() if isinstance(k, str)) + def group_keys(self): + ''' Acquire iterable over parameter group entry string keys. + + Returns + ------- + keys : Touple of (str, ...) + Python touple containing keys for the parameter group entries. + ''' + return (k for k in self._groups.keys() if isinstance(k, str)) + def group_listed(self): ''' Acquire iterable over sorted numerical parameter group pairs. @@ -1142,7 +1159,7 @@ def rename_group(self, group_id, new_group_id): if new_group_id in self._groups: if new_group_id == group_id: return - raise KeyError('New group identifier %s for group %s already exist.' % (str(new_group_id), grp.name)) + raise ValueError('Key %s for group %s already exist.' % (str(new_group_id), grp.name)) # Clear old id if isinstance(new_group_id, (str, bytes)): diff --git a/test/test_c3d.py b/test/test_c3d.py index 5738bb0..cd34f2d 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -37,7 +37,7 @@ def test_paramsb(self): r = c3d.Reader(Zipload._get('sample08.zip', 'TESTBPI.c3d')) self._log(r) for g in r.group_values(): - for p in g.values(): + for p in g.param_values(): if len(p.dimensions) == 0: val = None width = len(p.bytes) diff --git a/test/test_manager_group_functions.py b/test/test_group_accessors.py similarity index 94% rename from test/test_manager_group_functions.py rename to test/test_group_accessors.py index 539df3e..eaf1722 100644 --- a/test/test_manager_group_functions.py +++ b/test/test_group_accessors.py @@ -74,7 +74,7 @@ def assert_group_list(self): 'Initially %i, after change entry was %i' % (n, n2) assert g == g2, 'Group listed order changed for entry %i.' % i - def verify_add_groups(self, N): + def verify_add_group(self, N): '''Add N groups and verify count at each iteration.''' self.sample() max_key = self.max_key @@ -109,31 +109,31 @@ def verify_remove_all_using_name(self): self.assert_entry_count(delta=-1 - i) -class ManagerGroupTests(Base): +class TestGroupAccessors(Base): ''' Tests functionality associated with editing Group entries in the Manager class. ''' ZIP = 'sample01.zip' INTEL_INT = 'Eb015pi.c3d' INTEL_REAL = 'Eb015pr.c3d' - def test_Group_group_items(self): + def test_Manager_group_items(self): '''Test Manager.group_items''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) grp_keys = [k for (k, g) in reader.group_items()] - assert len(grp_keys) > 0, 'No group items in file or Group.group_items failed' + assert len(grp_keys) > 0, 'No group items in file or Manager.group_items failed' - def test_Group_group_listed(self): + def test_Manager_group_listed(self): '''Test Manager.group_listed''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) grp_list = [k for (k, g) in reader.group_listed()] - assert len(grp_list) > 0, 'No group items in file or Group.group_listed failed' + assert len(grp_list) > 0, 'No group items in file or Manager.group_listed failed' def test_Manager_add_group(self): '''Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) ref = GroupSample(reader) - ref.verify_add_groups(100) + ref.verify_add_group(100) ref.verify_remove_all_using_numeric() def test_Manager_removing_group_from_numeric(self): @@ -141,14 +141,14 @@ def test_Manager_removing_group_from_numeric(self): reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) ref = GroupSample(reader) ref.verify_remove_all_using_numeric() - ref.verify_add_groups(100) + ref.verify_add_group(100) def test_Manager_removing_group_from_name(self): '''Test if removing groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) ref = GroupSample(reader) ref.verify_remove_all_using_name() - ref.verify_add_groups(100) + ref.verify_add_group(100) def test_Manager_rename_group(self): '''Test if renaming groups acts as intended.''' @@ -172,7 +172,7 @@ def test_Manager_rename_group(self): try: reader.rename_group(new_names[0], new_names[1]) raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') - except KeyError as e: + except ValueError as e: pass # Correct def test_Manager_renumber_group(self): @@ -199,7 +199,7 @@ def test_Manager_renumber_group(self): try: reader.rename_group(max_key + 1, max_key + 2) raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') - except KeyError as e: + except ValueError as e: pass # Correct diff --git a/test/test_parameter_accessors.py b/test/test_parameter_accessors.py new file mode 100644 index 0000000..5238b83 --- /dev/null +++ b/test/test_parameter_accessors.py @@ -0,0 +1,139 @@ +''' Purpose for this file is to verify functions associated with Groups._params dictionary. +''' +import unittest +import c3d +import numpy as np +import test.verify as verify +from test.zipload import Zipload +from test.base import Base + +class ParamSample(): + ''' Helper object to verify parameter entries persist or terminate properly. ''' + def __init__(self, group): + assert isinstance(group, c3d.Group), 'Must pass group to ParamSample instance.' + self.flt_range = (-1e6, 1e6) + self.shape = (10, 2) + self.group = group + self.rnd = np.random.default_rng() + self.sample() + + @property + def items(self): + '''Helper to access group items. ''' + return [(k, g) for (k, g) in self.group.param_items()] + + @property + def keys(self): + '''Helper to access group items. ''' + return [k for (k, g) in self.group.param_items()] + + def sample(self): + '''Call before applying changes. ''' + self.s_items = self.items + self.s_keys = self.keys + + def assert_entry_count(self, delta=0): + '''Assert entry count. + + Arguments + --------- + delta: Number of entries added (+) or removed (-) since last sample. + ''' + items = self.items + assert len(self.s_items) + delta == len(items),\ + 'Rename added item entry. Expected %i entries, now has %i.' %\ + (len(self.s_items), len(items)) + + def assert_group_items(self, ignore=None): + '''Assert all named (str, Group) pairs persisted after change.''' + enumerator = range(len(self.s_items)) + for i, (n, g) in enumerate(self.s_items): + if n == ignore: + continue + g2 = self.group.get(n) + assert g == g2, 'Group listed order changed for entry %i.' % i + + def verify_add_parameter(self, N): + '''Add N parameters and verify count at each iteration.''' + self.sample() + for i in range(1, N): + test_name = 'TEST_ADD_PARAM_%i' % i + arr = self.rnd.uniform(*self.flt_range, size=self.shape).astype(np.float32) + self.group.add_param(test_name, bytes_per_element=4, dimensions=arr.shape, bytes=arr.T.tobytes()) + assert self.group.get(test_name) is not None, 'Added group does not exist.' + self.assert_group_items() + + def verify_remove_all(self): + '''Remove all groups using name key and verify count at each iteration.''' + self.sample() + keys = [k for (k, g) in self.items] + for i, key in enumerate(keys): + grp = self.group.get(key) + assert grp is not None, 'Expected group to exist.' + self.group.remove_param(key) + assert self.group.get(key) is None, 'Removed param persisted.' + self.assert_entry_count(delta=-1 - i) + + +class TestParameterAccessors(Base): + ''' Tests functionality associated with accessing and editing Paramater entries in Group objects. + ''' + ZIP = 'sample01.zip' + INTEL_INT = 'Eb015pi.c3d' + INTEL_REAL = 'Eb015pr.c3d' + + def test_Group_values(self): + '''Test Group.values()''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + for g in reader.group_values(): + assert len(g.param_values()) > 0, 'No group values in file or Group.param_values() failed' + + def test_Group_items(self): + '''Test Group.items()''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + for g in reader.group_values(): + assert len(g.param_items()) > 0, 'No group items in file or Group.param_items() failed' + + def test_Group_add_param(self): + '''Test if adding and groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + for g in reader.group_values(): + ref = ParamSample(g) + ref.verify_add_parameter(100) + ref.verify_remove_all() + + def test_Group_remove_param(self): + '''Test if removing groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + for g in reader.group_values(): + ref = ParamSample(g) + ref.verify_remove_all() + ref.verify_add_parameter(100) + + def test_Group_rename_param(self): + '''Test if renaming groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + + for g in reader.group_values(): + ref = ParamSample(g) + prm_keys = ref.keys + new_names = ['TEST_NAME' + str(i) for i in range(len(prm_keys))] + for key, nname in zip(prm_keys, new_names): + prm = g.get(key) + g.rename_param(key, nname) + prm2 = g.get(nname) + assert prm2 is not None, "Rename failed, renamed param does not exist." + assert prm == prm2, 'Rename failed, param acquired from new name is not identical.' + + ref.assert_entry_count() + try: + g.rename_param(new_names[0], new_names[1]) + raise RuntimeError('Overwriting existing numerical ID should raise a ValueError.') + except ValueError as e: + pass # Correct + + + + +if __name__ == '__main__': + unittest.main() From b0e25ea8f72c6b53e6ef697ff5ea2c76a895bbd8 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Tue, 29 Jun 2021 16:24:43 +0200 Subject: [PATCH 023/120] Moved custom writer properties to the header where possible --- c3d.py | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/c3d.py b/c3d.py index d359216..36887fd 100644 --- a/c3d.py +++ b/c3d.py @@ -189,8 +189,8 @@ class Header(object): Index of the last frame of data. analog_per_frame : int Number of analog frames per frame of 3D point data. The analog frame - rate (ANALOG:RATE) apparently equals the point frame rate (POINT:RATE) - times this value. + rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE) + times the analog per frame value. frame_rate : float The frame rate of the recording, in frames per second. scale_factor : float @@ -1649,7 +1649,7 @@ class Writer(Manager): point_units : str, optional The units that the point numbers represent. Defaults to ``'mm '``. gen_scale : float, optional - General scaling factor for data. Defaults to 1. + General scaling factor for analog data. Defaults to 1. ''' def __init__(self, @@ -1661,17 +1661,25 @@ def __init__(self, '''Set metadata for this writer. ''' - # Always write INTEL format - self._dtypes = DataTypes(PROCESSOR_INTEL) + self._dtypes = DataTypes(PROCESSOR_INTEL) # Always write INTEL format super(Writer, self).__init__() - self._point_rate = point_rate - self._analog_rate = analog_rate - self._analog_per_frame = analog_rate / point_rate - self._point_scale = point_scale + + # Custom properties self._point_units = point_units self._gen_scale = gen_scale + + # Header properties + self._header.frame_rate = np.float32(point_rate) + self._header.scale_factor = np.float32(self._point_scale) + self.analog_rate = analog_rate self._frames = [] + @analog_rate.setter + def analog_rate(self, value): + per_frame_rate = value / self.frame_rate + assert per_frame_rate.is_integer(), "Analog rate must be a multiple of the point rate." + return self._header.analog_per_frame = np.uint16(per_frame_rate) + def add_frames(self, frames): '''Add frames to this writer instance. @@ -1796,8 +1804,8 @@ def add_empty_array(name, desc, bpe): add('USED', 'Number of 3d markers', 2, ' Date: Tue, 29 Jun 2021 18:12:15 +0200 Subject: [PATCH 024/120] DataTypes.proc_type -> DataTypes._proc_type --- c3d.py | 50 ++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 10 deletions(-) diff --git a/c3d.py b/c3d.py index 36887fd..3ac1df4 100644 --- a/c3d.py +++ b/c3d.py @@ -4,6 +4,7 @@ import array import io +import copy import numpy as np import struct import warnings @@ -19,8 +20,8 @@ class DataTypes(object): Data types depend on the processor format the file is stored in. ''' def __init__(self, proc_type): - self.proc_type = proc_type - if proc_type == PROCESSOR_MIPS: + self._proc_type = proc_type + if self._proc_type == PROCESSOR_MIPS: # Big-Endian (SGI/MIPS format) self.float32 = np.dtype(np.float32).newbyteorder('>') self.float64 = np.dtype(np.float64).newbyteorder('>') @@ -49,19 +50,26 @@ def __init__(self, proc_type): def is_ieee(self): ''' True if the associated file is in the Intel format. ''' - return self.proc_type == PROCESSOR_INTEL + return self._proc_type == PROCESSOR_INTEL @property def is_dec(self): ''' True if the associated file is in the DEC format. ''' - return self.proc_type == PROCESSOR_DEC + return self._proc_type == PROCESSOR_DEC @property def is_mips(self): ''' True if the associated file is in the SGI/MIPS format. ''' - return self.proc_type == PROCESSOR_MIPS + return self._proc_type == PROCESSOR_MIPS + + @property + def proc_type(self): + ''' Get the processory type associated with the data format in the file. + ''' + processor_type = ['INTEL', 'DEC', 'MIPS'] + return processor_type[self._proc_type - PROCESSOR_INTEL] def decode_string(self, bytes): ''' Decode a byte array to a string. @@ -1618,11 +1626,9 @@ def read_frames(self, copy=True): @property def proc_type(self): - """ - Get the processory type associated with the data format in the file. - """ - processor_type = ['PROCESSOR_INTEL', 'PROCESSOR_DEC', 'PROCESSOR_MIPS'] - return processor_type[self._dtypes.proc_type - PROCESSOR_INTEL] + '''Get the processory type associated with the data format in the file. + ''' + return self._dtypes.proc_type class Writer(Manager): @@ -1680,6 +1686,30 @@ def analog_rate(self, value): assert per_frame_rate.is_integer(), "Analog rate must be a multiple of the point rate." return self._header.analog_per_frame = np.uint16(per_frame_rate) + @staticmethod + def from_reader(reader, conversion='consume'): + ''' + source : 'class' Manager + Source to copy. + conversion : str + Conversion mode, supported modes are: + 'consume' - (Default) Reader object will be consumed and explicitly deleted. + 'copy' - Reader objects will be deep copied. + 'shallow_copy' - No group parameters are copied. + ''' + writer = Writer() + if not reader._dtypes.is_intel: + warnings.warn('File was read in !'.format( + self._handle.tell() - final_byte_index)) + if consume == False: + + if consume: + writer._header = copy.deepcopy(reader._header) + else: + # Consume + writer._header = reader._header + reader._header = None + def add_frames(self, frames): '''Add frames to this writer instance. From e4a5e009cf7b40a39e8b8e7e044651fbf4e10321 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Tue, 29 Jun 2021 20:34:47 +0200 Subject: [PATCH 025/120] Correction for existing tests, updated readme, comments --- README.rst | 3 ++ c3d.py | 77 ++++++++++++++++++++++++++-------- test/test_software_examples.py | 5 +++ 3 files changed, 67 insertions(+), 18 deletions(-) diff --git a/README.rst b/README.rst index 11242de..aefb9c6 100644 --- a/README.rst +++ b/README.rst @@ -56,6 +56,9 @@ To run tests available in the test folder, following command can be run from the python -m unittest discover . +Test scripts will automatically download test files from `c3d.org`_. + +.. _c3d.org: https://www.c3d.org/sampledata.html Caveats ------- diff --git a/c3d.py b/c3d.py index 3ac1df4..266f846 100644 --- a/c3d.py +++ b/c3d.py @@ -1630,6 +1630,12 @@ def proc_type(self): ''' return self._dtypes.proc_type + def as_writer(self, conversion): + ''' Convert to writer using the conversion mode. + See Writer.from_reader() for supported converstion modes. + ''' + return Writer.from_reader(self, conversion=mode) + class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -1676,39 +1682,74 @@ def __init__(self, # Header properties self._header.frame_rate = np.float32(point_rate) - self._header.scale_factor = np.float32(self._point_scale) + self._header.scale_factor = np.float32(point_scale) self.analog_rate = analog_rate self._frames = [] + @property + def analog_rate(self): + return super(Writer, self).analog_rate + @analog_rate.setter def analog_rate(self, value): - per_frame_rate = value / self.frame_rate - assert per_frame_rate.is_integer(), "Analog rate must be a multiple of the point rate." - return self._header.analog_per_frame = np.uint16(per_frame_rate) + per_frame_rate = value / self.point_rate + assert float(per_frame_rate).is_integer(), "Analog rate must be a multiple of the point rate." + self._header.analog_per_frame = np.uint16(per_frame_rate) @staticmethod - def from_reader(reader, conversion='consume'): + def from_reader(reader, conversion=None): ''' source : 'class' Manager Source to copy. conversion : str - Conversion mode, supported modes are: + Conversion mode, None is equivalent to the default mode. Supported modes are: 'consume' - (Default) Reader object will be consumed and explicitly deleted. 'copy' - Reader objects will be deep copied. - 'shallow_copy' - No group parameters are copied. + 'shallow_copy' - Similar to 'copy' but group parameters are not copied. + + Returns + ------- + param : :class:`Param` + A parameter from the current group. + + Raises + ------ + ValueError + If mode string is not equivalent to one of the supported modes. + If attempting to convert non-Intel files using mode other than 'shallow_copy'. ''' writer = Writer() - if not reader._dtypes.is_intel: - warnings.warn('File was read in !'.format( - self._handle.tell() - final_byte_index)) - if consume == False: - - if consume: - writer._header = copy.deepcopy(reader._header) - else: - # Consume + # Modes + is_consume = conversion == 'consume' or conversion is None + is_shallow_copy = conversion == 'shallow_copy' + is_deep_copy = conversion == 'copy' + # Verify mode + if not (is_consume or is_shallow_copy or is_deep_copy): + raise ValueError( + "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format(mode)) + if not reader._dtypes.is_intel and not is_shallow_copy: + # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. + raise ValueError( + "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( + reader.proc_format)) + + if is_consume: writer._header = reader._header reader._header = None + writer._groups = reader._groups + reader._groups = None + del reader + elif is_deep_copy: + writer._header = copy.deepcopy(reader._header) + writer._groups = copy.deepcopy(reader._groups) + elif is_shallow_copy: + # Only copy header (no groups) + writer._header = copy.deepcopy(reader._header) + + # Copy frames + for i, frame in enumerate(reader.read_frames(copy=True)): + self.add_frames(frame) + def add_frames(self, frames): '''Add frames to this writer instance. @@ -1834,8 +1875,8 @@ def add_empty_array(name, desc, bpe): add('USED', 'Number of 3d markers', 2, ' Date: Thu, 1 Jul 2021 12:08:59 +0200 Subject: [PATCH 026/120] Working WriteRead --- c3d.py | 633 ++++++++++++++++------ test/test_c3d.py | 5 +- test/test_software_examples_write_read.py | 66 +++ test/verify.py | 75 ++- 4 files changed, 593 insertions(+), 186 deletions(-) create mode 100644 test/test_software_examples_write_read.py diff --git a/c3d.py b/c3d.py index 266f846..5501804 100644 --- a/c3d.py +++ b/c3d.py @@ -2,7 +2,6 @@ from __future__ import unicode_literals -import array import io import copy import numpy as np @@ -14,6 +13,7 @@ PROCESSOR_DEC = 85 PROCESSOR_MIPS = 86 +INVALID_FLAG = -100000 class DataTypes(object): ''' Container defining different data types used for reading file data. @@ -84,6 +84,13 @@ def decode_string(self, bytes): # Revert to using default decoder but replace characters return codecs.decode(bytes, decoders[0], 'replace') +class Decorator(object): + '''Base class for extending (decotrating) a python object. + ''' + def __init__(self, decoratee): + self._decoratee = decoratee + def __getattr__(self, name): + return getattr(self._decoratee, name) def UNPACK_FLOAT_IEEE(uint_32): '''Unpacks a single 32 bit unsigned int to a IEEE float representation @@ -176,6 +183,10 @@ def is_integer(value): '''Check if value input is integer.''' return isinstance(value, (int, np.int32, np.int64)) +def is_iterable(value): + '''Check if value is iterable.''' + return hasattr(value, '__iter__') + class Header(object): '''Header information from a C3D file. @@ -787,6 +798,9 @@ def __init__(self, dtypes, name=None, desc=None): def __repr__(self): return ''.format(self.desc) + def __contains__(self, key): + return key in self._params + @property def name(self): ''' Group name. ''' @@ -822,9 +836,10 @@ def desc(self, value): ''' if isinstance(value, bytes): self._desc = self._dtypes.decode_string(value) - elif value is not None and not isinstance(value, str): - raise TypeError('Expected descriptor to be byte string or python string, was %s.' % type(value)) - self._desc = value + elif isinstance(value, str) or value is None: + self._desc = value + else: + raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(value)) def param_items(self): ''' Acquire iterator for paramater key-entry pairs. ''' @@ -865,8 +880,20 @@ def add_param(self, name, **kwargs): automatically be case-normalized. Additional keyword arguments will be passed to the `Param` constructor. + + Raises + ------ + TypeError + Input arguments are of the wrong type. + KeyError + Name or numerical key already exist (attempt to overwrite existing data). ''' - self._params[name.upper()] = Param(name.upper(), self._dtypes, **kwargs) + if not isinstance(name, str): + raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name))) + name = name.upper() + if name in self._params: + raise KeyError('Parameter already exists with key {}'.format(name)) + self._params[name] = Param(name, self._dtypes, **kwargs) def remove_param(self, name): '''Remove the specified parameter. @@ -887,9 +914,16 @@ def rename_param(self, name, new_name): Parameter instance, or name. new_name : str New name for the parameter. + + Raises + ------ + KeyError + If no parameter with the original name exists. + ValueError + If the new name already exist (attempt to overwrite existing data). ''' if new_name in self._params: - raise ValueError("Key %s already exist." % new_name) + raise ValueError("Key {} already exist.".format(new_name)) if isinstance(name, Param): param = name name = param.name @@ -897,7 +931,7 @@ def rename_param(self, name, new_name): # Aquire instance using id param = self._params.get(name, None) if param is None: - raise ValueError('No parameter found matching the identifier: %s' % str(name)) + raise KeyError('No parameter found matching the identifier: {}'.format(str(name))) del self._params[name] self._params[new_name] = param @@ -985,6 +1019,9 @@ def __init__(self, header=None): self._header = header or Header() self._groups = {} + def __contains__(self, key): + return key in self._groups + @property def header(self): ''' Access the parsed c3d header. ''' @@ -1113,17 +1150,19 @@ def add_group(self, group_id, name, desc): ------ TypeError Input arguments are of the wrong type. + KeyError + Name or numerical key already exist (attempt to overwrite existing data). ''' if not is_integer(group_id): - raise ValueError('Expected Group numerical key to be integer, was %s.' % type(group_id)) + raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id)) if not (isinstance(name, str) or name is None): - raise ValueError('Expected Group name key to be string, was %s.' % type(name)) - group_id = int(group_id) # Assert python int + raise TypeError('Expected Group name key to be string, was %s.' % type(name)) + group_id = int(group_id) # Asserts python int if group_id in self._groups: - raise KeyError(group_id) + raise KeyError('Group with numerical key {} already exists'.format(group_id)) name = name.upper() if name in self._groups: - raise KeyError(name) + raise KeyError('No group matched name key {}'.format(name)) group = self._groups[name] = self._groups[group_id] = Group(self._dtypes, name, desc) return group @@ -1356,6 +1395,29 @@ def last_frame(self): # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) + def _get_analog_transform(self): + ''' Parse analog data transform parameters. + ''' + # Offsets + analog_offsets = np.zeros((self.analog_used, 1), int) + param = self.get('ANALOG:OFFSET') + if param is not None and param.num_elements > 0: + analog_offsets[:, 0] = param.int16_array[:self.analog_used] + + # Scale factors + analog_scales = np.ones((self.analog_used, 1), float) + gen_scale = 1. + param = self.get('ANALOG:GEN_SCALE') + if param is not None: + gen_scale = param.float_value + param = self.get('ANALOG:SCALE') + if param is not None and param.num_elements > 0: + analog_scales[:, 0] = param.float_array[:self.analog_used] + analog_scales *= gen_scale + + analog_scales = np.broadcast_to(analog_scales, (self.analog_used, self.analog_per_frame)) + analog_offsets = np.broadcast_to(analog_offsets, (self.analog_used, self.analog_per_frame)) + return analog_scales, analog_offsets class Reader(Manager): '''This class provides methods for reading the data in a C3D file. @@ -1430,8 +1492,8 @@ def seek_param_section_header(): if group_id > 0: # We've just started reading a parameter. If its group doesn't # exist, create a blank one. add the parameter to the group. - self._groups.setdefault( - group_id, Group(self._dtypes)).add_param(name, handle=buf) + group = self._groups.setdefault(group_id, Group(self._dtypes)) + group.add_param(name, handle=buf) else: # We've just started reading a group. If a group with the # appropriate numerical id exists already (because we've @@ -1442,14 +1504,14 @@ def seek_param_section_header(): desc = size and buf.read(size) or '' group = self.get(group_id) if group is not None: - self.rename_group(group, name) + self.rename_group(group, name) # Inserts name key group.desc = desc else: self.add_group(group_id, name, desc) self._check_metadata() - def read_frames(self, copy=True): + def read_frames(self, copy=True, analog_transform=True, camera_sum=False): '''Iterate over the data frames from our C3D file handle. Parameters @@ -1511,26 +1573,14 @@ def read_frames(self, copy=True): analog_word_bytes = 2 analog = np.array([], float) - offsets = np.zeros((self.analog_used, 1), int) - param = self.get('ANALOG:OFFSET') - if param is not None: - offsets = param.int16_array[:self.analog_used, None] - - analog_scales = np.ones((self.analog_used, 1), float) - param = self.get('ANALOG:SCALE') - if param is not None: - analog_scales[:, :] = param.float_array[:self.analog_used, None] - - gen_scale = 1. - param = self.get('ANALOG:GEN_SCALE') - if param is not None: - gen_scale = param.float_value + analog_scales, analog_offsets = self._get_analog_transform() # Seek to the start point of the data blocks self._handle.seek((self._header.data_block - 1) * 512) # Number of values (words) read in regard to POINT/ANALOG data N_point = 4 * self.point_used N_analog = self.analog_used * self.analog_per_frame + # Total bytes per frame point_bytes = N_point * point_word_bytes analog_bytes = N_analog * analog_word_bytes @@ -1559,7 +1609,7 @@ def read_frames(self, copy=True): # Re-read the raw byte representation directly points[:, :4] = np.frombuffer(raw_bytes, dtype=self._dtypes.float32, - count=N_point).reshape((int(self.point_used), 4)) + count=N_point).reshape((self.point_used, 4)) # Parse the camera-observed bits and residuals. # Notes: @@ -1570,8 +1620,13 @@ def read_frames(self, copy=True): # - While words are 16 bit, residual and camera mask is always interpreted as 8 packed in a single word! # - 16 or 32 bit may represent a sign (indication that certain files write a -1 floating point only) last_word = points[:, 3].astype(np.int32) - valid = (last_word & 0x80008000) == 0 - points[~valid, 3:5] = -1.0 + valid = last_word > -1 # (last_word & 0x80008000) == 0 + # Lookup other + #if np.sum(~valid) > 0: + #print(np.sum(~valid)) + #print(points[~valid, :3]) + #print(last_word[~valid]) + points[~valid, 3:5] = INVALID_FLAG c = last_word[valid] else: @@ -1584,16 +1639,18 @@ def read_frames(self, copy=True): # Parse last 16-bit word as two 8-bit words valid = raw[:, 3] > -1 - points[~valid, 3:5] = -1 + points[~valid, 3:5] = INVALID_FLAG c = raw[valid, 3].astype(self._dtypes.uint16) # Convert coordinate data # fourth value is floating-point (scaled) error estimate (residual) - points[valid, 3] = (c & 0xff).astype(np.float32) * scale_mag + points[valid, 3] = (c & 0x00ff).astype(np.float32) * scale_mag # fifth value is number of bits set in camera-observation byte - points[valid, 4] = sum((c & (1 << k)) >> k for k in range(8, 15)) - # Get value as is: points[valid, 4] = (c >> 8) + if camera_sum: + points[valid, 4] = sum((c & (1 << k)) >> k for k in range(8, 15)) + else: + points[valid, 4] = (c & 0xff00 >> 8) # Check if analog data exist, and parse if so if N_analog > 0: @@ -1608,7 +1665,7 @@ def read_frames(self, copy=True): analog = analog.reshape((-1, self.analog_used)).T analog = analog.astype(float) # Convert analog - analog = (analog - offsets) * analog_scales * gen_scale + analog = (analog - analog_offsets) * analog_scales # Output buffers if copy: @@ -1630,11 +1687,131 @@ def proc_type(self): ''' return self._dtypes.proc_type - def as_writer(self, conversion): - ''' Convert to writer using the conversion mode. - See Writer.from_reader() for supported converstion modes. + def to_writer(self, conversion): + ''' Convert to 'Writer' using the conversion mode. + See Writer.from_reader() for supported conversion modes and possible exceptions. + ''' + return Writer.from_reader(self, conversion=conversion) + + +class GroupEditable(Decorator): + ''' Group instance decorator providing convenience functions for Writer editing. + ''' + def __init__(self, group): + super(GroupEditable, self).__init__(group) + + def __contains__(self, key): + return key in self._decoratee + # + # Add decorator functions (throws on overwrite) + # + def add(self, name, desc, bpe, format, data, *dimensions): + ''' Add a parameter with 'data' package formated in accordance with 'format'. + ''' + if isinstance(data, bytes): + pass + else: + data = struct.pack(format, data) + + self.add_param(name, + desc=desc, + bytes_per_element=bpe, + bytes=data, + dimensions=list(dimensions)) + + def add_array(self, name, desc, data, dtype=None): + '''Add a parameter with the 'data' package. + + Arguments + --------- + data : Numpy array, or python iterable. + dtype : Numpy dtype to encode the array (Optional if data is numpy type). + ''' + if not isinstance(data, np.ndarray): + if dtype is not None: + raise ValueError('Must specify dtype when passning non-numpy array type.') + data = np.array(data, dtype=dtype) + elif dtype is None: + dtype = data.dtype + + self.add_param(name, + desc=desc, + bytes_per_element=dtype.itemsize, + bytes=data, + dimensions=data.shape) + + def add_str(self, name, desc, data, *dimensions): + ''' Add a string parameter. + ''' + self.add_param(name, + desc=desc, + bytes_per_element=-1, + bytes=data.encode('utf-8'), + dimensions=list(dimensions)) + + def add_empty_array(self, name, desc, bpe): + ''' Add an empty parameter block. + ''' + self.add_param(name, desc=desc, + bytes_per_element=bpe, dimensions=[0]) + + # + # Set decorator functions (overwrites) + # + def set(self, name, *args, **kwargs): + ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'. + ''' + try: + self.remove_param(name) + except KeyError as e: + pass + self.add(name, *args, **kwargs) + + def set_str(self, name, *args, **kwargs): + ''' Add a string parameter. ''' - return Writer.from_reader(self, conversion=mode) + try: + self.remove_param(name) + except KeyError as e: + pass + self.add_str(name, *args, **kwargs) + + def set_array(self, name, *args, **kwargs): + ''' Add a string parameter. + ''' + try: + self.remove_param(name) + except KeyError as e: + pass + self.add_array(name, *args, **kwargs) + + def set_empty_array(self, name, *args, **kwargs): + ''' Add an empty parameter block. + ''' + try: + self.remove_param(name) + except KeyError as e: + pass + self.add_empty_array(name, *args, **kwargs) + + # + # Try add decorator functions (catches KeyError) + # + def try_add(self, name, *args, **kwargs): + ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'. + ''' + try: + self.add(name, *args, **kwargs) + except KeyError as e: + pass + + def try_add_str(self, name, *args, **kwargs): + ''' Add a string parameter. + ''' + try: + self.add_str(name, *args, **kwargs) + except KeyError as e: + pass class Writer(Manager): @@ -1678,7 +1855,6 @@ def __init__(self, # Custom properties self._point_units = point_units - self._gen_scale = gen_scale # Header properties self._header.frame_rate = np.float32(point_rate) @@ -1686,16 +1862,6 @@ def __init__(self, self.analog_rate = analog_rate self._frames = [] - @property - def analog_rate(self): - return super(Writer, self).analog_rate - - @analog_rate.setter - def analog_rate(self, value): - per_frame_rate = value / self.point_rate - assert float(per_frame_rate).is_integer(), "Analog rate must be a multiple of the point rate." - self._header.analog_per_frame = np.uint16(per_frame_rate) - @staticmethod def from_reader(reader, conversion=None): ''' @@ -1705,12 +1871,14 @@ def from_reader(reader, conversion=None): Conversion mode, None is equivalent to the default mode. Supported modes are: 'consume' - (Default) Reader object will be consumed and explicitly deleted. 'copy' - Reader objects will be deep copied. - 'shallow_copy' - Similar to 'copy' but group parameters are not copied. + 'copy_metadata' - Similar to 'copy' but only copies metadata and not point and analog frame data. + 'copy_shallow' - Similar to 'copy' but group parameters are not copied. + 'copy_header' - Similar to 'copy_shallow' but only the header is copied (frame data is not copied). Returns ------- - param : :class:`Param` - A parameter from the current group. + param : :class:`Writer` + A writeable and persistent representation of the 'Reader' object. Raises ------ @@ -1720,14 +1888,18 @@ def from_reader(reader, conversion=None): ''' writer = Writer() # Modes + is_header_only = conversion == 'copy_header' + is_meta_copy = conversion == 'copy_metadata' + is_meta_only = is_header_only or is_meta_copy is_consume = conversion == 'consume' or conversion is None - is_shallow_copy = conversion == 'shallow_copy' - is_deep_copy = conversion == 'copy' + is_shallow_copy = conversion == 'shallow_copy' or is_header_only + is_deep_copy = conversion == 'copy' or is_meta_copy # Verify mode if not (is_consume or is_shallow_copy or is_deep_copy): raise ValueError( - "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format(mode)) - if not reader._dtypes.is_intel and not is_shallow_copy: + "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format( + conversion)) + if not reader._dtypes.is_ieee and not is_shallow_copy: # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. raise ValueError( "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( @@ -1746,21 +1918,231 @@ def from_reader(reader, conversion=None): # Only copy header (no groups) writer._header = copy.deepcopy(reader._header) - # Copy frames - for i, frame in enumerate(reader.read_frames(copy=True)): - self.add_frames(frame) + if not is_meta_only: + # Copy frames + for (i, point, analog) in reader.read_frames(copy=True, camera_sum=False): + writer.add_frames((point, analog)) + return writer + + @property + def analog_rate(self): + return super(Writer, self).analog_rate + + @analog_rate.setter + def analog_rate(self, value): + per_frame_rate = value / self.point_rate + assert float(per_frame_rate).is_integer(), "Analog rate must be a multiple of the point rate." + self._header.analog_per_frame = np.uint16(per_frame_rate) + + @property + def numeric_key_max(self): + ''' Get the largest numeric key. + ''' + num = 0 + if len(self._groups) > 0: + for i in self._groups.keys(): + if isinstance(i, int): + num = max(i, num) + return num + + @property + def numeric_key_next(self): + ''' Get a new unique numeric group key. + ''' + return self.numeric_key_max + 1 + + def get_create(self, label): + ''' Get or create the specified parameter group.''' + label = label.upper() + group = self.get(label) + if group is None: + group = self.add_group(self.numeric_key_next, label, label + ' group') + return group + + @property + def point_group(self): + ''' Get or create the POINT parameter group.''' + return self.get_create('POINT') + + @property + def analog_group(self): + ''' Get or create the ANALOG parameter group.''' + return self.get_create('ANALOG') + + @property + def trial_group(self): + ''' Get or create the TRIAL parameter group.''' + return self.get_create('TRIAL') + + def get(self, group, default=None): + '''Get a GroupEditable decorator for keyed group instance, or a parameter instance. + + Parameters + ---------- + group : str + Key, see Manager.get() for valid key formats. + default : any + Return this value if the named group and parameter are not found. + Returns + ------- + value : :class:`GroupEditable` or :class:`Param` + Either a decorated group instance or parameter with the specified name(s). If neither + is found, the default value is returned. + ''' + val = super(Writer, self).get(group, default) + if isinstance(val, Group): + return GroupEditable(val) + return val # Parameter + + def add_group(self, *args, **kwargs): + '''Add a new parameter group. See Manager.add_group() for more information. + + Returns + ------- + group : :class:`Group` + An editable group instance. + ''' + return GroupEditable(super(Writer, self).add_group(*args, **kwargs)) def add_frames(self, frames): '''Add frames to this writer instance. Parameters ---------- - frames : sequence of (point, analog) tuples - A sequence of frame data to add to the writer. + frames : single or sequence of (point, analog) pairs + A sequence or frame of frame data to add to the writer. ''' + sh = np.shape(frames) + # Single frame + if len(sh) != 2: + frames = [frames] + sh = np.shape(frames) + # Sequence of invalid shape + if sh[1] != 2: + raise ValueError( + 'Expected frame input to be sequence of point and analog pairs on form (-1, 2). ' + + '\Input was of shape {}.'.format(str(sh))) self._frames.extend(frames) + def set_point_labels(self, labels): + group = self.point_group + labels = np.ravel(labels) + # Get longest label name + label_max_size = 0 + label_max_size = max(label_max_size, np.max([len(label) for label in labels])) + + label_str = ''.join(label.ljust(label_max_size) for label in labels) + group.add_str('LABELS', 'labels', label_str, label_max_size, len(labels)) + + def set_analog_general_scale(self, value): + ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor). + ''' + self.analog_group.set('GEN_SCALE', 'Analog general scale factor', 4, '= UINT16_MAX: + # Should be floating point + group.set('LONG_FRAMES', 'Total frame count', 4, ' -1 - raw[~valid, 3] = -1 + raw[~valid, 3] = INVALID_FLAG raw[valid, :3] = points[valid, :3] / point_scale - raw[valid, 3] = ( - ((points[valid, 4]).astype(np.uint8) << 8) | - (points[valid, 3] / scale).astype(np.uint16) - ) - point = array.array(point_format) - point.extend(raw.flatten()) - point.tofile(handle) - analog = array.array(point_format) - analog.extend(analog) - analog.tofile(handle) + raw[valid, 3] = np.bitwise_or(np.rint(points[valid, 3] / scale_mag).astype(np.uint8), + (points[valid, 4].astype(np.uint16) << 8), + dtype=np.uint16) + + # Transform analog data + analog = analog * analog_scales_inv + analog_offsets + analog = analog.T + + # Write + analog = analog.astype(point_dtype) + handle.write(raw.tobytes()) + handle.write(analog.tobytes()) self._pad_block(handle) - - def write(self, handle, labels): - '''Write metadata and point + analog frames to a file handle. - - Parameters - ---------- - handle : file - Write metadata and C3D motion frames to the given file handle. The - writer does not close the handle. - ''' - if not self._frames: - return - - def add(name, desc, bpe, format, bytes, *dimensions): - group.add_param(name, - desc=desc, - bytes_per_element=bpe, - bytes=struct.pack(format, bytes), - dimensions=list(dimensions)) - - def add_str(name, desc, bytes, *dimensions): - group.add_param(name, - desc=desc, - bytes_per_element=-1, - bytes=bytes.encode('utf-8'), - dimensions=list(dimensions)) - - def add_empty_array(name, desc, bpe): - group.add_param(name, desc=desc, - bytes_per_element=bpe, dimensions=[0]) - - points, analog = self._frames[0] - ppf = len(points) - labels = np.ravel(labels) - - # POINT group - - # Get longest label name - label_max_size = 0 - label_max_size = max(label_max_size, np.max([len(label) for label in labels])) - - group = self.add_group(1, 'POINT', 'POINT group') - add('USED', 'Number of 3d markers', 2, ' -1.0 + bvalid = bpoint[:, 3] > -1.0 + valid_diff = np.sum(np.logical_xor(avalid, bvalid)) + if valid_diff > 0: + diff = np.logical_xor(avalid, bvalid) + print('A', apoint[diff, 3]) + print('B', bpoint[diff, 3]) + assert valid_diff == 0, '\n' +\ + 'Error in number of valid samples between {} and {}.\n'.format(alabel, blabel) +\ + 'Total number of validation mismatches: {} of {}'.format(valid_diff, len(avalid)) + + # Only compare valid point data + valid = avalid + apoint = apoint[valid] + bpoint = bpoint[valid] + + tot_points = len(apoint) + tot_analog = areader.analog_used * analog_count # Compare point data (coordinates) c = ['X', 'Y', 'Z'] + atol = equal_scale_fac * abs(areader.point_scale) for i in range(3): - axis_diff = nsampled_coordinates - np.sum(np.isclose(apoint[:, :, i], bpoint[:, :, i], - atol=equal_scale_fac*abs(areader.point_scale))) - assert axis_diff == 0, \ - 'Mismatched coordinates on {} axis for {} and {}, number of sampled diff: {} of {}'.format( - c[i], alabel, blabel, axis_diff, nsampled_coordinates) + was_close = np.isclose(apoint[:, i], bpoint[:, i], atol=atol) + axis_notclose = tot_points - np.sum(was_close) + assert axis_notclose == 0, '\n' +\ + 'Mismatched coordinates on {} axis between {} and {}.\n'.format(c[i], alabel, blabel) +\ + 'Samples with absolute difference larger then {:0.4f}: {} of {}.\n'.format( + atol, axis_notclose, tot_points) +\ + 'Maximum difference: {}'.format(np.max(np.abs(apoint[:, i] - bpoint[:, i]))) # Word 4 (residual + camera bits) - residual_diff = nsampled_coordinates - np.sum(np.isclose(apoint[:, :, 3], bpoint[:, :, 3])) - cam_diff = nsampled_coordinates - np.sum(np.isclose(apoint[:, :, 4], bpoint[:, :, 4], atol=1.001)) - cam_diff_non_equal = nsampled_coordinates - np.sum(np.isclose(apoint[:, :, 4], bpoint[:, :, 4])) + residual_diff = tot_points - np.sum(np.isclose(apoint[:, 3], bpoint[:, 3])) + cam_close = np.isclose(apoint[:, 4], bpoint[:, 4], atol=1.001) + cam_diff = tot_points - np.sum(cam_close) + cam_diff_non_equal = tot_points - np.sum(np.isclose(apoint[:, 4], bpoint[:, 4])) # Camera bit errors (warn if non identical, allow 1 cam bit diff, might be bad DEC implementation, or bad data) if cam_diff_non_equal > 0: - assert cam_diff == 0, 'Mismatch error in camera bit flags for {} and {}, number of samples with flag diff:\ - {} of {}'.format(alabel, blabel, cam_diff, nsampled_coordinates) - err_str = 'Mismatch in camera bit flags between {} and {}, number of samples with flag diff:\ - {} of {}'.format(alabel, blabel, cam_diff_non_equal, nsampled_coordinates) + print(apoint[~cam_close, 4]) + print(bpoint[~cam_close, 4]) + assert cam_diff == 0, \ + 'Mismatch error in camera bit flags for {} and {}, number of samples with flag diff: {} of {}'.format( + alabel, blabel, cam_diff, tot_points) + err_str =\ + 'Mismatch in camera bit flags between {} and {}, number of samples with flag diff: {} of {}'.format( + alabel, blabel, cam_diff_non_equal, tot_points) warnings.warn(err_str, RuntimeWarning) # Residual assert - assert residual_diff == 0, \ - 'Error in sample residuals between {} and {}, number of residual diff: {} of {}'.format( - alabel, blabel, residual_diff, nsampled_coordinates) + assert residual_diff == 0, '\n' +\ + 'Error in sample residuals between {} and {}.\n'.format(alabel, blabel) +\ + 'Total number of failed samples: {} of {}'.format(residual_diff, tot_points) # Compare analog - analog_diff = nsampled_analog - np.sum(np.isclose(aanalog, banalog)) - assert analog_diff == 0, \ - 'Mismatched analog samples between {} and {}, number of sampled diff: {} of {}'.format( - alabel, blabel, analog_diff, nsampled_analog) + was_close = np.isclose(aanalog, banalog) + analog_notclose = tot_analog - np.sum(was_close) + assert analog_notclose == 0, '\n' + \ + 'Mismatched analog samples between {} and {}.\n'.format(alabel, blabel) +\ + 'Total number of failed samples: {} of {}.\n'.format(analog_notclose, tot_analog) +\ + 'Largest absolute difference: {}.'.format(np.max(np.abs(aanalog - banalog))) From 1a8f678e1dc316d6f876d3a3755bacc3f0b55897 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 1 Jul 2021 20:18:20 +0200 Subject: [PATCH 027/120] Added nan-check, cleaned up + corrected read_frames, added big-endian system warning --- c3d.py | 132 ++++++++++++++-------- test/test_software_examples.py | 5 +- test/test_software_examples_write_read.py | 19 ++-- test/verify.py | 44 ++++---- 4 files changed, 122 insertions(+), 78 deletions(-) diff --git a/c3d.py b/c3d.py index 5501804..172b757 100644 --- a/c3d.py +++ b/c3d.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals +import sys import io import copy import numpy as np @@ -21,6 +22,12 @@ class DataTypes(object): ''' def __init__(self, proc_type): self._proc_type = proc_type + self._little_endian_sys = sys.byteorder == 'little' + self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \ + (self.is_mips and self.big_endian_sys) + if self.big_endian_sys: + warnings.warn('Systems with native byte order of big-endian are not supported.') + if self._proc_type == PROCESSOR_MIPS: # Big-Endian (SGI/MIPS format) self.float32 = np.dtype(np.float32).newbyteorder('>') @@ -46,6 +53,24 @@ def __init__(self, proc_type): self.int32 = np.int32 self.int64 = np.int64 + @property + def native(self): + ''' True if the native (system) byte order matches the file byte order. + ''' + return self._native + + @property + def little_endian_sys(self): + ''' True if native byte order is little-endian. + ''' + return self._little_endian_sys + + @property + def big_endian_sys(self): + ''' True if native byte order is big-endian. + ''' + return not self._little_endian_sys + @property def is_ieee(self): ''' True if the associated file is in the Intel format. @@ -158,12 +183,13 @@ def DEC_to_IEEE_BYTES(bytes): # See comments in DEC_to_IEEE() for DEC format definition # Reshuffle - bytes = np.frombuffer(bytes, dtype=np.dtype('B')) + bytes = memoryview(bytes) reshuffled = np.empty(len(bytes), dtype=np.dtype('B')) - reshuffled[0::4] = bytes[2::4] + reshuffled[::4] = bytes[2::4] reshuffled[1::4] = bytes[3::4] - reshuffled[2::4] = bytes[0::4] - reshuffled[3::4] = bytes[1::4] + ((bytes[1::4] & 0x7f == 0) - 1) # Decrement exponent by 2, if exp. > 1 + reshuffled[2::4] = bytes[::4] + # Decrement exponent by 2, if exp. > 1 + reshuffled[3::4] = bytes[1::4] + (np.bitwise_and(bytes[1::4], 0x7f) == 0) - 1 # There are different ways to adjust for differences in DEC/IEEE representation # after reshuffle. Two simple methods are: @@ -187,6 +213,11 @@ def is_iterable(value): '''Check if value is iterable.''' return hasattr(value, '__iter__') +def type_npy2struct(dtype): + ''' Convert numpy dtype format to a struct package format string. + ''' + return dtype.byteorder + dtype.char + class Header(object): '''Header information from a C3D file. @@ -1511,7 +1542,7 @@ def seek_param_section_header(): self._check_metadata() - def read_frames(self, copy=True, analog_transform=True, camera_sum=False): + def read_frames(self, copy=True, analog_transform=True, camera_sum=False, check_nan=True): '''Iterate over the data frames from our C3D file handle. Parameters @@ -1548,10 +1579,8 @@ def read_frames(self, copy=True, analog_transform=True, camera_sum=False): if is_float: point_word_bytes = 4 - point_dtype = self._dtypes.uint32 else: point_word_bytes = 2 - point_dtype = self._dtypes.int16 points = np.zeros((self.point_used, 5), np.float32) # TODO: handle ANALOG:BITS parameter here! @@ -1606,51 +1635,55 @@ def read_frames(self, copy=True, analog_transform=True, camera_sum=False): # Convert each of the first 6 16-bit words from DEC to IEEE float points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4)) else: # If IEEE or MIPS: - # Re-read the raw byte representation directly + # Convert each of the first 6 16-bit words to native float points[:, :4] = np.frombuffer(raw_bytes, dtype=self._dtypes.float32, count=N_point).reshape((self.point_used, 4)) - # Parse the camera-observed bits and residuals. - # Notes: - # - Invalid sample if residual is equal to -1. - # - A residual of 0.0 represent modeled data (filtered or interpolated). - # - The same format should be used internally when a float or integer representation is used, - # with the difference that the words are 16 and 8 bit respectively (see the MLS guide). - # - While words are 16 bit, residual and camera mask is always interpreted as 8 packed in a single word! - # - 16 or 32 bit may represent a sign (indication that certain files write a -1 floating point only) + # Cast last word to signed integer in system endian format last_word = points[:, 3].astype(np.int32) - valid = last_word > -1 # (last_word & 0x80008000) == 0 - # Lookup other - #if np.sum(~valid) > 0: - #print(np.sum(~valid)) - #print(points[~valid, :3]) - #print(last_word[~valid]) - points[~valid, 3:5] = INVALID_FLAG - c = last_word[valid] else: - # Convert the bytes to a unsigned 32 bit or signed 16 bit representation + # View the bytes as signed 16-bit integers raw = np.frombuffer(raw_bytes, - dtype=point_dtype, + dtype=self._dtypes.int16, count=N_point).reshape((self.point_used, 4)) - # Read point 2 byte words in int-16 format + # Read the first six 16-bit words as x, y, z coordinates points[:, :3] = raw[:, :3] * scale_mag - - # Parse last 16-bit word as two 8-bit words - valid = raw[:, 3] > -1 - points[~valid, 3:5] = INVALID_FLAG - c = raw[valid, 3].astype(self._dtypes.uint16) - - # Convert coordinate data - # fourth value is floating-point (scaled) error estimate (residual) - points[valid, 3] = (c & 0x00ff).astype(np.float32) * scale_mag - - # fifth value is number of bits set in camera-observation byte + # Cast last word to signed integer in system endian format + last_word = raw[:, 3].astype(np.int16) + + # Parse camera-observed bits and residuals. + # Notes: + # - Invalid sample if residual is equal to -1 (check if word < 0). + # - A residual of 0.0 represent modeled data (filtered or interpolated). + # - Camera and residual words are always 8-bit (1 byte), never 16-bit. + # - If floating point, the byte words are encoded in an integer cast to a float, + # and are written directly in byte form (see the MLS guide). + ## + # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded). + residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8 + #if self._dtypes.big_endian_sys -> swap words? + + # Fourth value is floating-point (scaled) error estimate (residual) + points[:, 3] = residual_byte * scale_mag + + # Determine invalid samples + invalid = last_word < 0 + if check_nan: + is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) + points[is_nan, :3] = 0.0 + invalid &= is_nan + # Update discarded - sign + points[invalid, 3] = -1 + + + # Fifth value is the camera-observation byte if camera_sum: - points[valid, 4] = sum((c & (1 << k)) >> k for k in range(8, 15)) + # Convert to observation sum + points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(8)) else: - points[valid, 4] = (c & 0xff00 >> 8) + points[:, 4] = camera_byte #.astype(np.float32) # Check if analog data exist, and parse if so if N_analog > 0: @@ -2087,7 +2120,13 @@ def write(self, handle): points, analog = self._frames[0] ppf = len(points) apf = len(analog) + + + first_frame = self.first_frame + if first_frame <= 0: # Bad value + first_frame = 1 nframes = len(self._frames) + last_frame = first_frame + nframes - 1 UINT16_MAX = 65535 @@ -2128,15 +2167,18 @@ def write(self, handle): # TRIAL group group = self.trial_group - group.set('ACTUAL_START_FIELD', 'actual start frame', 2, '= 65535: + first_frame = 1 + self._header.first_frame = np.uint16(first_frame) + self._header.last_frame = np.uint16(min(last_frame, 65535)) self._header.point_count = np.uint16(ppf) self._header.analog_count = np.uint16(np.prod(analog.shape)) @@ -2201,8 +2243,8 @@ def _write_frames(self, handle): for points, analog in self._frames: # Transform point data - valid = points[:, 3] > -1 - raw[~valid, 3] = INVALID_FLAG + valid = points[:, 3] >= 0.0 + raw[~valid, 3] = -1 raw[valid, :3] = points[valid, :3] / point_scale raw[valid, 3] = np.bitwise_or(np.rint(points[valid, 3] / scale_mag).astype(np.uint8), (points[valid, 4].astype(np.uint16) << 8), diff --git a/test/test_software_examples.py b/test/test_software_examples.py index 6d98c0c..0310f25 100644 --- a/test/test_software_examples.py +++ b/test/test_software_examples.py @@ -20,9 +20,8 @@ class Sample00(verify.WithinRangeTest, Base): ('Innovative Sports Training', ['Gait with EMG.c3d', 'Static Pose.c3d']), ('Motion Analysis Corporation', ['Sample_Jump2.c3d', 'Walk1.c3d']), ('NexGen Ergonomics', ['test1.c3d']), - ('Vicon Motion Systems', ['TableTennis.c3d']), - # Vicon files are weird, uses non-standard encodings. Walking01.c3d contain nan values and is not tested. - # ('Vicon Motion Systems', ['pyCGM2 lower limb CGM24 Walking01.c3d', 'TableTennis.c3d']), + # Vicon files are weird, uses non-standard encodings. Walking01.c3d contain nan values. + ('Vicon Motion Systems', ['pyCGM2 lower limb CGM24 Walking01.c3d', 'TableTennis.c3d']), ] diff --git a/test/test_software_examples_write_read.py b/test/test_software_examples_write_read.py index 94bc65b..981bffa 100644 --- a/test/test_software_examples_write_read.py +++ b/test/test_software_examples_write_read.py @@ -8,21 +8,20 @@ from test.base import Base from test.zipload import Zipload, TEMP -class WriteRead_SoftwareExamples(Base): +class Sample00(Base): ZIP = 'sample00.zip' DATA_RANGE = (-1e6, 1e6) zip_files = \ [ - #('Advanced Realtime Tracking GmbH', ['arthuman-sample.c3d', 'arthuman-sample-fingers.c3d']), - #('Codamotion', ['codamotion_gaitwands_19970212.c3d', 'codamotion_gaitwands_20150204.c3d']), - #('Cometa Systems', ['EMG Data Cometa.c3d']), - #('Innovative Sports Training', ['Gait with EMG.c3d', 'Static Pose.c3d']), - #('Motion Analysis Corporation', ['Sample_Jump2.c3d', 'Walk1.c3d']), - #('NexGen Ergonomics', ['test1.c3d']), - ('Vicon Motion Systems', ['TableTennis.c3d']), - # Vicon files are weird, uses non-standard encodings. Walking01.c3d contain nan values and is not tested. - # ('Vicon Motion Systems', ['pyCGM2 lower limb CGM24 Walking01.c3d', 'TableTennis.c3d']), + ('Advanced Realtime Tracking GmbH', ['arthuman-sample.c3d', 'arthuman-sample-fingers.c3d']), + ('Codamotion', ['codamotion_gaitwands_19970212.c3d', 'codamotion_gaitwands_20150204.c3d']), + ('Cometa Systems', ['EMG Data Cometa.c3d']), + ('Innovative Sports Training', ['Gait with EMG.c3d', 'Static Pose.c3d']), + ('Motion Analysis Corporation', ['Sample_Jump2.c3d', 'Walk1.c3d']), + ('NexGen Ergonomics', ['test1.c3d']), + # Vicon files are weird, uses non-standard encodings. Walking01.c3d contain nan values. + ('Vicon Motion Systems', ['pyCGM2 lower limb CGM24 Walking01.c3d', 'TableTennis.c3d']), ] def verify_read_write(self, file_path): diff --git a/test/verify.py b/test/verify.py index 43ba142..4551262 100644 --- a/test/verify.py +++ b/test/verify.py @@ -110,15 +110,17 @@ def check_zipfile(file_path): analog_min = np.min(analog) analog_max = np.max(analog) - assert np.all(npoint == reader.frame_count),\ - """Failed verifying POINT data in range ({}, {}), found {} number of mismatches in each axis - for all samples. Range for data was ({}, {})."""\ - .format(min_range, max_range, np.sum(np.abs(npoint - reader.frame_count), axis=0), - point_min, point_max) - assert np.all(nanalog == reader.analog_sample_count),\ - """Failed verifying ANALOG data in range ({}, {}), found {} number of mismatches for each channel - for all samples. Range for data was ({}, {})."""\ - .format(min_range, max_range, np.abs(nanalog - reader.analog_sample_count), analog_min, analog_max) + assert np.all(npoint == reader.frame_count), '\n' + \ + 'Failed verifying POINT data in range ({}, {}).\n'.format(min_range, max_range) +\ + 'Found a total of {} values outside plausible range.\n'.format( + np.sum(np.abs(npoint - reader.frame_count), axis=0)) +\ + 'Range for data was ({}, {})'.format(point_min, point_max) + + assert np.all(nanalog == reader.analog_sample_count),'\n' + \ + 'Failed verifying ANALOG data in range ({}, {}).\n'.format(min_range, max_range) +\ + 'Found a total of {} values outside plausible range.\n'.format( + np.abs(nanalog - reader.analog_sample_count)) +\ + 'Range for data was ({}, {})'.format(analog_min, analog_max) print('{} | READ: OK'.format(file)) @@ -284,8 +286,8 @@ def data_is_equal(areader, breader, alabel, blabel): apoint = np.reshape(apoint, (-1, 5)) bpoint = np.reshape(bpoint, (-1, 5)) - avalid = apoint[:, 3] > -1.0 - bvalid = bpoint[:, 3] > -1.0 + avalid = apoint[:, 3] >= 0 + bvalid = bpoint[:, 3] >= 0 valid_diff = np.sum(np.logical_xor(avalid, bvalid)) if valid_diff > 0: diff = np.logical_xor(avalid, bvalid) @@ -305,6 +307,7 @@ def data_is_equal(areader, breader, alabel, blabel): # Compare point data (coordinates) c = ['X', 'Y', 'Z'] + # Tolerance (allow scale x integer rounding error) atol = equal_scale_fac * abs(areader.point_scale) for i in range(3): was_close = np.isclose(apoint[:, i], bpoint[:, i], atol=atol) @@ -317,20 +320,21 @@ def data_is_equal(areader, breader, alabel, blabel): # Word 4 (residual + camera bits) residual_diff = tot_points - np.sum(np.isclose(apoint[:, 3], bpoint[:, 3])) - cam_close = np.isclose(apoint[:, 4], bpoint[:, 4], atol=1.001) - cam_diff = tot_points - np.sum(cam_close) - cam_diff_non_equal = tot_points - np.sum(np.isclose(apoint[:, 4], bpoint[:, 4])) + cam_close = np.isclose(apoint[:, 4], bpoint[:, 4]) + cam_diff_non_equal = tot_points - np.sum(cam_close) # Camera bit errors (warn if non identical, allow 1 cam bit diff, might be bad DEC implementation, or bad data) if cam_diff_non_equal > 0: print(apoint[~cam_close, 4]) print(bpoint[~cam_close, 4]) - assert cam_diff == 0, \ - 'Mismatch error in camera bit flags for {} and {}, number of samples with flag diff: {} of {}'.format( - alabel, blabel, cam_diff, tot_points) - err_str =\ - 'Mismatch in camera bit flags between {} and {}, number of samples with flag diff: {} of {}'.format( - alabel, blabel, cam_diff_non_equal, tot_points) + cam_close = np.isclose(apoint[:, 4], bpoint[:, 4], atol=1.001) + cam_diff = tot_points - np.sum(cam_close) + assert cam_diff == 0, '\n' + \ + 'Mismatch in camera bit flags between {} and {}.\n'.format(alabel, blabel) +\ + 'Number of samples with flag differences larger then 1 bit: {} of {}'.format(cam_diff, tot_points) + err_str = '\n' + \ + 'Mismatch in camera bit flags between {} and {}.\n'.format(alabel, blabel) +\ + 'Number of samples with flag difference of 1 bit: {} of {}'.format(cam_diff_non_equal, tot_points) warnings.warn(err_str, RuntimeWarning) # Residual assert assert residual_diff == 0, '\n' +\ From 09d2657fb38c510b4fd2051f798ce8d6199285aa Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 1 Jul 2021 20:20:20 +0200 Subject: [PATCH 028/120] Cleaned up verify --- test/verify.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/test/verify.py b/test/verify.py index 4551262..17e6d05 100644 --- a/test/verify.py +++ b/test/verify.py @@ -289,10 +289,6 @@ def data_is_equal(areader, breader, alabel, blabel): avalid = apoint[:, 3] >= 0 bvalid = bpoint[:, 3] >= 0 valid_diff = np.sum(np.logical_xor(avalid, bvalid)) - if valid_diff > 0: - diff = np.logical_xor(avalid, bvalid) - print('A', apoint[diff, 3]) - print('B', bpoint[diff, 3]) assert valid_diff == 0, '\n' +\ 'Error in number of valid samples between {} and {}.\n'.format(alabel, blabel) +\ 'Total number of validation mismatches: {} of {}'.format(valid_diff, len(avalid)) @@ -325,8 +321,8 @@ def data_is_equal(areader, breader, alabel, blabel): # Camera bit errors (warn if non identical, allow 1 cam bit diff, might be bad DEC implementation, or bad data) if cam_diff_non_equal > 0: - print(apoint[~cam_close, 4]) - print(bpoint[~cam_close, 4]) + #print(apoint[~cam_close, 4]) + #print(bpoint[~cam_close, 4]) cam_close = np.isclose(apoint[:, 4], bpoint[:, 4], atol=1.001) cam_diff = tot_points - np.sum(cam_close) assert cam_diff == 0, '\n' + \ From 813a3bf7dd367129548f6594d9ec0f50db319ecf Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 02:04:06 +0200 Subject: [PATCH 029/120] Event encodings in header, Support for Writer.from_reader('shallow_copy') + more Corrections: ---- Read TRIAL:ACTUAL_END_FIELD as 2 16 bit words in accordance with docs Read POINT:LONG_FRAMES as float in accordance with docs Set parameters helpers: --- analog labels point labels analog transform analog offset start/end frame +? --- c3d.py | 247 ++++++++++++++++------ test/test_software_examples_write_read.py | 78 ++++--- 2 files changed, 231 insertions(+), 94 deletions(-) diff --git a/c3d.py b/c3d.py index 172b757..edf14d3 100644 --- a/c3d.py +++ b/c3d.py @@ -53,24 +53,6 @@ def __init__(self, proc_type): self.int32 = np.int32 self.int64 = np.int64 - @property - def native(self): - ''' True if the native (system) byte order matches the file byte order. - ''' - return self._native - - @property - def little_endian_sys(self): - ''' True if native byte order is little-endian. - ''' - return self._little_endian_sys - - @property - def big_endian_sys(self): - ''' True if native byte order is big-endian. - ''' - return not self._little_endian_sys - @property def is_ieee(self): ''' True if the associated file is in the Intel format. @@ -96,6 +78,24 @@ def proc_type(self): processor_type = ['INTEL', 'DEC', 'MIPS'] return processor_type[self._proc_type - PROCESSOR_INTEL] + @property + def native(self): + ''' True if the native (system) byte order matches the file byte order. + ''' + return self._native + + @property + def little_endian_sys(self): + ''' True if native byte order is little-endian. + ''' + return self._little_endian_sys + + @property + def big_endian_sys(self): + ''' True if native byte order is big-endian. + ''' + return not self._little_endian_sys + def decode_string(self, bytes): ''' Decode a byte array to a string. ''' @@ -335,18 +335,18 @@ def write(self, handle): def __str__(self): '''Return a string representation of this Header's attributes.''' return '''\ - parameter_block: {0.parameter_block} - point_count: {0.point_count} - analog_count: {0.analog_count} - first_frame: {0.first_frame} - last_frame: {0.last_frame} - max_gap: {0.max_gap} - scale_factor: {0.scale_factor} - data_block: {0.data_block} - analog_per_frame: {0.analog_per_frame} - frame_rate: {0.frame_rate} -long_event_labels: {0.long_event_labels} - event_block: {0.event_block}'''.format(self) + parameter_block: {0.parameter_block} + point_count: {0.point_count} + analog_count: {0.analog_count} + first_frame: {0.first_frame} + last_frame: {0.last_frame} + max_gap: {0.max_gap} + scale_factor: {0.scale_factor} + data_block: {0.data_block} + analog_per_frame: {0.analog_per_frame} + frame_rate: {0.frame_rate} + long_event_labels: {0.long_event_labels} + event_block: {0.event_block}'''.format(self) def read(self, handle, fmt=BINARY_FORMAT_READ): '''Read and parse binary header data from a file handle. @@ -436,11 +436,11 @@ def _parse_events(self, dtypes, float_unpack): self.event_disp_flags = np.zeros(read_count, dtype=np.bool) self.event_labels = np.empty(read_count, dtype=object) for i in range(read_count): - ilong = i*4 + ilong = i * 4 # Unpack self.event_disp_flags[i] = disp_bytes[i] > 0 - self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong+4])[0]) - self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong+4]) + self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong + 4])[0]) + self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong + 4]) @property def events(self): @@ -452,6 +452,59 @@ def events(self): ''' return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags]) + def encode_events(self, events): + ''' Encode event data in the event block. + + Parameters + ---------- + events : [(float, str), ...] + Event data, iterable of touples containing the event timing and a 4 character label string. + Event timings should be calculated relative to sample 1 with the timing 0.0s, and should + not be relative to the first_frame header parameter. + ''' + endian = '<' + if sys.byteorder == 'big': + endian = '>' + + # Event block format + fmt = '{}{}{}{}{}'.format(endian, + str(18 * 4) + 's', # Timings + str(18) + 's', # Flags + 'H', # __ + str(18 * 4) + 's' # Labels + ) + # Pack bytes + event_timings = np.zeros(18, dtype=np.float32) + event_disp_flags = np.zeros(18, dtype=np.uint8) + event_labels = np.empty(18, dtype=object) + label_bytes = bytearray(18 * 4) + for i, (time, label) in enumerate(events): + if i > 17: + # Don't raise Error, header events are rarely used. + warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.') + break + + event_timings[i] = time + event_labels[i] = label + label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8') + + write_count = min(i + 1, 18) + event_disp_flags[:write_count] = 1 + + # Update event headers in self + self.long_event_labels = 0x3039 # Magic number + self.event_count = write_count + # Update event block + self.event_timings = event_timings[:write_count] + self.event_disp_flags = np.ones(write_count, dtype=np.bool) + self.event_labels = event_labels[:write_count] + self.event_block = struct.pack(fmt, + event_timings.tobytes(), + event_disp_flags.tobytes(), + 0, + label_bytes + ) + class Param(object): '''A class representing a single named parameter from a C3D file. @@ -542,7 +595,7 @@ def write(self, group_id, handle): handle.write(struct.pack('b', self.bytes_per_element)) handle.write(struct.pack('B', len(self.dimensions))) handle.write(struct.pack('B' * len(self.dimensions), *self.dimensions)) - if self.bytes: + if self.bytes is not None and len(self.bytes) > 0: handle.write(self.bytes) desc = self.desc.encode('utf-8') handle.write(struct.pack('B', len(desc))) @@ -568,13 +621,16 @@ def _as(self, dtype): '''Unpack the raw bytes of this param using the given struct format.''' return np.frombuffer(self.bytes, count=1, dtype=dtype)[0] - def _as_array(self, dtype): + def _as_array(self, dtype, copy=True): '''Unpack the raw bytes of this param using the given data format.''' assert self.dimensions, \ '{}: cannot get value as {} array!'.format(self.name, dtype) elems = np.frombuffer(self.bytes, dtype=dtype) # Reverse shape as the shape is defined in fortran format - return elems.reshape(self.dimensions[::-1]) + view = elems.reshape(self.dimensions[::-1]) + if copy: + return view.copy() + return view def _as_any(self, dtype): '''Unpack the raw bytes of this param as either array or single value.''' @@ -1402,7 +1458,9 @@ def first_frame(self): # this is a hack for phasespace files ... should put it in a subclass. param = self.get('TRIAL:ACTUAL_START_FIELD') if param is not None: - return param.uint32_value + # ACTUAL_START_FIELD is encoded in two 16 byte words... + words = param.uint16_array + return words[0] + words[1] * 65535 return self.header.first_frame @property @@ -1415,10 +1473,14 @@ def last_frame(self): end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: - end_frame[1] = param._as_integer_value + # Encoded as 2 16 bit words (rather then 1 32 bit word) + words = param.uint16_array + end_frame[1] = words[0] + words[1] * 65536 + #end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: - end_frame[2] = param._as_integer_value + # Encoded as float + end_frame[2] = int(param.float_value) param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint @@ -1426,28 +1488,34 @@ def last_frame(self): # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) - def _get_analog_transform(self): + def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets - analog_offsets = np.zeros((self.analog_used, 1), int) + analog_offsets = np.zeros((self.analog_used), int) param = self.get('ANALOG:OFFSET') if param is not None and param.num_elements > 0: - analog_offsets[:, 0] = param.int16_array[:self.analog_used] + analog_offsets[:] = param.int16_array[:self.analog_used] # Scale factors - analog_scales = np.ones((self.analog_used, 1), float) + analog_scales = np.ones((self.analog_used), float) gen_scale = 1. param = self.get('ANALOG:GEN_SCALE') if param is not None: gen_scale = param.float_value param = self.get('ANALOG:SCALE') if param is not None and param.num_elements > 0: - analog_scales[:, 0] = param.float_array[:self.analog_used] - analog_scales *= gen_scale + analog_scales[:] = param.float_array[:self.analog_used] - analog_scales = np.broadcast_to(analog_scales, (self.analog_used, self.analog_per_frame)) - analog_offsets = np.broadcast_to(analog_offsets, (self.analog_used, self.analog_per_frame)) + return gen_scale, analog_scales, analog_offsets + + def get_analog_transform(self): + ''' Get broadcastable analog transformation parameters. + ''' + gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters() + analog_scales *= gen_scale + analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) + analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) return analog_scales, analog_offsets class Reader(Manager): @@ -1602,7 +1670,7 @@ def read_frames(self, copy=True, analog_transform=True, camera_sum=False, check_ analog_word_bytes = 2 analog = np.array([], float) - analog_scales, analog_offsets = self._get_analog_transform() + analog_scales, analog_offsets = self.get_analog_transform() # Seek to the start point of the data blocks self._handle.seek((self._header.data_block - 1) * 512) @@ -1936,7 +2004,7 @@ def from_reader(reader, conversion=None): # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. raise ValueError( "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( - reader.proc_format)) + reader._dtypes.proc_type)) if is_consume: writer._header = reader._header @@ -1950,6 +2018,18 @@ def from_reader(reader, conversion=None): elif is_shallow_copy: # Only copy header (no groups) writer._header = copy.deepcopy(reader._header) + # Reformat header events + writer._header.encode_events(writer._header.events) + + # Transfer a minimal set parameters + writer.set_start_frame(reader.first_frame) + writer.set_point_labels(reader.point_labels) + writer.set_analog_labels(reader.analog_labels) + + gen_scale, analog_scales, analog_offsets = reader.get_analog_transform_parameters() + writer.set_analog_general_scale(gen_scale) + writer.set_analog_scales(analog_scales) + writer.set_analog_offsets(analog_offsets) if not is_meta_only: # Copy frames @@ -2058,20 +2138,31 @@ def add_frames(self, frames): '\Input was of shape {}.'.format(str(sh))) self._frames.extend(frames) - def set_point_labels(self, labels): - group = self.point_group + @staticmethod + def pack_labels(labels): labels = np.ravel(labels) # Get longest label name label_max_size = 0 label_max_size = max(label_max_size, np.max([len(label) for label in labels])) - label_str = ''.join(label.ljust(label_max_size) for label in labels) - group.add_str('LABELS', 'labels', label_str, label_max_size, len(labels)) + return label_str, label_max_size + + def set_point_labels(self, labels): + ''' Set point data labels. + ''' + label_str, label_max_size = Writer.pack_labels(labels) + self.point_group.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) + + def set_analog_labels(self, labels): + ''' Set analog data labels. + ''' + label_str, label_max_size = Writer.pack_labels(labels) + self.analog_group.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) def set_analog_general_scale(self, value): ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor). ''' - self.analog_group.set('GEN_SCALE', 'Analog general scale factor', 4, '= 65535: - first_frame = 1 - self._header.first_frame = np.uint16(first_frame) - self._header.last_frame = np.uint16(min(last_frame, 65535)) self._header.point_count = np.uint16(ppf) self._header.analog_count = np.uint16(np.prod(analog.shape)) @@ -2238,7 +2345,7 @@ def _write_frames(self, handle): point_scale = scale_mag raw = np.zeros((self.point_used, 4), point_dtype) - analog_scales, analog_offsets = self._get_analog_transform() + analog_scales, analog_offsets = self.get_analog_transform() analog_scales_inv = 1.0 / analog_scales for points, analog in self._frames: diff --git a/test/test_software_examples_write_read.py b/test/test_software_examples_write_read.py index 981bffa..2d924f3 100644 --- a/test/test_software_examples_write_read.py +++ b/test/test_software_examples_write_read.py @@ -8,10 +8,34 @@ from test.base import Base from test.zipload import Zipload, TEMP + +def verify_read_write(zip, file_path, proc_type='INTEL', real=True): + ''' Compare read write ouput to original read file. + ''' + A = c3d.Reader(Zipload._get(zip, file_path)) + + cpy_mode = 'copy' + if proc_type != 'INTEL': + cpy_mode = 'shallow_copy' + writer = A.to_writer(cpy_mode) + + tmp_path = os.path.join(TEMP, 'write_test.c3d') + with open(tmp_path, 'wb') as handle: + writer.write(handle) + + aname = 'Original' + bname = 'WriteRead' + test_id = '{} write read test'.format(proc_type) + + with open(tmp_path, 'rb') as handle: + B = c3d.Reader(handle) + verify.equal_headers(test_id, A, B, aname, bname, real, real) + verify.data_is_equal(A, B, aname, bname) + + class Sample00(Base): - ZIP = 'sample00.zip' - DATA_RANGE = (-1e6, 1e6) + ZIP = 'sample00.zip' zip_files = \ [ ('Advanced Realtime Tracking GmbH', ['arthuman-sample.c3d', 'arthuman-sample-fingers.c3d']), @@ -24,41 +48,47 @@ class Sample00(Base): ('Vicon Motion Systems', ['pyCGM2 lower limb CGM24 Walking01.c3d', 'TableTennis.c3d']), ] - def verify_read_write(self, file_path): - ''' Compare read write ouput to original read file. + def test_read_write_examples(self): + return + ''' Compare write ouput to original read ''' - A = c3d.Reader(Zipload._get(self.ZIP, file_path)) - writer = A.to_writer('copy') - - tmp_path = os.path.join(TEMP, 'write_test.c3d') - with open(tmp_path, 'wb') as handle: - writer.write(handle) - proc_type = 'INTEL' - aname = 'Original' - bname = 'WriteRead' - test_id = '{} write read test'.format(proc_type) + print('----------------------------') + print(type(self)) + print('----------------------------') + for folder, files in self.zip_files: + print('{} | Validating...'.format(folder)) + for file in files: + verify_read_write(self.ZIP, '{}/{}'.format(folder, file)) + print('... OK') + print('DONE') - with open(tmp_path, 'rb') as handle: - B = c3d.Reader(handle) - verify.equal_headers(test_id, A, B, aname, bname, True, True) - verify.data_is_equal(A, B, aname, bname) +class Sample01(Base): + ZIP = 'sample01.zip' + zip_files = \ + [ + ('Eb015pi.c3d', 'INTEL', False), + ('Eb015pr.c3d', 'INTEL', True), + ('Eb015vi.c3d', 'DEC', False), + ('Eb015vr.c3d', 'DEC', True), + ('Eb015si.c3d', 'SGI', False), + ('Eb015sr.c3d', 'SGI', True), + ] def test_read_write_examples(self): ''' Compare write ouput to original read ''' - print('----------------------------') print(type(self)) print('----------------------------') - for folder, files in self.zip_files: - print('{} | Validating...'.format(folder)) - for file in files: - self.verify_read_write('{}/{}'.format(folder, file)) + for (file, proc, is_real) in self.zip_files: + print('{} | Validating...'.format(file)) + verify_read_write(self.ZIP, file, proc, is_real) print('... OK') - print('DONE') + print('Done.') + if __name__ == '__main__': From 3925edfd0a9792ccd7f6e1db165d68a531927e72 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:03:22 +0200 Subject: [PATCH 030/120] set_screen_axis --- c3d.py | 37 ++++++++++++++++--------------------- 1 file changed, 16 insertions(+), 21 deletions(-) diff --git a/c3d.py b/c3d.py index edf14d3..9e1954a 100644 --- a/c3d.py +++ b/c3d.py @@ -1895,25 +1895,6 @@ def set_empty_array(self, name, *args, **kwargs): pass self.add_empty_array(name, *args, **kwargs) - # - # Try add decorator functions (catches KeyError) - # - def try_add(self, name, *args, **kwargs): - ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'. - ''' - try: - self.add(name, *args, **kwargs) - except KeyError as e: - pass - - def try_add_str(self, name, *args, **kwargs): - ''' Add a string parameter. - ''' - try: - self.add_str(name, *args, **kwargs) - except KeyError as e: - pass - class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -2217,6 +2198,22 @@ def _set_last_frame(self, frame): self.trial_group.set('ACTUAL_END_FIELD', 'Actual end frame', 2, ' Date: Fri, 2 Jul 2021 11:04:33 +0200 Subject: [PATCH 031/120] set_screen_axis, default values + actual set --- c3d.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/c3d.py b/c3d.py index 9e1954a..1ec869c 100644 --- a/c3d.py +++ b/c3d.py @@ -2199,7 +2199,7 @@ def _set_last_frame(self, frame): self._header.last_frame = np.uint16(min(frame, 65535)) - def set_screen_axis(self, X, Y): + def set_screen_axis(self, X='+X', Y='+Y'): ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. Parameter @@ -2211,8 +2211,8 @@ def set_screen_axis(self, X, Y): Second axis string with same format as Y. Determines the second Y screen axis. ''' group = self.point_group - group.set_str('X_SCREEN', 'X_SCREEN parameter', '+X', 2) - group.set_str('Y_SCREEN', 'Y_SCREEN parameter', '+Y', 2) + group.set_str('X_SCREEN', 'X_SCREEN parameter', X, 2) + group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y, 2) def write(self, handle): '''Write metadata and point + analog frames to a file handle. From 187e0fe24ecdee0f8b81f3723f180b6d24f7db20 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:06:50 +0200 Subject: [PATCH 032/120] assert input values --- c3d.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/c3d.py b/c3d.py index 1ec869c..4624773 100644 --- a/c3d.py +++ b/c3d.py @@ -2210,6 +2210,10 @@ def set_screen_axis(self, X='+X', Y='+Y'): Y : str Second axis string with same format as Y. Determines the second Y screen axis. ''' + if len(X) != 2: + raise ValueError('Expected string literal to be a 2 character string for the X_SCREEN parameter'. + if len(Y) != 2: + raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter'. group = self.point_group group.set_str('X_SCREEN', 'X_SCREEN parameter', X, 2) group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y, 2) From 235920a153a845b644c756f5b8189669e8711cbc Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:07:42 +0200 Subject: [PATCH 033/120] Missing bracket --- c3d.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c3d.py b/c3d.py index 4624773..bd34fb5 100644 --- a/c3d.py +++ b/c3d.py @@ -2211,9 +2211,9 @@ def set_screen_axis(self, X='+X', Y='+Y'): Second axis string with same format as Y. Determines the second Y screen axis. ''' if len(X) != 2: - raise ValueError('Expected string literal to be a 2 character string for the X_SCREEN parameter'. + raise ValueError('Expected string literal to be a 2 character string for the X_SCREEN parameter.') if len(Y) != 2: - raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter'. + raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter.') group = self.point_group group.set_str('X_SCREEN', 'X_SCREEN parameter', X, 2) group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y, 2) From 42d39777e45e1ffd76f8f9dc5414e6d70b1dd471 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:29:48 +0200 Subject: [PATCH 034/120] Added insert option to Writer.add_frames Added some simple Writer tests for add_frames and set_xxx --- c3d.py | 13 ++++++++++--- test/test_c3d.py | 22 +++++++++++++++++++++- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/c3d.py b/c3d.py index bd34fb5..74ae354 100644 --- a/c3d.py +++ b/c3d.py @@ -2099,13 +2099,16 @@ def add_group(self, *args, **kwargs): ''' return GroupEditable(super(Writer, self).add_group(*args, **kwargs)) - def add_frames(self, frames): + def add_frames(self, frames, index=None): '''Add frames to this writer instance. Parameters ---------- - frames : single or sequence of (point, analog) pairs + frames : Single or sequence of (point, analog) pairs A sequence or frame of frame data to add to the writer. + index : int or None + Insert the frame or sequence at the index (the first sequence frame will be inserted at give index). + Note that the index should be relative to 0 rather then the frame number provided by read_frames()! ''' sh = np.shape(frames) # Single frame @@ -2117,7 +2120,11 @@ def add_frames(self, frames): raise ValueError( 'Expected frame input to be sequence of point and analog pairs on form (-1, 2). ' + '\Input was of shape {}.'.format(str(sh))) - self._frames.extend(frames) + + if index is not None: + self._frames[index:index] = frames + else: + self._frames.extend(frames) @staticmethod def pack_labels(labels): diff --git a/test/test_c3d.py b/test/test_c3d.py index a8ebf0f..7883b2e 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -82,7 +82,7 @@ def test_frames(self): class WriterTest(Base): - def test_paramsd(self): + def test_add_frames(self): r = c3d.Reader(Zipload._get('sample08.zip', 'TESTDPI.c3d')) w = c3d.Writer( point_rate=r.point_rate, @@ -91,11 +91,31 @@ def test_paramsd(self): gen_scale=r.get_float('ANALOG:GEN_SCALE'), ) w.add_frames([(p, a) for _, p, a in r.read_frames()]) + w.add_frames([(p, a) for _, p, a in r.read_frames()], index=5) h = io.BytesIO() w.set_point_labels(r.point_labels) + w.set_analog_labels(r.analog_labels) w.write(h) + def test_set_params(self): + r = c3d.Reader(Zipload._get('sample08.zip', 'TESTDPI.c3d')) + w = c3d.Writer( + point_rate=r.point_rate, + analog_rate=r.analog_rate, + point_scale=r.point_scale, + gen_scale=r.get_float('ANALOG:GEN_SCALE'), + ) + w.add_frames([(p, a) for _, p, a in r.read_frames()]) + + h = io.BytesIO() + w.set_start_frame(255) + w.set_screen_axis() + w.set_screen_axis('-Y', '+Z') + w.set_point_labels(r.point_labels) + w.set_analog_labels(r.analog_labels) + + w.write(h) if __name__ == '__main__': unittest.main() From 8b0ab99777c3b0a09a62e366643c05d2f8d65cec Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:30:39 +0200 Subject: [PATCH 035/120] Comments --- test/test_c3d.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/test/test_c3d.py b/test/test_c3d.py index 7883b2e..7793d0f 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -1,4 +1,7 @@ -import c3d +''' Basic Reader and Writer tests. +''' + +'import c3d import importlib import io import unittest @@ -15,6 +18,8 @@ class ReaderTest(Base): + ''' Test basic Reader functionality + ''' def test_format_pi(self): r = c3d.Reader(Zipload._get('sample01.zip', 'Eb015pi.c3d')) self._log(r) @@ -82,6 +87,8 @@ def test_frames(self): class WriterTest(Base): + ''' Test basic writer functionality + ''' def test_add_frames(self): r = c3d.Reader(Zipload._get('sample08.zip', 'TESTDPI.c3d')) w = c3d.Writer( From 649cd68bd6c966f96846a3d6c96eb9bb8323552e Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:40:10 +0200 Subject: [PATCH 036/120] get_screen_axis() --- c3d.py | 14 ++++++++++++++ test/test_c3d.py | 15 +++++++++++---- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/c3d.py b/c3d.py index 74ae354..9490484 100644 --- a/c3d.py +++ b/c3d.py @@ -1488,6 +1488,20 @@ def last_frame(self): # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) + def get_screen_axis(self): + ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. + + Returns + ------- + value : Touple on form (str, str) or None + Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. + ''' + X = self.get('POINT:X_SCREEN') + Y = self.get('POINT:Y_SCREEN') + if X and Y: + return (X.string_value, Y.string_value) + return None + def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' diff --git a/test/test_c3d.py b/test/test_c3d.py index 7793d0f..1ae41e3 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -1,10 +1,10 @@ ''' Basic Reader and Writer tests. ''' - -'import c3d +import c3d import importlib import io import unittest +import numpy as np from test.base import Base from test.zipload import Zipload climate_spec = importlib.util.find_spec("climate") @@ -117,11 +117,18 @@ def test_set_params(self): h = io.BytesIO() w.set_start_frame(255) - w.set_screen_axis() - w.set_screen_axis('-Y', '+Z') w.set_point_labels(r.point_labels) w.set_analog_labels(r.analog_labels) + # Screen axis + X, Y = '-Y', '+Z' + w.set_screen_axis() + w.set_screen_axis(X, Y) + X_v, Y_v = w.get_screen_axis() + assert X_v == X and Y == Y_v, 'Mismatch between set & get screen axis.' + + assert np.all(np.equal(r.point_labels, w.point_labels)), 'Expected labels to be equal.' + w.write(h) if __name__ == '__main__': From 2b8abd7ca9c41142920d09134d7307df203871ca Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 11:52:29 +0200 Subject: [PATCH 037/120] default proc_type --- c3d.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/c3d.py b/c3d.py index 9490484..f119a85 100644 --- a/c3d.py +++ b/c3d.py @@ -14,13 +14,11 @@ PROCESSOR_DEC = 85 PROCESSOR_MIPS = 86 -INVALID_FLAG = -100000 - class DataTypes(object): ''' Container defining different data types used for reading file data. Data types depend on the processor format the file is stored in. ''' - def __init__(self, proc_type): + def __init__(self, proc_type=PROCESSOR_INTEL): self._proc_type = proc_type self._little_endian_sys = sys.byteorder == 'little' self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \ From 02e4490b0337ca6e5d85c0787aa985cd8171d205 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 12:08:55 +0200 Subject: [PATCH 038/120] Moved trailing functions --- c3d.py | 213 +--------------------- src/dtypes.py | 106 +++++++++++ src/utils.py | 109 +++++++++++ test/test_software_examples_write_read.py | 1 - 4 files changed, 219 insertions(+), 210 deletions(-) create mode 100644 src/dtypes.py create mode 100644 src/utils.py diff --git a/c3d.py b/c3d.py index f119a85..ffa05eb 100644 --- a/c3d.py +++ b/c3d.py @@ -8,213 +8,9 @@ import numpy as np import struct import warnings -import codecs +from src.dtypes import DataTypes +from src.utils import is_integer, is_iterable, DEC_to_IEEE, DEC_to_IEEE_BYTES, UNPACK_FLOAT_IEEE, Decorator -PROCESSOR_INTEL = 84 -PROCESSOR_DEC = 85 -PROCESSOR_MIPS = 86 - -class DataTypes(object): - ''' Container defining different data types used for reading file data. - Data types depend on the processor format the file is stored in. - ''' - def __init__(self, proc_type=PROCESSOR_INTEL): - self._proc_type = proc_type - self._little_endian_sys = sys.byteorder == 'little' - self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \ - (self.is_mips and self.big_endian_sys) - if self.big_endian_sys: - warnings.warn('Systems with native byte order of big-endian are not supported.') - - if self._proc_type == PROCESSOR_MIPS: - # Big-Endian (SGI/MIPS format) - self.float32 = np.dtype(np.float32).newbyteorder('>') - self.float64 = np.dtype(np.float64).newbyteorder('>') - self.uint8 = np.uint8 - self.uint16 = np.dtype(np.uint16).newbyteorder('>') - self.uint32 = np.dtype(np.uint32).newbyteorder('>') - self.uint64 = np.dtype(np.uint64).newbyteorder('>') - self.int8 = np.int8 - self.int16 = np.dtype(np.int16).newbyteorder('>') - self.int32 = np.dtype(np.int32).newbyteorder('>') - self.int64 = np.dtype(np.int64).newbyteorder('>') - else: - # Little-Endian format (Intel or DEC format) - self.float32 = np.float32 - self.float64 = np.float64 - self.uint8 = np.uint8 - self.uint16 = np.uint16 - self.uint32 = np.uint32 - self.uint64 = np.uint64 - self.int8 = np.int8 - self.int16 = np.int16 - self.int32 = np.int32 - self.int64 = np.int64 - - @property - def is_ieee(self): - ''' True if the associated file is in the Intel format. - ''' - return self._proc_type == PROCESSOR_INTEL - - @property - def is_dec(self): - ''' True if the associated file is in the DEC format. - ''' - return self._proc_type == PROCESSOR_DEC - - @property - def is_mips(self): - ''' True if the associated file is in the SGI/MIPS format. - ''' - return self._proc_type == PROCESSOR_MIPS - - @property - def proc_type(self): - ''' Get the processory type associated with the data format in the file. - ''' - processor_type = ['INTEL', 'DEC', 'MIPS'] - return processor_type[self._proc_type - PROCESSOR_INTEL] - - @property - def native(self): - ''' True if the native (system) byte order matches the file byte order. - ''' - return self._native - - @property - def little_endian_sys(self): - ''' True if native byte order is little-endian. - ''' - return self._little_endian_sys - - @property - def big_endian_sys(self): - ''' True if native byte order is big-endian. - ''' - return not self._little_endian_sys - - def decode_string(self, bytes): - ''' Decode a byte array to a string. - ''' - # Attempt to decode using different decoders - decoders = ['utf-8', 'latin-1'] - for dec in decoders: - try: - return codecs.decode(bytes, dec) - except UnicodeDecodeError: - continue - # Revert to using default decoder but replace characters - return codecs.decode(bytes, decoders[0], 'replace') - -class Decorator(object): - '''Base class for extending (decotrating) a python object. - ''' - def __init__(self, decoratee): - self._decoratee = decoratee - def __getattr__(self, name): - return getattr(self._decoratee, name) - -def UNPACK_FLOAT_IEEE(uint_32): - '''Unpacks a single 32 bit unsigned int to a IEEE float representation - ''' - return struct.unpack('f', struct.pack("I", uint_32))[0] - - -def DEC_to_IEEE(uint_32): - '''Convert the 32 bit representation of a DEC float to IEEE format. - - Params: - ---- - uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits. - Returns : IEEE formated floating point of the same shape as the input. - ''' - # Follows the bit pattern found: - # http://home.fnal.gov/~yang/Notes/ieee_vs_dec_float.txt - # Further formating descriptions can be found: - # http://www.irig106.org/docs/106-07/appendixO.pdf - # In accodance with the first ref. first & second 16 bit words are placed - # in a big endian 16 bit word representation, and needs to be inverted. - # Second reference describe the DEC->IEEE conversion. - - # Warning! Unsure if NaN numbers are managed appropriately. - - # Shuffle the first two bit words from DEC bit representation to an ordered representation. - # Note that the most significant fraction bits are placed in the first 7 bits. - # - # Below are the DEC layout in accordance with the references: - # ___________________________________________________________________________________ - # | Mantissa (16:0) | SIGN | Exponent (8:0) | Mantissa (23:17) | - # ___________________________________________________________________________________ - # |32- -16| 15 |14- -7|6- -0| - # - # Legend: - # _______________________________________________________ - # | Part (left bit of segment : right bit) | Part | .. - # _______________________________________________________ - # |Bit adress - .. - Bit adress | Bit adress - .. - #### - - # Swap the first and last 16 bits for a consistent alignment of the fraction - reshuffled = ((uint_32 & 0xFFFF0000) >> 16) | ((uint_32 & 0x0000FFFF) << 16) - # After the shuffle each part are in little-endian and ordered as: SIGN-Exponent-Fraction - exp_bits = ((reshuffled & 0xFF000000) - 1) & 0xFF000000 - reshuffled = (reshuffled & 0x00FFFFFF) | exp_bits - return UNPACK_FLOAT_IEEE(reshuffled) - - -def DEC_to_IEEE_BYTES(bytes): - '''Convert byte array containing 32 bit DEC floats to IEEE format. - - Params: - ---- - bytes : Byte array where every 4 bytes represent a single precision DEC float. - Returns : IEEE formated floating point of the same shape as the input. - ''' - - # See comments in DEC_to_IEEE() for DEC format definition - - # Reshuffle - bytes = memoryview(bytes) - reshuffled = np.empty(len(bytes), dtype=np.dtype('B')) - reshuffled[::4] = bytes[2::4] - reshuffled[1::4] = bytes[3::4] - reshuffled[2::4] = bytes[::4] - # Decrement exponent by 2, if exp. > 1 - reshuffled[3::4] = bytes[1::4] + (np.bitwise_and(bytes[1::4], 0x7f) == 0) - 1 - - # There are different ways to adjust for differences in DEC/IEEE representation - # after reshuffle. Two simple methods are: - # 1) Decrement exponent bits by 2, then convert to IEEE. - # 2) Convert to IEEE directly and divide by four. - # 3) Handle edge cases, expensive in python... - # However these are simple methods, and do not accurately convert when: - # 1) Exponent < 2 (without bias), impossible to decrement exponent without adjusting fraction/mantissa. - # 2) Exponent == 0, DEC numbers are then 0 or undefined while IEEE is not. NaN are produced when exponent == 255. - # Here method 1) is used, which mean that only small numbers will be represented incorrectly. - - return np.frombuffer(reshuffled.tobytes(), - dtype=np.float32, - count=int(len(bytes) / 4)) - -def is_integer(value): - '''Check if value input is integer.''' - return isinstance(value, (int, np.int32, np.int64)) - -def is_iterable(value): - '''Check if value is iterable.''' - return hasattr(value, '__iter__') - -def type_npy2struct(dtype): - ''' Convert numpy dtype format to a struct package format string. - ''' - return dtype.byteorder + dtype.char class Header(object): '''Header information from a C3D file. @@ -1743,7 +1539,6 @@ def read_frames(self, copy=True, analog_transform=True, camera_sum=False, check_ ## # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded). residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8 - #if self._dtypes.big_endian_sys -> swap words? # Fourth value is floating-point (scaled) error estimate (residual) points[:, 3] = residual_byte * scale_mag @@ -1944,7 +1739,7 @@ def __init__(self, '''Set metadata for this writer. ''' - self._dtypes = DataTypes(PROCESSOR_INTEL) # Always write INTEL format + self._dtypes = DataTypes() # Only support INTEL format from writing super(Writer, self).__init__() # Custom properties @@ -2334,7 +2129,7 @@ def _write_metadata(self, handle): # Groups handle.write(struct.pack( - 'BBBB', 0, 0, self.parameter_blocks(), PROCESSOR_INTEL)) + 'BBBB', 0, 0, self.parameter_blocks(), self._dtypes.processor)) for group_id, group in self.group_listed(): group.write(group_id, handle) diff --git a/src/dtypes.py b/src/dtypes.py new file mode 100644 index 0000000..e327b27 --- /dev/null +++ b/src/dtypes.py @@ -0,0 +1,106 @@ +import sys +import codecs +import numpy as np + +PROCESSOR_INTEL = 84 +PROCESSOR_DEC = 85 +PROCESSOR_MIPS = 86 + +class DataTypes(object): + ''' Container defining different data types used for reading file data. + Data types depend on the processor format the file is stored in. + ''' + def __init__(self, proc_type=PROCESSOR_INTEL): + self._proc_type = proc_type + self._little_endian_sys = sys.byteorder == 'little' + self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \ + (self.is_mips and self.big_endian_sys) + if self.big_endian_sys: + warnings.warn('Systems with native byte order of big-endian are not supported.') + + if self._proc_type == PROCESSOR_MIPS: + # Big-Endian (SGI/MIPS format) + self.float32 = np.dtype(np.float32).newbyteorder('>') + self.float64 = np.dtype(np.float64).newbyteorder('>') + self.uint8 = np.uint8 + self.uint16 = np.dtype(np.uint16).newbyteorder('>') + self.uint32 = np.dtype(np.uint32).newbyteorder('>') + self.uint64 = np.dtype(np.uint64).newbyteorder('>') + self.int8 = np.int8 + self.int16 = np.dtype(np.int16).newbyteorder('>') + self.int32 = np.dtype(np.int32).newbyteorder('>') + self.int64 = np.dtype(np.int64).newbyteorder('>') + else: + # Little-Endian format (Intel or DEC format) + self.float32 = np.float32 + self.float64 = np.float64 + self.uint8 = np.uint8 + self.uint16 = np.uint16 + self.uint32 = np.uint32 + self.uint64 = np.uint64 + self.int8 = np.int8 + self.int16 = np.int16 + self.int32 = np.int32 + self.int64 = np.int64 + + @property + def is_ieee(self): + ''' True if the associated file is in the Intel format. + ''' + return self._proc_type == PROCESSOR_INTEL + + @property + def is_dec(self): + ''' True if the associated file is in the DEC format. + ''' + return self._proc_type == PROCESSOR_DEC + + @property + def is_mips(self): + ''' True if the associated file is in the SGI/MIPS format. + ''' + return self._proc_type == PROCESSOR_MIPS + + @property + def proc_type(self): + ''' Get the processory type associated with the data format in the file. + ''' + processor_type = ['INTEL', 'DEC', 'MIPS'] + return processor_type[self._proc_type - PROCESSOR_INTEL] + + @property + def processor(self): + ''' Get the processor number encoded in the .c3d file. + ''' + return self._proc_type + + @property + def native(self): + ''' True if the native (system) byte order matches the file byte order. + ''' + return self._native + + @property + def little_endian_sys(self): + ''' True if native byte order is little-endian. + ''' + return self._little_endian_sys + + @property + def big_endian_sys(self): + ''' True if native byte order is big-endian. + ''' + return not self._little_endian_sys + + def decode_string(self, bytes): + ''' Decode a byte array to a string. + ''' + # Attempt to decode using different decoders + decoders = ['utf-8', 'latin-1'] + for dec in decoders: + try: + return codecs.decode(bytes, dec) + except UnicodeDecodeError: + continue + # Revert to using default decoder but replace characters + return codecs.decode(bytes, decoders[0], 'replace') diff --git a/src/utils.py b/src/utils.py new file mode 100644 index 0000000..5d4c8c3 --- /dev/null +++ b/src/utils.py @@ -0,0 +1,109 @@ +import numpy as np +import struct + +def is_integer(value): + '''Check if value input is integer.''' + return isinstance(value, (int, np.int32, np.int64)) + +def is_iterable(value): + '''Check if value is iterable.''' + return hasattr(value, '__iter__') + +def type_npy2struct(dtype): + ''' Convert numpy dtype format to a struct package format string. + ''' + return dtype.byteorder + dtype.char + +class Decorator(object): + '''Base class for extending (decorating) a python object. + ''' + def __init__(self, decoratee): + self._decoratee = decoratee + def __getattr__(self, name): + return getattr(self._decoratee, name) + +def UNPACK_FLOAT_IEEE(uint_32): + '''Unpacks a single 32 bit unsigned int to a IEEE float representation + ''' + return struct.unpack('f', struct.pack("I", uint_32))[0] + +def DEC_to_IEEE(uint_32): + '''Convert the 32 bit representation of a DEC float to IEEE format. + + Params: + ---- + uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits. + Returns : IEEE formated floating point of the same shape as the input. + ''' + # Follows the bit pattern found: + # http://home.fnal.gov/~yang/Notes/ieee_vs_dec_float.txt + # Further formating descriptions can be found: + # http://www.irig106.org/docs/106-07/appendixO.pdf + # In accodance with the first ref. first & second 16 bit words are placed + # in a big endian 16 bit word representation, and needs to be inverted. + # Second reference describe the DEC->IEEE conversion. + + # Warning! Unsure if NaN numbers are managed appropriately. + + # Shuffle the first two bit words from DEC bit representation to an ordered representation. + # Note that the most significant fraction bits are placed in the first 7 bits. + # + # Below are the DEC layout in accordance with the references: + # ___________________________________________________________________________________ + # | Mantissa (16:0) | SIGN | Exponent (8:0) | Mantissa (23:17) | + # ___________________________________________________________________________________ + # |32- -16| 15 |14- -7|6- -0| + # + # Legend: + # _______________________________________________________ + # | Part (left bit of segment : right bit) | Part | .. + # _______________________________________________________ + # |Bit adress - .. - Bit adress | Bit adress - .. + #### + + # Swap the first and last 16 bits for a consistent alignment of the fraction + reshuffled = ((uint_32 & 0xFFFF0000) >> 16) | ((uint_32 & 0x0000FFFF) << 16) + # After the shuffle each part are in little-endian and ordered as: SIGN-Exponent-Fraction + exp_bits = ((reshuffled & 0xFF000000) - 1) & 0xFF000000 + reshuffled = (reshuffled & 0x00FFFFFF) | exp_bits + return UNPACK_FLOAT_IEEE(reshuffled) + + +def DEC_to_IEEE_BYTES(bytes): + '''Convert byte array containing 32 bit DEC floats to IEEE format. + + Params: + ---- + bytes : Byte array where every 4 bytes represent a single precision DEC float. + Returns : IEEE formated floating point of the same shape as the input. + ''' + + # See comments in DEC_to_IEEE() for DEC format definition + + # Reshuffle + bytes = memoryview(bytes) + reshuffled = np.empty(len(bytes), dtype=np.dtype('B')) + reshuffled[::4] = bytes[2::4] + reshuffled[1::4] = bytes[3::4] + reshuffled[2::4] = bytes[::4] + # Decrement exponent by 2, if exp. > 1 + reshuffled[3::4] = bytes[1::4] + (np.bitwise_and(bytes[1::4], 0x7f) == 0) - 1 + + # There are different ways to adjust for differences in DEC/IEEE representation + # after reshuffle. Two simple methods are: + # 1) Decrement exponent bits by 2, then convert to IEEE. + # 2) Convert to IEEE directly and divide by four. + # 3) Handle edge cases, expensive in python... + # However these are simple methods, and do not accurately convert when: + # 1) Exponent < 2 (without bias), impossible to decrement exponent without adjusting fraction/mantissa. + # 2) Exponent == 0, DEC numbers are then 0 or undefined while IEEE is not. NaN are produced when exponent == 255. + # Here method 1) is used, which mean that only small numbers will be represented incorrectly. + + return np.frombuffer(reshuffled.tobytes(), + dtype=np.float32, + count=int(len(bytes) / 4)) diff --git a/test/test_software_examples_write_read.py b/test/test_software_examples_write_read.py index 2d924f3..3f02ae9 100644 --- a/test/test_software_examples_write_read.py +++ b/test/test_software_examples_write_read.py @@ -49,7 +49,6 @@ class Sample00(Base): ] def test_read_write_examples(self): - return ''' Compare write ouput to original read ''' From 6eece5953bcc5ebd82929cbf57c1a79120c7fab6 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 13:05:44 +0200 Subject: [PATCH 039/120] group.py, parameter.py --- c3d.py | 876 +---------------------------------------------- src/group.py | 231 +++++++++++++ src/parameter.py | 355 +++++++++++++++++++ 3 files changed, 590 insertions(+), 872 deletions(-) create mode 100644 src/group.py create mode 100644 src/parameter.py diff --git a/c3d.py b/c3d.py index ffa05eb..fc4a201 100644 --- a/c3d.py +++ b/c3d.py @@ -8,878 +8,10 @@ import numpy as np import struct import warnings +from src.header import Header +from src.group import Group from src.dtypes import DataTypes -from src.utils import is_integer, is_iterable, DEC_to_IEEE, DEC_to_IEEE_BYTES, UNPACK_FLOAT_IEEE, Decorator - - -class Header(object): - '''Header information from a C3D file. - - Attributes - ---------- - event_block : int - Index of the 512-byte block where labels (metadata) are found. - parameter_block : int - Index of the 512-byte block where parameters (metadata) are found. - data_block : int - Index of the 512-byte block where data starts. - point_count : int - Number of motion capture channels recorded in this file. - analog_count : int - Number of analog values recorded per frame of 3D point data. - first_frame : int - Index of the first frame of data. - last_frame : int - Index of the last frame of data. - analog_per_frame : int - Number of analog frames per frame of 3D point data. The analog frame - rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE) - times the analog per frame value. - frame_rate : float - The frame rate of the recording, in frames per second. - scale_factor : float - Multiply values in the file by this scale parameter. - long_event_labels : bool - max_gap : int - - .. note:: - The ``scale_factor`` attribute is not used in Phasespace C3D files; - instead, use the POINT.SCALE parameter. - - .. note:: - The ``first_frame`` and ``last_frame`` header attributes are not used in - C3D files generated by Phasespace. Instead, the first and last - frame numbers are stored in the POINTS:ACTUAL_START_FIELD and - POINTS:ACTUAL_END_FIELD parameters. - ''' - - # Read/Write header formats, read values as unsigned ints rather then floats. - BINARY_FORMAT_WRITE = ' 0 - self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong + 4])[0]) - self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong + 4]) - - @property - def events(self): - ''' Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label). - - Note*: - Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter. - Frame 1 therefor has the time 0.0 in relation to the event timing. - ''' - return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags]) - - def encode_events(self, events): - ''' Encode event data in the event block. - - Parameters - ---------- - events : [(float, str), ...] - Event data, iterable of touples containing the event timing and a 4 character label string. - Event timings should be calculated relative to sample 1 with the timing 0.0s, and should - not be relative to the first_frame header parameter. - ''' - endian = '<' - if sys.byteorder == 'big': - endian = '>' - - # Event block format - fmt = '{}{}{}{}{}'.format(endian, - str(18 * 4) + 's', # Timings - str(18) + 's', # Flags - 'H', # __ - str(18 * 4) + 's' # Labels - ) - # Pack bytes - event_timings = np.zeros(18, dtype=np.float32) - event_disp_flags = np.zeros(18, dtype=np.uint8) - event_labels = np.empty(18, dtype=object) - label_bytes = bytearray(18 * 4) - for i, (time, label) in enumerate(events): - if i > 17: - # Don't raise Error, header events are rarely used. - warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.') - break - - event_timings[i] = time - event_labels[i] = label - label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8') - - write_count = min(i + 1, 18) - event_disp_flags[:write_count] = 1 - - # Update event headers in self - self.long_event_labels = 0x3039 # Magic number - self.event_count = write_count - # Update event block - self.event_timings = event_timings[:write_count] - self.event_disp_flags = np.ones(write_count, dtype=np.bool) - self.event_labels = event_labels[:write_count] - self.event_block = struct.pack(fmt, - event_timings.tobytes(), - event_disp_flags.tobytes(), - 0, - label_bytes - ) - - -class Param(object): - '''A class representing a single named parameter from a C3D file. - - Attributes - ---------- - name : str - Name of this parameter. - dtype: DataTypes - Reference to the DataTypes object associated with the file. - desc : str - Brief description of this parameter. - bytes_per_element : int, optional - For array data, this describes the size of each element of data. For - string data (including arrays of strings), this should be -1. - dimensions : list of int - For array data, this describes the dimensions of the array, stored in - column-major order. For arrays of strings, the dimensions here will be - the number of columns (length of each string) followed by the number of - rows (number of strings). - bytes : str - Raw data for this parameter. - handle : - File handle positioned at the first byte of a .c3d parameter description. - ''' - - def __init__(self, - name, - dtype, - desc='', - bytes_per_element=1, - dimensions=None, - bytes=b'', - handle=None): - '''Set up a new parameter, only the name is required.''' - self.name = name - self._dtypes = dtype - self.desc = desc - self.bytes_per_element = bytes_per_element - self.dimensions = dimensions or [] - self.bytes = bytes - if handle: - self.read(handle) - - def __repr__(self): - return ''.format(self.desc) - - @property - def num_elements(self): - '''Return the number of elements in this parameter's array value.''' - e = 1 - for d in self.dimensions: - e *= d - return e - - @property - def total_bytes(self): - '''Return the number of bytes used for storing this parameter's data.''' - return self.num_elements * abs(self.bytes_per_element) - - def binary_size(self): - '''Return the number of bytes needed to store this parameter.''' - return ( - 1 + # group_id - 2 + # next offset marker - 1 + len(self.name.encode('utf-8')) + # size of name and name bytes - 1 + # data size - # size of dimensions and dimension bytes - 1 + len(self.dimensions) + - self.total_bytes + # data - 1 + len(self.desc.encode('utf-8')) # size of desc and desc bytes - ) - - def write(self, group_id, handle): - '''Write binary data for this parameter to a file handle. - - Parameters - ---------- - group_id : int - The numerical ID of the group that holds this parameter. - handle : file handle - An open, writable, binary file handle. - ''' - name = self.name.encode('utf-8') - handle.write(struct.pack('bb', len(name), group_id)) - handle.write(name) - handle.write(struct.pack(' 0: - handle.write(self.bytes) - desc = self.desc.encode('utf-8') - handle.write(struct.pack('B', len(desc))) - handle.write(desc) - - def read(self, handle): - '''Read binary data for this parameter from a file handle. - - This reads exactly enough data from the current position in the file to - initialize the parameter. - ''' - self.bytes_per_element, = struct.unpack('b', handle.read(1)) - dims, = struct.unpack('B', handle.read(1)) - self.dimensions = [struct.unpack('B', handle.read(1))[ - 0] for _ in range(dims)] - self.bytes = b'' - if self.total_bytes: - self.bytes = handle.read(self.total_bytes) - desc_size, = struct.unpack('B', handle.read(1)) - self.desc = desc_size and self._dtypes.decode_string(handle.read(desc_size)) or '' - - def _as(self, dtype): - '''Unpack the raw bytes of this param using the given struct format.''' - return np.frombuffer(self.bytes, count=1, dtype=dtype)[0] - - def _as_array(self, dtype, copy=True): - '''Unpack the raw bytes of this param using the given data format.''' - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, dtype) - elems = np.frombuffer(self.bytes, dtype=dtype) - # Reverse shape as the shape is defined in fortran format - view = elems.reshape(self.dimensions[::-1]) - if copy: - return view.copy() - return view - - def _as_any(self, dtype): - '''Unpack the raw bytes of this param as either array or single value.''' - if 0 in self.dimensions[:]: # Check if any dimension is 0 (empty buffer) - return [] # Buffer is empty - - if len(self.dimensions) == 0: # Parse data as a single value - if dtype == np.float32: # Floats need to be parsed separately! - return self.float_value - return self._as(dtype) - else: # Parse data as array - if dtype == np.float32: - data = self.float_array - else: - data = self._as_array(dtype) - if len(self.dimensions) < 2: # Check if data is contained in a single dimension - return data.flatten() - return data - - @property - def _as_integer_value(self): - ''' Get the param as either 32 bit float or unsigned integer. - Evaluates if an integer is stored as a floating point representation. - - Note: This is implemented purely for parsing start/end frames. - ''' - if self.total_bytes >= 4: - # Check if float value representation is an integer - value = self.float_value - if int(value) == value: - return value - return self.uint32_value - elif self.total_bytes >= 2: - return self.uint16_value - else: - return self.uint8_value - - @property - def int8_value(self): - '''Get the param as an 8-bit signed integer.''' - return self._as(self._dtypes.int8) - - @property - def uint8_value(self): - '''Get the param as an 8-bit unsigned integer.''' - return self._as(self._dtypes.uint8) - - @property - def int16_value(self): - '''Get the param as a 16-bit signed integer.''' - return self._as(self._dtypes.int16) - - @property - def uint16_value(self): - '''Get the param as a 16-bit unsigned integer.''' - return self._as(self._dtypes.uint16) - - @property - def int32_value(self): - '''Get the param as a 32-bit signed integer.''' - return self._as(self._dtypes.int32) - - @property - def uint32_value(self): - '''Get the param as a 32-bit unsigned integer.''' - return self._as(self._dtypes.uint32) - - @property - def float_value(self): - '''Get the param as a 32-bit float.''' - if self._dtypes.is_dec: - return DEC_to_IEEE(self._as(np.uint32)) - else: # is_mips or is_ieee - return self._as(self._dtypes.float32) - - @property - def bytes_value(self): - '''Get the param as a raw byte string.''' - return self.bytes - - @property - def string_value(self): - '''Get the param as a unicode string.''' - return self._dtypes.decode_string(self.bytes) - - @property - def int8_array(self): - '''Get the param as an array of 8-bit signed integers.''' - return self._as_array(self._dtypes.int8) - - @property - def uint8_array(self): - '''Get the param as an array of 8-bit unsigned integers.''' - return self._as_array(self._dtypes.uint8) - - @property - def int16_array(self): - '''Get the param as an array of 16-bit signed integers.''' - return self._as_array(self._dtypes.int16) - - @property - def uint16_array(self): - '''Get the param as an array of 16-bit unsigned integers.''' - return self._as_array(self._dtypes.uint16) - - @property - def int32_array(self): - '''Get the param as an array of 32-bit signed integers.''' - return self._as_array(self._dtypes.int32) - - @property - def uint32_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' - return self._as_array(self._dtypes.uint32) - - @property - def int64_array(self): - '''Get the param as an array of 32-bit signed integers.''' - return self._as_array(self._dtypes.int64) - - @property - def uint64_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' - return self._as_array(self._dtypes.uint64) - - @property - def float32_array(self): - '''Get the param as an array of 32-bit floats.''' - # Convert float data if not IEEE processor - if self._dtypes.is_dec: - # _as_array but for DEC - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, self._dtypes.float32) - return DEC_to_IEEE_BYTES(self.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format - else: # is_ieee or is_mips - return self._as_array(self._dtypes.float32) - - @property - def float64_array(self): - '''Get the param as an array of 64-bit floats.''' - # Convert float data if not IEEE processor - if self._dtypes.is_dec: - raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') - else: # is_ieee or is_mips - return self._as_array(self._dtypes.float64) - - @property - def float_array(self): - '''Get the param as an array of 32 or 64 bit floats.''' - # Convert float data if not IEEE processor - if self.bytes_per_element == 4: - return self.float32_array - elif self.bytes_per_element == 8: - return self.float64_array - else: - raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element + - "floating-point precission is not unsupported.") - - @property - def int_array(self): - '''Get the param as an array of integer values.''' - # Convert float data if not IEEE processor - if self.bytes_per_element == 1: - return self.int8_array - elif self.bytes_per_element == 2: - return self.int16_array - elif self.bytes_per_element == 4: - return self.int32_array - elif self.bytes_per_element == 8: - return self.int64_array - else: - raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % - self.bytes_per_element) - - @property - def uint_array(self): - '''Get the param as an array of integer values.''' - # Convert float data if not IEEE processor - if self.bytes_per_element == 1: - return self.uint8_array - elif self.bytes_per_element == 2: - return self.uint16_array - elif self.bytes_per_element == 4: - return self.uint32_array - elif self.bytes_per_element == 8: - return self.uint64_array - else: - raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % - self.bytes_per_element) - - @property - def bytes_array(self): - '''Get the param as an array of raw byte strings.''' - # Decode different dimensions - if len(self.dimensions) == 0: - return np.array([]) - elif len(self.dimensions) == 1: - return np.array(self.bytes) - else: - # Convert Fortran shape (data in memory is identical, shape is transposed) - word_len = self.dimensions[0] - dims = self.dimensions[1:][::-1] # Identical to: [:0:-1] - byte_steps = np.cumprod(self.dimensions[:-1])[::-1] - # Generate mult-dimensional array and parse byte words - byte_arr = np.empty(dims, dtype=object) - for i in np.ndindex(*dims): - # Calculate byte offset as sum of each array index times the byte step of each dimension. - off = np.sum(np.multiply(i, byte_steps)) - byte_arr[i] = self.bytes[off:off+word_len] - return byte_arr - - @property - def string_array(self): - '''Get the param as a python array of unicode strings.''' - # Decode different dimensions - if len(self.dimensions) == 0: - return np.array([]) - elif len(self.dimensions) == 1: - return np.array([self.string_value]) - else: - # Parse byte sequences - byte_arr = self.bytes_array - # Decode sequences - for i in np.ndindex(byte_arr.shape): - byte_arr[i] = self._dtypes.decode_string(byte_arr[i]) - return byte_arr - - -class Group(object): - '''A group of parameters from a C3D file. - - In C3D files, parameters are organized in groups. Each group has a name, a - description, and a set of named parameters. - - Attributes - ---------- - dtypes : 'DataTypes' - Data types object used for parsing. - name : str - Name of this parameter group. - desc : str - Description for this parameter group. - ''' - - def __init__(self, dtypes, name=None, desc=None): - self._params = {} - self._dtypes = dtypes - # Assign through property setters - self.name = name - self.desc = desc - - def __repr__(self): - return ''.format(self.desc) - - def __contains__(self, key): - return key in self._params - - @property - def name(self): - ''' Group name. ''' - return self._name - - @name.setter - def name(self, value): - ''' Group name string. - - Parameters - ---------- - value : str - New name for the group. - ''' - if value is None or isinstance(value, str): - self._name = value - else: - raise TypeError('Expected group name to be string, was %s.' % type(value)) - - @property - def desc(self): - ''' Group descriptor. ''' - return self._desc - - @desc.setter - def desc(self, value): - ''' Group descriptor. - - Parameters - ---------- - value : str, or bytes - New description for this parameter group. - ''' - if isinstance(value, bytes): - self._desc = self._dtypes.decode_string(value) - elif isinstance(value, str) or value is None: - self._desc = value - else: - raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(value)) - - def param_items(self): - ''' Acquire iterator for paramater key-entry pairs. ''' - return self._params.items() - - def param_values(self): - ''' Acquire iterator for parameter entries. ''' - return self._params.values() - - def param_keys(self): - ''' Acquire iterator for parameter entry keys. ''' - return self._params.keys() - - def get(self, key, default=None): - '''Get a parameter by key. - - Parameters - ---------- - key : any - Parameter key to look up in this group. - default : any, optional - Value to return if the key is not found. Defaults to None. - - Returns - ------- - param : :class:`Param` - A parameter from the current group. - ''' - return self._params.get(key, default) - - def add_param(self, name, **kwargs): - '''Add a parameter to this group. - - Parameters - ---------- - name : str - Name of the parameter to add to this group. The name will - automatically be case-normalized. - - Additional keyword arguments will be passed to the `Param` constructor. - - Raises - ------ - TypeError - Input arguments are of the wrong type. - KeyError - Name or numerical key already exist (attempt to overwrite existing data). - ''' - if not isinstance(name, str): - raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name))) - name = name.upper() - if name in self._params: - raise KeyError('Parameter already exists with key {}'.format(name)) - self._params[name] = Param(name, self._dtypes, **kwargs) - - def remove_param(self, name): - '''Remove the specified parameter. - - Parameters - ---------- - name : str - Name for the parameter to remove. - ''' - del self._params[name] - - def rename_param(self, name, new_name): - ''' Rename a specified parameter group. - - Parameters - ---------- - name : str, or 'Param' - Parameter instance, or name. - new_name : str - New name for the parameter. - - Raises - ------ - KeyError - If no parameter with the original name exists. - ValueError - If the new name already exist (attempt to overwrite existing data). - ''' - if new_name in self._params: - raise ValueError("Key {} already exist.".format(new_name)) - if isinstance(name, Param): - param = name - name = param.name - else: - # Aquire instance using id - param = self._params.get(name, None) - if param is None: - raise KeyError('No parameter found matching the identifier: {}'.format(str(name))) - del self._params[name] - self._params[new_name] = param - - def binary_size(self): - '''Return the number of bytes to store this group and its parameters.''' - return ( - 1 + # group_id - 1 + len(self._name.encode('utf-8')) + # size of name and name bytes - 2 + # next offset marker - 1 + len(self._desc.encode('utf-8')) + # size of desc and desc bytes - sum(p.binary_size() for p in self._params.values())) - - def write(self, group_id, handle): - '''Write this parameter group, with parameters, to a file handle. - - Parameters - ---------- - group_id : int - The numerical ID of the group. - handle : file handle - An open, writable, binary file handle. - ''' - name = self._name.encode('utf-8') - desc = self._desc.encode('utf-8') - handle.write(struct.pack('bb', len(name), -group_id)) - handle.write(name) - handle.write(struct.pack(''.format(self.desc) + + def __contains__(self, key): + return key in self._params + + @property + def name(self): + ''' Group name. ''' + return self._name + + @name.setter + def name(self, value): + ''' Group name string. + + Parameters + ---------- + value : str + New name for the group. + ''' + if value is None or isinstance(value, str): + self._name = value + else: + raise TypeError('Expected group name to be string, was %s.' % type(value)) + + @property + def desc(self): + ''' Group descriptor. ''' + return self._desc + + @desc.setter + def desc(self, value): + ''' Group descriptor. + + Parameters + ---------- + value : str, or bytes + New description for this parameter group. + ''' + if isinstance(value, bytes): + self._desc = self._dtypes.decode_string(value) + elif isinstance(value, str) or value is None: + self._desc = value + else: + raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(value)) + + def param_items(self): + ''' Acquire iterator for paramater key-entry pairs. ''' + return self._params.items() + + def param_values(self): + ''' Acquire iterator for parameter entries. ''' + return self._params.values() + + def param_keys(self): + ''' Acquire iterator for parameter entry keys. ''' + return self._params.keys() + + def get(self, key, default=None): + '''Get a parameter by key. + + Parameters + ---------- + key : any + Parameter key to look up in this group. + default : any, optional + Value to return if the key is not found. Defaults to None. + + Returns + ------- + param : :class:`Param` + A parameter from the current group. + ''' + return self._params.get(key, default) + + def add_param(self, name, **kwargs): + '''Add a parameter to this group. + + Parameters + ---------- + name : str + Name of the parameter to add to this group. The name will + automatically be case-normalized. + + Additional keyword arguments will be passed to the `Param` constructor. + + Raises + ------ + TypeError + Input arguments are of the wrong type. + KeyError + Name or numerical key already exist (attempt to overwrite existing data). + ''' + if not isinstance(name, str): + raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name))) + name = name.upper() + if name in self._params: + raise KeyError('Parameter already exists with key {}'.format(name)) + self._params[name] = Param(name, self._dtypes, **kwargs) + + def remove_param(self, name): + '''Remove the specified parameter. + + Parameters + ---------- + name : str + Name for the parameter to remove. + ''' + del self._params[name] + + def rename_param(self, name, new_name): + ''' Rename a specified parameter group. + + Parameters + ---------- + name : str, or 'Param' + Parameter instance, or name. + new_name : str + New name for the parameter. + + Raises + ------ + KeyError + If no parameter with the original name exists. + ValueError + If the new name already exist (attempt to overwrite existing data). + ''' + if new_name in self._params: + raise ValueError("Key {} already exist.".format(new_name)) + if isinstance(name, Param): + param = name + name = param.name + else: + # Aquire instance using id + param = self._params.get(name, None) + if param is None: + raise KeyError('No parameter found matching the identifier: {}'.format(str(name))) + del self._params[name] + self._params[new_name] = param + + def binary_size(self): + '''Return the number of bytes to store this group and its parameters.''' + return ( + 1 + # group_id + 1 + len(self._name.encode('utf-8')) + # size of name and name bytes + 2 + # next offset marker + 1 + len(self._desc.encode('utf-8')) + # size of desc and desc bytes + sum(p.binary_size() for p in self._params.values())) + + def write(self, group_id, handle): + '''Write this parameter group, with parameters, to a file handle. + + Parameters + ---------- + group_id : int + The numerical ID of the group. + handle : file handle + An open, writable, binary file handle. + ''' + name = self._name.encode('utf-8') + desc = self._desc.encode('utf-8') + handle.write(struct.pack('bb', len(name), -group_id)) + handle.write(name) + handle.write(struct.pack(''.format(self.desc) + + @property + def num_elements(self): + '''Return the number of elements in this parameter's array value.''' + e = 1 + for d in self.dimensions: + e *= d + return e + + @property + def total_bytes(self): + '''Return the number of bytes used for storing this parameter's data.''' + return self.num_elements * abs(self.bytes_per_element) + + def binary_size(self): + '''Return the number of bytes needed to store this parameter.''' + return ( + 1 + # group_id + 2 + # next offset marker + 1 + len(self.name.encode('utf-8')) + # size of name and name bytes + 1 + # data size + # size of dimensions and dimension bytes + 1 + len(self.dimensions) + + self.total_bytes + # data + 1 + len(self.desc.encode('utf-8')) # size of desc and desc bytes + ) + + def write(self, group_id, handle): + '''Write binary data for this parameter to a file handle. + + Parameters + ---------- + group_id : int + The numerical ID of the group that holds this parameter. + handle : file handle + An open, writable, binary file handle. + ''' + name = self.name.encode('utf-8') + handle.write(struct.pack('bb', len(name), group_id)) + handle.write(name) + handle.write(struct.pack(' 0: + handle.write(self.bytes) + desc = self.desc.encode('utf-8') + handle.write(struct.pack('B', len(desc))) + handle.write(desc) + + def read(self, handle): + '''Read binary data for this parameter from a file handle. + + This reads exactly enough data from the current position in the file to + initialize the parameter. + ''' + self.bytes_per_element, = struct.unpack('b', handle.read(1)) + dims, = struct.unpack('B', handle.read(1)) + self.dimensions = [struct.unpack('B', handle.read(1))[ + 0] for _ in range(dims)] + self.bytes = b'' + if self.total_bytes: + self.bytes = handle.read(self.total_bytes) + desc_size, = struct.unpack('B', handle.read(1)) + self.desc = desc_size and self._dtypes.decode_string(handle.read(desc_size)) or '' + + def _as(self, dtype): + '''Unpack the raw bytes of this param using the given struct format.''' + return np.frombuffer(self.bytes, count=1, dtype=dtype)[0] + + def _as_array(self, dtype, copy=True): + '''Unpack the raw bytes of this param using the given data format.''' + assert self.dimensions, \ + '{}: cannot get value as {} array!'.format(self.name, dtype) + elems = np.frombuffer(self.bytes, dtype=dtype) + # Reverse shape as the shape is defined in fortran format + view = elems.reshape(self.dimensions[::-1]) + if copy: + return view.copy() + return view + + def _as_any(self, dtype): + '''Unpack the raw bytes of this param as either array or single value.''' + if 0 in self.dimensions[:]: # Check if any dimension is 0 (empty buffer) + return [] # Buffer is empty + + if len(self.dimensions) == 0: # Parse data as a single value + if dtype == np.float32: # Floats need to be parsed separately! + return self.float_value + return self._as(dtype) + else: # Parse data as array + if dtype == np.float32: + data = self.float_array + else: + data = self._as_array(dtype) + if len(self.dimensions) < 2: # Check if data is contained in a single dimension + return data.flatten() + return data + + @property + def _as_integer_value(self): + ''' Get the param as either 32 bit float or unsigned integer. + Evaluates if an integer is stored as a floating point representation. + + Note: This is implemented purely for parsing start/end frames. + ''' + if self.total_bytes >= 4: + # Check if float value representation is an integer + value = self.float_value + if int(value) == value: + return value + return self.uint32_value + elif self.total_bytes >= 2: + return self.uint16_value + else: + return self.uint8_value + + @property + def int8_value(self): + '''Get the param as an 8-bit signed integer.''' + return self._as(self._dtypes.int8) + + @property + def uint8_value(self): + '''Get the param as an 8-bit unsigned integer.''' + return self._as(self._dtypes.uint8) + + @property + def int16_value(self): + '''Get the param as a 16-bit signed integer.''' + return self._as(self._dtypes.int16) + + @property + def uint16_value(self): + '''Get the param as a 16-bit unsigned integer.''' + return self._as(self._dtypes.uint16) + + @property + def int32_value(self): + '''Get the param as a 32-bit signed integer.''' + return self._as(self._dtypes.int32) + + @property + def uint32_value(self): + '''Get the param as a 32-bit unsigned integer.''' + return self._as(self._dtypes.uint32) + + @property + def float_value(self): + '''Get the param as a 32-bit float.''' + if self._dtypes.is_dec: + return DEC_to_IEEE(self._as(np.uint32)) + else: # is_mips or is_ieee + return self._as(self._dtypes.float32) + + @property + def bytes_value(self): + '''Get the param as a raw byte string.''' + return self.bytes + + @property + def string_value(self): + '''Get the param as a unicode string.''' + return self._dtypes.decode_string(self.bytes) + + @property + def int8_array(self): + '''Get the param as an array of 8-bit signed integers.''' + return self._as_array(self._dtypes.int8) + + @property + def uint8_array(self): + '''Get the param as an array of 8-bit unsigned integers.''' + return self._as_array(self._dtypes.uint8) + + @property + def int16_array(self): + '''Get the param as an array of 16-bit signed integers.''' + return self._as_array(self._dtypes.int16) + + @property + def uint16_array(self): + '''Get the param as an array of 16-bit unsigned integers.''' + return self._as_array(self._dtypes.uint16) + + @property + def int32_array(self): + '''Get the param as an array of 32-bit signed integers.''' + return self._as_array(self._dtypes.int32) + + @property + def uint32_array(self): + '''Get the param as an array of 32-bit unsigned integers.''' + return self._as_array(self._dtypes.uint32) + + @property + def int64_array(self): + '''Get the param as an array of 32-bit signed integers.''' + return self._as_array(self._dtypes.int64) + + @property + def uint64_array(self): + '''Get the param as an array of 32-bit unsigned integers.''' + return self._as_array(self._dtypes.uint64) + + @property + def float32_array(self): + '''Get the param as an array of 32-bit floats.''' + # Convert float data if not IEEE processor + if self._dtypes.is_dec: + # _as_array but for DEC + assert self.dimensions, \ + '{}: cannot get value as {} array!'.format(self.name, self._dtypes.float32) + return DEC_to_IEEE_BYTES(self.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format + else: # is_ieee or is_mips + return self._as_array(self._dtypes.float32) + + @property + def float64_array(self): + '''Get the param as an array of 64-bit floats.''' + # Convert float data if not IEEE processor + if self._dtypes.is_dec: + raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') + else: # is_ieee or is_mips + return self._as_array(self._dtypes.float64) + + @property + def float_array(self): + '''Get the param as an array of 32 or 64 bit floats.''' + # Convert float data if not IEEE processor + if self.bytes_per_element == 4: + return self.float32_array + elif self.bytes_per_element == 8: + return self.float64_array + else: + raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element + + "floating-point precission is not unsupported.") + + @property + def int_array(self): + '''Get the param as an array of integer values.''' + # Convert float data if not IEEE processor + if self.bytes_per_element == 1: + return self.int8_array + elif self.bytes_per_element == 2: + return self.int16_array + elif self.bytes_per_element == 4: + return self.int32_array + elif self.bytes_per_element == 8: + return self.int64_array + else: + raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % + self.bytes_per_element) + + @property + def uint_array(self): + '''Get the param as an array of integer values.''' + # Convert float data if not IEEE processor + if self.bytes_per_element == 1: + return self.uint8_array + elif self.bytes_per_element == 2: + return self.uint16_array + elif self.bytes_per_element == 4: + return self.uint32_array + elif self.bytes_per_element == 8: + return self.uint64_array + else: + raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." % + self.bytes_per_element) + + @property + def bytes_array(self): + '''Get the param as an array of raw byte strings.''' + # Decode different dimensions + if len(self.dimensions) == 0: + return np.array([]) + elif len(self.dimensions) == 1: + return np.array(self.bytes) + else: + # Convert Fortran shape (data in memory is identical, shape is transposed) + word_len = self.dimensions[0] + dims = self.dimensions[1:][::-1] # Identical to: [:0:-1] + byte_steps = np.cumprod(self.dimensions[:-1])[::-1] + # Generate mult-dimensional array and parse byte words + byte_arr = np.empty(dims, dtype=object) + for i in np.ndindex(*dims): + # Calculate byte offset as sum of each array index times the byte step of each dimension. + off = np.sum(np.multiply(i, byte_steps)) + byte_arr[i] = self.bytes[off:off+word_len] + return byte_arr + + @property + def string_array(self): + '''Get the param as a python array of unicode strings.''' + # Decode different dimensions + if len(self.dimensions) == 0: + return np.array([]) + elif len(self.dimensions) == 1: + return np.array([self.string_value]) + else: + # Parse byte sequences + byte_arr = self.bytes_array + # Decode sequences + for i in np.ndindex(byte_arr.shape): + byte_arr[i] = self._dtypes.decode_string(byte_arr[i]) + return byte_arr From b7482721491236ed61407538a6512d7f2027479e Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 13:06:00 +0200 Subject: [PATCH 040/120] header.py --- src/header.py | 291 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 src/header.py diff --git a/src/header.py b/src/header.py new file mode 100644 index 0000000..1024bcd --- /dev/null +++ b/src/header.py @@ -0,0 +1,291 @@ +import sys +import struct +import numpy as np +from .utils import UNPACK_FLOAT_IEEE, DEC_to_IEEE + +class Header(object): + '''Header information from a C3D file. + + Attributes + ---------- + event_block : int + Index of the 512-byte block where labels (metadata) are found. + parameter_block : int + Index of the 512-byte block where parameters (metadata) are found. + data_block : int + Index of the 512-byte block where data starts. + point_count : int + Number of motion capture channels recorded in this file. + analog_count : int + Number of analog values recorded per frame of 3D point data. + first_frame : int + Index of the first frame of data. + last_frame : int + Index of the last frame of data. + analog_per_frame : int + Number of analog frames per frame of 3D point data. The analog frame + rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE) + times the analog per frame value. + frame_rate : float + The frame rate of the recording, in frames per second. + scale_factor : float + Multiply values in the file by this scale parameter. + long_event_labels : bool + max_gap : int + + .. note:: + The ``scale_factor`` attribute is not used in Phasespace C3D files; + instead, use the POINT.SCALE parameter. + + .. note:: + The ``first_frame`` and ``last_frame`` header attributes are not used in + C3D files generated by Phasespace. Instead, the first and last + frame numbers are stored in the POINTS:ACTUAL_START_FIELD and + POINTS:ACTUAL_END_FIELD parameters. + ''' + + # Read/Write header formats, read values as unsigned ints rather then floats. + BINARY_FORMAT_WRITE = ' 0 + self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong + 4])[0]) + self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong + 4]) + + @property + def events(self): + ''' Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label). + + Note*: + Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter. + Frame 1 therefor has the time 0.0 in relation to the event timing. + ''' + return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags]) + + def encode_events(self, events): + ''' Encode event data in the event block. + + Parameters + ---------- + events : [(float, str), ...] + Event data, iterable of touples containing the event timing and a 4 character label string. + Event timings should be calculated relative to sample 1 with the timing 0.0s, and should + not be relative to the first_frame header parameter. + ''' + endian = '<' + if sys.byteorder == 'big': + endian = '>' + + # Event block format + fmt = '{}{}{}{}{}'.format(endian, + str(18 * 4) + 's', # Timings + str(18) + 's', # Flags + 'H', # __ + str(18 * 4) + 's' # Labels + ) + # Pack bytes + event_timings = np.zeros(18, dtype=np.float32) + event_disp_flags = np.zeros(18, dtype=np.uint8) + event_labels = np.empty(18, dtype=object) + label_bytes = bytearray(18 * 4) + for i, (time, label) in enumerate(events): + if i > 17: + # Don't raise Error, header events are rarely used. + warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.') + break + + event_timings[i] = time + event_labels[i] = label + label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8') + + write_count = min(i + 1, 18) + event_disp_flags[:write_count] = 1 + + # Update event headers in self + self.long_event_labels = 0x3039 # Magic number + self.event_count = write_count + # Update event block + self.event_timings = event_timings[:write_count] + self.event_disp_flags = np.ones(write_count, dtype=np.bool) + self.event_labels = event_labels[:write_count] + self.event_block = struct.pack(fmt, + event_timings.tobytes(), + event_disp_flags.tobytes(), + 0, + label_bytes + ) From a8622292312c92aa94c1b79d2afaf139750b8a54 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 13:40:56 +0200 Subject: [PATCH 041/120] manager.py + working tests --- c3d.py | 446 +---------------------- src/__init__.py | 0 src/manager.py | 450 ++++++++++++++++++++++++ test/test_parameter_bytes_conversion.py | 54 +-- 4 files changed, 479 insertions(+), 471 deletions(-) create mode 100644 src/__init__.py create mode 100644 src/manager.py diff --git a/c3d.py b/c3d.py index fc4a201..e67bf09 100644 --- a/c3d.py +++ b/c3d.py @@ -1,5 +1,4 @@ '''A Python module for reading and writing C3D files.''' - from __future__ import unicode_literals import sys @@ -8,456 +7,13 @@ import numpy as np import struct import warnings +from src.manager import Manager from src.header import Header from src.group import Group from src.dtypes import DataTypes from src.utils import is_integer, is_iterable, DEC_to_IEEE_BYTES, Decorator -class Manager(object): - '''A base class for managing C3D file metadata. - - This class manages a C3D header (which contains some stock metadata fields) - as well as a set of parameter groups. Each group is accessible using its - name. - - Attributes - ---------- - header : `Header` - Header information for the C3D file. - ''' - - def __init__(self, header=None): - '''Set up a new Manager with a Header.''' - self._header = header or Header() - self._groups = {} - - def __contains__(self, key): - return key in self._groups - - @property - def header(self): - ''' Access to .c3d header data. ''' - return self._header - - def group_items(self): - ''' Acquire iterable over parameter group pairs. - - Returns - ------- - items : Touple of ((str, :class:`Group`), ...) - Python touple containing pairs of name keys and parameter group entries. - ''' - return ((k, v) for k, v in self._groups.items() if isinstance(k, str)) - - def group_values(self): - ''' Acquire iterable over parameter group entries. - - Returns - ------- - values : Touple of (:class:`Group`, ...) - Python touple containing unique parameter group entries. - ''' - return (v for k, v in self._groups.items() if isinstance(k, str)) - - def group_keys(self): - ''' Acquire iterable over parameter group entry string keys. - - Returns - ------- - keys : Touple of (str, ...) - Python touple containing keys for the parameter group entries. - ''' - return (k for k in self._groups.keys() if isinstance(k, str)) - - def group_listed(self): - ''' Acquire iterable over sorted numerical parameter group pairs. - - Returns - ------- - items : Touple of ((int, :class:`Group`), ...) - Sorted python touple containing pairs of numerical keys and parameter group entries. - ''' - return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int)) - - def _check_metadata(self): - ''' Ensure that the metadata in our file is self-consistent. ''' - assert self._header.point_count == self.point_used, ( - 'inconsistent point count! {} header != {} POINT:USED'.format( - self._header.point_count, - self.point_used, - )) - - assert self._header.scale_factor == self.point_scale, ( - 'inconsistent scale factor! {} header != {} POINT:SCALE'.format( - self._header.scale_factor, - self.point_scale, - )) - - assert self._header.frame_rate == self.point_rate, ( - 'inconsistent frame rate! {} header != {} POINT:RATE'.format( - self._header.frame_rate, - self.point_rate, - )) - - if self.point_rate: - ratio = self.analog_rate / self.point_rate - else: - ratio = 0 - assert self._header.analog_per_frame == ratio, ( - 'inconsistent analog rate! {} header != {} analog-fps / {} point-fps'.format( - self._header.analog_per_frame, - self.analog_rate, - self.point_rate, - )) - - count = self.analog_used * self._header.analog_per_frame - assert self._header.analog_count == count, ( - 'inconsistent analog count! {} header != {} analog used * {} per-frame'.format( - self._header.analog_count, - self.analog_used, - self._header.analog_per_frame, - )) - - try: - start = self.get_uint16('POINT:DATA_START') - if self._header.data_block != start: - warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( - self._header.data_block, start)) - except AttributeError: - warnings.warn('''no pointer available in POINT:DATA_START indicating the start of the data block, using - header pointer as fallback''') - - def check_parameters(params): - for name in params: - if self.get(name) is None: - warnings.warn('missing parameter {}'.format(name)) - - if self.point_used > 0: - check_parameters(('POINT:LABELS', 'POINT:DESCRIPTIONS')) - else: - warnings.warn('No point data found in file.') - if self.analog_used > 0: - check_parameters(('ANALOG:LABELS', 'ANALOG:DESCRIPTIONS')) - else: - warnings.warn('No analog data found in file.') - - def add_group(self, group_id, name, desc): - '''Add a new parameter group. - - Parameters - ---------- - group_id : int - The numeric ID for a group to check or create. - name : str, optional - If a group is created, assign this name to the group. - desc : str, optional - If a group is created, assign this description to the group. - - Returns - ------- - group : :class:`Group` - A group with the given ID, name, and description. - - Raises - ------ - TypeError - Input arguments are of the wrong type. - KeyError - Name or numerical key already exist (attempt to overwrite existing data). - ''' - if not is_integer(group_id): - raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id)) - if not (isinstance(name, str) or name is None): - raise TypeError('Expected Group name key to be string, was %s.' % type(name)) - group_id = int(group_id) # Asserts python int - if group_id in self._groups: - raise KeyError('Group with numerical key {} already exists'.format(group_id)) - name = name.upper() - if name in self._groups: - raise KeyError('No group matched name key {}'.format(name)) - group = self._groups[name] = self._groups[group_id] = Group(self._dtypes, name, desc) - return group - - def remove_group(self, group_id): - '''Remove the parameter group. - - Parameters - ---------- - group_id : int, or str - The numeric or name ID key for a group to remove all entries for. - ''' - grp = self._groups.get(group_id, None) - if grp is None: - return - gkeys = [k for (k, v) in self._groups.items() if v == grp] - for k in gkeys: - del self._groups[k] - - def rename_group(self, group_id, new_group_id): - ''' Rename a specified parameter group. - - Parameters - ---------- - group_id : int, str, or 'Group' - Group instance, name, or numerical identifier for the group. - new_group_id : str, or int - If string, it is the new name for the group. If integer, it will replace its numerical group id. - - Raises - ------ - KeyError - If a group with a duplicate ID or name already exists. - ''' - if isinstance(group_id, Group): - grp = group_id - else: - # Aquire instance using id - grp = self._groups.get(group_id, None) - if grp is None: - raise KeyError('No group found matching the identifier: %s' % str(group_id)) - if new_group_id in self._groups: - if new_group_id == group_id: - return - raise ValueError('Key %s for group %s already exist.' % (str(new_group_id), grp.name)) - - # Clear old id - if isinstance(new_group_id, (str, bytes)): - if grp.name in self._groups: - del self._groups[grp.name] - grp._name = new_group_id - elif is_integer(new_group_id): - new_group_id = int(new_group_id) # Ensure python int - del self._groups[group_id] - else: - raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) - # Update - self._groups[new_group_id] = grp - - def get(self, group, default=None): - '''Get a group or parameter. - - Parameters - ---------- - group : str - If this string contains a period (.), then the part before the - period will be used to retrieve a group, and the part after the - period will be used to retrieve a parameter from that group. If this - string does not contain a period, then just a group will be - returned. - default : any - Return this value if the named group and parameter are not found. - - Returns - ------- - value : :class:`Group` or :class:`Param` - Either a group or parameter with the specified name(s). If neither - is found, returns the default value. - ''' - if is_integer(group): - return self._groups.get(int(group), default) - group = group.upper() - param = None - if '.' in group: - group, param = group.split('.', 1) - if ':' in group: - group, param = group.split(':', 1) - if group not in self._groups: - return default - group = self._groups[group] - if param is not None: - return group.get(param, default) - return group - - def get_int8(self, key): - '''Get a parameter value as an 8-bit signed integer.''' - return self.get(key).int8_value - - def get_uint8(self, key): - '''Get a parameter value as an 8-bit unsigned integer.''' - return self.get(key).uint8_value - - def get_int16(self, key): - '''Get a parameter value as a 16-bit signed integer.''' - return self.get(key).int16_value - - def get_uint16(self, key): - '''Get a parameter value as a 16-bit unsigned integer.''' - return self.get(key).uint16_value - - def get_int32(self, key): - '''Get a parameter value as a 32-bit signed integer.''' - return self.get(key).int32_value - - def get_uint32(self, key): - '''Get a parameter value as a 32-bit unsigned integer.''' - return self.get(key).uint32_value - - def get_float(self, key): - '''Get a parameter value as a 32-bit float.''' - return self.get(key).float_value - - def get_bytes(self, key): - '''Get a parameter value as a byte string.''' - return self.get(key).bytes_value - - def get_string(self, key): - '''Get a parameter value as a string.''' - return self.get(key).string_value - - def parameter_blocks(self): - '''Compute the size (in 512B blocks) of the parameter section.''' - bytes = 4. + sum(g.binary_size() for g in self._groups.values()) - return int(np.ceil(bytes / 512)) - - @property - def point_rate(self): - ''' Number of sampled 3D coordinates per second. - ''' - try: - return self.get_float('POINT:RATE') - except AttributeError: - return self.header.frame_rate - - @property - def point_scale(self): - try: - return self.get_float('POINT:SCALE') - except AttributeError: - return self.header.scale_factor - - @property - def point_used(self): - ''' Number of sampled 3D point coordinates per frame. - ''' - try: - return self.get_uint16('POINT:USED') - except AttributeError: - return self.header.point_count - - @property - def analog_used(self): - ''' Number of analog measurements, or channels, for each analog data sample. - ''' - try: - return self.get_uint16('ANALOG:USED') - except AttributeError: - return self.header.analog_count - - @property - def analog_rate(self): - ''' Number of analog data samples per second. - ''' - try: - return self.get_float('ANALOG:RATE') - except AttributeError: - return self.header.analog_per_frame * self.point_rate - - @property - def analog_per_frame(self): - ''' Number of analog samples per 3D frame (point sample). - ''' - return int(self.analog_rate / self.point_rate) - - @property - def analog_sample_count(self): - ''' Number of analog samples per channel. - ''' - has_analog = self.analog_used > 0 - return int(self.frame_count * self.analog_per_frame) * has_analog - - @property - def point_labels(self): - return self.get('POINT:LABELS').string_array - - @property - def analog_labels(self): - return self.get('ANALOG:LABELS').string_array - - @property - def frame_count(self): - return self.last_frame - self.first_frame + 1 # Add 1 since range is inclusive [first, last] - - @property - def first_frame(self): - # Start frame seems to be less of an issue to determine. - # this is a hack for phasespace files ... should put it in a subclass. - param = self.get('TRIAL:ACTUAL_START_FIELD') - if param is not None: - # ACTUAL_START_FIELD is encoded in two 16 byte words... - words = param.uint16_array - return words[0] + words[1] * 65535 - return self.header.first_frame - - @property - def last_frame(self): - # Number of frames can be represented in many formats, first check if valid header values - if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: - return self.header.last_frame - - # Check different parameter options where the frame can be encoded - end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] - param = self.get('TRIAL:ACTUAL_END_FIELD') - if param is not None: - # Encoded as 2 16 bit words (rather then 1 32 bit word) - words = param.uint16_array - end_frame[1] = words[0] + words[1] * 65536 - #end_frame[1] = param.uint32_value - param = self.get('POINT:LONG_FRAMES') - if param is not None: - # Encoded as float - end_frame[2] = int(param.float_value) - param = self.get('POINT:FRAMES') - if param is not None: - # Can be encoded either as 32 bit float or 16 bit uint - end_frame[3] = param._as_integer_value - # Return the largest of the all (queue bad reading...) - return int(np.max(end_frame)) - - def get_screen_axis(self): - ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. - - Returns - ------- - value : Touple on form (str, str) or None - Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. - ''' - X = self.get('POINT:X_SCREEN') - Y = self.get('POINT:Y_SCREEN') - if X and Y: - return (X.string_value, Y.string_value) - return None - - def get_analog_transform_parameters(self): - ''' Parse analog data transform parameters. - ''' - # Offsets - analog_offsets = np.zeros((self.analog_used), int) - param = self.get('ANALOG:OFFSET') - if param is not None and param.num_elements > 0: - analog_offsets[:] = param.int16_array[:self.analog_used] - - # Scale factors - analog_scales = np.ones((self.analog_used), float) - gen_scale = 1. - param = self.get('ANALOG:GEN_SCALE') - if param is not None: - gen_scale = param.float_value - param = self.get('ANALOG:SCALE') - if param is not None and param.num_elements > 0: - analog_scales[:] = param.float_array[:self.analog_used] - - return gen_scale, analog_scales, analog_offsets - - def get_analog_transform(self): - ''' Get broadcastable analog transformation parameters. - ''' - gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters() - analog_scales *= gen_scale - analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) - analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) - return analog_scales, analog_offsets - class Reader(Manager): '''This class provides methods for reading the data in a C3D file. diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/manager.py b/src/manager.py new file mode 100644 index 0000000..1e0bcdc --- /dev/null +++ b/src/manager.py @@ -0,0 +1,450 @@ +import numpy as np +import warnings +from src.header import Header +from src.group import Group +from src.utils import is_integer, is_iterable + + +class Manager(object): + '''A base class for managing C3D file metadata. + + This class manages a C3D header (which contains some stock metadata fields) + as well as a set of parameter groups. Each group is accessible using its + name. + + Attributes + ---------- + header : `Header` + Header information for the C3D file. + ''' + + def __init__(self, header=None): + '''Set up a new Manager with a Header.''' + self._header = header or Header() + self._groups = {} + + def __contains__(self, key): + return key in self._groups + + @property + def header(self): + ''' Access to .c3d header data. ''' + return self._header + + def group_items(self): + ''' Acquire iterable over parameter group pairs. + + Returns + ------- + items : Touple of ((str, :class:`Group`), ...) + Python touple containing pairs of name keys and parameter group entries. + ''' + return ((k, v) for k, v in self._groups.items() if isinstance(k, str)) + + def group_values(self): + ''' Acquire iterable over parameter group entries. + + Returns + ------- + values : Touple of (:class:`Group`, ...) + Python touple containing unique parameter group entries. + ''' + return (v for k, v in self._groups.items() if isinstance(k, str)) + + def group_keys(self): + ''' Acquire iterable over parameter group entry string keys. + + Returns + ------- + keys : Touple of (str, ...) + Python touple containing keys for the parameter group entries. + ''' + return (k for k in self._groups.keys() if isinstance(k, str)) + + def group_listed(self): + ''' Acquire iterable over sorted numerical parameter group pairs. + + Returns + ------- + items : Touple of ((int, :class:`Group`), ...) + Sorted python touple containing pairs of numerical keys and parameter group entries. + ''' + return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int)) + + def _check_metadata(self): + ''' Ensure that the metadata in our file is self-consistent. ''' + assert self._header.point_count == self.point_used, ( + 'inconsistent point count! {} header != {} POINT:USED'.format( + self._header.point_count, + self.point_used, + )) + + assert self._header.scale_factor == self.point_scale, ( + 'inconsistent scale factor! {} header != {} POINT:SCALE'.format( + self._header.scale_factor, + self.point_scale, + )) + + assert self._header.frame_rate == self.point_rate, ( + 'inconsistent frame rate! {} header != {} POINT:RATE'.format( + self._header.frame_rate, + self.point_rate, + )) + + if self.point_rate: + ratio = self.analog_rate / self.point_rate + else: + ratio = 0 + assert self._header.analog_per_frame == ratio, ( + 'inconsistent analog rate! {} header != {} analog-fps / {} point-fps'.format( + self._header.analog_per_frame, + self.analog_rate, + self.point_rate, + )) + + count = self.analog_used * self._header.analog_per_frame + assert self._header.analog_count == count, ( + 'inconsistent analog count! {} header != {} analog used * {} per-frame'.format( + self._header.analog_count, + self.analog_used, + self._header.analog_per_frame, + )) + + try: + start = self.get_uint16('POINT:DATA_START') + if self._header.data_block != start: + warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( + self._header.data_block, start)) + except AttributeError: + warnings.warn('''no pointer available in POINT:DATA_START indicating the start of the data block, using + header pointer as fallback''') + + def check_parameters(params): + for name in params: + if self.get(name) is None: + warnings.warn('missing parameter {}'.format(name)) + + if self.point_used > 0: + check_parameters(('POINT:LABELS', 'POINT:DESCRIPTIONS')) + else: + warnings.warn('No point data found in file.') + if self.analog_used > 0: + check_parameters(('ANALOG:LABELS', 'ANALOG:DESCRIPTIONS')) + else: + warnings.warn('No analog data found in file.') + + def add_group(self, group_id, name, desc): + '''Add a new parameter group. + + Parameters + ---------- + group_id : int + The numeric ID for a group to check or create. + name : str, optional + If a group is created, assign this name to the group. + desc : str, optional + If a group is created, assign this description to the group. + + Returns + ------- + group : :class:`Group` + A group with the given ID, name, and description. + + Raises + ------ + TypeError + Input arguments are of the wrong type. + KeyError + Name or numerical key already exist (attempt to overwrite existing data). + ''' + if not is_integer(group_id): + raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id)) + if not (isinstance(name, str) or name is None): + raise TypeError('Expected Group name key to be string, was %s.' % type(name)) + group_id = int(group_id) # Asserts python int + if group_id in self._groups: + raise KeyError('Group with numerical key {} already exists'.format(group_id)) + name = name.upper() + if name in self._groups: + raise KeyError('No group matched name key {}'.format(name)) + group = self._groups[name] = self._groups[group_id] = Group(self._dtypes, name, desc) + return group + + def remove_group(self, group_id): + '''Remove the parameter group. + + Parameters + ---------- + group_id : int, or str + The numeric or name ID key for a group to remove all entries for. + ''' + grp = self._groups.get(group_id, None) + if grp is None: + return + gkeys = [k for (k, v) in self._groups.items() if v == grp] + for k in gkeys: + del self._groups[k] + + def rename_group(self, group_id, new_group_id): + ''' Rename a specified parameter group. + + Parameters + ---------- + group_id : int, str, or 'Group' + Group instance, name, or numerical identifier for the group. + new_group_id : str, or int + If string, it is the new name for the group. If integer, it will replace its numerical group id. + + Raises + ------ + KeyError + If a group with a duplicate ID or name already exists. + ''' + if isinstance(group_id, Group): + grp = group_id + else: + # Aquire instance using id + grp = self._groups.get(group_id, None) + if grp is None: + raise KeyError('No group found matching the identifier: %s' % str(group_id)) + if new_group_id in self._groups: + if new_group_id == group_id: + return + raise ValueError('Key %s for group %s already exist.' % (str(new_group_id), grp.name)) + + # Clear old id + if isinstance(new_group_id, (str, bytes)): + if grp.name in self._groups: + del self._groups[grp.name] + grp._name = new_group_id + elif is_integer(new_group_id): + new_group_id = int(new_group_id) # Ensure python int + del self._groups[group_id] + else: + raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) + # Update + self._groups[new_group_id] = grp + + def get(self, group, default=None): + '''Get a group or parameter. + + Parameters + ---------- + group : str + If this string contains a period (.), then the part before the + period will be used to retrieve a group, and the part after the + period will be used to retrieve a parameter from that group. If this + string does not contain a period, then just a group will be + returned. + default : any + Return this value if the named group and parameter are not found. + + Returns + ------- + value : :class:`Group` or :class:`Param` + Either a group or parameter with the specified name(s). If neither + is found, returns the default value. + ''' + if is_integer(group): + return self._groups.get(int(group), default) + group = group.upper() + param = None + if '.' in group: + group, param = group.split('.', 1) + if ':' in group: + group, param = group.split(':', 1) + if group not in self._groups: + return default + group = self._groups[group] + if param is not None: + return group.get(param, default) + return group + + def get_int8(self, key): + '''Get a parameter value as an 8-bit signed integer.''' + return self.get(key).int8_value + + def get_uint8(self, key): + '''Get a parameter value as an 8-bit unsigned integer.''' + return self.get(key).uint8_value + + def get_int16(self, key): + '''Get a parameter value as a 16-bit signed integer.''' + return self.get(key).int16_value + + def get_uint16(self, key): + '''Get a parameter value as a 16-bit unsigned integer.''' + return self.get(key).uint16_value + + def get_int32(self, key): + '''Get a parameter value as a 32-bit signed integer.''' + return self.get(key).int32_value + + def get_uint32(self, key): + '''Get a parameter value as a 32-bit unsigned integer.''' + return self.get(key).uint32_value + + def get_float(self, key): + '''Get a parameter value as a 32-bit float.''' + return self.get(key).float_value + + def get_bytes(self, key): + '''Get a parameter value as a byte string.''' + return self.get(key).bytes_value + + def get_string(self, key): + '''Get a parameter value as a string.''' + return self.get(key).string_value + + def parameter_blocks(self): + '''Compute the size (in 512B blocks) of the parameter section.''' + bytes = 4. + sum(g.binary_size() for g in self._groups.values()) + return int(np.ceil(bytes / 512)) + + @property + def point_rate(self): + ''' Number of sampled 3D coordinates per second. + ''' + try: + return self.get_float('POINT:RATE') + except AttributeError: + return self.header.frame_rate + + @property + def point_scale(self): + try: + return self.get_float('POINT:SCALE') + except AttributeError: + return self.header.scale_factor + + @property + def point_used(self): + ''' Number of sampled 3D point coordinates per frame. + ''' + try: + return self.get_uint16('POINT:USED') + except AttributeError: + return self.header.point_count + + @property + def analog_used(self): + ''' Number of analog measurements, or channels, for each analog data sample. + ''' + try: + return self.get_uint16('ANALOG:USED') + except AttributeError: + return self.header.analog_count + + @property + def analog_rate(self): + ''' Number of analog data samples per second. + ''' + try: + return self.get_float('ANALOG:RATE') + except AttributeError: + return self.header.analog_per_frame * self.point_rate + + @property + def analog_per_frame(self): + ''' Number of analog samples per 3D frame (point sample). + ''' + return int(self.analog_rate / self.point_rate) + + @property + def analog_sample_count(self): + ''' Number of analog samples per channel. + ''' + has_analog = self.analog_used > 0 + return int(self.frame_count * self.analog_per_frame) * has_analog + + @property + def point_labels(self): + return self.get('POINT:LABELS').string_array + + @property + def analog_labels(self): + return self.get('ANALOG:LABELS').string_array + + @property + def frame_count(self): + return self.last_frame - self.first_frame + 1 # Add 1 since range is inclusive [first, last] + + @property + def first_frame(self): + # Start frame seems to be less of an issue to determine. + # this is a hack for phasespace files ... should put it in a subclass. + param = self.get('TRIAL:ACTUAL_START_FIELD') + if param is not None: + # ACTUAL_START_FIELD is encoded in two 16 byte words... + words = param.uint16_array + return words[0] + words[1] * 65535 + return self.header.first_frame + + @property + def last_frame(self): + # Number of frames can be represented in many formats, first check if valid header values + if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: + return self.header.last_frame + + # Check different parameter options where the frame can be encoded + end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] + param = self.get('TRIAL:ACTUAL_END_FIELD') + if param is not None: + # Encoded as 2 16 bit words (rather then 1 32 bit word) + words = param.uint16_array + end_frame[1] = words[0] + words[1] * 65536 + #end_frame[1] = param.uint32_value + param = self.get('POINT:LONG_FRAMES') + if param is not None: + # Encoded as float + end_frame[2] = int(param.float_value) + param = self.get('POINT:FRAMES') + if param is not None: + # Can be encoded either as 32 bit float or 16 bit uint + end_frame[3] = param._as_integer_value + # Return the largest of the all (queue bad reading...) + return int(np.max(end_frame)) + + def get_screen_axis(self): + ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. + + Returns + ------- + value : Touple on form (str, str) or None + Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. + ''' + X = self.get('POINT:X_SCREEN') + Y = self.get('POINT:Y_SCREEN') + if X and Y: + return (X.string_value, Y.string_value) + return None + + def get_analog_transform_parameters(self): + ''' Parse analog data transform parameters. + ''' + # Offsets + analog_offsets = np.zeros((self.analog_used), int) + param = self.get('ANALOG:OFFSET') + if param is not None and param.num_elements > 0: + analog_offsets[:] = param.int16_array[:self.analog_used] + + # Scale factors + analog_scales = np.ones((self.analog_used), float) + gen_scale = 1. + param = self.get('ANALOG:GEN_SCALE') + if param is not None: + gen_scale = param.float_value + param = self.get('ANALOG:SCALE') + if param is not None and param.num_elements > 0: + analog_scales[:] = param.float_array[:self.analog_used] + + return gen_scale, analog_scales, analog_offsets + + def get_analog_transform(self): + ''' Get broadcastable analog transformation parameters. + ''' + gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters() + analog_scales *= gen_scale + analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) + analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) + return analog_scales, analog_offsets diff --git a/test/test_parameter_bytes_conversion.py b/test/test_parameter_bytes_conversion.py index 14d02c4..03d8827 100644 --- a/test/test_parameter_bytes_conversion.py +++ b/test/test_parameter_bytes_conversion.py @@ -2,6 +2,8 @@ import struct import unittest import numpy as np +from src.dtypes import DataTypes, PROCESSOR_INTEL +from src.parameter import Param def genByteWordArr(word, shape): @@ -46,7 +48,7 @@ class ParameterValueTest(unittest.TestCase): def setUp(self): self.rnd = np.random.default_rng() - self.dtypes = c3d.DataTypes(c3d.PROCESSOR_INTEL) + self.dtypes = DataTypes(PROCESSOR_INTEL) def test_a_param_float32(self): ''' Verify a single 32 bit floating point value is parsed correctly @@ -54,7 +56,7 @@ def test_a_param_float32(self): for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.float32(self.rnd.uniform(*ParameterValueTest.RANGE_32_BIT)) bytes = struct.pack(' 5) - P = c3d.Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) + P = Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) arr_out = P.string_array assert arr.T.shape == arr_out.shape, "Mismatch in 'string_array' converted shape. Was %s, expected %s" %\ @@ -323,7 +325,7 @@ def test_k_parse_random_string_array(self): # 4 dims for wlen in range(10): arr, shape = genRndByteArr(wlen, [7, 5, 3], wlen > 5) - P = c3d.Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) + P = Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) arr_out = P.string_array assert arr.T.shape == arr_out.shape, "Mismatch in 'string_array' converted shape. Was %s, expected %s" %\ @@ -335,7 +337,7 @@ def test_k_parse_random_string_array(self): # 5 dims for wlen in range(10): arr, shape = genRndByteArr(wlen, [7, 6, 5, 3], wlen > 5) - P = c3d.Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) + P = Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) arr_out = P.string_array assert arr.T.shape == arr_out.shape, "Mismatch in 'string_array' converted shape. Was %s, expected %s" %\ From 2f34fc17849bfddfcefcdf94859e0fb7e95dc8f9 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 23:35:13 +0200 Subject: [PATCH 042/120] Step in the 'right' direction --- c3d.py | 204 +++++-------- src/group.py | 389 ++++++++++++++++++------ src/manager.py | 178 +++++------ src/parameter.py | 132 +++++--- test/test_c3d.py | 10 +- test/test_group_accessors.py | 63 ++-- test/test_parameter_accessors.py | 53 ++-- test/test_parameter_bytes_conversion.py | 54 ++-- 8 files changed, 679 insertions(+), 404 deletions(-) diff --git a/c3d.py b/c3d.py index e67bf09..414789d 100644 --- a/c3d.py +++ b/c3d.py @@ -9,9 +9,9 @@ import warnings from src.manager import Manager from src.header import Header -from src.group import Group +from src.group import GroupData, GroupWritable, GroupReadonly from src.dtypes import DataTypes -from src.utils import is_integer, is_iterable, DEC_to_IEEE_BYTES, Decorator +from src.utils import is_integer, is_iterable, DEC_to_IEEE_BYTES class Reader(Manager): @@ -87,7 +87,7 @@ def seek_param_section_header(): if group_id > 0: # We've just started reading a parameter. If its group doesn't # exist, create a blank one. add the parameter to the group. - group = self._groups.setdefault(group_id, Group(self._dtypes)) + group = self._groups.setdefault(group_id, GroupData(self._dtypes)) group.add_param(name, handle=buf) else: # We've just started reading a group. If a group with the @@ -97,16 +97,16 @@ def seek_param_section_header(): group_id = abs(group_id) size, = struct.unpack('B', buf.read(1)) desc = size and buf.read(size) or '' - group = self.get(group_id) + group = self._get(group_id) if group is not None: - self.rename_group(group, name) # Inserts name key + self._rename_group(group, name) # Inserts name key group.desc = desc else: - self.add_group(group_id, name, desc) + self._add_group(group_id, name, desc) self._check_metadata() - def read_frames(self, copy=True, analog_transform=True, camera_sum=False, check_nan=True): + def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False): '''Iterate over the data frames from our C3D file handle. Parameters @@ -283,113 +283,56 @@ def proc_type(self): ''' return self._dtypes.proc_type - def to_writer(self, conversion): + def to_writer(self, conversion=None): ''' Convert to 'Writer' using the conversion mode. See Writer.from_reader() for supported conversion modes and possible exceptions. ''' return Writer.from_reader(self, conversion=conversion) + def get(self, key, default=None): + '''Get a readonly group or parameter. -class GroupEditable(Decorator): - ''' Group instance decorator providing convenience functions for Writer editing. - ''' - def __init__(self, group): - super(GroupEditable, self).__init__(group) - - def __contains__(self, key): - return key in self._decoratee - # - # Add decorator functions (throws on overwrite) - # - def add(self, name, desc, bpe, format, data, *dimensions): - ''' Add a parameter with 'data' package formated in accordance with 'format'. - ''' - if isinstance(data, bytes): - pass - else: - data = struct.pack(format, data) + Parameters + ---------- + key : str + If this string contains a period (.), then the part before the + period will be used to retrieve a group, and the part after the + period will be used to retrieve a parameter from that group. If this + string does not contain a period, then just a group will be + returned. + default : any + Return this value if the named group and parameter are not found. - self.add_param(name, - desc=desc, - bytes_per_element=bpe, - bytes=data, - dimensions=list(dimensions)) + Returns + ------- + value : :class:`GroupReadonly` or :class:`Param` + Either a group or parameter with the specified name(s). If neither + is found, returns the default value. + ''' + val = self._get(key) + if val is None: + return default + return val.readonly() - def add_array(self, name, desc, data, dtype=None): - '''Add a parameter with the 'data' package. + def items(self): + ''' Acquire iterable over parameter group pairs. - Arguments - --------- - data : Numpy array, or python iterable. - dtype : Numpy dtype to encode the array (Optional if data is numpy type). - ''' - if not isinstance(data, np.ndarray): - if dtype is not None: - raise ValueError('Must specify dtype when passning non-numpy array type.') - data = np.array(data, dtype=dtype) - elif dtype is None: - dtype = data.dtype - - self.add_param(name, - desc=desc, - bytes_per_element=dtype.itemsize, - bytes=data, - dimensions=data.shape) - - def add_str(self, name, desc, data, *dimensions): - ''' Add a string parameter. - ''' - self.add_param(name, - desc=desc, - bytes_per_element=-1, - bytes=data.encode('utf-8'), - dimensions=list(dimensions)) - - def add_empty_array(self, name, desc, bpe): - ''' Add an empty parameter block. - ''' - self.add_param(name, desc=desc, - bytes_per_element=bpe, dimensions=[0]) - - # - # Set decorator functions (overwrites) - # - def set(self, name, *args, **kwargs): - ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'. - ''' - try: - self.remove_param(name) - except KeyError as e: - pass - self.add(name, *args, **kwargs) - - def set_str(self, name, *args, **kwargs): - ''' Add a string parameter. - ''' - try: - self.remove_param(name) - except KeyError as e: - pass - self.add_str(name, *args, **kwargs) - - def set_array(self, name, *args, **kwargs): - ''' Add a string parameter. - ''' - try: - self.remove_param(name) - except KeyError as e: - pass - self.add_array(name, *args, **kwargs) - - def set_empty_array(self, name, *args, **kwargs): - ''' Add an empty parameter block. + Returns + ------- + items : Touple of ((str, :class:`Group`), ...) + Python touple containing pairs of name keys and parameter group entries. ''' - try: - self.remove_param(name) - except KeyError as e: - pass - self.add_empty_array(name, *args, **kwargs) + return ((k, GroupReadonly(v)) for k, v in self._groups.items() if isinstance(k, str)) + def values(self): + ''' Acquire iterable over parameter group entries. + + Returns + ------- + values : Touple of (:class:`Group`, ...) + Python touple containing unique parameter group entries. + ''' + return (GroupReadonly(v) for k, v in self._groups.items() if isinstance(k, str)) class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -421,18 +364,13 @@ class Writer(Manager): def __init__(self, point_rate=480., analog_rate=0., - point_scale=-1., - point_units='mm ', - gen_scale=1.): + point_scale=-1.): '''Set metadata for this writer. ''' self._dtypes = DataTypes() # Only support INTEL format from writing super(Writer, self).__init__() - # Custom properties - self._point_units = point_units - # Header properties self._header.frame_rate = np.float32(point_rate) self._header.scale_factor = np.float32(point_scale) @@ -484,10 +422,7 @@ def from_reader(reader, conversion=None): if is_consume: writer._header = reader._header - reader._header = None writer._groups = reader._groups - reader._groups = None - del reader elif is_deep_copy: writer._header = copy.deepcopy(reader._header) writer._groups = copy.deepcopy(reader._groups) @@ -511,6 +446,11 @@ def from_reader(reader, conversion=None): # Copy frames for (i, point, analog) in reader.read_frames(copy=True, camera_sum=False): writer.add_frames((point, analog)) + if is_consume: + # Cleanup + reader._header = None + reader._groups = None + del reader return writer @property @@ -564,35 +504,32 @@ def trial_group(self): return self.get_create('TRIAL') def get(self, group, default=None): - '''Get a GroupEditable decorator for keyed group instance, or a parameter instance. + '''Get a writable group or a parameter instance. Parameters ---------- - group : str + key : str Key, see Manager.get() for valid key formats. default : any Return this value if the named group and parameter are not found. Returns ------- - value : :class:`GroupEditable` or :class:`Param` + value : :class:`GroupWritable` or :class:`ParamWritable` Either a decorated group instance or parameter with the specified name(s). If neither is found, the default value is returned. ''' - val = super(Writer, self).get(group, default) - if isinstance(val, Group): - return GroupEditable(val) - return val # Parameter + return super(Writer, self)._get(group, default) def add_group(self, *args, **kwargs): '''Add a new parameter group. See Manager.add_group() for more information. Returns ------- - group : :class:`Group` + group : :class:`GroupWritable` An editable group instance. ''' - return GroupEditable(super(Writer, self).add_group(*args, **kwargs)) + return GroupWritable(super(Writer, self)._add_group(*args, **kwargs)) def add_frames(self, frames, index=None): '''Add frames to this writer instance. @@ -630,6 +567,18 @@ def pack_labels(labels): label_str = ''.join(label.ljust(label_max_size) for label in labels) return label_str, label_max_size + def add_group(self, *args): + ''' Add a new parameter group (see Manager._rename_group for args). ''' + return self._add_group(*args) + + def rename_group(self, *args): + ''' Rename a specified parameter group (see Manager._rename_group for args). ''' + self._rename_group(*args) + + def remove_group(self, *args): + '''Remove the parameter group. (see Manager._rename_group for args). ''' + self._remove_group(*args) + def set_point_labels(self, labels): ''' Set point data labels. ''' @@ -760,8 +709,7 @@ def write(self, handle): group.set('RATE', 'Point data sample rate', 4, ''.format(self.desc) @@ -31,75 +32,36 @@ def __repr__(self): def __contains__(self, key): return key in self._params - @property - def name(self): - ''' Group name. ''' - return self._name + def __getitem__(self, key): + return self._params[key] - @name.setter - def name(self, value): - ''' Group name string. + @property + def binary_size(self): + '''Return the number of bytes to store this group and its parameters.''' + return ( + 1 + # group_id + 1 + len(self.name.encode('utf-8')) + # size of name and name bytes + 2 + # next offset marker + 1 + len(self.desc.encode('utf-8')) + # size of desc and desc bytes + sum(p.binary_size for p in self._params.values())) - Parameters - ---------- - value : str - New name for the group. - ''' + def set_name(self, value): + ''' Set the group name string. ''' if value is None or isinstance(value, str): - self._name = value + self.name = value else: raise TypeError('Expected group name to be string, was %s.' % type(value)) - @property - def desc(self): - ''' Group descriptor. ''' - return self._desc - - @desc.setter - def desc(self, value): - ''' Group descriptor. - - Parameters - ---------- - value : str, or bytes - New description for this parameter group. + def set_desc(self, value): + ''' Set the Group descriptor. ''' if isinstance(value, bytes): - self._desc = self._dtypes.decode_string(value) + self.desc = self._dtypes.decode_string(value) elif isinstance(value, str) or value is None: - self._desc = value + self.desc = value else: raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(value)) - def param_items(self): - ''' Acquire iterator for paramater key-entry pairs. ''' - return self._params.items() - - def param_values(self): - ''' Acquire iterator for parameter entries. ''' - return self._params.values() - - def param_keys(self): - ''' Acquire iterator for parameter entry keys. ''' - return self._params.keys() - - def get(self, key, default=None): - '''Get a parameter by key. - - Parameters - ---------- - key : any - Parameter key to look up in this group. - default : any, optional - Value to return if the key is not found. Defaults to None. - - Returns - ------- - param : :class:`Param` - A parameter from the current group. - ''' - return self._params.get(key, default) - def add_param(self, name, **kwargs): '''Add a parameter to this group. @@ -109,7 +71,7 @@ def add_param(self, name, **kwargs): Name of the parameter to add to this group. The name will automatically be case-normalized. - Additional keyword arguments will be passed to the `Param` constructor. + Additional keyword arguments will be passed to the `ParamData` constructor. Raises ------ @@ -123,7 +85,7 @@ def add_param(self, name, **kwargs): name = name.upper() if name in self._params: raise KeyError('Parameter already exists with key {}'.format(name)) - self._params[name] = Param(name, self._dtypes, **kwargs) + self._params[name] = ParamData(name, self._dtypes, **kwargs) def remove_param(self, name): '''Remove the specified parameter. @@ -140,7 +102,7 @@ def rename_param(self, name, new_name): Parameters ---------- - name : str, or 'Param' + name : str, or 'ParamData' Parameter instance, or name. new_name : str New name for the parameter. @@ -154,7 +116,7 @@ def rename_param(self, name, new_name): ''' if new_name in self._params: raise ValueError("Key {} already exist.".format(new_name)) - if isinstance(name, Param): + if isinstance(name, ParamData): param = name name = param.name else: @@ -165,15 +127,6 @@ def rename_param(self, name, new_name): del self._params[name] self._params[new_name] = param - def binary_size(self): - '''Return the number of bytes to store this group and its parameters.''' - return ( - 1 + # group_id - 1 + len(self._name.encode('utf-8')) + # size of name and name bytes - 2 + # next offset marker - 1 + len(self._desc.encode('utf-8')) + # size of desc and desc bytes - sum(p.binary_size() for p in self._params.values())) - def write(self, group_id, handle): '''Write this parameter group, with parameters, to a file handle. @@ -184,8 +137,8 @@ def write(self, group_id, handle): handle : file handle An open, writable, binary file handle. ''' - name = self._name.encode('utf-8') - desc = self._desc.encode('utf-8') + name = self.name.encode('utf-8') + desc = self.desc.encode('utf-8') handle.write(struct.pack('bb', len(name), -group_id)) handle.write(name) handle.write(struct.pack(' 0: @@ -133,7 +129,7 @@ def check_parameters(params): else: warnings.warn('No analog data found in file.') - def add_group(self, group_id, name, desc): + def _add_group(self, group_id, name, desc): '''Add a new parameter group. Parameters @@ -167,10 +163,10 @@ def add_group(self, group_id, name, desc): name = name.upper() if name in self._groups: raise KeyError('No group matched name key {}'.format(name)) - group = self._groups[name] = self._groups[group_id] = Group(self._dtypes, name, desc) + group = self._groups[name] = self._groups[group_id] = GroupData(self._dtypes, name, desc) return group - def remove_group(self, group_id): + def _remove_group(self, group_id): '''Remove the parameter group. Parameters @@ -185,7 +181,7 @@ def remove_group(self, group_id): for k in gkeys: del self._groups[k] - def rename_group(self, group_id, new_group_id): + def _rename_group(self, group_id, new_group_id): ''' Rename a specified parameter group. Parameters @@ -200,8 +196,8 @@ def rename_group(self, group_id, new_group_id): KeyError If a group with a duplicate ID or name already exists. ''' - if isinstance(group_id, Group): - grp = group_id + if isinstance(group_id, GroupReadonly): + grp = group_id._data else: # Aquire instance using id grp = self._groups.get(group_id, None) @@ -216,7 +212,7 @@ def rename_group(self, group_id, new_group_id): if isinstance(new_group_id, (str, bytes)): if grp.name in self._groups: del self._groups[grp.name] - grp._name = new_group_id + grp.name = new_group_id elif is_integer(new_group_id): new_group_id = int(new_group_id) # Ensure python int del self._groups[group_id] @@ -225,7 +221,7 @@ def rename_group(self, group_id, new_group_id): # Update self._groups[new_group_id] = grp - def get(self, group, default=None): + def _get(self, group, default=None): '''Get a group or parameter. Parameters @@ -246,7 +242,10 @@ def get(self, group, default=None): is found, returns the default value. ''' if is_integer(group): - return self._groups.get(int(group), default) + group = self._groups.get(int(group)) + if group is None: + return default + return GroupWritable(group) group = group.upper() param = None if '.' in group: @@ -255,56 +254,24 @@ def get(self, group, default=None): group, param = group.split(':', 1) if group not in self._groups: return default - group = self._groups[group] + group = GroupWritable(self._groups[group]) if param is not None: return group.get(param, default) return group - def get_int8(self, key): - '''Get a parameter value as an 8-bit signed integer.''' - return self.get(key).int8_value - - def get_uint8(self, key): - '''Get a parameter value as an 8-bit unsigned integer.''' - return self.get(key).uint8_value - - def get_int16(self, key): - '''Get a parameter value as a 16-bit signed integer.''' - return self.get(key).int16_value - - def get_uint16(self, key): - '''Get a parameter value as a 16-bit unsigned integer.''' - return self.get(key).uint16_value - - def get_int32(self, key): - '''Get a parameter value as a 32-bit signed integer.''' - return self.get(key).int32_value - - def get_uint32(self, key): - '''Get a parameter value as a 32-bit unsigned integer.''' - return self.get(key).uint32_value - - def get_float(self, key): - '''Get a parameter value as a 32-bit float.''' - return self.get(key).float_value - - def get_bytes(self, key): - '''Get a parameter value as a byte string.''' - return self.get(key).bytes_value - - def get_string(self, key): - '''Get a parameter value as a string.''' - return self.get(key).string_value + @property + def header(self): + ''' Access to .c3d header data. ''' + return self._header def parameter_blocks(self): '''Compute the size (in 512B blocks) of the parameter section.''' - bytes = 4. + sum(g.binary_size() for g in self._groups.values()) + bytes = 4. + sum(g.binary_size for g in self._groups.values()) return int(np.ceil(bytes / 512)) @property def point_rate(self): - ''' Number of sampled 3D coordinates per second. - ''' + ''' Number of sampled 3D coordinates per second. ''' try: return self.get_float('POINT:RATE') except AttributeError: @@ -312,6 +279,7 @@ def point_rate(self): @property def point_scale(self): + ''' Scaling applied to non-float data. ''' try: return self.get_float('POINT:SCALE') except AttributeError: @@ -319,8 +287,7 @@ def point_scale(self): @property def point_used(self): - ''' Number of sampled 3D point coordinates per frame. - ''' + ''' Number of sampled 3D point coordinates per frame. ''' try: return self.get_uint16('POINT:USED') except AttributeError: @@ -328,17 +295,18 @@ def point_used(self): @property def analog_used(self): - ''' Number of analog measurements, or channels, for each analog data sample. - ''' + ''' Number of analog measurements, or channels, for each analog data sample. ''' try: return self.get_uint16('ANALOG:USED') except AttributeError: - return self.header.analog_count + per_frame = self.header.analog_per_frame + if per_frame > 0: + return int(self.header.analog_count / per_frame) + return 0 @property def analog_rate(self): - ''' Number of analog data samples per second. - ''' + ''' Number of analog data samples per second. ''' try: return self.get_float('ANALOG:RATE') except AttributeError: @@ -346,34 +314,36 @@ def analog_rate(self): @property def analog_per_frame(self): - ''' Number of analog samples per 3D frame (point sample). - ''' + ''' Number of analog frames per 3D frame (point sample). ''' return int(self.analog_rate / self.point_rate) @property def analog_sample_count(self): - ''' Number of analog samples per channel. - ''' + ''' Number of analog samples per channel. ''' has_analog = self.analog_used > 0 return int(self.frame_count * self.analog_per_frame) * has_analog @property def point_labels(self): - return self.get('POINT:LABELS').string_array + ''' Labels for each POINT data channel. ''' + return self._get('POINT:LABELS').string_array @property def analog_labels(self): + ''' Labels for each ANALOG data channel. ''' return self.get('ANALOG:LABELS').string_array @property def frame_count(self): + ''' Number of frames recorded in the data. ''' return self.last_frame - self.first_frame + 1 # Add 1 since range is inclusive [first, last] @property def first_frame(self): + ''' Trial frame corresponding to the first frame recorded in the data. ''' # Start frame seems to be less of an issue to determine. # this is a hack for phasespace files ... should put it in a subclass. - param = self.get('TRIAL:ACTUAL_START_FIELD') + param = self._get('TRIAL:ACTUAL_START_FIELD') if param is not None: # ACTUAL_START_FIELD is encoded in two 16 byte words... words = param.uint16_array @@ -382,23 +352,24 @@ def first_frame(self): @property def last_frame(self): + ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' # Number of frames can be represented in many formats, first check if valid header values if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: return self.header.last_frame # Check different parameter options where the frame can be encoded end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] - param = self.get('TRIAL:ACTUAL_END_FIELD') + param = self._get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) words = param.uint16_array end_frame[1] = words[0] + words[1] * 65536 #end_frame[1] = param.uint32_value - param = self.get('POINT:LONG_FRAMES') + param = self._get('POINT:LONG_FRAMES') if param is not None: # Encoded as float end_frame[2] = int(param.float_value) - param = self.get('POINT:FRAMES') + param = self._get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint end_frame[3] = param._as_integer_value @@ -406,35 +377,34 @@ def last_frame(self): return int(np.max(end_frame)) def get_screen_axis(self): - ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. + ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group. Returns ------- value : Touple on form (str, str) or None Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. ''' - X = self.get('POINT:X_SCREEN') - Y = self.get('POINT:Y_SCREEN') + X = self._get('POINT:X_SCREEN') + Y = self._get('POINT:Y_SCREEN') if X and Y: return (X.string_value, Y.string_value) return None def get_analog_transform_parameters(self): - ''' Parse analog data transform parameters. - ''' + ''' Parse analog data transform parameters. ''' # Offsets analog_offsets = np.zeros((self.analog_used), int) - param = self.get('ANALOG:OFFSET') + param = self._get('ANALOG:OFFSET') if param is not None and param.num_elements > 0: analog_offsets[:] = param.int16_array[:self.analog_used] # Scale factors analog_scales = np.ones((self.analog_used), float) gen_scale = 1. - param = self.get('ANALOG:GEN_SCALE') + param = self._get('ANALOG:GEN_SCALE') if param is not None: gen_scale = param.float_value - param = self.get('ANALOG:SCALE') + param = self._get('ANALOG:SCALE') if param is not None and param.num_elements > 0: analog_scales[:] = param.float_array[:self.analog_used] @@ -448,3 +418,39 @@ def get_analog_transform(self): analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) return analog_scales, analog_offsets + + def get_int8(self, key): + '''Get a parameter value as an 8-bit signed integer.''' + return self._get(key).int8_value + + def get_uint8(self, key): + '''Get a parameter value as an 8-bit unsigned integer.''' + return self._get(key).uint8_value + + def get_int16(self, key): + '''Get a parameter value as a 16-bit signed integer.''' + return self._get(key).int16_value + + def get_uint16(self, key): + '''Get a parameter value as a 16-bit unsigned integer.''' + return self._get(key).uint16_value + + def get_int32(self, key): + '''Get a parameter value as a 32-bit signed integer.''' + return self._get(key).int32_value + + def get_uint32(self, key): + '''Get a parameter value as a 32-bit unsigned integer.''' + return self._get(key).uint32_value + + def get_float(self, key): + '''Get a parameter value as a 32-bit float.''' + return self._get(key).float_value + + def get_bytes(self, key): + '''Get a parameter value as a byte string.''' + return self._get(key).bytes_value + + def get_string(self, key): + '''Get a parameter value as a string.''' + return self._get(key).string_value diff --git a/src/parameter.py b/src/parameter.py index 1bab30e..3a8b570 100644 --- a/src/parameter.py +++ b/src/parameter.py @@ -2,7 +2,7 @@ import numpy as np from .utils import DEC_to_IEEE, DEC_to_IEEE_BYTES -class Param(object): +class ParamData(object): '''A class representing a single named parameter from a C3D file. Attributes @@ -23,8 +23,6 @@ class Param(object): rows (number of strings). bytes : str Raw data for this parameter. - handle : - File handle positioned at the first byte of a .c3d parameter description. ''' def __init__(self, @@ -37,7 +35,7 @@ def __init__(self, handle=None): '''Set up a new parameter, only the name is required.''' self.name = name - self._dtypes = dtype + self.dtypes = dtype self.desc = desc self.bytes_per_element = bytes_per_element self.dimensions = dimensions or [] @@ -61,6 +59,7 @@ def total_bytes(self): '''Return the number of bytes used for storing this parameter's data.''' return self.num_elements * abs(self.bytes_per_element) + @property def binary_size(self): '''Return the number of bytes needed to store this parameter.''' return ( @@ -87,7 +86,7 @@ def write(self, group_id, handle): name = self.name.encode('utf-8') handle.write(struct.pack('bb', len(name), group_id)) handle.write(name) - handle.write(struct.pack(' 0, 'No group items in file or Manager.group_items failed' def test_Manager_group_listed(self): '''Test Manager.group_listed''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - grp_list = [k for (k, g) in reader.group_listed()] + grp_list = [k for (k, g) in reader.listed()] assert len(grp_list) > 0, 'No group items in file or Manager.group_listed failed' + def test_Manager_add_group(self): + '''Test if renaming groups acts as intended.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + keys = reader.keys() + try: + reader.add_group(0) + raise ValueError('Reader should not allow adding groups.') + except AttributeError: + pass + try: + reader.remove_group(keys[0]) + raise ValueError('Reader should not allow removing groups.') + except AttributeError: + pass + try: + reader.rename_group(keys[0], 'TEST_NAME') + raise ValueError('Reader should not allow renaming groups.') + except AttributeError: + pass def test_Manager_add_group(self): '''Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - ref = GroupSample(reader) + ref = GroupSample(reader.to_writer()) ref.verify_add_group(100) ref.verify_remove_all_using_numeric() def test_Manager_removing_group_from_numeric(self): '''Test if removing groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - ref = GroupSample(reader) + ref = GroupSample(reader.to_writer()) ref.verify_remove_all_using_numeric() ref.verify_add_group(100) def test_Manager_removing_group_from_name(self): '''Test if removing groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - ref = GroupSample(reader) + ref = GroupSample(reader.to_writer()) ref.verify_remove_all_using_name() ref.verify_add_group(100) def test_Manager_rename_group(self): '''Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - ref = GroupSample(reader) + writer = reader.to_writer() + ref = GroupSample(writer) grp_keys = [k for (k, g) in ref.group_items] new_names = ['TEST_NAME' + str(i) for i in range(len(grp_keys))] for key, test_name in zip(grp_keys, new_names): - grp = reader.get(key) - reader.rename_group(key, test_name) - grp2 = reader.get(test_name) + grp = writer.get(key) + writer.rename_group(key, test_name) + grp2 = writer.get(test_name) assert grp2 is not None, "Rename failed, group with name '%s' does not exist." - assert grp == grp2, 'Rename failed, group acquired from new name is not identical.' + assert grp._data == grp2._data, 'Rename failed, group acquired from new name is not identical.' ref.assert_entry_count() ref.assert_group_list() try: - reader.rename_group(new_names[0], new_names[1]) + writer.rename_group(new_names[0], new_names[1]) raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') except ValueError as e: pass # Correct @@ -178,7 +198,8 @@ def test_Manager_rename_group(self): def test_Manager_renumber_group(self): '''Test if renaming (renumbering) groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - ref = GroupSample(reader) + writer = reader.to_writer() + ref = GroupSample(writer) grp_ids = [k for (k, g) in ref.group_listed] max_key = ref.max_key @@ -186,18 +207,18 @@ def test_Manager_renumber_group(self): for i, key in enumerate(grp_ids): test_num = max_key + i + 1 - grp = reader.get(key) - reader.rename_group(key, test_num) - grp2 = reader.get(test_num) + grp = writer.get(key) + writer.rename_group(key, test_num) + grp2 = writer.get(test_num) assert grp2 is not None, "Rename failed, group with name '%s' does not exist." - assert grp == grp2, 'Rename failed, group acquired from new name is not identical.' + assert grp._data == grp2._data, 'Rename failed, group acquired from new name is not identical.' ref.assert_entry_count() ref.assert_group_items() try: - reader.rename_group(max_key + 1, max_key + 2) + writer.rename_group(max_key + 1, max_key + 2) raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') except ValueError as e: pass # Correct diff --git a/test/test_parameter_accessors.py b/test/test_parameter_accessors.py index 5238b83..dedd16a 100644 --- a/test/test_parameter_accessors.py +++ b/test/test_parameter_accessors.py @@ -2,30 +2,35 @@ ''' import unittest import c3d +from src.group import GroupReadonly, GroupWritable import numpy as np import test.verify as verify from test.zipload import Zipload from test.base import Base +rnd = np.random.default_rng() + +def add_dummy_param(group, name='TEST_NAME', shape=(10, 2), flt_range=(-1e6, 1e6)): + arr = rnd.uniform(*flt_range, size=shape).astype(np.float32) + group.add_param(name, bytes_per_element=4, dimensions=arr.shape, bytes=arr.T.tobytes()) + class ParamSample(): ''' Helper object to verify parameter entries persist or terminate properly. ''' def __init__(self, group): - assert isinstance(group, c3d.Group), 'Must pass group to ParamSample instance.' - self.flt_range = (-1e6, 1e6) - self.shape = (10, 2) + assert isinstance(group, GroupWritable), \ + 'Must pass GroupWritable to ParamSample instance, was %s' % type(group) self.group = group - self.rnd = np.random.default_rng() self.sample() @property def items(self): '''Helper to access group items. ''' - return [(k, g) for (k, g) in self.group.param_items()] + return [(k, g) for (k, g) in self.group.items()] @property def keys(self): '''Helper to access group items. ''' - return [k for (k, g) in self.group.param_items()] + return [k for (k, g) in self.group.items()] def sample(self): '''Call before applying changes. ''' @@ -51,15 +56,14 @@ def assert_group_items(self, ignore=None): if n == ignore: continue g2 = self.group.get(n) - assert g == g2, 'Group listed order changed for entry %i.' % i + assert g._data == g2._data, 'Group listed order changed for entry %i.' % i def verify_add_parameter(self, N): '''Add N parameters and verify count at each iteration.''' self.sample() for i in range(1, N): test_name = 'TEST_ADD_PARAM_%i' % i - arr = self.rnd.uniform(*self.flt_range, size=self.shape).astype(np.float32) - self.group.add_param(test_name, bytes_per_element=4, dimensions=arr.shape, bytes=arr.T.tobytes()) + add_dummy_param(self.group, test_name) assert self.group.get(test_name) is not None, 'Added group does not exist.' self.assert_group_items() @@ -85,19 +89,32 @@ class TestParameterAccessors(Base): def test_Group_values(self): '''Test Group.values()''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - for g in reader.group_values(): - assert len(g.param_values()) > 0, 'No group values in file or Group.param_values() failed' + for g in reader.values(): + N = len([v for v in g.values()]) + assert N > 0, 'No group values in file or GroupReadonly.values() failed' def test_Group_items(self): '''Test Group.items()''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - for g in reader.group_values(): - assert len(g.param_items()) > 0, 'No group items in file or Group.param_items() failed' + for g in reader.values(): + N = len([kv for kv in g.items()]) + assert N > 0, 'No group items in file or GroupReadonly.items() failed' + + def test_Group_readonly_add_param(self): + '''Test if adding parameter to a readonly group fails.''' + reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) + for g in reader.values(): + try: + add_dummy_param(g) + raise RuntimeError('Adding to readonly should not be possible.') + except AttributeError: + pass def test_Group_add_param(self): '''Test if adding and groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - for g in reader.group_values(): + writer = reader.to_writer() + for g in writer.values(): ref = ParamSample(g) ref.verify_add_parameter(100) ref.verify_remove_all() @@ -105,7 +122,8 @@ def test_Group_add_param(self): def test_Group_remove_param(self): '''Test if removing groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - for g in reader.group_values(): + writer = reader.to_writer() + for g in writer.values(): ref = ParamSample(g) ref.verify_remove_all() ref.verify_add_parameter(100) @@ -114,7 +132,8 @@ def test_Group_rename_param(self): '''Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) - for g in reader.group_values(): + writer = reader.to_writer() + for g in writer.values(): ref = ParamSample(g) prm_keys = ref.keys new_names = ['TEST_NAME' + str(i) for i in range(len(prm_keys))] @@ -123,7 +142,7 @@ def test_Group_rename_param(self): g.rename_param(key, nname) prm2 = g.get(nname) assert prm2 is not None, "Rename failed, renamed param does not exist." - assert prm == prm2, 'Rename failed, param acquired from new name is not identical.' + assert prm._data == prm2._data, 'Rename failed, param acquired from new name is not identical.' ref.assert_entry_count() try: diff --git a/test/test_parameter_bytes_conversion.py b/test/test_parameter_bytes_conversion.py index 03d8827..db3466e 100644 --- a/test/test_parameter_bytes_conversion.py +++ b/test/test_parameter_bytes_conversion.py @@ -1,9 +1,8 @@ -import c3d import struct import unittest import numpy as np from src.dtypes import DataTypes, PROCESSOR_INTEL -from src.parameter import Param +from src.parameter import ParamData, ParamReadonly def genByteWordArr(word, shape): @@ -33,6 +32,9 @@ def genRndFloatArr(shape, rnd, range=(-1e6, 1e6)): ''' return rnd.uniform(range[0], range[1], shape) +def make_param(*args, **kwargs): + return ParamReadonly(ParamData(*args, **kwargs)) + class ParameterValueTest(unittest.TestCase): ''' Test read Parameter arrays @@ -56,7 +58,7 @@ def test_a_param_float32(self): for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.float32(self.rnd.uniform(*ParameterValueTest.RANGE_32_BIT)) bytes = struct.pack(' 5) - P = Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) + P = make_param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) arr_out = P.string_array assert arr.T.shape == arr_out.shape, "Mismatch in 'string_array' converted shape. Was %s, expected %s" %\ @@ -325,7 +327,7 @@ def test_k_parse_random_string_array(self): # 4 dims for wlen in range(10): arr, shape = genRndByteArr(wlen, [7, 5, 3], wlen > 5) - P = Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) + P = make_param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) arr_out = P.string_array assert arr.T.shape == arr_out.shape, "Mismatch in 'string_array' converted shape. Was %s, expected %s" %\ @@ -337,7 +339,7 @@ def test_k_parse_random_string_array(self): # 5 dims for wlen in range(10): arr, shape = genRndByteArr(wlen, [7, 6, 5, 3], wlen > 5) - P = Param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) + P = make_param('STRING_TEST', self.dtypes, bytes_per_element=-1, dimensions=shape, bytes=arr.T.tobytes()) arr_out = P.string_array assert arr.T.shape == arr_out.shape, "Mismatch in 'string_array' converted shape. Was %s, expected %s" %\ From 89ad991b30581be6a8955c414c657c2eb13b6b12 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Fri, 2 Jul 2021 23:47:14 +0200 Subject: [PATCH 043/120] Restructure + unittests working --- c3d.py | 22 +++++++++------------- src/manager.py | 5 ++++- src/parameter.py | 36 ++++++++++++++++++------------------ test/verify.py | 4 ++-- 4 files changed, 33 insertions(+), 34 deletions(-) diff --git a/c3d.py b/c3d.py index 414789d..8286223 100644 --- a/c3d.py +++ b/c3d.py @@ -531,6 +531,14 @@ def add_group(self, *args, **kwargs): ''' return GroupWritable(super(Writer, self)._add_group(*args, **kwargs)) + def rename_group(self, *args): + ''' Rename a specified parameter group (see Manager._rename_group for args). ''' + self._rename_group(*args) + + def remove_group(self, *args): + '''Remove the parameter group. (see Manager._rename_group for args). ''' + self._remove_group(*args) + def add_frames(self, frames, index=None): '''Add frames to this writer instance. @@ -567,18 +575,6 @@ def pack_labels(labels): label_str = ''.join(label.ljust(label_max_size) for label in labels) return label_str, label_max_size - def add_group(self, *args): - ''' Add a new parameter group (see Manager._rename_group for args). ''' - return self._add_group(*args) - - def rename_group(self, *args): - ''' Rename a specified parameter group (see Manager._rename_group for args). ''' - self._rename_group(*args) - - def remove_group(self, *args): - '''Remove the parameter group. (see Manager._rename_group for args). ''' - self._remove_group(*args) - def set_point_labels(self, labels): ''' Set point data labels. ''' @@ -766,7 +762,7 @@ def _write_metadata(self, handle): # Groups handle.write(struct.pack( 'BBBB', 0, 0, self.parameter_blocks(), self._dtypes.processor)) - for group_id, group in self._listed(): + for group_id, group in self.listed(): group._data.write(group_id, handle) # Padding diff --git a/src/manager.py b/src/manager.py index 20130d0..dd8eefc 100644 --- a/src/manager.py +++ b/src/manager.py @@ -372,7 +372,10 @@ def last_frame(self): param = self._get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint - end_frame[3] = param._as_integer_value + if param.bytes_per_element == 2: + end_frame[3] = param.uint16_value + else: + end_frame[3] = int(param.float_value) # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) diff --git a/src/parameter.py b/src/parameter.py index 3a8b570..aba932e 100644 --- a/src/parameter.py +++ b/src/parameter.py @@ -145,24 +145,6 @@ def _as_any(self, dtype): return data.flatten() return data - @property - def _as_integer_value(self): - ''' Get the param as either 32 bit float or unsigned integer. - Evaluates if an integer is stored as a floating point representation. - - Note: This is implemented purely for parsing start/end frames. - ''' - if self.total_bytes >= 4: - # Check if float value representation is an integer - value = self.float_value - if int(value) == value: - return value - return self.uint32_value - elif self.total_bytes >= 2: - return self.uint16_value - else: - return self.uint8_value - class ParamReadonly(object): def __init__(self, data): @@ -396,6 +378,24 @@ def string_array(self): byte_arr[i] = self.dtypes.decode_string(byte_arr[i]) return byte_arr + @property + def _as_integer_value(self): + ''' Get the param as either 32 bit float or unsigned integer. + Evaluates if an integer is stored as a floating point representation. + + Note: This is implemented purely for parsing start/end frames. + ''' + if self.total_bytes >= 4: + # Check if float value representation is an integer + value = self.float_value + if int(value) == value: + return value + return self.uint32_value + elif self.total_bytes >= 2: + return self.uint16_value + else: + return self.uint8_value + class ParamWritable(ParamReadonly): def __init__(self, data): super(ParamWritable, self).__init__(data) diff --git a/test/verify.py b/test/verify.py index 17e6d05..807ee80 100644 --- a/test/verify.py +++ b/test/verify.py @@ -327,10 +327,10 @@ def data_is_equal(areader, breader, alabel, blabel): cam_diff = tot_points - np.sum(cam_close) assert cam_diff == 0, '\n' + \ 'Mismatch in camera bit flags between {} and {}.\n'.format(alabel, blabel) +\ - 'Number of samples with flag differences larger then 1 bit: {} of {}'.format(cam_diff, tot_points) + 'Number of samples with flag differences larger then 1: {} of {}'.format(cam_diff, tot_points) err_str = '\n' + \ 'Mismatch in camera bit flags between {} and {}.\n'.format(alabel, blabel) +\ - 'Number of samples with flag difference of 1 bit: {} of {}'.format(cam_diff_non_equal, tot_points) + 'Number of samples with flag difference of 1: {} of {}'.format(cam_diff_non_equal, tot_points) warnings.warn(err_str, RuntimeWarning) # Residual assert assert residual_diff == 0, '\n' +\ From 3bcf57b4e8b280d27bfb12347e299ebf20efd3e0 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 11:33:27 +0200 Subject: [PATCH 044/120] GroupWritable -> Group ParamWritable -> Param Group.__eq__ Param.__eq__ --- c3d.py | 61 +++++++++++++--------------- src/group.py | 40 +++++++++---------- src/manager.py | 68 ++++++++++++++++---------------- src/parameter.py | 9 +++-- test/test_group_accessors.py | 8 ++-- test/test_parameter_accessors.py | 10 ++--- 6 files changed, 96 insertions(+), 100 deletions(-) diff --git a/c3d.py b/c3d.py index 8286223..b5d721e 100644 --- a/c3d.py +++ b/c3d.py @@ -9,7 +9,6 @@ import warnings from src.manager import Manager from src.header import Header -from src.group import GroupData, GroupWritable, GroupReadonly from src.dtypes import DataTypes from src.utils import is_integer, is_iterable, DEC_to_IEEE_BYTES @@ -87,7 +86,9 @@ def seek_param_section_header(): if group_id > 0: # We've just started reading a parameter. If its group doesn't # exist, create a blank one. add the parameter to the group. - group = self._groups.setdefault(group_id, GroupData(self._dtypes)) + group = super(Reader, self).get(group_id) + if group is None: + group = self._add_group(group_id) group.add_param(name, handle=buf) else: # We've just started reading a group. If a group with the @@ -97,7 +98,7 @@ def seek_param_section_header(): group_id = abs(group_id) size, = struct.unpack('B', buf.read(1)) desc = size and buf.read(size) or '' - group = self._get(group_id) + group = super(Reader, self).get(group_id) if group is not None: self._rename_group(group, name) # Inserts name key group.desc = desc @@ -309,30 +310,40 @@ def get(self, key, default=None): Either a group or parameter with the specified name(s). If neither is found, returns the default value. ''' - val = self._get(key) - if val is None: - return default - return val.readonly() + val = super(Reader, self).get(key) + if val: + return val.readonly() + return default def items(self): ''' Acquire iterable over parameter group pairs. Returns ------- - items : Touple of ((str, :class:`Group`), ...) + items : Touple of ((str, :class:`GroupReadonly`), ...) Python touple containing pairs of name keys and parameter group entries. ''' - return ((k, GroupReadonly(v)) for k, v in self._groups.items() if isinstance(k, str)) + return ((k, v.readonly()) for k, v in super(Reader, self).items()) def values(self): ''' Acquire iterable over parameter group entries. Returns ------- - values : Touple of (:class:`Group`, ...) + values : Touple of (:class:`GroupReadonly`, ...) Python touple containing unique parameter group entries. ''' - return (GroupReadonly(v) for k, v in self._groups.items() if isinstance(k, str)) + return (v.readonly() for k, v in super(Reader, self).items()) + + def listed(self): + ''' Acquire iterable over parameter group entries. + + Returns + ------- + items : Touple of ((int, :class:`GroupReadonly`), ...) + Python touple containing unique parameter group entries. + ''' + return ((k, v.readonly()) for k, v in super(Reader, self).listed()) class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -503,41 +514,23 @@ def trial_group(self): ''' Get or create the TRIAL parameter group.''' return self.get_create('TRIAL') - def get(self, group, default=None): - '''Get a writable group or a parameter instance. - - Parameters - ---------- - key : str - Key, see Manager.get() for valid key formats. - default : any - Return this value if the named group and parameter are not found. - - Returns - ------- - value : :class:`GroupWritable` or :class:`ParamWritable` - Either a decorated group instance or parameter with the specified name(s). If neither - is found, the default value is returned. - ''' - return super(Writer, self)._get(group, default) - - def add_group(self, *args, **kwargs): + def add_group(self, group_id, name, desc): '''Add a new parameter group. See Manager.add_group() for more information. Returns ------- - group : :class:`GroupWritable` + group : :class:`Group` An editable group instance. ''' - return GroupWritable(super(Writer, self)._add_group(*args, **kwargs)) + return super(Writer, self)._add_group(group_id, name, desc) def rename_group(self, *args): ''' Rename a specified parameter group (see Manager._rename_group for args). ''' - self._rename_group(*args) + super(Writer, self)._rename_group(*args) def remove_group(self, *args): '''Remove the parameter group. (see Manager._rename_group for args). ''' - self._remove_group(*args) + super(Writer, self)._remove_group(*args) def add_frames(self, frames, index=None): '''Add frames to this writer instance. diff --git a/src/group.py b/src/group.py index a732adf..53726f8 100644 --- a/src/group.py +++ b/src/group.py @@ -1,6 +1,6 @@ import struct import numpy as np -from .parameter import ParamData, ParamReadonly, ParamWritable +from .parameter import ParamData, Param from .utils import Decorator class GroupData(object): @@ -85,7 +85,7 @@ def add_param(self, name, **kwargs): name = name.upper() if name in self._params: raise KeyError('Parameter already exists with key {}'.format(name)) - self._params[name] = ParamData(name, self._dtypes, **kwargs) + self._params[name] = Param(ParamData(name, self._dtypes, **kwargs)) def remove_param(self, name): '''Remove the specified parameter. @@ -102,7 +102,7 @@ def rename_param(self, name, new_name): Parameters ---------- - name : str, or 'ParamData' + name : str, or 'Param' Parameter instance, or name. new_name : str New name for the parameter. @@ -116,14 +116,12 @@ def rename_param(self, name, new_name): ''' if new_name in self._params: raise ValueError("Key {} already exist.".format(new_name)) - if isinstance(name, ParamData): + if isinstance(name, Param): param = name name = param.name else: # Aquire instance using id - param = self._params.get(name, None) - if param is None: - raise KeyError('No parameter found matching the identifier: {}'.format(str(name))) + param = self._params[name] del self._params[name] self._params[new_name] = param @@ -145,7 +143,7 @@ def write(self, group_id, handle): handle.write(struct.pack('B', len(desc))) handle.write(desc) for param in self._params.values(): - param.write(group_id, handle) + param._data.write(group_id, handle) class GroupReadonly(object): ''' Handle exposing readable attributes of a GroupData entry. @@ -156,6 +154,9 @@ def __init__(self, data): def __contains__(self, key): return key in self._data._params + def __eq__(self, other): + return self._data is other._data + @property def name(self): ''' Group name. ''' @@ -168,11 +169,11 @@ def desc(self): def items(self): ''' Acquire iterator for paramater key-entry pairs. ''' - return ((k, ParamReadonly(v)) for k, v in self._data._params.items()) + return ((k, v.readonly()) for k, v in self._data._params.items()) def values(self): ''' Acquire iterator for parameter entries. ''' - return (ParamReadonly(v) for v in self._data._params.values()) + return (v.readonly() for v in self._data._params.values()) def keys(self): ''' Acquire iterator for parameter entry keys. ''' @@ -193,9 +194,9 @@ def get(self, key, default=None): param : :class:`ParamReadable` A parameter from the current group. ''' - val = self._data._params.get(key) + val = self._data._params.get(key, default) if val: - return ParamReadonly(val) + return val.readonly() return default def get_int8(self, key): @@ -234,13 +235,13 @@ def get_string(self, key): '''Get the value of the given parameter as a string.''' return self._data[key.upper()].string_value -class GroupWritable(GroupReadonly): +class Group(GroupReadonly): ''' Handle exposing readable and writeable attributes of a GroupData entry. Group instance decorator providing convenience functions for Writer editing. ''' def __init__(self, data): - super(GroupWritable, self).__init__(data) + super(Group, self).__init__(data) def readonly(self): ''' Make access readonly. ''' @@ -280,11 +281,11 @@ def desc(self, value): def items(self): ''' Acquire iterator for paramater key-entry pairs. ''' - return ((k, ParamWritable(v)) for k, v in self._data._params.items()) + return ((k, v) for k, v in self._data._params.items()) def values(self): ''' Acquire iterator for parameter entries. ''' - return (ParamWritable(v) for v in self._data._params.values()) + return (v for v in self._data._params.values()) def get(self, key, default=None): '''Get a parameter by key. @@ -301,10 +302,7 @@ def get(self, key, default=None): param : :class:`ParamReadable` A parameter from the current group. ''' - val = self._data._params.get(key) - if val: - return ParamWritable(val) - return default + return self._data._params.get(key, default) # # Forward param editing # @@ -343,7 +341,7 @@ def rename_param(self, name, new_name): Parameters ---------- - name : str, or 'ParamData' + name : str, or 'Param' Parameter instance, or name. new_name : str New name for the parameter. diff --git a/src/manager.py b/src/manager.py index dd8eefc..2a7f742 100644 --- a/src/manager.py +++ b/src/manager.py @@ -1,7 +1,7 @@ import numpy as np import warnings from src.header import Header -from src.group import GroupData, GroupReadonly, GroupWritable +from src.group import GroupData, GroupReadonly, Group from src.utils import is_integer, is_iterable @@ -34,7 +34,7 @@ def items(self): items : Touple of ((str, :class:`Group`), ...) Python touple containing pairs of name keys and parameter group entries. ''' - return ((k, GroupWritable(v)) for k, v in self._groups.items() if isinstance(k, str)) + return ((k, v) for k, v in self._groups.items() if isinstance(k, str)) def values(self): ''' Acquire iterable over parameter group entries. @@ -44,7 +44,7 @@ def values(self): values : Touple of (:class:`Group`, ...) Python touple containing unique parameter group entries. ''' - return (GroupWritable(v) for k, v in self._groups.items() if isinstance(k, str)) + return (v for k, v in self._groups.items() if isinstance(k, str)) def keys(self): ''' Acquire iterable over parameter group entry string keys. @@ -64,7 +64,7 @@ def listed(self): items : Touple of ((int, :class:`Group`), ...) Sorted python touple containing pairs of numerical keys and parameter group entries. ''' - return sorted((i, GroupWritable(g)) for i, g in self._groups.items() if isinstance(i, int)) + return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int)) def _check_metadata(self): ''' Ensure that the metadata in our file is self-consistent. ''' @@ -117,7 +117,7 @@ def _check_metadata(self): def check_parameters(params): for name in params: - if self._get(name) is None: + if self.get(name) is None: warnings.warn('missing parameter {}'.format(name)) if self.point_used > 0: @@ -129,7 +129,7 @@ def check_parameters(params): else: warnings.warn('No analog data found in file.') - def _add_group(self, group_id, name, desc): + def _add_group(self, group_id, name=None, desc=None): '''Add a new parameter group. Parameters @@ -155,15 +155,17 @@ def _add_group(self, group_id, name, desc): ''' if not is_integer(group_id): raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id)) - if not (isinstance(name, str) or name is None): - raise TypeError('Expected Group name key to be string, was %s.' % type(name)) + if not isinstance(name, str): + if name is not None: + raise TypeError('Expected Group name key to be string, was %s.' % type(name)) + else: + name = name.upper() group_id = int(group_id) # Asserts python int if group_id in self._groups: raise KeyError('Group with numerical key {} already exists'.format(group_id)) - name = name.upper() if name in self._groups: raise KeyError('No group matched name key {}'.format(name)) - group = self._groups[name] = self._groups[group_id] = GroupData(self._dtypes, name, desc) + group = self._groups[name] = self._groups[group_id] = Group(GroupData(self._dtypes, name, desc)) return group def _remove_group(self, group_id): @@ -221,7 +223,7 @@ def _rename_group(self, group_id, new_group_id): # Update self._groups[new_group_id] = grp - def _get(self, group, default=None): + def get(self, group, default=None): '''Get a group or parameter. Parameters @@ -245,7 +247,7 @@ def _get(self, group, default=None): group = self._groups.get(int(group)) if group is None: return default - return GroupWritable(group) + return group group = group.upper() param = None if '.' in group: @@ -254,7 +256,7 @@ def _get(self, group, default=None): group, param = group.split(':', 1) if group not in self._groups: return default - group = GroupWritable(self._groups[group]) + group = self._groups[group] if param is not None: return group.get(param, default) return group @@ -266,7 +268,7 @@ def header(self): def parameter_blocks(self): '''Compute the size (in 512B blocks) of the parameter section.''' - bytes = 4. + sum(g.binary_size for g in self._groups.values()) + bytes = 4. + sum(g._data.binary_size for g in self._groups.values()) return int(np.ceil(bytes / 512)) @property @@ -326,7 +328,7 @@ def analog_sample_count(self): @property def point_labels(self): ''' Labels for each POINT data channel. ''' - return self._get('POINT:LABELS').string_array + return self.get('POINT:LABELS').string_array @property def analog_labels(self): @@ -343,7 +345,7 @@ def first_frame(self): ''' Trial frame corresponding to the first frame recorded in the data. ''' # Start frame seems to be less of an issue to determine. # this is a hack for phasespace files ... should put it in a subclass. - param = self._get('TRIAL:ACTUAL_START_FIELD') + param = self.get('TRIAL:ACTUAL_START_FIELD') if param is not None: # ACTUAL_START_FIELD is encoded in two 16 byte words... words = param.uint16_array @@ -359,17 +361,17 @@ def last_frame(self): # Check different parameter options where the frame can be encoded end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] - param = self._get('TRIAL:ACTUAL_END_FIELD') + param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) words = param.uint16_array end_frame[1] = words[0] + words[1] * 65536 #end_frame[1] = param.uint32_value - param = self._get('POINT:LONG_FRAMES') + param = self.get('POINT:LONG_FRAMES') if param is not None: # Encoded as float end_frame[2] = int(param.float_value) - param = self._get('POINT:FRAMES') + param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint if param.bytes_per_element == 2: @@ -387,8 +389,8 @@ def get_screen_axis(self): value : Touple on form (str, str) or None Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. ''' - X = self._get('POINT:X_SCREEN') - Y = self._get('POINT:Y_SCREEN') + X = self.get('POINT:X_SCREEN') + Y = self.get('POINT:Y_SCREEN') if X and Y: return (X.string_value, Y.string_value) return None @@ -397,17 +399,17 @@ def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets analog_offsets = np.zeros((self.analog_used), int) - param = self._get('ANALOG:OFFSET') + param = self.get('ANALOG:OFFSET') if param is not None and param.num_elements > 0: analog_offsets[:] = param.int16_array[:self.analog_used] # Scale factors analog_scales = np.ones((self.analog_used), float) gen_scale = 1. - param = self._get('ANALOG:GEN_SCALE') + param = self.get('ANALOG:GEN_SCALE') if param is not None: gen_scale = param.float_value - param = self._get('ANALOG:SCALE') + param = self.get('ANALOG:SCALE') if param is not None and param.num_elements > 0: analog_scales[:] = param.float_array[:self.analog_used] @@ -424,36 +426,36 @@ def get_analog_transform(self): def get_int8(self, key): '''Get a parameter value as an 8-bit signed integer.''' - return self._get(key).int8_value + return self.get(key).int8_value def get_uint8(self, key): '''Get a parameter value as an 8-bit unsigned integer.''' - return self._get(key).uint8_value + return self.get(key).uint8_value def get_int16(self, key): '''Get a parameter value as a 16-bit signed integer.''' - return self._get(key).int16_value + return self.get(key).int16_value def get_uint16(self, key): '''Get a parameter value as a 16-bit unsigned integer.''' - return self._get(key).uint16_value + return self.get(key).uint16_value def get_int32(self, key): '''Get a parameter value as a 32-bit signed integer.''' - return self._get(key).int32_value + return self.get(key).int32_value def get_uint32(self, key): '''Get a parameter value as a 32-bit unsigned integer.''' - return self._get(key).uint32_value + return self.get(key).uint32_value def get_float(self, key): '''Get a parameter value as a 32-bit float.''' - return self._get(key).float_value + return self.get(key).float_value def get_bytes(self, key): '''Get a parameter value as a byte string.''' - return self._get(key).bytes_value + return self.get(key).bytes_value def get_string(self, key): '''Get a parameter value as a string.''' - return self._get(key).string_value + return self.get(key).string_value diff --git a/src/parameter.py b/src/parameter.py index aba932e..30ffa34 100644 --- a/src/parameter.py +++ b/src/parameter.py @@ -150,6 +150,9 @@ class ParamReadonly(object): def __init__(self, data): self._data = data + def __eq__(self, other): + return self._data is other._data + @property def name(self): ''' Name string. ''' @@ -395,10 +398,10 @@ def _as_integer_value(self): return self.uint16_value else: return self.uint8_value - -class ParamWritable(ParamReadonly): + +class Param(ParamReadonly): def __init__(self, data): - super(ParamWritable, self).__init__(data) + super(Param, self).__init__(data) def readonly(self): ''' Readonly ''' diff --git a/test/test_group_accessors.py b/test/test_group_accessors.py index b4a49cd..8d92531 100644 --- a/test/test_group_accessors.py +++ b/test/test_group_accessors.py @@ -64,7 +64,7 @@ def assert_group_items(self): for i, (n, g), (n2, g2) in zip(enumerator, sorted(self.s_grp_items), sorted(self.group_items)): assert n == n2, 'Group numeric id missmatch after changes for entry %i. ' % i +\ 'Initially %i, after change entry was %i' % (n, n2) - assert g._data == g2._data, 'Group listed order changed for entry %i.' % i + assert g == g2, 'Group listed order changed for entry %i.' % i def assert_group_list(self): '''Assert all numerical (int, Group) pairs persisted after change.''' @@ -72,7 +72,7 @@ def assert_group_list(self): for i, (n, g), (n2, g2) in zip(enumerator, self.s_grp_list, self.group_listed): assert n == n2, 'Group string id missmatch after changes for entry %i. ' % i +\ 'Initially %i, after change entry was %i' % (n, n2) - assert g._data == g2._data, 'Group listed order changed for entry %i.' % i + assert g == g2, 'Group listed order changed for entry %i.' % i def verify_add_group(self, N): '''Add N groups and verify count at each iteration.''' @@ -184,7 +184,7 @@ def test_Manager_rename_group(self): grp2 = writer.get(test_name) assert grp2 is not None, "Rename failed, group with name '%s' does not exist." - assert grp._data == grp2._data, 'Rename failed, group acquired from new name is not identical.' + assert grp == grp2, 'Rename failed, group acquired from new name is not identical.' ref.assert_entry_count() ref.assert_group_list() @@ -212,7 +212,7 @@ def test_Manager_renumber_group(self): grp2 = writer.get(test_num) assert grp2 is not None, "Rename failed, group with name '%s' does not exist." - assert grp._data == grp2._data, 'Rename failed, group acquired from new name is not identical.' + assert grp == grp2, 'Rename failed, group acquired from new name is not identical.' ref.assert_entry_count() ref.assert_group_items() diff --git a/test/test_parameter_accessors.py b/test/test_parameter_accessors.py index dedd16a..c6ff0fb 100644 --- a/test/test_parameter_accessors.py +++ b/test/test_parameter_accessors.py @@ -2,7 +2,7 @@ ''' import unittest import c3d -from src.group import GroupReadonly, GroupWritable +from src.group import Group import numpy as np import test.verify as verify from test.zipload import Zipload @@ -17,8 +17,8 @@ def add_dummy_param(group, name='TEST_NAME', shape=(10, 2), flt_range=(-1e6, 1e6 class ParamSample(): ''' Helper object to verify parameter entries persist or terminate properly. ''' def __init__(self, group): - assert isinstance(group, GroupWritable), \ - 'Must pass GroupWritable to ParamSample instance, was %s' % type(group) + assert isinstance(group, Group), \ + 'Must pass Group to ParamSample instance, was %s' % type(group) self.group = group self.sample() @@ -56,7 +56,7 @@ def assert_group_items(self, ignore=None): if n == ignore: continue g2 = self.group.get(n) - assert g._data == g2._data, 'Group listed order changed for entry %i.' % i + assert g == g2, 'Group listed order changed for entry %i.' % i def verify_add_parameter(self, N): '''Add N parameters and verify count at each iteration.''' @@ -142,7 +142,7 @@ def test_Group_rename_param(self): g.rename_param(key, nname) prm2 = g.get(nname) assert prm2 is not None, "Rename failed, renamed param does not exist." - assert prm._data == prm2._data, 'Rename failed, param acquired from new name is not identical.' + assert prm == prm2, 'Rename failed, param acquired from new name is not identical.' ref.assert_entry_count() try: From c2779f467deb388bb1445745a2350169319ded50 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 11:54:29 +0200 Subject: [PATCH 045/120] Parameter.uint_value, Parameter.int_value --- src/group.py | 4 ++-- src/parameter.py | 44 ++++++++++++++++++++++++++++++++++++-------- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/src/group.py b/src/group.py index 53726f8..ec975b5 100644 --- a/src/group.py +++ b/src/group.py @@ -356,7 +356,7 @@ def rename_param(self, name, new_name): self._data.rename_param(name, new_name) # - # Add decorator functions (throws on overwrite) + # Add convenience functions (throws on overwrite) # def add(self, name, desc, bpe, format, data, *dimensions): ''' Add a parameter with 'data' package formated in accordance with 'format'. @@ -409,7 +409,7 @@ def add_empty_array(self, name, desc, bpe): bytes_per_element=bpe, dimensions=[0]) # - # Set decorator functions (overwrite) + # Set convenience functions (overwrite) # def set(self, name, *args, **kwargs): ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'. diff --git a/src/parameter.py b/src/parameter.py index 30ffa34..d66ab23 100644 --- a/src/parameter.py +++ b/src/parameter.py @@ -221,13 +221,43 @@ def uint32_value(self): '''Get the param as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) + @property + def uint_value(self): + ''' Get the param as a unsigned integer of appropriate type. + ''' + if self.total_bytes >= 4: + return self.uint32_value + elif self.total_bytes >= 2: + return self.uint16_value + else: + return self.uint8_value + + @property + def int_value(self): + ''' Get the param as a signed integer of appropriate type. + ''' + if self.total_bytes >= 4: + return self.int32_value + elif self.total_bytes >= 2: + return self.int16_value + else: + return self.int8_value + @property def float_value(self): - '''Get the param as a 32-bit float.''' - if self.dtypes.is_dec: - return DEC_to_IEEE(self._data._as(np.uint32)) - else: # is_mips or is_ieee - return self._data._as(self.dtypes.float32) + '''Get the param as a floating point value of appropriate type.''' + if self.total_bytes > 4: + if self.dtypes.is_dec: + raise AttributeError("64 bit DEC floating point is not supported.") + # 64-bit floating point is not a standard + return self._data._as(self.dtypes.float64) + elif self.total_bytes == 4: + if self.dtypes.is_dec: + return DEC_to_IEEE(self._data._as(np.uint32)) + else: # is_mips or is_ieee + return self._data._as(self.dtypes.float32) + else: + raise AttributeError("Only 32 and 64 bit floating point is supported.") @property def bytes_value(self): @@ -382,11 +412,9 @@ def string_array(self): return byte_arr @property - def _as_integer_value(self): + def _as_integer_or_float_value(self): ''' Get the param as either 32 bit float or unsigned integer. Evaluates if an integer is stored as a floating point representation. - - Note: This is implemented purely for parsing start/end frames. ''' if self.total_bytes >= 4: # Check if float value representation is an integer From 64c1947675d34ce83714adbaec3033345fb815d7 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 11:57:05 +0200 Subject: [PATCH 046/120] .py --- scripts/{c3d-metadata => c3d-metadata.py} | 0 scripts/{c3d-viewer => c3d-viewer.py} | 0 scripts/{c3d2npz => c3d2npz.py} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename scripts/{c3d-metadata => c3d-metadata.py} (100%) mode change 100755 => 100644 rename scripts/{c3d-viewer => c3d-viewer.py} (100%) mode change 100755 => 100644 rename scripts/{c3d2npz => c3d2npz.py} (100%) mode change 100755 => 100644 diff --git a/scripts/c3d-metadata b/scripts/c3d-metadata.py old mode 100755 new mode 100644 similarity index 100% rename from scripts/c3d-metadata rename to scripts/c3d-metadata.py diff --git a/scripts/c3d-viewer b/scripts/c3d-viewer.py old mode 100755 new mode 100644 similarity index 100% rename from scripts/c3d-viewer rename to scripts/c3d-viewer.py diff --git a/scripts/c3d2npz b/scripts/c3d2npz.py old mode 100755 new mode 100644 similarity index 100% rename from scripts/c3d2npz rename to scripts/c3d2npz.py From 3839ad6003497dd5ede4c49a4fc8c8daa25da56a Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 12:17:48 +0200 Subject: [PATCH 047/120] Looped viewer --- scripts/c3d-viewer.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/scripts/c3d-viewer.py b/scripts/c3d-viewer.py index 5be64b1..b215e36 100644 --- a/scripts/c3d-viewer.py +++ b/scripts/c3d-viewer.py @@ -1,8 +1,13 @@ #!/usr/bin/env python '''A simple OpenGL viewer for C3D files.''' - -import c3d +try: + import c3d +except ModuleNotFoundError: + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d import argparse import collections import contextlib @@ -90,7 +95,8 @@ def __init__(self, c3d_reader, trace=None, paused=False): super(Viewer, self).__init__( width=800, height=450, resizable=True, vsync=False, config=config) - self._frames = c3d_reader.read_frames(copy=False) + self.c3d_reader = c3d_reader + self._frames = iter(()) self._frame_rate = c3d_reader.header.frame_rate self._maxlen = 16 @@ -221,6 +227,8 @@ def _next_frame(self): try: return next(self._frames) except StopIteration: + self._frames = self.c3d_reader.read_frames(copy=False) + return self._next_frame() pyglet.app.exit() def update(self, dt): From 2ce5afaf85cd2ce8a2a73404d559e4c597516e7c Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 12:24:17 +0200 Subject: [PATCH 048/120] Script readme --- scripts/README.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 scripts/README.rst diff --git a/scripts/README.rst b/scripts/README.rst new file mode 100644 index 0000000..c693686 --- /dev/null +++ b/scripts/README.rst @@ -0,0 +1,18 @@ +Viewer +~~~~~ + +A + +Requirements :: + + pip install pyglet + +Run (from script directory) :: + + py .\c3d-viewer.py 'path-to-c3d-file' + +Commands + + Esc - Terminate + Space - Pause + Mouse - Orientate view From 1426b6f2bb702ddec45e64f581a0ac23396f6f21 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 12:25:56 +0200 Subject: [PATCH 049/120] readme --- scripts/README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/README.rst b/scripts/README.rst index c693686..1aaed4a 100644 --- a/scripts/README.rst +++ b/scripts/README.rst @@ -1,7 +1,7 @@ Viewer ~~~~~ -A +Simple 3D-viewer for .c3d data. Requirements :: @@ -13,6 +13,6 @@ Run (from script directory) :: Commands - Esc - Terminate - Space - Pause - Mouse - Orientate view + Esc : Terminate + Space : Pause + Mouse : Orientate view From 7be54d3c0a7328b1b613c8942257631bf19804d5 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sat, 3 Jul 2021 15:52:31 +0200 Subject: [PATCH 050/120] Update README.rst --- scripts/README.rst | 61 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/scripts/README.rst b/scripts/README.rst index 1aaed4a..5236271 100644 --- a/scripts/README.rst +++ b/scripts/README.rst @@ -1,18 +1,67 @@ -Viewer +Scripts are installed with the package and can be executed from a suitable command prompt by:: + + script-name.py 'path-to-c3d-file' + +or from the script directory:: + + py .\script-name.py 'path-to-c3d-file' -options + +The first option only works on Windows if .py files are associated with the python executable (`this answer`_ might help if its not working). + +.. _this answer: https://stackoverflow.com/questions/1934675/how-to-execute-python-scripts-in-windows + + +CSV converter +~~~~~ + +Generate a .csv file from frame data contained in a .c3d file. + +Invoke as:: + + c3d2csv.py 'path-to-c3d-file' -options + +Commandline options + + -r : Output column with residual values after point x,y,z coordinate columns. + -c : Output column with camera counts as the last point coordinate column. + -a : Output analog channel values after point coordinates. + -e : Endline string appended after each record (defaults to '\n'). + -s : Separator string inserted between records (defaults to ','). + + +NPZ converter +~~~~~ + +Generate a .npz file from frame data (point and analog) contained in a .c3d file. + +Invoke as:: + + c3d2npz.py 'path-to-c3d-file' + +C3D Viewer ~~~~~ -Simple 3D-viewer for .c3d data. +Simple 3D-viewer for displaying .c3d data. Requirements :: pip install pyglet -Run (from script directory) :: +Invoke as:: - py .\c3d-viewer.py 'path-to-c3d-file' + c3d-viewer.py 'path-to-c3d-file' -Commands +Interaction commands :: - Esc : Terminate + Esc : Terminate Space : Pause Mouse : Orientate view + +Metadata viewer +~~~~~ + +Prints metadata for a .c3d file to console. + +Invoke as:: + + c3d-metadata.py 'path-to-c3d-file' From dbacfffd42d9a270d004b556a8ff61be026a8553 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sat, 3 Jul 2021 15:53:34 +0200 Subject: [PATCH 051/120] Update README.rst --- scripts/README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/README.rst b/scripts/README.rst index 5236271..7bfe662 100644 --- a/scripts/README.rst +++ b/scripts/README.rst @@ -20,13 +20,13 @@ Invoke as:: c3d2csv.py 'path-to-c3d-file' -options -Commandline options +Commandline options :: -r : Output column with residual values after point x,y,z coordinate columns. -c : Output column with camera counts as the last point coordinate column. -a : Output analog channel values after point coordinates. - -e : Endline string appended after each record (defaults to '\n'). - -s : Separator string inserted between records (defaults to ','). + -e : Endline string appended after each record (defaults to '\n'). + -s : Separator string inserted between records (defaults to ','). NPZ converter From 0043b42bc68b47c6bd8cddb8fa19e1b124259045 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 15:54:42 +0200 Subject: [PATCH 052/120] Package re-organization for setup.py --- __init__.py | 0 c3d/__init__.py | 8 + {src => c3d}/dtypes.py | 0 {src => c3d}/group.py | 0 {src => c3d}/header.py | 0 {src => c3d}/manager.py | 6 +- {src => c3d}/parameter.py | 2 +- c3d/reader.py | 344 ++++++++++++++++++++++++ {src => c3d}/utils.py | 0 c3d.py => c3d/writer.py | 344 +----------------------- scripts/c3d-metadata.py | 15 +- scripts/c3d2csv.py | 16 +- scripts/c3d2npz.py | 13 +- setup.py | 4 +- src/__init__.py | 0 test/test_parameter_accessors.py | 2 +- test/test_parameter_bytes_conversion.py | 4 +- 17 files changed, 395 insertions(+), 363 deletions(-) delete mode 100644 __init__.py create mode 100644 c3d/__init__.py rename {src => c3d}/dtypes.py (100%) rename {src => c3d}/group.py (100%) rename {src => c3d}/header.py (100%) rename {src => c3d}/manager.py (99%) rename {src => c3d}/parameter.py (99%) create mode 100644 c3d/reader.py rename {src => c3d}/utils.py (100%) rename c3d.py => c3d/writer.py (53%) delete mode 100644 src/__init__.py diff --git a/__init__.py b/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/c3d/__init__.py b/c3d/__init__.py new file mode 100644 index 0000000..3a4dfbc --- /dev/null +++ b/c3d/__init__.py @@ -0,0 +1,8 @@ +from . import dtypes +from . import group +from . import header +from . import manager +from . import parameter +from . import utils +from .reader import Reader +from .writer import Writer diff --git a/src/dtypes.py b/c3d/dtypes.py similarity index 100% rename from src/dtypes.py rename to c3d/dtypes.py diff --git a/src/group.py b/c3d/group.py similarity index 100% rename from src/group.py rename to c3d/group.py diff --git a/src/header.py b/c3d/header.py similarity index 100% rename from src/header.py rename to c3d/header.py diff --git a/src/manager.py b/c3d/manager.py similarity index 99% rename from src/manager.py rename to c3d/manager.py index 2a7f742..74ce113 100644 --- a/src/manager.py +++ b/c3d/manager.py @@ -1,8 +1,8 @@ import numpy as np import warnings -from src.header import Header -from src.group import GroupData, GroupReadonly, Group -from src.utils import is_integer, is_iterable +from .header import Header +from .group import GroupData, GroupReadonly, Group +from .utils import is_integer, is_iterable class Manager(object): diff --git a/src/parameter.py b/c3d/parameter.py similarity index 99% rename from src/parameter.py rename to c3d/parameter.py index d66ab23..6a65da5 100644 --- a/src/parameter.py +++ b/c3d/parameter.py @@ -262,7 +262,7 @@ def float_value(self): @property def bytes_value(self): '''Get the param as a raw byte string.''' - return self._data.bytes.copy() + return self._data.bytes @property def string_value(self): diff --git a/c3d/reader.py b/c3d/reader.py new file mode 100644 index 0000000..26b8335 --- /dev/null +++ b/c3d/reader.py @@ -0,0 +1,344 @@ +'''A Python module for reading and writing C3D files.''' + +import io +import numpy as np +import struct +import warnings +from .manager import Manager +from .header import Header +from .dtypes import DataTypes +from .utils import DEC_to_IEEE_BYTES + + +class Reader(Manager): + '''This class provides methods for reading the data in a C3D file. + + A C3D file contains metadata and frame-based data describing 3D motion. + + You can iterate over the frames in the file by calling `read_frames()` after + construction: + + >>> r = c3d.Reader(open('capture.c3d', 'rb')) + >>> for frame_no, points, analog in r.read_frames(): + ... print('{0.shape} points in this frame'.format(points)) + ''' + + def __init__(self, handle): + '''Initialize this C3D file by reading header and parameter data. + + Parameters + ---------- + handle : file handle + Read metadata and C3D motion frames from the given file handle. This + handle is assumed to be `seek`-able and `read`-able. The handle must + remain open for the life of the `Reader` instance. The `Reader` does + not `close` the handle. + + Raises + ------ + ValueError + If the processor metadata in the C3D file is anything other than 84 + (Intel format). + ''' + super(Reader, self).__init__(Header(handle)) + + self._handle = handle + + def seek_param_section_header(): + ''' Seek to and read the first 4 byte of the parameter header section ''' + self._handle.seek((self._header.parameter_block - 1) * 512) + # metadata header + return self._handle.read(4) + + # Begin by reading the processor type: + buf = seek_param_section_header() + _, _, parameter_blocks, processor = struct.unpack('BBBB', buf) + self._dtypes = DataTypes(processor) + # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) + self._header._processor_convert(self._dtypes, handle) + + # Restart reading the parameter header after parsing processor type + buf = seek_param_section_header() + + start_byte = self._handle.tell() + endbyte = start_byte + 512 * parameter_blocks - 4 + while self._handle.tell() < endbyte: + chars_in_name, group_id = struct.unpack('bb', self._handle.read(2)) + if group_id == 0 or chars_in_name == 0: + # we've reached the end of the parameter section. + break + name = self._dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper() + + # Read the byte segment associated with the parameter and create a + # separate binary stream object from the data. + offset_to_next, = struct.unpack(['h'][self._dtypes.is_mips], self._handle.read(2)) + if offset_to_next == 0: + # Last parameter, as number of bytes are unknown, + # read the remaining bytes in the parameter section. + bytes = self._handle.read(endbyte - self._handle.tell()) + else: + bytes = self._handle.read(offset_to_next - 2) + buf = io.BytesIO(bytes) + + if group_id > 0: + # We've just started reading a parameter. If its group doesn't + # exist, create a blank one. add the parameter to the group. + group = super(Reader, self).get(group_id) + if group is None: + group = self._add_group(group_id) + group.add_param(name, handle=buf) + else: + # We've just started reading a group. If a group with the + # appropriate numerical id exists already (because we've + # already created it for a parameter), just set the name of + # the group. Otherwise, add a new group. + group_id = abs(group_id) + size, = struct.unpack('B', buf.read(1)) + desc = size and buf.read(size) or '' + group = super(Reader, self).get(group_id) + if group is not None: + self._rename_group(group, name) # Inserts name key + group.desc = desc + else: + self._add_group(group_id, name, desc) + + self._check_metadata() + + def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False): + '''Iterate over the data frames from our C3D file handle. + + Parameters + ---------- + copy : bool + If False, the reader returns a reference to the same data buffers + for every frame. The default is True, which causes the reader to + return a unique data buffer for each frame. Set this to False if you + consume frames as you iterate over them, or True if you store them + for later. + + Returns + ------- + frames : sequence of (frame number, points, analog) + This method generates a sequence of (frame number, points, analog) + tuples, one tuple per frame. The first element of each tuple is the + frame number. The second is a numpy array of parsed, 5D point data + and the third element of each tuple is a numpy array of analog + values that were recorded during the frame. (Often the analog data + are sampled at a higher frequency than the 3D point data, resulting + in multiple analog frames per frame of point data.) + + The first three columns in the returned point data are the (x, y, z) + coordinates of the observed motion capture point. The fourth column + is an estimate of the error for this particular point, and the fifth + column is the number of cameras that observed the point in question. + Both the fourth and fifth values are -1 if the point is considered + to be invalid. + ''' + # Point magnitude scalar, if scale parameter is < 0 data is floating point + # (in which case the magnitude is the absolute value) + scale_mag = abs(self.point_scale) + is_float = self.point_scale < 0 + + if is_float: + point_word_bytes = 4 + else: + point_word_bytes = 2 + points = np.zeros((self.point_used, 5), np.float32) + + # TODO: handle ANALOG:BITS parameter here! + p = self.get('ANALOG:FORMAT') + analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED' + if is_float: + analog_dtype = self._dtypes.float32 + analog_word_bytes = 4 + elif analog_unsigned: + # Note*: Floating point is 'always' defined for both analog and point data, according to the standard. + analog_dtype = self._dtypes.uint16 + analog_word_bytes = 2 + # Verify BITS parameter for analog + p = self.get('ANALOG:BITS') + if p and p._as_integer_value / 8 != analog_word_bytes: + raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value)) + else: + analog_dtype = self._dtypes.int16 + analog_word_bytes = 2 + + analog = np.array([], float) + analog_scales, analog_offsets = self.get_analog_transform() + + # Seek to the start point of the data blocks + self._handle.seek((self._header.data_block - 1) * 512) + # Number of values (words) read in regard to POINT/ANALOG data + N_point = 4 * self.point_used + N_analog = self.analog_used * self.analog_per_frame + + # Total bytes per frame + point_bytes = N_point * point_word_bytes + analog_bytes = N_analog * analog_word_bytes + # Parse the data blocks + for frame_no in range(self.first_frame, self.last_frame + 1): + # Read the byte data (used) for the block + raw_bytes = self._handle.read(N_point * point_word_bytes) + raw_analog = self._handle.read(N_analog * analog_word_bytes) + # Verify read pointers (any of the two can be assumed to be 0) + if len(raw_bytes) < point_bytes: + warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {} + and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell())) + return + if len(raw_analog) < analog_bytes: + warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {} + and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell())) + return + + if is_float: + # Convert every 4 byte words to a float-32 reprensentation + # (the fourth column is still not a float32 representation) + if self._dtypes.is_dec: + # Convert each of the first 6 16-bit words from DEC to IEEE float + points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4)) + else: # If IEEE or MIPS: + # Convert each of the first 6 16-bit words to native float + points[:, :4] = np.frombuffer(raw_bytes, + dtype=self._dtypes.float32, + count=N_point).reshape((self.point_used, 4)) + + # Cast last word to signed integer in system endian format + last_word = points[:, 3].astype(np.int32) + + else: + # View the bytes as signed 16-bit integers + raw = np.frombuffer(raw_bytes, + dtype=self._dtypes.int16, + count=N_point).reshape((self.point_used, 4)) + # Read the first six 16-bit words as x, y, z coordinates + points[:, :3] = raw[:, :3] * scale_mag + # Cast last word to signed integer in system endian format + last_word = raw[:, 3].astype(np.int16) + + # Parse camera-observed bits and residuals. + # Notes: + # - Invalid sample if residual is equal to -1 (check if word < 0). + # - A residual of 0.0 represent modeled data (filtered or interpolated). + # - Camera and residual words are always 8-bit (1 byte), never 16-bit. + # - If floating point, the byte words are encoded in an integer cast to a float, + # and are written directly in byte form (see the MLS guide). + ## + # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded). + residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8 + + # Fourth value is floating-point (scaled) error estimate (residual) + points[:, 3] = residual_byte * scale_mag + + # Determine invalid samples + invalid = last_word < 0 + if check_nan: + is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) + points[is_nan, :3] = 0.0 + invalid &= is_nan + # Update discarded - sign + points[invalid, 3] = -1 + + + # Fifth value is the camera-observation byte + if camera_sum: + # Convert to observation sum + points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(8)) + else: + points[:, 4] = camera_byte #.astype(np.float32) + + # Check if analog data exist, and parse if so + if N_analog > 0: + if is_float and self._dtypes.is_dec: + # Convert each of the 16-bit words from DEC to IEEE float + analog = DEC_to_IEEE_BYTES(raw_analog) + else: + # Integer or INTEL/MIPS floating point data can be parsed directly + analog = np.frombuffer(raw_analog, dtype=analog_dtype, count=N_analog) + + # Reformat and convert + analog = analog.reshape((-1, self.analog_used)).T + analog = analog.astype(float) + # Convert analog + analog = (analog - analog_offsets) * analog_scales + + # Output buffers + if copy: + yield frame_no, points.copy(), analog # .copy(), a new array is generated per frame for analog data. + else: + yield frame_no, points, analog + + # Function evaluating EOF, note that data section is written in blocks of 512 + final_byte_index = self._handle.tell() + self._handle.seek(0, 2) # os.SEEK_END) + # Check if more then 1 block remain + if self._handle.tell() - final_byte_index >= 512: + warnings.warn('incomplete reading of data blocks. {} bytes remained after all datablocks were read!'.format( + self._handle.tell() - final_byte_index)) + + @property + def proc_type(self): + '''Get the processory type associated with the data format in the file. + ''' + return self._dtypes.proc_type + + def to_writer(self, conversion=None): + ''' Convert to 'Writer' using the conversion mode. + See Writer.from_reader() for supported conversion modes and possible exceptions. + ''' + from .writer import Writer + return Writer.from_reader(self, conversion=conversion) + + def get(self, key, default=None): + '''Get a readonly group or parameter. + + Parameters + ---------- + key : str + If this string contains a period (.), then the part before the + period will be used to retrieve a group, and the part after the + period will be used to retrieve a parameter from that group. If this + string does not contain a period, then just a group will be + returned. + default : any + Return this value if the named group and parameter are not found. + + Returns + ------- + value : :class:`GroupReadonly` or :class:`Param` + Either a group or parameter with the specified name(s). If neither + is found, returns the default value. + ''' + val = super(Reader, self).get(key) + if val: + return val.readonly() + return default + + def items(self): + ''' Acquire iterable over parameter group pairs. + + Returns + ------- + items : Touple of ((str, :class:`GroupReadonly`), ...) + Python touple containing pairs of name keys and parameter group entries. + ''' + return ((k, v.readonly()) for k, v in super(Reader, self).items()) + + def values(self): + ''' Acquire iterable over parameter group entries. + + Returns + ------- + values : Touple of (:class:`GroupReadonly`, ...) + Python touple containing unique parameter group entries. + ''' + return (v.readonly() for k, v in super(Reader, self).items()) + + def listed(self): + ''' Acquire iterable over parameter group entries. + + Returns + ------- + items : Touple of ((int, :class:`GroupReadonly`), ...) + Python touple containing unique parameter group entries. + ''' + return ((k, v.readonly()) for k, v in super(Reader, self).listed()) diff --git a/src/utils.py b/c3d/utils.py similarity index 100% rename from src/utils.py rename to c3d/utils.py diff --git a/c3d.py b/c3d/writer.py similarity index 53% rename from c3d.py rename to c3d/writer.py index b5d721e..bb0c173 100644 --- a/c3d.py +++ b/c3d/writer.py @@ -1,350 +1,14 @@ '''A Python module for reading and writing C3D files.''' -from __future__ import unicode_literals -import sys -import io import copy import numpy as np import struct -import warnings -from src.manager import Manager -from src.header import Header -from src.dtypes import DataTypes -from src.utils import is_integer, is_iterable, DEC_to_IEEE_BYTES +#import warnings +from .manager import Manager +from .dtypes import DataTypes +from .utils import is_integer, is_iterable -class Reader(Manager): - '''This class provides methods for reading the data in a C3D file. - - A C3D file contains metadata and frame-based data describing 3D motion. - - You can iterate over the frames in the file by calling `read_frames()` after - construction: - - >>> r = c3d.Reader(open('capture.c3d', 'rb')) - >>> for frame_no, points, analog in r.read_frames(): - ... print('{0.shape} points in this frame'.format(points)) - ''' - - def __init__(self, handle): - '''Initialize this C3D file by reading header and parameter data. - - Parameters - ---------- - handle : file handle - Read metadata and C3D motion frames from the given file handle. This - handle is assumed to be `seek`-able and `read`-able. The handle must - remain open for the life of the `Reader` instance. The `Reader` does - not `close` the handle. - - Raises - ------ - ValueError - If the processor metadata in the C3D file is anything other than 84 - (Intel format). - ''' - super(Reader, self).__init__(Header(handle)) - - self._handle = handle - - def seek_param_section_header(): - ''' Seek to and read the first 4 byte of the parameter header section ''' - self._handle.seek((self._header.parameter_block - 1) * 512) - # metadata header - return self._handle.read(4) - - # Begin by reading the processor type: - buf = seek_param_section_header() - _, _, parameter_blocks, processor = struct.unpack('BBBB', buf) - self._dtypes = DataTypes(processor) - # Convert header parameters in accordance with the processor type (MIPS format re-reads the header) - self._header._processor_convert(self._dtypes, handle) - - # Restart reading the parameter header after parsing processor type - buf = seek_param_section_header() - - start_byte = self._handle.tell() - endbyte = start_byte + 512 * parameter_blocks - 4 - while self._handle.tell() < endbyte: - chars_in_name, group_id = struct.unpack('bb', self._handle.read(2)) - if group_id == 0 or chars_in_name == 0: - # we've reached the end of the parameter section. - break - name = self._dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper() - - # Read the byte segment associated with the parameter and create a - # separate binary stream object from the data. - offset_to_next, = struct.unpack(['h'][self._dtypes.is_mips], self._handle.read(2)) - if offset_to_next == 0: - # Last parameter, as number of bytes are unknown, - # read the remaining bytes in the parameter section. - bytes = self._handle.read(endbyte - self._handle.tell()) - else: - bytes = self._handle.read(offset_to_next - 2) - buf = io.BytesIO(bytes) - - if group_id > 0: - # We've just started reading a parameter. If its group doesn't - # exist, create a blank one. add the parameter to the group. - group = super(Reader, self).get(group_id) - if group is None: - group = self._add_group(group_id) - group.add_param(name, handle=buf) - else: - # We've just started reading a group. If a group with the - # appropriate numerical id exists already (because we've - # already created it for a parameter), just set the name of - # the group. Otherwise, add a new group. - group_id = abs(group_id) - size, = struct.unpack('B', buf.read(1)) - desc = size and buf.read(size) or '' - group = super(Reader, self).get(group_id) - if group is not None: - self._rename_group(group, name) # Inserts name key - group.desc = desc - else: - self._add_group(group_id, name, desc) - - self._check_metadata() - - def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False): - '''Iterate over the data frames from our C3D file handle. - - Parameters - ---------- - copy : bool - If False, the reader returns a reference to the same data buffers - for every frame. The default is True, which causes the reader to - return a unique data buffer for each frame. Set this to False if you - consume frames as you iterate over them, or True if you store them - for later. - - Returns - ------- - frames : sequence of (frame number, points, analog) - This method generates a sequence of (frame number, points, analog) - tuples, one tuple per frame. The first element of each tuple is the - frame number. The second is a numpy array of parsed, 5D point data - and the third element of each tuple is a numpy array of analog - values that were recorded during the frame. (Often the analog data - are sampled at a higher frequency than the 3D point data, resulting - in multiple analog frames per frame of point data.) - - The first three columns in the returned point data are the (x, y, z) - coordinates of the observed motion capture point. The fourth column - is an estimate of the error for this particular point, and the fifth - column is the number of cameras that observed the point in question. - Both the fourth and fifth values are -1 if the point is considered - to be invalid. - ''' - # Point magnitude scalar, if scale parameter is < 0 data is floating point - # (in which case the magnitude is the absolute value) - scale_mag = abs(self.point_scale) - is_float = self.point_scale < 0 - - if is_float: - point_word_bytes = 4 - else: - point_word_bytes = 2 - points = np.zeros((self.point_used, 5), np.float32) - - # TODO: handle ANALOG:BITS parameter here! - p = self.get('ANALOG:FORMAT') - analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED' - if is_float: - analog_dtype = self._dtypes.float32 - analog_word_bytes = 4 - elif analog_unsigned: - # Note*: Floating point is 'always' defined for both analog and point data, according to the standard. - analog_dtype = self._dtypes.uint16 - analog_word_bytes = 2 - # Verify BITS parameter for analog - p = self.get('ANALOG:BITS') - if p and p._as_integer_value / 8 != analog_word_bytes: - raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value)) - else: - analog_dtype = self._dtypes.int16 - analog_word_bytes = 2 - - analog = np.array([], float) - analog_scales, analog_offsets = self.get_analog_transform() - - # Seek to the start point of the data blocks - self._handle.seek((self._header.data_block - 1) * 512) - # Number of values (words) read in regard to POINT/ANALOG data - N_point = 4 * self.point_used - N_analog = self.analog_used * self.analog_per_frame - - # Total bytes per frame - point_bytes = N_point * point_word_bytes - analog_bytes = N_analog * analog_word_bytes - # Parse the data blocks - for frame_no in range(self.first_frame, self.last_frame + 1): - # Read the byte data (used) for the block - raw_bytes = self._handle.read(N_point * point_word_bytes) - raw_analog = self._handle.read(N_analog * analog_word_bytes) - # Verify read pointers (any of the two can be assumed to be 0) - if len(raw_bytes) < point_bytes: - warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {} - and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell())) - return - if len(raw_analog) < analog_bytes: - warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {} - and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell())) - return - - if is_float: - # Convert every 4 byte words to a float-32 reprensentation - # (the fourth column is still not a float32 representation) - if self._dtypes.is_dec: - # Convert each of the first 6 16-bit words from DEC to IEEE float - points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4)) - else: # If IEEE or MIPS: - # Convert each of the first 6 16-bit words to native float - points[:, :4] = np.frombuffer(raw_bytes, - dtype=self._dtypes.float32, - count=N_point).reshape((self.point_used, 4)) - - # Cast last word to signed integer in system endian format - last_word = points[:, 3].astype(np.int32) - - else: - # View the bytes as signed 16-bit integers - raw = np.frombuffer(raw_bytes, - dtype=self._dtypes.int16, - count=N_point).reshape((self.point_used, 4)) - # Read the first six 16-bit words as x, y, z coordinates - points[:, :3] = raw[:, :3] * scale_mag - # Cast last word to signed integer in system endian format - last_word = raw[:, 3].astype(np.int16) - - # Parse camera-observed bits and residuals. - # Notes: - # - Invalid sample if residual is equal to -1 (check if word < 0). - # - A residual of 0.0 represent modeled data (filtered or interpolated). - # - Camera and residual words are always 8-bit (1 byte), never 16-bit. - # - If floating point, the byte words are encoded in an integer cast to a float, - # and are written directly in byte form (see the MLS guide). - ## - # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded). - residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8 - - # Fourth value is floating-point (scaled) error estimate (residual) - points[:, 3] = residual_byte * scale_mag - - # Determine invalid samples - invalid = last_word < 0 - if check_nan: - is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) - points[is_nan, :3] = 0.0 - invalid &= is_nan - # Update discarded - sign - points[invalid, 3] = -1 - - - # Fifth value is the camera-observation byte - if camera_sum: - # Convert to observation sum - points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(8)) - else: - points[:, 4] = camera_byte #.astype(np.float32) - - # Check if analog data exist, and parse if so - if N_analog > 0: - if is_float and self._dtypes.is_dec: - # Convert each of the 16-bit words from DEC to IEEE float - analog = DEC_to_IEEE_BYTES(raw_analog) - else: - # Integer or INTEL/MIPS floating point data can be parsed directly - analog = np.frombuffer(raw_analog, dtype=analog_dtype, count=N_analog) - - # Reformat and convert - analog = analog.reshape((-1, self.analog_used)).T - analog = analog.astype(float) - # Convert analog - analog = (analog - analog_offsets) * analog_scales - - # Output buffers - if copy: - yield frame_no, points.copy(), analog # .copy(), a new array is generated per frame for analog data. - else: - yield frame_no, points, analog - - # Function evaluating EOF, note that data section is written in blocks of 512 - final_byte_index = self._handle.tell() - self._handle.seek(0, 2) # os.SEEK_END) - # Check if more then 1 block remain - if self._handle.tell() - final_byte_index >= 512: - warnings.warn('incomplete reading of data blocks. {} bytes remained after all datablocks were read!'.format( - self._handle.tell() - final_byte_index)) - - @property - def proc_type(self): - '''Get the processory type associated with the data format in the file. - ''' - return self._dtypes.proc_type - - def to_writer(self, conversion=None): - ''' Convert to 'Writer' using the conversion mode. - See Writer.from_reader() for supported conversion modes and possible exceptions. - ''' - return Writer.from_reader(self, conversion=conversion) - - def get(self, key, default=None): - '''Get a readonly group or parameter. - - Parameters - ---------- - key : str - If this string contains a period (.), then the part before the - period will be used to retrieve a group, and the part after the - period will be used to retrieve a parameter from that group. If this - string does not contain a period, then just a group will be - returned. - default : any - Return this value if the named group and parameter are not found. - - Returns - ------- - value : :class:`GroupReadonly` or :class:`Param` - Either a group or parameter with the specified name(s). If neither - is found, returns the default value. - ''' - val = super(Reader, self).get(key) - if val: - return val.readonly() - return default - - def items(self): - ''' Acquire iterable over parameter group pairs. - - Returns - ------- - items : Touple of ((str, :class:`GroupReadonly`), ...) - Python touple containing pairs of name keys and parameter group entries. - ''' - return ((k, v.readonly()) for k, v in super(Reader, self).items()) - - def values(self): - ''' Acquire iterable over parameter group entries. - - Returns - ------- - values : Touple of (:class:`GroupReadonly`, ...) - Python touple containing unique parameter group entries. - ''' - return (v.readonly() for k, v in super(Reader, self).items()) - - def listed(self): - ''' Acquire iterable over parameter group entries. - - Returns - ------- - items : Touple of ((int, :class:`GroupReadonly`), ...) - Python touple containing unique parameter group entries. - ''' - return ((k, v.readonly()) for k, v in super(Reader, self).listed()) - class Writer(Manager): '''This class writes metadata and frames to a C3D file. diff --git a/scripts/c3d-metadata.py b/scripts/c3d-metadata.py index 5b25eac..cb054f6 100644 --- a/scripts/c3d-metadata.py +++ b/scripts/c3d-metadata.py @@ -4,9 +4,14 @@ from __future__ import print_function -import c3d import argparse import sys +try: + import c3d +except ModuleNotFoundError: + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d parser = argparse.ArgumentParser(description='Display C3D group and parameter information.') parser.add_argument('input', default='-', metavar='FILE', nargs='+', @@ -15,9 +20,9 @@ def print_metadata(reader): print('Header information:\n{}'.format(reader.header)) - for key, g in sorted(reader.group_items()): + for key, g in sorted(reader.items()): print('') - for key, p in sorted(g.param_items()): + for key, p in sorted(g.items()): print_param(g, p) @@ -26,7 +31,7 @@ def print_param(g, p): if len(p.dimensions) == 0: val = None - width = len(p.bytes) + width = p.total_bytes if width == 2: val = p.int16_value elif width == 4: @@ -51,7 +56,7 @@ def print_param(g, p): C, R = p.dimensions for r in range(R): print('{0.name}.{1.name}[{2}] = {3}'.format( - g, p, r, repr(p.bytes[r * C:(r+1) * C]))) + g, p, r, repr(p.bytes_value[r * C:(r+1) * C]))) def main(args): diff --git a/scripts/c3d2csv.py b/scripts/c3d2csv.py index 128b01a..b15966f 100644 --- a/scripts/c3d2csv.py +++ b/scripts/c3d2csv.py @@ -4,14 +4,19 @@ from __future__ import print_function -import c3d import sys import argparse +try: + import c3d +except ModuleNotFoundError: + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d parser = argparse.ArgumentParser(description='Convert a C3D file to CSV (text) format.') -parser.add_argument('-a', '--include-analog', action='store_true', help='output analog values after point positions') -parser.add_argument('-c', '--include-camera', action='store_true', help='output camera count with each point position') -parser.add_argument('-r', '--include-error', action='store_true', help='output error value with each point position') +parser.add_argument('-a', '--include-analog', action='store_true', help='Output analog channel values after point coordinates.') +parser.add_argument('-c', '--include-camera', action='store_true', help='Output column with camera counts as the last point coordinate column.') +parser.add_argument('-r', '--include-error', action='store_true', help='Output column with residual values after point x,y,z coordinate columns.') parser.add_argument('-e', '--end', default='\\n', metavar='K', help='write K between records') parser.add_argument('-s', '--sep', default=',', metavar='C', help='write C between fields in a record') parser.add_argument('input', default='-', metavar='FILE', nargs='+', help='process data from this input FILE') @@ -24,8 +29,9 @@ def convert(filename, args, sep, end): if open_file_streams: input = open(filename, 'rb') output = open(filename.replace('.c3d', '.csv'), 'w') + try: - for frame_no, points, analog in c3d.Reader(input).read_frames(copy=False): + for frame_no, points, analog in c3d.Reader(input).read_frames(copy=False, camera_sum=True): fields = [frame_no] for x, y, z, err, cam in points: fields.append(str(x)) diff --git a/scripts/c3d2npz.py b/scripts/c3d2npz.py index d2a7e79..4629b3c 100644 --- a/scripts/c3d2npz.py +++ b/scripts/c3d2npz.py @@ -9,8 +9,13 @@ import logging import gzip import numpy as np -import sys from tempfile import TemporaryFile +try: + import c3d +except ModuleNotFoundError: + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d parser = argparse.ArgumentParser(description='Convert a C3D file to NPZ (numpy binary) format.') parser.add_argument('input', default='-', metavar='FILE', nargs='+', help='process data from this input FILE') @@ -22,7 +27,7 @@ def convert(filename, args): if filename != '-': input = open(filename, 'rb') outname = filename.replace('.c3d', '.npz') - + points = [] analog = [] for i, (_, p, a) in enumerate(c3d.Reader(input).read_frames()): @@ -32,10 +37,10 @@ def convert(filename, args): logging.debug('%s: extracted %d point frames', outname, len(points)) np.savez(outname, points=points, analog=analog) - print(outname + ': saved', len(points), "x", str(points[0].shape), "points,", + print(outname + ': saved', len(points), "x", str(points[0].shape), "points,", len(analog), analog[0].shape, 'analog' if len(analog) else () ) - + if filename != '-': input.close() diff --git a/setup.py b/setup.py index 5f847ab..3e19414 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ setuptools.setup( name='c3d', version='0.3.0', - py_modules=['c3d'], + py_modules=['c3d.dtypes', 'c3d.group', 'c3d.header', 'c3d.manager', 'c3d.reader', 'c3d.writer', 'c3d.parameter', 'c3d.utils', 'scripts.c3d-viewer'], author='UT Vision, Cognition, and Action Lab', author_email='leif@cs.utexas.edu', description='A library for manipulating C3D binary files', @@ -13,7 +13,7 @@ url='http://github.com/EmbodiedCognition/py-c3d', keywords=('c3d motion-capture'), install_requires=['numpy'], - scripts=['scripts/c3d{}'.format(s) for s in '-metadata -viewer 2csv 2npz'.split()], + scripts=['scripts/c3d{}.py'.format(s) for s in '-metadata -viewer 2csv 2npz'.split()], classifiers=[ 'Development Status :: 4 - Beta', 'Intended Audience :: Science/Research', diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/test/test_parameter_accessors.py b/test/test_parameter_accessors.py index c6ff0fb..1be26ae 100644 --- a/test/test_parameter_accessors.py +++ b/test/test_parameter_accessors.py @@ -2,7 +2,7 @@ ''' import unittest import c3d -from src.group import Group +from c3d.group import Group import numpy as np import test.verify as verify from test.zipload import Zipload diff --git a/test/test_parameter_bytes_conversion.py b/test/test_parameter_bytes_conversion.py index db3466e..3525ccf 100644 --- a/test/test_parameter_bytes_conversion.py +++ b/test/test_parameter_bytes_conversion.py @@ -1,8 +1,8 @@ import struct import unittest import numpy as np -from src.dtypes import DataTypes, PROCESSOR_INTEL -from src.parameter import ParamData, ParamReadonly +from c3d.dtypes import DataTypes, PROCESSOR_INTEL +from c3d.parameter import ParamData, ParamReadonly def genByteWordArr(word, shape): From 5ccf0b8bc3721af24b30a4014d4701cf83f2f967 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sat, 3 Jul 2021 16:00:16 +0200 Subject: [PATCH 053/120] Update README.rst script link --- README.rst | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/README.rst b/README.rst index aefb9c6..b836757 100644 --- a/README.rst +++ b/README.rst @@ -25,10 +25,13 @@ Usage Tools ~~~~~ -This package includes a script for converting C3D motion data to CSV format +This package includes scripts_ for converting C3D motion data to CSV format (``c3d2csv``) and an OpenGL-based visualization tool for observing the motion described by a C3D file (``c3d-viewer``). +.. _package documentation: http://c3d.readthedocs.org +.. _scripts: ./scripts + Library ~~~~~~~ From 8c07bf762ba047f1b28f1946b2f50217fed3c2ad Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 16:01:22 +0200 Subject: [PATCH 054/120] New version number --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 3e19414..42bae93 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setuptools.setup( name='c3d', - version='0.3.0', + version='0.5.0', py_modules=['c3d.dtypes', 'c3d.group', 'c3d.header', 'c3d.manager', 'c3d.reader', 'c3d.writer', 'c3d.parameter', 'c3d.utils', 'scripts.c3d-viewer'], author='UT Vision, Cognition, and Action Lab', author_email='leif@cs.utexas.edu', From bdbbe0496e501cb66f4486b85bf55f6d8a56785c Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 21:16:22 +0200 Subject: [PATCH 055/120] =?UTF-8?q?Improved=20mod=C3=BAle,=20class,=20and?= =?UTF-8?q?=20function=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- c3d/dtypes.py | 24 +++--- c3d/group.py | 200 +++++++++++++++++++++++++---------------------- c3d/header.py | 3 + c3d/manager.py | 65 ++++++--------- c3d/parameter.py | 47 ++++++----- c3d/reader.py | 35 +++------ c3d/utils.py | 2 + c3d/writer.py | 50 +++++++++--- test/test_c3d.py | 9 ++- 9 files changed, 231 insertions(+), 204 deletions(-) diff --git a/c3d/dtypes.py b/c3d/dtypes.py index e327b27..9071d0d 100644 --- a/c3d/dtypes.py +++ b/c3d/dtypes.py @@ -1,3 +1,7 @@ +''' +State object defining the data types associated with a given .c3d processor format. +''' + import sys import codecs import numpy as np @@ -7,7 +11,7 @@ PROCESSOR_MIPS = 86 class DataTypes(object): - ''' Container defining different data types used for reading file data. + ''' Container defining the data types used when parsing byte data. Data types depend on the processor format the file is stored in. ''' def __init__(self, proc_type=PROCESSOR_INTEL): @@ -44,55 +48,55 @@ def __init__(self, proc_type=PROCESSOR_INTEL): self.int64 = np.int64 @property - def is_ieee(self): + def is_ieee(self) -> bool: ''' True if the associated file is in the Intel format. ''' return self._proc_type == PROCESSOR_INTEL @property - def is_dec(self): + def is_dec(self) -> bool: ''' True if the associated file is in the DEC format. ''' return self._proc_type == PROCESSOR_DEC @property - def is_mips(self): + def is_mips(self) -> bool: ''' True if the associated file is in the SGI/MIPS format. ''' return self._proc_type == PROCESSOR_MIPS @property - def proc_type(self): + def proc_type(self) -> str: ''' Get the processory type associated with the data format in the file. ''' processor_type = ['INTEL', 'DEC', 'MIPS'] return processor_type[self._proc_type - PROCESSOR_INTEL] @property - def processor(self): + def processor(self) -> int: ''' Get the processor number encoded in the .c3d file. ''' return self._proc_type @property - def native(self): + def native(self) -> bool: ''' True if the native (system) byte order matches the file byte order. ''' return self._native @property - def little_endian_sys(self): + def little_endian_sys(self) -> bool: ''' True if native byte order is little-endian. ''' return self._little_endian_sys @property - def big_endian_sys(self): + def big_endian_sys(self) -> bool: ''' True if native byte order is big-endian. ''' return not self._little_endian_sys - def decode_string(self, bytes): + def decode_string(self, bytes) -> str: ''' Decode a byte array to a string. ''' # Attempt to decode using different decoders diff --git a/c3d/group.py b/c3d/group.py index ec975b5..46b539f 100644 --- a/c3d/group.py +++ b/c3d/group.py @@ -1,17 +1,20 @@ +''' Classes used to represent the concept of parameter groups in a .c3d file. +''' import struct import numpy as np from .parameter import ParamData, Param from .utils import Decorator class GroupData(object): - '''A group of parameters from a C3D file. + '''A group of parameters stored in a C3D file. - In C3D files, parameters are organized in groups. Each group has a name, a - description, and a set of named parameters. + In C3D files, parameters are organized in groups. Each group has a name (key), a + description, and a set of named parameters. Each group is also internally associated + with a numeric key. Attributes ---------- - dtypes : 'DataTypes' + dtypes : `c3d.dtypes.DataTypes` Data types object used for parsing. name : str Name of this parameter group. @@ -36,7 +39,7 @@ def __getitem__(self, key): return self._params[key] @property - def binary_size(self): + def binary_size(self) -> int: '''Return the number of bytes to store this group and its parameters.''' return ( 1 + # group_id @@ -45,22 +48,22 @@ def binary_size(self): 1 + len(self.desc.encode('utf-8')) + # size of desc and desc bytes sum(p.binary_size for p in self._params.values())) - def set_name(self, value): + def set_name(self, name): ''' Set the group name string. ''' - if value is None or isinstance(value, str): - self.name = value + if name is None or isinstance(name, str): + self.name = name else: - raise TypeError('Expected group name to be string, was %s.' % type(value)) + raise TypeError('Expected group name to be string, was %s.' % type(name)) - def set_desc(self, value): + def set_desc(self, desc): ''' Set the Group descriptor. ''' - if isinstance(value, bytes): - self.desc = self._dtypes.decode_string(value) - elif isinstance(value, str) or value is None: - self.desc = value + if isinstance(desc, bytes): + self.desc = self._dtypes.decode_string(desc) + elif isinstance(desc, str) or desc is None: + self.desc = desc else: - raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(value)) + raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(desc)) def add_param(self, name, **kwargs): '''Add a parameter to this group. @@ -71,7 +74,7 @@ def add_param(self, name, **kwargs): Name of the parameter to add to this group. The name will automatically be case-normalized. - Additional keyword arguments will be passed to the `ParamData` constructor. + See constructor of `c3d.parameter.ParamData` for additional keyword arguments. Raises ------ @@ -102,11 +105,10 @@ def rename_param(self, name, new_name): Parameters ---------- - name : str, or 'Param' + name : str, or `c3d.group.GroupReadonly` Parameter instance, or name. new_name : str New name for the parameter. - Raises ------ KeyError @@ -146,7 +148,7 @@ def write(self, group_id, handle): param._data.write(group_id, handle) class GroupReadonly(object): - ''' Handle exposing readable attributes of a GroupData entry. + ''' Wrapper exposing readonly attributes of a `c3d.group.GroupData` entry. ''' def __init__(self, data): self._data = data @@ -158,25 +160,25 @@ def __eq__(self, other): return self._data is other._data @property - def name(self): - ''' Group name. ''' + def name(self) -> str: + ''' Access group name. ''' return self._data.name @property - def desc(self): - ''' Group descriptor. ''' + def desc(self) -> str: + '''Access group descriptor. ''' return self._data.desc def items(self): - ''' Acquire iterator for paramater key-entry pairs. ''' + ''' Get iterator for paramater key-entry pairs. ''' return ((k, v.readonly()) for k, v in self._data._params.items()) def values(self): - ''' Acquire iterator for parameter entries. ''' + ''' Get iterator for parameter entries. ''' return (v.readonly() for v in self._data._params.values()) def keys(self): - ''' Acquire iterator for parameter entry keys. ''' + ''' Get iterator for parameter entry keys. ''' return self._data._params.keys() def get(self, key, default=None): @@ -236,55 +238,39 @@ def get_string(self, key): return self._data[key.upper()].string_value class Group(GroupReadonly): - ''' Handle exposing readable and writeable attributes of a GroupData entry. - - Group instance decorator providing convenience functions for Writer editing. + ''' Wrapper exposing readable and writeable attributes of a `c3d.group.GroupData` entry. ''' def __init__(self, data): super(Group, self).__init__(data) def readonly(self): - ''' Make access readonly. ''' + ''' Returns a `c3d.group.GroupReadonly` instance with readonly access. ''' return GroupReadonly(self._data) @property - def name(self): - ''' Group name. ''' + def name(self) -> str: + ''' Get or set name. ''' return self._data.name @name.setter - def name(self, value): - ''' Group name string. - - Parameters - ---------- - value : str - New name for the group. - ''' + def name(self, value) -> str: self._data.set_name(value) @property - def desc(self): - ''' Group descriptor. ''' + def desc(self) -> str: + ''' Get or set descriptor. ''' return self._data.desc @desc.setter - def desc(self, value): - ''' Group descriptor. - - Parameters - ---------- - value : str, or bytes - New description for this parameter group. - ''' + def desc(self, value) -> str: self._data.set_desc(value) def items(self): - ''' Acquire iterator for paramater key-entry pairs. ''' + ''' Iterator for paramater key-entry pairs. ''' return ((k, v) for k, v in self._data._params.items()) def values(self): - ''' Acquire iterator for parameter entries. ''' + ''' Iterator iterator for parameter entries. ''' return (v for v in self._data._params.values()) def get(self, key, default=None): @@ -309,20 +295,7 @@ def get(self, key, default=None): def add_param(self, name, **kwargs): '''Add a parameter to this group. - Parameters - ---------- - name : str - Name of the parameter to add to this group. The name will - automatically be case-normalized. - - Additional keyword arguments will be passed to the `ParamData` constructor. - - Raises - ------ - TypeError - Input arguments are of the wrong type. - KeyError - Name or numerical key already exist (attempt to overwrite existing data). + See constructor of `c3d.parameter.ParamData` for additional keyword arguments. ''' self._data.add_param(name, **kwargs) @@ -341,25 +314,35 @@ def rename_param(self, name, new_name): Parameters ---------- - name : str, or 'Param' - Parameter instance, or name. - new_name : str - New name for the parameter. - - Raises - ------ - KeyError - If no parameter with the original name exists. - ValueError - If the new name already exist (attempt to overwrite existing data). + See arguments in `c3d.group.GroupData.rename_param`. ''' self._data.rename_param(name, new_name) # - # Add convenience functions (throws on overwrite) + # Convenience functions for adding parameters. # def add(self, name, desc, bpe, format, data, *dimensions): - ''' Add a parameter with 'data' package formated in accordance with 'format'. + ''' Add a parameter with `data` package formated in accordance with `format`. + + Convenience function for `c3d.group.GroupData.add_param` calling struct.pack() on `data`. + + Example: + + >>> group.set('RATE', 'Point data sample rate', 4, ' '`c3d.header.Header`': ''' Access to .c3d header data. ''' return self._header - def parameter_blocks(self): + def parameter_blocks(self) -> int: '''Compute the size (in 512B blocks) of the parameter section.''' bytes = 4. + sum(g._data.binary_size for g in self._groups.values()) return int(np.ceil(bytes / 512)) @property - def point_rate(self): + def point_rate(self) -> float: ''' Number of sampled 3D coordinates per second. ''' try: return self.get_float('POINT:RATE') @@ -280,7 +261,7 @@ def point_rate(self): return self.header.frame_rate @property - def point_scale(self): + def point_scale(self) -> float: ''' Scaling applied to non-float data. ''' try: return self.get_float('POINT:SCALE') @@ -288,7 +269,7 @@ def point_scale(self): return self.header.scale_factor @property - def point_used(self): + def point_used(self) -> int: ''' Number of sampled 3D point coordinates per frame. ''' try: return self.get_uint16('POINT:USED') @@ -296,7 +277,7 @@ def point_used(self): return self.header.point_count @property - def analog_used(self): + def analog_used(self) -> int: ''' Number of analog measurements, or channels, for each analog data sample. ''' try: return self.get_uint16('ANALOG:USED') @@ -307,7 +288,7 @@ def analog_used(self): return 0 @property - def analog_rate(self): + def analog_rate(self) -> float: ''' Number of analog data samples per second. ''' try: return self.get_float('ANALOG:RATE') @@ -315,33 +296,33 @@ def analog_rate(self): return self.header.analog_per_frame * self.point_rate @property - def analog_per_frame(self): + def analog_per_frame(self) -> int: ''' Number of analog frames per 3D frame (point sample). ''' return int(self.analog_rate / self.point_rate) @property - def analog_sample_count(self): + def analog_sample_count(self) -> int: ''' Number of analog samples per channel. ''' has_analog = self.analog_used > 0 return int(self.frame_count * self.analog_per_frame) * has_analog @property - def point_labels(self): + def point_labels(self) -> list: ''' Labels for each POINT data channel. ''' return self.get('POINT:LABELS').string_array @property - def analog_labels(self): + def analog_labels(self) -> list: ''' Labels for each ANALOG data channel. ''' return self.get('ANALOG:LABELS').string_array @property - def frame_count(self): + def frame_count(self) -> int: ''' Number of frames recorded in the data. ''' return self.last_frame - self.first_frame + 1 # Add 1 since range is inclusive [first, last] @property - def first_frame(self): + def first_frame(self) -> int: ''' Trial frame corresponding to the first frame recorded in the data. ''' # Start frame seems to be less of an issue to determine. # this is a hack for phasespace files ... should put it in a subclass. @@ -353,7 +334,7 @@ def first_frame(self): return self.header.first_frame @property - def last_frame(self): + def last_frame(self) -> int: ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' # Number of frames can be represented in many formats, first check if valid header values if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: @@ -386,7 +367,7 @@ def get_screen_axis(self): Returns ------- - value : Touple on form (str, str) or None + value : (str, str) or None Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. ''' X = self.get('POINT:X_SCREEN') diff --git a/c3d/parameter.py b/c3d/parameter.py index 6a65da5..542d6c6 100644 --- a/c3d/parameter.py +++ b/c3d/parameter.py @@ -1,3 +1,5 @@ +''' Classes used to represent the concept of a parameter in a .c3d file. +''' import struct import numpy as np from .utils import DEC_to_IEEE, DEC_to_IEEE_BYTES @@ -18,7 +20,7 @@ class ParamData(object): string data (including arrays of strings), this should be -1. dimensions : list of int For array data, this describes the dimensions of the array, stored in - column-major order. For arrays of strings, the dimensions here will be + column-major (Fortran) order. For arrays of strings, the dimensions here will be the number of columns (length of each string) followed by the number of rows (number of strings). bytes : str @@ -47,7 +49,7 @@ def __repr__(self): return ''.format(self.desc) @property - def num_elements(self): + def num_elements(self) -> int: '''Return the number of elements in this parameter's array value.''' e = 1 for d in self.dimensions: @@ -55,12 +57,12 @@ def num_elements(self): return e @property - def total_bytes(self): + def total_bytes(self) -> int: '''Return the number of bytes used for storing this parameter's data.''' return self.num_elements * abs(self.bytes_per_element) @property - def binary_size(self): + def binary_size(self) -> int: '''Return the number of bytes needed to store this parameter.''' return ( 1 + # group_id @@ -146,6 +148,8 @@ def _as_any(self, dtype): return data class ParamReadonly(object): + ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry. + ''' def __init__(self, data): self._data = data @@ -154,40 +158,42 @@ def __eq__(self, other): return self._data is other._data @property - def name(self): - ''' Name string. ''' + def name(self) -> str: + ''' Get the parameter name. ''' return self._data.name @property - def desc(self): - ''' Descriptor string. ''' + def desc(self) -> str: + ''' Get the parameter descriptor. ''' return self._data.desc @property def dtypes(self): + ''' Convenience accessor to the `c3d.dtypes.DataTypes` instance associated with the parameter. ''' return self._data.dtypes @property - def dimensions(self): + def dimensions(self) -> (int, ...): + ''' Shape of the parameter data (Fortran shape). ''' return self._data.dimensions @property - def num_elements(self): + def num_elements(self) -> int: '''Return the number of elements in this parameter's array value.''' return self._data.num_elements @property - def bytes_per_element(self): + def bytes_per_element(self) -> int: '''Return the number of bytes used to store each data element.''' return self._data.bytes_per_element @property - def total_bytes(self): + def total_bytes(self) -> int: '''Return the number of bytes used for storing this parameter's data.''' return self._data.total_bytes @property - def binary_size(self): + def binary_size(self) -> int: '''Return the number of bytes needed to store this parameter.''' return self._data.binary_size @@ -223,8 +229,7 @@ def uint32_value(self): @property def uint_value(self): - ''' Get the param as a unsigned integer of appropriate type. - ''' + ''' Get the param as a unsigned integer of appropriate type. ''' if self.total_bytes >= 4: return self.uint32_value elif self.total_bytes >= 2: @@ -234,8 +239,7 @@ def uint_value(self): @property def int_value(self): - ''' Get the param as a signed integer of appropriate type. - ''' + ''' Get the param as a signed integer of appropriate type. ''' if self.total_bytes >= 4: return self.int32_value elif self.total_bytes >= 2: @@ -428,19 +432,20 @@ def _as_integer_or_float_value(self): return self.uint8_value class Param(ParamReadonly): + ''' Wrapper exposing both readable and writable attributes of a `c3d.parameter.ParamData` entry. + ''' def __init__(self, data): super(Param, self).__init__(data) def readonly(self): - ''' Readonly ''' + ''' Returns a readonly `c3d.parameter.ParamReadonly` instance. ''' return ParamReadonly(self._data) @property - def bytes(self): - ''' Raw access to parameter bytes. ''' + def bytes(self) -> bytes: + ''' Get or set the parameter bytes. ''' return self._data.bytes @bytes.setter def bytes(self, value): - ''' Set parameter bytes. ''' self._data.bytes = value diff --git a/c3d/reader.py b/c3d/reader.py index 26b8335..d5abca4 100644 --- a/c3d/reader.py +++ b/c3d/reader.py @@ -36,9 +36,8 @@ def __init__(self, handle): Raises ------ - ValueError - If the processor metadata in the C3D file is anything other than 84 - (Intel format). + AssertionError + If the metadata in the C3D file is inconsistent. ''' super(Reader, self).__init__(Header(handle)) @@ -276,14 +275,15 @@ def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_s self._handle.tell() - final_byte_index)) @property - def proc_type(self): + def proc_type(self) -> int: '''Get the processory type associated with the data format in the file. ''' return self._dtypes.proc_type def to_writer(self, conversion=None): - ''' Convert to 'Writer' using the conversion mode. - See Writer.from_reader() for supported conversion modes and possible exceptions. + ''' Converts the reader to a `c3d.writer.Writer` instance using the conversion mode. + + See `c3d.writer.Writer.from_reader()` for supported conversion modes. ''' from .writer import Writer return Writer.from_reader(self, conversion=conversion) @@ -304,7 +304,7 @@ def get(self, key, default=None): Returns ------- - value : :class:`GroupReadonly` or :class:`Param` + value : `c3d.group.GroupReadonly` or `c3d.parameter.ParamReadonly` Either a group or parameter with the specified name(s). If neither is found, returns the default value. ''' @@ -314,31 +314,16 @@ def get(self, key, default=None): return default def items(self): - ''' Acquire iterable over parameter group pairs. - - Returns - ------- - items : Touple of ((str, :class:`GroupReadonly`), ...) - Python touple containing pairs of name keys and parameter group entries. + ''' Get iterable over pairs of (str, `c3d.group.GroupReadonly`) entries. ''' return ((k, v.readonly()) for k, v in super(Reader, self).items()) def values(self): - ''' Acquire iterable over parameter group entries. - - Returns - ------- - values : Touple of (:class:`GroupReadonly`, ...) - Python touple containing unique parameter group entries. + ''' Get iterable over `c3d.group.GroupReadonly` entries. ''' return (v.readonly() for k, v in super(Reader, self).items()) def listed(self): - ''' Acquire iterable over parameter group entries. - - Returns - ------- - items : Touple of ((int, :class:`GroupReadonly`), ...) - Python touple containing unique parameter group entries. + ''' Get iterable over pairs of (int, `c3d.group.GroupReadonly`) entries. ''' return ((k, v.readonly()) for k, v in super(Reader, self).listed()) diff --git a/c3d/utils.py b/c3d/utils.py index 5d4c8c3..0c28231 100644 --- a/c3d/utils.py +++ b/c3d/utils.py @@ -1,3 +1,5 @@ +''' Trailing utility functions. +''' import numpy as np import struct diff --git a/c3d/writer.py b/c3d/writer.py index bb0c173..2029f1e 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -40,7 +40,7 @@ def __init__(self, point_rate=480., analog_rate=0., point_scale=-1.): - '''Set metadata for this writer. + '''Set minimal metadata for this writer. ''' self._dtypes = DataTypes() # Only support INTEL format from writing @@ -55,7 +55,7 @@ def __init__(self, @staticmethod def from_reader(reader, conversion=None): ''' - source : 'class' Manager + source : `c3d.manager.Manager` Source to copy. conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: @@ -216,7 +216,7 @@ def add_frames(self, frames, index=None): if sh[1] != 2: raise ValueError( 'Expected frame input to be sequence of point and analog pairs on form (-1, 2). ' + - '\Input was of shape {}.'.format(str(sh))) + 'Input was of shape {}.'.format(str(sh))) if index is not None: self._frames[index:index] = frames @@ -225,6 +225,32 @@ def add_frames(self, frames, index=None): @staticmethod def pack_labels(labels): + ''' Static method used to pack and pad the set of `labels` strings before + passing the output into a `c3d.group.Group.add_str`. + + Parameters + ---------- + labels : iterable + List of strings to pack and pad into a single string suitable for encoding in a Parameter entry. + + Example + ------- + >>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3'] + >>> param_str, label_max_size = Writer.pack_labels(labels) + >>> writer.point_group.add_str('LABELS', + 'Point labels.', + label_str, + label_max_size, + len(labels)) + + Returns + ------- + param_str : str + String containing `labels` packed into a single variable where + each string is padded to match the longest `labels` string. + label_max_size : int + Number of bytes associated with the longest `label` string, all strings are padded to this length. + ''' labels = np.ravel(labels) # Get longest label name label_max_size = 0 @@ -319,8 +345,8 @@ def set_screen_axis(self, X='+X', Y='+Y'): if len(Y) != 2: raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter.') group = self.point_group - group.set_str('X_SCREEN', 'X_SCREEN parameter', X, 2) - group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y, 2) + group.set_str('X_SCREEN', 'X_SCREEN parameter', X) + group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y) def write(self, handle): '''Write metadata and point + analog frames to a file handle. @@ -353,23 +379,23 @@ def write(self, handle): group.set('FRAMES', 'Total frame count', 2, '= UINT16_MAX: # Should be floating point - group.set('LONG_FRAMES', 'Total frame count', 4, ' Date: Sat, 3 Jul 2021 21:45:56 +0200 Subject: [PATCH 056/120] Module documentation edit --- c3d/header.py | 2 +- c3d/manager.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/c3d/header.py b/c3d/header.py index 2f1df87..25a3718 100644 --- a/c3d/header.py +++ b/c3d/header.py @@ -1,5 +1,5 @@ ''' -Defines the header class used for reading, writing and tracking the metadata in the header of a .c3d file. +Defines the header class used for reading, writing and tracking metadata in the .c3d header. ''' import sys import struct diff --git a/c3d/manager.py b/c3d/manager.py index 3779f34..13ebb16 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -1,4 +1,4 @@ -''' Defines the base class containing common attributes for both the Reader and Writer instances. +''' Manager base class defining common attributes for both the Reader and Writer instances. ''' import numpy as np import warnings From c0ec8d67dbcf127045dad56063bbb27bce66c756 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 21:46:33 +0200 Subject: [PATCH 057/120] Removed the sphinx-better-theme documentation --- docs/Makefile | 153 -------------------------------- docs/_static/style-tweaks.css | 1 - docs/_templates/gitwidgets.html | 4 - docs/conf.py | 62 ------------- docs/index.rst | 66 -------------- docs/reference.rst | 13 --- docs/requirements.txt | 2 - 7 files changed, 301 deletions(-) delete mode 100644 docs/Makefile delete mode 100644 docs/_static/style-tweaks.css delete mode 100644 docs/_templates/gitwidgets.html delete mode 100644 docs/conf.py delete mode 100644 docs/index.rst delete mode 100644 docs/reference.rst delete mode 100644 docs/requirements.txt diff --git a/docs/Makefile b/docs/Makefile deleted file mode 100644 index 7a05fd9..0000000 --- a/docs/Makefile +++ /dev/null @@ -1,153 +0,0 @@ -# Makefile for Sphinx documentation -# - -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -PAPER = -BUILDDIR = _build - -# Internal variables. -PAPEROPT_a4 = -D latex_paper_size=a4 -PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . -# the i18n builder cannot share the environment and doctrees with the others -I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . - -.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext - -help: - @echo "Please use \`make ' where is one of" - @echo " html to make standalone HTML files" - @echo " dirhtml to make HTML files named index.html in directories" - @echo " singlehtml to make a single large HTML file" - @echo " pickle to make pickle files" - @echo " json to make JSON files" - @echo " htmlhelp to make HTML files and a HTML help project" - @echo " qthelp to make HTML files and a qthelp project" - @echo " devhelp to make HTML files and a Devhelp project" - @echo " epub to make an epub" - @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" - @echo " latexpdf to make LaTeX files and run them through pdflatex" - @echo " text to make text files" - @echo " man to make manual pages" - @echo " texinfo to make Texinfo files" - @echo " info to make Texinfo files and run them through makeinfo" - @echo " gettext to make PO message catalogs" - @echo " changes to make an overview of all changed/added/deprecated items" - @echo " linkcheck to check all external links for integrity" - @echo " doctest to run all doctests embedded in the documentation (if enabled)" - -clean: - -rm -rf $(BUILDDIR)/* - -html: - $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." - -dirhtml: - $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml - @echo - @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." - -singlehtml: - $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml - @echo - @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." - -pickle: - $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle - @echo - @echo "Build finished; now you can process the pickle files." - -json: - $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json - @echo - @echo "Build finished; now you can process the JSON files." - -htmlhelp: - $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp - @echo - @echo "Build finished; now you can run HTML Help Workshop with the" \ - ".hhp project file in $(BUILDDIR)/htmlhelp." - -qthelp: - $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp - @echo - @echo "Build finished; now you can run "qcollectiongenerator" with the" \ - ".qhcp project file in $(BUILDDIR)/qthelp, like this:" - @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/c3d.qhcp" - @echo "To view the help file:" - @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/c3d.qhc" - -devhelp: - $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp - @echo - @echo "Build finished." - @echo "To view the help file:" - @echo "# mkdir -p $$HOME/.local/share/devhelp/c3d" - @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/c3d" - @echo "# devhelp" - -epub: - $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub - @echo - @echo "Build finished. The epub file is in $(BUILDDIR)/epub." - -latex: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo - @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." - @echo "Run \`make' in that directory to run these through (pdf)latex" \ - "(use \`make latexpdf' here to do that automatically)." - -latexpdf: - $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex - @echo "Running LaTeX files through pdflatex..." - $(MAKE) -C $(BUILDDIR)/latex all-pdf - @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." - -text: - $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text - @echo - @echo "Build finished. The text files are in $(BUILDDIR)/text." - -man: - $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man - @echo - @echo "Build finished. The manual pages are in $(BUILDDIR)/man." - -texinfo: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo - @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." - @echo "Run \`make' in that directory to run these through makeinfo" \ - "(use \`make info' here to do that automatically)." - -info: - $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo - @echo "Running Texinfo files through makeinfo..." - make -C $(BUILDDIR)/texinfo info - @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." - -gettext: - $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale - @echo - @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." - -changes: - $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes - @echo - @echo "The overview file is in $(BUILDDIR)/changes." - -linkcheck: - $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck - @echo - @echo "Link check complete; look for any errors in the above output " \ - "or in $(BUILDDIR)/linkcheck/output.txt." - -doctest: - $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest - @echo "Testing of doctests in the sources finished, look at the " \ - "results in $(BUILDDIR)/doctest/output.txt." diff --git a/docs/_static/style-tweaks.css b/docs/_static/style-tweaks.css deleted file mode 100644 index 825e6f1..0000000 --- a/docs/_static/style-tweaks.css +++ /dev/null @@ -1 +0,0 @@ -a tt, a:visited tt, a:active tt { color: #369; } diff --git a/docs/_templates/gitwidgets.html b/docs/_templates/gitwidgets.html deleted file mode 100644 index ccc4c9d..0000000 --- a/docs/_templates/gitwidgets.html +++ /dev/null @@ -1,4 +0,0 @@ -
- - -
diff --git a/docs/conf.py b/docs/conf.py deleted file mode 100644 index 9d308c6..0000000 --- a/docs/conf.py +++ /dev/null @@ -1,62 +0,0 @@ -import better - -extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.autosummary', - # 'sphinx.ext.doctest', - 'sphinx.ext.intersphinx', - 'sphinx.ext.mathjax', - # 'sphinx.ext.pngmath', - 'sphinx.ext.viewcode', - 'numpydoc', - ] -autosummary_generate = True -autodoc_default_flags = ['members'] -numpydoc_show_class_members = False -numpydoc_show_inherited_class_members = True -source_suffix = '.rst' -source_encoding = 'utf-8-sig' -master_doc = 'index' -project = u'C3D' -copyright = u'2015, Leif Johnson' -version = '0.3' -release = '0.3.1pre' -exclude_patterns = ['_build'] -templates_path = ['_templates'] -pygments_style = 'tango' - -html_theme = 'better' -html_theme_path = [better.better_theme_path] -html_theme_options = dict( - rightsidebar=False, - inlinecss='', - cssfiles=['_static/style-tweaks.css'], - showheader=True, - showrelbartop=True, - showrelbarbottom=True, - linktotheme=True, - sidebarwidth='15rem', - textcolor='#111', - headtextcolor='#333', - footertextcolor='#333', - ga_ua='', - ga_domain='', -) -html_short_title = 'Home' -html_static_path = ['_static'] - - -def h(xs): - return ['{}.html'.format(x) for x in xs.split()] - - -html_sidebars = { - 'index': h('gitwidgets globaltoc sourcelink searchbox'), - '**': h('gitwidgets localtoc sourcelink searchbox'), -} - -intersphinx_mapping = { - 'python': ('http://docs.python.org/', None), - 'numpy': ('http://docs.scipy.org/doc/numpy/', None), - 'scipy': ('http://docs.scipy.org/doc/scipy/reference/', None), -} diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index d8cd9dc..0000000 --- a/docs/index.rst +++ /dev/null @@ -1,66 +0,0 @@ -===================== -Python C3D Processing -===================== - -This package provides a single Python module for reading and writing binary -motion-capture files in the `C3D file format`_. - -.. _C3D file format: https://www.c3d.org/HTML/default.htm - -Examples -======== - -Reading -------- - -To read the frames of a C3D file, use a :class:`Reader ` instance:: - - import c3d - - reader = c3d.Reader(open('my-motion.c3d', 'rb')) - for i, points, analog in reader.read_frames(): - print('frame {}: point {}, analog {}'.format( - i, points.shape, analog.shape) - -The :func:`read_frames ` method generates tuples -containing the frame index, a ``numpy`` array of point data, and a ``numpy`` -array of analog data. - -Writing -------- - -To write data frames to a C3D file, use a :class:`Writer ` -instance:: - - import c3d - import numpy as np - - writer = c3d.Writer() - for _ in range(100): - writer.add_frame(np.random.randn(30, 5)) - with open('random-points.c3d', 'wb') as h: - writer.write(h) - -The :func:`add_frame ` method takes a ``numpy`` array of -point data---and, optionally, a ``numpy`` array of analog data---and adds it to -an internal data buffer. The :func:`write ` method serializes -all of the frame data to a C3D binary file. - -Command-Line Scripts -==================== - -The ``c3d`` package also contains several command-line scripts. - -Two of these scripts convert C3D binary data to other formats: -- ``c3d2csv``: Converts C3D data to comma-separated values. -- ``c3d2npz``: Converts C3D data to serialized ``numpy`` arrays. - -The ``c3d-metadata`` script simply prints out the metadata from a C3D file. - -Finally, the ``c3d-viewer`` script provides a basic OpenGL viewer for C3D data. -This script depends on ``pyglet``. - -.. toctree:: - :maxdepth: 2 - - reference diff --git a/docs/reference.rst b/docs/reference.rst deleted file mode 100644 index bfbb4e5..0000000 --- a/docs/reference.rst +++ /dev/null @@ -1,13 +0,0 @@ -========= -Reference -========= - -.. automodule:: c3d - :no-members: - :no-inherited-members: - -.. autosummary:: - :toctree: generated/ - - Reader - Writer diff --git a/docs/requirements.txt b/docs/requirements.txt deleted file mode 100644 index e10c929..0000000 --- a/docs/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ -numpydoc -sphinx-better-theme From 9f2728de8763b0e325cdedbe0f66ddca2215c618 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sat, 3 Jul 2021 23:09:03 +0200 Subject: [PATCH 058/120] examples.md --- c3d/__init__.py | 13 +++++++++++++ c3d/writer.py | 2 +- docs/examples.md | 43 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 docs/examples.md diff --git a/c3d/__init__.py b/c3d/__init__.py index 3a4dfbc..e6ed081 100644 --- a/c3d/__init__.py +++ b/c3d/__init__.py @@ -1,3 +1,16 @@ +""" +===================== +Python C3D Processing +===================== + +This package provides a single Python module for reading and writing binary +motion-capture files in the [C3D file format]. + +[C3D file format]: https://www.c3d.org/HTML/default.htm + +.. include:: ../docs/examples.md + +""" from . import dtypes from . import group from . import header diff --git a/c3d/writer.py b/c3d/writer.py index 2029f1e..289ddb6 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -204,7 +204,7 @@ def add_frames(self, frames, index=None): frames : Single or sequence of (point, analog) pairs A sequence or frame of frame data to add to the writer. index : int or None - Insert the frame or sequence at the index (the first sequence frame will be inserted at give index). + Insert the frame or sequence at the index (the first sequence frame will be inserted at the given `index`). Note that the index should be relative to 0 rather then the frame number provided by read_frames()! ''' sh = np.shape(frames) diff --git a/docs/examples.md b/docs/examples.md new file mode 100644 index 0000000..7b23cd3 --- /dev/null +++ b/docs/examples.md @@ -0,0 +1,43 @@ + + +Examples +======== + +Reading +------- + +To read the frames from a C3D file, use a `c3d.reader.Reader` instance: + +>>> import c3d +>>> +>>> reader = c3d.Reader(open('my-motion.c3d', 'rb')) +>>> for i, points, analog in reader.read_frames(): +>>> print('frame {}: point {}, analog {}'.format( +>>> i, points.shape, analog.shape) + +The method `c3d.reader.Reader.read_frames` generates tuples +containing the trial frame index, a ``numpy`` array of point data, +and a ``numpy`` array of analog data. + +Writing +------- + +To write data frames to a C3D file, use a `c3d.writer.Writer` +instance: + +>>> import c3d +>>> import numpy as np +>>> +>>> writer = c3d.Writer() +>>> for _ in range(100): +>>> writer.add_frame(np.random.randn(30, 5)) +>>> with open('random-points.c3d', 'wb') as h: +>>> writer.write(h) + +The function `c3d.writer.Writer.add_frames` takes ``numpy`` array of +point data---and, optionally, a ``numpy`` array of analog data---and adds it to +an internal data buffer. The :func:`write ` method serializes +all of the frame data to a C3D binary file. + +Editing +------- From eb875c260d277b89447fa770715a72e710e71ef8 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 12:40:47 +0200 Subject: [PATCH 059/120] Moved pack_labels() -> utils Added check for frame shape in Writer.add_frames() Documentation --- c3d/utils.py | 37 +++++++++++++++++ c3d/writer.py | 109 ++++++++++++++++++++++++-------------------------- 2 files changed, 90 insertions(+), 56 deletions(-) diff --git a/c3d/utils.py b/c3d/utils.py index 0c28231..8f81e9a 100644 --- a/c3d/utils.py +++ b/c3d/utils.py @@ -7,15 +7,52 @@ def is_integer(value): '''Check if value input is integer.''' return isinstance(value, (int, np.int32, np.int64)) + def is_iterable(value): '''Check if value is iterable.''' return hasattr(value, '__iter__') + def type_npy2struct(dtype): ''' Convert numpy dtype format to a struct package format string. ''' return dtype.byteorder + dtype.char + +def pack_labels(labels): + ''' Static method used to pack and pad the set of `labels` strings before + passing the output into a `c3d.group.Group.add_str`. + + Parameters + ---------- + labels : iterable + List of strings to pack and pad into a single string suitable for encoding in a Parameter entry. + + Example + ------- + >>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3'] + >>> param_str, label_max_size = Writer.pack_labels(labels) + >>> writer.point_group.add_str('LABELS', + 'Point labels.', + label_str, + label_max_size, + len(labels)) + + Returns + ------- + param_str : str + String containing `labels` packed into a single variable where + each string is padded to match the longest `labels` string. + label_max_size : int + Number of bytes associated with the longest `label` string, all strings are padded to this length. + ''' + labels = np.ravel(labels) + # Get longest label name + label_max_size = 0 + label_max_size = max(label_max_size, np.max([len(label) for label in labels])) + label_str = ''.join(label.ljust(label_max_size) for label in labels) + return label_str, label_max_size + class Decorator(object): '''Base class for extending (decorating) a python object. ''' diff --git a/c3d/writer.py b/c3d/writer.py index 289ddb6..2a272d9 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -4,10 +4,9 @@ import numpy as np import struct #import warnings +from . import utils from .manager import Manager from .dtypes import DataTypes -from .utils import is_integer, is_iterable - class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -55,15 +54,27 @@ def __init__(self, @staticmethod def from_reader(reader, conversion=None): ''' + + Parameters + ---------- source : `c3d.manager.Manager` Source to copy. conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: - 'consume' - (Default) Reader object will be consumed and explicitly deleted. + + 'consume' - (Default) Reader object will be + consumed and explicitly deleted. + 'copy' - Reader objects will be deep copied. - 'copy_metadata' - Similar to 'copy' but only copies metadata and not point and analog frame data. - 'copy_shallow' - Similar to 'copy' but group parameters are not copied. - 'copy_header' - Similar to 'copy_shallow' but only the header is copied (frame data is not copied). + + 'copy_metadata' - Similar to 'copy' but only copies metadata and + not point and analog frame data. + + 'copy_shallow' - Similar to 'copy' but group parameters are + not copied. + + 'copy_header' - Similar to 'copy_shallow' but only the + header is copied (frame data is not copied). Returns ------- @@ -209,13 +220,30 @@ def add_frames(self, frames, index=None): ''' sh = np.shape(frames) # Single frame - if len(sh) != 2: + if len(sh) < 2: frames = [frames] sh = np.shape(frames) + + # Check data shapes match + if len(self._frames) > 0: + point0, analog0 = self._frames[0] + psh, ash = np.shape(point0), np.shape(analog0) + for f in frames: + if np.shape(f[0]) != psh: + raise ValueError( + 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( + str(psh), str(np.shape(f[0])) + )) + if np.shape(f[1]) != ash: + raise ValueError( + 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( + str(ash), str(np.shape(f[1])) + )) + # Sequence of invalid shape if sh[1] != 2: raise ValueError( - 'Expected frame input to be sequence of point and analog pairs on form (-1, 2). ' + + 'Expected frame input to be sequence of point and analog pairs on form (None, 2). ' + 'Input was of shape {}.'.format(str(sh))) if index is not None: @@ -223,52 +251,21 @@ def add_frames(self, frames, index=None): else: self._frames.extend(frames) - @staticmethod - def pack_labels(labels): - ''' Static method used to pack and pad the set of `labels` strings before - passing the output into a `c3d.group.Group.add_str`. - - Parameters - ---------- - labels : iterable - List of strings to pack and pad into a single string suitable for encoding in a Parameter entry. - - Example - ------- - >>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3'] - >>> param_str, label_max_size = Writer.pack_labels(labels) - >>> writer.point_group.add_str('LABELS', - 'Point labels.', - label_str, - label_max_size, - len(labels)) - - Returns - ------- - param_str : str - String containing `labels` packed into a single variable where - each string is padded to match the longest `labels` string. - label_max_size : int - Number of bytes associated with the longest `label` string, all strings are padded to this length. - ''' - labels = np.ravel(labels) - # Get longest label name - label_max_size = 0 - label_max_size = max(label_max_size, np.max([len(label) for label in labels])) - label_str = ''.join(label.ljust(label_max_size) for label in labels) - return label_str, label_max_size - def set_point_labels(self, labels): ''' Set point data labels. ''' - label_str, label_max_size = Writer.pack_labels(labels) + label_str, label_max_size = utils.pack_labels(labels) self.point_group.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) def set_analog_labels(self, labels): ''' Set analog data labels. ''' - label_str, label_max_size = Writer.pack_labels(labels) - self.analog_group.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) + grp = self.analog_group + if labels is None: + grp.add_empty_array('LABELS', 'Analog labels.', -1) + else: + label_str, label_max_size = utils.pack_labels(labels) + grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) def set_analog_general_scale(self, value): ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor). @@ -283,7 +280,7 @@ def set_analog_scales(self, values): values : iterable or None Iterable containing individual scale factors for encoding analog channel data. ''' - if is_iterable(values): + if utils.is_iterable(values): data = np.array([v for v in values], dtype=np.float32) self.analog_group.set_array('SCALE', 'Analog channel scale factors', data) elif values is None: @@ -299,7 +296,7 @@ def set_analog_offsets(self, values): values : iterable or None Iterable containing individual offsets for encoding analog channel data. ''' - if is_iterable(values): + if utils.is_iterable(values): data = np.array([v for v in values], dtype=np.int16) self.analog_group.set_array('OFFSET', 'Analog channel offsets', data) elif values is None: @@ -310,8 +307,8 @@ def set_analog_offsets(self, values): def set_start_frame(self, frame=1): ''' Set the 'TRIAL:ACTUAL_START_FIELD' parameter and header.first_frame entry. - Parameter - --------- + Parameters + ---------- frame : int Number for the first frame recorded in the file. Frame counter for a trial recording always start at 1 for the first frame. @@ -332,11 +329,11 @@ def _set_last_frame(self, frame): def set_screen_axis(self, X='+X', Y='+Y'): ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. - Parameter - --------- + Parameters + ---------- X : str - 2 character string with first character indicating positive or negative axis (+/-), - and the second axis (X/Y/Z). Examples: '+X' or '-Y' + Two byte string with first character indicating positive or negative axis (+/-), + and the second axis (X/Y/Z). Example strings '+X' or '-Y' Y : str Second axis string with same format as Y. Determines the second Y screen axis. ''' @@ -349,7 +346,7 @@ def set_screen_axis(self, X='+X', Y='+Y'): group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y) def write(self, handle): - '''Write metadata and point + analog frames to a file handle. + '''Write metadata, point and analog frames to a file handle. Parameters ---------- @@ -415,7 +412,7 @@ def write(self, handle): self.get('POINT:DATA_START').bytes = struct.pack(' Date: Sun, 4 Jul 2021 12:42:55 +0200 Subject: [PATCH 060/120] Changed metadata warning for existence of POINT/ANALOG data to not consider cases when respective LABEL entry exists but is empty. --- c3d/manager.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/c3d/manager.py b/c3d/manager.py index 13ebb16..eb858a3 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -104,11 +104,20 @@ def check_parameters(params): if self.point_used > 0: check_parameters(('POINT:LABELS', 'POINT:DESCRIPTIONS')) else: - warnings.warn('No point data found in file.') + lab = self.get('POINT:LABELS') + if lab is None: + warnings.warn('No point data found in file.') + elif lab.num_elements > 0: + warnings.warn('No point data found in file, but file contains POINT:LABELS entries') if self.analog_used > 0: check_parameters(('ANALOG:LABELS', 'ANALOG:DESCRIPTIONS')) else: - warnings.warn('No analog data found in file.') + lab = self.get('ANALOG:LABELS') + if lab is None: + warnings.warn('No analog data found in file.') + elif lab.num_elements > 0: + warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries') + def _add_group(self, group_id, name=None, desc=None): '''Add a new parameter group. From 1f9860a683499ee83dd6bacfb112a10b6bb060da Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 12:57:17 +0200 Subject: [PATCH 061/120] Allow None to be passed to set_xxx_labels --- c3d/writer.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/c3d/writer.py b/c3d/writer.py index 2a272d9..bd6dbe9 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -253,12 +253,26 @@ def add_frames(self, frames, index=None): def set_point_labels(self, labels): ''' Set point data labels. + + Parameters + ---------- + labels : iterable + Set POINT:LABELS parameter entry from a set of string labels. ''' - label_str, label_max_size = utils.pack_labels(labels) - self.point_group.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) + grp = self.point_group + if labels is None: + grp.add_empty_array('LABELS', 'Point labels.', -1) + else: + label_str, label_max_size = utils.pack_labels(labels) + grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) def set_analog_labels(self, labels): ''' Set analog data labels. + + Parameters + ---------- + labels : iterable + Set ANALOG:LABELS parameter entry from a set of string labels. ''' grp = self.analog_group if labels is None: From cebf825e1de6a2a1147868515c3681dbc1078ccb Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 12:57:46 +0200 Subject: [PATCH 062/120] Examples --- docs/examples.md | 2 +- examples/write.py | 17 +++++++++++++++++ test/test_examples.py | 24 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) create mode 100644 examples/write.py create mode 100644 test/test_examples.py diff --git a/docs/examples.md b/docs/examples.md index 7b23cd3..0e52b22 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -36,7 +36,7 @@ instance: The function `c3d.writer.Writer.add_frames` takes ``numpy`` array of point data---and, optionally, a ``numpy`` array of analog data---and adds it to -an internal data buffer. The :func:`write ` method serializes +an internal data buffer. The function `c3d.writer.Writer.write` serializes all of the frame data to a C3D binary file. Editing diff --git a/examples/write.py b/examples/write.py new file mode 100644 index 0000000..10dcd8e --- /dev/null +++ b/examples/write.py @@ -0,0 +1,17 @@ +import numpy as np +try: + import c3d +except ModuleNotFoundError: + import sys + import os + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d + + +writer = c3d.Writer() +for _ in range(100): + writer.add_frames((np.random.randn(30, 5), ())) +writer.set_point_labels(['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2']) +writer.set_analog_labels(None) +with open('random-points.c3d', 'wb') as h: + writer.write(h) diff --git a/test/test_examples.py b/test/test_examples.py new file mode 100644 index 0000000..5d274d7 --- /dev/null +++ b/test/test_examples.py @@ -0,0 +1,24 @@ +''' Tests for the documentation examples +''' +import c3d +import io +import unittest +import numpy as np +from test.base import Base + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\examples')) + + +class Examples(Base): + ''' Test basic writer functionality + ''' + def test_write(self): + import write + # Raises 'FileNotFound' if the file was not generated + os.remove('..\\random-points.c3d') + + +if __name__ == '__main__': + unittest.main() From 146ab40fa223866d7be56e0047aa4fee49d2fccc Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 13:55:21 +0200 Subject: [PATCH 063/120] Writing example --- docs/examples.md | 39 ++++++++++++++++++++++++++------------- examples/write.py | 14 ++++++++++---- test/test_examples.py | 10 +++++++++- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 0e52b22..b63b734 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -25,19 +25,32 @@ Writing To write data frames to a C3D file, use a `c3d.writer.Writer` instance: ->>> import c3d ->>> import numpy as np ->>> ->>> writer = c3d.Writer() ->>> for _ in range(100): ->>> writer.add_frame(np.random.randn(30, 5)) ->>> with open('random-points.c3d', 'wb') as h: ->>> writer.write(h) - -The function `c3d.writer.Writer.add_frames` takes ``numpy`` array of -point data---and, optionally, a ``numpy`` array of analog data---and adds it to -an internal data buffer. The function `c3d.writer.Writer.write` serializes -all of the frame data to a C3D binary file. + import c3d + import numpy as np + + # Writes 100 frames recorded at 200 Hz. + # Each frame contains recordings for 24 coordinate markers. + writer = c3d.Writer(point_rate=200) + for _ in range(100): + writer.add_frames((np.random.randn(24, 5), ())) + + writer.set_point_labels(['RFT1', 'RFT2', 'RFT3', 'RFT4', + 'LFT1', 'LFT2', 'LFT3', 'LFT4', + 'RSK1', 'RSK2', 'RSK3', 'RSK4', + 'LSK1', 'LSK2', 'LSK3', 'LSK4', + 'RTH1', 'RTH2', 'RTH3', 'RTH4', + 'LTH1', 'LTH2', 'LTH3', 'LTH4' + ]) + writer.set_analog_labels(None) + + with open('random-points.c3d', 'wb') as h: + writer.write(h) + +The function `c3d.writer.Writer.add_frames` take pairs of ``numpy`` +or python arrays. First array in each frame tuple contains the point +data and the second analog data for the frame. References of the data +are tracked until `c3d.writer.Writer.write` is called, serializing all +data frames to a C3D binary file. Editing ------- diff --git a/examples/write.py b/examples/write.py index 10dcd8e..6d0b83c 100644 --- a/examples/write.py +++ b/examples/write.py @@ -7,11 +7,17 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) import c3d - -writer = c3d.Writer() +# Writes 100 frames recorded at 200 Hz. +# Each frame contains recordings for 24 coordinate markers. +writer = c3d.Writer(point_rate=200) for _ in range(100): - writer.add_frames((np.random.randn(30, 5), ())) -writer.set_point_labels(['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2']) + writer.add_frames((np.random.randn(24, 5), ())) + +writer.set_point_labels(['RFT1', 'RFT2', 'RFT3', 'RFT4', 'LFT1', 'LFT2', 'LFT3', 'LFT4', + 'RSK1', 'RSK2', 'RSK3', 'RSK4', 'LSK1', 'LSK2', 'LSK3', 'LSK4', + 'RTH1', 'RTH2', 'RTH3', 'RTH4', 'LTH1', 'LTH2', 'LTH3', 'LTH4' + ]) writer.set_analog_labels(None) + with open('random-points.c3d', 'wb') as h: writer.write(h) diff --git a/test/test_examples.py b/test/test_examples.py index 5d274d7..0d3d2a7 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -16,8 +16,16 @@ class Examples(Base): ''' def test_write(self): import write + path = 'random-points.c3d' + + with open(path, 'rb') as f: + reader = c3d.Reader(f) + assert reader.frame_count == 100, \ + 'Expected 30 point frames in write.py test, was {}'.format(reader.frame_count) + assert reader.point_used == 24, \ + 'Expected 5 point samples in write.py test, was {}'.format(reader.point_used) # Raises 'FileNotFound' if the file was not generated - os.remove('..\\random-points.c3d') + os.remove(path) if __name__ == '__main__': From aeaebd066f5896a70c67e0d04445a19b100c0a7c Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 14:04:51 +0200 Subject: [PATCH 064/120] Reading example --- docs/examples.md | 12 ++++++------ test/test_examples.py | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index b63b734..830c239 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,12 +8,12 @@ Reading To read the frames from a C3D file, use a `c3d.reader.Reader` instance: ->>> import c3d ->>> ->>> reader = c3d.Reader(open('my-motion.c3d', 'rb')) ->>> for i, points, analog in reader.read_frames(): ->>> print('frame {}: point {}, analog {}'.format( ->>> i, points.shape, analog.shape) + import c3d + + reader = c3d.Reader(open('my-motion.c3d', 'rb')) + for i, points, analog in reader.read_frames(): + print('frame {}: point {}, analog {}'.format( + i, points.shape, analog.shape) The method `c3d.reader.Reader.read_frames` generates tuples containing the trial frame index, a ``numpy`` array of point data, diff --git a/test/test_examples.py b/test/test_examples.py index 0d3d2a7..aad4f8c 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -14,6 +14,9 @@ class Examples(Base): ''' Test basic writer functionality ''' + def test_read(self): + import read + def test_write(self): import write path = 'random-points.c3d' From 0a99ed6d9da801d84eabf099e90fd0ca11447c04 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 14:22:14 +0200 Subject: [PATCH 065/120] Example documentation --- docs/examples.md | 24 +++++++++++++----------- examples/write.py | 1 + 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 830c239..54cdc77 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -10,14 +10,15 @@ To read the frames from a C3D file, use a `c3d.reader.Reader` instance: import c3d - reader = c3d.Reader(open('my-motion.c3d', 'rb')) - for i, points, analog in reader.read_frames(): - print('frame {}: point {}, analog {}'.format( - i, points.shape, analog.shape) + with open(file_path, 'rb') as file: + reader = c3d.Reader(file) + for i, points, analog in reader.read_frames(): + print('frame {}: point {}, analog {}'.format( + i, points.shape, analog.shape)) The method `c3d.reader.Reader.read_frames` generates tuples -containing the trial frame index, a ``numpy`` array of point data, -and a ``numpy`` array of analog data. +containing the trial frame number, a ``numpy`` array of point +data, and a ``numpy`` array of analog data. Writing ------- @@ -46,11 +47,12 @@ instance: with open('random-points.c3d', 'wb') as h: writer.write(h) -The function `c3d.writer.Writer.add_frames` take pairs of ``numpy`` -or python arrays. First array in each frame tuple contains the point -data and the second analog data for the frame. References of the data -are tracked until `c3d.writer.Writer.write` is called, serializing all -data frames to a C3D binary file. +The function `c3d.writer.Writer.add_frames` take pairs of ``numpy`` or python +arrays. First array in each frame tuple contains the point data and the second +contains analog data for the frame, leaving one of the arrays empty indicates +no analog --- or point data--- should be included in the file. References of the +data arrays are tracked until `c3d.writer.Writer.write` is called, serializing +metadata and data frames to a C3D binary file. Editing ------- diff --git a/examples/write.py b/examples/write.py index 6d0b83c..6a05b73 100644 --- a/examples/write.py +++ b/examples/write.py @@ -2,6 +2,7 @@ try: import c3d except ModuleNotFoundError: + # Force load package with no instalation import sys import os sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) From 9bef84129a858cb9dc1215135f69d9c6c89a6de8 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 15:03:53 +0200 Subject: [PATCH 066/120] Documentation --- c3d/__init__.py | 6 +++--- c3d/reader.py | 10 +++++++++- docs/examples.md | 14 ++++++++------ 3 files changed, 20 insertions(+), 10 deletions(-) diff --git a/c3d/__init__.py b/c3d/__init__.py index e6ed081..b985753 100644 --- a/c3d/__init__.py +++ b/c3d/__init__.py @@ -1,9 +1,9 @@ """ -===================== +--------------------- Python C3D Processing -===================== +--------------------- -This package provides a single Python module for reading and writing binary +This package provides pure Python modules for reading, writing, and editing binary motion-capture files in the [C3D file format]. [C3D file format]: https://www.c3d.org/HTML/default.htm diff --git a/c3d/reader.py b/c3d/reader.py index d5abca4..2e0d4cf 100644 --- a/c3d/reader.py +++ b/c3d/reader.py @@ -114,6 +114,14 @@ def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_s return a unique data buffer for each frame. Set this to False if you consume frames as you iterate over them, or True if you store them for later. + analog_transform : bool, default=True + If True, ANALOG:SCALE, ANALOG:GEN_SCALE, and ANALOG:OFFSET transforms + available in the file are applied to the analog channels. + check_nan : bool, default=True + If True, point x,y,z coordinates with nan values will be marked invalidated + and residuals will be set to -1. + camera_sum : bool, default=False + Camera flag bits will be summed, converting the fifth column to a camera visibility counter. Returns ------- @@ -241,7 +249,7 @@ def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_s # Fifth value is the camera-observation byte if camera_sum: # Convert to observation sum - points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(8)) + points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7)) else: points[:, 4] = camera_byte #.astype(np.float32) diff --git a/docs/examples.md b/docs/examples.md index 54cdc77..4b4f336 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -3,6 +3,8 @@ Examples ======== +Access to data blocks in a .c3d file is provided through the `c3d.reader.Reader` and `c3d.writer.Writer` classes. + Reading ------- @@ -47,12 +49,12 @@ instance: with open('random-points.c3d', 'wb') as h: writer.write(h) -The function `c3d.writer.Writer.add_frames` take pairs of ``numpy`` or python -arrays. First array in each frame tuple contains the point data and the second -contains analog data for the frame, leaving one of the arrays empty indicates -no analog --- or point data--- should be included in the file. References of the -data arrays are tracked until `c3d.writer.Writer.write` is called, serializing -metadata and data frames to a C3D binary file. +The function `c3d.writer.Writer.add_frames` take pairs of ``numpy`` or ``python +arrays``, with the first array in each tuple defining point data and the second +defining the analog data for the frame. Leaving one of the arrays empty indicates +to the writer that no analog --- or point data--- should be included in the file. +References of the data arrays are tracked until `c3d.writer.Writer.write` +is called, which serializes metadata and data frames into a C3D binary file stream. Editing ------- From 56a5d8f42a9d64aa92b687736fb3b475ceb1c566 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 15:36:11 +0200 Subject: [PATCH 067/120] Edit example --- docs/examples.md | 19 ++++++++++++++++++- examples/edit.py | 21 +++++++++++++++++++++ examples/my-looped-motion.c3d | Bin 0 -> 311808 bytes examples/my-motion.c3d | Bin 0 -> 156672 bytes examples/read.py | 18 ++++++++++++++++++ test/test_examples.py | 10 ++++++++++ 6 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 examples/edit.py create mode 100644 examples/my-looped-motion.c3d create mode 100644 examples/my-motion.c3d create mode 100644 examples/read.py diff --git a/docs/examples.md b/docs/examples.md index 4b4f336..6917c66 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -12,7 +12,7 @@ To read the frames from a C3D file, use a `c3d.reader.Reader` instance: import c3d - with open(file_path, 'rb') as file: + with open('my-motion.c3d', 'rb') as file: reader = c3d.Reader(file) for i, points, analog in reader.read_frames(): print('frame {}: point {}, analog {}'.format( @@ -58,3 +58,20 @@ is called, which serializes metadata and data frames into a C3D binary file stre Editing ------- + +Editing c3d files is possible by combining the use of `c3d.reader.Reader` and `c3d.writer.Writer` +instances through the use of `c3d.reader.Reader.to_writer`. By opening a .c3d file stream through +a reader instance, `c3d.reader.Reader.to_writer` can be used to create an independent Writer instance +copying the file contents onto the heap. Rereading the `reader` frame data from the file +and inserting the frames in reverse, a looped version of the .c3d file can be created! + + import c3d + + with open('my-motion.c3d', 'rb') as file: + reader = c3d.Reader(file) + writer = reader.to_writer('copy') + for i, points, analog in reader.read_frames(): + writer.add_frames((points, analog), index=reader.frame_count) + + with open('my-looped-motion.c3d', 'wb') as h: + writer.write(h) diff --git a/examples/edit.py b/examples/edit.py new file mode 100644 index 0000000..c1c9444 --- /dev/null +++ b/examples/edit.py @@ -0,0 +1,21 @@ +import numpy as np +import os +try: + import c3d +except ModuleNotFoundError: + # Force load package with no instalation + import sys + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d + +# My motion == Sample01.Eb015pi.c3d +file_path = os.path.join(os.path.dirname(__file__), 'my-motion.c3d') + +with open(file_path, 'rb') as file: + reader = c3d.Reader(file) + writer = reader.to_writer('copy') + for i, points, analog in reader.read_frames(): + writer.add_frames((points, analog), index=reader.frame_count) + +with open('my-looped-motion.c3d', 'wb') as h: + writer.write(h) diff --git a/examples/my-looped-motion.c3d b/examples/my-looped-motion.c3d new file mode 100644 index 0000000000000000000000000000000000000000..428d5b1d59f917c921d677692ba36aa9cbc77d85 GIT binary patch literal 311808 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">Zky|Jft)ntd7Y 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 literal 0 HcmV?d00001 diff --git a/examples/my-motion.c3d b/examples/my-motion.c3d new file mode 100644 index 0000000000000000000000000000000000000000..c2ab9da96cabcbcfc6504dc696d456d8a45df67e GIT binary patch literal 156672 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
+
+
+

Module c3d.dtypes

+
+
+

State object defining the data types associated with a given .c3d processor format.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class DataTypes +(proc_type=84) +
+
+

Container defining the data types used when parsing byte data. +Data types depend on the processor format the file is stored in.

+

Instance variables

+
+
var big_endian_sys : bool
+
+

True if native byte order is big-endian.

+
+
var is_dec : bool
+
+

True if the associated file is in the DEC format.

+
+
var is_ieee : bool
+
+

True if the associated file is in the Intel format.

+
+
var is_mips : bool
+
+

True if the associated file is in the SGI/MIPS format.

+
+
var little_endian_sys : bool
+
+

True if native byte order is little-endian.

+
+
var native : bool
+
+

True if the native (system) byte order matches the file byte order.

+
+
var proc_type : str
+
+

Get the processory type associated with the data format in the file.

+
+
var processor : int
+
+

Get the processor number encoded in the .c3d file.

+
+
+

Methods

+
+
+def decode_string(self, bytes) ‑> str +
+
+

Decode a byte array to a string.

+
+
+
+
+
+
+
+
+ + + \ No newline at end of file diff --git a/docs/c3d/group.html b/docs/c3d/group.html new file mode 100644 index 0000000..15d8f73 --- /dev/null +++ b/docs/c3d/group.html @@ -0,0 +1,536 @@ + + + + + + +c3d.group API documentation + + + + + + + + + + + +
+
+
+

Module c3d.group

+
+
+

Classes used to represent the concept of parameter groups in a .c3d file.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Group +(data) +
+
+

Wrapper exposing readable and writeable attributes of a GroupData entry.

+

Ancestors

+ +

Instance variables

+
+
var desc : str
+
+

Get or set descriptor.

+
+
var name : str
+
+

Get or set name.

+
+
+

Methods

+
+
+def add(self, name, desc, bpe, format, data, *dimensions) +
+
+

Add a parameter with data package formated in accordance with format.

+

Convenience function for GroupData.add_param() calling struct.pack() on data.

+

Example:

+
>>> group.set('RATE', 'Point data sample rate', 4, '<f', 100)
+
+

Parameters

+
+
name : str
+
Parameter name.
+
desc : str
+
Parameter descriptor.
+
bpe : int
+
Number of bytes for each atomic element.
+
format : str or None
+
struct.format() compatible format string see: +https://docs.python.org/3/library/struct.html#format-characters
+
*dimensions : int, optional
+
Shape associated with the data (if the data argument represents multiple elements).
+
+
+
+def add_array(self, name, desc, data, dtype=None) +
+
+

Add a parameter with the data package.

+

Parameters

+
+
name : str
+
Parameter name.
+
desc : str
+
Parameter descriptor.
+
data : np.ndarray, or iterable
+
Data array to encode in the parameter.
+
dtype : np.dtype, optional
+
Numpy data type used to encode the array (optional only if data.dtype returns a numpy type).
+
+
+
+def add_empty_array(self, name, desc='', bpe=1) +
+
+

Add an empty parameter block.

+

Parameters

+
+
name : str
+
Parameter name.
+
+
+
+def add_param(self, name, **kwargs) +
+
+

Add a parameter to this group.

+

See constructor of ParamData for additional keyword arguments.

+
+
+def add_str(self, name, desc, data, *dimensions) +
+
+

Add a string parameter.

+

Parameters

+
+
name : str
+
Parameter name.
+
desc : str
+
Parameter descriptor.
+
data : str
+
String to encode in the parameter.
+
*dimensions : int, optional
+
Shape associated with the string (if the string represents multiple elements).
+
+
+
+def get(self, key, default=None) +
+
+

Get a parameter by key.

+

Parameters

+
+
key : any
+
Parameter key to look up in this group.
+
default : any, optional
+
Value to return if the key is not found. Defaults to None.
+
+

Returns

+
+
param : :class:ParamReadable``
+
A parameter from the current group.
+
+
+
+def items(self) +
+
+

Iterator for paramater key-entry pairs.

+
+
+def readonly(self) +
+
+

Returns a GroupReadonly instance with readonly access.

+
+
+def remove_param(self, name) +
+
+

Remove the specified parameter.

+

Parameters

+
+
name : str
+
Name for the parameter to remove.
+
+
+
+def rename_param(self, name, new_name) +
+
+

Rename a specified parameter group.

+

Parameters

+

See arguments in GroupData.rename_param().

+
+
+def set(self, name, *args, **kwargs) +
+
+

Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'.

+

See arguments in Group.add().

+
+
+def set_array(self, name, *args, **kwargs) +
+
+

Add or overwrite a parameter with the data package.

+

See arguments in Group.add_array().

+
+
+def set_empty_array(self, name, *args, **kwargs) +
+
+

Add an empty parameter block.

+

See arguments in Group.add_empty_array().

+
+
+def set_str(self, name, *args, **kwargs) +
+
+

Add or overwrite a string parameter.

+

See arguments in Group.add_str().

+
+
+def values(self) +
+
+

Iterator iterator for parameter entries.

+
+
+

Inherited members

+ +
+
+class GroupData +(dtypes, name=None, desc=None) +
+
+

A group of parameters stored in a C3D file.

+

In C3D files, parameters are organized in groups. Each group has a name (key), a +description, and a set of named parameters. Each group is also internally associated +with a numeric key.

+

Attributes

+
+
dtypes : DataTypes
+
Data types object used for parsing.
+
name : str
+
Name of this parameter group.
+
desc : str
+
Description for this parameter group.
+
+

Instance variables

+
+
var binary_size : int
+
+

Return the number of bytes to store this group and its parameters.

+
+
+

Methods

+
+
+def add_param(self, name, **kwargs) +
+
+

Add a parameter to this group.

+

Parameters

+
+
name : str
+
Name of the parameter to add to this group. The name will +automatically be case-normalized.
+
+

See constructor of ParamData for additional keyword arguments.

+

Raises

+
+
TypeError
+
Input arguments are of the wrong type.
+
KeyError
+
Name or numerical key already exist (attempt to overwrite existing data).
+
+
+
+def remove_param(self, name) +
+
+

Remove the specified parameter.

+

Parameters

+
+
name : str
+
Name for the parameter to remove.
+
+
+
+def rename_param(self, name, new_name) +
+
+

Rename a specified parameter group.

+

Parameters

+
+
name : str, or GroupReadonly
+
Parameter instance, or name.
+
new_name : str
+
New name for the parameter.
+
+

Raises

+
+
KeyError
+
If no parameter with the original name exists.
+
ValueError
+
If the new name already exist (attempt to overwrite existing data).
+
+
+
+def set_desc(self, desc) +
+
+

Set the Group descriptor.

+
+
+def set_name(self, name) +
+
+

Set the group name string.

+
+
+def write(self, group_id, handle) +
+
+

Write this parameter group, with parameters, to a file handle.

+

Parameters

+
+
group_id : int
+
The numerical ID of the group.
+
handle : file handle
+
An open, writable, binary file handle.
+
+
+
+
+
+class GroupReadonly +(data) +
+
+

Wrapper exposing readonly attributes of a GroupData entry.

+

Subclasses

+ +

Instance variables

+
+
var desc : str
+
+

Access group descriptor.

+
+
var name : str
+
+

Access group name.

+
+
+

Methods

+
+
+def get(self, key, default=None) +
+
+

Get a readonly parameter by key.

+

Parameters

+
+
key : any
+
Parameter key to look up in this group.
+
default : any, optional
+
Value to return if the key is not found. Defaults to None.
+
+

Returns

+
+
param : :class:ParamReadable``
+
A parameter from the current group.
+
+
+
+def get_bytes(self, key) +
+
+

Get the value of the given parameter as a byte array.

+
+
+def get_float(self, key) +
+
+

Get the value of the given parameter as a 32-bit float.

+
+
+def get_int16(self, key) +
+
+

Get the value of the given parameter as a 16-bit signed integer.

+
+
+def get_int32(self, key) +
+
+

Get the value of the given parameter as a 32-bit signed integer.

+
+
+def get_int8(self, key) +
+
+

Get the value of the given parameter as an 8-bit signed integer.

+
+
+def get_string(self, key) +
+
+

Get the value of the given parameter as a string.

+
+
+def get_uint16(self, key) +
+
+

Get the value of the given parameter as a 16-bit unsigned integer.

+
+
+def get_uint32(self, key) +
+
+

Get the value of the given parameter as a 32-bit unsigned integer.

+
+
+def get_uint8(self, key) +
+
+

Get the value of the given parameter as an 8-bit unsigned integer.

+
+
+def items(self) +
+
+

Get iterator for paramater key-entry pairs.

+
+
+def keys(self) +
+
+

Get iterator for parameter entry keys.

+
+
+def values(self) +
+
+

Get iterator for parameter entries.

+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/header.html b/docs/c3d/header.html new file mode 100644 index 0000000..e29382b --- /dev/null +++ b/docs/c3d/header.html @@ -0,0 +1,206 @@ + + + + + + +c3d.header API documentation + + + + + + + + + + + +
+
+
+

Module c3d.header

+
+
+

Defines the header class used for reading, writing and tracking metadata in the .c3d header.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Header +(handle=None) +
+
+

Header information from a C3D file.

+

Attributes

+
+
event_block : int
+
Index of the 512-byte block where labels (metadata) are found.
+
parameter_block : int
+
Index of the 512-byte block where parameters (metadata) are found.
+
data_block : int
+
Index of the 512-byte block where data starts.
+
point_count : int
+
Number of motion capture channels recorded in this file.
+
analog_count : int
+
Number of analog values recorded per frame of 3D point data.
+
first_frame : int
+
Index of the first frame of data.
+
last_frame : int
+
Index of the last frame of data.
+
analog_per_frame : int
+
Number of analog frames per frame of 3D point data. The analog frame +rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE) +times the analog per frame value.
+
frame_rate : float
+
The frame rate of the recording, in frames per second.
+
scale_factor : float
+
Multiply values in the file by this scale parameter.
+
long_event_labels : bool
+
 
+
max_gap : int
+
 
+
+
+

Note

+

The scale_factor attribute is not used in Phasespace C3D files; +instead, use the POINT.SCALE parameter.

+
+
+

Note

+

The first_frame and last_frame header attributes are not used in +C3D files generated by Phasespace. Instead, the first and last +frame numbers are stored in the POINTS:ACTUAL_START_FIELD and +POINTS:ACTUAL_END_FIELD parameters.

+
+

Create a new Header object.

+

Parameters

+
+
handle : file handle, optional
+
If given, initialize attributes for the Header from this file +handle. The handle must be seek-able and readable. If handle is +not given, Header attributes are initialized with default values.
+
+

Class variables

+
+
var BINARY_FORMAT_READ
+
+
+
+
var BINARY_FORMAT_READ_BIG_ENDIAN
+
+
+
+
var BINARY_FORMAT_WRITE
+
+
+
+
+

Instance variables

+
+
var events
+
+

Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label).

+

Note*: +Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter. +Frame 1 therefor has the time 0.0 in relation to the event timing.

+
+
+

Methods

+
+
+def encode_events(self, events) +
+
+

Encode event data in the event block.

+

Parameters

+
+
events : [(float, str), …]
+
Event data, iterable of touples containing the event timing and a 4 character label string. +Event timings should be calculated relative to sample 1 with the timing 0.0s, and should +not be relative to the first_frame header parameter.
+
+
+
+def read(self, handle, fmt='<BBHHHHHIHHI274sHHH164s44s') +
+
+

Read and parse binary header data from a file handle.

+

This method reads exactly 512 bytes from the beginning of the given file +handle.

+

Parameters

+
+
handle : file handle
+
The given handle will be reset to 0 using seek and then 512 bytes +will be read to initialize the attributes in this Header. The handle +must be readable.
+
+

fmt : Formating string used to read the header.

+

Raises

+
+
AssertionError
+
If the magic byte from the header is not 80 (the C3D magic value).
+
+
+
+def write(self, handle) +
+
+

Write binary header data to a file handle.

+

This method writes exactly 512 bytes to the beginning of the given file +handle.

+

Parameters

+
+
handle : file handle
+
The given handle will be reset to 0 using seek and then 512 bytes +will be written to describe the parameters in this Header. The +handle must be writeable.
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/index.html b/docs/c3d/index.html new file mode 100644 index 0000000..cac4396 --- /dev/null +++ b/docs/c3d/index.html @@ -0,0 +1,176 @@ + + + + + + +c3d API documentation + + + + + + + + + + + +
+
+
+

Package c3d

+
+
+
+

Python C3D Processing

+

This package provides pure Python modules for reading, writing, and editing binary +motion-capture files in the C3D file format.

+

Scripts

+

The package installation comes with some pre-packaged scripts for +viewing, and converting .c3d files to .csv and .npz formats. +See the script directory on github for more information.

+

Examples

+

Access to data blocks in a .c3d file is provided through the Reader and Writer classes. +Runnable implementation of the examples are provided in the examples/ directory in the github repository.

+

Reading

+

To read the frames from a C3D file, use a Reader instance:

+
import c3d
+
+with open('my-motion.c3d', 'rb') as file:
+    reader = c3d.Reader(file)
+    for i, points, analog in reader.read_frames():
+        print('frame {}: point {}, analog {}'.format(
+              i, points.shape, analog.shape))
+
+

The method Reader.read_frames() generates tuples +containing the trial frame number, a numpy array of point +data, and a numpy array of analog data.

+

Writing

+

To write data frames to a C3D file, use a Writer +instance:

+
import c3d
+import numpy as np
+
+# Writes 100 frames recorded at 200 Hz.
+# Each frame contains recordings for 24 coordinate markers.
+writer = c3d.Writer(point_rate=200)
+for _ in range(100):
+    writer.add_frames((np.random.randn(24, 5), ()))
+
+writer.set_point_labels(['RFT1', 'RFT2', 'RFT3', 'RFT4',
+                         'LFT1', 'LFT2', 'LFT3', 'LFT4',
+                         'RSK1', 'RSK2', 'RSK3', 'RSK4',
+                         'LSK1', 'LSK2', 'LSK3', 'LSK4',
+                         'RTH1', 'RTH2', 'RTH3', 'RTH4',
+                         'LTH1', 'LTH2', 'LTH3', 'LTH4'
+                        ])
+writer.set_analog_labels(None)
+
+with open('random-points.c3d', 'wb') as h:
+    writer.write(h)
+
+

The function Writer.add_frames() take pairs of numpy or python +arrays, with the first array in each tuple defining point data and the second +defining the analog data for the frame. Leaving one of the arrays empty indicates +to the writer that no analog — or point data— should be included in the file. +References of the data arrays are tracked until Writer.write() +is called, which serializes metadata and data frames into a C3D binary file stream.

+

Editing

+

Editing c3d files is possible by combining the use of Reader and Writer +instances through the use of Reader.to_writer(). By opening a .c3d file stream through +a reader instance, Reader.to_writer() can be used to create an independent Writer instance +copying the file contents onto the heap. Rereading the c3d.reader frame data from the file +and inserting the frames in reverse, a looped version of the .c3d file can be created!

+
import c3d
+
+with open('my-motion.c3d', 'rb') as file:
+    reader = c3d.Reader(file)
+    writer = reader.to_writer('copy')
+    for i, points, analog in reader.read_frames():
+        writer.add_frames((points, analog), index=reader.frame_count)
+
+with open('my-looped-motion.c3d', 'wb') as h:
+    writer.write(h)
+
+
+
+

Sub-modules

+
+
c3d.dtypes
+
+

State object defining the data types associated with a given .c3d processor format.

+
+
c3d.group
+
+

Classes used to represent the concept of parameter groups in a .c3d file.

+
+
c3d.header
+
+

Defines the header class used for reading, writing and tracking metadata in the .c3d header.

+
+
c3d.manager
+
+

Manager base class defining common attributes for both the Reader and Writer instances.

+
+
c3d.parameter
+
+

Classes used to represent the concept of a parameter in a .c3d file.

+
+
c3d.reader
+
+

A Python module for reading and writing C3D files.

+
+
c3d.utils
+
+

Trailing utility functions.

+
+
c3d.writer
+
+

A Python module for reading and writing C3D files.

+
+
+
+
+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/manager.html b/docs/c3d/manager.html new file mode 100644 index 0000000..f0ce4b0 --- /dev/null +++ b/docs/c3d/manager.html @@ -0,0 +1,306 @@ + + + + + + +c3d.manager API documentation + + + + + + + + + + + +
+
+
+

Module c3d.manager

+
+
+

Manager base class defining common attributes for both the Reader and Writer instances.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Manager +(header=None) +
+
+

A base class for managing C3D file metadata.

+

This class manages a C3D header (which contains some stock metadata fields) +as well as a set of parameter groups. Each group is accessible using its +name.

+

Attributes

+
+
header : Header
+
Header information for the C3D file.
+
+

Set up a new Manager with a Header.

+

Subclasses

+ +

Instance variables

+
+
var analog_labels : list
+
+

Labels for each ANALOG data channel.

+
+
var analog_per_frame : int
+
+

Number of analog frames per 3D frame (point sample).

+
+
var analog_rate : float
+
+

Number of analog data samples per second.

+
+
var analog_sample_count : int
+
+

Number of analog samples per channel.

+
+
var analog_used : int
+
+

Number of analog measurements, or channels, for each analog data sample.

+
+
var first_frame : int
+
+

Trial frame corresponding to the first frame recorded in the data.

+
+
var frame_count : int
+
+

Number of frames recorded in the data.

+
+
var header : `Header`
+
+

Access to .c3d header data.

+
+
var last_frame : int
+
+

Trial frame corresponding to the last frame recorded in the data (inclusive).

+
+
var point_labels : list
+
+

Labels for each POINT data channel.

+
+
var point_rate : float
+
+

Number of sampled 3D coordinates per second.

+
+
var point_scale : float
+
+

Scaling applied to non-float data.

+
+
var point_used : int
+
+

Number of sampled 3D point coordinates per frame.

+
+
+

Methods

+
+
+def get(self, group, default=None) +
+
+

Get a group or parameter.

+

Parameters

+
+
group : str
+
If this string contains a period (.), then the part before the +period will be used to retrieve a group, and the part after the +period will be used to retrieve a parameter from that group. If this +string does not contain a period, then just a group will be +returned.
+
default : any
+
Return this value if the named group and parameter are not found.
+
+

Returns

+
+
value : Group or Param
+
Either a group or parameter with the specified name(s). If neither +is found, returns the default value.
+
+
+
+def get_analog_transform(self) +
+
+

Get broadcastable analog transformation parameters.

+
+
+def get_analog_transform_parameters(self) +
+
+

Parse analog data transform parameters.

+
+
+def get_bytes(self, key) +
+
+

Get a parameter value as a byte string.

+
+
+def get_float(self, key) +
+
+

Get a parameter value as a 32-bit float.

+
+
+def get_int16(self, key) +
+
+

Get a parameter value as a 16-bit signed integer.

+
+
+def get_int32(self, key) +
+
+

Get a parameter value as a 32-bit signed integer.

+
+
+def get_int8(self, key) +
+
+

Get a parameter value as an 8-bit signed integer.

+
+
+def get_screen_axis(self) +
+
+

Get the X_SCREEN and Y_SCREEN parameters in the POINT group.

+

Returns

+
+
value : (str, str) or None
+
Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
+
+
+
+def get_string(self, key) +
+
+

Get a parameter value as a string.

+
+
+def get_uint16(self, key) +
+
+

Get a parameter value as a 16-bit unsigned integer.

+
+
+def get_uint32(self, key) +
+
+

Get a parameter value as a 32-bit unsigned integer.

+
+
+def get_uint8(self, key) +
+
+

Get a parameter value as an 8-bit unsigned integer.

+
+
+def items(self) +
+
+

Get iterable over pairs of (str, Group) entries.

+
+
+def keys(self) +
+
+

Get iterable over parameter name keys.

+
+
+def listed(self) +
+
+

Get iterable over pairs of (int, Group) entries.

+
+
+def parameter_blocks(self) ‑> int +
+
+

Compute the size (in 512B blocks) of the parameter section.

+
+
+def values(self) +
+
+

Get iterable over Group entries.

+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/parameter.html b/docs/c3d/parameter.html new file mode 100644 index 0000000..1d62160 --- /dev/null +++ b/docs/c3d/parameter.html @@ -0,0 +1,401 @@ + + + + + + +c3d.parameter API documentation + + + + + + + + + + + +
+
+
+

Module c3d.parameter

+
+
+

Classes used to represent the concept of a parameter in a .c3d file.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Param +(data) +
+
+

Wrapper exposing both readable and writable attributes of a ParamData entry.

+

Ancestors

+ +

Instance variables

+
+
var bytes : bytes
+
+

Get or set the parameter bytes.

+
+
+

Methods

+
+
+def readonly(self) +
+
+

Returns a readonly ParamReadonly instance.

+
+
+

Inherited members

+ +
+
+class ParamData +(name, dtype, desc='', bytes_per_element=1, dimensions=None, bytes=b'', handle=None) +
+
+

A class representing a single named parameter from a C3D file.

+

Attributes

+
+
name : str
+
Name of this parameter.
+
dtype : DataTypes
+
Reference to the DataTypes object associated with the file.
+
desc : str
+
Brief description of this parameter.
+
bytes_per_element : int, optional
+
For array data, this describes the size of each element of data. For +string data (including arrays of strings), this should be -1.
+
dimensions : list of int
+
For array data, this describes the dimensions of the array, stored in +column-major (Fortran) order. For arrays of strings, the dimensions here will be +the number of columns (length of each string) followed by the number of +rows (number of strings).
+
bytes : str
+
Raw data for this parameter.
+
+

Set up a new parameter, only the name is required.

+

Instance variables

+
+
var binary_size : int
+
+

Return the number of bytes needed to store this parameter.

+
+
var num_elements : int
+
+

Return the number of elements in this parameter's array value.

+
+
var total_bytes : int
+
+

Return the number of bytes used for storing this parameter's data.

+
+
+

Methods

+
+
+def read(self, handle) +
+
+

Read binary data for this parameter from a file handle.

+

This reads exactly enough data from the current position in the file to +initialize the parameter.

+
+
+def write(self, group_id, handle) +
+
+

Write binary data for this parameter to a file handle.

+

Parameters

+
+
group_id : int
+
The numerical ID of the group that holds this parameter.
+
handle : file handle
+
An open, writable, binary file handle.
+
+
+
+
+
+class ParamReadonly +(data) +
+
+

Wrapper exposing readonly attributes of a ParamData entry.

+

Subclasses

+ +

Instance variables

+
+
var binary_size : int
+
+

Return the number of bytes needed to store this parameter.

+
+
var bytes_array
+
+

Get the param as an array of raw byte strings.

+
+
var bytes_per_element : int
+
+

Return the number of bytes used to store each data element.

+
+
var bytes_value
+
+

Get the param as a raw byte string.

+
+
var desc : str
+
+

Get the parameter descriptor.

+
+
var dimensions : (, Ellipsis)
+
+

Shape of the parameter data (Fortran shape).

+
+
var dtypes
+
+

Convenience accessor to the DataTypes instance associated with the parameter.

+
+
var float32_array
+
+

Get the param as an array of 32-bit floats.

+
+
var float64_array
+
+

Get the param as an array of 64-bit floats.

+
+
var float_array
+
+

Get the param as an array of 32 or 64 bit floats.

+
+
var float_value
+
+

Get the param as a floating point value of appropriate type.

+
+
var int16_array
+
+

Get the param as an array of 16-bit signed integers.

+
+
var int16_value
+
+

Get the param as a 16-bit signed integer.

+
+
var int32_array
+
+

Get the param as an array of 32-bit signed integers.

+
+
var int32_value
+
+

Get the param as a 32-bit signed integer.

+
+
var int64_array
+
+

Get the param as an array of 32-bit signed integers.

+
+
var int8_array
+
+

Get the param as an array of 8-bit signed integers.

+
+
var int8_value
+
+

Get the param as an 8-bit signed integer.

+
+
var int_array
+
+

Get the param as an array of integer values.

+
+
var int_value
+
+

Get the param as a signed integer of appropriate type.

+
+
var name : str
+
+

Get the parameter name.

+
+
var num_elements : int
+
+

Return the number of elements in this parameter's array value.

+
+
var string_array
+
+

Get the param as a python array of unicode strings.

+
+
var string_value
+
+

Get the param as a unicode string.

+
+
var total_bytes : int
+
+

Return the number of bytes used for storing this parameter's data.

+
+
var uint16_array
+
+

Get the param as an array of 16-bit unsigned integers.

+
+
var uint16_value
+
+

Get the param as a 16-bit unsigned integer.

+
+
var uint32_array
+
+

Get the param as an array of 32-bit unsigned integers.

+
+
var uint32_value
+
+

Get the param as a 32-bit unsigned integer.

+
+
var uint64_array
+
+

Get the param as an array of 32-bit unsigned integers.

+
+
var uint8_array
+
+

Get the param as an array of 8-bit unsigned integers.

+
+
var uint8_value
+
+

Get the param as an 8-bit unsigned integer.

+
+
var uint_array
+
+

Get the param as an array of integer values.

+
+
var uint_value
+
+

Get the param as a unsigned integer of appropriate type.

+
+
+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/reader.html b/docs/c3d/reader.html new file mode 100644 index 0000000..a9eac9d --- /dev/null +++ b/docs/c3d/reader.html @@ -0,0 +1,239 @@ + + + + + + +c3d.reader API documentation + + + + + + + + + + + +
+
+
+

Module c3d.reader

+
+
+

A Python module for reading and writing C3D files.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Reader +(handle) +
+
+

This class provides methods for reading the data in a C3D file.

+

A C3D file contains metadata and frame-based data describing 3D motion.

+

You can iterate over the frames in the file by calling read_frames() after +construction:

+
>>> r = c3d.Reader(open('capture.c3d', 'rb'))
+>>> for frame_no, points, analog in r.read_frames():
+...     print('{0.shape} points in this frame'.format(points))
+
+

Initialize this C3D file by reading header and parameter data.

+

Parameters

+
+
handle : file handle
+
Read metadata and C3D motion frames from the given file handle. This +handle is assumed to be seek-able and read-able. The handle must +remain open for the life of the Reader instance. The Reader does +not close the handle.
+
+

Raises

+
+
AssertionError
+
If the metadata in the C3D file is inconsistent.
+
+

Ancestors

+ +

Instance variables

+
+
var proc_type : int
+
+

Get the processory type associated with the data format in the file.

+
+
+

Methods

+
+
+def get(self, key, default=None) +
+
+

Get a readonly group or parameter.

+

Parameters

+
+
key : str
+
If this string contains a period (.), then the part before the +period will be used to retrieve a group, and the part after the +period will be used to retrieve a parameter from that group. If this +string does not contain a period, then just a group will be +returned.
+
default : any
+
Return this value if the named group and parameter are not found.
+
+

Returns

+
+
value : GroupReadonly or ParamReadonly
+
Either a group or parameter with the specified name(s). If neither +is found, returns the default value.
+
+
+
+def items(self) +
+
+

Get iterable over pairs of (str, GroupReadonly) entries.

+
+
+def listed(self) +
+
+

Get iterable over pairs of (int, GroupReadonly) entries.

+
+
+def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False) +
+
+

Iterate over the data frames from our C3D file handle.

+

Parameters

+
+
copy : bool
+
If False, the reader returns a reference to the same data buffers +for every frame. The default is True, which causes the reader to +return a unique data buffer for each frame. Set this to False if you +consume frames as you iterate over them, or True if you store them +for later.
+
analog_transform : bool, default=True
+
If True, ANALOG:SCALE, ANALOG:GEN_SCALE, and ANALOG:OFFSET transforms +available in the file are applied to the analog channels.
+
check_nan : bool, default=True
+
If True, point x,y,z coordinates with nan values will be marked invalidated +and residuals will be set to -1.
+
camera_sum : bool, default=False
+
Camera flag bits will be summed, converting the fifth column to a camera visibility counter.
+
+

Returns

+
+
frames : sequence of (frame number, points, analog)
+
+

This method generates a sequence of (frame number, points, analog) +tuples, one tuple per frame. The first element of each tuple is the +frame number. The second is a numpy array of parsed, 5D point data +and the third element of each tuple is a numpy array of analog +values that were recorded during the frame. (Often the analog data +are sampled at a higher frequency than the 3D point data, resulting +in multiple analog frames per frame of point data.)

+

The first three columns in the returned point data are the (x, y, z) +coordinates of the observed motion capture point. The fourth column +is an estimate of the error for this particular point, and the fifth +column is the number of cameras that observed the point in question. +Both the fourth and fifth values are -1 if the point is considered +to be invalid.

+
+
+
+
+def to_writer(self, conversion=None) +
+
+

Converts the reader to a Writer instance using the conversion mode.

+

See Writer.from_reader() for supported conversion modes.

+
+
+def values(self) +
+
+

Get iterable over GroupReadonly entries.

+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/utils.html b/docs/c3d/utils.html new file mode 100644 index 0000000..9560227 --- /dev/null +++ b/docs/c3d/utils.html @@ -0,0 +1,163 @@ + + + + + + +c3d.utils API documentation + + + + + + + + + + + +
+
+
+

Module c3d.utils

+
+
+

Trailing utility functions.

+
+
+
+
+
+
+

Functions

+
+
+def DEC_to_IEEE(uint_32) +
+
+

Convert the 32 bit representation of a DEC float to IEEE format.

+

Params:

+

uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits. +Returns : IEEE formated floating point of the same shape as the input.

+
+
+def DEC_to_IEEE_BYTES(bytes) +
+
+

Convert byte array containing 32 bit DEC floats to IEEE format.

+

Params:

+

bytes : Byte array where every 4 bytes represent a single precision DEC float. +Returns : IEEE formated floating point of the same shape as the input.

+
+
+def UNPACK_FLOAT_IEEE(uint_32) +
+
+

Unpacks a single 32 bit unsigned int to a IEEE float representation

+
+
+def UNPACK_FLOAT_MIPS(uint_32) +
+
+

Unpacks a single 32 bit unsigned int to a IEEE float representation

+
+
+def is_integer(value) +
+
+

Check if value input is integer.

+
+
+def is_iterable(value) +
+
+

Check if value is iterable.

+
+
+def pack_labels(labels) +
+
+

Static method used to pack and pad the set of labels strings before +passing the output into a Group.add_str().

+

Parameters

+
+
labels : iterable
+
List of strings to pack and pad into a single string suitable for encoding in a Parameter entry.
+
+

Example

+
>>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3']
+>>> param_str, label_max_size = Writer.pack_labels(labels)
+>>> writer.point_group.add_str('LABELS',
+                               'Point labels.',
+                               label_str,
+                               label_max_size,
+                               len(labels))
+
+

Returns

+
+
param_str : str
+
String containing labels packed into a single variable where +each string is padded to match the longest labels string.
+
label_max_size : int
+
Number of bytes associated with the longest label string, all strings are padded to this length.
+
+
+
+def type_npy2struct(dtype) +
+
+

Convert numpy dtype format to a struct package format string.

+
+
+
+
+

Classes

+
+
+class Decorator +(decoratee) +
+
+

Base class for extending (decorating) a python object.

+
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html new file mode 100644 index 0000000..2ba71a3 --- /dev/null +++ b/docs/c3d/writer.html @@ -0,0 +1,355 @@ + + + + + + +c3d.writer API documentation + + + + + + + + + + + +
+
+
+

Module c3d.writer

+
+
+

A Python module for reading and writing C3D files.

+
+
+
+
+
+
+
+
+

Classes

+
+
+class Writer +(point_rate=480.0, analog_rate=0.0, point_scale=-1.0) +
+
+

This class writes metadata and frames to a C3D file.

+

For example, to read an existing C3D file, apply some sort of data +processing to the frames, and write out another C3D file::

+
>>> r = c3d.Reader(open('data.c3d', 'rb'))
+>>> w = c3d.Writer()
+>>> w.add_frames(process_frames_somehow(r.read_frames()))
+>>> with open('smoothed.c3d', 'wb') as handle:
+>>>     w.write(handle)
+
+

Parameters

+
+
point_rate : float, optional
+
The frame rate of the data. Defaults to 480.
+
analog_rate : float, optional
+
The number of analog samples per frame. Defaults to 0.
+
point_scale : float, optional
+
The scale factor for point data. Defaults to -1 (i.e., "check the +POINT:SCALE parameter").
+
point_units : str, optional
+
The units that the point numbers represent. Defaults to 'mm +'.
+
gen_scale : float, optional
+
General scaling factor for analog data. Defaults to 1.
+
+

Set minimal metadata for this writer.

+

Ancestors

+ +

Static methods

+
+
+def from_reader(reader, conversion=None) +
+
+

Parameters

+
+
source : Manager
+
Source to copy.
+
conversion : str
+
Conversion mode, None is equivalent to the default mode. Supported modes are:
'consume'       - (Default) Reader object will be
+                  consumed and explicitly deleted.
+
+'copy'          - Reader objects will be deep copied.
+
+'copy_metadata' - Similar to 'copy' but only copies metadata and
+                  not point and analog frame data.
+
+'copy_shallow'  - Similar to 'copy' but group parameters are
+                  not copied.
+
+'copy_header'   - Similar to 'copy_shallow' but only the
+                  header is copied (frame data is not copied).
+
+
+
+

Returns

+
+
param : :class:Writer``
+
A writeable and persistent representation of the 'Reader' object.
+
+

Raises

+
+
ValueError
+
If mode string is not equivalent to one of the supported modes. +If attempting to convert non-Intel files using mode other than 'shallow_copy'.
+
+
+
+

Instance variables

+
+
var analog_group
+
+

Get or create the ANALOG parameter group.

+
+
var numeric_key_max
+
+

Get the largest numeric key.

+
+
var numeric_key_next
+
+

Get a new unique numeric group key.

+
+
var point_group
+
+

Get or create the POINT parameter group.

+
+
var trial_group
+
+

Get or create the TRIAL parameter group.

+
+
+

Methods

+
+
+def add_frames(self, frames, index=None) +
+
+

Add frames to this writer instance.

+

Parameters

+
+
frames : Single or sequence of (point, analog) pairs
+
A sequence or frame of frame data to add to the writer.
+
index : int or None
+
Insert the frame or sequence at the index (the first sequence frame will be inserted at the given index). +Note that the index should be relative to 0 rather then the frame number provided by read_frames()!
+
+
+
+def add_group(self, group_id, name, desc) +
+
+

Add a new parameter group. See Manager.add_group() for more information.

+

Returns

+
+
group : :class:Group``
+
An editable group instance.
+
+
+
+def get_create(self, label) +
+
+

Get or create the specified parameter group.

+
+
+def remove_group(self, *args) +
+
+

Remove the parameter group. (see Manager._rename_group for args).

+
+
+def rename_group(self, *args) +
+
+

Rename a specified parameter group (see Manager._rename_group for args).

+
+
+def set_analog_general_scale(self, value) +
+
+

Set ANALOG:GEN_SCALE factor (uniform analog scale factor).

+
+
+def set_analog_labels(self, labels) +
+
+

Set analog data labels.

+

Parameters

+
+
labels : iterable
+
Set ANALOG:LABELS parameter entry from a set of string labels.
+
+
+
+def set_analog_offsets(self, values) +
+
+

Set ANALOG:OFFSET offsets (per channel offset).

+

Parameters

+
+
values : iterable or None
+
Iterable containing individual offsets for encoding analog channel data.
+
+
+
+def set_analog_scales(self, values) +
+
+

Set ANALOG:SCALE factors (per channel scale factor).

+

Parameters

+
+
values : iterable or None
+
Iterable containing individual scale factors for encoding analog channel data.
+
+
+
+def set_point_labels(self, labels) +
+
+

Set point data labels.

+

Parameters

+
+
labels : iterable
+
Set POINT:LABELS parameter entry from a set of string labels.
+
+
+
+def set_screen_axis(self, X='+X', Y='+Y') +
+
+

Set the X_SCREEN and Y_SCREEN parameters in the POINT group.

+

Parameters

+
+
X : str
+
Two byte string with first character indicating positive or negative axis (+/-), +and the second axis (X/Y/Z). Example strings '+X' or '-Y'
+
Y : str
+
Second axis string with same format as Y. Determines the second Y screen axis.
+
+
+
+def set_start_frame(self, frame=1) +
+
+

Set the 'TRIAL:ACTUAL_START_FIELD' parameter and header.first_frame entry.

+

Parameters

+
+
frame : int
+
Number for the first frame recorded in the file. +Frame counter for a trial recording always start at 1 for the first frame.
+
+
+
+def write(self, handle) +
+
+

Write metadata, point and analog frames to a file handle.

+

Parameters

+
+
handle : file
+
Write metadata and C3D motion frames to the given file handle. The +writer does not close the handle.
+
+
+
+

Inherited members

+ +
+
+
+
+ +
+ + + \ No newline at end of file diff --git a/docs/examples.md b/docs/examples.md index 4c77c3a..69f396d 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,10 +1,17 @@ +Scripts +======== + +The package installation comes with some pre-packaged scripts for +viewing, and converting .c3d files to .csv and .npz formats. +See the [script directory] on github for more information. +[script directory]: ../scripts Examples ======== Access to data blocks in a .c3d file is provided through the `c3d.reader.Reader` and `c3d.writer.Writer` classes. -Implementation of the examples are provided in the `example/` in the github repository. +Runnable implementation of the examples are provided in the `examples/` directory in the github repository. Reading ------- From 7350a83bf0bfb40dcda30cb534e364ea16769b8b Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 16:52:26 +0200 Subject: [PATCH 071/120] Set theme jekyll-theme-cayman --- docs/_config.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/_config.yml diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..c419263 --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-cayman \ No newline at end of file From 06517464466e232117f50907c7bf5ed0effc5226 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 16:56:02 +0200 Subject: [PATCH 072/120] Links --- docs/examples.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 69f396d..d07e75a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -3,15 +3,17 @@ Scripts The package installation comes with some pre-packaged scripts for viewing, and converting .c3d files to .csv and .npz formats. -See the [script directory] on github for more information. +See the [scripts/] directory on github for more information. -[script directory]: ../scripts +[scripts/]: https://github.com/EmbodiedCognition/py-c3d/tree/master/scripts Examples ======== Access to data blocks in a .c3d file is provided through the `c3d.reader.Reader` and `c3d.writer.Writer` classes. -Runnable implementation of the examples are provided in the `examples/` directory in the github repository. +Runnable implementation of the examples are provided in the [examples/] directory in the github repository. + +[examples/]: https://github.com/EmbodiedCognition/py-c3d/tree/master/examples Reading ------- From 277ecd39f32826bd25d2fd10b5fdfa8e407362e1 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 16:59:58 +0200 Subject: [PATCH 073/120] index.md --- docs/index.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/index.md diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..78faf4e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,3 @@ +{{ define "html" }} +{{ with .GetPage "/c3d" }}{{.Render}}{{end}} +{{ end }} From 9567309ddc45feef2000648b747cbf85e2173457 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 17:05:15 +0200 Subject: [PATCH 074/120] Test readme --- docs/README.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 docs/README.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..78faf4e --- /dev/null +++ b/docs/README.md @@ -0,0 +1,3 @@ +{{ define "html" }} +{{ with .GetPage "/c3d" }}{{.Render}}{{end}} +{{ end }} From 3df5e624fac1f089313bc690d13ad1bb0906ca95 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 17:19:16 +0200 Subject: [PATCH 075/120] rst --- docs/README.md | 3 --- docs/README.rst | 11 +++++++++++ docs/index.md | 24 +++++++++++++++++++++--- 3 files changed, 32 insertions(+), 6 deletions(-) delete mode 100644 docs/README.md create mode 100644 docs/README.rst diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 78faf4e..0000000 --- a/docs/README.md +++ /dev/null @@ -1,3 +0,0 @@ -{{ define "html" }} -{{ with .GetPage "/c3d" }}{{.Render}}{{end}} -{{ end }} diff --git a/docs/README.rst b/docs/README.rst new file mode 100644 index 0000000..1ae953a --- /dev/null +++ b/docs/README.rst @@ -0,0 +1,11 @@ +Building the docs +----------------- + + +Requirement for building the documentation is the pdoc3 package:: + + pip install pdoc3 + +and once installed the package can be built from the root directory with the command:: + + pdoc --html c3d --force --config show_source_code=False --output-dir docs diff --git a/docs/index.md b/docs/index.md index 78faf4e..f89b494 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,3 +1,21 @@ -{{ define "html" }} -{{ with .GetPage "/c3d" }}{{.Render}}{{end}} -{{ end }} + +Documentation +---------- + +https://mattiasfredriksson.github.io/py-c3d/c3d/ + + +Installing +---------- + +Currently pip installation is outdate, installation requires cloning the github +repository and build and install using the normal Python setup process:: + + git clone https://github.com/EmbodiedCognition/py-c3d + cd py-c3d + python setup.py install + + +or install the outdated version with pip:: + + pip install c3d From 7817533af3799b1c22f29611dd43e6dbc9df84f7 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 17:26:46 +0200 Subject: [PATCH 076/120] Links --- docs/README.rst | 7 ++++++- docs/index.md | 10 ++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/README.rst b/docs/README.rst index 1ae953a..97630cd 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -1,3 +1,8 @@ +Reading the docs +----------------- + +Documentation is available at: https://mattiasfredriksson.github.io/py-c3d/c3d/ + Building the docs ----------------- @@ -6,6 +11,6 @@ Requirement for building the documentation is the pdoc3 package:: pip install pdoc3 -and once installed the package can be built from the root directory with the command:: +Once installed, documentation can be updated from the root directory with the command:: pdoc --html c3d --force --config show_source_code=False --output-dir docs diff --git a/docs/index.md b/docs/index.md index f89b494..2c99bf6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,15 +1,17 @@ -Documentation ----------- +* [Documentation] + +* [Scripts] -https://mattiasfredriksson.github.io/py-c3d/c3d/ +[Documentation]: https://mattiasfredriksson.github.io/py-c3d/c3d/ +[Scripts]: ../scripts Installing ---------- Currently pip installation is outdate, installation requires cloning the github -repository and build and install using the normal Python setup process:: +repository and installing using the Python setup process:: git clone https://github.com/EmbodiedCognition/py-c3d cd py-c3d From c02fd0821a1312668ece0aa0e31492dbef073281 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 18:41:03 +0200 Subject: [PATCH 077/120] doc main page update --- docs/README.rst | 6 +++++- docs/index.md | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/docs/README.rst b/docs/README.rst index 97630cd..67926b7 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -7,10 +7,14 @@ Building the docs ----------------- -Requirement for building the documentation is the pdoc3 package:: +Building the docs requires the pdoc3 package:: pip install pdoc3 Once installed, documentation can be updated from the root directory with the command:: pdoc --html c3d --force --config show_source_code=False --output-dir docs + +Once updated you can access the documentation in the `docs/c3d/`_ folder. + +.. _docs/c3d/: docs/c3d/index.html diff --git a/docs/index.md b/docs/index.md index 2c99bf6..0828fc1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,20 +4,20 @@ * [Scripts] -[Documentation]: https://mattiasfredriksson.github.io/py-c3d/c3d/ -[Scripts]: ../scripts +[Documentation]: ./c3d/ +[Scripts]: https://github.com/EmbodiedCognition/py-c3d/tree/master/scripts Installing ---------- Currently pip installation is outdate, installation requires cloning the github -repository and installing using the Python setup process:: +repository and installing using the Python setup process: git clone https://github.com/EmbodiedCognition/py-c3d cd py-c3d python setup.py install -or install the outdated version with pip:: +or install the outdated version with pip: pip install c3d From c101962721ca74c5312f2215c283aac05859bc91 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 18:42:25 +0200 Subject: [PATCH 078/120] Docs --- docs/README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.rst b/docs/README.rst index 67926b7..9fa877f 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -17,4 +17,4 @@ Once installed, documentation can be updated from the root directory with the co Once updated you can access the documentation in the `docs/c3d/`_ folder. -.. _docs/c3d/: docs/c3d/index.html +.. _docs/c3d/: ./c3d/index.html From 1c6fbced08a00b04cb4c9fa47d6a991f9994cc9b Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 18:44:26 +0200 Subject: [PATCH 079/120] Update README.rst Folder not index --- docs/README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/README.rst b/docs/README.rst index 9fa877f..2003a97 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -17,4 +17,4 @@ Once installed, documentation can be updated from the root directory with the co Once updated you can access the documentation in the `docs/c3d/`_ folder. -.. _docs/c3d/: ./c3d/index.html +.. _docs/c3d/: ./c3d From d76722aec5ad2b70667500e811a2242765b4b016 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 19:02:33 +0200 Subject: [PATCH 080/120] Updated docs --- README.rst | 13 +------------ c3d/__init__.py | 7 +++++++ docs/c3d/index.html | 10 ++++------ docs/examples.md | 9 --------- test/README.rst | 10 ++++++++++ 5 files changed, 22 insertions(+), 27 deletions(-) create mode 100644 test/README.rst diff --git a/README.rst b/README.rst index b836757..751d6a9 100644 --- a/README.rst +++ b/README.rst @@ -29,7 +29,6 @@ This package includes scripts_ for converting C3D motion data to CSV format (``c3d2csv``) and an OpenGL-based visualization tool for observing the motion described by a C3D file (``c3d-viewer``). -.. _package documentation: http://c3d.readthedocs.org .. _scripts: ./scripts Library @@ -50,18 +49,8 @@ To use the C3D library, just import the package and create a ``Reader`` or You can also get and set metadata fields using the library; see the `package documentation`_ for more details. -.. _package documentation: http://c3d.readthedocs.org +.. _package documentation: https://mattiasfredriksson.github.io/py-c3d/c3d/ -Tests -~~~~~ - -To run tests available in the test folder, following command can be run from the root of the package directory:: - - python -m unittest discover . - -Test scripts will automatically download test files from `c3d.org`_. - -.. _c3d.org: https://www.c3d.org/sampledata.html Caveats ------- diff --git a/c3d/__init__.py b/c3d/__init__.py index b985753..5731759 100644 --- a/c3d/__init__.py +++ b/c3d/__init__.py @@ -8,6 +8,13 @@ [C3D file format]: https://www.c3d.org/HTML/default.htm +Installing +---------- + +See the [main page] https://mattiasfredriksson.github.io/py-c3d/ + +[C3D file format]: https://www.c3d.org/HTML/default.htm + .. include:: ../docs/examples.md """ diff --git a/docs/c3d/index.html b/docs/c3d/index.html index cac4396..158a29d 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -27,13 +27,11 @@

Package c3d

Python C3D Processing

This package provides pure Python modules for reading, writing, and editing binary motion-capture files in the C3D file format.

-

Scripts

-

The package installation comes with some pre-packaged scripts for -viewing, and converting .c3d files to .csv and .npz formats. -See the script directory on github for more information.

+

Installing

+

See the [main page] https://mattiasfredriksson.github.io/py-c3d/

Examples

Access to data blocks in a .c3d file is provided through the Reader and Writer classes. -Runnable implementation of the examples are provided in the examples/ directory in the github repository.

+Runnable implementation of the examples are provided in the examples/ directory in the github repository.

Reading

To read the frames from a C3D file, use a Reader instance:

import c3d
@@ -144,7 +142,7 @@ 

Index

  • Python C3D Processing
  • -
  • Scripts
  • +
  • Installing
  • Examples
    • Reading
    • Writing
    • diff --git a/docs/examples.md b/docs/examples.md index d07e75a..27f0b05 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -1,12 +1,3 @@ -Scripts -======== - -The package installation comes with some pre-packaged scripts for -viewing, and converting .c3d files to .csv and .npz formats. -See the [scripts/] directory on github for more information. - -[scripts/]: https://github.com/EmbodiedCognition/py-c3d/tree/master/scripts - Examples ======== diff --git a/test/README.rst b/test/README.rst new file mode 100644 index 0000000..bf286c0 --- /dev/null +++ b/test/README.rst @@ -0,0 +1,10 @@ +Tests +~~~~~ + +To run tests, use the following command from the root of the package directory:: + + python -m unittest discover . + +Test scripts will automatically download test files from `c3d.org`_. + +.. _c3d.org: https://www.c3d.org/sampledata.html From 58fe5422a97f1c9063489802f9d9023fe73eb596 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 19:13:48 +0200 Subject: [PATCH 081/120] corrected main page link --- README.rst | 4 ++-- c3d/__init__.py | 4 ++-- docs/c3d/index.html | 2 +- docs/index.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 751d6a9..b93d717 100644 --- a/README.rst +++ b/README.rst @@ -8,7 +8,7 @@ data recorded by a 3D motion tracking apparatus. Installing ---------- -Install with pip:: +Install with pip (currently outdated, download package from github instead):: pip install c3d @@ -34,7 +34,7 @@ described by a C3D file (``c3d-viewer``). Library ~~~~~~~ -To use the C3D library, just import the package and create a ``Reader`` or +To use the C3D library, just import the package and create a ``Reader`` and/or ``Writer`` depending on your intended usage .. code-block:: python diff --git a/c3d/__init__.py b/c3d/__init__.py index 5731759..be4619e 100644 --- a/c3d/__init__.py +++ b/c3d/__init__.py @@ -11,9 +11,9 @@ Installing ---------- -See the [main page] https://mattiasfredriksson.github.io/py-c3d/ +See the [main page]. -[C3D file format]: https://www.c3d.org/HTML/default.htm +[main page]: https://mattiasfredriksson.github.io/py-c3d/ .. include:: ../docs/examples.md diff --git a/docs/c3d/index.html b/docs/c3d/index.html index 158a29d..0474685 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -28,7 +28,7 @@

      Python C3D Processing

      This package provides pure Python modules for reading, writing, and editing binary motion-capture files in the C3D file format.

      Installing

      -

      See the [main page] https://mattiasfredriksson.github.io/py-c3d/

      +

      See the main page.

      Examples

      Access to data blocks in a .c3d file is provided through the Reader and Writer classes. Runnable implementation of the examples are provided in the examples/ directory in the github repository.

      diff --git a/docs/index.md b/docs/index.md index 0828fc1..5535bfa 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ * [Documentation] -* [Scripts] +* [Tool scripts] [Documentation]: ./c3d/ From 647e06a9235a939f3daa67c18c19e05fa6d56b6e Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 20:04:54 +0200 Subject: [PATCH 082/120] Update README.rst --- README.rst | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/README.rst b/README.rst index b93d717..0af68d9 100644 --- a/README.rst +++ b/README.rst @@ -22,6 +22,8 @@ repository and build and install using the normal Python setup process:: Usage ----- +Documentation and examples are available in the `package documentation`_. + Tools ~~~~~ @@ -41,29 +43,32 @@ To use the C3D library, just import the package and create a ``Reader`` and/or import c3d - with open('data.c3d', 'rb') as handle: - reader = c3d.Reader(handle) - for i, (points, analog) in enumerate(reader.read_frames()): - print('Frame {}: {}'.format(i, points.round(2))) + with open('my-motion.c3d', 'rb') as file: + reader = c3d.Reader(file) + for i, points, analog in reader.read_frames(): + print('frame {}: point {}, analog {}'.format( + i, points.shape, analog.shape)) You can also get and set metadata fields using the library; see the `package documentation`_ for more details. .. _package documentation: https://mattiasfredriksson.github.io/py-c3d/c3d/ - Caveats ------- -This library is minimally effective, in the sense that the only motion tracking -system I have access to (for testing) is a Phasespace system. If you try out the -library and find that it doesn't work with your motion tracking system, let me -know. Pull requests are also welcome! +The package is tested against the `software examples`_ but may still not support +every possible format. For example, parameters serialized in multiple parameters +are not handled automatically (such as POINT:LABELS and POINT:LABELS2). Reading and +writing files from a big-endian system is also not supported. + +Tests are currently only run on Windows, which means that Linux and Mac users may +experience some issues. If you experience issues with a file or feature, feel free +to post an issue (preferably by including example file/code/python exception) +or make a pull request! + +The package is also Python only, for other languages there are other packages such as `ezc3d`_. -Also, if you're looking for more functionality than just reading and writing C3D -files, there are a lot of better toolkits out there that support a lot more file -formats and provide more functionality, perhaps at the cost of increased -complexity. The `biomechanical toolkit`_ is a good package for analyzing motion -data. +.. _software examples: https://www.c3d.org/sampledata.html +.. _ezc3d: https://github.com/pyomeca/ezc3d -.. _biomechanical toolkit: http://code.google.com/p/b-tk/ From ec4883102fa1dfd3015ece234522697c5bc1d855 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 20:06:04 +0200 Subject: [PATCH 083/120] Scripts listing --- docs/index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/index.md b/docs/index.md index 5535bfa..0828fc1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,7 +1,7 @@ * [Documentation] -* [Tool scripts] +* [Scripts] [Documentation]: ./c3d/ From 77048d5b7f84ac6a58e70aeaddc1304934076302 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 20:09:04 +0200 Subject: [PATCH 084/120] Version 0.6.0 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 42bae93..d795261 100644 --- a/setup.py +++ b/setup.py @@ -3,7 +3,7 @@ setuptools.setup( name='c3d', - version='0.5.0', + version='0.6.0', py_modules=['c3d.dtypes', 'c3d.group', 'c3d.header', 'c3d.manager', 'c3d.reader', 'c3d.writer', 'c3d.parameter', 'c3d.utils', 'scripts.c3d-viewer'], author='UT Vision, Cognition, and Action Lab', author_email='leif@cs.utexas.edu', From 35e25cb8c7d2ec490b72c634f944ee656f8c36d5 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 20:33:00 +0200 Subject: [PATCH 085/120] Updated docs --- c3d/__init__.py | 3 ++- docs/c3d/index.html | 12 ++++++------ docs/examples.md | 10 +++++----- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/c3d/__init__.py b/c3d/__init__.py index be4619e..cb3c41e 100644 --- a/c3d/__init__.py +++ b/c3d/__init__.py @@ -11,9 +11,10 @@ Installing ---------- -See the [main page]. +See the [main page] or [github page]. [main page]: https://mattiasfredriksson.github.io/py-c3d/ +[github page]: https://github.com/EmbodiedCognition/py-c3d .. include:: ../docs/examples.md diff --git a/docs/c3d/index.html b/docs/c3d/index.html index 0474685..774bb4e 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -28,7 +28,7 @@

      Python C3D Processing

      This package provides pure Python modules for reading, writing, and editing binary motion-capture files in the C3D file format.

      Installing

      -

      See the main page.

      +

      See the main page or github page.

      Examples

      Access to data blocks in a .c3d file is provided through the Reader and Writer classes. Runnable implementation of the examples are provided in the examples/ directory in the github repository.

      @@ -71,16 +71,16 @@

      Writing

The function Writer.add_frames() take pairs of numpy or python arrays, with the first array in each tuple defining point data and the second -defining the analog data for the frame. Leaving one of the arrays empty indicates +analog data for the frame. Leaving one of the arrays empty indicates to the writer that no analog — or point data— should be included in the file. References of the data arrays are tracked until Writer.write() -is called, which serializes metadata and data frames into a C3D binary file stream.

+is called, which serializes the metadata and data frames into a C3D binary file stream.

Editing

Editing c3d files is possible by combining the use of Reader and Writer -instances through the use of Reader.to_writer(). By opening a .c3d file stream through +instances through the use of Reader.to_writer(). Opening a .c3d file stream through a reader instance, Reader.to_writer() can be used to create an independent Writer instance -copying the file contents onto the heap. Rereading the c3d.reader frame data from the file -and inserting the frames in reverse, a looped version of the .c3d file can be created!

+containing a heap copy of the file contents. Rereading the data frames from the file +and inserting into the writer in reverse, a looped version of the .c3d file can be created!

import c3d
 
 with open('my-motion.c3d', 'rb') as file:
diff --git a/docs/examples.md b/docs/examples.md
index 27f0b05..d9d63e4 100644
--- a/docs/examples.md
+++ b/docs/examples.md
@@ -52,19 +52,19 @@ instance:
 
 The function `c3d.writer.Writer.add_frames` take pairs of ``numpy`` or ``python
 arrays``, with the first array in each tuple defining point data and the second
-defining the analog data for the frame. Leaving one of the arrays empty indicates
+analog data for the frame. Leaving one of the arrays empty indicates
 to the writer that no analog --- or point data--- should be included in the file.
 References of the data arrays are tracked until `c3d.writer.Writer.write`
-is called, which serializes metadata and data frames into a C3D binary file stream.
+is called, which serializes the metadata and data frames into a C3D binary file stream.
 
 Editing
 -------
 
 Editing c3d files is possible by combining the use of `c3d.reader.Reader` and `c3d.writer.Writer`
-instances through the use of `c3d.reader.Reader.to_writer`. By opening a .c3d file stream through
+instances through the use of `c3d.reader.Reader.to_writer`. Opening a .c3d file stream through
 a reader instance, `c3d.reader.Reader.to_writer` can be used to create an independent Writer instance
-copying the file contents onto the heap. Rereading the `reader` frame data from the file
-and inserting the frames in reverse, a looped version of the .c3d file can be created!
+containing a heap copy of the file contents. Rereading the data frames from the file
+and inserting into the writer in reverse, a looped version of the .c3d file can be created!
 
     import c3d
 

From bcba72564c9925401f3b2573c2e0d28d7f878b37 Mon Sep 17 00:00:00 2001
From: MattiasF 
Date: Sun, 4 Jul 2021 21:56:33 +0200
Subject: [PATCH 086/120] Updated docs

---
 c3d/reader.py           |    2 +-
 c3d/writer.py           |   10 +-
 docs/c3d/dtypes.html    |  316 +++++++++
 docs/c3d/group.html     | 1480 +++++++++++++++++++++++++++++++++++++++
 docs/c3d/header.html    |  750 ++++++++++++++++++++
 docs/c3d/index.html     |   82 ++-
 docs/c3d/manager.html   | 1284 +++++++++++++++++++++++++++++++++
 docs/c3d/parameter.html | 1411 +++++++++++++++++++++++++++++++++++++
 docs/c3d/reader.html    |  937 ++++++++++++++++++++++++-
 docs/c3d/utils.html     |  329 +++++++++
 docs/c3d/writer.html    | 1465 +++++++++++++++++++++++++++++++++++++-
 docs/examples.md        |   56 +-
 test/test_examples.py   |    7 +-
 13 files changed, 8102 insertions(+), 27 deletions(-)

diff --git a/c3d/reader.py b/c3d/reader.py
index 2e0d4cf..5a94992 100644
--- a/c3d/reader.py
+++ b/c3d/reader.py
@@ -1,4 +1,4 @@
-'''A Python module for reading and writing C3D files.'''
+'''Contains the Reader class for reading C3D files.'''
 
 import io
 import numpy as np
diff --git a/c3d/writer.py b/c3d/writer.py
index bd6dbe9..3a76a22 100644
--- a/c3d/writer.py
+++ b/c3d/writer.py
@@ -1,4 +1,4 @@
-'''A Python module for reading and writing C3D files.'''
+'''Contains the Writer class for writing C3D files.'''
 
 import copy
 import numpy as np
@@ -167,7 +167,7 @@ def numeric_key_next(self):
         return self.numeric_key_max + 1
 
     def get_create(self, label):
-        ''' Get or create the specified parameter group.'''
+        ''' Get or create a parameter `c3d.group.Group`.'''
         label = label.upper()
         group = self.get(label)
         if group is None:
@@ -194,7 +194,7 @@ def add_group(self, group_id, name, desc):
 
         Returns
         -------
-        group : :class:`Group`
+        group : `c3d.group.Group`
             An editable group instance.
         '''
         return super(Writer, self)._add_group(group_id, name, desc)
@@ -292,7 +292,7 @@ def set_analog_scales(self, values):
         Parameters
         ----------
         values : iterable or None
-            Iterable containing individual scale factors for encoding analog channel data.
+            Iterable containing individual scale factors (float32) for scaling analog channel data.
         '''
         if utils.is_iterable(values):
             data = np.array([v for v in values], dtype=np.float32)
@@ -308,7 +308,7 @@ def set_analog_offsets(self, values):
         Parameters
         ----------
         values : iterable or None
-            Iterable containing individual offsets for encoding analog channel data.
+            Iterable containing individual offsets (int16) for encoding analog channel data.
         '''
         if utils.is_iterable(values):
             data = np.array([v for v in values], dtype=np.int16)
diff --git a/docs/c3d/dtypes.html b/docs/c3d/dtypes.html
index 729ad33..81ca407 100644
--- a/docs/c3d/dtypes.html
+++ b/docs/c3d/dtypes.html
@@ -23,6 +23,121 @@ 

Module c3d.dtypes

State object defining the data types associated with a given .c3d processor format.

+
+ +Expand source code + +
'''
+State object defining the data types associated with a given .c3d processor format.
+'''
+
+import sys
+import codecs
+import numpy as np
+
+PROCESSOR_INTEL = 84
+PROCESSOR_DEC = 85
+PROCESSOR_MIPS = 86
+
+class DataTypes(object):
+    ''' Container defining the data types used when parsing byte data.
+        Data types depend on the processor format the file is stored in.
+    '''
+    def __init__(self, proc_type=PROCESSOR_INTEL):
+        self._proc_type = proc_type
+        self._little_endian_sys = sys.byteorder == 'little'
+        self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \
+                       (self.is_mips and self.big_endian_sys)
+        if self.big_endian_sys:
+            warnings.warn('Systems with native byte order of big-endian are not supported.')
+
+        if self._proc_type == PROCESSOR_MIPS:
+            # Big-Endian (SGI/MIPS format)
+            self.float32 = np.dtype(np.float32).newbyteorder('>')
+            self.float64 = np.dtype(np.float64).newbyteorder('>')
+            self.uint8 = np.uint8
+            self.uint16 = np.dtype(np.uint16).newbyteorder('>')
+            self.uint32 = np.dtype(np.uint32).newbyteorder('>')
+            self.uint64 = np.dtype(np.uint64).newbyteorder('>')
+            self.int8 = np.int8
+            self.int16 = np.dtype(np.int16).newbyteorder('>')
+            self.int32 = np.dtype(np.int32).newbyteorder('>')
+            self.int64 = np.dtype(np.int64).newbyteorder('>')
+        else:
+            # Little-Endian format (Intel or DEC format)
+            self.float32 = np.float32
+            self.float64 = np.float64
+            self.uint8 = np.uint8
+            self.uint16 = np.uint16
+            self.uint32 = np.uint32
+            self.uint64 = np.uint64
+            self.int8 = np.int8
+            self.int16 = np.int16
+            self.int32 = np.int32
+            self.int64 = np.int64
+
+    @property
+    def is_ieee(self) -> bool:
+        ''' True if the associated file is in the Intel format.
+        '''
+        return self._proc_type == PROCESSOR_INTEL
+
+    @property
+    def is_dec(self) -> bool:
+        ''' True if the associated file is in the DEC format.
+        '''
+        return self._proc_type == PROCESSOR_DEC
+
+    @property
+    def is_mips(self) -> bool:
+        ''' True if the associated file is in the SGI/MIPS format.
+        '''
+        return self._proc_type == PROCESSOR_MIPS
+
+    @property
+    def proc_type(self) -> str:
+        ''' Get the processory type associated with the data format in the file.
+        '''
+        processor_type = ['INTEL', 'DEC', 'MIPS']
+        return processor_type[self._proc_type - PROCESSOR_INTEL]
+
+    @property
+    def processor(self) -> int:
+        ''' Get the processor number encoded in the .c3d file.
+        '''
+        return self._proc_type
+
+    @property
+    def native(self) -> bool:
+        ''' True if the native (system) byte order matches the file byte order.
+        '''
+        return self._native
+
+    @property
+    def little_endian_sys(self) -> bool:
+        ''' True if native byte order is little-endian.
+        '''
+        return self._little_endian_sys
+
+    @property
+    def big_endian_sys(self) -> bool:
+        ''' True if native byte order is big-endian.
+        '''
+        return not self._little_endian_sys
+
+    def decode_string(self, bytes) -> str:
+        ''' Decode a byte array to a string.
+        '''
+        # Attempt to decode using different decoders
+        decoders = ['utf-8', 'latin-1']
+        for dec in decoders:
+            try:
+                return codecs.decode(bytes, dec)
+            except UnicodeDecodeError:
+                continue
+        # Revert to using default decoder but replace characters
+        return codecs.decode(bytes, decoders[0], 'replace')
+
@@ -40,39 +155,223 @@

Classes

Container defining the data types used when parsing byte data. Data types depend on the processor format the file is stored in.

+
+ +Expand source code + +
class DataTypes(object):
+    ''' Container defining the data types used when parsing byte data.
+        Data types depend on the processor format the file is stored in.
+    '''
+    def __init__(self, proc_type=PROCESSOR_INTEL):
+        self._proc_type = proc_type
+        self._little_endian_sys = sys.byteorder == 'little'
+        self._native = ((self.is_ieee or self.is_dec) and self.little_endian_sys) or \
+                       (self.is_mips and self.big_endian_sys)
+        if self.big_endian_sys:
+            warnings.warn('Systems with native byte order of big-endian are not supported.')
+
+        if self._proc_type == PROCESSOR_MIPS:
+            # Big-Endian (SGI/MIPS format)
+            self.float32 = np.dtype(np.float32).newbyteorder('>')
+            self.float64 = np.dtype(np.float64).newbyteorder('>')
+            self.uint8 = np.uint8
+            self.uint16 = np.dtype(np.uint16).newbyteorder('>')
+            self.uint32 = np.dtype(np.uint32).newbyteorder('>')
+            self.uint64 = np.dtype(np.uint64).newbyteorder('>')
+            self.int8 = np.int8
+            self.int16 = np.dtype(np.int16).newbyteorder('>')
+            self.int32 = np.dtype(np.int32).newbyteorder('>')
+            self.int64 = np.dtype(np.int64).newbyteorder('>')
+        else:
+            # Little-Endian format (Intel or DEC format)
+            self.float32 = np.float32
+            self.float64 = np.float64
+            self.uint8 = np.uint8
+            self.uint16 = np.uint16
+            self.uint32 = np.uint32
+            self.uint64 = np.uint64
+            self.int8 = np.int8
+            self.int16 = np.int16
+            self.int32 = np.int32
+            self.int64 = np.int64
+
+    @property
+    def is_ieee(self) -> bool:
+        ''' True if the associated file is in the Intel format.
+        '''
+        return self._proc_type == PROCESSOR_INTEL
+
+    @property
+    def is_dec(self) -> bool:
+        ''' True if the associated file is in the DEC format.
+        '''
+        return self._proc_type == PROCESSOR_DEC
+
+    @property
+    def is_mips(self) -> bool:
+        ''' True if the associated file is in the SGI/MIPS format.
+        '''
+        return self._proc_type == PROCESSOR_MIPS
+
+    @property
+    def proc_type(self) -> str:
+        ''' Get the processory type associated with the data format in the file.
+        '''
+        processor_type = ['INTEL', 'DEC', 'MIPS']
+        return processor_type[self._proc_type - PROCESSOR_INTEL]
+
+    @property
+    def processor(self) -> int:
+        ''' Get the processor number encoded in the .c3d file.
+        '''
+        return self._proc_type
+
+    @property
+    def native(self) -> bool:
+        ''' True if the native (system) byte order matches the file byte order.
+        '''
+        return self._native
+
+    @property
+    def little_endian_sys(self) -> bool:
+        ''' True if native byte order is little-endian.
+        '''
+        return self._little_endian_sys
+
+    @property
+    def big_endian_sys(self) -> bool:
+        ''' True if native byte order is big-endian.
+        '''
+        return not self._little_endian_sys
+
+    def decode_string(self, bytes) -> str:
+        ''' Decode a byte array to a string.
+        '''
+        # Attempt to decode using different decoders
+        decoders = ['utf-8', 'latin-1']
+        for dec in decoders:
+            try:
+                return codecs.decode(bytes, dec)
+            except UnicodeDecodeError:
+                continue
+        # Revert to using default decoder but replace characters
+        return codecs.decode(bytes, decoders[0], 'replace')
+

Instance variables

var big_endian_sys : bool

True if native byte order is big-endian.

+
+ +Expand source code + +
@property
+def big_endian_sys(self) -> bool:
+    ''' True if native byte order is big-endian.
+    '''
+    return not self._little_endian_sys
+
var is_dec : bool

True if the associated file is in the DEC format.

+
+ +Expand source code + +
@property
+def is_dec(self) -> bool:
+    ''' True if the associated file is in the DEC format.
+    '''
+    return self._proc_type == PROCESSOR_DEC
+
var is_ieee : bool

True if the associated file is in the Intel format.

+
+ +Expand source code + +
@property
+def is_ieee(self) -> bool:
+    ''' True if the associated file is in the Intel format.
+    '''
+    return self._proc_type == PROCESSOR_INTEL
+
var is_mips : bool

True if the associated file is in the SGI/MIPS format.

+
+ +Expand source code + +
@property
+def is_mips(self) -> bool:
+    ''' True if the associated file is in the SGI/MIPS format.
+    '''
+    return self._proc_type == PROCESSOR_MIPS
+
var little_endian_sys : bool

True if native byte order is little-endian.

+
+ +Expand source code + +
@property
+def little_endian_sys(self) -> bool:
+    ''' True if native byte order is little-endian.
+    '''
+    return self._little_endian_sys
+
var native : bool

True if the native (system) byte order matches the file byte order.

+
+ +Expand source code + +
@property
+def native(self) -> bool:
+    ''' True if the native (system) byte order matches the file byte order.
+    '''
+    return self._native
+
var proc_type : str

Get the processory type associated with the data format in the file.

+
+ +Expand source code + +
@property
+def proc_type(self) -> str:
+    ''' Get the processory type associated with the data format in the file.
+    '''
+    processor_type = ['INTEL', 'DEC', 'MIPS']
+    return processor_type[self._proc_type - PROCESSOR_INTEL]
+
var processor : int

Get the processor number encoded in the .c3d file.

+
+ +Expand source code + +
@property
+def processor(self) -> int:
+    ''' Get the processor number encoded in the .c3d file.
+    '''
+    return self._proc_type
+

Methods

@@ -82,6 +381,23 @@

Methods

Decode a byte array to a string.

+
+ +Expand source code + +
def decode_string(self, bytes) -> str:
+    ''' Decode a byte array to a string.
+    '''
+    # Attempt to decode using different decoders
+    decoders = ['utf-8', 'latin-1']
+    for dec in decoders:
+        try:
+            return codecs.decode(bytes, dec)
+        except UnicodeDecodeError:
+            continue
+    # Revert to using default decoder but replace characters
+    return codecs.decode(bytes, decoders[0], 'replace')
+
diff --git a/docs/c3d/group.html b/docs/c3d/group.html index 15d8f73..833dd60 100644 --- a/docs/c3d/group.html +++ b/docs/c3d/group.html @@ -23,6 +23,473 @@

Module c3d.group

Classes used to represent the concept of parameter groups in a .c3d file.

+
+ +Expand source code + +
''' Classes used to represent the concept of parameter groups in a .c3d file.
+'''
+import struct
+import numpy as np
+from .parameter import ParamData, Param
+from .utils import Decorator
+
+class GroupData(object):
+    '''A group of parameters stored in a C3D file.
+
+    In C3D files, parameters are organized in groups. Each group has a name (key), a
+    description, and a set of named parameters. Each group is also internally associated
+    with a numeric key.
+
+    Attributes
+    ----------
+    dtypes : `c3d.dtypes.DataTypes`
+        Data types object used for parsing.
+    name : str
+        Name of this parameter group.
+    desc : str
+        Description for this parameter group.
+    '''
+
+    def __init__(self, dtypes, name=None, desc=None):
+        self._params = {}
+        self._dtypes = dtypes
+        # Assign through property setters
+        self.set_name(name)
+        self.set_desc(desc)
+
+    def __repr__(self):
+        return '<Group: {}>'.format(self.desc)
+
+    def __contains__(self, key):
+        return key in self._params
+
+    def __getitem__(self, key):
+        return self._params[key]
+
+    @property
+    def binary_size(self) -> int:
+        '''Return the number of bytes to store this group and its parameters.'''
+        return (
+            1 +  # group_id
+            1 + len(self.name.encode('utf-8')) +  # size of name and name bytes
+            2 +  # next offset marker
+            1 + len(self.desc.encode('utf-8')) +  # size of desc and desc bytes
+            sum(p.binary_size for p in self._params.values()))
+
+    def set_name(self, name):
+        ''' Set the group name string. '''
+        if name is None or isinstance(name, str):
+            self.name = name
+        else:
+            raise TypeError('Expected group name to be string, was %s.' % type(name))
+
+    def set_desc(self, desc):
+        ''' Set the Group descriptor.
+        '''
+        if isinstance(desc, bytes):
+            self.desc = self._dtypes.decode_string(desc)
+        elif isinstance(desc, str) or desc is None:
+            self.desc = desc
+        else:
+            raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(desc))
+
+    def add_param(self, name, **kwargs):
+        '''Add a parameter to this group.
+
+        Parameters
+        ----------
+        name : str
+            Name of the parameter to add to this group. The name will
+            automatically be case-normalized.
+
+        See constructor of `c3d.parameter.ParamData` for additional keyword arguments.
+
+        Raises
+        ------
+        TypeError
+            Input arguments are of the wrong type.
+        KeyError
+            Name or numerical key already exist (attempt to overwrite existing data).
+        '''
+        if not isinstance(name, str):
+            raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name)))
+        name = name.upper()
+        if name in self._params:
+            raise KeyError('Parameter already exists with key {}'.format(name))
+        self._params[name] = Param(ParamData(name, self._dtypes, **kwargs))
+
+    def remove_param(self, name):
+        '''Remove the specified parameter.
+
+        Parameters
+        ----------
+        name : str
+            Name for the parameter to remove.
+        '''
+        del self._params[name]
+
+    def rename_param(self, name, new_name):
+        ''' Rename a specified parameter group.
+
+        Parameters
+        ----------
+        name : str, or `c3d.group.GroupReadonly`
+            Parameter instance, or name.
+        new_name : str
+            New name for the parameter.
+        Raises
+        ------
+        KeyError
+            If no parameter with the original name exists.
+        ValueError
+            If the new name already exist (attempt to overwrite existing data).
+        '''
+        if new_name in self._params:
+            raise ValueError("Key {} already exist.".format(new_name))
+        if isinstance(name, Param):
+            param = name
+            name = param.name
+        else:
+            # Aquire instance using id
+            param = self._params[name]
+        del self._params[name]
+        self._params[new_name] = param
+
+    def write(self, group_id, handle):
+        '''Write this parameter group, with parameters, to a file handle.
+
+        Parameters
+        ----------
+        group_id : int
+            The numerical ID of the group.
+        handle : file handle
+            An open, writable, binary file handle.
+        '''
+        name = self.name.encode('utf-8')
+        desc = self.desc.encode('utf-8')
+        handle.write(struct.pack('bb', len(name), -group_id))
+        handle.write(name)
+        handle.write(struct.pack('<h', 3 + len(desc)))
+        handle.write(struct.pack('B', len(desc)))
+        handle.write(desc)
+        for param in self._params.values():
+            param._data.write(group_id, handle)
+
+class GroupReadonly(object):
+    ''' Wrapper exposing readonly attributes of a `c3d.group.GroupData` entry.
+    '''
+    def __init__(self, data):
+        self._data = data
+
+    def __contains__(self, key):
+        return key in self._data._params
+
+    def __eq__(self, other):
+        return self._data is other._data
+
+    @property
+    def name(self) -> str:
+        ''' Access group name. '''
+        return self._data.name
+
+    @property
+    def desc(self) -> str:
+        '''Access group descriptor. '''
+        return self._data.desc
+
+    def items(self):
+        ''' Get iterator for paramater key-entry pairs. '''
+        return ((k, v.readonly()) for k, v in self._data._params.items())
+
+    def values(self):
+        ''' Get iterator for parameter entries. '''
+        return (v.readonly() for v in self._data._params.values())
+
+    def keys(self):
+        ''' Get iterator for parameter entry keys. '''
+        return self._data._params.keys()
+
+    def get(self, key, default=None):
+        '''Get a readonly parameter by key.
+
+        Parameters
+        ----------
+        key : any
+            Parameter key to look up in this group.
+        default : any, optional
+            Value to return if the key is not found. Defaults to None.
+
+        Returns
+        -------
+        param : :class:`ParamReadable`
+            A parameter from the current group.
+        '''
+        val = self._data._params.get(key, default)
+        if val:
+            return val.readonly()
+        return default
+
+    def get_int8(self, key):
+        '''Get the value of the given parameter as an 8-bit signed integer.'''
+        return self._data[key.upper()].int8_value
+
+    def get_uint8(self, key):
+        '''Get the value of the given parameter as an 8-bit unsigned integer.'''
+        return self._data[key.upper()].uint8_value
+
+    def get_int16(self, key):
+        '''Get the value of the given parameter as a 16-bit signed integer.'''
+        return self._data[key.upper()].int16_value
+
+    def get_uint16(self, key):
+        '''Get the value of the given parameter as a 16-bit unsigned integer.'''
+        return self._data[key.upper()].uint16_value
+
+    def get_int32(self, key):
+        '''Get the value of the given parameter as a 32-bit signed integer.'''
+        return self._data[key.upper()].int32_value
+
+    def get_uint32(self, key):
+        '''Get the value of the given parameter as a 32-bit unsigned integer.'''
+        return self._data[key.upper()].uint32_value
+
+    def get_float(self, key):
+        '''Get the value of the given parameter as a 32-bit float.'''
+        return self._data[key.upper()].float_value
+
+    def get_bytes(self, key):
+        '''Get the value of the given parameter as a byte array.'''
+        return self._data[key.upper()].bytes_value
+
+    def get_string(self, key):
+        '''Get the value of the given parameter as a string.'''
+        return self._data[key.upper()].string_value
+
+class Group(GroupReadonly):
+    ''' Wrapper exposing readable and writeable attributes of a `c3d.group.GroupData` entry.
+    '''
+    def __init__(self, data):
+        super(Group, self).__init__(data)
+
+    def readonly(self):
+        ''' Returns a `c3d.group.GroupReadonly` instance with readonly access. '''
+        return GroupReadonly(self._data)
+
+    @property
+    def name(self) -> str:
+        ''' Get or set name. '''
+        return self._data.name
+
+    @name.setter
+    def name(self, value) -> str:
+        self._data.set_name(value)
+
+    @property
+    def desc(self) -> str:
+        ''' Get or set descriptor. '''
+        return self._data.desc
+
+    @desc.setter
+    def desc(self, value) -> str:
+        self._data.set_desc(value)
+
+    def items(self):
+        ''' Iterator for paramater key-entry pairs. '''
+        return ((k, v) for k, v in self._data._params.items())
+
+    def values(self):
+        ''' Iterator iterator for parameter entries. '''
+        return (v for v in self._data._params.values())
+
+    def get(self, key, default=None):
+        '''Get a parameter by key.
+
+        Parameters
+        ----------
+        key : any
+            Parameter key to look up in this group.
+        default : any, optional
+            Value to return if the key is not found. Defaults to None.
+
+        Returns
+        -------
+        param : :class:`ParamReadable`
+            A parameter from the current group.
+        '''
+        return self._data._params.get(key, default)
+    #
+    #  Forward param editing
+    #
+    def add_param(self, name, **kwargs):
+        '''Add a parameter to this group.
+
+        See constructor of `c3d.parameter.ParamData` for additional keyword arguments.
+        '''
+        self._data.add_param(name, **kwargs)
+
+    def remove_param(self, name):
+        '''Remove the specified parameter.
+
+        Parameters
+        ----------
+        name : str
+            Name for the parameter to remove.
+        '''
+        self._data.remove_param(name)
+
+    def rename_param(self, name, new_name):
+        ''' Rename a specified parameter group.
+
+        Parameters
+        ----------
+        See arguments in `c3d.group.GroupData.rename_param`.
+        '''
+        self._data.rename_param(name, new_name)
+
+    #
+    #   Convenience functions for adding parameters.
+    #
+    def add(self, name, desc, bpe, format, data, *dimensions):
+        ''' Add a parameter with `data` package formated in accordance with `format`.
+
+        Convenience function for `c3d.group.GroupData.add_param` calling struct.pack() on `data`.
+
+        Example:
+
+        >>> group.set('RATE', 'Point data sample rate', 4, '<f', 100)
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        desc : str
+            Parameter descriptor.
+        bpe : int
+            Number of bytes for each atomic element.
+        format : str or None
+            `struct.format()` compatible format string see:
+            https://docs.python.org/3/library/struct.html#format-characters
+        *dimensions : int, optional
+            Shape associated with the data (if the data argument represents multiple elements).
+        '''
+        if isinstance(data, bytes):
+            pass
+        else:
+            data = struct.pack(format, data)
+
+        self.add_param(name,
+                       desc=desc,
+                       bytes_per_element=bpe,
+                       bytes=data,
+                       dimensions=list(dimensions))
+
+    def add_array(self, name, desc, data, dtype=None):
+        '''Add a parameter with the `data` package.
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        desc : str
+            Parameter descriptor.
+        data : np.ndarray, or iterable
+            Data array to encode in the parameter.
+        dtype : np.dtype, optional
+            Numpy data type used to encode the array (optional only if `data.dtype` returns a numpy type).
+        '''
+        if not isinstance(data, np.ndarray):
+            if dtype is None:
+                dtype = data.dtype
+            data = np.array(data, dtype=dtype)
+        elif dtype is None:
+            dtype = data.dtype
+
+        self.add_param(name,
+                       desc=desc,
+                       bytes_per_element=dtype.itemsize,
+                       bytes=data.tobytes(),
+                       dimensions=data.shape[::-1])
+
+    def add_str(self, name, desc, data, *dimensions):
+        ''' Add a string parameter.
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        desc : str
+            Parameter descriptor.
+        data : str
+            String to encode in the parameter.
+        *dimensions : int, optional
+            Shape associated with the string (if the string represents multiple elements).
+        '''
+        shape = list(dimensions)
+        self.add_param(name,
+                       desc=desc,
+                       bytes_per_element=-1,
+                       bytes=data.encode('utf-8'),
+                       dimensions=shape or [len(data)])
+
+    def add_empty_array(self, name, desc='', bpe=1):
+        ''' Add an empty parameter block.
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        '''
+        self.add_param(name, desc=desc,
+                       bytes_per_element=bpe, dimensions=[0])
+
+    #
+    #   Convenience functions for adding or overwriting parameters.
+    #
+    def set(self, name, *args, **kwargs):
+        ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'.
+
+        See arguments in `c3d.group.Group.add`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add(name, *args, **kwargs)
+
+    def set_str(self, name, *args, **kwargs):
+        ''' Add or overwrite a string parameter.
+
+        See arguments in `c3d.group.Group.add_str`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add_str(name, *args, **kwargs)
+
+    def set_array(self, name, *args, **kwargs):
+        ''' Add or overwrite a parameter with the `data` package.
+
+        See arguments in `c3d.group.Group.add_array`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add_array(name, *args, **kwargs)
+
+    def set_empty_array(self, name, *args, **kwargs):
+        ''' Add an empty parameter block.
+
+        See arguments in `c3d.group.Group.add_empty_array`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add_empty_array(name, *args, **kwargs)
+
@@ -39,6 +506,234 @@

Classes

Wrapper exposing readable and writeable attributes of a GroupData entry.

+
+ +Expand source code + +
class Group(GroupReadonly):
+    ''' Wrapper exposing readable and writeable attributes of a `c3d.group.GroupData` entry.
+    '''
+    def __init__(self, data):
+        super(Group, self).__init__(data)
+
+    def readonly(self):
+        ''' Returns a `c3d.group.GroupReadonly` instance with readonly access. '''
+        return GroupReadonly(self._data)
+
+    @property
+    def name(self) -> str:
+        ''' Get or set name. '''
+        return self._data.name
+
+    @name.setter
+    def name(self, value) -> str:
+        self._data.set_name(value)
+
+    @property
+    def desc(self) -> str:
+        ''' Get or set descriptor. '''
+        return self._data.desc
+
+    @desc.setter
+    def desc(self, value) -> str:
+        self._data.set_desc(value)
+
+    def items(self):
+        ''' Iterator for paramater key-entry pairs. '''
+        return ((k, v) for k, v in self._data._params.items())
+
+    def values(self):
+        ''' Iterator iterator for parameter entries. '''
+        return (v for v in self._data._params.values())
+
+    def get(self, key, default=None):
+        '''Get a parameter by key.
+
+        Parameters
+        ----------
+        key : any
+            Parameter key to look up in this group.
+        default : any, optional
+            Value to return if the key is not found. Defaults to None.
+
+        Returns
+        -------
+        param : :class:`ParamReadable`
+            A parameter from the current group.
+        '''
+        return self._data._params.get(key, default)
+    #
+    #  Forward param editing
+    #
+    def add_param(self, name, **kwargs):
+        '''Add a parameter to this group.
+
+        See constructor of `c3d.parameter.ParamData` for additional keyword arguments.
+        '''
+        self._data.add_param(name, **kwargs)
+
+    def remove_param(self, name):
+        '''Remove the specified parameter.
+
+        Parameters
+        ----------
+        name : str
+            Name for the parameter to remove.
+        '''
+        self._data.remove_param(name)
+
+    def rename_param(self, name, new_name):
+        ''' Rename a specified parameter group.
+
+        Parameters
+        ----------
+        See arguments in `c3d.group.GroupData.rename_param`.
+        '''
+        self._data.rename_param(name, new_name)
+
+    #
+    #   Convenience functions for adding parameters.
+    #
+    def add(self, name, desc, bpe, format, data, *dimensions):
+        ''' Add a parameter with `data` package formated in accordance with `format`.
+
+        Convenience function for `c3d.group.GroupData.add_param` calling struct.pack() on `data`.
+
+        Example:
+
+        >>> group.set('RATE', 'Point data sample rate', 4, '<f', 100)
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        desc : str
+            Parameter descriptor.
+        bpe : int
+            Number of bytes for each atomic element.
+        format : str or None
+            `struct.format()` compatible format string see:
+            https://docs.python.org/3/library/struct.html#format-characters
+        *dimensions : int, optional
+            Shape associated with the data (if the data argument represents multiple elements).
+        '''
+        if isinstance(data, bytes):
+            pass
+        else:
+            data = struct.pack(format, data)
+
+        self.add_param(name,
+                       desc=desc,
+                       bytes_per_element=bpe,
+                       bytes=data,
+                       dimensions=list(dimensions))
+
+    def add_array(self, name, desc, data, dtype=None):
+        '''Add a parameter with the `data` package.
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        desc : str
+            Parameter descriptor.
+        data : np.ndarray, or iterable
+            Data array to encode in the parameter.
+        dtype : np.dtype, optional
+            Numpy data type used to encode the array (optional only if `data.dtype` returns a numpy type).
+        '''
+        if not isinstance(data, np.ndarray):
+            if dtype is None:
+                dtype = data.dtype
+            data = np.array(data, dtype=dtype)
+        elif dtype is None:
+            dtype = data.dtype
+
+        self.add_param(name,
+                       desc=desc,
+                       bytes_per_element=dtype.itemsize,
+                       bytes=data.tobytes(),
+                       dimensions=data.shape[::-1])
+
+    def add_str(self, name, desc, data, *dimensions):
+        ''' Add a string parameter.
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        desc : str
+            Parameter descriptor.
+        data : str
+            String to encode in the parameter.
+        *dimensions : int, optional
+            Shape associated with the string (if the string represents multiple elements).
+        '''
+        shape = list(dimensions)
+        self.add_param(name,
+                       desc=desc,
+                       bytes_per_element=-1,
+                       bytes=data.encode('utf-8'),
+                       dimensions=shape or [len(data)])
+
+    def add_empty_array(self, name, desc='', bpe=1):
+        ''' Add an empty parameter block.
+
+        Parameters
+        ----------
+        name : str
+            Parameter name.
+        '''
+        self.add_param(name, desc=desc,
+                       bytes_per_element=bpe, dimensions=[0])
+
+    #
+    #   Convenience functions for adding or overwriting parameters.
+    #
+    def set(self, name, *args, **kwargs):
+        ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'.
+
+        See arguments in `c3d.group.Group.add`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add(name, *args, **kwargs)
+
+    def set_str(self, name, *args, **kwargs):
+        ''' Add or overwrite a string parameter.
+
+        See arguments in `c3d.group.Group.add_str`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add_str(name, *args, **kwargs)
+
+    def set_array(self, name, *args, **kwargs):
+        ''' Add or overwrite a parameter with the `data` package.
+
+        See arguments in `c3d.group.Group.add_array`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add_array(name, *args, **kwargs)
+
+    def set_empty_array(self, name, *args, **kwargs):
+        ''' Add an empty parameter block.
+
+        See arguments in `c3d.group.Group.add_empty_array`.
+        '''
+        try:
+            self.remove_param(name)
+        except KeyError as e:
+            pass
+        self.add_empty_array(name, *args, **kwargs)
+

Ancestors

  • GroupReadonly
  • @@ -48,10 +743,28 @@

    Instance variables

    var desc : str

    Get or set descriptor.

    +
    + +Expand source code + +
    @property
    +def desc(self) -> str:
    +    ''' Get or set descriptor. '''
    +    return self._data.desc
    +
    var name : str

    Get or set name.

    +
    + +Expand source code + +
    @property
    +def name(self) -> str:
    +    ''' Get or set name. '''
    +    return self._data.name
    +

    Methods

    @@ -79,6 +792,44 @@

    Parameters

    *dimensions : int, optional
    Shape associated with the data (if the data argument represents multiple elements).
    +
    + +Expand source code + +
    def add(self, name, desc, bpe, format, data, *dimensions):
    +    ''' Add a parameter with `data` package formated in accordance with `format`.
    +
    +    Convenience function for `c3d.group.GroupData.add_param` calling struct.pack() on `data`.
    +
    +    Example:
    +
    +    >>> group.set('RATE', 'Point data sample rate', 4, '<f', 100)
    +
    +    Parameters
    +    ----------
    +    name : str
    +        Parameter name.
    +    desc : str
    +        Parameter descriptor.
    +    bpe : int
    +        Number of bytes for each atomic element.
    +    format : str or None
    +        `struct.format()` compatible format string see:
    +        https://docs.python.org/3/library/struct.html#format-characters
    +    *dimensions : int, optional
    +        Shape associated with the data (if the data argument represents multiple elements).
    +    '''
    +    if isinstance(data, bytes):
    +        pass
    +    else:
    +        data = struct.pack(format, data)
    +
    +    self.add_param(name,
    +                   desc=desc,
    +                   bytes_per_element=bpe,
    +                   bytes=data,
    +                   dimensions=list(dimensions))
    +
def add_array(self, name, desc, data, dtype=None) @@ -96,6 +847,37 @@

Parameters

dtype : np.dtype, optional
Numpy data type used to encode the array (optional only if data.dtype returns a numpy type).
+
+ +Expand source code + +
def add_array(self, name, desc, data, dtype=None):
+    '''Add a parameter with the `data` package.
+
+    Parameters
+    ----------
+    name : str
+        Parameter name.
+    desc : str
+        Parameter descriptor.
+    data : np.ndarray, or iterable
+        Data array to encode in the parameter.
+    dtype : np.dtype, optional
+        Numpy data type used to encode the array (optional only if `data.dtype` returns a numpy type).
+    '''
+    if not isinstance(data, np.ndarray):
+        if dtype is None:
+            dtype = data.dtype
+        data = np.array(data, dtype=dtype)
+    elif dtype is None:
+        dtype = data.dtype
+
+    self.add_param(name,
+                   desc=desc,
+                   bytes_per_element=dtype.itemsize,
+                   bytes=data.tobytes(),
+                   dimensions=data.shape[::-1])
+
def add_empty_array(self, name, desc='', bpe=1) @@ -107,6 +889,21 @@

Parameters

name : str
Parameter name.
+
+ +Expand source code + +
def add_empty_array(self, name, desc='', bpe=1):
+    ''' Add an empty parameter block.
+
+    Parameters
+    ----------
+    name : str
+        Parameter name.
+    '''
+    self.add_param(name, desc=desc,
+                   bytes_per_element=bpe, dimensions=[0])
+
def add_param(self, name, **kwargs) @@ -114,6 +911,17 @@

Parameters

Add a parameter to this group.

See constructor of ParamData for additional keyword arguments.

+
+ +Expand source code + +
def add_param(self, name, **kwargs):
+    '''Add a parameter to this group.
+
+    See constructor of `c3d.parameter.ParamData` for additional keyword arguments.
+    '''
+    self._data.add_param(name, **kwargs)
+
def add_str(self, name, desc, data, *dimensions) @@ -131,6 +939,31 @@

Parameters

*dimensions : int, optional
Shape associated with the string (if the string represents multiple elements).
+
+ +Expand source code + +
def add_str(self, name, desc, data, *dimensions):
+    ''' Add a string parameter.
+
+    Parameters
+    ----------
+    name : str
+        Parameter name.
+    desc : str
+        Parameter descriptor.
+    data : str
+        String to encode in the parameter.
+    *dimensions : int, optional
+        Shape associated with the string (if the string represents multiple elements).
+    '''
+    shape = list(dimensions)
+    self.add_param(name,
+                   desc=desc,
+                   bytes_per_element=-1,
+                   bytes=data.encode('utf-8'),
+                   dimensions=shape or [len(data)])
+
def get(self, key, default=None) @@ -149,18 +982,55 @@

Returns

param : :class:ParamReadable``
A parameter from the current group.
+
+ +Expand source code + +
def get(self, key, default=None):
+    '''Get a parameter by key.
+
+    Parameters
+    ----------
+    key : any
+        Parameter key to look up in this group.
+    default : any, optional
+        Value to return if the key is not found. Defaults to None.
+
+    Returns
+    -------
+    param : :class:`ParamReadable`
+        A parameter from the current group.
+    '''
+    return self._data._params.get(key, default)
+
def items(self)

Iterator for paramater key-entry pairs.

+
+ +Expand source code + +
def items(self):
+    ''' Iterator for paramater key-entry pairs. '''
+    return ((k, v) for k, v in self._data._params.items())
+
def readonly(self)

Returns a GroupReadonly instance with readonly access.

+
+ +Expand source code + +
def readonly(self):
+    ''' Returns a `c3d.group.GroupReadonly` instance with readonly access. '''
+    return GroupReadonly(self._data)
+
def remove_param(self, name) @@ -172,6 +1042,20 @@

Parameters

name : str
Name for the parameter to remove.
+
+ +Expand source code + +
def remove_param(self, name):
+    '''Remove the specified parameter.
+
+    Parameters
+    ----------
+    name : str
+        Name for the parameter to remove.
+    '''
+    self._data.remove_param(name)
+
def rename_param(self, name, new_name) @@ -180,6 +1064,19 @@

Parameters

Rename a specified parameter group.

Parameters

See arguments in GroupData.rename_param().

+
+ +Expand source code + +
def rename_param(self, name, new_name):
+    ''' Rename a specified parameter group.
+
+    Parameters
+    ----------
+    See arguments in `c3d.group.GroupData.rename_param`.
+    '''
+    self._data.rename_param(name, new_name)
+
def set(self, name, *args, **kwargs) @@ -187,6 +1084,21 @@

Parameters

Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'.

See arguments in Group.add().

+
+ +Expand source code + +
def set(self, name, *args, **kwargs):
+    ''' Add or overwrite a parameter with 'bytes' package formated in accordance with 'format'.
+
+    See arguments in `c3d.group.Group.add`.
+    '''
+    try:
+        self.remove_param(name)
+    except KeyError as e:
+        pass
+    self.add(name, *args, **kwargs)
+
def set_array(self, name, *args, **kwargs) @@ -194,6 +1106,21 @@

Parameters

Add or overwrite a parameter with the data package.

See arguments in Group.add_array().

+
+ +Expand source code + +
def set_array(self, name, *args, **kwargs):
+    ''' Add or overwrite a parameter with the `data` package.
+
+    See arguments in `c3d.group.Group.add_array`.
+    '''
+    try:
+        self.remove_param(name)
+    except KeyError as e:
+        pass
+    self.add_array(name, *args, **kwargs)
+
def set_empty_array(self, name, *args, **kwargs) @@ -201,6 +1128,21 @@

Parameters

Add an empty parameter block.

See arguments in Group.add_empty_array().

+
+ +Expand source code + +
def set_empty_array(self, name, *args, **kwargs):
+    ''' Add an empty parameter block.
+
+    See arguments in `c3d.group.Group.add_empty_array`.
+    '''
+    try:
+        self.remove_param(name)
+    except KeyError as e:
+        pass
+    self.add_empty_array(name, *args, **kwargs)
+
def set_str(self, name, *args, **kwargs) @@ -208,12 +1150,35 @@

Parameters

Add or overwrite a string parameter.

See arguments in Group.add_str().

+
+ +Expand source code + +
def set_str(self, name, *args, **kwargs):
+    ''' Add or overwrite a string parameter.
+
+    See arguments in `c3d.group.Group.add_str`.
+    '''
+    try:
+        self.remove_param(name)
+    except KeyError as e:
+        pass
+    self.add_str(name, *args, **kwargs)
+
def values(self)

Iterator iterator for parameter entries.

+
+ +Expand source code + +
def values(self):
+    ''' Iterator iterator for parameter entries. '''
+    return (v for v in self._data._params.values())
+

Inherited members

@@ -252,11 +1217,171 @@

Attributes

desc : str
Description for this parameter group.
+
+ +Expand source code + +
class GroupData(object):
+    '''A group of parameters stored in a C3D file.
+
+    In C3D files, parameters are organized in groups. Each group has a name (key), a
+    description, and a set of named parameters. Each group is also internally associated
+    with a numeric key.
+
+    Attributes
+    ----------
+    dtypes : `c3d.dtypes.DataTypes`
+        Data types object used for parsing.
+    name : str
+        Name of this parameter group.
+    desc : str
+        Description for this parameter group.
+    '''
+
+    def __init__(self, dtypes, name=None, desc=None):
+        self._params = {}
+        self._dtypes = dtypes
+        # Assign through property setters
+        self.set_name(name)
+        self.set_desc(desc)
+
+    def __repr__(self):
+        return '<Group: {}>'.format(self.desc)
+
+    def __contains__(self, key):
+        return key in self._params
+
+    def __getitem__(self, key):
+        return self._params[key]
+
+    @property
+    def binary_size(self) -> int:
+        '''Return the number of bytes to store this group and its parameters.'''
+        return (
+            1 +  # group_id
+            1 + len(self.name.encode('utf-8')) +  # size of name and name bytes
+            2 +  # next offset marker
+            1 + len(self.desc.encode('utf-8')) +  # size of desc and desc bytes
+            sum(p.binary_size for p in self._params.values()))
+
+    def set_name(self, name):
+        ''' Set the group name string. '''
+        if name is None or isinstance(name, str):
+            self.name = name
+        else:
+            raise TypeError('Expected group name to be string, was %s.' % type(name))
+
+    def set_desc(self, desc):
+        ''' Set the Group descriptor.
+        '''
+        if isinstance(desc, bytes):
+            self.desc = self._dtypes.decode_string(desc)
+        elif isinstance(desc, str) or desc is None:
+            self.desc = desc
+        else:
+            raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(desc))
+
+    def add_param(self, name, **kwargs):
+        '''Add a parameter to this group.
+
+        Parameters
+        ----------
+        name : str
+            Name of the parameter to add to this group. The name will
+            automatically be case-normalized.
+
+        See constructor of `c3d.parameter.ParamData` for additional keyword arguments.
+
+        Raises
+        ------
+        TypeError
+            Input arguments are of the wrong type.
+        KeyError
+            Name or numerical key already exist (attempt to overwrite existing data).
+        '''
+        if not isinstance(name, str):
+            raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name)))
+        name = name.upper()
+        if name in self._params:
+            raise KeyError('Parameter already exists with key {}'.format(name))
+        self._params[name] = Param(ParamData(name, self._dtypes, **kwargs))
+
+    def remove_param(self, name):
+        '''Remove the specified parameter.
+
+        Parameters
+        ----------
+        name : str
+            Name for the parameter to remove.
+        '''
+        del self._params[name]
+
+    def rename_param(self, name, new_name):
+        ''' Rename a specified parameter group.
+
+        Parameters
+        ----------
+        name : str, or `c3d.group.GroupReadonly`
+            Parameter instance, or name.
+        new_name : str
+            New name for the parameter.
+        Raises
+        ------
+        KeyError
+            If no parameter with the original name exists.
+        ValueError
+            If the new name already exist (attempt to overwrite existing data).
+        '''
+        if new_name in self._params:
+            raise ValueError("Key {} already exist.".format(new_name))
+        if isinstance(name, Param):
+            param = name
+            name = param.name
+        else:
+            # Aquire instance using id
+            param = self._params[name]
+        del self._params[name]
+        self._params[new_name] = param
+
+    def write(self, group_id, handle):
+        '''Write this parameter group, with parameters, to a file handle.
+
+        Parameters
+        ----------
+        group_id : int
+            The numerical ID of the group.
+        handle : file handle
+            An open, writable, binary file handle.
+        '''
+        name = self.name.encode('utf-8')
+        desc = self.desc.encode('utf-8')
+        handle.write(struct.pack('bb', len(name), -group_id))
+        handle.write(name)
+        handle.write(struct.pack('<h', 3 + len(desc)))
+        handle.write(struct.pack('B', len(desc)))
+        handle.write(desc)
+        for param in self._params.values():
+            param._data.write(group_id, handle)
+

Instance variables

var binary_size : int

Return the number of bytes to store this group and its parameters.

+
+ +Expand source code + +
@property
+def binary_size(self) -> int:
+    '''Return the number of bytes to store this group and its parameters.'''
+    return (
+        1 +  # group_id
+        1 + len(self.name.encode('utf-8')) +  # size of name and name bytes
+        2 +  # next offset marker
+        1 + len(self.desc.encode('utf-8')) +  # size of desc and desc bytes
+        sum(p.binary_size for p in self._params.values()))
+

Methods

@@ -280,6 +1405,35 @@

Raises

KeyError
Name or numerical key already exist (attempt to overwrite existing data).
+
+ +Expand source code + +
def add_param(self, name, **kwargs):
+    '''Add a parameter to this group.
+
+    Parameters
+    ----------
+    name : str
+        Name of the parameter to add to this group. The name will
+        automatically be case-normalized.
+
+    See constructor of `c3d.parameter.ParamData` for additional keyword arguments.
+
+    Raises
+    ------
+    TypeError
+        Input arguments are of the wrong type.
+    KeyError
+        Name or numerical key already exist (attempt to overwrite existing data).
+    '''
+    if not isinstance(name, str):
+        raise TypeError("Expected 'name' argument to be a string, was of type {}".format(type(name)))
+    name = name.upper()
+    if name in self._params:
+        raise KeyError('Parameter already exists with key {}'.format(name))
+    self._params[name] = Param(ParamData(name, self._dtypes, **kwargs))
+
def remove_param(self, name) @@ -291,6 +1445,20 @@

Parameters

name : str
Name for the parameter to remove.
+
+ +Expand source code + +
def remove_param(self, name):
+    '''Remove the specified parameter.
+
+    Parameters
+    ----------
+    name : str
+        Name for the parameter to remove.
+    '''
+    del self._params[name]
+
def rename_param(self, name, new_name) @@ -311,18 +1479,74 @@

Raises

ValueError
If the new name already exist (attempt to overwrite existing data).
+
+ +Expand source code + +
def rename_param(self, name, new_name):
+    ''' Rename a specified parameter group.
+
+    Parameters
+    ----------
+    name : str, or `c3d.group.GroupReadonly`
+        Parameter instance, or name.
+    new_name : str
+        New name for the parameter.
+    Raises
+    ------
+    KeyError
+        If no parameter with the original name exists.
+    ValueError
+        If the new name already exist (attempt to overwrite existing data).
+    '''
+    if new_name in self._params:
+        raise ValueError("Key {} already exist.".format(new_name))
+    if isinstance(name, Param):
+        param = name
+        name = param.name
+    else:
+        # Aquire instance using id
+        param = self._params[name]
+    del self._params[name]
+    self._params[new_name] = param
+
def set_desc(self, desc)

Set the Group descriptor.

+
+ +Expand source code + +
def set_desc(self, desc):
+    ''' Set the Group descriptor.
+    '''
+    if isinstance(desc, bytes):
+        self.desc = self._dtypes.decode_string(desc)
+    elif isinstance(desc, str) or desc is None:
+        self.desc = desc
+    else:
+        raise TypeError('Expected descriptor to be python string, bytes or None, was %s.' % type(desc))
+
def set_name(self, name)

Set the group name string.

+
+ +Expand source code + +
def set_name(self, name):
+    ''' Set the group name string. '''
+    if name is None or isinstance(name, str):
+        self.name = name
+    else:
+        raise TypeError('Expected group name to be string, was %s.' % type(name))
+
def write(self, group_id, handle) @@ -336,6 +1560,30 @@

Parameters

handle : file handle
An open, writable, binary file handle.
+
+ +Expand source code + +
def write(self, group_id, handle):
+    '''Write this parameter group, with parameters, to a file handle.
+
+    Parameters
+    ----------
+    group_id : int
+        The numerical ID of the group.
+    handle : file handle
+        An open, writable, binary file handle.
+    '''
+    name = self.name.encode('utf-8')
+    desc = self.desc.encode('utf-8')
+    handle.write(struct.pack('bb', len(name), -group_id))
+    handle.write(name)
+    handle.write(struct.pack('<h', 3 + len(desc)))
+    handle.write(struct.pack('B', len(desc)))
+    handle.write(desc)
+    for param in self._params.values():
+        param._data.write(group_id, handle)
+
@@ -345,6 +1593,100 @@

Parameters

Wrapper exposing readonly attributes of a GroupData entry.

+
+ +Expand source code + +
class GroupReadonly(object):
+    ''' Wrapper exposing readonly attributes of a `c3d.group.GroupData` entry.
+    '''
+    def __init__(self, data):
+        self._data = data
+
+    def __contains__(self, key):
+        return key in self._data._params
+
+    def __eq__(self, other):
+        return self._data is other._data
+
+    @property
+    def name(self) -> str:
+        ''' Access group name. '''
+        return self._data.name
+
+    @property
+    def desc(self) -> str:
+        '''Access group descriptor. '''
+        return self._data.desc
+
+    def items(self):
+        ''' Get iterator for paramater key-entry pairs. '''
+        return ((k, v.readonly()) for k, v in self._data._params.items())
+
+    def values(self):
+        ''' Get iterator for parameter entries. '''
+        return (v.readonly() for v in self._data._params.values())
+
+    def keys(self):
+        ''' Get iterator for parameter entry keys. '''
+        return self._data._params.keys()
+
+    def get(self, key, default=None):
+        '''Get a readonly parameter by key.
+
+        Parameters
+        ----------
+        key : any
+            Parameter key to look up in this group.
+        default : any, optional
+            Value to return if the key is not found. Defaults to None.
+
+        Returns
+        -------
+        param : :class:`ParamReadable`
+            A parameter from the current group.
+        '''
+        val = self._data._params.get(key, default)
+        if val:
+            return val.readonly()
+        return default
+
+    def get_int8(self, key):
+        '''Get the value of the given parameter as an 8-bit signed integer.'''
+        return self._data[key.upper()].int8_value
+
+    def get_uint8(self, key):
+        '''Get the value of the given parameter as an 8-bit unsigned integer.'''
+        return self._data[key.upper()].uint8_value
+
+    def get_int16(self, key):
+        '''Get the value of the given parameter as a 16-bit signed integer.'''
+        return self._data[key.upper()].int16_value
+
+    def get_uint16(self, key):
+        '''Get the value of the given parameter as a 16-bit unsigned integer.'''
+        return self._data[key.upper()].uint16_value
+
+    def get_int32(self, key):
+        '''Get the value of the given parameter as a 32-bit signed integer.'''
+        return self._data[key.upper()].int32_value
+
+    def get_uint32(self, key):
+        '''Get the value of the given parameter as a 32-bit unsigned integer.'''
+        return self._data[key.upper()].uint32_value
+
+    def get_float(self, key):
+        '''Get the value of the given parameter as a 32-bit float.'''
+        return self._data[key.upper()].float_value
+
+    def get_bytes(self, key):
+        '''Get the value of the given parameter as a byte array.'''
+        return self._data[key.upper()].bytes_value
+
+    def get_string(self, key):
+        '''Get the value of the given parameter as a string.'''
+        return self._data[key.upper()].string_value
+

Subclasses

  • Group
  • @@ -354,10 +1696,28 @@

    Instance variables

    var desc : str

    Access group descriptor.

    +
    + +Expand source code + +
    @property
    +def desc(self) -> str:
    +    '''Access group descriptor. '''
    +    return self._data.desc
    +
    var name : str

    Access group name.

    +
    + +Expand source code + +
    @property
    +def name(self) -> str:
    +    ''' Access group name. '''
    +    return self._data.name
    +

    Methods

    @@ -379,78 +1739,198 @@

    Returns

    param : :class:ParamReadable``
    A parameter from the current group.
    +
    + +Expand source code + +
    def get(self, key, default=None):
    +    '''Get a readonly parameter by key.
    +
    +    Parameters
    +    ----------
    +    key : any
    +        Parameter key to look up in this group.
    +    default : any, optional
    +        Value to return if the key is not found. Defaults to None.
    +
    +    Returns
    +    -------
    +    param : :class:`ParamReadable`
    +        A parameter from the current group.
    +    '''
    +    val = self._data._params.get(key, default)
    +    if val:
    +        return val.readonly()
    +    return default
    +
def get_bytes(self, key)

Get the value of the given parameter as a byte array.

+
+ +Expand source code + +
def get_bytes(self, key):
+    '''Get the value of the given parameter as a byte array.'''
+    return self._data[key.upper()].bytes_value
+
def get_float(self, key)

Get the value of the given parameter as a 32-bit float.

+
+ +Expand source code + +
def get_float(self, key):
+    '''Get the value of the given parameter as a 32-bit float.'''
+    return self._data[key.upper()].float_value
+
def get_int16(self, key)

Get the value of the given parameter as a 16-bit signed integer.

+
+ +Expand source code + +
def get_int16(self, key):
+    '''Get the value of the given parameter as a 16-bit signed integer.'''
+    return self._data[key.upper()].int16_value
+
def get_int32(self, key)

Get the value of the given parameter as a 32-bit signed integer.

+
+ +Expand source code + +
def get_int32(self, key):
+    '''Get the value of the given parameter as a 32-bit signed integer.'''
+    return self._data[key.upper()].int32_value
+
def get_int8(self, key)

Get the value of the given parameter as an 8-bit signed integer.

+
+ +Expand source code + +
def get_int8(self, key):
+    '''Get the value of the given parameter as an 8-bit signed integer.'''
+    return self._data[key.upper()].int8_value
+
def get_string(self, key)

Get the value of the given parameter as a string.

+
+ +Expand source code + +
def get_string(self, key):
+    '''Get the value of the given parameter as a string.'''
+    return self._data[key.upper()].string_value
+
def get_uint16(self, key)

Get the value of the given parameter as a 16-bit unsigned integer.

+
+ +Expand source code + +
def get_uint16(self, key):
+    '''Get the value of the given parameter as a 16-bit unsigned integer.'''
+    return self._data[key.upper()].uint16_value
+
def get_uint32(self, key)

Get the value of the given parameter as a 32-bit unsigned integer.

+
+ +Expand source code + +
def get_uint32(self, key):
+    '''Get the value of the given parameter as a 32-bit unsigned integer.'''
+    return self._data[key.upper()].uint32_value
+
def get_uint8(self, key)

Get the value of the given parameter as an 8-bit unsigned integer.

+
+ +Expand source code + +
def get_uint8(self, key):
+    '''Get the value of the given parameter as an 8-bit unsigned integer.'''
+    return self._data[key.upper()].uint8_value
+
def items(self)

Get iterator for paramater key-entry pairs.

+
+ +Expand source code + +
def items(self):
+    ''' Get iterator for paramater key-entry pairs. '''
+    return ((k, v.readonly()) for k, v in self._data._params.items())
+
def keys(self)

Get iterator for parameter entry keys.

+
+ +Expand source code + +
def keys(self):
+    ''' Get iterator for parameter entry keys. '''
+    return self._data._params.keys()
+
def values(self)

Get iterator for parameter entries.

+
+ +Expand source code + +
def values(self):
+    ''' Get iterator for parameter entries. '''
+    return (v.readonly() for v in self._data._params.values())
+
diff --git a/docs/c3d/header.html b/docs/c3d/header.html index e29382b..dd42c47 100644 --- a/docs/c3d/header.html +++ b/docs/c3d/header.html @@ -23,6 +23,305 @@

Module c3d.header

Defines the header class used for reading, writing and tracking metadata in the .c3d header.

+
+ +Expand source code + +
'''
+Defines the header class used for reading, writing and tracking metadata in the .c3d header.
+'''
+import sys
+import struct
+import numpy as np
+from .utils import UNPACK_FLOAT_IEEE, DEC_to_IEEE
+
+class Header(object):
+    '''Header information from a C3D file.
+
+    Attributes
+    ----------
+    event_block : int
+        Index of the 512-byte block where labels (metadata) are found.
+    parameter_block : int
+        Index of the 512-byte block where parameters (metadata) are found.
+    data_block : int
+        Index of the 512-byte block where data starts.
+    point_count : int
+        Number of motion capture channels recorded in this file.
+    analog_count : int
+        Number of analog values recorded per frame of 3D point data.
+    first_frame : int
+        Index of the first frame of data.
+    last_frame : int
+        Index of the last frame of data.
+    analog_per_frame : int
+        Number of analog frames per frame of 3D point data. The analog frame
+        rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE)
+        times the analog per frame value.
+    frame_rate : float
+        The frame rate of the recording, in frames per second.
+    scale_factor : float
+        Multiply values in the file by this scale parameter.
+    long_event_labels : bool
+    max_gap : int
+
+    .. note::
+        The ``scale_factor`` attribute is not used in Phasespace C3D files;
+        instead, use the POINT.SCALE parameter.
+
+    .. note::
+        The ``first_frame`` and ``last_frame`` header attributes are not used in
+        C3D files generated by Phasespace. Instead, the first and last
+        frame numbers are stored in the POINTS:ACTUAL_START_FIELD and
+        POINTS:ACTUAL_END_FIELD parameters.
+    '''
+
+    # Read/Write header formats, read values as unsigned ints rather then floats.
+    BINARY_FORMAT_WRITE = '<BBHHHHHfHHf274sHHH164s44s'
+    BINARY_FORMAT_READ = '<BBHHHHHIHHI274sHHH164s44s'
+    BINARY_FORMAT_READ_BIG_ENDIAN = '>BBHHHHHIHHI274sHHH164s44s'
+
+    def __init__(self, handle=None):
+        '''Create a new Header object.
+
+        Parameters
+        ----------
+        handle : file handle, optional
+            If given, initialize attributes for the Header from this file
+            handle. The handle must be seek-able and readable. If `handle` is
+            not given, Header attributes are initialized with default values.
+        '''
+        self.parameter_block = 2
+        self.data_block = 3
+
+        self.point_count = 50
+        self.analog_count = 0
+
+        self.first_frame = 1
+        self.last_frame = 1
+        self.analog_per_frame = 0
+        self.frame_rate = 60.0
+
+        self.max_gap = 0
+        self.scale_factor = -1.0
+        self.long_event_labels = False
+        self.event_count = 0
+
+        self.event_block = b''
+        self.event_timings = np.zeros(0, dtype=np.float32)
+        self.event_disp_flags = np.zeros(0, dtype=np.bool)
+        self.event_labels = []
+
+        if handle:
+            self.read(handle)
+
+    def write(self, handle):
+        '''Write binary header data to a file handle.
+
+        This method writes exactly 512 bytes to the beginning of the given file
+        handle.
+
+        Parameters
+        ----------
+        handle : file handle
+            The given handle will be reset to 0 using `seek` and then 512 bytes
+            will be written to describe the parameters in this Header. The
+            handle must be writeable.
+        '''
+        handle.seek(0)
+        handle.write(struct.pack(self.BINARY_FORMAT_WRITE,
+                                 # Pack vars:
+                                 self.parameter_block,
+                                 0x50,
+                                 self.point_count,
+                                 self.analog_count,
+                                 self.first_frame,
+                                 self.last_frame,
+                                 self.max_gap,
+                                 self.scale_factor,
+                                 self.data_block,
+                                 self.analog_per_frame,
+                                 self.frame_rate,
+                                 b'',
+                                 self.long_event_labels and 0x3039 or 0x0,  # If True write long_event_key else 0
+                                 self.event_count,
+                                 0x0,
+                                 self.event_block,
+                                 b''))
+
+    def __str__(self):
+        '''Return a string representation of this Header's attributes.'''
+        return '''\
+                  parameter_block: {0.parameter_block}
+                      point_count: {0.point_count}
+                     analog_count: {0.analog_count}
+                      first_frame: {0.first_frame}
+                       last_frame: {0.last_frame}
+                          max_gap: {0.max_gap}
+                     scale_factor: {0.scale_factor}
+                       data_block: {0.data_block}
+                 analog_per_frame: {0.analog_per_frame}
+                       frame_rate: {0.frame_rate}
+                long_event_labels: {0.long_event_labels}
+                      event_block: {0.event_block}'''.format(self)
+
+    def read(self, handle, fmt=BINARY_FORMAT_READ):
+        '''Read and parse binary header data from a file handle.
+
+        This method reads exactly 512 bytes from the beginning of the given file
+        handle.
+
+        Parameters
+        ----------
+        handle : file handle
+            The given handle will be reset to 0 using `seek` and then 512 bytes
+            will be read to initialize the attributes in this Header. The handle
+            must be readable.
+
+        fmt : Formating string used to read the header.
+
+        Raises
+        ------
+        AssertionError
+            If the magic byte from the header is not 80 (the C3D magic value).
+        '''
+        handle.seek(0)
+        raw = handle.read(512)
+
+        (self.parameter_block,
+         magic,
+         self.point_count,
+         self.analog_count,
+         self.first_frame,
+         self.last_frame,
+         self.max_gap,
+         self.scale_factor,
+         self.data_block,
+         self.analog_per_frame,
+         self.frame_rate,
+         _,
+         self.long_event_labels,
+         self.event_count,
+         __,
+         self.event_block,
+         _) = struct.unpack(fmt, raw)
+
+        # Check magic number
+        assert magic == 80, 'C3D magic {} != 80 !'.format(magic)
+
+        # Check long event key
+        self.long_event_labels = self.long_event_labels == 0x3039
+
+    def _processor_convert(self, dtypes, handle):
+        ''' Function interpreting the header once a processor type has been determined.
+        '''
+
+        if dtypes.is_dec:
+            self.scale_factor = DEC_to_IEEE(self.scale_factor)
+            self.frame_rate = DEC_to_IEEE(self.frame_rate)
+            float_unpack = DEC_to_IEEE
+        elif dtypes.is_ieee:
+            self.scale_factor = UNPACK_FLOAT_IEEE(self.scale_factor)
+            self.frame_rate = UNPACK_FLOAT_IEEE(self.frame_rate)
+            float_unpack = UNPACK_FLOAT_IEEE
+        elif dtypes.is_mips:
+            # Re-read header in big-endian
+            self.read(handle, Header.BINARY_FORMAT_READ_BIG_ENDIAN)
+            # Then unpack
+            self.scale_factor = UNPACK_FLOAT_IEEE(self.scale_factor)
+            self.frame_rate = UNPACK_FLOAT_IEEE(self.frame_rate)
+            float_unpack = UNPACK_FLOAT_IEEE
+
+        self._parse_events(dtypes, float_unpack)
+
+    def _parse_events(self, dtypes, float_unpack):
+        ''' Parse the event section of the header.
+        '''
+
+        # Event section byte blocks
+        time_bytes = self.event_block[:72]
+        disp_bytes = self.event_block[72:90]
+        label_bytes = self.event_block[92:]
+
+        if dtypes.is_mips:
+            unpack_fmt = '>I'
+        else:
+            unpack_fmt = '<I'
+
+        read_count = self.event_count
+        self.event_timings = np.zeros(read_count, dtype=np.float32)
+        self.event_disp_flags = np.zeros(read_count, dtype=np.bool)
+        self.event_labels = np.empty(read_count, dtype=object)
+        for i in range(read_count):
+            ilong = i * 4
+            # Unpack
+            self.event_disp_flags[i] = disp_bytes[i] > 0
+            self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong + 4])[0])
+            self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong + 4])
+
+    @property
+    def events(self):
+        ''' Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label).
+
+            Note*:
+            Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter.
+            Frame 1 therefor has the time 0.0 in relation to the event timing.
+        '''
+        return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags])
+
+    def encode_events(self, events):
+        ''' Encode event data in the event block.
+
+        Parameters
+        ----------
+        events : [(float, str), ...]
+            Event data, iterable of touples containing the event timing and a 4 character label string.
+             Event timings should be calculated relative to sample 1 with the timing 0.0s, and should
+             not be relative to the first_frame header parameter.
+        '''
+        endian = '<'
+        if sys.byteorder == 'big':
+            endian = '>'
+
+        # Event block format
+        fmt = '{}{}{}{}{}'.format(endian,
+                                  str(18 * 4) + 's',  # Timings
+                                  str(18) + 's',      # Flags
+                                  'H',                # __
+                                  str(18 * 4) + 's'   # Labels
+                                 )
+        # Pack bytes
+        event_timings = np.zeros(18, dtype=np.float32)
+        event_disp_flags = np.zeros(18, dtype=np.uint8)
+        event_labels = np.empty(18, dtype=object)
+        label_bytes = bytearray(18 * 4)
+        for i, (time, label) in enumerate(events):
+            if i > 17:
+                # Don't raise Error, header events are rarely used.
+                warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.')
+                break
+
+            event_timings[i] = time
+            event_labels[i] = label
+            label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8')
+
+        write_count = min(i + 1, 18)
+        event_disp_flags[:write_count] = 1
+
+        # Update event headers in self
+        self.long_event_labels = 0x3039 # Magic number
+        self.event_count = write_count
+        # Update event block
+        self.event_timings = event_timings[:write_count]
+        self.event_disp_flags = np.ones(write_count, dtype=np.bool)
+        self.event_labels = event_labels[:write_count]
+        self.event_block = struct.pack(fmt,
+                                       event_timings.tobytes(),
+                                       event_disp_flags.tobytes(),
+                                       0,
+                                       label_bytes
+                                      )
+
@@ -88,6 +387,297 @@

Parameters

handle. The handle must be seek-able and readable. If handle is not given, Header attributes are initialized with default values. +
+ +Expand source code + +
class Header(object):
+    '''Header information from a C3D file.
+
+    Attributes
+    ----------
+    event_block : int
+        Index of the 512-byte block where labels (metadata) are found.
+    parameter_block : int
+        Index of the 512-byte block where parameters (metadata) are found.
+    data_block : int
+        Index of the 512-byte block where data starts.
+    point_count : int
+        Number of motion capture channels recorded in this file.
+    analog_count : int
+        Number of analog values recorded per frame of 3D point data.
+    first_frame : int
+        Index of the first frame of data.
+    last_frame : int
+        Index of the last frame of data.
+    analog_per_frame : int
+        Number of analog frames per frame of 3D point data. The analog frame
+        rate (ANALOG:RATE) is equivalent to the point frame rate (POINT:RATE)
+        times the analog per frame value.
+    frame_rate : float
+        The frame rate of the recording, in frames per second.
+    scale_factor : float
+        Multiply values in the file by this scale parameter.
+    long_event_labels : bool
+    max_gap : int
+
+    .. note::
+        The ``scale_factor`` attribute is not used in Phasespace C3D files;
+        instead, use the POINT.SCALE parameter.
+
+    .. note::
+        The ``first_frame`` and ``last_frame`` header attributes are not used in
+        C3D files generated by Phasespace. Instead, the first and last
+        frame numbers are stored in the POINTS:ACTUAL_START_FIELD and
+        POINTS:ACTUAL_END_FIELD parameters.
+    '''
+
+    # Read/Write header formats, read values as unsigned ints rather then floats.
+    BINARY_FORMAT_WRITE = '<BBHHHHHfHHf274sHHH164s44s'
+    BINARY_FORMAT_READ = '<BBHHHHHIHHI274sHHH164s44s'
+    BINARY_FORMAT_READ_BIG_ENDIAN = '>BBHHHHHIHHI274sHHH164s44s'
+
+    def __init__(self, handle=None):
+        '''Create a new Header object.
+
+        Parameters
+        ----------
+        handle : file handle, optional
+            If given, initialize attributes for the Header from this file
+            handle. The handle must be seek-able and readable. If `handle` is
+            not given, Header attributes are initialized with default values.
+        '''
+        self.parameter_block = 2
+        self.data_block = 3
+
+        self.point_count = 50
+        self.analog_count = 0
+
+        self.first_frame = 1
+        self.last_frame = 1
+        self.analog_per_frame = 0
+        self.frame_rate = 60.0
+
+        self.max_gap = 0
+        self.scale_factor = -1.0
+        self.long_event_labels = False
+        self.event_count = 0
+
+        self.event_block = b''
+        self.event_timings = np.zeros(0, dtype=np.float32)
+        self.event_disp_flags = np.zeros(0, dtype=np.bool)
+        self.event_labels = []
+
+        if handle:
+            self.read(handle)
+
+    def write(self, handle):
+        '''Write binary header data to a file handle.
+
+        This method writes exactly 512 bytes to the beginning of the given file
+        handle.
+
+        Parameters
+        ----------
+        handle : file handle
+            The given handle will be reset to 0 using `seek` and then 512 bytes
+            will be written to describe the parameters in this Header. The
+            handle must be writeable.
+        '''
+        handle.seek(0)
+        handle.write(struct.pack(self.BINARY_FORMAT_WRITE,
+                                 # Pack vars:
+                                 self.parameter_block,
+                                 0x50,
+                                 self.point_count,
+                                 self.analog_count,
+                                 self.first_frame,
+                                 self.last_frame,
+                                 self.max_gap,
+                                 self.scale_factor,
+                                 self.data_block,
+                                 self.analog_per_frame,
+                                 self.frame_rate,
+                                 b'',
+                                 self.long_event_labels and 0x3039 or 0x0,  # If True write long_event_key else 0
+                                 self.event_count,
+                                 0x0,
+                                 self.event_block,
+                                 b''))
+
+    def __str__(self):
+        '''Return a string representation of this Header's attributes.'''
+        return '''\
+                  parameter_block: {0.parameter_block}
+                      point_count: {0.point_count}
+                     analog_count: {0.analog_count}
+                      first_frame: {0.first_frame}
+                       last_frame: {0.last_frame}
+                          max_gap: {0.max_gap}
+                     scale_factor: {0.scale_factor}
+                       data_block: {0.data_block}
+                 analog_per_frame: {0.analog_per_frame}
+                       frame_rate: {0.frame_rate}
+                long_event_labels: {0.long_event_labels}
+                      event_block: {0.event_block}'''.format(self)
+
+    def read(self, handle, fmt=BINARY_FORMAT_READ):
+        '''Read and parse binary header data from a file handle.
+
+        This method reads exactly 512 bytes from the beginning of the given file
+        handle.
+
+        Parameters
+        ----------
+        handle : file handle
+            The given handle will be reset to 0 using `seek` and then 512 bytes
+            will be read to initialize the attributes in this Header. The handle
+            must be readable.
+
+        fmt : Formating string used to read the header.
+
+        Raises
+        ------
+        AssertionError
+            If the magic byte from the header is not 80 (the C3D magic value).
+        '''
+        handle.seek(0)
+        raw = handle.read(512)
+
+        (self.parameter_block,
+         magic,
+         self.point_count,
+         self.analog_count,
+         self.first_frame,
+         self.last_frame,
+         self.max_gap,
+         self.scale_factor,
+         self.data_block,
+         self.analog_per_frame,
+         self.frame_rate,
+         _,
+         self.long_event_labels,
+         self.event_count,
+         __,
+         self.event_block,
+         _) = struct.unpack(fmt, raw)
+
+        # Check magic number
+        assert magic == 80, 'C3D magic {} != 80 !'.format(magic)
+
+        # Check long event key
+        self.long_event_labels = self.long_event_labels == 0x3039
+
+    def _processor_convert(self, dtypes, handle):
+        ''' Function interpreting the header once a processor type has been determined.
+        '''
+
+        if dtypes.is_dec:
+            self.scale_factor = DEC_to_IEEE(self.scale_factor)
+            self.frame_rate = DEC_to_IEEE(self.frame_rate)
+            float_unpack = DEC_to_IEEE
+        elif dtypes.is_ieee:
+            self.scale_factor = UNPACK_FLOAT_IEEE(self.scale_factor)
+            self.frame_rate = UNPACK_FLOAT_IEEE(self.frame_rate)
+            float_unpack = UNPACK_FLOAT_IEEE
+        elif dtypes.is_mips:
+            # Re-read header in big-endian
+            self.read(handle, Header.BINARY_FORMAT_READ_BIG_ENDIAN)
+            # Then unpack
+            self.scale_factor = UNPACK_FLOAT_IEEE(self.scale_factor)
+            self.frame_rate = UNPACK_FLOAT_IEEE(self.frame_rate)
+            float_unpack = UNPACK_FLOAT_IEEE
+
+        self._parse_events(dtypes, float_unpack)
+
+    def _parse_events(self, dtypes, float_unpack):
+        ''' Parse the event section of the header.
+        '''
+
+        # Event section byte blocks
+        time_bytes = self.event_block[:72]
+        disp_bytes = self.event_block[72:90]
+        label_bytes = self.event_block[92:]
+
+        if dtypes.is_mips:
+            unpack_fmt = '>I'
+        else:
+            unpack_fmt = '<I'
+
+        read_count = self.event_count
+        self.event_timings = np.zeros(read_count, dtype=np.float32)
+        self.event_disp_flags = np.zeros(read_count, dtype=np.bool)
+        self.event_labels = np.empty(read_count, dtype=object)
+        for i in range(read_count):
+            ilong = i * 4
+            # Unpack
+            self.event_disp_flags[i] = disp_bytes[i] > 0
+            self.event_timings[i] = float_unpack(struct.unpack(unpack_fmt, time_bytes[ilong:ilong + 4])[0])
+            self.event_labels[i] = dtypes.decode_string(label_bytes[ilong:ilong + 4])
+
+    @property
+    def events(self):
+        ''' Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label).
+
+            Note*:
+            Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter.
+            Frame 1 therefor has the time 0.0 in relation to the event timing.
+        '''
+        return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags])
+
+    def encode_events(self, events):
+        ''' Encode event data in the event block.
+
+        Parameters
+        ----------
+        events : [(float, str), ...]
+            Event data, iterable of touples containing the event timing and a 4 character label string.
+             Event timings should be calculated relative to sample 1 with the timing 0.0s, and should
+             not be relative to the first_frame header parameter.
+        '''
+        endian = '<'
+        if sys.byteorder == 'big':
+            endian = '>'
+
+        # Event block format
+        fmt = '{}{}{}{}{}'.format(endian,
+                                  str(18 * 4) + 's',  # Timings
+                                  str(18) + 's',      # Flags
+                                  'H',                # __
+                                  str(18 * 4) + 's'   # Labels
+                                 )
+        # Pack bytes
+        event_timings = np.zeros(18, dtype=np.float32)
+        event_disp_flags = np.zeros(18, dtype=np.uint8)
+        event_labels = np.empty(18, dtype=object)
+        label_bytes = bytearray(18 * 4)
+        for i, (time, label) in enumerate(events):
+            if i > 17:
+                # Don't raise Error, header events are rarely used.
+                warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.')
+                break
+
+            event_timings[i] = time
+            event_labels[i] = label
+            label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8')
+
+        write_count = min(i + 1, 18)
+        event_disp_flags[:write_count] = 1
+
+        # Update event headers in self
+        self.long_event_labels = 0x3039 # Magic number
+        self.event_count = write_count
+        # Update event block
+        self.event_timings = event_timings[:write_count]
+        self.event_disp_flags = np.ones(write_count, dtype=np.bool)
+        self.event_labels = event_labels[:write_count]
+        self.event_block = struct.pack(fmt,
+                                       event_timings.tobytes(),
+                                       event_disp_flags.tobytes(),
+                                       0,
+                                       label_bytes
+                                      )
+

Class variables

var BINARY_FORMAT_READ
@@ -111,6 +701,20 @@

Instance variables

Note*: Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter. Frame 1 therefor has the time 0.0 in relation to the event timing.

+
+ +Expand source code + +
@property
+def events(self):
+    ''' Get an iterable over displayed events defined in the header. Iterable items are on form (timing, label).
+
+        Note*:
+        Time as defined by the 'timing' is relative to frame 1 and not the 'first_frame' parameter.
+        Frame 1 therefor has the time 0.0 in relation to the event timing.
+    '''
+    return zip(self.event_timings[self.event_disp_flags], self.event_labels[self.event_disp_flags])
+

Methods

@@ -127,6 +731,63 @@

Parameters

Event timings should be calculated relative to sample 1 with the timing 0.0s, and should not be relative to the first_frame header parameter. +
+ +Expand source code + +
def encode_events(self, events):
+    ''' Encode event data in the event block.
+
+    Parameters
+    ----------
+    events : [(float, str), ...]
+        Event data, iterable of touples containing the event timing and a 4 character label string.
+         Event timings should be calculated relative to sample 1 with the timing 0.0s, and should
+         not be relative to the first_frame header parameter.
+    '''
+    endian = '<'
+    if sys.byteorder == 'big':
+        endian = '>'
+
+    # Event block format
+    fmt = '{}{}{}{}{}'.format(endian,
+                              str(18 * 4) + 's',  # Timings
+                              str(18) + 's',      # Flags
+                              'H',                # __
+                              str(18 * 4) + 's'   # Labels
+                             )
+    # Pack bytes
+    event_timings = np.zeros(18, dtype=np.float32)
+    event_disp_flags = np.zeros(18, dtype=np.uint8)
+    event_labels = np.empty(18, dtype=object)
+    label_bytes = bytearray(18 * 4)
+    for i, (time, label) in enumerate(events):
+        if i > 17:
+            # Don't raise Error, header events are rarely used.
+            warnings.warn('Maximum of 18 events can be encoded in the header, skipping remaining events.')
+            break
+
+        event_timings[i] = time
+        event_labels[i] = label
+        label_bytes[i * 4:(i + 1) * 4] = label.encode('utf-8')
+
+    write_count = min(i + 1, 18)
+    event_disp_flags[:write_count] = 1
+
+    # Update event headers in self
+    self.long_event_labels = 0x3039 # Magic number
+    self.event_count = write_count
+    # Update event block
+    self.event_timings = event_timings[:write_count]
+    self.event_disp_flags = np.ones(write_count, dtype=np.bool)
+    self.event_labels = event_labels[:write_count]
+    self.event_block = struct.pack(fmt,
+                                   event_timings.tobytes(),
+                                   event_disp_flags.tobytes(),
+                                   0,
+                                   label_bytes
+                                  )
+
def read(self, handle, fmt='<BBHHHHHIHHI274sHHH164s44s') @@ -148,6 +809,57 @@

Raises

AssertionError
If the magic byte from the header is not 80 (the C3D magic value).
+
+ +Expand source code + +
def read(self, handle, fmt=BINARY_FORMAT_READ):
+    '''Read and parse binary header data from a file handle.
+
+    This method reads exactly 512 bytes from the beginning of the given file
+    handle.
+
+    Parameters
+    ----------
+    handle : file handle
+        The given handle will be reset to 0 using `seek` and then 512 bytes
+        will be read to initialize the attributes in this Header. The handle
+        must be readable.
+
+    fmt : Formating string used to read the header.
+
+    Raises
+    ------
+    AssertionError
+        If the magic byte from the header is not 80 (the C3D magic value).
+    '''
+    handle.seek(0)
+    raw = handle.read(512)
+
+    (self.parameter_block,
+     magic,
+     self.point_count,
+     self.analog_count,
+     self.first_frame,
+     self.last_frame,
+     self.max_gap,
+     self.scale_factor,
+     self.data_block,
+     self.analog_per_frame,
+     self.frame_rate,
+     _,
+     self.long_event_labels,
+     self.event_count,
+     __,
+     self.event_block,
+     _) = struct.unpack(fmt, raw)
+
+    # Check magic number
+    assert magic == 80, 'C3D magic {} != 80 !'.format(magic)
+
+    # Check long event key
+    self.long_event_labels = self.long_event_labels == 0x3039
+
def write(self, handle) @@ -163,6 +875,44 @@

Parameters

will be written to describe the parameters in this Header. The handle must be writeable. +
+ +Expand source code + +
def write(self, handle):
+    '''Write binary header data to a file handle.
+
+    This method writes exactly 512 bytes to the beginning of the given file
+    handle.
+
+    Parameters
+    ----------
+    handle : file handle
+        The given handle will be reset to 0 using `seek` and then 512 bytes
+        will be written to describe the parameters in this Header. The
+        handle must be writeable.
+    '''
+    handle.seek(0)
+    handle.write(struct.pack(self.BINARY_FORMAT_WRITE,
+                             # Pack vars:
+                             self.parameter_block,
+                             0x50,
+                             self.point_count,
+                             self.analog_count,
+                             self.first_frame,
+                             self.last_frame,
+                             self.max_gap,
+                             self.scale_factor,
+                             self.data_block,
+                             self.analog_per_frame,
+                             self.frame_rate,
+                             b'',
+                             self.long_event_labels and 0x3039 or 0x0,  # If True write long_event_key else 0
+                             self.event_count,
+                             0x0,
+                             self.event_block,
+                             b''))
+
diff --git a/docs/c3d/index.html b/docs/c3d/index.html index 774bb4e..1b42573 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -31,7 +31,7 @@

Installing

See the main page or github page.

Examples

Access to data blocks in a .c3d file is provided through the Reader and Writer classes. -Runnable implementation of the examples are provided in the examples/ directory in the github repository.

+Implementation of the examples below are provided in the examples/ directory in the github repository.

Reading

To read the frames from a C3D file, use a Reader instance:

import c3d
@@ -77,10 +77,11 @@ 

Writing

is called, which serializes the metadata and data frames into a C3D binary file stream.

Editing

Editing c3d files is possible by combining the use of Reader and Writer -instances through the use of Reader.to_writer(). Opening a .c3d file stream through -a reader instance, Reader.to_writer() can be used to create an independent Writer instance -containing a heap copy of the file contents. Rereading the data frames from the file -and inserting into the writer in reverse, a looped version of the .c3d file can be created!

+instances through the Reader.to_writer() method. To edit a file, open a file stream and pass +it to a Reader instance. Convert it using the Reader.to_writer() method to create +an independent Writer instance containing a heap copy of the file contents. +Rereading the data frames from the reader and inserting them in reverse into +the writer will then create a looped version of the .c3d file!

import c3d
 
 with open('my-motion.c3d', 'rb') as file:
@@ -92,6 +93,71 @@ 

Editing

with open('my-looped-motion.c3d', 'wb') as h: writer.write(h)
+

Accessing Metadata

+

Metadata in a .c3d file exists in two forms, a Header and ParamData. +Reading metadata fields can be done though the Reader but editing requires a +Writer instance.

+

Header fields can be accessed from the common Manager.header attribute. +Parameters are available through a parameter Group, and can be accessed +through the Manager.get() method:

+
group = reader.get('POINT')
+param = group.get('LABELS')
+
+

or use the simpler method

+
param = reader.get('POINT:LABELS')
+
+

Note that for accessing parameters in the Reader, Reader.get() +returns a GroupReadonly instance. Convenience functions are provided +for some of the common metadata fields +such as Manager.frame_count. In the case you require specific +metadata fields, consider visiting the documentation for the C3D format and/or inspect +the file using the c3d-metadata script.

+

Writing Metadata

+

Once a Writer instance is created, to edit +parameter data Writer.get_create() a group:

+
group = writer.get_create('ANALOG')
+
+

and to write a float32 entry, use the Group.add() or Group.set() functions

+
group.set('GEN_SCALE', 'Analog general scale factor', 4, '<f', value)
+
+

In this case, one can use the Writer.set_analog_general_scale() method instead. +For serializing other types, see the source code for some of the convenience functions such as +Writer.set_point_labels() (2D string array) or +Writer.set_analog_scales() (1D float array).

+
+ +Expand source code + +
"""
+---------------------
+Python C3D Processing
+---------------------
+
+This package provides pure Python modules for reading, writing, and editing binary
+motion-capture files in the [C3D file format].
+
+[C3D file format]: https://www.c3d.org/HTML/default.htm
+
+Installing
+----------
+
+See the [main page] or [github page].
+
+[main page]: https://mattiasfredriksson.github.io/py-c3d/
+[github page]: https://github.com/EmbodiedCognition/py-c3d
+
+.. include:: ../docs/examples.md
+
+"""
+from . import dtypes
+from . import group
+from . import header
+from . import manager
+from . import parameter
+from . import utils
+from .reader import Reader
+from .writer import Writer
+

Sub-modules

@@ -118,7 +184,7 @@

Sub-modules

c3d.reader
-

A Python module for reading and writing C3D files.

+

Contains the Reader class for reading C3D files.

c3d.utils
@@ -126,7 +192,7 @@

Sub-modules

c3d.writer
-

A Python module for reading and writing C3D files.

+

Contains the Writer class for writing C3D files.

@@ -147,6 +213,8 @@

Index

  • Reading
  • Writing
  • Editing
  • +
  • Accessing metadata
  • +
  • Writing metadata
  • diff --git a/docs/c3d/manager.html b/docs/c3d/manager.html index f0ce4b0..41cfcbe 100644 --- a/docs/c3d/manager.html +++ b/docs/c3d/manager.html @@ -23,6 +23,462 @@

    Module c3d.manager

    Manager base class defining common attributes for both the Reader and Writer instances.

    +
    + +Expand source code + +
    ''' Manager base class defining common attributes for both the Reader and Writer instances.
    +'''
    +import numpy as np
    +import warnings
    +from .header import Header
    +from .group import GroupData, GroupReadonly, Group
    +from .utils import is_integer, is_iterable
    +
    +
    +class Manager(object):
    +    '''A base class for managing C3D file metadata.
    +
    +    This class manages a C3D header (which contains some stock metadata fields)
    +    as well as a set of parameter groups. Each group is accessible using its
    +    name.
    +
    +    Attributes
    +    ----------
    +    header : `c3d.header.Header`
    +        Header information for the C3D file.
    +    '''
    +
    +    def __init__(self, header=None):
    +        '''Set up a new Manager with a Header.'''
    +        self._header = header or Header()
    +        self._groups = {}
    +
    +    def __contains__(self, key):
    +        return key in self._groups
    +
    +    def items(self):
    +        ''' Get iterable over pairs of (str, `c3d.group.Group`) entries.
    +        '''
    +        return ((k, v) for k, v in self._groups.items() if isinstance(k, str))
    +
    +    def values(self):
    +        ''' Get iterable over `c3d.group.Group` entries.
    +        '''
    +        return (v for k, v in self._groups.items() if isinstance(k, str))
    +
    +    def keys(self):
    +        ''' Get iterable over parameter name keys.
    +        '''
    +        return (k for k in self._groups.keys() if isinstance(k, str))
    +
    +    def listed(self):
    +        ''' Get iterable over pairs of (int, `c3d.group.Group`) entries.
    +        '''
    +        return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int))
    +
    +    def _check_metadata(self):
    +        ''' Ensure that the metadata in our file is self-consistent. '''
    +        assert self._header.point_count == self.point_used, (
    +            'inconsistent point count! {} header != {} POINT:USED'.format(
    +                self._header.point_count,
    +                self.point_used,
    +            ))
    +
    +        assert self._header.scale_factor == self.point_scale, (
    +            'inconsistent scale factor! {} header != {} POINT:SCALE'.format(
    +                self._header.scale_factor,
    +                self.point_scale,
    +            ))
    +
    +        assert self._header.frame_rate == self.point_rate, (
    +            'inconsistent frame rate! {} header != {} POINT:RATE'.format(
    +                self._header.frame_rate,
    +                self.point_rate,
    +            ))
    +
    +        if self.point_rate:
    +            ratio = self.analog_rate / self.point_rate
    +        else:
    +            ratio = 0
    +        assert self._header.analog_per_frame == ratio, (
    +            'inconsistent analog rate! {} header != {} analog-fps / {} point-fps'.format(
    +                self._header.analog_per_frame,
    +                self.analog_rate,
    +                self.point_rate,
    +            ))
    +
    +        count = self.analog_used * self._header.analog_per_frame
    +        assert self._header.analog_count == count, (
    +            'inconsistent analog count! {} header != {} analog used * {} per-frame'.format(
    +                self._header.analog_count,
    +                self.analog_used,
    +                self._header.analog_per_frame,
    +            ))
    +
    +        try:
    +            start = self.get_uint16('POINT:DATA_START')
    +            if self._header.data_block != start:
    +                warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format(
    +                    self._header.data_block, start))
    +        except AttributeError:
    +            warnings.warn('''no pointer available in POINT:DATA_START indicating the start of the data block, using
    +                             header pointer as fallback''')
    +
    +        def check_parameters(params):
    +            for name in params:
    +                if self.get(name) is None:
    +                    warnings.warn('missing parameter {}'.format(name))
    +
    +        if self.point_used > 0:
    +            check_parameters(('POINT:LABELS', 'POINT:DESCRIPTIONS'))
    +        else:
    +            lab = self.get('POINT:LABELS')
    +            if lab is None:
    +                warnings.warn('No point data found in file.')
    +            elif lab.num_elements > 0:
    +                warnings.warn('No point data found in file, but file contains POINT:LABELS entries')
    +        if self.analog_used > 0:
    +            check_parameters(('ANALOG:LABELS', 'ANALOG:DESCRIPTIONS'))
    +        else:
    +            lab = self.get('ANALOG:LABELS')
    +            if lab is None:
    +                warnings.warn('No analog data found in file.')
    +            elif lab.num_elements > 0:
    +                warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries')
    +
    +
    +    def _add_group(self, group_id, name=None, desc=None):
    +        '''Add a new parameter group.
    +
    +        Parameters
    +        ----------
    +        group_id : int
    +            The numeric ID for a group to check or create.
    +        name : str, optional
    +            If a group is created, assign this name to the group.
    +        desc : str, optional
    +            If a group is created, assign this description to the group.
    +
    +        Returns
    +        -------
    +        group : :class:`Group`
    +            A group with the given ID, name, and description.
    +
    +        Raises
    +        ------
    +        TypeError
    +            Input arguments are of the wrong type.
    +        KeyError
    +            Name or numerical key already exist (attempt to overwrite existing data).
    +        '''
    +        if not is_integer(group_id):
    +            raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id))
    +        if not isinstance(name, str):
    +            if name is not None:
    +                raise TypeError('Expected Group name key to be string, was %s.' % type(name))
    +        else:
    +            name = name.upper()
    +        group_id = int(group_id) # Asserts python int
    +        if group_id in self._groups:
    +            raise KeyError('Group with numerical key {} already exists'.format(group_id))
    +        if name in self._groups:
    +            raise KeyError('No group matched name key {}'.format(name))
    +        group = self._groups[name] = self._groups[group_id] = Group(GroupData(self._dtypes, name, desc))
    +        return group
    +
    +    def _remove_group(self, group_id):
    +        '''Remove the parameter group.
    +
    +        Parameters
    +        ----------
    +        group_id : int, or str
    +            The numeric or name ID key for a group to remove all entries for.
    +        '''
    +        grp = self._groups.get(group_id, None)
    +        if grp is None:
    +            return
    +        gkeys = [k for (k, v) in self._groups.items() if v == grp]
    +        for k in gkeys:
    +            del self._groups[k]
    +
    +    def _rename_group(self, group_id, new_group_id):
    +        ''' Rename a specified parameter group.
    +
    +        Parameters
    +        ----------
    +        group_id : int, str, or 'Group'
    +            Group instance, name, or numerical identifier for the group.
    +        new_group_id : str, or int
    +            If string, it is the new name for the group. If integer, it will replace its numerical group id.
    +
    +        Raises
    +        ------
    +        KeyError
    +            If a group with a duplicate ID or name already exists.
    +        '''
    +        if isinstance(group_id, GroupReadonly):
    +            grp = group_id._data
    +        else:
    +            # Aquire instance using id
    +            grp = self._groups.get(group_id, None)
    +            if grp is None:
    +                raise KeyError('No group found matching the identifier: %s' % str(group_id))
    +        if new_group_id in self._groups:
    +            if new_group_id == group_id:
    +                return
    +            raise ValueError('Key %s for group %s already exist.' % (str(new_group_id), grp.name))
    +
    +        # Clear old id
    +        if isinstance(new_group_id, (str, bytes)):
    +            if grp.name in self._groups:
    +                del self._groups[grp.name]
    +            grp.name = new_group_id
    +        elif is_integer(new_group_id):
    +            new_group_id = int(new_group_id) # Ensure python int
    +            del self._groups[group_id]
    +        else:
    +            raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id)))
    +        # Update
    +        self._groups[new_group_id] = grp
    +
    +    def get(self, group, default=None):
    +        '''Get a group or parameter.
    +
    +        Parameters
    +        ----------
    +        group : str
    +            If this string contains a period (.), then the part before the
    +            period will be used to retrieve a group, and the part after the
    +            period will be used to retrieve a parameter from that group. If this
    +            string does not contain a period, then just a group will be
    +            returned.
    +        default : any
    +            Return this value if the named group and parameter are not found.
    +
    +        Returns
    +        -------
    +        value : `c3d.group.Group` or `c3d.parameter.Param`
    +            Either a group or parameter with the specified name(s). If neither
    +            is found, returns the default value.
    +        '''
    +        if is_integer(group):
    +            group = self._groups.get(int(group))
    +            if group is None:
    +                return default
    +            return group
    +        group = group.upper()
    +        param = None
    +        if '.' in group:
    +            group, param = group.split('.', 1)
    +        if ':' in group:
    +            group, param = group.split(':', 1)
    +        if group not in self._groups:
    +            return default
    +        group = self._groups[group]
    +        if param is not None:
    +            return group.get(param, default)
    +        return group
    +
    +    @property
    +    def header(self) -> '`c3d.header.Header`':
    +        ''' Access to .c3d header data. '''
    +        return self._header
    +
    +    def parameter_blocks(self) -> int:
    +        '''Compute the size (in 512B blocks) of the parameter section.'''
    +        bytes = 4. + sum(g._data.binary_size for g in self._groups.values())
    +        return int(np.ceil(bytes / 512))
    +
    +    @property
    +    def point_rate(self) -> float:
    +        ''' Number of sampled 3D coordinates per second. '''
    +        try:
    +            return self.get_float('POINT:RATE')
    +        except AttributeError:
    +            return self.header.frame_rate
    +
    +    @property
    +    def point_scale(self) -> float:
    +        ''' Scaling applied to non-float data. '''
    +        try:
    +            return self.get_float('POINT:SCALE')
    +        except AttributeError:
    +            return self.header.scale_factor
    +
    +    @property
    +    def point_used(self) -> int:
    +        ''' Number of sampled 3D point coordinates per frame. '''
    +        try:
    +            return self.get_uint16('POINT:USED')
    +        except AttributeError:
    +            return self.header.point_count
    +
    +    @property
    +    def analog_used(self) -> int:
    +        ''' Number of analog measurements, or channels, for each analog data sample. '''
    +        try:
    +            return self.get_uint16('ANALOG:USED')
    +        except AttributeError:
    +            per_frame = self.header.analog_per_frame
    +            if per_frame > 0:
    +                return int(self.header.analog_count / per_frame)
    +            return 0
    +
    +    @property
    +    def analog_rate(self) -> float:
    +        '''  Number of analog data samples per second. '''
    +        try:
    +            return self.get_float('ANALOG:RATE')
    +        except AttributeError:
    +            return self.header.analog_per_frame * self.point_rate
    +
    +    @property
    +    def analog_per_frame(self) -> int:
    +        '''  Number of analog frames per 3D frame (point sample). '''
    +        return int(self.analog_rate / self.point_rate)
    +
    +    @property
    +    def analog_sample_count(self) -> int:
    +        ''' Number of analog samples per channel. '''
    +        has_analog = self.analog_used > 0
    +        return int(self.frame_count * self.analog_per_frame) * has_analog
    +
    +    @property
    +    def point_labels(self) -> list:
    +        ''' Labels for each POINT data channel. '''
    +        return self.get('POINT:LABELS').string_array
    +
    +    @property
    +    def analog_labels(self) -> list:
    +        ''' Labels for each ANALOG data channel. '''
    +        return self.get('ANALOG:LABELS').string_array
    +
    +    @property
    +    def frame_count(self) -> int:
    +        ''' Number of frames recorded in the data. '''
    +        return self.last_frame - self.first_frame + 1  # Add 1 since range is inclusive [first, last]
    +
    +    @property
    +    def first_frame(self) -> int:
    +        ''' Trial frame corresponding to the first frame recorded in the data. '''
    +        # Start frame seems to be less of an issue to determine.
    +        # this is a hack for phasespace files ... should put it in a subclass.
    +        param = self.get('TRIAL:ACTUAL_START_FIELD')
    +        if param is not None:
    +            # ACTUAL_START_FIELD is encoded in two 16 byte words...
    +            words = param.uint16_array
    +            return words[0] + words[1] * 65535
    +        return self.header.first_frame
    +
    +    @property
    +    def last_frame(self) -> int:
    +        ''' Trial frame corresponding to the last frame recorded in the data (inclusive). '''
    +        # Number of frames can be represented in many formats, first check if valid header values
    +        if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535:
    +            return self.header.last_frame
    +
    +        # Check different parameter options where the frame can be encoded
    +        end_frame = [self.header.last_frame, 0.0, 0.0, 0.0]
    +        param = self.get('TRIAL:ACTUAL_END_FIELD')
    +        if param is not None:
    +            # Encoded as 2 16 bit words (rather then 1 32 bit word)
    +            words = param.uint16_array
    +            end_frame[1] = words[0] + words[1] * 65536
    +            #end_frame[1] = param.uint32_value
    +        param = self.get('POINT:LONG_FRAMES')
    +        if param is not None:
    +            # Encoded as float
    +            end_frame[2] = int(param.float_value)
    +        param = self.get('POINT:FRAMES')
    +        if param is not None:
    +            # Can be encoded either as 32 bit float or 16 bit uint
    +            if param.bytes_per_element == 2:
    +                end_frame[3] = param.uint16_value
    +            else:
    +                end_frame[3] = int(param.float_value)
    +        # Return the largest of the all (queue bad reading...)
    +        return int(np.max(end_frame))
    +
    +    def get_screen_axis(self):
    +        ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group.
    +
    +        Returns
    +        -------
    +        value : (str, str) or None
    +            Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
    +        '''
    +        X = self.get('POINT:X_SCREEN')
    +        Y = self.get('POINT:Y_SCREEN')
    +        if X and Y:
    +            return (X.string_value, Y.string_value)
    +        return None
    +
    +    def get_analog_transform_parameters(self):
    +        ''' Parse analog data transform parameters. '''
    +        # Offsets
    +        analog_offsets = np.zeros((self.analog_used), int)
    +        param = self.get('ANALOG:OFFSET')
    +        if param is not None and param.num_elements > 0:
    +            analog_offsets[:] = param.int16_array[:self.analog_used]
    +
    +        # Scale factors
    +        analog_scales = np.ones((self.analog_used), float)
    +        gen_scale = 1.
    +        param = self.get('ANALOG:GEN_SCALE')
    +        if param is not None:
    +            gen_scale = param.float_value
    +        param = self.get('ANALOG:SCALE')
    +        if param is not None and param.num_elements > 0:
    +            analog_scales[:] = param.float_array[:self.analog_used]
    +
    +        return gen_scale, analog_scales, analog_offsets
    +
    +    def get_analog_transform(self):
    +        ''' Get broadcastable analog transformation parameters.
    +        '''
    +        gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters()
    +        analog_scales *= gen_scale
    +        analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame))
    +        analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame))
    +        return analog_scales, analog_offsets
    +
    +    def get_int8(self, key):
    +        '''Get a parameter value as an 8-bit signed integer.'''
    +        return self.get(key).int8_value
    +
    +    def get_uint8(self, key):
    +        '''Get a parameter value as an 8-bit unsigned integer.'''
    +        return self.get(key).uint8_value
    +
    +    def get_int16(self, key):
    +        '''Get a parameter value as a 16-bit signed integer.'''
    +        return self.get(key).int16_value
    +
    +    def get_uint16(self, key):
    +        '''Get a parameter value as a 16-bit unsigned integer.'''
    +        return self.get(key).uint16_value
    +
    +    def get_int32(self, key):
    +        '''Get a parameter value as a 32-bit signed integer.'''
    +        return self.get(key).int32_value
    +
    +    def get_uint32(self, key):
    +        '''Get a parameter value as a 32-bit unsigned integer.'''
    +        return self.get(key).uint32_value
    +
    +    def get_float(self, key):
    +        '''Get a parameter value as a 32-bit float.'''
    +        return self.get(key).float_value
    +
    +    def get_bytes(self, key):
    +        '''Get a parameter value as a byte string.'''
    +        return self.get(key).bytes_value
    +
    +    def get_string(self, key):
    +        '''Get a parameter value as a string.'''
    +        return self.get(key).string_value
    +
    @@ -48,6 +504,453 @@

    Attributes

    Header information for the C3D file.

    Set up a new Manager with a Header.

    +
    + +Expand source code + +
    class Manager(object):
    +    '''A base class for managing C3D file metadata.
    +
    +    This class manages a C3D header (which contains some stock metadata fields)
    +    as well as a set of parameter groups. Each group is accessible using its
    +    name.
    +
    +    Attributes
    +    ----------
    +    header : `c3d.header.Header`
    +        Header information for the C3D file.
    +    '''
    +
    +    def __init__(self, header=None):
    +        '''Set up a new Manager with a Header.'''
    +        self._header = header or Header()
    +        self._groups = {}
    +
    +    def __contains__(self, key):
    +        return key in self._groups
    +
    +    def items(self):
    +        ''' Get iterable over pairs of (str, `c3d.group.Group`) entries.
    +        '''
    +        return ((k, v) for k, v in self._groups.items() if isinstance(k, str))
    +
    +    def values(self):
    +        ''' Get iterable over `c3d.group.Group` entries.
    +        '''
    +        return (v for k, v in self._groups.items() if isinstance(k, str))
    +
    +    def keys(self):
    +        ''' Get iterable over parameter name keys.
    +        '''
    +        return (k for k in self._groups.keys() if isinstance(k, str))
    +
    +    def listed(self):
    +        ''' Get iterable over pairs of (int, `c3d.group.Group`) entries.
    +        '''
    +        return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int))
    +
    +    def _check_metadata(self):
    +        ''' Ensure that the metadata in our file is self-consistent. '''
    +        assert self._header.point_count == self.point_used, (
    +            'inconsistent point count! {} header != {} POINT:USED'.format(
    +                self._header.point_count,
    +                self.point_used,
    +            ))
    +
    +        assert self._header.scale_factor == self.point_scale, (
    +            'inconsistent scale factor! {} header != {} POINT:SCALE'.format(
    +                self._header.scale_factor,
    +                self.point_scale,
    +            ))
    +
    +        assert self._header.frame_rate == self.point_rate, (
    +            'inconsistent frame rate! {} header != {} POINT:RATE'.format(
    +                self._header.frame_rate,
    +                self.point_rate,
    +            ))
    +
    +        if self.point_rate:
    +            ratio = self.analog_rate / self.point_rate
    +        else:
    +            ratio = 0
    +        assert self._header.analog_per_frame == ratio, (
    +            'inconsistent analog rate! {} header != {} analog-fps / {} point-fps'.format(
    +                self._header.analog_per_frame,
    +                self.analog_rate,
    +                self.point_rate,
    +            ))
    +
    +        count = self.analog_used * self._header.analog_per_frame
    +        assert self._header.analog_count == count, (
    +            'inconsistent analog count! {} header != {} analog used * {} per-frame'.format(
    +                self._header.analog_count,
    +                self.analog_used,
    +                self._header.analog_per_frame,
    +            ))
    +
    +        try:
    +            start = self.get_uint16('POINT:DATA_START')
    +            if self._header.data_block != start:
    +                warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format(
    +                    self._header.data_block, start))
    +        except AttributeError:
    +            warnings.warn('''no pointer available in POINT:DATA_START indicating the start of the data block, using
    +                             header pointer as fallback''')
    +
    +        def check_parameters(params):
    +            for name in params:
    +                if self.get(name) is None:
    +                    warnings.warn('missing parameter {}'.format(name))
    +
    +        if self.point_used > 0:
    +            check_parameters(('POINT:LABELS', 'POINT:DESCRIPTIONS'))
    +        else:
    +            lab = self.get('POINT:LABELS')
    +            if lab is None:
    +                warnings.warn('No point data found in file.')
    +            elif lab.num_elements > 0:
    +                warnings.warn('No point data found in file, but file contains POINT:LABELS entries')
    +        if self.analog_used > 0:
    +            check_parameters(('ANALOG:LABELS', 'ANALOG:DESCRIPTIONS'))
    +        else:
    +            lab = self.get('ANALOG:LABELS')
    +            if lab is None:
    +                warnings.warn('No analog data found in file.')
    +            elif lab.num_elements > 0:
    +                warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries')
    +
    +
    +    def _add_group(self, group_id, name=None, desc=None):
    +        '''Add a new parameter group.
    +
    +        Parameters
    +        ----------
    +        group_id : int
    +            The numeric ID for a group to check or create.
    +        name : str, optional
    +            If a group is created, assign this name to the group.
    +        desc : str, optional
    +            If a group is created, assign this description to the group.
    +
    +        Returns
    +        -------
    +        group : :class:`Group`
    +            A group with the given ID, name, and description.
    +
    +        Raises
    +        ------
    +        TypeError
    +            Input arguments are of the wrong type.
    +        KeyError
    +            Name or numerical key already exist (attempt to overwrite existing data).
    +        '''
    +        if not is_integer(group_id):
    +            raise TypeError('Expected Group numerical key to be integer, was %s.' % type(group_id))
    +        if not isinstance(name, str):
    +            if name is not None:
    +                raise TypeError('Expected Group name key to be string, was %s.' % type(name))
    +        else:
    +            name = name.upper()
    +        group_id = int(group_id) # Asserts python int
    +        if group_id in self._groups:
    +            raise KeyError('Group with numerical key {} already exists'.format(group_id))
    +        if name in self._groups:
    +            raise KeyError('No group matched name key {}'.format(name))
    +        group = self._groups[name] = self._groups[group_id] = Group(GroupData(self._dtypes, name, desc))
    +        return group
    +
    +    def _remove_group(self, group_id):
    +        '''Remove the parameter group.
    +
    +        Parameters
    +        ----------
    +        group_id : int, or str
    +            The numeric or name ID key for a group to remove all entries for.
    +        '''
    +        grp = self._groups.get(group_id, None)
    +        if grp is None:
    +            return
    +        gkeys = [k for (k, v) in self._groups.items() if v == grp]
    +        for k in gkeys:
    +            del self._groups[k]
    +
    +    def _rename_group(self, group_id, new_group_id):
    +        ''' Rename a specified parameter group.
    +
    +        Parameters
    +        ----------
    +        group_id : int, str, or 'Group'
    +            Group instance, name, or numerical identifier for the group.
    +        new_group_id : str, or int
    +            If string, it is the new name for the group. If integer, it will replace its numerical group id.
    +
    +        Raises
    +        ------
    +        KeyError
    +            If a group with a duplicate ID or name already exists.
    +        '''
    +        if isinstance(group_id, GroupReadonly):
    +            grp = group_id._data
    +        else:
    +            # Aquire instance using id
    +            grp = self._groups.get(group_id, None)
    +            if grp is None:
    +                raise KeyError('No group found matching the identifier: %s' % str(group_id))
    +        if new_group_id in self._groups:
    +            if new_group_id == group_id:
    +                return
    +            raise ValueError('Key %s for group %s already exist.' % (str(new_group_id), grp.name))
    +
    +        # Clear old id
    +        if isinstance(new_group_id, (str, bytes)):
    +            if grp.name in self._groups:
    +                del self._groups[grp.name]
    +            grp.name = new_group_id
    +        elif is_integer(new_group_id):
    +            new_group_id = int(new_group_id) # Ensure python int
    +            del self._groups[group_id]
    +        else:
    +            raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id)))
    +        # Update
    +        self._groups[new_group_id] = grp
    +
    +    def get(self, group, default=None):
    +        '''Get a group or parameter.
    +
    +        Parameters
    +        ----------
    +        group : str
    +            If this string contains a period (.), then the part before the
    +            period will be used to retrieve a group, and the part after the
    +            period will be used to retrieve a parameter from that group. If this
    +            string does not contain a period, then just a group will be
    +            returned.
    +        default : any
    +            Return this value if the named group and parameter are not found.
    +
    +        Returns
    +        -------
    +        value : `c3d.group.Group` or `c3d.parameter.Param`
    +            Either a group or parameter with the specified name(s). If neither
    +            is found, returns the default value.
    +        '''
    +        if is_integer(group):
    +            group = self._groups.get(int(group))
    +            if group is None:
    +                return default
    +            return group
    +        group = group.upper()
    +        param = None
    +        if '.' in group:
    +            group, param = group.split('.', 1)
    +        if ':' in group:
    +            group, param = group.split(':', 1)
    +        if group not in self._groups:
    +            return default
    +        group = self._groups[group]
    +        if param is not None:
    +            return group.get(param, default)
    +        return group
    +
    +    @property
    +    def header(self) -> '`c3d.header.Header`':
    +        ''' Access to .c3d header data. '''
    +        return self._header
    +
    +    def parameter_blocks(self) -> int:
    +        '''Compute the size (in 512B blocks) of the parameter section.'''
    +        bytes = 4. + sum(g._data.binary_size for g in self._groups.values())
    +        return int(np.ceil(bytes / 512))
    +
    +    @property
    +    def point_rate(self) -> float:
    +        ''' Number of sampled 3D coordinates per second. '''
    +        try:
    +            return self.get_float('POINT:RATE')
    +        except AttributeError:
    +            return self.header.frame_rate
    +
    +    @property
    +    def point_scale(self) -> float:
    +        ''' Scaling applied to non-float data. '''
    +        try:
    +            return self.get_float('POINT:SCALE')
    +        except AttributeError:
    +            return self.header.scale_factor
    +
    +    @property
    +    def point_used(self) -> int:
    +        ''' Number of sampled 3D point coordinates per frame. '''
    +        try:
    +            return self.get_uint16('POINT:USED')
    +        except AttributeError:
    +            return self.header.point_count
    +
    +    @property
    +    def analog_used(self) -> int:
    +        ''' Number of analog measurements, or channels, for each analog data sample. '''
    +        try:
    +            return self.get_uint16('ANALOG:USED')
    +        except AttributeError:
    +            per_frame = self.header.analog_per_frame
    +            if per_frame > 0:
    +                return int(self.header.analog_count / per_frame)
    +            return 0
    +
    +    @property
    +    def analog_rate(self) -> float:
    +        '''  Number of analog data samples per second. '''
    +        try:
    +            return self.get_float('ANALOG:RATE')
    +        except AttributeError:
    +            return self.header.analog_per_frame * self.point_rate
    +
    +    @property
    +    def analog_per_frame(self) -> int:
    +        '''  Number of analog frames per 3D frame (point sample). '''
    +        return int(self.analog_rate / self.point_rate)
    +
    +    @property
    +    def analog_sample_count(self) -> int:
    +        ''' Number of analog samples per channel. '''
    +        has_analog = self.analog_used > 0
    +        return int(self.frame_count * self.analog_per_frame) * has_analog
    +
    +    @property
    +    def point_labels(self) -> list:
    +        ''' Labels for each POINT data channel. '''
    +        return self.get('POINT:LABELS').string_array
    +
    +    @property
    +    def analog_labels(self) -> list:
    +        ''' Labels for each ANALOG data channel. '''
    +        return self.get('ANALOG:LABELS').string_array
    +
    +    @property
    +    def frame_count(self) -> int:
    +        ''' Number of frames recorded in the data. '''
    +        return self.last_frame - self.first_frame + 1  # Add 1 since range is inclusive [first, last]
    +
    +    @property
    +    def first_frame(self) -> int:
    +        ''' Trial frame corresponding to the first frame recorded in the data. '''
    +        # Start frame seems to be less of an issue to determine.
    +        # this is a hack for phasespace files ... should put it in a subclass.
    +        param = self.get('TRIAL:ACTUAL_START_FIELD')
    +        if param is not None:
    +            # ACTUAL_START_FIELD is encoded in two 16 byte words...
    +            words = param.uint16_array
    +            return words[0] + words[1] * 65535
    +        return self.header.first_frame
    +
    +    @property
    +    def last_frame(self) -> int:
    +        ''' Trial frame corresponding to the last frame recorded in the data (inclusive). '''
    +        # Number of frames can be represented in many formats, first check if valid header values
    +        if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535:
    +            return self.header.last_frame
    +
    +        # Check different parameter options where the frame can be encoded
    +        end_frame = [self.header.last_frame, 0.0, 0.0, 0.0]
    +        param = self.get('TRIAL:ACTUAL_END_FIELD')
    +        if param is not None:
    +            # Encoded as 2 16 bit words (rather then 1 32 bit word)
    +            words = param.uint16_array
    +            end_frame[1] = words[0] + words[1] * 65536
    +            #end_frame[1] = param.uint32_value
    +        param = self.get('POINT:LONG_FRAMES')
    +        if param is not None:
    +            # Encoded as float
    +            end_frame[2] = int(param.float_value)
    +        param = self.get('POINT:FRAMES')
    +        if param is not None:
    +            # Can be encoded either as 32 bit float or 16 bit uint
    +            if param.bytes_per_element == 2:
    +                end_frame[3] = param.uint16_value
    +            else:
    +                end_frame[3] = int(param.float_value)
    +        # Return the largest of the all (queue bad reading...)
    +        return int(np.max(end_frame))
    +
    +    def get_screen_axis(self):
    +        ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group.
    +
    +        Returns
    +        -------
    +        value : (str, str) or None
    +            Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
    +        '''
    +        X = self.get('POINT:X_SCREEN')
    +        Y = self.get('POINT:Y_SCREEN')
    +        if X and Y:
    +            return (X.string_value, Y.string_value)
    +        return None
    +
    +    def get_analog_transform_parameters(self):
    +        ''' Parse analog data transform parameters. '''
    +        # Offsets
    +        analog_offsets = np.zeros((self.analog_used), int)
    +        param = self.get('ANALOG:OFFSET')
    +        if param is not None and param.num_elements > 0:
    +            analog_offsets[:] = param.int16_array[:self.analog_used]
    +
    +        # Scale factors
    +        analog_scales = np.ones((self.analog_used), float)
    +        gen_scale = 1.
    +        param = self.get('ANALOG:GEN_SCALE')
    +        if param is not None:
    +            gen_scale = param.float_value
    +        param = self.get('ANALOG:SCALE')
    +        if param is not None and param.num_elements > 0:
    +            analog_scales[:] = param.float_array[:self.analog_used]
    +
    +        return gen_scale, analog_scales, analog_offsets
    +
    +    def get_analog_transform(self):
    +        ''' Get broadcastable analog transformation parameters.
    +        '''
    +        gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters()
    +        analog_scales *= gen_scale
    +        analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame))
    +        analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame))
    +        return analog_scales, analog_offsets
    +
    +    def get_int8(self, key):
    +        '''Get a parameter value as an 8-bit signed integer.'''
    +        return self.get(key).int8_value
    +
    +    def get_uint8(self, key):
    +        '''Get a parameter value as an 8-bit unsigned integer.'''
    +        return self.get(key).uint8_value
    +
    +    def get_int16(self, key):
    +        '''Get a parameter value as a 16-bit signed integer.'''
    +        return self.get(key).int16_value
    +
    +    def get_uint16(self, key):
    +        '''Get a parameter value as a 16-bit unsigned integer.'''
    +        return self.get(key).uint16_value
    +
    +    def get_int32(self, key):
    +        '''Get a parameter value as a 32-bit signed integer.'''
    +        return self.get(key).int32_value
    +
    +    def get_uint32(self, key):
    +        '''Get a parameter value as a 32-bit unsigned integer.'''
    +        return self.get(key).uint32_value
    +
    +    def get_float(self, key):
    +        '''Get a parameter value as a 32-bit float.'''
    +        return self.get(key).float_value
    +
    +    def get_bytes(self, key):
    +        '''Get a parameter value as a byte string.'''
    +        return self.get(key).bytes_value
    +
    +    def get_string(self, key):
    +        '''Get a parameter value as a string.'''
    +        return self.get(key).string_value
    +

    Subclasses

    • Reader
    • @@ -58,54 +961,221 @@

      Instance variables

      var analog_labels : list

      Labels for each ANALOG data channel.

      +
      + +Expand source code + +
      @property
      +def analog_labels(self) -> list:
      +    ''' Labels for each ANALOG data channel. '''
      +    return self.get('ANALOG:LABELS').string_array
      +
      var analog_per_frame : int

      Number of analog frames per 3D frame (point sample).

      +
      + +Expand source code + +
      @property
      +def analog_per_frame(self) -> int:
      +    '''  Number of analog frames per 3D frame (point sample). '''
      +    return int(self.analog_rate / self.point_rate)
      +
      var analog_rate : float

      Number of analog data samples per second.

      +
      + +Expand source code + +
      @property
      +def analog_rate(self) -> float:
      +    '''  Number of analog data samples per second. '''
      +    try:
      +        return self.get_float('ANALOG:RATE')
      +    except AttributeError:
      +        return self.header.analog_per_frame * self.point_rate
      +
      var analog_sample_count : int

      Number of analog samples per channel.

      +
      + +Expand source code + +
      @property
      +def analog_sample_count(self) -> int:
      +    ''' Number of analog samples per channel. '''
      +    has_analog = self.analog_used > 0
      +    return int(self.frame_count * self.analog_per_frame) * has_analog
      +
      var analog_used : int

      Number of analog measurements, or channels, for each analog data sample.

      +
      + +Expand source code + +
      @property
      +def analog_used(self) -> int:
      +    ''' Number of analog measurements, or channels, for each analog data sample. '''
      +    try:
      +        return self.get_uint16('ANALOG:USED')
      +    except AttributeError:
      +        per_frame = self.header.analog_per_frame
      +        if per_frame > 0:
      +            return int(self.header.analog_count / per_frame)
      +        return 0
      +
      var first_frame : int

      Trial frame corresponding to the first frame recorded in the data.

      +
      + +Expand source code + +
      @property
      +def first_frame(self) -> int:
      +    ''' Trial frame corresponding to the first frame recorded in the data. '''
      +    # Start frame seems to be less of an issue to determine.
      +    # this is a hack for phasespace files ... should put it in a subclass.
      +    param = self.get('TRIAL:ACTUAL_START_FIELD')
      +    if param is not None:
      +        # ACTUAL_START_FIELD is encoded in two 16 byte words...
      +        words = param.uint16_array
      +        return words[0] + words[1] * 65535
      +    return self.header.first_frame
      +
      var frame_count : int

      Number of frames recorded in the data.

      +
      + +Expand source code + +
      @property
      +def frame_count(self) -> int:
      +    ''' Number of frames recorded in the data. '''
      +    return self.last_frame - self.first_frame + 1  # Add 1 since range is inclusive [first, last]
      +
      var header : `Header`

      Access to .c3d header data.

      +
      + +Expand source code + +
      @property
      +def header(self) -> '`c3d.header.Header`':
      +    ''' Access to .c3d header data. '''
      +    return self._header
      +
      var last_frame : int

      Trial frame corresponding to the last frame recorded in the data (inclusive).

      +
      + +Expand source code + +
      @property
      +def last_frame(self) -> int:
      +    ''' Trial frame corresponding to the last frame recorded in the data (inclusive). '''
      +    # Number of frames can be represented in many formats, first check if valid header values
      +    if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535:
      +        return self.header.last_frame
      +
      +    # Check different parameter options where the frame can be encoded
      +    end_frame = [self.header.last_frame, 0.0, 0.0, 0.0]
      +    param = self.get('TRIAL:ACTUAL_END_FIELD')
      +    if param is not None:
      +        # Encoded as 2 16 bit words (rather then 1 32 bit word)
      +        words = param.uint16_array
      +        end_frame[1] = words[0] + words[1] * 65536
      +        #end_frame[1] = param.uint32_value
      +    param = self.get('POINT:LONG_FRAMES')
      +    if param is not None:
      +        # Encoded as float
      +        end_frame[2] = int(param.float_value)
      +    param = self.get('POINT:FRAMES')
      +    if param is not None:
      +        # Can be encoded either as 32 bit float or 16 bit uint
      +        if param.bytes_per_element == 2:
      +            end_frame[3] = param.uint16_value
      +        else:
      +            end_frame[3] = int(param.float_value)
      +    # Return the largest of the all (queue bad reading...)
      +    return int(np.max(end_frame))
      +
      var point_labels : list

      Labels for each POINT data channel.

      +
      + +Expand source code + +
      @property
      +def point_labels(self) -> list:
      +    ''' Labels for each POINT data channel. '''
      +    return self.get('POINT:LABELS').string_array
      +
      var point_rate : float

      Number of sampled 3D coordinates per second.

      +
      + +Expand source code + +
      @property
      +def point_rate(self) -> float:
      +    ''' Number of sampled 3D coordinates per second. '''
      +    try:
      +        return self.get_float('POINT:RATE')
      +    except AttributeError:
      +        return self.header.frame_rate
      +
      var point_scale : float

      Scaling applied to non-float data.

      +
      + +Expand source code + +
      @property
      +def point_scale(self) -> float:
      +    ''' Scaling applied to non-float data. '''
      +    try:
      +        return self.get_float('POINT:SCALE')
      +    except AttributeError:
      +        return self.header.scale_factor
      +
      var point_used : int

      Number of sampled 3D point coordinates per frame.

      +
      + +Expand source code + +
      @property
      +def point_used(self) -> int:
      +    ''' Number of sampled 3D point coordinates per frame. '''
      +    try:
      +        return self.get_uint16('POINT:USED')
      +    except AttributeError:
      +        return self.header.point_count
      +

      Methods

      @@ -132,48 +1202,167 @@

      Returns

      Either a group or parameter with the specified name(s). If neither is found, returns the default value.
      +
      + +Expand source code + +
      def get(self, group, default=None):
      +    '''Get a group or parameter.
      +
      +    Parameters
      +    ----------
      +    group : str
      +        If this string contains a period (.), then the part before the
      +        period will be used to retrieve a group, and the part after the
      +        period will be used to retrieve a parameter from that group. If this
      +        string does not contain a period, then just a group will be
      +        returned.
      +    default : any
      +        Return this value if the named group and parameter are not found.
      +
      +    Returns
      +    -------
      +    value : `c3d.group.Group` or `c3d.parameter.Param`
      +        Either a group or parameter with the specified name(s). If neither
      +        is found, returns the default value.
      +    '''
      +    if is_integer(group):
      +        group = self._groups.get(int(group))
      +        if group is None:
      +            return default
      +        return group
      +    group = group.upper()
      +    param = None
      +    if '.' in group:
      +        group, param = group.split('.', 1)
      +    if ':' in group:
      +        group, param = group.split(':', 1)
      +    if group not in self._groups:
      +        return default
      +    group = self._groups[group]
      +    if param is not None:
      +        return group.get(param, default)
      +    return group
      +
      def get_analog_transform(self)

      Get broadcastable analog transformation parameters.

      +
      + +Expand source code + +
      def get_analog_transform(self):
      +    ''' Get broadcastable analog transformation parameters.
      +    '''
      +    gen_scale, analog_scales, analog_offsets = self.get_analog_transform_parameters()
      +    analog_scales *= gen_scale
      +    analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame))
      +    analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame))
      +    return analog_scales, analog_offsets
      +
      def get_analog_transform_parameters(self)

      Parse analog data transform parameters.

      +
      + +Expand source code + +
      def get_analog_transform_parameters(self):
      +    ''' Parse analog data transform parameters. '''
      +    # Offsets
      +    analog_offsets = np.zeros((self.analog_used), int)
      +    param = self.get('ANALOG:OFFSET')
      +    if param is not None and param.num_elements > 0:
      +        analog_offsets[:] = param.int16_array[:self.analog_used]
      +
      +    # Scale factors
      +    analog_scales = np.ones((self.analog_used), float)
      +    gen_scale = 1.
      +    param = self.get('ANALOG:GEN_SCALE')
      +    if param is not None:
      +        gen_scale = param.float_value
      +    param = self.get('ANALOG:SCALE')
      +    if param is not None and param.num_elements > 0:
      +        analog_scales[:] = param.float_array[:self.analog_used]
      +
      +    return gen_scale, analog_scales, analog_offsets
      +
      def get_bytes(self, key)

      Get a parameter value as a byte string.

      +
      + +Expand source code + +
      def get_bytes(self, key):
      +    '''Get a parameter value as a byte string.'''
      +    return self.get(key).bytes_value
      +
      def get_float(self, key)

      Get a parameter value as a 32-bit float.

      +
      + +Expand source code + +
      def get_float(self, key):
      +    '''Get a parameter value as a 32-bit float.'''
      +    return self.get(key).float_value
      +
      def get_int16(self, key)

      Get a parameter value as a 16-bit signed integer.

      +
      + +Expand source code + +
      def get_int16(self, key):
      +    '''Get a parameter value as a 16-bit signed integer.'''
      +    return self.get(key).int16_value
      +
      def get_int32(self, key)

      Get a parameter value as a 32-bit signed integer.

      +
      + +Expand source code + +
      def get_int32(self, key):
      +    '''Get a parameter value as a 32-bit signed integer.'''
      +    return self.get(key).int32_value
      +
      def get_int8(self, key)

      Get a parameter value as an 8-bit signed integer.

      +
      + +Expand source code + +
      def get_int8(self, key):
      +    '''Get a parameter value as an 8-bit signed integer.'''
      +    return self.get(key).int8_value
      +
      def get_screen_axis(self) @@ -185,60 +1374,155 @@

      Returns

      value : (str, str) or None
      Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
      +
      + +Expand source code + +
      def get_screen_axis(self):
      +    ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group.
      +
      +    Returns
      +    -------
      +    value : (str, str) or None
      +        Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
      +    '''
      +    X = self.get('POINT:X_SCREEN')
      +    Y = self.get('POINT:Y_SCREEN')
      +    if X and Y:
      +        return (X.string_value, Y.string_value)
      +    return None
      +
      def get_string(self, key)

      Get a parameter value as a string.

      +
      + +Expand source code + +
      def get_string(self, key):
      +    '''Get a parameter value as a string.'''
      +    return self.get(key).string_value
      +
      def get_uint16(self, key)

      Get a parameter value as a 16-bit unsigned integer.

      +
      + +Expand source code + +
      def get_uint16(self, key):
      +    '''Get a parameter value as a 16-bit unsigned integer.'''
      +    return self.get(key).uint16_value
      +
      def get_uint32(self, key)

      Get a parameter value as a 32-bit unsigned integer.

      +
      + +Expand source code + +
      def get_uint32(self, key):
      +    '''Get a parameter value as a 32-bit unsigned integer.'''
      +    return self.get(key).uint32_value
      +
      def get_uint8(self, key)

      Get a parameter value as an 8-bit unsigned integer.

      +
      + +Expand source code + +
      def get_uint8(self, key):
      +    '''Get a parameter value as an 8-bit unsigned integer.'''
      +    return self.get(key).uint8_value
      +
      def items(self)

      Get iterable over pairs of (str, Group) entries.

      +
      + +Expand source code + +
      def items(self):
      +    ''' Get iterable over pairs of (str, `c3d.group.Group`) entries.
      +    '''
      +    return ((k, v) for k, v in self._groups.items() if isinstance(k, str))
      +
      def keys(self)

      Get iterable over parameter name keys.

      +
      + +Expand source code + +
      def keys(self):
      +    ''' Get iterable over parameter name keys.
      +    '''
      +    return (k for k in self._groups.keys() if isinstance(k, str))
      +
      def listed(self)

      Get iterable over pairs of (int, Group) entries.

      +
      + +Expand source code + +
      def listed(self):
      +    ''' Get iterable over pairs of (int, `c3d.group.Group`) entries.
      +    '''
      +    return sorted((i, g) for i, g in self._groups.items() if isinstance(i, int))
      +
      def parameter_blocks(self) ‑> int

      Compute the size (in 512B blocks) of the parameter section.

      +
      + +Expand source code + +
      def parameter_blocks(self) -> int:
      +    '''Compute the size (in 512B blocks) of the parameter section.'''
      +    bytes = 4. + sum(g._data.binary_size for g in self._groups.values())
      +    return int(np.ceil(bytes / 512))
      +
      def values(self)

      Get iterable over Group entries.

      +
      + +Expand source code + +
      def values(self):
      +    ''' Get iterable over `c3d.group.Group` entries.
      +    '''
      +    return (v for k, v in self._groups.items() if isinstance(k, str))
      +
      diff --git a/docs/c3d/parameter.html b/docs/c3d/parameter.html index 1d62160..f32c8ab 100644 --- a/docs/c3d/parameter.html +++ b/docs/c3d/parameter.html @@ -23,6 +23,462 @@

      Module c3d.parameter

      Classes used to represent the concept of a parameter in a .c3d file.

      +
      + +Expand source code + +
      ''' Classes used to represent the concept of a parameter in a .c3d file.
      +'''
      +import struct
      +import numpy as np
      +from .utils import DEC_to_IEEE, DEC_to_IEEE_BYTES
      +
      +class ParamData(object):
      +    '''A class representing a single named parameter from a C3D file.
      +
      +    Attributes
      +    ----------
      +    name : str
      +        Name of this parameter.
      +    dtype: DataTypes
      +        Reference to the DataTypes object associated with the file.
      +    desc : str
      +        Brief description of this parameter.
      +    bytes_per_element : int, optional
      +        For array data, this describes the size of each element of data. For
      +        string data (including arrays of strings), this should be -1.
      +    dimensions : list of int
      +        For array data, this describes the dimensions of the array, stored in
      +        column-major (Fortran) order. For arrays of strings, the dimensions here will be
      +        the number of columns (length of each string) followed by the number of
      +        rows (number of strings).
      +    bytes : str
      +        Raw data for this parameter.
      +    '''
      +
      +    def __init__(self,
      +                 name,
      +                 dtype,
      +                 desc='',
      +                 bytes_per_element=1,
      +                 dimensions=None,
      +                 bytes=b'',
      +                 handle=None):
      +        '''Set up a new parameter, only the name is required.'''
      +        self.name = name
      +        self.dtypes = dtype
      +        self.desc = desc
      +        self.bytes_per_element = bytes_per_element
      +        self.dimensions = dimensions or []
      +        self.bytes = bytes
      +        if handle:
      +            self.read(handle)
      +
      +    def __repr__(self):
      +        return '<Param: {}>'.format(self.desc)
      +
      +    @property
      +    def num_elements(self) -> int:
      +        '''Return the number of elements in this parameter's array value.'''
      +        e = 1
      +        for d in self.dimensions:
      +            e *= d
      +        return e
      +
      +    @property
      +    def total_bytes(self) -> int:
      +        '''Return the number of bytes used for storing this parameter's data.'''
      +        return self.num_elements * abs(self.bytes_per_element)
      +
      +    @property
      +    def binary_size(self) -> int:
      +        '''Return the number of bytes needed to store this parameter.'''
      +        return (
      +            1 +  # group_id
      +            2 +  # next offset marker
      +            1 + len(self.name.encode('utf-8')) +  # size of name and name bytes
      +            1 +  # data size
      +            # size of dimensions and dimension bytes
      +            1 + len(self.dimensions) +
      +            self.total_bytes +  # data
      +            1 + len(self.desc.encode('utf-8'))  # size of desc and desc bytes
      +        )
      +
      +    def write(self, group_id, handle):
      +        '''Write binary data for this parameter to a file handle.
      +
      +        Parameters
      +        ----------
      +        group_id : int
      +            The numerical ID of the group that holds this parameter.
      +        handle : file handle
      +            An open, writable, binary file handle.
      +        '''
      +        name = self.name.encode('utf-8')
      +        handle.write(struct.pack('bb', len(name), group_id))
      +        handle.write(name)
      +        handle.write(struct.pack('<h', self.binary_size - 2 - len(name)))
      +        handle.write(struct.pack('b', self.bytes_per_element))
      +        handle.write(struct.pack('B', len(self.dimensions)))
      +        handle.write(struct.pack('B' * len(self.dimensions), *self.dimensions))
      +        if self.bytes is not None and len(self.bytes) > 0:
      +            handle.write(self.bytes)
      +        desc = self.desc.encode('utf-8')
      +        handle.write(struct.pack('B', len(desc)))
      +        handle.write(desc)
      +
      +    def read(self, handle):
      +        '''Read binary data for this parameter from a file handle.
      +
      +        This reads exactly enough data from the current position in the file to
      +        initialize the parameter.
      +        '''
      +        self.bytes_per_element, = struct.unpack('b', handle.read(1))
      +        dims, = struct.unpack('B', handle.read(1))
      +        self.dimensions = [struct.unpack('B', handle.read(1))[
      +            0] for _ in range(dims)]
      +        self.bytes = b''
      +        if self.total_bytes:
      +            self.bytes = handle.read(self.total_bytes)
      +        desc_size, = struct.unpack('B', handle.read(1))
      +        self.desc = desc_size and self.dtypes.decode_string(handle.read(desc_size)) or ''
      +
      +    def _as(self, dtype):
      +        '''Unpack the raw bytes of this param using the given struct format.'''
      +        return np.frombuffer(self.bytes, count=1, dtype=dtype)[0]
      +
      +    def _as_array(self, dtype, copy=True):
      +        '''Unpack the raw bytes of this param using the given data format.'''
      +        assert self.dimensions, \
      +            '{}: cannot get value as {} array!'.format(self.name, dtype)
      +        elems = np.frombuffer(self.bytes, dtype=dtype)
      +        # Reverse shape as the shape is defined in fortran format
      +        view = elems.reshape(self.dimensions[::-1])
      +        if copy:
      +            return view.copy()
      +        return view
      +
      +    def _as_any(self, dtype):
      +        '''Unpack the raw bytes of this param as either array or single value.'''
      +        if 0 in self.dimensions[:]:             # Check if any dimension is 0 (empty buffer)
      +            return []                                           # Buffer is empty
      +
      +        if len(self.dimensions) == 0:           # Parse data as a single value
      +            if dtype == np.float32:                     # Floats need to be parsed separately!
      +                return self.float_value
      +            return self._data._as(dtype)
      +        else:                                                           # Parse data as array
      +            if dtype == np.float32:
      +                data = self.float_array
      +            else:
      +                data = self._data._as_array(dtype)
      +            if len(self.dimensions) < 2:    # Check if data is contained in a single dimension
      +                return data.flatten()
      +            return data
      +
      +class ParamReadonly(object):
      +    ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry.
      +    '''
      +
      +    def __init__(self, data):
      +        self._data = data
      +
      +    def __eq__(self, other):
      +        return self._data is other._data
      +
      +    @property
      +    def name(self) -> str:
      +        ''' Get the parameter name. '''
      +        return self._data.name
      +
      +    @property
      +    def desc(self) -> str:
      +        ''' Get the parameter descriptor. '''
      +        return self._data.desc
      +
      +    @property
      +    def dtypes(self):
      +        ''' Convenience accessor to the `c3d.dtypes.DataTypes` instance associated with the parameter. '''
      +        return self._data.dtypes
      +
      +    @property
      +    def dimensions(self) -> (int, ...):
      +        ''' Shape of the parameter data (Fortran shape). '''
      +        return self._data.dimensions
      +
      +    @property
      +    def num_elements(self) -> int:
      +        '''Return the number of elements in this parameter's array value.'''
      +        return self._data.num_elements
      +
      +    @property
      +    def bytes_per_element(self) -> int:
      +        '''Return the number of bytes used to store each data element.'''
      +        return self._data.bytes_per_element
      +
      +    @property
      +    def total_bytes(self) -> int:
      +        '''Return the number of bytes used for storing this parameter's data.'''
      +        return self._data.total_bytes
      +
      +    @property
      +    def binary_size(self) -> int:
      +        '''Return the number of bytes needed to store this parameter.'''
      +        return self._data.binary_size
      +
      +    @property
      +    def int8_value(self):
      +        '''Get the param as an 8-bit signed integer.'''
      +        return self._data._as(self.dtypes.int8)
      +
      +    @property
      +    def uint8_value(self):
      +        '''Get the param as an 8-bit unsigned integer.'''
      +        return self._data._as(self.dtypes.uint8)
      +
      +    @property
      +    def int16_value(self):
      +        '''Get the param as a 16-bit signed integer.'''
      +        return self._data._as(self.dtypes.int16)
      +
      +    @property
      +    def uint16_value(self):
      +        '''Get the param as a 16-bit unsigned integer.'''
      +        return self._data._as(self.dtypes.uint16)
      +
      +    @property
      +    def int32_value(self):
      +        '''Get the param as a 32-bit signed integer.'''
      +        return self._data._as(self.dtypes.int32)
      +
      +    @property
      +    def uint32_value(self):
      +        '''Get the param as a 32-bit unsigned integer.'''
      +        return self._data._as(self.dtypes.uint32)
      +
      +    @property
      +    def uint_value(self):
      +        ''' Get the param as a unsigned integer of appropriate type. '''
      +        if self.total_bytes >= 4:
      +            return self.uint32_value
      +        elif self.total_bytes >= 2:
      +            return self.uint16_value
      +        else:
      +            return self.uint8_value
      +
      +    @property
      +    def int_value(self):
      +        ''' Get the param as a signed integer of appropriate type. '''
      +        if self.total_bytes >= 4:
      +            return self.int32_value
      +        elif self.total_bytes >= 2:
      +            return self.int16_value
      +        else:
      +            return self.int8_value
      +
      +    @property
      +    def float_value(self):
      +        '''Get the param as a floating point value of appropriate type.'''
      +        if self.total_bytes > 4:
      +            if self.dtypes.is_dec:
      +                raise AttributeError("64 bit DEC floating point is not supported.")
      +            # 64-bit floating point is not a standard
      +            return self._data._as(self.dtypes.float64)
      +        elif self.total_bytes == 4:
      +            if self.dtypes.is_dec:
      +                return DEC_to_IEEE(self._data._as(np.uint32))
      +            else:  # is_mips or is_ieee
      +                return self._data._as(self.dtypes.float32)
      +        else:
      +            raise AttributeError("Only 32 and 64 bit floating point is supported.")
      +
      +    @property
      +    def bytes_value(self):
      +        '''Get the param as a raw byte string.'''
      +        return self._data.bytes
      +
      +    @property
      +    def string_value(self):
      +        '''Get the param as a unicode string.'''
      +        return self.dtypes.decode_string(self._data.bytes)
      +
      +    @property
      +    def int8_array(self):
      +        '''Get the param as an array of 8-bit signed integers.'''
      +        return self._data._as_array(self.dtypes.int8)
      +
      +    @property
      +    def uint8_array(self):
      +        '''Get the param as an array of 8-bit unsigned integers.'''
      +        return self._data._as_array(self.dtypes.uint8)
      +
      +    @property
      +    def int16_array(self):
      +        '''Get the param as an array of 16-bit signed integers.'''
      +        return self._data._as_array(self.dtypes.int16)
      +
      +    @property
      +    def uint16_array(self):
      +        '''Get the param as an array of 16-bit unsigned integers.'''
      +        return self._data._as_array(self.dtypes.uint16)
      +
      +    @property
      +    def int32_array(self):
      +        '''Get the param as an array of 32-bit signed integers.'''
      +        return self._data._as_array(self.dtypes.int32)
      +
      +    @property
      +    def uint32_array(self):
      +        '''Get the param as an array of 32-bit unsigned integers.'''
      +        return self._data._as_array(self.dtypes.uint32)
      +
      +    @property
      +    def int64_array(self):
      +        '''Get the param as an array of 32-bit signed integers.'''
      +        return self._data._as_array(self.dtypes.int64)
      +
      +    @property
      +    def uint64_array(self):
      +        '''Get the param as an array of 32-bit unsigned integers.'''
      +        return self._data._as_array(self.dtypes.uint64)
      +
      +    @property
      +    def float32_array(self):
      +        '''Get the param as an array of 32-bit floats.'''
      +        # Convert float data if not IEEE processor
      +        if self.dtypes.is_dec:
      +            # _as_array but for DEC
      +            assert self.dimensions, \
      +                '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32)
      +            return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1])  # Reverse fortran format
      +        else:  # is_ieee or is_mips
      +            return self._data._as_array(self.dtypes.float32)
      +
      +    @property
      +    def float64_array(self):
      +        '''Get the param as an array of 64-bit floats.'''
      +        # Convert float data if not IEEE processor
      +        if self.dtypes.is_dec:
      +            raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.')
      +        else:  # is_ieee or is_mips
      +            return self._data._as_array(self.dtypes.float64)
      +
      +    @property
      +    def float_array(self):
      +        '''Get the param as an array of 32 or 64 bit floats.'''
      +        # Convert float data if not IEEE processor
      +        if self.bytes_per_element == 4:
      +            return self.float32_array
      +        elif self.bytes_per_element == 8:
      +            return self.float64_array
      +        else:
      +            raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element +
      +                            "floating-point precission is not unsupported.")
      +
      +    @property
      +    def int_array(self):
      +        '''Get the param as an array of integer values.'''
      +        # Convert float data if not IEEE processor
      +        if self.bytes_per_element == 1:
      +            return self.int8_array
      +        elif self.bytes_per_element == 2:
      +            return self.int16_array
      +        elif self.bytes_per_element == 4:
      +            return self.int32_array
      +        elif self.bytes_per_element == 8:
      +            return self.int64_array
      +        else:
      +            raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." %
      +                            self.bytes_per_element)
      +
      +    @property
      +    def uint_array(self):
      +        '''Get the param as an array of integer values.'''
      +        # Convert float data if not IEEE processor
      +        if self.bytes_per_element == 1:
      +            return self.uint8_array
      +        elif self.bytes_per_element == 2:
      +            return self.uint16_array
      +        elif self.bytes_per_element == 4:
      +            return self.uint32_array
      +        elif self.bytes_per_element == 8:
      +            return self.uint64_array
      +        else:
      +            raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." %
      +                            self.bytes_per_element)
      +
      +    @property
      +    def bytes_array(self):
      +        '''Get the param as an array of raw byte strings.'''
      +        # Decode different dimensions
      +        if len(self.dimensions) == 0:
      +            return np.array([])
      +        elif len(self.dimensions) == 1:
      +            return np.array(self._data.bytes)
      +        else:
      +            # Convert Fortran shape (data in memory is identical, shape is transposed)
      +            word_len = self.dimensions[0]
      +            dims = self.dimensions[1:][::-1]  # Identical to: [:0:-1]
      +            byte_steps = np.cumprod(self.dimensions[:-1])[::-1]
      +            # Generate mult-dimensional array and parse byte words
      +            byte_arr = np.empty(dims, dtype=object)
      +            for i in np.ndindex(*dims):
      +                # Calculate byte offset as sum of each array index times the byte step of each dimension.
      +                off = np.sum(np.multiply(i, byte_steps))
      +                byte_arr[i] = self._data.bytes[off:off+word_len]
      +            return byte_arr
      +
      +    @property
      +    def string_array(self):
      +        '''Get the param as a python array of unicode strings.'''
      +        # Decode different dimensions
      +        if len(self.dimensions) == 0:
      +            return np.array([])
      +        elif len(self.dimensions) == 1:
      +            return np.array([self.string_value])
      +        else:
      +            # Parse byte sequences
      +            byte_arr = self.bytes_array
      +            # Decode sequences
      +            for i in np.ndindex(byte_arr.shape):
      +                byte_arr[i] = self.dtypes.decode_string(byte_arr[i])
      +            return byte_arr
      +
      +    @property
      +    def _as_integer_or_float_value(self):
      +        ''' Get the param as either 32 bit float or unsigned integer.
      +            Evaluates if an integer is stored as a floating point representation.
      +        '''
      +        if self.total_bytes >= 4:
      +            # Check if float value representation is an integer
      +            value = self.float_value
      +            if int(value) == value:
      +                return value
      +            return self.uint32_value
      +        elif self.total_bytes >= 2:
      +            return self.uint16_value
      +        else:
      +            return self.uint8_value
      +
      +class Param(ParamReadonly):
      +    ''' Wrapper exposing both readable and writable attributes of a `c3d.parameter.ParamData` entry.
      +    '''
      +    def __init__(self, data):
      +        super(Param, self).__init__(data)
      +
      +    def readonly(self):
      +        ''' Returns a readonly `c3d.parameter.ParamReadonly` instance. '''
      +        return ParamReadonly(self._data)
      +
      +    @property
      +    def bytes(self) -> bytes:
      +        ''' Get or set the parameter bytes. '''
      +        return self._data.bytes
      +
      +    @bytes.setter
      +    def bytes(self, value):
      +        self._data.bytes = value
      +
      @@ -39,6 +495,29 @@

      Classes

    Wrapper exposing both readable and writable attributes of a ParamData entry.

    +
    + +Expand source code + +
    class Param(ParamReadonly):
    +    ''' Wrapper exposing both readable and writable attributes of a `c3d.parameter.ParamData` entry.
    +    '''
    +    def __init__(self, data):
    +        super(Param, self).__init__(data)
    +
    +    def readonly(self):
    +        ''' Returns a readonly `c3d.parameter.ParamReadonly` instance. '''
    +        return ParamReadonly(self._data)
    +
    +    @property
    +    def bytes(self) -> bytes:
    +        ''' Get or set the parameter bytes. '''
    +        return self._data.bytes
    +
    +    @bytes.setter
    +    def bytes(self, value):
    +        self._data.bytes = value
    +

    Ancestors

    • ParamReadonly
    • @@ -48,6 +527,15 @@

      Instance variables

      var bytes : bytes

      Get or set the parameter bytes.

      +
      + +Expand source code + +
      @property
      +def bytes(self) -> bytes:
      +    ''' Get or set the parameter bytes. '''
      +    return self._data.bytes
      +

      Methods

      @@ -57,6 +545,14 @@

      Methods

      Returns a readonly ParamReadonly instance.

      +
      + +Expand source code + +
      def readonly(self):
      +    ''' Returns a readonly `c3d.parameter.ParamReadonly` instance. '''
      +    return ParamReadonly(self._data)
      +

      Inherited members

      @@ -127,19 +623,205 @@

      Attributes

      Raw data for this parameter.

      Set up a new parameter, only the name is required.

      +
      + +Expand source code + +
      class ParamData(object):
      +    '''A class representing a single named parameter from a C3D file.
      +
      +    Attributes
      +    ----------
      +    name : str
      +        Name of this parameter.
      +    dtype: DataTypes
      +        Reference to the DataTypes object associated with the file.
      +    desc : str
      +        Brief description of this parameter.
      +    bytes_per_element : int, optional
      +        For array data, this describes the size of each element of data. For
      +        string data (including arrays of strings), this should be -1.
      +    dimensions : list of int
      +        For array data, this describes the dimensions of the array, stored in
      +        column-major (Fortran) order. For arrays of strings, the dimensions here will be
      +        the number of columns (length of each string) followed by the number of
      +        rows (number of strings).
      +    bytes : str
      +        Raw data for this parameter.
      +    '''
      +
      +    def __init__(self,
      +                 name,
      +                 dtype,
      +                 desc='',
      +                 bytes_per_element=1,
      +                 dimensions=None,
      +                 bytes=b'',
      +                 handle=None):
      +        '''Set up a new parameter, only the name is required.'''
      +        self.name = name
      +        self.dtypes = dtype
      +        self.desc = desc
      +        self.bytes_per_element = bytes_per_element
      +        self.dimensions = dimensions or []
      +        self.bytes = bytes
      +        if handle:
      +            self.read(handle)
      +
      +    def __repr__(self):
      +        return '<Param: {}>'.format(self.desc)
      +
      +    @property
      +    def num_elements(self) -> int:
      +        '''Return the number of elements in this parameter's array value.'''
      +        e = 1
      +        for d in self.dimensions:
      +            e *= d
      +        return e
      +
      +    @property
      +    def total_bytes(self) -> int:
      +        '''Return the number of bytes used for storing this parameter's data.'''
      +        return self.num_elements * abs(self.bytes_per_element)
      +
      +    @property
      +    def binary_size(self) -> int:
      +        '''Return the number of bytes needed to store this parameter.'''
      +        return (
      +            1 +  # group_id
      +            2 +  # next offset marker
      +            1 + len(self.name.encode('utf-8')) +  # size of name and name bytes
      +            1 +  # data size
      +            # size of dimensions and dimension bytes
      +            1 + len(self.dimensions) +
      +            self.total_bytes +  # data
      +            1 + len(self.desc.encode('utf-8'))  # size of desc and desc bytes
      +        )
      +
      +    def write(self, group_id, handle):
      +        '''Write binary data for this parameter to a file handle.
      +
      +        Parameters
      +        ----------
      +        group_id : int
      +            The numerical ID of the group that holds this parameter.
      +        handle : file handle
      +            An open, writable, binary file handle.
      +        '''
      +        name = self.name.encode('utf-8')
      +        handle.write(struct.pack('bb', len(name), group_id))
      +        handle.write(name)
      +        handle.write(struct.pack('<h', self.binary_size - 2 - len(name)))
      +        handle.write(struct.pack('b', self.bytes_per_element))
      +        handle.write(struct.pack('B', len(self.dimensions)))
      +        handle.write(struct.pack('B' * len(self.dimensions), *self.dimensions))
      +        if self.bytes is not None and len(self.bytes) > 0:
      +            handle.write(self.bytes)
      +        desc = self.desc.encode('utf-8')
      +        handle.write(struct.pack('B', len(desc)))
      +        handle.write(desc)
      +
      +    def read(self, handle):
      +        '''Read binary data for this parameter from a file handle.
      +
      +        This reads exactly enough data from the current position in the file to
      +        initialize the parameter.
      +        '''
      +        self.bytes_per_element, = struct.unpack('b', handle.read(1))
      +        dims, = struct.unpack('B', handle.read(1))
      +        self.dimensions = [struct.unpack('B', handle.read(1))[
      +            0] for _ in range(dims)]
      +        self.bytes = b''
      +        if self.total_bytes:
      +            self.bytes = handle.read(self.total_bytes)
      +        desc_size, = struct.unpack('B', handle.read(1))
      +        self.desc = desc_size and self.dtypes.decode_string(handle.read(desc_size)) or ''
      +
      +    def _as(self, dtype):
      +        '''Unpack the raw bytes of this param using the given struct format.'''
      +        return np.frombuffer(self.bytes, count=1, dtype=dtype)[0]
      +
      +    def _as_array(self, dtype, copy=True):
      +        '''Unpack the raw bytes of this param using the given data format.'''
      +        assert self.dimensions, \
      +            '{}: cannot get value as {} array!'.format(self.name, dtype)
      +        elems = np.frombuffer(self.bytes, dtype=dtype)
      +        # Reverse shape as the shape is defined in fortran format
      +        view = elems.reshape(self.dimensions[::-1])
      +        if copy:
      +            return view.copy()
      +        return view
      +
      +    def _as_any(self, dtype):
      +        '''Unpack the raw bytes of this param as either array or single value.'''
      +        if 0 in self.dimensions[:]:             # Check if any dimension is 0 (empty buffer)
      +            return []                                           # Buffer is empty
      +
      +        if len(self.dimensions) == 0:           # Parse data as a single value
      +            if dtype == np.float32:                     # Floats need to be parsed separately!
      +                return self.float_value
      +            return self._data._as(dtype)
      +        else:                                                           # Parse data as array
      +            if dtype == np.float32:
      +                data = self.float_array
      +            else:
      +                data = self._data._as_array(dtype)
      +            if len(self.dimensions) < 2:    # Check if data is contained in a single dimension
      +                return data.flatten()
      +            return data
      +

      Instance variables

      var binary_size : int

      Return the number of bytes needed to store this parameter.

      +
      + +Expand source code + +
      @property
      +def binary_size(self) -> int:
      +    '''Return the number of bytes needed to store this parameter.'''
      +    return (
      +        1 +  # group_id
      +        2 +  # next offset marker
      +        1 + len(self.name.encode('utf-8')) +  # size of name and name bytes
      +        1 +  # data size
      +        # size of dimensions and dimension bytes
      +        1 + len(self.dimensions) +
      +        self.total_bytes +  # data
      +        1 + len(self.desc.encode('utf-8'))  # size of desc and desc bytes
      +    )
      +
      var num_elements : int

      Return the number of elements in this parameter's array value.

      +
      + +Expand source code + +
      @property
      +def num_elements(self) -> int:
      +    '''Return the number of elements in this parameter's array value.'''
      +    e = 1
      +    for d in self.dimensions:
      +        e *= d
      +    return e
      +
      var total_bytes : int

      Return the number of bytes used for storing this parameter's data.

      +
      + +Expand source code + +
      @property
      +def total_bytes(self) -> int:
      +    '''Return the number of bytes used for storing this parameter's data.'''
      +    return self.num_elements * abs(self.bytes_per_element)
      +

      Methods

      @@ -151,6 +833,26 @@

      Methods

      Read binary data for this parameter from a file handle.

      This reads exactly enough data from the current position in the file to initialize the parameter.

      +
      + +Expand source code + +
      def read(self, handle):
      +    '''Read binary data for this parameter from a file handle.
      +
      +    This reads exactly enough data from the current position in the file to
      +    initialize the parameter.
      +    '''
      +    self.bytes_per_element, = struct.unpack('b', handle.read(1))
      +    dims, = struct.unpack('B', handle.read(1))
      +    self.dimensions = [struct.unpack('B', handle.read(1))[
      +        0] for _ in range(dims)]
      +    self.bytes = b''
      +    if self.total_bytes:
      +        self.bytes = handle.read(self.total_bytes)
      +    desc_size, = struct.unpack('B', handle.read(1))
      +    self.desc = desc_size and self.dtypes.decode_string(handle.read(desc_size)) or ''
      +
    def write(self, group_id, handle) @@ -164,6 +866,33 @@

    Parameters

    handle : file handle
    An open, writable, binary file handle.
    +
    + +Expand source code + +
    def write(self, group_id, handle):
    +    '''Write binary data for this parameter to a file handle.
    +
    +    Parameters
    +    ----------
    +    group_id : int
    +        The numerical ID of the group that holds this parameter.
    +    handle : file handle
    +        An open, writable, binary file handle.
    +    '''
    +    name = self.name.encode('utf-8')
    +    handle.write(struct.pack('bb', len(name), group_id))
    +    handle.write(name)
    +    handle.write(struct.pack('<h', self.binary_size - 2 - len(name)))
    +    handle.write(struct.pack('b', self.bytes_per_element))
    +    handle.write(struct.pack('B', len(self.dimensions)))
    +    handle.write(struct.pack('B' * len(self.dimensions), *self.dimensions))
    +    if self.bytes is not None and len(self.bytes) > 0:
    +        handle.write(self.bytes)
    +    desc = self.desc.encode('utf-8')
    +    handle.write(struct.pack('B', len(desc)))
    +    handle.write(desc)
    +
    @@ -173,6 +902,294 @@

    Parameters

    Wrapper exposing readonly attributes of a ParamData entry.

    +
    + +Expand source code + +
    class ParamReadonly(object):
    +    ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry.
    +    '''
    +
    +    def __init__(self, data):
    +        self._data = data
    +
    +    def __eq__(self, other):
    +        return self._data is other._data
    +
    +    @property
    +    def name(self) -> str:
    +        ''' Get the parameter name. '''
    +        return self._data.name
    +
    +    @property
    +    def desc(self) -> str:
    +        ''' Get the parameter descriptor. '''
    +        return self._data.desc
    +
    +    @property
    +    def dtypes(self):
    +        ''' Convenience accessor to the `c3d.dtypes.DataTypes` instance associated with the parameter. '''
    +        return self._data.dtypes
    +
    +    @property
    +    def dimensions(self) -> (int, ...):
    +        ''' Shape of the parameter data (Fortran shape). '''
    +        return self._data.dimensions
    +
    +    @property
    +    def num_elements(self) -> int:
    +        '''Return the number of elements in this parameter's array value.'''
    +        return self._data.num_elements
    +
    +    @property
    +    def bytes_per_element(self) -> int:
    +        '''Return the number of bytes used to store each data element.'''
    +        return self._data.bytes_per_element
    +
    +    @property
    +    def total_bytes(self) -> int:
    +        '''Return the number of bytes used for storing this parameter's data.'''
    +        return self._data.total_bytes
    +
    +    @property
    +    def binary_size(self) -> int:
    +        '''Return the number of bytes needed to store this parameter.'''
    +        return self._data.binary_size
    +
    +    @property
    +    def int8_value(self):
    +        '''Get the param as an 8-bit signed integer.'''
    +        return self._data._as(self.dtypes.int8)
    +
    +    @property
    +    def uint8_value(self):
    +        '''Get the param as an 8-bit unsigned integer.'''
    +        return self._data._as(self.dtypes.uint8)
    +
    +    @property
    +    def int16_value(self):
    +        '''Get the param as a 16-bit signed integer.'''
    +        return self._data._as(self.dtypes.int16)
    +
    +    @property
    +    def uint16_value(self):
    +        '''Get the param as a 16-bit unsigned integer.'''
    +        return self._data._as(self.dtypes.uint16)
    +
    +    @property
    +    def int32_value(self):
    +        '''Get the param as a 32-bit signed integer.'''
    +        return self._data._as(self.dtypes.int32)
    +
    +    @property
    +    def uint32_value(self):
    +        '''Get the param as a 32-bit unsigned integer.'''
    +        return self._data._as(self.dtypes.uint32)
    +
    +    @property
    +    def uint_value(self):
    +        ''' Get the param as a unsigned integer of appropriate type. '''
    +        if self.total_bytes >= 4:
    +            return self.uint32_value
    +        elif self.total_bytes >= 2:
    +            return self.uint16_value
    +        else:
    +            return self.uint8_value
    +
    +    @property
    +    def int_value(self):
    +        ''' Get the param as a signed integer of appropriate type. '''
    +        if self.total_bytes >= 4:
    +            return self.int32_value
    +        elif self.total_bytes >= 2:
    +            return self.int16_value
    +        else:
    +            return self.int8_value
    +
    +    @property
    +    def float_value(self):
    +        '''Get the param as a floating point value of appropriate type.'''
    +        if self.total_bytes > 4:
    +            if self.dtypes.is_dec:
    +                raise AttributeError("64 bit DEC floating point is not supported.")
    +            # 64-bit floating point is not a standard
    +            return self._data._as(self.dtypes.float64)
    +        elif self.total_bytes == 4:
    +            if self.dtypes.is_dec:
    +                return DEC_to_IEEE(self._data._as(np.uint32))
    +            else:  # is_mips or is_ieee
    +                return self._data._as(self.dtypes.float32)
    +        else:
    +            raise AttributeError("Only 32 and 64 bit floating point is supported.")
    +
    +    @property
    +    def bytes_value(self):
    +        '''Get the param as a raw byte string.'''
    +        return self._data.bytes
    +
    +    @property
    +    def string_value(self):
    +        '''Get the param as a unicode string.'''
    +        return self.dtypes.decode_string(self._data.bytes)
    +
    +    @property
    +    def int8_array(self):
    +        '''Get the param as an array of 8-bit signed integers.'''
    +        return self._data._as_array(self.dtypes.int8)
    +
    +    @property
    +    def uint8_array(self):
    +        '''Get the param as an array of 8-bit unsigned integers.'''
    +        return self._data._as_array(self.dtypes.uint8)
    +
    +    @property
    +    def int16_array(self):
    +        '''Get the param as an array of 16-bit signed integers.'''
    +        return self._data._as_array(self.dtypes.int16)
    +
    +    @property
    +    def uint16_array(self):
    +        '''Get the param as an array of 16-bit unsigned integers.'''
    +        return self._data._as_array(self.dtypes.uint16)
    +
    +    @property
    +    def int32_array(self):
    +        '''Get the param as an array of 32-bit signed integers.'''
    +        return self._data._as_array(self.dtypes.int32)
    +
    +    @property
    +    def uint32_array(self):
    +        '''Get the param as an array of 32-bit unsigned integers.'''
    +        return self._data._as_array(self.dtypes.uint32)
    +
    +    @property
    +    def int64_array(self):
    +        '''Get the param as an array of 32-bit signed integers.'''
    +        return self._data._as_array(self.dtypes.int64)
    +
    +    @property
    +    def uint64_array(self):
    +        '''Get the param as an array of 32-bit unsigned integers.'''
    +        return self._data._as_array(self.dtypes.uint64)
    +
    +    @property
    +    def float32_array(self):
    +        '''Get the param as an array of 32-bit floats.'''
    +        # Convert float data if not IEEE processor
    +        if self.dtypes.is_dec:
    +            # _as_array but for DEC
    +            assert self.dimensions, \
    +                '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32)
    +            return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1])  # Reverse fortran format
    +        else:  # is_ieee or is_mips
    +            return self._data._as_array(self.dtypes.float32)
    +
    +    @property
    +    def float64_array(self):
    +        '''Get the param as an array of 64-bit floats.'''
    +        # Convert float data if not IEEE processor
    +        if self.dtypes.is_dec:
    +            raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.')
    +        else:  # is_ieee or is_mips
    +            return self._data._as_array(self.dtypes.float64)
    +
    +    @property
    +    def float_array(self):
    +        '''Get the param as an array of 32 or 64 bit floats.'''
    +        # Convert float data if not IEEE processor
    +        if self.bytes_per_element == 4:
    +            return self.float32_array
    +        elif self.bytes_per_element == 8:
    +            return self.float64_array
    +        else:
    +            raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element +
    +                            "floating-point precission is not unsupported.")
    +
    +    @property
    +    def int_array(self):
    +        '''Get the param as an array of integer values.'''
    +        # Convert float data if not IEEE processor
    +        if self.bytes_per_element == 1:
    +            return self.int8_array
    +        elif self.bytes_per_element == 2:
    +            return self.int16_array
    +        elif self.bytes_per_element == 4:
    +            return self.int32_array
    +        elif self.bytes_per_element == 8:
    +            return self.int64_array
    +        else:
    +            raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." %
    +                            self.bytes_per_element)
    +
    +    @property
    +    def uint_array(self):
    +        '''Get the param as an array of integer values.'''
    +        # Convert float data if not IEEE processor
    +        if self.bytes_per_element == 1:
    +            return self.uint8_array
    +        elif self.bytes_per_element == 2:
    +            return self.uint16_array
    +        elif self.bytes_per_element == 4:
    +            return self.uint32_array
    +        elif self.bytes_per_element == 8:
    +            return self.uint64_array
    +        else:
    +            raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." %
    +                            self.bytes_per_element)
    +
    +    @property
    +    def bytes_array(self):
    +        '''Get the param as an array of raw byte strings.'''
    +        # Decode different dimensions
    +        if len(self.dimensions) == 0:
    +            return np.array([])
    +        elif len(self.dimensions) == 1:
    +            return np.array(self._data.bytes)
    +        else:
    +            # Convert Fortran shape (data in memory is identical, shape is transposed)
    +            word_len = self.dimensions[0]
    +            dims = self.dimensions[1:][::-1]  # Identical to: [:0:-1]
    +            byte_steps = np.cumprod(self.dimensions[:-1])[::-1]
    +            # Generate mult-dimensional array and parse byte words
    +            byte_arr = np.empty(dims, dtype=object)
    +            for i in np.ndindex(*dims):
    +                # Calculate byte offset as sum of each array index times the byte step of each dimension.
    +                off = np.sum(np.multiply(i, byte_steps))
    +                byte_arr[i] = self._data.bytes[off:off+word_len]
    +            return byte_arr
    +
    +    @property
    +    def string_array(self):
    +        '''Get the param as a python array of unicode strings.'''
    +        # Decode different dimensions
    +        if len(self.dimensions) == 0:
    +            return np.array([])
    +        elif len(self.dimensions) == 1:
    +            return np.array([self.string_value])
    +        else:
    +            # Parse byte sequences
    +            byte_arr = self.bytes_array
    +            # Decode sequences
    +            for i in np.ndindex(byte_arr.shape):
    +                byte_arr[i] = self.dtypes.decode_string(byte_arr[i])
    +            return byte_arr
    +
    +    @property
    +    def _as_integer_or_float_value(self):
    +        ''' Get the param as either 32 bit float or unsigned integer.
    +            Evaluates if an integer is stored as a floating point representation.
    +        '''
    +        if self.total_bytes >= 4:
    +            # Check if float value representation is an integer
    +            value = self.float_value
    +            if int(value) == value:
    +                return value
    +            return self.uint32_value
    +        elif self.total_bytes >= 2:
    +            return self.uint16_value
    +        else:
    +            return self.uint8_value
    +

    Subclasses

    • Param
    • @@ -182,138 +1199,532 @@

      Instance variables

      var binary_size : int

      Return the number of bytes needed to store this parameter.

      +
      + +Expand source code + +
      @property
      +def binary_size(self) -> int:
      +    '''Return the number of bytes needed to store this parameter.'''
      +    return self._data.binary_size
      +
      var bytes_array

      Get the param as an array of raw byte strings.

      +
      + +Expand source code + +
      @property
      +def bytes_array(self):
      +    '''Get the param as an array of raw byte strings.'''
      +    # Decode different dimensions
      +    if len(self.dimensions) == 0:
      +        return np.array([])
      +    elif len(self.dimensions) == 1:
      +        return np.array(self._data.bytes)
      +    else:
      +        # Convert Fortran shape (data in memory is identical, shape is transposed)
      +        word_len = self.dimensions[0]
      +        dims = self.dimensions[1:][::-1]  # Identical to: [:0:-1]
      +        byte_steps = np.cumprod(self.dimensions[:-1])[::-1]
      +        # Generate mult-dimensional array and parse byte words
      +        byte_arr = np.empty(dims, dtype=object)
      +        for i in np.ndindex(*dims):
      +            # Calculate byte offset as sum of each array index times the byte step of each dimension.
      +            off = np.sum(np.multiply(i, byte_steps))
      +            byte_arr[i] = self._data.bytes[off:off+word_len]
      +        return byte_arr
      +
      var bytes_per_element : int

      Return the number of bytes used to store each data element.

      +
      + +Expand source code + +
      @property
      +def bytes_per_element(self) -> int:
      +    '''Return the number of bytes used to store each data element.'''
      +    return self._data.bytes_per_element
      +
      var bytes_value

      Get the param as a raw byte string.

      +
      + +Expand source code + +
      @property
      +def bytes_value(self):
      +    '''Get the param as a raw byte string.'''
      +    return self._data.bytes
      +
      var desc : str

      Get the parameter descriptor.

      +
      + +Expand source code + +
      @property
      +def desc(self) -> str:
      +    ''' Get the parameter descriptor. '''
      +    return self._data.desc
      +
      var dimensions : (, Ellipsis)

      Shape of the parameter data (Fortran shape).

      +
      + +Expand source code + +
      @property
      +def dimensions(self) -> (int, ...):
      +    ''' Shape of the parameter data (Fortran shape). '''
      +    return self._data.dimensions
      +
      var dtypes

      Convenience accessor to the DataTypes instance associated with the parameter.

      +
      + +Expand source code + +
      @property
      +def dtypes(self):
      +    ''' Convenience accessor to the `c3d.dtypes.DataTypes` instance associated with the parameter. '''
      +    return self._data.dtypes
      +
      var float32_array

      Get the param as an array of 32-bit floats.

      +
      + +Expand source code + +
      @property
      +def float32_array(self):
      +    '''Get the param as an array of 32-bit floats.'''
      +    # Convert float data if not IEEE processor
      +    if self.dtypes.is_dec:
      +        # _as_array but for DEC
      +        assert self.dimensions, \
      +            '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32)
      +        return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1])  # Reverse fortran format
      +    else:  # is_ieee or is_mips
      +        return self._data._as_array(self.dtypes.float32)
      +
      var float64_array

      Get the param as an array of 64-bit floats.

      +
      + +Expand source code + +
      @property
      +def float64_array(self):
      +    '''Get the param as an array of 64-bit floats.'''
      +    # Convert float data if not IEEE processor
      +    if self.dtypes.is_dec:
      +        raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.')
      +    else:  # is_ieee or is_mips
      +        return self._data._as_array(self.dtypes.float64)
      +
      var float_array

      Get the param as an array of 32 or 64 bit floats.

      +
      + +Expand source code + +
      @property
      +def float_array(self):
      +    '''Get the param as an array of 32 or 64 bit floats.'''
      +    # Convert float data if not IEEE processor
      +    if self.bytes_per_element == 4:
      +        return self.float32_array
      +    elif self.bytes_per_element == 8:
      +        return self.float64_array
      +    else:
      +        raise TypeError("Parsing parameter bytes to an array with %i bit " % self.bytes_per_element +
      +                        "floating-point precission is not unsupported.")
      +
      var float_value

      Get the param as a floating point value of appropriate type.

      +
      + +Expand source code + +
      @property
      +def float_value(self):
      +    '''Get the param as a floating point value of appropriate type.'''
      +    if self.total_bytes > 4:
      +        if self.dtypes.is_dec:
      +            raise AttributeError("64 bit DEC floating point is not supported.")
      +        # 64-bit floating point is not a standard
      +        return self._data._as(self.dtypes.float64)
      +    elif self.total_bytes == 4:
      +        if self.dtypes.is_dec:
      +            return DEC_to_IEEE(self._data._as(np.uint32))
      +        else:  # is_mips or is_ieee
      +            return self._data._as(self.dtypes.float32)
      +    else:
      +        raise AttributeError("Only 32 and 64 bit floating point is supported.")
      +
      var int16_array

      Get the param as an array of 16-bit signed integers.

      +
      + +Expand source code + +
      @property
      +def int16_array(self):
      +    '''Get the param as an array of 16-bit signed integers.'''
      +    return self._data._as_array(self.dtypes.int16)
      +
      var int16_value

      Get the param as a 16-bit signed integer.

      +
      + +Expand source code + +
      @property
      +def int16_value(self):
      +    '''Get the param as a 16-bit signed integer.'''
      +    return self._data._as(self.dtypes.int16)
      +
      var int32_array

      Get the param as an array of 32-bit signed integers.

      +
      + +Expand source code + +
      @property
      +def int32_array(self):
      +    '''Get the param as an array of 32-bit signed integers.'''
      +    return self._data._as_array(self.dtypes.int32)
      +
      var int32_value

      Get the param as a 32-bit signed integer.

      +
      + +Expand source code + +
      @property
      +def int32_value(self):
      +    '''Get the param as a 32-bit signed integer.'''
      +    return self._data._as(self.dtypes.int32)
      +
      var int64_array

      Get the param as an array of 32-bit signed integers.

      +
      + +Expand source code + +
      @property
      +def int64_array(self):
      +    '''Get the param as an array of 32-bit signed integers.'''
      +    return self._data._as_array(self.dtypes.int64)
      +
      var int8_array

      Get the param as an array of 8-bit signed integers.

      +
      + +Expand source code + +
      @property
      +def int8_array(self):
      +    '''Get the param as an array of 8-bit signed integers.'''
      +    return self._data._as_array(self.dtypes.int8)
      +
      var int8_value

      Get the param as an 8-bit signed integer.

      +
      + +Expand source code + +
      @property
      +def int8_value(self):
      +    '''Get the param as an 8-bit signed integer.'''
      +    return self._data._as(self.dtypes.int8)
      +
      var int_array

      Get the param as an array of integer values.

      +
      + +Expand source code + +
      @property
      +def int_array(self):
      +    '''Get the param as an array of integer values.'''
      +    # Convert float data if not IEEE processor
      +    if self.bytes_per_element == 1:
      +        return self.int8_array
      +    elif self.bytes_per_element == 2:
      +        return self.int16_array
      +    elif self.bytes_per_element == 4:
      +        return self.int32_array
      +    elif self.bytes_per_element == 8:
      +        return self.int64_array
      +    else:
      +        raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." %
      +                        self.bytes_per_element)
      +
      var int_value

      Get the param as a signed integer of appropriate type.

      +
      + +Expand source code + +
      @property
      +def int_value(self):
      +    ''' Get the param as a signed integer of appropriate type. '''
      +    if self.total_bytes >= 4:
      +        return self.int32_value
      +    elif self.total_bytes >= 2:
      +        return self.int16_value
      +    else:
      +        return self.int8_value
      +
      var name : str

      Get the parameter name.

      +
      + +Expand source code + +
      @property
      +def name(self) -> str:
      +    ''' Get the parameter name. '''
      +    return self._data.name
      +
      var num_elements : int

      Return the number of elements in this parameter's array value.

      +
      + +Expand source code + +
      @property
      +def num_elements(self) -> int:
      +    '''Return the number of elements in this parameter's array value.'''
      +    return self._data.num_elements
      +
      var string_array

      Get the param as a python array of unicode strings.

      +
      + +Expand source code + +
      @property
      +def string_array(self):
      +    '''Get the param as a python array of unicode strings.'''
      +    # Decode different dimensions
      +    if len(self.dimensions) == 0:
      +        return np.array([])
      +    elif len(self.dimensions) == 1:
      +        return np.array([self.string_value])
      +    else:
      +        # Parse byte sequences
      +        byte_arr = self.bytes_array
      +        # Decode sequences
      +        for i in np.ndindex(byte_arr.shape):
      +            byte_arr[i] = self.dtypes.decode_string(byte_arr[i])
      +        return byte_arr
      +
      var string_value

      Get the param as a unicode string.

      +
      + +Expand source code + +
      @property
      +def string_value(self):
      +    '''Get the param as a unicode string.'''
      +    return self.dtypes.decode_string(self._data.bytes)
      +
      var total_bytes : int

      Return the number of bytes used for storing this parameter's data.

      +
      + +Expand source code + +
      @property
      +def total_bytes(self) -> int:
      +    '''Return the number of bytes used for storing this parameter's data.'''
      +    return self._data.total_bytes
      +
      var uint16_array

      Get the param as an array of 16-bit unsigned integers.

      +
      + +Expand source code + +
      @property
      +def uint16_array(self):
      +    '''Get the param as an array of 16-bit unsigned integers.'''
      +    return self._data._as_array(self.dtypes.uint16)
      +
      var uint16_value

      Get the param as a 16-bit unsigned integer.

      +
      + +Expand source code + +
      @property
      +def uint16_value(self):
      +    '''Get the param as a 16-bit unsigned integer.'''
      +    return self._data._as(self.dtypes.uint16)
      +
      var uint32_array

      Get the param as an array of 32-bit unsigned integers.

      +
      + +Expand source code + +
      @property
      +def uint32_array(self):
      +    '''Get the param as an array of 32-bit unsigned integers.'''
      +    return self._data._as_array(self.dtypes.uint32)
      +
      var uint32_value

      Get the param as a 32-bit unsigned integer.

      +
      + +Expand source code + +
      @property
      +def uint32_value(self):
      +    '''Get the param as a 32-bit unsigned integer.'''
      +    return self._data._as(self.dtypes.uint32)
      +
      var uint64_array

      Get the param as an array of 32-bit unsigned integers.

      +
      + +Expand source code + +
      @property
      +def uint64_array(self):
      +    '''Get the param as an array of 32-bit unsigned integers.'''
      +    return self._data._as_array(self.dtypes.uint64)
      +
      var uint8_array

      Get the param as an array of 8-bit unsigned integers.

      +
      + +Expand source code + +
      @property
      +def uint8_array(self):
      +    '''Get the param as an array of 8-bit unsigned integers.'''
      +    return self._data._as_array(self.dtypes.uint8)
      +
      var uint8_value

      Get the param as an 8-bit unsigned integer.

      +
      + +Expand source code + +
      @property
      +def uint8_value(self):
      +    '''Get the param as an 8-bit unsigned integer.'''
      +    return self._data._as(self.dtypes.uint8)
      +
      var uint_array

      Get the param as an array of integer values.

      +
      + +Expand source code + +
      @property
      +def uint_array(self):
      +    '''Get the param as an array of integer values.'''
      +    # Convert float data if not IEEE processor
      +    if self.bytes_per_element == 1:
      +        return self.uint8_array
      +    elif self.bytes_per_element == 2:
      +        return self.uint16_array
      +    elif self.bytes_per_element == 4:
      +        return self.uint32_array
      +    elif self.bytes_per_element == 8:
      +        return self.uint64_array
      +    else:
      +        raise TypeError("Parsing parameter bytes to an array with %i bit integer values is not unsupported." %
      +                        self.bytes_per_element)
      +
      var uint_value

      Get the param as a unsigned integer of appropriate type.

      +
      + +Expand source code + +
      @property
      +def uint_value(self):
      +    ''' Get the param as a unsigned integer of appropriate type. '''
      +    if self.total_bytes >= 4:
      +        return self.uint32_value
      +    elif self.total_bytes >= 2:
      +        return self.uint16_value
      +    else:
      +        return self.uint8_value
      +
    diff --git a/docs/c3d/reader.html b/docs/c3d/reader.html index a9eac9d..0b868b0 100644 --- a/docs/c3d/reader.html +++ b/docs/c3d/reader.html @@ -5,7 +5,7 @@ c3d.reader API documentation - + @@ -22,7 +22,349 @@

    Module c3d.reader

    -

    A Python module for reading and writing C3D files.

    +

    Contains the Reader class for reading C3D files.

    +
    + +Expand source code + +
    '''Contains the Reader class for reading C3D files.'''
    +
    +import io
    +import numpy as np
    +import struct
    +import warnings
    +from .manager import Manager
    +from .header import Header
    +from .dtypes import DataTypes
    +from .utils import DEC_to_IEEE_BYTES
    +
    +
    +class Reader(Manager):
    +    '''This class provides methods for reading the data in a C3D file.
    +
    +    A C3D file contains metadata and frame-based data describing 3D motion.
    +
    +    You can iterate over the frames in the file by calling `read_frames()` after
    +    construction:
    +
    +    >>> r = c3d.Reader(open('capture.c3d', 'rb'))
    +    >>> for frame_no, points, analog in r.read_frames():
    +    ...     print('{0.shape} points in this frame'.format(points))
    +    '''
    +
    +    def __init__(self, handle):
    +        '''Initialize this C3D file by reading header and parameter data.
    +
    +        Parameters
    +        ----------
    +        handle : file handle
    +            Read metadata and C3D motion frames from the given file handle. This
    +            handle is assumed to be `seek`-able and `read`-able. The handle must
    +            remain open for the life of the `Reader` instance. The `Reader` does
    +            not `close` the handle.
    +
    +        Raises
    +        ------
    +        AssertionError
    +            If the metadata in the C3D file is inconsistent.
    +        '''
    +        super(Reader, self).__init__(Header(handle))
    +
    +        self._handle = handle
    +
    +        def seek_param_section_header():
    +            ''' Seek to and read the first 4 byte of the parameter header section '''
    +            self._handle.seek((self._header.parameter_block - 1) * 512)
    +            # metadata header
    +            return self._handle.read(4)
    +
    +        # Begin by reading the processor type:
    +        buf = seek_param_section_header()
    +        _, _, parameter_blocks, processor = struct.unpack('BBBB', buf)
    +        self._dtypes = DataTypes(processor)
    +        # Convert header parameters in accordance with the processor type (MIPS format re-reads the header)
    +        self._header._processor_convert(self._dtypes, handle)
    +
    +        # Restart reading the parameter header after parsing processor type
    +        buf = seek_param_section_header()
    +
    +        start_byte = self._handle.tell()
    +        endbyte = start_byte + 512 * parameter_blocks - 4
    +        while self._handle.tell() < endbyte:
    +            chars_in_name, group_id = struct.unpack('bb', self._handle.read(2))
    +            if group_id == 0 or chars_in_name == 0:
    +                # we've reached the end of the parameter section.
    +                break
    +            name = self._dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper()
    +
    +            # Read the byte segment associated with the parameter and create a
    +            # separate binary stream object from the data.
    +            offset_to_next, = struct.unpack(['<h', '>h'][self._dtypes.is_mips], self._handle.read(2))
    +            if offset_to_next == 0:
    +                # Last parameter, as number of bytes are unknown,
    +                # read the remaining bytes in the parameter section.
    +                bytes = self._handle.read(endbyte - self._handle.tell())
    +            else:
    +                bytes = self._handle.read(offset_to_next - 2)
    +            buf = io.BytesIO(bytes)
    +
    +            if group_id > 0:
    +                # We've just started reading a parameter. If its group doesn't
    +                # exist, create a blank one. add the parameter to the group.
    +                group = super(Reader, self).get(group_id)
    +                if group is None:
    +                    group = self._add_group(group_id)
    +                group.add_param(name, handle=buf)
    +            else:
    +                # We've just started reading a group. If a group with the
    +                # appropriate numerical id exists already (because we've
    +                # already created it for a parameter), just set the name of
    +                # the group. Otherwise, add a new group.
    +                group_id = abs(group_id)
    +                size, = struct.unpack('B', buf.read(1))
    +                desc = size and buf.read(size) or ''
    +                group = super(Reader, self).get(group_id)
    +                if group is not None:
    +                    self._rename_group(group, name)  # Inserts name key
    +                    group.desc = desc
    +                else:
    +                    self._add_group(group_id, name, desc)
    +
    +        self._check_metadata()
    +
    +    def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False):
    +        '''Iterate over the data frames from our C3D file handle.
    +
    +        Parameters
    +        ----------
    +        copy : bool
    +            If False, the reader returns a reference to the same data buffers
    +            for every frame. The default is True, which causes the reader to
    +            return a unique data buffer for each frame. Set this to False if you
    +            consume frames as you iterate over them, or True if you store them
    +            for later.
    +        analog_transform : bool, default=True
    +            If True, ANALOG:SCALE, ANALOG:GEN_SCALE, and ANALOG:OFFSET transforms
    +            available in the file are applied to the analog channels.
    +        check_nan : bool, default=True
    +            If True, point x,y,z coordinates with nan values will be marked invalidated
    +            and residuals will be set to -1.
    +        camera_sum : bool, default=False
    +            Camera flag bits will be summed, converting the fifth column to a camera visibility counter.
    +
    +        Returns
    +        -------
    +        frames : sequence of (frame number, points, analog)
    +            This method generates a sequence of (frame number, points, analog)
    +            tuples, one tuple per frame. The first element of each tuple is the
    +            frame number. The second is a numpy array of parsed, 5D point data
    +            and the third element of each tuple is a numpy array of analog
    +            values that were recorded during the frame. (Often the analog data
    +            are sampled at a higher frequency than the 3D point data, resulting
    +            in multiple analog frames per frame of point data.)
    +
    +            The first three columns in the returned point data are the (x, y, z)
    +            coordinates of the observed motion capture point. The fourth column
    +            is an estimate of the error for this particular point, and the fifth
    +            column is the number of cameras that observed the point in question.
    +            Both the fourth and fifth values are -1 if the point is considered
    +            to be invalid.
    +        '''
    +        # Point magnitude scalar, if scale parameter is < 0 data is floating point
    +        # (in which case the magnitude is the absolute value)
    +        scale_mag = abs(self.point_scale)
    +        is_float = self.point_scale < 0
    +
    +        if is_float:
    +            point_word_bytes = 4
    +        else:
    +            point_word_bytes = 2
    +        points = np.zeros((self.point_used, 5), np.float32)
    +
    +        # TODO: handle ANALOG:BITS parameter here!
    +        p = self.get('ANALOG:FORMAT')
    +        analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED'
    +        if is_float:
    +            analog_dtype = self._dtypes.float32
    +            analog_word_bytes = 4
    +        elif analog_unsigned:
    +            # Note*: Floating point is 'always' defined for both analog and point data, according to the standard.
    +            analog_dtype = self._dtypes.uint16
    +            analog_word_bytes = 2
    +            # Verify BITS parameter for analog
    +            p = self.get('ANALOG:BITS')
    +            if p and p._as_integer_value / 8 != analog_word_bytes:
    +                raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value))
    +        else:
    +            analog_dtype = self._dtypes.int16
    +            analog_word_bytes = 2
    +
    +        analog = np.array([], float)
    +        analog_scales, analog_offsets = self.get_analog_transform()
    +
    +        # Seek to the start point of the data blocks
    +        self._handle.seek((self._header.data_block - 1) * 512)
    +        # Number of values (words) read in regard to POINT/ANALOG data
    +        N_point = 4 * self.point_used
    +        N_analog = self.analog_used * self.analog_per_frame
    +
    +        # Total bytes per frame
    +        point_bytes = N_point * point_word_bytes
    +        analog_bytes = N_analog * analog_word_bytes
    +        # Parse the data blocks
    +        for frame_no in range(self.first_frame, self.last_frame + 1):
    +            # Read the byte data (used) for the block
    +            raw_bytes = self._handle.read(N_point * point_word_bytes)
    +            raw_analog = self._handle.read(N_analog * analog_word_bytes)
    +            # Verify read pointers (any of the two can be assumed to be 0)
    +            if len(raw_bytes) < point_bytes:
    +                warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {}
    +                                 and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell()))
    +                return
    +            if len(raw_analog) < analog_bytes:
    +                warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {}
    +                                 and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell()))
    +                return
    +
    +            if is_float:
    +                # Convert every 4 byte words to a float-32 reprensentation
    +                # (the fourth column is still not a float32 representation)
    +                if self._dtypes.is_dec:
    +                    # Convert each of the first 6 16-bit words from DEC to IEEE float
    +                    points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4))
    +                else:  # If IEEE or MIPS:
    +                    # Convert each of the first 6 16-bit words to native float
    +                    points[:, :4] = np.frombuffer(raw_bytes,
    +                                                  dtype=self._dtypes.float32,
    +                                                  count=N_point).reshape((self.point_used, 4))
    +
    +                # Cast last word to signed integer in system endian format
    +                last_word = points[:, 3].astype(np.int32)
    +
    +            else:
    +                # View the bytes as signed 16-bit integers
    +                raw = np.frombuffer(raw_bytes,
    +                                    dtype=self._dtypes.int16,
    +                                    count=N_point).reshape((self.point_used, 4))
    +                # Read the first six 16-bit words as x, y, z coordinates
    +                points[:, :3] = raw[:, :3] * scale_mag
    +                # Cast last word to signed integer in system endian format
    +                last_word = raw[:, 3].astype(np.int16)
    +
    +            # Parse camera-observed bits and residuals.
    +            # Notes:
    +            # - Invalid sample if residual is equal to -1 (check if word < 0).
    +            # - A residual of 0.0 represent modeled data (filtered or interpolated).
    +            # - Camera and residual words are always 8-bit (1 byte), never 16-bit.
    +            # - If floating point, the byte words are encoded in an integer cast to a float,
    +            #    and are written directly in byte form (see the MLS guide).
    +            ##
    +            # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded).
    +            residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8
    +
    +            # Fourth value is floating-point (scaled) error estimate (residual)
    +            points[:, 3] = residual_byte * scale_mag
    +
    +            # Determine invalid samples
    +            invalid = last_word < 0
    +            if check_nan:
    +                is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1)
    +                points[is_nan, :3] = 0.0
    +                invalid &= is_nan
    +            # Update discarded - sign
    +            points[invalid, 3] = -1
    +
    +
    +            # Fifth value is the camera-observation byte
    +            if camera_sum:
    +                # Convert to observation sum
    +                points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7))
    +            else:
    +                points[:, 4] = camera_byte #.astype(np.float32)
    +
    +            # Check if analog data exist, and parse if so
    +            if N_analog > 0:
    +                if is_float and self._dtypes.is_dec:
    +                    # Convert each of the 16-bit words from DEC to IEEE float
    +                    analog = DEC_to_IEEE_BYTES(raw_analog)
    +                else:
    +                    # Integer or INTEL/MIPS floating point data can be parsed directly
    +                    analog = np.frombuffer(raw_analog, dtype=analog_dtype, count=N_analog)
    +
    +                # Reformat and convert
    +                analog = analog.reshape((-1, self.analog_used)).T
    +                analog = analog.astype(float)
    +                # Convert analog
    +                analog = (analog - analog_offsets) * analog_scales
    +
    +            # Output buffers
    +            if copy:
    +                yield frame_no, points.copy(), analog  # .copy(), a new array is generated per frame for analog data.
    +            else:
    +                yield frame_no, points, analog
    +
    +        # Function evaluating EOF, note that data section is written in blocks of 512
    +        final_byte_index = self._handle.tell()
    +        self._handle.seek(0, 2)  # os.SEEK_END)
    +        # Check if more then 1 block remain
    +        if self._handle.tell() - final_byte_index >= 512:
    +            warnings.warn('incomplete reading of data blocks. {} bytes remained after all datablocks were read!'.format(
    +                self._handle.tell() - final_byte_index))
    +
    +    @property
    +    def proc_type(self) -> int:
    +        '''Get the processory type associated with the data format in the file.
    +        '''
    +        return self._dtypes.proc_type
    +
    +    def to_writer(self, conversion=None):
    +        ''' Converts the reader to a `c3d.writer.Writer` instance using the conversion mode.
    +
    +        See `c3d.writer.Writer.from_reader()` for supported conversion modes.
    +        '''
    +        from .writer import Writer
    +        return Writer.from_reader(self, conversion=conversion)
    +
    +    def get(self, key, default=None):
    +        '''Get a readonly group or parameter.
    +
    +        Parameters
    +        ----------
    +        key : str
    +            If this string contains a period (.), then the part before the
    +            period will be used to retrieve a group, and the part after the
    +            period will be used to retrieve a parameter from that group. If this
    +            string does not contain a period, then just a group will be
    +            returned.
    +        default : any
    +            Return this value if the named group and parameter are not found.
    +
    +        Returns
    +        -------
    +        value : `c3d.group.GroupReadonly` or `c3d.parameter.ParamReadonly`
    +            Either a group or parameter with the specified name(s). If neither
    +            is found, returns the default value.
    +        '''
    +        val = super(Reader, self).get(key)
    +        if val:
    +            return val.readonly()
    +        return default
    +
    +    def items(self):
    +        ''' Get iterable over pairs of (str, `c3d.group.GroupReadonly`) entries.
    +        '''
    +        return ((k, v.readonly()) for k, v in super(Reader, self).items())
    +
    +    def values(self):
    +        ''' Get iterable over `c3d.group.GroupReadonly` entries.
    +        '''
    +        return (v.readonly() for k, v in super(Reader, self).items())
    +
    +    def listed(self):
    +        ''' Get iterable over pairs of (int, `c3d.group.GroupReadonly`) entries.
    +        '''
    +        return ((k, v.readonly()) for k, v in super(Reader, self).listed())
    +
    @@ -60,6 +402,336 @@

    Raises

    AssertionError
    If the metadata in the C3D file is inconsistent.
    +
    + +Expand source code + +
    class Reader(Manager):
    +    '''This class provides methods for reading the data in a C3D file.
    +
    +    A C3D file contains metadata and frame-based data describing 3D motion.
    +
    +    You can iterate over the frames in the file by calling `read_frames()` after
    +    construction:
    +
    +    >>> r = c3d.Reader(open('capture.c3d', 'rb'))
    +    >>> for frame_no, points, analog in r.read_frames():
    +    ...     print('{0.shape} points in this frame'.format(points))
    +    '''
    +
    +    def __init__(self, handle):
    +        '''Initialize this C3D file by reading header and parameter data.
    +
    +        Parameters
    +        ----------
    +        handle : file handle
    +            Read metadata and C3D motion frames from the given file handle. This
    +            handle is assumed to be `seek`-able and `read`-able. The handle must
    +            remain open for the life of the `Reader` instance. The `Reader` does
    +            not `close` the handle.
    +
    +        Raises
    +        ------
    +        AssertionError
    +            If the metadata in the C3D file is inconsistent.
    +        '''
    +        super(Reader, self).__init__(Header(handle))
    +
    +        self._handle = handle
    +
    +        def seek_param_section_header():
    +            ''' Seek to and read the first 4 byte of the parameter header section '''
    +            self._handle.seek((self._header.parameter_block - 1) * 512)
    +            # metadata header
    +            return self._handle.read(4)
    +
    +        # Begin by reading the processor type:
    +        buf = seek_param_section_header()
    +        _, _, parameter_blocks, processor = struct.unpack('BBBB', buf)
    +        self._dtypes = DataTypes(processor)
    +        # Convert header parameters in accordance with the processor type (MIPS format re-reads the header)
    +        self._header._processor_convert(self._dtypes, handle)
    +
    +        # Restart reading the parameter header after parsing processor type
    +        buf = seek_param_section_header()
    +
    +        start_byte = self._handle.tell()
    +        endbyte = start_byte + 512 * parameter_blocks - 4
    +        while self._handle.tell() < endbyte:
    +            chars_in_name, group_id = struct.unpack('bb', self._handle.read(2))
    +            if group_id == 0 or chars_in_name == 0:
    +                # we've reached the end of the parameter section.
    +                break
    +            name = self._dtypes.decode_string(self._handle.read(abs(chars_in_name))).upper()
    +
    +            # Read the byte segment associated with the parameter and create a
    +            # separate binary stream object from the data.
    +            offset_to_next, = struct.unpack(['<h', '>h'][self._dtypes.is_mips], self._handle.read(2))
    +            if offset_to_next == 0:
    +                # Last parameter, as number of bytes are unknown,
    +                # read the remaining bytes in the parameter section.
    +                bytes = self._handle.read(endbyte - self._handle.tell())
    +            else:
    +                bytes = self._handle.read(offset_to_next - 2)
    +            buf = io.BytesIO(bytes)
    +
    +            if group_id > 0:
    +                # We've just started reading a parameter. If its group doesn't
    +                # exist, create a blank one. add the parameter to the group.
    +                group = super(Reader, self).get(group_id)
    +                if group is None:
    +                    group = self._add_group(group_id)
    +                group.add_param(name, handle=buf)
    +            else:
    +                # We've just started reading a group. If a group with the
    +                # appropriate numerical id exists already (because we've
    +                # already created it for a parameter), just set the name of
    +                # the group. Otherwise, add a new group.
    +                group_id = abs(group_id)
    +                size, = struct.unpack('B', buf.read(1))
    +                desc = size and buf.read(size) or ''
    +                group = super(Reader, self).get(group_id)
    +                if group is not None:
    +                    self._rename_group(group, name)  # Inserts name key
    +                    group.desc = desc
    +                else:
    +                    self._add_group(group_id, name, desc)
    +
    +        self._check_metadata()
    +
    +    def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False):
    +        '''Iterate over the data frames from our C3D file handle.
    +
    +        Parameters
    +        ----------
    +        copy : bool
    +            If False, the reader returns a reference to the same data buffers
    +            for every frame. The default is True, which causes the reader to
    +            return a unique data buffer for each frame. Set this to False if you
    +            consume frames as you iterate over them, or True if you store them
    +            for later.
    +        analog_transform : bool, default=True
    +            If True, ANALOG:SCALE, ANALOG:GEN_SCALE, and ANALOG:OFFSET transforms
    +            available in the file are applied to the analog channels.
    +        check_nan : bool, default=True
    +            If True, point x,y,z coordinates with nan values will be marked invalidated
    +            and residuals will be set to -1.
    +        camera_sum : bool, default=False
    +            Camera flag bits will be summed, converting the fifth column to a camera visibility counter.
    +
    +        Returns
    +        -------
    +        frames : sequence of (frame number, points, analog)
    +            This method generates a sequence of (frame number, points, analog)
    +            tuples, one tuple per frame. The first element of each tuple is the
    +            frame number. The second is a numpy array of parsed, 5D point data
    +            and the third element of each tuple is a numpy array of analog
    +            values that were recorded during the frame. (Often the analog data
    +            are sampled at a higher frequency than the 3D point data, resulting
    +            in multiple analog frames per frame of point data.)
    +
    +            The first three columns in the returned point data are the (x, y, z)
    +            coordinates of the observed motion capture point. The fourth column
    +            is an estimate of the error for this particular point, and the fifth
    +            column is the number of cameras that observed the point in question.
    +            Both the fourth and fifth values are -1 if the point is considered
    +            to be invalid.
    +        '''
    +        # Point magnitude scalar, if scale parameter is < 0 data is floating point
    +        # (in which case the magnitude is the absolute value)
    +        scale_mag = abs(self.point_scale)
    +        is_float = self.point_scale < 0
    +
    +        if is_float:
    +            point_word_bytes = 4
    +        else:
    +            point_word_bytes = 2
    +        points = np.zeros((self.point_used, 5), np.float32)
    +
    +        # TODO: handle ANALOG:BITS parameter here!
    +        p = self.get('ANALOG:FORMAT')
    +        analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED'
    +        if is_float:
    +            analog_dtype = self._dtypes.float32
    +            analog_word_bytes = 4
    +        elif analog_unsigned:
    +            # Note*: Floating point is 'always' defined for both analog and point data, according to the standard.
    +            analog_dtype = self._dtypes.uint16
    +            analog_word_bytes = 2
    +            # Verify BITS parameter for analog
    +            p = self.get('ANALOG:BITS')
    +            if p and p._as_integer_value / 8 != analog_word_bytes:
    +                raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value))
    +        else:
    +            analog_dtype = self._dtypes.int16
    +            analog_word_bytes = 2
    +
    +        analog = np.array([], float)
    +        analog_scales, analog_offsets = self.get_analog_transform()
    +
    +        # Seek to the start point of the data blocks
    +        self._handle.seek((self._header.data_block - 1) * 512)
    +        # Number of values (words) read in regard to POINT/ANALOG data
    +        N_point = 4 * self.point_used
    +        N_analog = self.analog_used * self.analog_per_frame
    +
    +        # Total bytes per frame
    +        point_bytes = N_point * point_word_bytes
    +        analog_bytes = N_analog * analog_word_bytes
    +        # Parse the data blocks
    +        for frame_no in range(self.first_frame, self.last_frame + 1):
    +            # Read the byte data (used) for the block
    +            raw_bytes = self._handle.read(N_point * point_word_bytes)
    +            raw_analog = self._handle.read(N_analog * analog_word_bytes)
    +            # Verify read pointers (any of the two can be assumed to be 0)
    +            if len(raw_bytes) < point_bytes:
    +                warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {}
    +                                 and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell()))
    +                return
    +            if len(raw_analog) < analog_bytes:
    +                warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {}
    +                                 and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell()))
    +                return
    +
    +            if is_float:
    +                # Convert every 4 byte words to a float-32 reprensentation
    +                # (the fourth column is still not a float32 representation)
    +                if self._dtypes.is_dec:
    +                    # Convert each of the first 6 16-bit words from DEC to IEEE float
    +                    points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4))
    +                else:  # If IEEE or MIPS:
    +                    # Convert each of the first 6 16-bit words to native float
    +                    points[:, :4] = np.frombuffer(raw_bytes,
    +                                                  dtype=self._dtypes.float32,
    +                                                  count=N_point).reshape((self.point_used, 4))
    +
    +                # Cast last word to signed integer in system endian format
    +                last_word = points[:, 3].astype(np.int32)
    +
    +            else:
    +                # View the bytes as signed 16-bit integers
    +                raw = np.frombuffer(raw_bytes,
    +                                    dtype=self._dtypes.int16,
    +                                    count=N_point).reshape((self.point_used, 4))
    +                # Read the first six 16-bit words as x, y, z coordinates
    +                points[:, :3] = raw[:, :3] * scale_mag
    +                # Cast last word to signed integer in system endian format
    +                last_word = raw[:, 3].astype(np.int16)
    +
    +            # Parse camera-observed bits and residuals.
    +            # Notes:
    +            # - Invalid sample if residual is equal to -1 (check if word < 0).
    +            # - A residual of 0.0 represent modeled data (filtered or interpolated).
    +            # - Camera and residual words are always 8-bit (1 byte), never 16-bit.
    +            # - If floating point, the byte words are encoded in an integer cast to a float,
    +            #    and are written directly in byte form (see the MLS guide).
    +            ##
    +            # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded).
    +            residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8
    +
    +            # Fourth value is floating-point (scaled) error estimate (residual)
    +            points[:, 3] = residual_byte * scale_mag
    +
    +            # Determine invalid samples
    +            invalid = last_word < 0
    +            if check_nan:
    +                is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1)
    +                points[is_nan, :3] = 0.0
    +                invalid &= is_nan
    +            # Update discarded - sign
    +            points[invalid, 3] = -1
    +
    +
    +            # Fifth value is the camera-observation byte
    +            if camera_sum:
    +                # Convert to observation sum
    +                points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7))
    +            else:
    +                points[:, 4] = camera_byte #.astype(np.float32)
    +
    +            # Check if analog data exist, and parse if so
    +            if N_analog > 0:
    +                if is_float and self._dtypes.is_dec:
    +                    # Convert each of the 16-bit words from DEC to IEEE float
    +                    analog = DEC_to_IEEE_BYTES(raw_analog)
    +                else:
    +                    # Integer or INTEL/MIPS floating point data can be parsed directly
    +                    analog = np.frombuffer(raw_analog, dtype=analog_dtype, count=N_analog)
    +
    +                # Reformat and convert
    +                analog = analog.reshape((-1, self.analog_used)).T
    +                analog = analog.astype(float)
    +                # Convert analog
    +                analog = (analog - analog_offsets) * analog_scales
    +
    +            # Output buffers
    +            if copy:
    +                yield frame_no, points.copy(), analog  # .copy(), a new array is generated per frame for analog data.
    +            else:
    +                yield frame_no, points, analog
    +
    +        # Function evaluating EOF, note that data section is written in blocks of 512
    +        final_byte_index = self._handle.tell()
    +        self._handle.seek(0, 2)  # os.SEEK_END)
    +        # Check if more then 1 block remain
    +        if self._handle.tell() - final_byte_index >= 512:
    +            warnings.warn('incomplete reading of data blocks. {} bytes remained after all datablocks were read!'.format(
    +                self._handle.tell() - final_byte_index))
    +
    +    @property
    +    def proc_type(self) -> int:
    +        '''Get the processory type associated with the data format in the file.
    +        '''
    +        return self._dtypes.proc_type
    +
    +    def to_writer(self, conversion=None):
    +        ''' Converts the reader to a `c3d.writer.Writer` instance using the conversion mode.
    +
    +        See `c3d.writer.Writer.from_reader()` for supported conversion modes.
    +        '''
    +        from .writer import Writer
    +        return Writer.from_reader(self, conversion=conversion)
    +
    +    def get(self, key, default=None):
    +        '''Get a readonly group or parameter.
    +
    +        Parameters
    +        ----------
    +        key : str
    +            If this string contains a period (.), then the part before the
    +            period will be used to retrieve a group, and the part after the
    +            period will be used to retrieve a parameter from that group. If this
    +            string does not contain a period, then just a group will be
    +            returned.
    +        default : any
    +            Return this value if the named group and parameter are not found.
    +
    +        Returns
    +        -------
    +        value : `c3d.group.GroupReadonly` or `c3d.parameter.ParamReadonly`
    +            Either a group or parameter with the specified name(s). If neither
    +            is found, returns the default value.
    +        '''
    +        val = super(Reader, self).get(key)
    +        if val:
    +            return val.readonly()
    +        return default
    +
    +    def items(self):
    +        ''' Get iterable over pairs of (str, `c3d.group.GroupReadonly`) entries.
    +        '''
    +        return ((k, v.readonly()) for k, v in super(Reader, self).items())
    +
    +    def values(self):
    +        ''' Get iterable over `c3d.group.GroupReadonly` entries.
    +        '''
    +        return (v.readonly() for k, v in super(Reader, self).items())
    +
    +    def listed(self):
    +        ''' Get iterable over pairs of (int, `c3d.group.GroupReadonly`) entries.
    +        '''
    +        return ((k, v.readonly()) for k, v in super(Reader, self).listed())
    +

    Ancestors

    • Manager
    • @@ -69,6 +741,16 @@

      Instance variables

      var proc_type : int

      Get the processory type associated with the data format in the file.

      +
      + +Expand source code + +
      @property
      +def proc_type(self) -> int:
      +    '''Get the processory type associated with the data format in the file.
      +    '''
      +    return self._dtypes.proc_type
      +

      Methods

      @@ -95,18 +777,65 @@

      Returns

      Either a group or parameter with the specified name(s). If neither is found, returns the default value.
      +
      + +Expand source code + +
      def get(self, key, default=None):
      +    '''Get a readonly group or parameter.
      +
      +    Parameters
      +    ----------
      +    key : str
      +        If this string contains a period (.), then the part before the
      +        period will be used to retrieve a group, and the part after the
      +        period will be used to retrieve a parameter from that group. If this
      +        string does not contain a period, then just a group will be
      +        returned.
      +    default : any
      +        Return this value if the named group and parameter are not found.
      +
      +    Returns
      +    -------
      +    value : `c3d.group.GroupReadonly` or `c3d.parameter.ParamReadonly`
      +        Either a group or parameter with the specified name(s). If neither
      +        is found, returns the default value.
      +    '''
      +    val = super(Reader, self).get(key)
      +    if val:
      +        return val.readonly()
      +    return default
      +
      def items(self)

      Get iterable over pairs of (str, GroupReadonly) entries.

      +
      + +Expand source code + +
      def items(self):
      +    ''' Get iterable over pairs of (str, `c3d.group.GroupReadonly`) entries.
      +    '''
      +    return ((k, v.readonly()) for k, v in super(Reader, self).items())
      +
      def listed(self)

      Get iterable over pairs of (int, GroupReadonly) entries.

      +
      + +Expand source code + +
      def listed(self):
      +    ''' Get iterable over pairs of (int, `c3d.group.GroupReadonly`) entries.
      +    '''
      +    return ((k, v.readonly()) for k, v in super(Reader, self).listed())
      +
      def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False) @@ -149,6 +878,189 @@

      Returns

      to be invalid.

      +
      + +Expand source code + +
      def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_sum=False):
      +    '''Iterate over the data frames from our C3D file handle.
      +
      +    Parameters
      +    ----------
      +    copy : bool
      +        If False, the reader returns a reference to the same data buffers
      +        for every frame. The default is True, which causes the reader to
      +        return a unique data buffer for each frame. Set this to False if you
      +        consume frames as you iterate over them, or True if you store them
      +        for later.
      +    analog_transform : bool, default=True
      +        If True, ANALOG:SCALE, ANALOG:GEN_SCALE, and ANALOG:OFFSET transforms
      +        available in the file are applied to the analog channels.
      +    check_nan : bool, default=True
      +        If True, point x,y,z coordinates with nan values will be marked invalidated
      +        and residuals will be set to -1.
      +    camera_sum : bool, default=False
      +        Camera flag bits will be summed, converting the fifth column to a camera visibility counter.
      +
      +    Returns
      +    -------
      +    frames : sequence of (frame number, points, analog)
      +        This method generates a sequence of (frame number, points, analog)
      +        tuples, one tuple per frame. The first element of each tuple is the
      +        frame number. The second is a numpy array of parsed, 5D point data
      +        and the third element of each tuple is a numpy array of analog
      +        values that were recorded during the frame. (Often the analog data
      +        are sampled at a higher frequency than the 3D point data, resulting
      +        in multiple analog frames per frame of point data.)
      +
      +        The first three columns in the returned point data are the (x, y, z)
      +        coordinates of the observed motion capture point. The fourth column
      +        is an estimate of the error for this particular point, and the fifth
      +        column is the number of cameras that observed the point in question.
      +        Both the fourth and fifth values are -1 if the point is considered
      +        to be invalid.
      +    '''
      +    # Point magnitude scalar, if scale parameter is < 0 data is floating point
      +    # (in which case the magnitude is the absolute value)
      +    scale_mag = abs(self.point_scale)
      +    is_float = self.point_scale < 0
      +
      +    if is_float:
      +        point_word_bytes = 4
      +    else:
      +        point_word_bytes = 2
      +    points = np.zeros((self.point_used, 5), np.float32)
      +
      +    # TODO: handle ANALOG:BITS parameter here!
      +    p = self.get('ANALOG:FORMAT')
      +    analog_unsigned = p and p.string_value.strip().upper() == 'UNSIGNED'
      +    if is_float:
      +        analog_dtype = self._dtypes.float32
      +        analog_word_bytes = 4
      +    elif analog_unsigned:
      +        # Note*: Floating point is 'always' defined for both analog and point data, according to the standard.
      +        analog_dtype = self._dtypes.uint16
      +        analog_word_bytes = 2
      +        # Verify BITS parameter for analog
      +        p = self.get('ANALOG:BITS')
      +        if p and p._as_integer_value / 8 != analog_word_bytes:
      +            raise NotImplementedError('Analog data using {} bits is not supported.'.format(p._as_integer_value))
      +    else:
      +        analog_dtype = self._dtypes.int16
      +        analog_word_bytes = 2
      +
      +    analog = np.array([], float)
      +    analog_scales, analog_offsets = self.get_analog_transform()
      +
      +    # Seek to the start point of the data blocks
      +    self._handle.seek((self._header.data_block - 1) * 512)
      +    # Number of values (words) read in regard to POINT/ANALOG data
      +    N_point = 4 * self.point_used
      +    N_analog = self.analog_used * self.analog_per_frame
      +
      +    # Total bytes per frame
      +    point_bytes = N_point * point_word_bytes
      +    analog_bytes = N_analog * analog_word_bytes
      +    # Parse the data blocks
      +    for frame_no in range(self.first_frame, self.last_frame + 1):
      +        # Read the byte data (used) for the block
      +        raw_bytes = self._handle.read(N_point * point_word_bytes)
      +        raw_analog = self._handle.read(N_analog * analog_word_bytes)
      +        # Verify read pointers (any of the two can be assumed to be 0)
      +        if len(raw_bytes) < point_bytes:
      +            warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {}
      +                             and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell()))
      +            return
      +        if len(raw_analog) < analog_bytes:
      +            warnings.warn('''reached end of file (EOF) while reading POINT data at frame index {}
      +                             and file pointer {}!'''.format(frame_no - self.first_frame, self._handle.tell()))
      +            return
      +
      +        if is_float:
      +            # Convert every 4 byte words to a float-32 reprensentation
      +            # (the fourth column is still not a float32 representation)
      +            if self._dtypes.is_dec:
      +                # Convert each of the first 6 16-bit words from DEC to IEEE float
      +                points[:, :4] = DEC_to_IEEE_BYTES(raw_bytes).reshape((self.point_used, 4))
      +            else:  # If IEEE or MIPS:
      +                # Convert each of the first 6 16-bit words to native float
      +                points[:, :4] = np.frombuffer(raw_bytes,
      +                                              dtype=self._dtypes.float32,
      +                                              count=N_point).reshape((self.point_used, 4))
      +
      +            # Cast last word to signed integer in system endian format
      +            last_word = points[:, 3].astype(np.int32)
      +
      +        else:
      +            # View the bytes as signed 16-bit integers
      +            raw = np.frombuffer(raw_bytes,
      +                                dtype=self._dtypes.int16,
      +                                count=N_point).reshape((self.point_used, 4))
      +            # Read the first six 16-bit words as x, y, z coordinates
      +            points[:, :3] = raw[:, :3] * scale_mag
      +            # Cast last word to signed integer in system endian format
      +            last_word = raw[:, 3].astype(np.int16)
      +
      +        # Parse camera-observed bits and residuals.
      +        # Notes:
      +        # - Invalid sample if residual is equal to -1 (check if word < 0).
      +        # - A residual of 0.0 represent modeled data (filtered or interpolated).
      +        # - Camera and residual words are always 8-bit (1 byte), never 16-bit.
      +        # - If floating point, the byte words are encoded in an integer cast to a float,
      +        #    and are written directly in byte form (see the MLS guide).
      +        ##
      +        # Read the residual and camera byte words (Note* if 32 bit word negative sign is discarded).
      +        residual_byte, camera_byte = (last_word & 0x00ff), (last_word & 0x7f00) >> 8
      +
      +        # Fourth value is floating-point (scaled) error estimate (residual)
      +        points[:, 3] = residual_byte * scale_mag
      +
      +        # Determine invalid samples
      +        invalid = last_word < 0
      +        if check_nan:
      +            is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1)
      +            points[is_nan, :3] = 0.0
      +            invalid &= is_nan
      +        # Update discarded - sign
      +        points[invalid, 3] = -1
      +
      +
      +        # Fifth value is the camera-observation byte
      +        if camera_sum:
      +            # Convert to observation sum
      +            points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7))
      +        else:
      +            points[:, 4] = camera_byte #.astype(np.float32)
      +
      +        # Check if analog data exist, and parse if so
      +        if N_analog > 0:
      +            if is_float and self._dtypes.is_dec:
      +                # Convert each of the 16-bit words from DEC to IEEE float
      +                analog = DEC_to_IEEE_BYTES(raw_analog)
      +            else:
      +                # Integer or INTEL/MIPS floating point data can be parsed directly
      +                analog = np.frombuffer(raw_analog, dtype=analog_dtype, count=N_analog)
      +
      +            # Reformat and convert
      +            analog = analog.reshape((-1, self.analog_used)).T
      +            analog = analog.astype(float)
      +            # Convert analog
      +            analog = (analog - analog_offsets) * analog_scales
      +
      +        # Output buffers
      +        if copy:
      +            yield frame_no, points.copy(), analog  # .copy(), a new array is generated per frame for analog data.
      +        else:
      +            yield frame_no, points, analog
      +
      +    # Function evaluating EOF, note that data section is written in blocks of 512
      +    final_byte_index = self._handle.tell()
      +    self._handle.seek(0, 2)  # os.SEEK_END)
      +    # Check if more then 1 block remain
      +    if self._handle.tell() - final_byte_index >= 512:
      +        warnings.warn('incomplete reading of data blocks. {} bytes remained after all datablocks were read!'.format(
      +            self._handle.tell() - final_byte_index))
      +
      def to_writer(self, conversion=None) @@ -156,12 +1068,33 @@

      Returns

      Converts the reader to a Writer instance using the conversion mode.

      See Writer.from_reader() for supported conversion modes.

      +
      + +Expand source code + +
      def to_writer(self, conversion=None):
      +    ''' Converts the reader to a `c3d.writer.Writer` instance using the conversion mode.
      +
      +    See `c3d.writer.Writer.from_reader()` for supported conversion modes.
      +    '''
      +    from .writer import Writer
      +    return Writer.from_reader(self, conversion=conversion)
      +
      def values(self)

      Get iterable over GroupReadonly entries.

      +
      + +Expand source code + +
      def values(self):
      +    ''' Get iterable over `c3d.group.GroupReadonly` entries.
      +    '''
      +    return (v.readonly() for k, v in super(Reader, self).items())
      +

      Inherited members

      diff --git a/docs/c3d/utils.html b/docs/c3d/utils.html index 9560227..466598c 100644 --- a/docs/c3d/utils.html +++ b/docs/c3d/utils.html @@ -23,6 +23,159 @@

      Module c3d.utils

      Trailing utility functions.

      +
      + +Expand source code + +
      ''' Trailing utility functions.
      +'''
      +import numpy as np
      +import struct
      +
      +def is_integer(value):
      +    '''Check if value input is integer.'''
      +    return isinstance(value, (int, np.int32, np.int64))
      +
      +
      +def is_iterable(value):
      +    '''Check if value is iterable.'''
      +    return hasattr(value, '__iter__')
      +
      +
      +def type_npy2struct(dtype):
      +    ''' Convert numpy dtype format to a struct package format string.
      +    '''
      +    return dtype.byteorder + dtype.char
      +
      +
      +def pack_labels(labels):
      +    ''' Static method used to pack and pad the set of `labels` strings before
      +        passing the output into a `c3d.group.Group.add_str`.
      +
      +    Parameters
      +    ----------
      +    labels : iterable
      +        List of strings to pack and pad into a single string suitable for encoding in a Parameter entry.
      +
      +    Example
      +    -------
      +    >>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3']
      +    >>> param_str, label_max_size = Writer.pack_labels(labels)
      +    >>> writer.point_group.add_str('LABELS',
      +                                   'Point labels.',
      +                                   label_str,
      +                                   label_max_size,
      +                                   len(labels))
      +
      +    Returns
      +    -------
      +    param_str : str
      +        String containing `labels` packed into a single variable where
      +        each string is padded to match the longest `labels` string.
      +    label_max_size : int
      +        Number of bytes associated with the longest `label` string, all strings are padded to this length.
      +    '''
      +    labels = np.ravel(labels)
      +    # Get longest label name
      +    label_max_size = 0
      +    label_max_size = max(label_max_size, np.max([len(label) for label in labels]))
      +    label_str = ''.join(label.ljust(label_max_size) for label in labels)
      +    return label_str, label_max_size
      +
      +class Decorator(object):
      +    '''Base class for extending (decorating) a python object.
      +    '''
      +    def __init__(self, decoratee):
      +        self._decoratee = decoratee
      +    def __getattr__(self, name):
      +        return getattr(self._decoratee, name)
      +
      +def UNPACK_FLOAT_IEEE(uint_32):
      +    '''Unpacks a single 32 bit unsigned int to a IEEE float representation
      +    '''
      +    return struct.unpack('f', struct.pack("<I", uint_32))[0]
      +
      +def UNPACK_FLOAT_MIPS(uint_32):
      +    '''Unpacks a single 32 bit unsigned int to a IEEE float representation
      +    '''
      +    return struct.unpack('f', struct.pack(">I", uint_32))[0]
      +
      +def DEC_to_IEEE(uint_32):
      +    '''Convert the 32 bit representation of a DEC float to IEEE format.
      +
      +    Params:
      +    ----
      +    uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits.
      +    Returns : IEEE formated floating point of the same shape as the input.
      +    '''
      +    # Follows the bit pattern found:
      +    #   http://home.fnal.gov/~yang/Notes/ieee_vs_dec_float.txt
      +    # Further formating descriptions can be found:
      +    #   http://www.irig106.org/docs/106-07/appendixO.pdf
      +    # In accodance with the first ref. first & second 16 bit words are placed
      +    # in a big endian 16 bit word representation, and needs to be inverted.
      +    # Second reference describe the DEC->IEEE conversion.
      +
      +    # Warning! Unsure if NaN numbers are managed appropriately.
      +
      +    # Shuffle the first two bit words from DEC bit representation to an ordered representation.
      +    # Note that the most significant fraction bits are placed in the first 7 bits.
      +    #
      +    # Below are the DEC layout in accordance with the references:
      +    # ___________________________________________________________________________________
      +    # |         Mantissa (16:0)         |       SIGN    |       Exponent (8:0)  |       Mantissa (23:17)        |
      +    # ___________________________________________________________________________________
      +    # |32-                                        -16|   15        |14-                           -7|6-                                   -0|
      +    #
      +    # Legend:
      +    # _______________________________________________________
      +    # | Part (left bit of segment : right bit) | Part | ..
      +    # _______________________________________________________
      +    # |Bit adress -     ..       - Bit adress | Bit adress - ..
      +    ####
      +
      +    # Swap the first and last 16  bits for a consistent alignment of the fraction
      +    reshuffled = ((uint_32 & 0xFFFF0000) >> 16) | ((uint_32 & 0x0000FFFF) << 16)
      +    # After the shuffle each part are in little-endian and ordered as: SIGN-Exponent-Fraction
      +    exp_bits = ((reshuffled & 0xFF000000) - 1) & 0xFF000000
      +    reshuffled = (reshuffled & 0x00FFFFFF) | exp_bits
      +    return UNPACK_FLOAT_IEEE(reshuffled)
      +
      +
      +def DEC_to_IEEE_BYTES(bytes):
      +    '''Convert byte array containing 32 bit DEC floats to IEEE format.
      +
      +    Params:
      +    ----
      +    bytes : Byte array where every 4 bytes represent a single precision DEC float.
      +    Returns : IEEE formated floating point of the same shape as the input.
      +    '''
      +
      +    # See comments in DEC_to_IEEE() for DEC format definition
      +
      +    # Reshuffle
      +    bytes = memoryview(bytes)
      +    reshuffled = np.empty(len(bytes), dtype=np.dtype('B'))
      +    reshuffled[::4] = bytes[2::4]
      +    reshuffled[1::4] = bytes[3::4]
      +    reshuffled[2::4] = bytes[::4]
      +    # Decrement exponent by 2, if exp. > 1
      +    reshuffled[3::4] = bytes[1::4] + (np.bitwise_and(bytes[1::4], 0x7f) == 0) - 1
      +
      +    # There are different ways to adjust for differences in DEC/IEEE representation
      +    # after reshuffle. Two simple methods are:
      +    # 1) Decrement exponent bits by 2, then convert to IEEE.
      +    # 2) Convert to IEEE directly and divide by four.
      +    # 3) Handle edge cases, expensive in python...
      +    # However these are simple methods, and do not accurately convert when:
      +    # 1) Exponent < 2 (without bias), impossible to decrement exponent without adjusting fraction/mantissa.
      +    # 2) Exponent == 0, DEC numbers are then 0 or undefined while IEEE is not. NaN are produced when exponent == 255.
      +    # Here method 1) is used, which mean that only small numbers will be represented incorrectly.
      +
      +    return np.frombuffer(reshuffled.tobytes(),
      +                         dtype=np.float32,
      +                         count=int(len(bytes) / 4))
      +
      @@ -39,6 +192,51 @@

      Functions

      Params:

      uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits. Returns : IEEE formated floating point of the same shape as the input.

      +
      + +Expand source code + +
      def DEC_to_IEEE(uint_32):
      +    '''Convert the 32 bit representation of a DEC float to IEEE format.
      +
      +    Params:
      +    ----
      +    uint_32 : 32 bit unsigned integer containing the DEC single precision float point bits.
      +    Returns : IEEE formated floating point of the same shape as the input.
      +    '''
      +    # Follows the bit pattern found:
      +    #   http://home.fnal.gov/~yang/Notes/ieee_vs_dec_float.txt
      +    # Further formating descriptions can be found:
      +    #   http://www.irig106.org/docs/106-07/appendixO.pdf
      +    # In accodance with the first ref. first & second 16 bit words are placed
      +    # in a big endian 16 bit word representation, and needs to be inverted.
      +    # Second reference describe the DEC->IEEE conversion.
      +
      +    # Warning! Unsure if NaN numbers are managed appropriately.
      +
      +    # Shuffle the first two bit words from DEC bit representation to an ordered representation.
      +    # Note that the most significant fraction bits are placed in the first 7 bits.
      +    #
      +    # Below are the DEC layout in accordance with the references:
      +    # ___________________________________________________________________________________
      +    # |         Mantissa (16:0)         |       SIGN    |       Exponent (8:0)  |       Mantissa (23:17)        |
      +    # ___________________________________________________________________________________
      +    # |32-                                        -16|   15        |14-                           -7|6-                                   -0|
      +    #
      +    # Legend:
      +    # _______________________________________________________
      +    # | Part (left bit of segment : right bit) | Part | ..
      +    # _______________________________________________________
      +    # |Bit adress -     ..       - Bit adress | Bit adress - ..
      +    ####
      +
      +    # Swap the first and last 16  bits for a consistent alignment of the fraction
      +    reshuffled = ((uint_32 & 0xFFFF0000) >> 16) | ((uint_32 & 0x0000FFFF) << 16)
      +    # After the shuffle each part are in little-endian and ordered as: SIGN-Exponent-Fraction
      +    exp_bits = ((reshuffled & 0xFF000000) - 1) & 0xFF000000
      +    reshuffled = (reshuffled & 0x00FFFFFF) | exp_bits
      +    return UNPACK_FLOAT_IEEE(reshuffled)
      +
      def DEC_to_IEEE_BYTES(bytes) @@ -48,30 +246,102 @@

      Params:

      Params:

      bytes : Byte array where every 4 bytes represent a single precision DEC float. Returns : IEEE formated floating point of the same shape as the input.

      +
      + +Expand source code + +
      def DEC_to_IEEE_BYTES(bytes):
      +    '''Convert byte array containing 32 bit DEC floats to IEEE format.
      +
      +    Params:
      +    ----
      +    bytes : Byte array where every 4 bytes represent a single precision DEC float.
      +    Returns : IEEE formated floating point of the same shape as the input.
      +    '''
      +
      +    # See comments in DEC_to_IEEE() for DEC format definition
      +
      +    # Reshuffle
      +    bytes = memoryview(bytes)
      +    reshuffled = np.empty(len(bytes), dtype=np.dtype('B'))
      +    reshuffled[::4] = bytes[2::4]
      +    reshuffled[1::4] = bytes[3::4]
      +    reshuffled[2::4] = bytes[::4]
      +    # Decrement exponent by 2, if exp. > 1
      +    reshuffled[3::4] = bytes[1::4] + (np.bitwise_and(bytes[1::4], 0x7f) == 0) - 1
      +
      +    # There are different ways to adjust for differences in DEC/IEEE representation
      +    # after reshuffle. Two simple methods are:
      +    # 1) Decrement exponent bits by 2, then convert to IEEE.
      +    # 2) Convert to IEEE directly and divide by four.
      +    # 3) Handle edge cases, expensive in python...
      +    # However these are simple methods, and do not accurately convert when:
      +    # 1) Exponent < 2 (without bias), impossible to decrement exponent without adjusting fraction/mantissa.
      +    # 2) Exponent == 0, DEC numbers are then 0 or undefined while IEEE is not. NaN are produced when exponent == 255.
      +    # Here method 1) is used, which mean that only small numbers will be represented incorrectly.
      +
      +    return np.frombuffer(reshuffled.tobytes(),
      +                         dtype=np.float32,
      +                         count=int(len(bytes) / 4))
      +
      def UNPACK_FLOAT_IEEE(uint_32)

      Unpacks a single 32 bit unsigned int to a IEEE float representation

      +
      + +Expand source code + +
      def UNPACK_FLOAT_IEEE(uint_32):
      +    '''Unpacks a single 32 bit unsigned int to a IEEE float representation
      +    '''
      +    return struct.unpack('f', struct.pack("<I", uint_32))[0]
      +
      def UNPACK_FLOAT_MIPS(uint_32)

      Unpacks a single 32 bit unsigned int to a IEEE float representation

      +
      + +Expand source code + +
      def UNPACK_FLOAT_MIPS(uint_32):
      +    '''Unpacks a single 32 bit unsigned int to a IEEE float representation
      +    '''
      +    return struct.unpack('f', struct.pack(">I", uint_32))[0]
      +
      def is_integer(value)

      Check if value input is integer.

      +
      + +Expand source code + +
      def is_integer(value):
      +    '''Check if value input is integer.'''
      +    return isinstance(value, (int, np.int32, np.int64))
      +
      def is_iterable(value)

      Check if value is iterable.

      +
      + +Expand source code + +
      def is_iterable(value):
      +    '''Check if value is iterable.'''
      +    return hasattr(value, '__iter__')
      +
      def pack_labels(labels) @@ -101,12 +371,59 @@

      Returns

      label_max_size : int
      Number of bytes associated with the longest label string, all strings are padded to this length.
      +
      + +Expand source code + +
      def pack_labels(labels):
      +    ''' Static method used to pack and pad the set of `labels` strings before
      +        passing the output into a `c3d.group.Group.add_str`.
      +
      +    Parameters
      +    ----------
      +    labels : iterable
      +        List of strings to pack and pad into a single string suitable for encoding in a Parameter entry.
      +
      +    Example
      +    -------
      +    >>> labels = ['RFT1', 'RFT2', 'RFT3', 'LFT1', 'LFT2', 'LFT3']
      +    >>> param_str, label_max_size = Writer.pack_labels(labels)
      +    >>> writer.point_group.add_str('LABELS',
      +                                   'Point labels.',
      +                                   label_str,
      +                                   label_max_size,
      +                                   len(labels))
      +
      +    Returns
      +    -------
      +    param_str : str
      +        String containing `labels` packed into a single variable where
      +        each string is padded to match the longest `labels` string.
      +    label_max_size : int
      +        Number of bytes associated with the longest `label` string, all strings are padded to this length.
      +    '''
      +    labels = np.ravel(labels)
      +    # Get longest label name
      +    label_max_size = 0
      +    label_max_size = max(label_max_size, np.max([len(label) for label in labels]))
      +    label_str = ''.join(label.ljust(label_max_size) for label in labels)
      +    return label_str, label_max_size
      +
      def type_npy2struct(dtype)

      Convert numpy dtype format to a struct package format string.

      +
      + +Expand source code + +
      def type_npy2struct(dtype):
      +    ''' Convert numpy dtype format to a struct package format string.
      +    '''
      +    return dtype.byteorder + dtype.char
      +
      @@ -119,6 +436,18 @@

      Classes

      Base class for extending (decorating) a python object.

      +
      + +Expand source code + +
      class Decorator(object):
      +    '''Base class for extending (decorating) a python object.
      +    '''
      +    def __init__(self, decoratee):
      +        self._decoratee = decoratee
      +    def __getattr__(self, name):
      +        return getattr(self._decoratee, name)
      +
      diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html index 2ba71a3..3ffe26f 100644 --- a/docs/c3d/writer.html +++ b/docs/c3d/writer.html @@ -5,7 +5,7 @@ c3d.writer API documentation - + @@ -22,7 +22,519 @@

      Module c3d.writer

      -

      A Python module for reading and writing C3D files.

      +

      Contains the Writer class for writing C3D files.

      +
      + +Expand source code + +
      '''Contains the Writer class for writing C3D files.'''
      +
      +import copy
      +import numpy as np
      +import struct
      +#import warnings
      +from . import utils
      +from .manager import Manager
      +from .dtypes import DataTypes
      +
      +class Writer(Manager):
      +    '''This class writes metadata and frames to a C3D file.
      +
      +    For example, to read an existing C3D file, apply some sort of data
      +    processing to the frames, and write out another C3D file::
      +
      +    >>> r = c3d.Reader(open('data.c3d', 'rb'))
      +    >>> w = c3d.Writer()
      +    >>> w.add_frames(process_frames_somehow(r.read_frames()))
      +    >>> with open('smoothed.c3d', 'wb') as handle:
      +    >>>     w.write(handle)
      +
      +    Parameters
      +    ----------
      +    point_rate : float, optional
      +        The frame rate of the data. Defaults to 480.
      +    analog_rate : float, optional
      +        The number of analog samples per frame. Defaults to 0.
      +    point_scale : float, optional
      +        The scale factor for point data. Defaults to -1 (i.e., "check the
      +        POINT:SCALE parameter").
      +    point_units : str, optional
      +        The units that the point numbers represent. Defaults to ``'mm  '``.
      +    gen_scale : float, optional
      +        General scaling factor for analog data. Defaults to 1.
      +    '''
      +
      +    def __init__(self,
      +                 point_rate=480.,
      +                 analog_rate=0.,
      +                 point_scale=-1.):
      +        '''Set minimal metadata for this writer.
      +
      +        '''
      +        self._dtypes = DataTypes() # Only support INTEL format from writing
      +        super(Writer, self).__init__()
      +
      +        # Header properties
      +        self._header.frame_rate = np.float32(point_rate)
      +        self._header.scale_factor = np.float32(point_scale)
      +        self.analog_rate = analog_rate
      +        self._frames = []
      +
      +    @staticmethod
      +    def from_reader(reader, conversion=None):
      +        '''
      +
      +        Parameters
      +        ----------
      +        source : `c3d.manager.Manager`
      +            Source to copy.
      +        conversion : str
      +            Conversion mode, None is equivalent to the default mode. Supported modes are:
      +
      +                'consume'       - (Default) Reader object will be
      +                                  consumed and explicitly deleted.
      +
      +                'copy'          - Reader objects will be deep copied.
      +
      +                'copy_metadata' - Similar to 'copy' but only copies metadata and
      +                                  not point and analog frame data.
      +
      +                'copy_shallow'  - Similar to 'copy' but group parameters are
      +                                  not copied.
      +
      +                'copy_header'   - Similar to 'copy_shallow' but only the
      +                                  header is copied (frame data is not copied).
      +
      +        Returns
      +        -------
      +        param : :class:`Writer`
      +            A writeable and persistent representation of the 'Reader' object.
      +
      +        Raises
      +        ------
      +        ValueError
      +            If mode string is not equivalent to one of the supported modes.
      +            If attempting to convert non-Intel files using mode other than 'shallow_copy'.
      +        '''
      +        writer = Writer()
      +        # Modes
      +        is_header_only = conversion == 'copy_header'
      +        is_meta_copy = conversion == 'copy_metadata'
      +        is_meta_only = is_header_only or is_meta_copy
      +        is_consume = conversion == 'consume' or conversion is None
      +        is_shallow_copy = conversion == 'shallow_copy' or is_header_only
      +        is_deep_copy = conversion == 'copy' or is_meta_copy
      +        # Verify mode
      +        if not (is_consume or is_shallow_copy or is_deep_copy):
      +            raise ValueError(
      +                "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format(
      +                conversion))
      +        if not reader._dtypes.is_ieee and not is_shallow_copy:
      +            # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data.
      +            raise ValueError(
      +                "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format(
      +                reader._dtypes.proc_type))
      +
      +        if is_consume:
      +            writer._header = reader._header
      +            writer._groups = reader._groups
      +        elif is_deep_copy:
      +            writer._header = copy.deepcopy(reader._header)
      +            writer._groups = copy.deepcopy(reader._groups)
      +        elif is_shallow_copy:
      +            # Only copy header (no groups)
      +            writer._header = copy.deepcopy(reader._header)
      +            # Reformat header events
      +            writer._header.encode_events(writer._header.events)
      +
      +            # Transfer a minimal set parameters
      +            writer.set_start_frame(reader.first_frame)
      +            writer.set_point_labels(reader.point_labels)
      +            writer.set_analog_labels(reader.analog_labels)
      +
      +            gen_scale, analog_scales, analog_offsets = reader.get_analog_transform_parameters()
      +            writer.set_analog_general_scale(gen_scale)
      +            writer.set_analog_scales(analog_scales)
      +            writer.set_analog_offsets(analog_offsets)
      +
      +        if not is_meta_only:
      +            # Copy frames
      +            for (i, point, analog) in reader.read_frames(copy=True, camera_sum=False):
      +                writer.add_frames((point, analog))
      +        if is_consume:
      +            # Cleanup
      +            reader._header = None
      +            reader._groups = None
      +            del reader
      +        return writer
      +
      +    @property
      +    def analog_rate(self):
      +        return super(Writer, self).analog_rate
      +
      +    @analog_rate.setter
      +    def analog_rate(self, value):
      +        per_frame_rate = value / self.point_rate
      +        assert float(per_frame_rate).is_integer(), "Analog rate must be a multiple of the point rate."
      +        self._header.analog_per_frame = np.uint16(per_frame_rate)
      +
      +    @property
      +    def numeric_key_max(self):
      +        ''' Get the largest numeric key.
      +        '''
      +        num = 0
      +        if len(self._groups) > 0:
      +            for i in self._groups.keys():
      +                if isinstance(i, int):
      +                    num = max(i, num)
      +        return num
      +
      +    @property
      +    def numeric_key_next(self):
      +        ''' Get a new unique numeric group key.
      +        '''
      +        return self.numeric_key_max + 1
      +
      +    def get_create(self, label):
      +        ''' Get or create a parameter `c3d.group.Group`.'''
      +        label = label.upper()
      +        group = self.get(label)
      +        if group is None:
      +            group = self.add_group(self.numeric_key_next, label, label + ' group')
      +        return group
      +
      +    @property
      +    def point_group(self):
      +        ''' Get or create the POINT parameter group.'''
      +        return self.get_create('POINT')
      +
      +    @property
      +    def analog_group(self):
      +        ''' Get or create the ANALOG parameter group.'''
      +        return self.get_create('ANALOG')
      +
      +    @property
      +    def trial_group(self):
      +        ''' Get or create the TRIAL parameter group.'''
      +        return self.get_create('TRIAL')
      +
      +    def add_group(self, group_id, name, desc):
      +        '''Add a new parameter group. See Manager.add_group() for more information.
      +
      +        Returns
      +        -------
      +        group : `c3d.group.Group`
      +            An editable group instance.
      +        '''
      +        return super(Writer, self)._add_group(group_id, name, desc)
      +
      +    def rename_group(self, *args):
      +        ''' Rename a specified parameter group (see Manager._rename_group for args). '''
      +        super(Writer, self)._rename_group(*args)
      +
      +    def remove_group(self, *args):
      +        '''Remove the parameter group. (see Manager._rename_group for args). '''
      +        super(Writer, self)._remove_group(*args)
      +
      +    def add_frames(self, frames, index=None):
      +        '''Add frames to this writer instance.
      +
      +        Parameters
      +        ----------
      +        frames : Single or sequence of (point, analog) pairs
      +            A sequence or frame of frame data to add to the writer.
      +        index : int or None
      +            Insert the frame or sequence at the index (the first sequence frame will be inserted at the given `index`).
      +            Note that the index should be relative to 0 rather then the frame number provided by read_frames()!
      +        '''
      +        sh = np.shape(frames)
      +        # Single frame
      +        if len(sh) < 2:
      +            frames = [frames]
      +            sh = np.shape(frames)
      +
      +        # Check data shapes match
      +        if len(self._frames) > 0:
      +            point0, analog0 = self._frames[0]
      +            psh, ash = np.shape(point0), np.shape(analog0)
      +            for f in frames:
      +                if np.shape(f[0]) != psh:
      +                    raise ValueError(
      +                        'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format(
      +                        str(psh), str(np.shape(f[0]))
      +                        ))
      +                if np.shape(f[1]) != ash:
      +                    raise ValueError(
      +                        'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format(
      +                        str(ash), str(np.shape(f[1]))
      +                        ))
      +
      +        # Sequence of invalid shape
      +        if sh[1] != 2:
      +            raise ValueError(
      +                'Expected frame input to be sequence of point and analog pairs on form (None, 2). ' +
      +                'Input was of shape {}.'.format(str(sh)))
      +
      +        if index is not None:
      +            self._frames[index:index] = frames
      +        else:
      +            self._frames.extend(frames)
      +
      +    def set_point_labels(self, labels):
      +        ''' Set point data labels.
      +
      +        Parameters
      +        ----------
      +        labels : iterable
      +            Set POINT:LABELS parameter entry from a set of string labels.
      +        '''
      +        grp = self.point_group
      +        if labels is None:
      +            grp.add_empty_array('LABELS', 'Point labels.', -1)
      +        else:
      +            label_str, label_max_size = utils.pack_labels(labels)
      +            grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels))
      +
      +    def set_analog_labels(self, labels):
      +        ''' Set analog data labels.
      +
      +        Parameters
      +        ----------
      +        labels : iterable
      +            Set ANALOG:LABELS parameter entry from a set of string labels.
      +        '''
      +        grp = self.analog_group
      +        if labels is None:
      +            grp.add_empty_array('LABELS', 'Analog labels.', -1)
      +        else:
      +            label_str, label_max_size = utils.pack_labels(labels)
      +            grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels))
      +
      +    def set_analog_general_scale(self, value):
      +        ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor).
      +        '''
      +        self.analog_group.set('GEN_SCALE', 'Analog general scale factor', 4, '<f', value)
      +
      +    def set_analog_scales(self, values):
      +        ''' Set ANALOG:SCALE factors (per channel scale factor).
      +
      +        Parameters
      +        ----------
      +        values : iterable or None
      +            Iterable containing individual scale factors (float32) for scaling analog channel data.
      +        '''
      +        if utils.is_iterable(values):
      +            data = np.array([v for v in values], dtype=np.float32)
      +            self.analog_group.set_array('SCALE', 'Analog channel scale factors', data)
      +        elif values is None:
      +            self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4)
      +        else:
      +            raise ValueError('Expected iterable containing analog scale factors.')
      +
      +    def set_analog_offsets(self, values):
      +        ''' Set ANALOG:OFFSET offsets (per channel offset).
      +
      +        Parameters
      +        ----------
      +        values : iterable or None
      +            Iterable containing individual offsets (int16) for encoding analog channel data.
      +        '''
      +        if utils.is_iterable(values):
      +            data = np.array([v for v in values], dtype=np.int16)
      +            self.analog_group.set_array('OFFSET', 'Analog channel offsets', data)
      +        elif values is None:
      +            self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2)
      +        else:
      +            raise ValueError('Expected iterable containing analog data offsets.')
      +
      +    def set_start_frame(self, frame=1):
      +        ''' Set the 'TRIAL:ACTUAL_START_FIELD' parameter and header.first_frame entry.
      +
      +        Parameters
      +        ----------
      +        frame : int
      +            Number for the first frame recorded in the file.
      +            Frame counter for a trial recording always start at 1 for the first frame.
      +        '''
      +        self.trial_group.set('ACTUAL_START_FIELD', 'Actual start frame', 2, '<I', frame, 2)
      +        if frame < 65535:
      +            self._header.first_frame = np.uint16(frame)
      +        else:
      +            self._header.first_frame = np.uint16(65535)
      +
      +    def _set_last_frame(self, frame):
      +        ''' Sets the 'TRIAL:ACTUAL_END_FIELD' parameter and header.last_frame entry.
      +        '''
      +        self.trial_group.set('ACTUAL_END_FIELD', 'Actual end frame', 2, '<I', frame, 2)
      +        self._header.last_frame = np.uint16(min(frame, 65535))
      +
      +
      +    def set_screen_axis(self, X='+X', Y='+Y'):
      +        ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group.
      +
      +        Parameters
      +        ----------
      +        X : str
      +            Two byte string with first character indicating positive or negative axis (+/-),
      +            and the second axis (X/Y/Z). Example strings '+X' or '-Y'
      +        Y : str
      +            Second axis string with same format as Y. Determines the second Y screen axis.
      +        '''
      +        if len(X) != 2:
      +            raise ValueError('Expected string literal to be a 2 character string for the X_SCREEN parameter.')
      +        if len(Y) != 2:
      +            raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter.')
      +        group = self.point_group
      +        group.set_str('X_SCREEN', 'X_SCREEN parameter', X)
      +        group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y)
      +
      +    def write(self, handle):
      +        '''Write metadata, point and analog frames to a file handle.
      +
      +        Parameters
      +        ----------
      +        handle : file
      +            Write metadata and C3D motion frames to the given file handle. The
      +            writer does not close the handle.
      +        '''
      +        if not self._frames:
      +            raise RuntimeError('Attempted to write empty file.')
      +
      +        points, analog = self._frames[0]
      +        ppf = len(points)
      +        apf = len(analog)
      +
      +
      +        first_frame = self.first_frame
      +        if first_frame <= 0: # Bad value
      +            first_frame = 1
      +        nframes = len(self._frames)
      +        last_frame = first_frame + nframes - 1
      +
      +        UINT16_MAX = 65535
      +
      +        # POINT group
      +        group = self.point_group
      +        group.set('USED', 'Number of point samples', 2, '<H', ppf)
      +        group.set('FRAMES', 'Total frame count', 2, '<H', min(UINT16_MAX, nframes))
      +        if nframes >= UINT16_MAX:
      +            # Should be floating point
      +            group.set('LONG_FRAMES', 'Total frame count', 4, '<f', nframes)
      +        elif 'LONG_FRAMES' in group:
      +            # Docs states it should not exist if frame_count < 65535
      +            group.remove_param('LONG_FRAMES')
      +        group.set('DATA_START', 'First data block containing frame samples.', 2, '<H', 0)
      +        group.set('SCALE', 'Point data scaling factor', 4, '<f', self.point_scale)
      +        group.set('RATE', 'Point data sample rate', 4, '<f', self.point_rate)
      +        # Optional
      +        if 'UNITS' not in group:
      +            group.add_str('UNITS', 'Units used for point data measurements.', 'mm')
      +        if 'DESCRIPTIONS' not in group:
      +            group.add_str('DESCRIPTIONS', 'Channel descriptions.', '  ' * ppf, 2, ppf)
      +
      +        # ANALOG group
      +        group = self.analog_group
      +        group.set('USED', 'Analog channel count', 2, '<H', apf)
      +        group.set('RATE', 'Analog samples per second', 4, '<f', self.analog_rate)
      +        if 'GEN_SCALE' not in group:
      +            self.set_analog_general_scale(1.0)
      +        # Optional
      +        if 'SCALE' not in group:
      +            self.set_analog_scales(None)
      +        if 'OFFSET' not in group:
      +            self.set_analog_offsets(None)
      +        if 'DESCRIPTIONS' not in group:
      +            group.add_str('DESCRIPTIONS', 'Channel descriptions.', '  ' * apf, 2, apf)
      +
      +        # TRIAL group
      +        self.set_start_frame(first_frame)
      +        self._set_last_frame(last_frame)
      +
      +        # sync parameter information to header.
      +        start_block = self.parameter_blocks() + 2
      +        self.get('POINT:DATA_START').bytes = struct.pack('<H', start_block)
      +        self._header.data_block = np.uint16(start_block)
      +        self._header.point_count = np.uint16(ppf)
      +        self._header.analog_count = np.uint16(np.prod(np.shape(analog)))
      +
      +        self._write_metadata(handle)
      +        self._write_frames(handle)
      +
      +    def _pad_block(self, handle):
      +        '''Pad the file with 0s to the end of the next block boundary.'''
      +        extra = handle.tell() % 512
      +        if extra:
      +            handle.write(b'\x00' * (512 - extra))
      +
      +    def _write_metadata(self, handle):
      +        '''Write metadata to a file handle.
      +
      +        Parameters
      +        ----------
      +        handle : file
      +            Write metadata and C3D motion frames to the given file handle. The
      +            writer does not close the handle.
      +        '''
      +        self._check_metadata()
      +
      +        # Header
      +        self._header.write(handle)
      +        self._pad_block(handle)
      +        assert handle.tell() == 512
      +
      +        # Groups
      +        handle.write(struct.pack(
      +            'BBBB', 0, 0, self.parameter_blocks(), self._dtypes.processor))
      +        for group_id, group in self.listed():
      +            group._data.write(group_id, handle)
      +
      +        # Padding
      +        self._pad_block(handle)
      +        while handle.tell() != 512 * (self.header.data_block - 1):
      +            handle.write(b'\x00' * 512)
      +
      +    def _write_frames(self, handle):
      +        '''Write our frame data to the given file handle.
      +
      +        Parameters
      +        ----------
      +        handle : file
      +            Write metadata and C3D motion frames to the given file handle. The
      +            writer does not close the handle.
      +        '''
      +        assert handle.tell() == 512 * (self._header.data_block - 1)
      +        scale_mag = abs(self.point_scale)
      +        is_float = self.point_scale < 0
      +        if is_float:
      +            point_dtype = self._dtypes.float32
      +            point_scale = 1.0
      +        else:
      +            point_dtype = self._dtypes.int16
      +            point_scale = scale_mag
      +        raw = np.zeros((self.point_used, 4), point_dtype)
      +
      +        analog_scales, analog_offsets = self.get_analog_transform()
      +        analog_scales_inv = 1.0 / analog_scales
      +
      +        for points, analog in self._frames:
      +            # Transform point data
      +            valid = points[:, 3] >= 0.0
      +            raw[~valid, 3] = -1
      +            raw[valid, :3] = points[valid, :3] / point_scale
      +            raw[valid, 3] = np.bitwise_or(np.rint(points[valid, 3] / scale_mag).astype(np.uint8),
      +                                          (points[valid, 4].astype(np.uint16) << 8),
      +                                          dtype=np.uint16)
      +
      +            # Transform analog data
      +            analog = analog * analog_scales_inv + analog_offsets
      +            analog = analog.T
      +
      +            # Write
      +            analog = analog.astype(point_dtype)
      +            handle.write(raw.tobytes())
      +            handle.write(analog.tobytes())
      +        self._pad_block(handle)
      +
      @@ -63,6 +575,508 @@

      Parameters

      General scaling factor for analog data. Defaults to 1.

      Set minimal metadata for this writer.

      +
      + +Expand source code + +
      class Writer(Manager):
      +    '''This class writes metadata and frames to a C3D file.
      +
      +    For example, to read an existing C3D file, apply some sort of data
      +    processing to the frames, and write out another C3D file::
      +
      +    >>> r = c3d.Reader(open('data.c3d', 'rb'))
      +    >>> w = c3d.Writer()
      +    >>> w.add_frames(process_frames_somehow(r.read_frames()))
      +    >>> with open('smoothed.c3d', 'wb') as handle:
      +    >>>     w.write(handle)
      +
      +    Parameters
      +    ----------
      +    point_rate : float, optional
      +        The frame rate of the data. Defaults to 480.
      +    analog_rate : float, optional
      +        The number of analog samples per frame. Defaults to 0.
      +    point_scale : float, optional
      +        The scale factor for point data. Defaults to -1 (i.e., "check the
      +        POINT:SCALE parameter").
      +    point_units : str, optional
      +        The units that the point numbers represent. Defaults to ``'mm  '``.
      +    gen_scale : float, optional
      +        General scaling factor for analog data. Defaults to 1.
      +    '''
      +
      +    def __init__(self,
      +                 point_rate=480.,
      +                 analog_rate=0.,
      +                 point_scale=-1.):
      +        '''Set minimal metadata for this writer.
      +
      +        '''
      +        self._dtypes = DataTypes() # Only support INTEL format from writing
      +        super(Writer, self).__init__()
      +
      +        # Header properties
      +        self._header.frame_rate = np.float32(point_rate)
      +        self._header.scale_factor = np.float32(point_scale)
      +        self.analog_rate = analog_rate
      +        self._frames = []
      +
      +    @staticmethod
      +    def from_reader(reader, conversion=None):
      +        '''
      +
      +        Parameters
      +        ----------
      +        source : `c3d.manager.Manager`
      +            Source to copy.
      +        conversion : str
      +            Conversion mode, None is equivalent to the default mode. Supported modes are:
      +
      +                'consume'       - (Default) Reader object will be
      +                                  consumed and explicitly deleted.
      +
      +                'copy'          - Reader objects will be deep copied.
      +
      +                'copy_metadata' - Similar to 'copy' but only copies metadata and
      +                                  not point and analog frame data.
      +
      +                'copy_shallow'  - Similar to 'copy' but group parameters are
      +                                  not copied.
      +
      +                'copy_header'   - Similar to 'copy_shallow' but only the
      +                                  header is copied (frame data is not copied).
      +
      +        Returns
      +        -------
      +        param : :class:`Writer`
      +            A writeable and persistent representation of the 'Reader' object.
      +
      +        Raises
      +        ------
      +        ValueError
      +            If mode string is not equivalent to one of the supported modes.
      +            If attempting to convert non-Intel files using mode other than 'shallow_copy'.
      +        '''
      +        writer = Writer()
      +        # Modes
      +        is_header_only = conversion == 'copy_header'
      +        is_meta_copy = conversion == 'copy_metadata'
      +        is_meta_only = is_header_only or is_meta_copy
      +        is_consume = conversion == 'consume' or conversion is None
      +        is_shallow_copy = conversion == 'shallow_copy' or is_header_only
      +        is_deep_copy = conversion == 'copy' or is_meta_copy
      +        # Verify mode
      +        if not (is_consume or is_shallow_copy or is_deep_copy):
      +            raise ValueError(
      +                "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format(
      +                conversion))
      +        if not reader._dtypes.is_ieee and not is_shallow_copy:
      +            # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data.
      +            raise ValueError(
      +                "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format(
      +                reader._dtypes.proc_type))
      +
      +        if is_consume:
      +            writer._header = reader._header
      +            writer._groups = reader._groups
      +        elif is_deep_copy:
      +            writer._header = copy.deepcopy(reader._header)
      +            writer._groups = copy.deepcopy(reader._groups)
      +        elif is_shallow_copy:
      +            # Only copy header (no groups)
      +            writer._header = copy.deepcopy(reader._header)
      +            # Reformat header events
      +            writer._header.encode_events(writer._header.events)
      +
      +            # Transfer a minimal set parameters
      +            writer.set_start_frame(reader.first_frame)
      +            writer.set_point_labels(reader.point_labels)
      +            writer.set_analog_labels(reader.analog_labels)
      +
      +            gen_scale, analog_scales, analog_offsets = reader.get_analog_transform_parameters()
      +            writer.set_analog_general_scale(gen_scale)
      +            writer.set_analog_scales(analog_scales)
      +            writer.set_analog_offsets(analog_offsets)
      +
      +        if not is_meta_only:
      +            # Copy frames
      +            for (i, point, analog) in reader.read_frames(copy=True, camera_sum=False):
      +                writer.add_frames((point, analog))
      +        if is_consume:
      +            # Cleanup
      +            reader._header = None
      +            reader._groups = None
      +            del reader
      +        return writer
      +
      +    @property
      +    def analog_rate(self):
      +        return super(Writer, self).analog_rate
      +
      +    @analog_rate.setter
      +    def analog_rate(self, value):
      +        per_frame_rate = value / self.point_rate
      +        assert float(per_frame_rate).is_integer(), "Analog rate must be a multiple of the point rate."
      +        self._header.analog_per_frame = np.uint16(per_frame_rate)
      +
      +    @property
      +    def numeric_key_max(self):
      +        ''' Get the largest numeric key.
      +        '''
      +        num = 0
      +        if len(self._groups) > 0:
      +            for i in self._groups.keys():
      +                if isinstance(i, int):
      +                    num = max(i, num)
      +        return num
      +
      +    @property
      +    def numeric_key_next(self):
      +        ''' Get a new unique numeric group key.
      +        '''
      +        return self.numeric_key_max + 1
      +
      +    def get_create(self, label):
      +        ''' Get or create a parameter `c3d.group.Group`.'''
      +        label = label.upper()
      +        group = self.get(label)
      +        if group is None:
      +            group = self.add_group(self.numeric_key_next, label, label + ' group')
      +        return group
      +
      +    @property
      +    def point_group(self):
      +        ''' Get or create the POINT parameter group.'''
      +        return self.get_create('POINT')
      +
      +    @property
      +    def analog_group(self):
      +        ''' Get or create the ANALOG parameter group.'''
      +        return self.get_create('ANALOG')
      +
      +    @property
      +    def trial_group(self):
      +        ''' Get or create the TRIAL parameter group.'''
      +        return self.get_create('TRIAL')
      +
      +    def add_group(self, group_id, name, desc):
      +        '''Add a new parameter group. See Manager.add_group() for more information.
      +
      +        Returns
      +        -------
      +        group : `c3d.group.Group`
      +            An editable group instance.
      +        '''
      +        return super(Writer, self)._add_group(group_id, name, desc)
      +
      +    def rename_group(self, *args):
      +        ''' Rename a specified parameter group (see Manager._rename_group for args). '''
      +        super(Writer, self)._rename_group(*args)
      +
      +    def remove_group(self, *args):
      +        '''Remove the parameter group. (see Manager._rename_group for args). '''
      +        super(Writer, self)._remove_group(*args)
      +
      +    def add_frames(self, frames, index=None):
      +        '''Add frames to this writer instance.
      +
      +        Parameters
      +        ----------
      +        frames : Single or sequence of (point, analog) pairs
      +            A sequence or frame of frame data to add to the writer.
      +        index : int or None
      +            Insert the frame or sequence at the index (the first sequence frame will be inserted at the given `index`).
      +            Note that the index should be relative to 0 rather then the frame number provided by read_frames()!
      +        '''
      +        sh = np.shape(frames)
      +        # Single frame
      +        if len(sh) < 2:
      +            frames = [frames]
      +            sh = np.shape(frames)
      +
      +        # Check data shapes match
      +        if len(self._frames) > 0:
      +            point0, analog0 = self._frames[0]
      +            psh, ash = np.shape(point0), np.shape(analog0)
      +            for f in frames:
      +                if np.shape(f[0]) != psh:
      +                    raise ValueError(
      +                        'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format(
      +                        str(psh), str(np.shape(f[0]))
      +                        ))
      +                if np.shape(f[1]) != ash:
      +                    raise ValueError(
      +                        'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format(
      +                        str(ash), str(np.shape(f[1]))
      +                        ))
      +
      +        # Sequence of invalid shape
      +        if sh[1] != 2:
      +            raise ValueError(
      +                'Expected frame input to be sequence of point and analog pairs on form (None, 2). ' +
      +                'Input was of shape {}.'.format(str(sh)))
      +
      +        if index is not None:
      +            self._frames[index:index] = frames
      +        else:
      +            self._frames.extend(frames)
      +
      +    def set_point_labels(self, labels):
      +        ''' Set point data labels.
      +
      +        Parameters
      +        ----------
      +        labels : iterable
      +            Set POINT:LABELS parameter entry from a set of string labels.
      +        '''
      +        grp = self.point_group
      +        if labels is None:
      +            grp.add_empty_array('LABELS', 'Point labels.', -1)
      +        else:
      +            label_str, label_max_size = utils.pack_labels(labels)
      +            grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels))
      +
      +    def set_analog_labels(self, labels):
      +        ''' Set analog data labels.
      +
      +        Parameters
      +        ----------
      +        labels : iterable
      +            Set ANALOG:LABELS parameter entry from a set of string labels.
      +        '''
      +        grp = self.analog_group
      +        if labels is None:
      +            grp.add_empty_array('LABELS', 'Analog labels.', -1)
      +        else:
      +            label_str, label_max_size = utils.pack_labels(labels)
      +            grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels))
      +
      +    def set_analog_general_scale(self, value):
      +        ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor).
      +        '''
      +        self.analog_group.set('GEN_SCALE', 'Analog general scale factor', 4, '<f', value)
      +
      +    def set_analog_scales(self, values):
      +        ''' Set ANALOG:SCALE factors (per channel scale factor).
      +
      +        Parameters
      +        ----------
      +        values : iterable or None
      +            Iterable containing individual scale factors (float32) for scaling analog channel data.
      +        '''
      +        if utils.is_iterable(values):
      +            data = np.array([v for v in values], dtype=np.float32)
      +            self.analog_group.set_array('SCALE', 'Analog channel scale factors', data)
      +        elif values is None:
      +            self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4)
      +        else:
      +            raise ValueError('Expected iterable containing analog scale factors.')
      +
      +    def set_analog_offsets(self, values):
      +        ''' Set ANALOG:OFFSET offsets (per channel offset).
      +
      +        Parameters
      +        ----------
      +        values : iterable or None
      +            Iterable containing individual offsets (int16) for encoding analog channel data.
      +        '''
      +        if utils.is_iterable(values):
      +            data = np.array([v for v in values], dtype=np.int16)
      +            self.analog_group.set_array('OFFSET', 'Analog channel offsets', data)
      +        elif values is None:
      +            self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2)
      +        else:
      +            raise ValueError('Expected iterable containing analog data offsets.')
      +
      +    def set_start_frame(self, frame=1):
      +        ''' Set the 'TRIAL:ACTUAL_START_FIELD' parameter and header.first_frame entry.
      +
      +        Parameters
      +        ----------
      +        frame : int
      +            Number for the first frame recorded in the file.
      +            Frame counter for a trial recording always start at 1 for the first frame.
      +        '''
      +        self.trial_group.set('ACTUAL_START_FIELD', 'Actual start frame', 2, '<I', frame, 2)
      +        if frame < 65535:
      +            self._header.first_frame = np.uint16(frame)
      +        else:
      +            self._header.first_frame = np.uint16(65535)
      +
      +    def _set_last_frame(self, frame):
      +        ''' Sets the 'TRIAL:ACTUAL_END_FIELD' parameter and header.last_frame entry.
      +        '''
      +        self.trial_group.set('ACTUAL_END_FIELD', 'Actual end frame', 2, '<I', frame, 2)
      +        self._header.last_frame = np.uint16(min(frame, 65535))
      +
      +
      +    def set_screen_axis(self, X='+X', Y='+Y'):
      +        ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group.
      +
      +        Parameters
      +        ----------
      +        X : str
      +            Two byte string with first character indicating positive or negative axis (+/-),
      +            and the second axis (X/Y/Z). Example strings '+X' or '-Y'
      +        Y : str
      +            Second axis string with same format as Y. Determines the second Y screen axis.
      +        '''
      +        if len(X) != 2:
      +            raise ValueError('Expected string literal to be a 2 character string for the X_SCREEN parameter.')
      +        if len(Y) != 2:
      +            raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter.')
      +        group = self.point_group
      +        group.set_str('X_SCREEN', 'X_SCREEN parameter', X)
      +        group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y)
      +
      +    def write(self, handle):
      +        '''Write metadata, point and analog frames to a file handle.
      +
      +        Parameters
      +        ----------
      +        handle : file
      +            Write metadata and C3D motion frames to the given file handle. The
      +            writer does not close the handle.
      +        '''
      +        if not self._frames:
      +            raise RuntimeError('Attempted to write empty file.')
      +
      +        points, analog = self._frames[0]
      +        ppf = len(points)
      +        apf = len(analog)
      +
      +
      +        first_frame = self.first_frame
      +        if first_frame <= 0: # Bad value
      +            first_frame = 1
      +        nframes = len(self._frames)
      +        last_frame = first_frame + nframes - 1
      +
      +        UINT16_MAX = 65535
      +
      +        # POINT group
      +        group = self.point_group
      +        group.set('USED', 'Number of point samples', 2, '<H', ppf)
      +        group.set('FRAMES', 'Total frame count', 2, '<H', min(UINT16_MAX, nframes))
      +        if nframes >= UINT16_MAX:
      +            # Should be floating point
      +            group.set('LONG_FRAMES', 'Total frame count', 4, '<f', nframes)
      +        elif 'LONG_FRAMES' in group:
      +            # Docs states it should not exist if frame_count < 65535
      +            group.remove_param('LONG_FRAMES')
      +        group.set('DATA_START', 'First data block containing frame samples.', 2, '<H', 0)
      +        group.set('SCALE', 'Point data scaling factor', 4, '<f', self.point_scale)
      +        group.set('RATE', 'Point data sample rate', 4, '<f', self.point_rate)
      +        # Optional
      +        if 'UNITS' not in group:
      +            group.add_str('UNITS', 'Units used for point data measurements.', 'mm')
      +        if 'DESCRIPTIONS' not in group:
      +            group.add_str('DESCRIPTIONS', 'Channel descriptions.', '  ' * ppf, 2, ppf)
      +
      +        # ANALOG group
      +        group = self.analog_group
      +        group.set('USED', 'Analog channel count', 2, '<H', apf)
      +        group.set('RATE', 'Analog samples per second', 4, '<f', self.analog_rate)
      +        if 'GEN_SCALE' not in group:
      +            self.set_analog_general_scale(1.0)
      +        # Optional
      +        if 'SCALE' not in group:
      +            self.set_analog_scales(None)
      +        if 'OFFSET' not in group:
      +            self.set_analog_offsets(None)
      +        if 'DESCRIPTIONS' not in group:
      +            group.add_str('DESCRIPTIONS', 'Channel descriptions.', '  ' * apf, 2, apf)
      +
      +        # TRIAL group
      +        self.set_start_frame(first_frame)
      +        self._set_last_frame(last_frame)
      +
      +        # sync parameter information to header.
      +        start_block = self.parameter_blocks() + 2
      +        self.get('POINT:DATA_START').bytes = struct.pack('<H', start_block)
      +        self._header.data_block = np.uint16(start_block)
      +        self._header.point_count = np.uint16(ppf)
      +        self._header.analog_count = np.uint16(np.prod(np.shape(analog)))
      +
      +        self._write_metadata(handle)
      +        self._write_frames(handle)
      +
      +    def _pad_block(self, handle):
      +        '''Pad the file with 0s to the end of the next block boundary.'''
      +        extra = handle.tell() % 512
      +        if extra:
      +            handle.write(b'\x00' * (512 - extra))
      +
      +    def _write_metadata(self, handle):
      +        '''Write metadata to a file handle.
      +
      +        Parameters
      +        ----------
      +        handle : file
      +            Write metadata and C3D motion frames to the given file handle. The
      +            writer does not close the handle.
      +        '''
      +        self._check_metadata()
      +
      +        # Header
      +        self._header.write(handle)
      +        self._pad_block(handle)
      +        assert handle.tell() == 512
      +
      +        # Groups
      +        handle.write(struct.pack(
      +            'BBBB', 0, 0, self.parameter_blocks(), self._dtypes.processor))
      +        for group_id, group in self.listed():
      +            group._data.write(group_id, handle)
      +
      +        # Padding
      +        self._pad_block(handle)
      +        while handle.tell() != 512 * (self.header.data_block - 1):
      +            handle.write(b'\x00' * 512)
      +
      +    def _write_frames(self, handle):
      +        '''Write our frame data to the given file handle.
      +
      +        Parameters
      +        ----------
      +        handle : file
      +            Write metadata and C3D motion frames to the given file handle. The
      +            writer does not close the handle.
      +        '''
      +        assert handle.tell() == 512 * (self._header.data_block - 1)
      +        scale_mag = abs(self.point_scale)
      +        is_float = self.point_scale < 0
      +        if is_float:
      +            point_dtype = self._dtypes.float32
      +            point_scale = 1.0
      +        else:
      +            point_dtype = self._dtypes.int16
      +            point_scale = scale_mag
      +        raw = np.zeros((self.point_used, 4), point_dtype)
      +
      +        analog_scales, analog_offsets = self.get_analog_transform()
      +        analog_scales_inv = 1.0 / analog_scales
      +
      +        for points, analog in self._frames:
      +            # Transform point data
      +            valid = points[:, 3] >= 0.0
      +            raw[~valid, 3] = -1
      +            raw[valid, :3] = points[valid, :3] / point_scale
      +            raw[valid, 3] = np.bitwise_or(np.rint(points[valid, 3] / scale_mag).astype(np.uint8),
      +                                          (points[valid, 4].astype(np.uint16) << 8),
      +                                          dtype=np.uint16)
      +
      +            # Transform analog data
      +            analog = analog * analog_scales_inv + analog_offsets
      +            analog = analog.T
      +
      +            # Write
      +            analog = analog.astype(point_dtype)
      +            handle.write(raw.tobytes())
      +            handle.write(analog.tobytes())
      +        self._pad_block(handle)
      +

      Ancestors

      • Manager
      • @@ -105,6 +1119,98 @@

        Raises

        If mode string is not equivalent to one of the supported modes. If attempting to convert non-Intel files using mode other than 'shallow_copy'.
        +
        + +Expand source code + +
        @staticmethod
        +def from_reader(reader, conversion=None):
        +    '''
        +
        +    Parameters
        +    ----------
        +    source : `c3d.manager.Manager`
        +        Source to copy.
        +    conversion : str
        +        Conversion mode, None is equivalent to the default mode. Supported modes are:
        +
        +            'consume'       - (Default) Reader object will be
        +                              consumed and explicitly deleted.
        +
        +            'copy'          - Reader objects will be deep copied.
        +
        +            'copy_metadata' - Similar to 'copy' but only copies metadata and
        +                              not point and analog frame data.
        +
        +            'copy_shallow'  - Similar to 'copy' but group parameters are
        +                              not copied.
        +
        +            'copy_header'   - Similar to 'copy_shallow' but only the
        +                              header is copied (frame data is not copied).
        +
        +    Returns
        +    -------
        +    param : :class:`Writer`
        +        A writeable and persistent representation of the 'Reader' object.
        +
        +    Raises
        +    ------
        +    ValueError
        +        If mode string is not equivalent to one of the supported modes.
        +        If attempting to convert non-Intel files using mode other than 'shallow_copy'.
        +    '''
        +    writer = Writer()
        +    # Modes
        +    is_header_only = conversion == 'copy_header'
        +    is_meta_copy = conversion == 'copy_metadata'
        +    is_meta_only = is_header_only or is_meta_copy
        +    is_consume = conversion == 'consume' or conversion is None
        +    is_shallow_copy = conversion == 'shallow_copy' or is_header_only
        +    is_deep_copy = conversion == 'copy' or is_meta_copy
        +    # Verify mode
        +    if not (is_consume or is_shallow_copy or is_deep_copy):
        +        raise ValueError(
        +            "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format(
        +            conversion))
        +    if not reader._dtypes.is_ieee and not is_shallow_copy:
        +        # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data.
        +        raise ValueError(
        +            "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format(
        +            reader._dtypes.proc_type))
        +
        +    if is_consume:
        +        writer._header = reader._header
        +        writer._groups = reader._groups
        +    elif is_deep_copy:
        +        writer._header = copy.deepcopy(reader._header)
        +        writer._groups = copy.deepcopy(reader._groups)
        +    elif is_shallow_copy:
        +        # Only copy header (no groups)
        +        writer._header = copy.deepcopy(reader._header)
        +        # Reformat header events
        +        writer._header.encode_events(writer._header.events)
        +
        +        # Transfer a minimal set parameters
        +        writer.set_start_frame(reader.first_frame)
        +        writer.set_point_labels(reader.point_labels)
        +        writer.set_analog_labels(reader.analog_labels)
        +
        +        gen_scale, analog_scales, analog_offsets = reader.get_analog_transform_parameters()
        +        writer.set_analog_general_scale(gen_scale)
        +        writer.set_analog_scales(analog_scales)
        +        writer.set_analog_offsets(analog_offsets)
        +
        +    if not is_meta_only:
        +        # Copy frames
        +        for (i, point, analog) in reader.read_frames(copy=True, camera_sum=False):
        +            writer.add_frames((point, analog))
        +    if is_consume:
        +        # Cleanup
        +        reader._header = None
        +        reader._groups = None
        +        del reader
        +    return writer
        +

        Instance variables

        @@ -112,22 +1218,74 @@

        Instance variables

        var analog_group

        Get or create the ANALOG parameter group.

        +
        + +Expand source code + +
        @property
        +def analog_group(self):
        +    ''' Get or create the ANALOG parameter group.'''
        +    return self.get_create('ANALOG')
        +
        var numeric_key_max

        Get the largest numeric key.

        +
        + +Expand source code + +
        @property
        +def numeric_key_max(self):
        +    ''' Get the largest numeric key.
        +    '''
        +    num = 0
        +    if len(self._groups) > 0:
        +        for i in self._groups.keys():
        +            if isinstance(i, int):
        +                num = max(i, num)
        +    return num
        +
        var numeric_key_next

        Get a new unique numeric group key.

        +
        + +Expand source code + +
        @property
        +def numeric_key_next(self):
        +    ''' Get a new unique numeric group key.
        +    '''
        +    return self.numeric_key_max + 1
        +
        var point_group

        Get or create the POINT parameter group.

        +
        + +Expand source code + +
        @property
        +def point_group(self):
        +    ''' Get or create the POINT parameter group.'''
        +    return self.get_create('POINT')
        +
        var trial_group

        Get or create the TRIAL parameter group.

        +
        + +Expand source code + +
        @property
        +def trial_group(self):
        +    ''' Get or create the TRIAL parameter group.'''
        +    return self.get_create('TRIAL')
        +

        Methods

        @@ -145,6 +1303,54 @@

        Parameters

        Insert the frame or sequence at the index (the first sequence frame will be inserted at the given index). Note that the index should be relative to 0 rather then the frame number provided by read_frames()!
        +
        + +Expand source code + +
        def add_frames(self, frames, index=None):
        +    '''Add frames to this writer instance.
        +
        +    Parameters
        +    ----------
        +    frames : Single or sequence of (point, analog) pairs
        +        A sequence or frame of frame data to add to the writer.
        +    index : int or None
        +        Insert the frame or sequence at the index (the first sequence frame will be inserted at the given `index`).
        +        Note that the index should be relative to 0 rather then the frame number provided by read_frames()!
        +    '''
        +    sh = np.shape(frames)
        +    # Single frame
        +    if len(sh) < 2:
        +        frames = [frames]
        +        sh = np.shape(frames)
        +
        +    # Check data shapes match
        +    if len(self._frames) > 0:
        +        point0, analog0 = self._frames[0]
        +        psh, ash = np.shape(point0), np.shape(analog0)
        +        for f in frames:
        +            if np.shape(f[0]) != psh:
        +                raise ValueError(
        +                    'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format(
        +                    str(psh), str(np.shape(f[0]))
        +                    ))
        +            if np.shape(f[1]) != ash:
        +                raise ValueError(
        +                    'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format(
        +                    str(ash), str(np.shape(f[1]))
        +                    ))
        +
        +    # Sequence of invalid shape
        +    if sh[1] != 2:
        +        raise ValueError(
        +            'Expected frame input to be sequence of point and analog pairs on form (None, 2). ' +
        +            'Input was of shape {}.'.format(str(sh)))
        +
        +    if index is not None:
        +        self._frames[index:index] = frames
        +    else:
        +        self._frames.extend(frames)
        +
        def add_group(self, group_id, name, desc) @@ -153,33 +1359,84 @@

        Parameters

        Add a new parameter group. See Manager.add_group() for more information.

        Returns

        -
        group : :class:Group``
        +
        group : Group
        An editable group instance.
        +
        + +Expand source code + +
        def add_group(self, group_id, name, desc):
        +    '''Add a new parameter group. See Manager.add_group() for more information.
        +
        +    Returns
        +    -------
        +    group : `c3d.group.Group`
        +        An editable group instance.
        +    '''
        +    return super(Writer, self)._add_group(group_id, name, desc)
        +
        def get_create(self, label)
        -

        Get or create the specified parameter group.

        +

        Get or create a parameter Group.

        +
        + +Expand source code + +
        def get_create(self, label):
        +    ''' Get or create a parameter `c3d.group.Group`.'''
        +    label = label.upper()
        +    group = self.get(label)
        +    if group is None:
        +        group = self.add_group(self.numeric_key_next, label, label + ' group')
        +    return group
        +
        def remove_group(self, *args)

        Remove the parameter group. (see Manager._rename_group for args).

        +
        + +Expand source code + +
        def remove_group(self, *args):
        +    '''Remove the parameter group. (see Manager._rename_group for args). '''
        +    super(Writer, self)._remove_group(*args)
        +
        def rename_group(self, *args)

        Rename a specified parameter group (see Manager._rename_group for args).

        +
        + +Expand source code + +
        def rename_group(self, *args):
        +    ''' Rename a specified parameter group (see Manager._rename_group for args). '''
        +    super(Writer, self)._rename_group(*args)
        +
        def set_analog_general_scale(self, value)

        Set ANALOG:GEN_SCALE factor (uniform analog scale factor).

        +
        + +Expand source code + +
        def set_analog_general_scale(self, value):
        +    ''' Set ANALOG:GEN_SCALE factor (uniform analog scale factor).
        +    '''
        +    self.analog_group.set('GEN_SCALE', 'Analog general scale factor', 4, '<f', value)
        +
        def set_analog_labels(self, labels) @@ -191,6 +1448,25 @@

        Parameters

        labels : iterable
        Set ANALOG:LABELS parameter entry from a set of string labels.
        +
        + +Expand source code + +
        def set_analog_labels(self, labels):
        +    ''' Set analog data labels.
        +
        +    Parameters
        +    ----------
        +    labels : iterable
        +        Set ANALOG:LABELS parameter entry from a set of string labels.
        +    '''
        +    grp = self.analog_group
        +    if labels is None:
        +        grp.add_empty_array('LABELS', 'Analog labels.', -1)
        +    else:
        +        label_str, label_max_size = utils.pack_labels(labels)
        +        grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels))
        +
        def set_analog_offsets(self, values) @@ -200,8 +1476,28 @@

        Parameters

        Parameters

        values : iterable or None
        -
        Iterable containing individual offsets for encoding analog channel data.
        +
        Iterable containing individual offsets (int16) for encoding analog channel data.
        +
        + +Expand source code + +
        def set_analog_offsets(self, values):
        +    ''' Set ANALOG:OFFSET offsets (per channel offset).
        +
        +    Parameters
        +    ----------
        +    values : iterable or None
        +        Iterable containing individual offsets (int16) for encoding analog channel data.
        +    '''
        +    if utils.is_iterable(values):
        +        data = np.array([v for v in values], dtype=np.int16)
        +        self.analog_group.set_array('OFFSET', 'Analog channel offsets', data)
        +    elif values is None:
        +        self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2)
        +    else:
        +        raise ValueError('Expected iterable containing analog data offsets.')
        +
        def set_analog_scales(self, values) @@ -211,8 +1507,28 @@

        Parameters

        Parameters

        values : iterable or None
        -
        Iterable containing individual scale factors for encoding analog channel data.
        +
        Iterable containing individual scale factors (float32) for scaling analog channel data.
        +
        + +Expand source code + +
        def set_analog_scales(self, values):
        +    ''' Set ANALOG:SCALE factors (per channel scale factor).
        +
        +    Parameters
        +    ----------
        +    values : iterable or None
        +        Iterable containing individual scale factors (float32) for scaling analog channel data.
        +    '''
        +    if utils.is_iterable(values):
        +        data = np.array([v for v in values], dtype=np.float32)
        +        self.analog_group.set_array('SCALE', 'Analog channel scale factors', data)
        +    elif values is None:
        +        self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4)
        +    else:
        +        raise ValueError('Expected iterable containing analog scale factors.')
        +
        def set_point_labels(self, labels) @@ -224,6 +1540,25 @@

        Parameters

        labels : iterable
        Set POINT:LABELS parameter entry from a set of string labels.
        +
        + +Expand source code + +
        def set_point_labels(self, labels):
        +    ''' Set point data labels.
        +
        +    Parameters
        +    ----------
        +    labels : iterable
        +        Set POINT:LABELS parameter entry from a set of string labels.
        +    '''
        +    grp = self.point_group
        +    if labels is None:
        +        grp.add_empty_array('LABELS', 'Point labels.', -1)
        +    else:
        +        label_str, label_max_size = utils.pack_labels(labels)
        +        grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels))
        +
        def set_screen_axis(self, X='+X', Y='+Y') @@ -238,6 +1573,29 @@

        Parameters

        Y : str
        Second axis string with same format as Y. Determines the second Y screen axis.
        +
        + +Expand source code + +
        def set_screen_axis(self, X='+X', Y='+Y'):
        +    ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group.
        +
        +    Parameters
        +    ----------
        +    X : str
        +        Two byte string with first character indicating positive or negative axis (+/-),
        +        and the second axis (X/Y/Z). Example strings '+X' or '-Y'
        +    Y : str
        +        Second axis string with same format as Y. Determines the second Y screen axis.
        +    '''
        +    if len(X) != 2:
        +        raise ValueError('Expected string literal to be a 2 character string for the X_SCREEN parameter.')
        +    if len(Y) != 2:
        +        raise ValueError('Expected string literal to be a 2 character string for the Y_SCREEN parameter.')
        +    group = self.point_group
        +    group.set_str('X_SCREEN', 'X_SCREEN parameter', X)
        +    group.set_str('Y_SCREEN', 'Y_SCREEN parameter', Y)
        +
        def set_start_frame(self, frame=1) @@ -250,6 +1608,25 @@

        Parameters

        Number for the first frame recorded in the file. Frame counter for a trial recording always start at 1 for the first frame.
        +
        + +Expand source code + +
        def set_start_frame(self, frame=1):
        +    ''' Set the 'TRIAL:ACTUAL_START_FIELD' parameter and header.first_frame entry.
        +
        +    Parameters
        +    ----------
        +    frame : int
        +        Number for the first frame recorded in the file.
        +        Frame counter for a trial recording always start at 1 for the first frame.
        +    '''
        +    self.trial_group.set('ACTUAL_START_FIELD', 'Actual start frame', 2, '<I', frame, 2)
        +    if frame < 65535:
        +        self._header.first_frame = np.uint16(frame)
        +    else:
        +        self._header.first_frame = np.uint16(65535)
        +
        def write(self, handle) @@ -262,6 +1639,82 @@

        Parameters

        Write metadata and C3D motion frames to the given file handle. The writer does not close the handle.
        +
        + +Expand source code + +
        def write(self, handle):
        +    '''Write metadata, point and analog frames to a file handle.
        +
        +    Parameters
        +    ----------
        +    handle : file
        +        Write metadata and C3D motion frames to the given file handle. The
        +        writer does not close the handle.
        +    '''
        +    if not self._frames:
        +        raise RuntimeError('Attempted to write empty file.')
        +
        +    points, analog = self._frames[0]
        +    ppf = len(points)
        +    apf = len(analog)
        +
        +
        +    first_frame = self.first_frame
        +    if first_frame <= 0: # Bad value
        +        first_frame = 1
        +    nframes = len(self._frames)
        +    last_frame = first_frame + nframes - 1
        +
        +    UINT16_MAX = 65535
        +
        +    # POINT group
        +    group = self.point_group
        +    group.set('USED', 'Number of point samples', 2, '<H', ppf)
        +    group.set('FRAMES', 'Total frame count', 2, '<H', min(UINT16_MAX, nframes))
        +    if nframes >= UINT16_MAX:
        +        # Should be floating point
        +        group.set('LONG_FRAMES', 'Total frame count', 4, '<f', nframes)
        +    elif 'LONG_FRAMES' in group:
        +        # Docs states it should not exist if frame_count < 65535
        +        group.remove_param('LONG_FRAMES')
        +    group.set('DATA_START', 'First data block containing frame samples.', 2, '<H', 0)
        +    group.set('SCALE', 'Point data scaling factor', 4, '<f', self.point_scale)
        +    group.set('RATE', 'Point data sample rate', 4, '<f', self.point_rate)
        +    # Optional
        +    if 'UNITS' not in group:
        +        group.add_str('UNITS', 'Units used for point data measurements.', 'mm')
        +    if 'DESCRIPTIONS' not in group:
        +        group.add_str('DESCRIPTIONS', 'Channel descriptions.', '  ' * ppf, 2, ppf)
        +
        +    # ANALOG group
        +    group = self.analog_group
        +    group.set('USED', 'Analog channel count', 2, '<H', apf)
        +    group.set('RATE', 'Analog samples per second', 4, '<f', self.analog_rate)
        +    if 'GEN_SCALE' not in group:
        +        self.set_analog_general_scale(1.0)
        +    # Optional
        +    if 'SCALE' not in group:
        +        self.set_analog_scales(None)
        +    if 'OFFSET' not in group:
        +        self.set_analog_offsets(None)
        +    if 'DESCRIPTIONS' not in group:
        +        group.add_str('DESCRIPTIONS', 'Channel descriptions.', '  ' * apf, 2, apf)
        +
        +    # TRIAL group
        +    self.set_start_frame(first_frame)
        +    self._set_last_frame(last_frame)
        +
        +    # sync parameter information to header.
        +    start_block = self.parameter_blocks() + 2
        +    self.get('POINT:DATA_START').bytes = struct.pack('<H', start_block)
        +    self._header.data_block = np.uint16(start_block)
        +    self._header.point_count = np.uint16(ppf)
        +    self._header.analog_count = np.uint16(np.prod(np.shape(analog)))
        +
        +    self._write_metadata(handle)
        +    self._write_frames(handle)
        +

        Inherited members

        diff --git a/docs/examples.md b/docs/examples.md index d9d63e4..274f517 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -2,7 +2,7 @@ Examples ======== Access to data blocks in a .c3d file is provided through the `c3d.reader.Reader` and `c3d.writer.Writer` classes. -Runnable implementation of the examples are provided in the [examples/] directory in the github repository. +Implementation of the examples below are provided in the [examples/] directory in the github repository. [examples/]: https://github.com/EmbodiedCognition/py-c3d/tree/master/examples @@ -61,10 +61,11 @@ Editing ------- Editing c3d files is possible by combining the use of `c3d.reader.Reader` and `c3d.writer.Writer` -instances through the use of `c3d.reader.Reader.to_writer`. Opening a .c3d file stream through -a reader instance, `c3d.reader.Reader.to_writer` can be used to create an independent Writer instance -containing a heap copy of the file contents. Rereading the data frames from the file -and inserting into the writer in reverse, a looped version of the .c3d file can be created! +instances through the `c3d.reader.Reader.to_writer` method. To edit a file, open a file stream and pass +it to a `c3d.reader.Reader` instance. Convert it using the `c3d.reader.Reader.to_writer` method to create +an independent `c3d.writer.Writer` instance containing a heap copy of the file contents. +Rereading the data frames from the reader and inserting them in reverse into +the writer will then create a looped version of the .c3d file! import c3d @@ -76,3 +77,48 @@ and inserting into the writer in reverse, a looped version of the .c3d file can with open('my-looped-motion.c3d', 'wb') as h: writer.write(h) + + +Accessing metadata +---------------- + +Metadata in a .c3d file exists in two forms, a `c3d.header.Header` and `c3d.parameter.ParamData`. +Reading metadata fields can be done though the `c3d.reader.Reader` but editing requires a +`c3d.writer.Writer` instance. + +`c3d.header.Header` fields can be accessed from the common `c3d.manager.Manager.header` attribute. +Parameters are available through a parameter `c3d.group.Group`, and can be accessed +through the `c3d.manager.Manager.get` method: + + group = reader.get('POINT') + param = group.get('LABELS') + +or use the simpler method + + param = reader.get('POINT:LABELS') + +Note that for accessing parameters in the `c3d.reader.Reader`, `c3d.reader.Reader.get` +returns a `c3d.group.GroupReadonly` instance. Convenience functions are provided +for some of the common metadata fields +such as `c3d.manager.Manager.frame_count`. In the case you require specific +metadata fields, consider visiting the documentation for the [C3D format] and/or inspect +the file using the c3d-metadata script. + +[C3D format]: https://c3d.org/docs/C3D_User_Guide.pdf + +Writing metadata +---------------- + +Once a `c3d.writer.Writer` instance is created, to edit +parameter data `c3d.writer.Writer.get_create` a group: + + group = writer.get_create('ANALOG') + +and to write a float32 entry, use the `c3d.group.Group.add` or `c3d.group.Group.set` functions + + group.set('GEN_SCALE', 'Analog general scale factor', 4, ' Date: Sun, 4 Jul 2021 22:07:13 +0200 Subject: [PATCH 087/120] pycodestyle ./c3d --max-line-length 120 --- c3d/dtypes.py | 1 + c3d/group.py | 4 ++++ c3d/header.py | 7 ++++--- c3d/manager.py | 7 +++---- c3d/parameter.py | 3 +++ c3d/reader.py | 3 +-- c3d/utils.py | 6 ++++++ c3d/writer.py | 19 ++++++++++--------- 8 files changed, 32 insertions(+), 18 deletions(-) diff --git a/c3d/dtypes.py b/c3d/dtypes.py index 9071d0d..193615e 100644 --- a/c3d/dtypes.py +++ b/c3d/dtypes.py @@ -10,6 +10,7 @@ PROCESSOR_DEC = 85 PROCESSOR_MIPS = 86 + class DataTypes(object): ''' Container defining the data types used when parsing byte data. Data types depend on the processor format the file is stored in. diff --git a/c3d/group.py b/c3d/group.py index 46b539f..853e9ec 100644 --- a/c3d/group.py +++ b/c3d/group.py @@ -5,6 +5,7 @@ from .parameter import ParamData, Param from .utils import Decorator + class GroupData(object): '''A group of parameters stored in a C3D file. @@ -147,6 +148,7 @@ def write(self, group_id, handle): for param in self._params.values(): param._data.write(group_id, handle) + class GroupReadonly(object): ''' Wrapper exposing readonly attributes of a `c3d.group.GroupData` entry. ''' @@ -237,6 +239,7 @@ def get_string(self, key): '''Get the value of the given parameter as a string.''' return self._data[key.upper()].string_value + class Group(GroupReadonly): ''' Wrapper exposing readable and writeable attributes of a `c3d.group.GroupData` entry. ''' @@ -289,6 +292,7 @@ def get(self, key, default=None): A parameter from the current group. ''' return self._data._params.get(key, default) + # # Forward param editing # diff --git a/c3d/header.py b/c3d/header.py index 25a3718..29ac894 100644 --- a/c3d/header.py +++ b/c3d/header.py @@ -6,6 +6,7 @@ import numpy as np from .utils import UNPACK_FLOAT_IEEE, DEC_to_IEEE + class Header(object): '''Header information from a C3D file. @@ -260,7 +261,7 @@ def encode_events(self, events): str(18) + 's', # Flags 'H', # __ str(18 * 4) + 's' # Labels - ) + ) # Pack bytes event_timings = np.zeros(18, dtype=np.float32) event_disp_flags = np.zeros(18, dtype=np.uint8) @@ -280,7 +281,7 @@ def encode_events(self, events): event_disp_flags[:write_count] = 1 # Update event headers in self - self.long_event_labels = 0x3039 # Magic number + self.long_event_labels = 0x3039 # Magic number self.event_count = write_count # Update event block self.event_timings = event_timings[:write_count] @@ -291,4 +292,4 @@ def encode_events(self, events): event_disp_flags.tobytes(), 0, label_bytes - ) + ) diff --git a/c3d/manager.py b/c3d/manager.py index eb858a3..47ab6ef 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -118,7 +118,6 @@ def check_parameters(params): elif lab.num_elements > 0: warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries') - def _add_group(self, group_id, name=None, desc=None): '''Add a new parameter group. @@ -150,7 +149,7 @@ def _add_group(self, group_id, name=None, desc=None): raise TypeError('Expected Group name key to be string, was %s.' % type(name)) else: name = name.upper() - group_id = int(group_id) # Asserts python int + group_id = int(group_id) # Asserts python int if group_id in self._groups: raise KeyError('Group with numerical key {} already exists'.format(group_id)) if name in self._groups: @@ -206,7 +205,7 @@ def _rename_group(self, group_id, new_group_id): del self._groups[grp.name] grp.name = new_group_id elif is_integer(new_group_id): - new_group_id = int(new_group_id) # Ensure python int + new_group_id = int(new_group_id) # Ensure python int del self._groups[group_id] else: raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) @@ -356,7 +355,7 @@ def last_frame(self) -> int: # Encoded as 2 16 bit words (rather then 1 32 bit word) words = param.uint16_array end_frame[1] = words[0] + words[1] * 65536 - #end_frame[1] = param.uint32_value + # end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: # Encoded as float diff --git a/c3d/parameter.py b/c3d/parameter.py index 542d6c6..9e2e0f1 100644 --- a/c3d/parameter.py +++ b/c3d/parameter.py @@ -4,6 +4,7 @@ import numpy as np from .utils import DEC_to_IEEE, DEC_to_IEEE_BYTES + class ParamData(object): '''A class representing a single named parameter from a C3D file. @@ -147,6 +148,7 @@ def _as_any(self, dtype): return data.flatten() return data + class ParamReadonly(object): ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry. ''' @@ -431,6 +433,7 @@ def _as_integer_or_float_value(self): else: return self.uint8_value + class Param(ParamReadonly): ''' Wrapper exposing both readable and writable attributes of a `c3d.parameter.ParamData` entry. ''' diff --git a/c3d/reader.py b/c3d/reader.py index 5a94992..0668673 100644 --- a/c3d/reader.py +++ b/c3d/reader.py @@ -245,13 +245,12 @@ def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_s # Update discarded - sign points[invalid, 3] = -1 - # Fifth value is the camera-observation byte if camera_sum: # Convert to observation sum points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7)) else: - points[:, 4] = camera_byte #.astype(np.float32) + points[:, 4] = camera_byte # .astype(np.float32) # Check if analog data exist, and parse if so if N_analog > 0: diff --git a/c3d/utils.py b/c3d/utils.py index 8f81e9a..63f684e 100644 --- a/c3d/utils.py +++ b/c3d/utils.py @@ -3,6 +3,7 @@ import numpy as np import struct + def is_integer(value): '''Check if value input is integer.''' return isinstance(value, (int, np.int32, np.int64)) @@ -53,24 +54,29 @@ def pack_labels(labels): label_str = ''.join(label.ljust(label_max_size) for label in labels) return label_str, label_max_size + class Decorator(object): '''Base class for extending (decorating) a python object. ''' def __init__(self, decoratee): self._decoratee = decoratee + def __getattr__(self, name): return getattr(self._decoratee, name) + def UNPACK_FLOAT_IEEE(uint_32): '''Unpacks a single 32 bit unsigned int to a IEEE float representation ''' return struct.unpack('f', struct.pack("I", uint_32))[0] + def DEC_to_IEEE(uint_32): '''Convert the 32 bit representation of a DEC float to IEEE format. diff --git a/c3d/writer.py b/c3d/writer.py index 3a76a22..5f6898f 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -3,11 +3,12 @@ import copy import numpy as np import struct -#import warnings +# import warnings from . import utils from .manager import Manager from .dtypes import DataTypes + class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -42,7 +43,7 @@ def __init__(self, '''Set minimal metadata for this writer. ''' - self._dtypes = DataTypes() # Only support INTEL format from writing + self._dtypes = DataTypes() # Only support INTEL format from writing super(Writer, self).__init__() # Header properties @@ -99,12 +100,14 @@ def from_reader(reader, conversion=None): if not (is_consume or is_shallow_copy or is_deep_copy): raise ValueError( "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format( - conversion)) + conversion + )) if not reader._dtypes.is_ieee and not is_shallow_copy: # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. raise ValueError( "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( - reader._dtypes.proc_type)) + reader._dtypes.proc_type + )) if is_consume: writer._header = reader._header @@ -232,12 +235,12 @@ def add_frames(self, frames, index=None): if np.shape(f[0]) != psh: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(psh), str(np.shape(f[0])) + str(psh), str(np.shape(f[0])) )) if np.shape(f[1]) != ash: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(ash), str(np.shape(f[1])) + str(ash), str(np.shape(f[1])) )) # Sequence of invalid shape @@ -339,7 +342,6 @@ def _set_last_frame(self, frame): self.trial_group.set('ACTUAL_END_FIELD', 'Actual end frame', 2, ' Date: Sun, 4 Jul 2021 22:11:21 +0200 Subject: [PATCH 088/120] Code style --- c3d/README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 c3d/README.rst diff --git a/c3d/README.rst b/c3d/README.rst new file mode 100644 index 0000000..d912137 --- /dev/null +++ b/c3d/README.rst @@ -0,0 +1,10 @@ +Code style +---------- + +Use pycodestyle with the settings + + pycodestyle . --max-line-length 120 + +Pycodestyle can be installed from pip: + + pip install pycodestyle From 9afe8d5f42efbefd9c54e9846ebe774d1f8ae627 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 22:12:18 +0200 Subject: [PATCH 089/120] pip command --- c3d/README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/c3d/README.rst b/c3d/README.rst index d912137..6e39c5e 100644 --- a/c3d/README.rst +++ b/c3d/README.rst @@ -5,6 +5,6 @@ Use pycodestyle with the settings pycodestyle . --max-line-length 120 -Pycodestyle can be installed from pip: +Pycodestyle can be installed with the pip command pip install pycodestyle From d0ed984d671e39ef65b2e47820ee2dd2984b42ad Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 22:13:40 +0200 Subject: [PATCH 090/120] Update README.rst --config show_source_code=True --- docs/README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/README.rst b/docs/README.rst index 2003a97..f484e1e 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -1,7 +1,7 @@ Reading the docs ----------------- -Documentation is available at: https://mattiasfredriksson.github.io/py-c3d/c3d/ +Online documentation can be accessed at: https://mattiasfredriksson.github.io/py-c3d/c3d/ Building the docs ----------------- @@ -13,7 +13,7 @@ Building the docs requires the pdoc3 package:: Once installed, documentation can be updated from the root directory with the command:: - pdoc --html c3d --force --config show_source_code=False --output-dir docs + pdoc --html c3d --force --config show_source_code=True --output-dir docs Once updated you can access the documentation in the `docs/c3d/`_ folder. From 7a6d36a8b07cca4d8ee5edfb0b56f152f8b85dba Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 22:14:24 +0200 Subject: [PATCH 091/120] Pycodestyles doc update --- docs/c3d/dtypes.html | 1 + docs/c3d/group.html | 5 +++++ docs/c3d/header.html | 19 ++++++++-------- docs/c3d/manager.html | 16 ++++++-------- docs/c3d/parameter.html | 3 +++ docs/c3d/reader.html | 9 +++----- docs/c3d/utils.html | 7 ++++++ docs/c3d/writer.html | 48 +++++++++++++++++++++-------------------- 8 files changed, 61 insertions(+), 47 deletions(-) diff --git a/docs/c3d/dtypes.html b/docs/c3d/dtypes.html index 81ca407..c9473d4 100644 --- a/docs/c3d/dtypes.html +++ b/docs/c3d/dtypes.html @@ -39,6 +39,7 @@

        Module c3d.dtypes

        PROCESSOR_DEC = 85 PROCESSOR_MIPS = 86 + class DataTypes(object): ''' Container defining the data types used when parsing byte data. Data types depend on the processor format the file is stored in. diff --git a/docs/c3d/group.html b/docs/c3d/group.html index 833dd60..bce40b9 100644 --- a/docs/c3d/group.html +++ b/docs/c3d/group.html @@ -34,6 +34,7 @@

        Module c3d.group

        from .parameter import ParamData, Param from .utils import Decorator + class GroupData(object): '''A group of parameters stored in a C3D file. @@ -176,6 +177,7 @@

        Module c3d.group

        for param in self._params.values(): param._data.write(group_id, handle) + class GroupReadonly(object): ''' Wrapper exposing readonly attributes of a `c3d.group.GroupData` entry. ''' @@ -266,6 +268,7 @@

        Module c3d.group

        '''Get the value of the given parameter as a string.''' return self._data[key.upper()].string_value + class Group(GroupReadonly): ''' Wrapper exposing readable and writeable attributes of a `c3d.group.GroupData` entry. ''' @@ -318,6 +321,7 @@

        Module c3d.group

        A parameter from the current group. ''' return self._data._params.get(key, default) + # # Forward param editing # @@ -562,6 +566,7 @@

        Classes

        A parameter from the current group. ''' return self._data._params.get(key, default) + # # Forward param editing # diff --git a/docs/c3d/header.html b/docs/c3d/header.html index dd42c47..95ee820 100644 --- a/docs/c3d/header.html +++ b/docs/c3d/header.html @@ -35,6 +35,7 @@

        Module c3d.header

        import numpy as np from .utils import UNPACK_FLOAT_IEEE, DEC_to_IEEE + class Header(object): '''Header information from a C3D file. @@ -289,7 +290,7 @@

        Module c3d.header

        str(18) + 's', # Flags 'H', # __ str(18 * 4) + 's' # Labels - ) + ) # Pack bytes event_timings = np.zeros(18, dtype=np.float32) event_disp_flags = np.zeros(18, dtype=np.uint8) @@ -309,7 +310,7 @@

        Module c3d.header

        event_disp_flags[:write_count] = 1 # Update event headers in self - self.long_event_labels = 0x3039 # Magic number + self.long_event_labels = 0x3039 # Magic number self.event_count = write_count # Update event block self.event_timings = event_timings[:write_count] @@ -320,7 +321,7 @@

        Module c3d.header

        event_disp_flags.tobytes(), 0, label_bytes - )
    + )
    @@ -645,7 +646,7 @@

    Parameters

    str(18) + 's', # Flags 'H', # __ str(18 * 4) + 's' # Labels - ) + ) # Pack bytes event_timings = np.zeros(18, dtype=np.float32) event_disp_flags = np.zeros(18, dtype=np.uint8) @@ -665,7 +666,7 @@

    Parameters

    event_disp_flags[:write_count] = 1 # Update event headers in self - self.long_event_labels = 0x3039 # Magic number + self.long_event_labels = 0x3039 # Magic number self.event_count = write_count # Update event block self.event_timings = event_timings[:write_count] @@ -676,7 +677,7 @@

    Parameters

    event_disp_flags.tobytes(), 0, label_bytes - )
    + )

    Class variables

    @@ -755,7 +756,7 @@

    Parameters

    str(18) + 's', # Flags 'H', # __ str(18 * 4) + 's' # Labels - ) + ) # Pack bytes event_timings = np.zeros(18, dtype=np.float32) event_disp_flags = np.zeros(18, dtype=np.uint8) @@ -775,7 +776,7 @@

    Parameters

    event_disp_flags[:write_count] = 1 # Update event headers in self - self.long_event_labels = 0x3039 # Magic number + self.long_event_labels = 0x3039 # Magic number self.event_count = write_count # Update event block self.event_timings = event_timings[:write_count] @@ -786,7 +787,7 @@

    Parameters

    event_disp_flags.tobytes(), 0, label_bytes - ) + )
    diff --git a/docs/c3d/manager.html b/docs/c3d/manager.html index 41cfcbe..bd64c3b 100644 --- a/docs/c3d/manager.html +++ b/docs/c3d/manager.html @@ -147,7 +147,6 @@

    Module c3d.manager

    elif lab.num_elements > 0: warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries') - def _add_group(self, group_id, name=None, desc=None): '''Add a new parameter group. @@ -179,7 +178,7 @@

    Module c3d.manager

    raise TypeError('Expected Group name key to be string, was %s.' % type(name)) else: name = name.upper() - group_id = int(group_id) # Asserts python int + group_id = int(group_id) # Asserts python int if group_id in self._groups: raise KeyError('Group with numerical key {} already exists'.format(group_id)) if name in self._groups: @@ -235,7 +234,7 @@

    Module c3d.manager

    del self._groups[grp.name] grp.name = new_group_id elif is_integer(new_group_id): - new_group_id = int(new_group_id) # Ensure python int + new_group_id = int(new_group_id) # Ensure python int del self._groups[group_id] else: raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) @@ -385,7 +384,7 @@

    Module c3d.manager

    # Encoded as 2 16 bit words (rather then 1 32 bit word) words = param.uint16_array end_frame[1] = words[0] + words[1] * 65536 - #end_frame[1] = param.uint32_value + # end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: # Encoded as float @@ -619,7 +618,6 @@

    Attributes

    elif lab.num_elements > 0: warnings.warn('No analog data found in file, but file contains ANALOG:LABELS entries') - def _add_group(self, group_id, name=None, desc=None): '''Add a new parameter group. @@ -651,7 +649,7 @@

    Attributes

    raise TypeError('Expected Group name key to be string, was %s.' % type(name)) else: name = name.upper() - group_id = int(group_id) # Asserts python int + group_id = int(group_id) # Asserts python int if group_id in self._groups: raise KeyError('Group with numerical key {} already exists'.format(group_id)) if name in self._groups: @@ -707,7 +705,7 @@

    Attributes

    del self._groups[grp.name] grp.name = new_group_id elif is_integer(new_group_id): - new_group_id = int(new_group_id) # Ensure python int + new_group_id = int(new_group_id) # Ensure python int del self._groups[group_id] else: raise KeyError('Invalid group identifier of type: %s' % str(type(new_group_id))) @@ -857,7 +855,7 @@

    Attributes

    # Encoded as 2 16 bit words (rather then 1 32 bit word) words = param.uint16_array end_frame[1] = words[0] + words[1] * 65536 - #end_frame[1] = param.uint32_value + # end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: # Encoded as float @@ -1100,7 +1098,7 @@

    Instance variables

    # Encoded as 2 16 bit words (rather then 1 32 bit word) words = param.uint16_array end_frame[1] = words[0] + words[1] * 65536 - #end_frame[1] = param.uint32_value + # end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: # Encoded as float diff --git a/docs/c3d/parameter.html b/docs/c3d/parameter.html index f32c8ab..8a28333 100644 --- a/docs/c3d/parameter.html +++ b/docs/c3d/parameter.html @@ -33,6 +33,7 @@

    Module c3d.parameter

    import numpy as np from .utils import DEC_to_IEEE, DEC_to_IEEE_BYTES + class ParamData(object): '''A class representing a single named parameter from a C3D file. @@ -176,6 +177,7 @@

    Module c3d.parameter

    return data.flatten() return data + class ParamReadonly(object): ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry. ''' @@ -460,6 +462,7 @@

    Module c3d.parameter

    else: return self.uint8_value + class Param(ParamReadonly): ''' Wrapper exposing both readable and writable attributes of a `c3d.parameter.ParamData` entry. ''' diff --git a/docs/c3d/reader.html b/docs/c3d/reader.html index 0b868b0..de72e63 100644 --- a/docs/c3d/reader.html +++ b/docs/c3d/reader.html @@ -274,13 +274,12 @@

    Module c3d.reader

    # Update discarded - sign points[invalid, 3] = -1 - # Fifth value is the camera-observation byte if camera_sum: # Convert to observation sum points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7)) else: - points[:, 4] = camera_byte #.astype(np.float32) + points[:, 4] = camera_byte # .astype(np.float32) # Check if analog data exist, and parse if so if N_analog > 0: @@ -641,13 +640,12 @@

    Raises

    # Update discarded - sign points[invalid, 3] = -1 - # Fifth value is the camera-observation byte if camera_sum: # Convert to observation sum points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7)) else: - points[:, 4] = camera_byte #.astype(np.float32) + points[:, 4] = camera_byte # .astype(np.float32) # Check if analog data exist, and parse if so if N_analog > 0: @@ -1024,13 +1022,12 @@

    Returns

    # Update discarded - sign points[invalid, 3] = -1 - # Fifth value is the camera-observation byte if camera_sum: # Convert to observation sum points[:, 4] = sum((camera_byte & (1 << k)) >> k for k in range(7)) else: - points[:, 4] = camera_byte #.astype(np.float32) + points[:, 4] = camera_byte # .astype(np.float32) # Check if analog data exist, and parse if so if N_analog > 0: diff --git a/docs/c3d/utils.html b/docs/c3d/utils.html index 466598c..dff3183 100644 --- a/docs/c3d/utils.html +++ b/docs/c3d/utils.html @@ -32,6 +32,7 @@

    Module c3d.utils

    import numpy as np import struct + def is_integer(value): '''Check if value input is integer.''' return isinstance(value, (int, np.int32, np.int64)) @@ -82,24 +83,29 @@

    Module c3d.utils

    label_str = ''.join(label.ljust(label_max_size) for label in labels) return label_str, label_max_size + class Decorator(object): '''Base class for extending (decorating) a python object. ''' def __init__(self, decoratee): self._decoratee = decoratee + def __getattr__(self, name): return getattr(self._decoratee, name) + def UNPACK_FLOAT_IEEE(uint_32): '''Unpacks a single 32 bit unsigned int to a IEEE float representation ''' return struct.unpack('f', struct.pack("<I", uint_32))[0] + def UNPACK_FLOAT_MIPS(uint_32): '''Unpacks a single 32 bit unsigned int to a IEEE float representation ''' return struct.unpack('f', struct.pack(">I", uint_32))[0] + def DEC_to_IEEE(uint_32): '''Convert the 32 bit representation of a DEC float to IEEE format. @@ -445,6 +451,7 @@

    Classes

    ''' def __init__(self, decoratee): self._decoratee = decoratee + def __getattr__(self, name): return getattr(self._decoratee, name)
    diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html index 3ffe26f..35d06ae 100644 --- a/docs/c3d/writer.html +++ b/docs/c3d/writer.html @@ -32,11 +32,12 @@

    Module c3d.writer

    import copy import numpy as np import struct -#import warnings +# import warnings from . import utils from .manager import Manager from .dtypes import DataTypes + class Writer(Manager): '''This class writes metadata and frames to a C3D file. @@ -71,7 +72,7 @@

    Module c3d.writer

    '''Set minimal metadata for this writer. ''' - self._dtypes = DataTypes() # Only support INTEL format from writing + self._dtypes = DataTypes() # Only support INTEL format from writing super(Writer, self).__init__() # Header properties @@ -128,12 +129,14 @@

    Module c3d.writer

    if not (is_consume or is_shallow_copy or is_deep_copy): raise ValueError( "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format( - conversion)) + conversion + )) if not reader._dtypes.is_ieee and not is_shallow_copy: # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. raise ValueError( "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( - reader._dtypes.proc_type)) + reader._dtypes.proc_type + )) if is_consume: writer._header = reader._header @@ -261,12 +264,12 @@

    Module c3d.writer

    if np.shape(f[0]) != psh: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(psh), str(np.shape(f[0])) + str(psh), str(np.shape(f[0])) )) if np.shape(f[1]) != ash: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(ash), str(np.shape(f[1])) + str(ash), str(np.shape(f[1])) )) # Sequence of invalid shape @@ -368,7 +371,6 @@

    Module c3d.writer

    self.trial_group.set('ACTUAL_END_FIELD', 'Actual end frame', 2, '<I', frame, 2) self._header.last_frame = np.uint16(min(frame, 65535)) - def set_screen_axis(self, X='+X', Y='+Y'): ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. @@ -404,9 +406,8 @@

    Module c3d.writer

    ppf = len(points) apf = len(analog) - first_frame = self.first_frame - if first_frame <= 0: # Bad value + if first_frame <= 0: # Bad value first_frame = 1 nframes = len(self._frames) last_frame = first_frame + nframes - 1 @@ -613,7 +614,7 @@

    Parameters

    '''Set minimal metadata for this writer. ''' - self._dtypes = DataTypes() # Only support INTEL format from writing + self._dtypes = DataTypes() # Only support INTEL format from writing super(Writer, self).__init__() # Header properties @@ -670,12 +671,14 @@

    Parameters

    if not (is_consume or is_shallow_copy or is_deep_copy): raise ValueError( "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format( - conversion)) + conversion + )) if not reader._dtypes.is_ieee and not is_shallow_copy: # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. raise ValueError( "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( - reader._dtypes.proc_type)) + reader._dtypes.proc_type + )) if is_consume: writer._header = reader._header @@ -803,12 +806,12 @@

    Parameters

    if np.shape(f[0]) != psh: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(psh), str(np.shape(f[0])) + str(psh), str(np.shape(f[0])) )) if np.shape(f[1]) != ash: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(ash), str(np.shape(f[1])) + str(ash), str(np.shape(f[1])) )) # Sequence of invalid shape @@ -910,7 +913,6 @@

    Parameters

    self.trial_group.set('ACTUAL_END_FIELD', 'Actual end frame', 2, '<I', frame, 2) self._header.last_frame = np.uint16(min(frame, 65535)) - def set_screen_axis(self, X='+X', Y='+Y'): ''' Set the X_SCREEN and Y_SCREEN parameters in the POINT group. @@ -946,9 +948,8 @@

    Parameters

    ppf = len(points) apf = len(analog) - first_frame = self.first_frame - if first_frame <= 0: # Bad value + if first_frame <= 0: # Bad value first_frame = 1 nframes = len(self._frames) last_frame = first_frame + nframes - 1 @@ -1171,12 +1172,14 @@

    Raises

    if not (is_consume or is_shallow_copy or is_deep_copy): raise ValueError( "Unknown mode argument %s. Supported modes are: 'consume', 'copy', or 'shallow_copy'".format( - conversion)) + conversion + )) if not reader._dtypes.is_ieee and not is_shallow_copy: # Can't copy/consume non-Intel files due to the uncertainty of converting parameter data. raise ValueError( "File was read in %s format and only 'shallow_copy' mode is supported for non Intel files!".format( - reader._dtypes.proc_type)) + reader._dtypes.proc_type + )) if is_consume: writer._header = reader._header @@ -1332,12 +1335,12 @@

    Parameters

    if np.shape(f[0]) != psh: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(psh), str(np.shape(f[0])) + str(psh), str(np.shape(f[0])) )) if np.shape(f[1]) != ash: raise ValueError( 'Shape of analog data does not previous frames. Expexted shape {}, was {}.'.format( - str(ash), str(np.shape(f[1])) + str(ash), str(np.shape(f[1])) )) # Sequence of invalid shape @@ -1659,9 +1662,8 @@

    Parameters

    ppf = len(points) apf = len(analog) - first_frame = self.first_frame - if first_frame <= 0: # Bad value + if first_frame <= 0: # Bad value first_frame = 1 nframes = len(self._frames) last_frame = first_frame + nframes - 1 From 67598431dace48dd98932d35de93d959d5b86cd0 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 22:16:08 +0200 Subject: [PATCH 092/120] Update README.rst --- README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.rst b/README.rst index 0af68d9..cc3117c 100644 --- a/README.rst +++ b/README.rst @@ -59,8 +59,8 @@ Caveats The package is tested against the `software examples`_ but may still not support every possible format. For example, parameters serialized in multiple parameters -are not handled automatically (such as POINT:LABELS and POINT:LABELS2). Reading and -writing files from a big-endian system is also not supported. +are not handled automatically (such as a LABELS field stored in both POINT:LABELS and +POINT:LABELS2). Reading and writing files from a big-endian system is also not supported. Tests are currently only run on Windows, which means that Linux and Mac users may experience some issues. If you experience issues with a file or feature, feel free From dd5a627c0e4246d0e5da4a3dceda71a2cb92c777 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 23:22:16 +0200 Subject: [PATCH 093/120] Update README.rst --- c3d/README.rst | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/c3d/README.rst b/c3d/README.rst index 6e39c5e..a0c6c4a 100644 --- a/c3d/README.rst +++ b/c3d/README.rst @@ -8,3 +8,47 @@ Use pycodestyle with the settings Pycodestyle can be installed with the pip command pip install pycodestyle + +Docstrings +----------- + +Use `numpy style`_ doc strings + +.. _numpy style: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard + +Docstring example +----------- + + def my_function(aparam, bparam): + ''' Short summary. + + Description, classes can be referred to through package/module `c3d.reader.Reader`. + Methods can be referred to in the same way e.g. `c3d.reader.Reader.read_frames`. + + Parameters + ---------- + aparam : `c3d.reader.Reader` + This is the first input parameter. + bparam : str + The second parameter should be a string. + + Returns + ------- + aarg : `c3d.writer.Writer` + First return argument is a Writer instance. + barg : int + Second return argument is of type int. + + Raises + ------ + ValueError + It occurs for some reason. + ''' + + ... + some_code + ... + + return aarg, barg + + From c697676d3473409d2926268281ff433fc0f96f4f Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 23:23:56 +0200 Subject: [PATCH 094/120] Docstrings --- c3d/writer.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/c3d/writer.py b/c3d/writer.py index 5f6898f..6b6edf5 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -54,12 +54,12 @@ def __init__(self, @staticmethod def from_reader(reader, conversion=None): - ''' + ''' Convert a `c3d.reader.Reader` to a persistent `c3d.writer.Writer` instance. Parameters ---------- - source : `c3d.manager.Manager` - Source to copy. + source : `c3d.reader.Reader` + Source to copy data from. conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: @@ -79,8 +79,8 @@ def from_reader(reader, conversion=None): Returns ------- - param : :class:`Writer` - A writeable and persistent representation of the 'Reader' object. + param : `c3d.writer.Writer` + A writeable and persistent representation of the `c3d.reader.Reader` object. Raises ------ From ebb119a824e074e968859c43cb25a8a011f77080 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 23:25:08 +0200 Subject: [PATCH 095/120] Test readme.md --- c3d/{README.rst => README.md} | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) rename c3d/{README.rst => README.md} (86%) diff --git a/c3d/README.rst b/c3d/README.md similarity index 86% rename from c3d/README.rst rename to c3d/README.md index a0c6c4a..4554f88 100644 --- a/c3d/README.rst +++ b/c3d/README.md @@ -8,20 +8,20 @@ Use pycodestyle with the settings Pycodestyle can be installed with the pip command pip install pycodestyle - + Docstrings ----------- -Use `numpy style`_ doc strings +Use [numpy style] doc strings -.. _numpy style: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard +[numpy style] https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard Docstring example ----------- def my_function(aparam, bparam): ''' Short summary. - + Description, classes can be referred to through package/module `c3d.reader.Reader`. Methods can be referred to in the same way e.g. `c3d.reader.Reader.read_frames`. @@ -44,11 +44,9 @@ Docstring example ValueError It occurs for some reason. ''' - + ... some_code ... - + return aarg, barg - - From 24f5005b2f22aac8b2d3acda99faccba57ee8831 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 23:27:21 +0200 Subject: [PATCH 096/120] .md worked --- c3d/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c3d/README.md b/c3d/README.md index 4554f88..05c168c 100644 --- a/c3d/README.md +++ b/c3d/README.md @@ -12,9 +12,9 @@ Pycodestyle can be installed with the pip command Docstrings ----------- -Use [numpy style] doc strings +Use [numpy style] doc strings. -[numpy style] https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard +[numpy style]: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard Docstring example ----------- From 5f7120aca08eab876d6c6f8aeddb855752824893 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 23:30:01 +0200 Subject: [PATCH 097/120] Updated docs --- docs/c3d/writer.html | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html index 35d06ae..9a5dc36 100644 --- a/docs/c3d/writer.html +++ b/docs/c3d/writer.html @@ -83,12 +83,12 @@

    Module c3d.writer

    @staticmethod def from_reader(reader, conversion=None): - ''' + ''' Convert a `c3d.reader.Reader` to a persistent `c3d.writer.Writer` instance. Parameters ---------- - source : `c3d.manager.Manager` - Source to copy. + source : `c3d.reader.Reader` + Source to copy data from. conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: @@ -108,8 +108,8 @@

    Module c3d.writer

    Returns ------- - param : :class:`Writer` - A writeable and persistent representation of the 'Reader' object. + param : `c3d.writer.Writer` + A writeable and persistent representation of the `c3d.reader.Reader` object. Raises ------ @@ -625,12 +625,12 @@

    Parameters

    @staticmethod def from_reader(reader, conversion=None): - ''' + ''' Convert a `c3d.reader.Reader` to a persistent `c3d.writer.Writer` instance. Parameters ---------- - source : `c3d.manager.Manager` - Source to copy. + source : `c3d.reader.Reader` + Source to copy data from. conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: @@ -650,8 +650,8 @@

    Parameters

    Returns ------- - param : :class:`Writer` - A writeable and persistent representation of the 'Reader' object. + param : `c3d.writer.Writer` + A writeable and persistent representation of the `c3d.reader.Reader` object. Raises ------ @@ -1088,10 +1088,11 @@

    Static methods

    def from_reader(reader, conversion=None)
    -

    Parameters

    +

    Convert a Reader to a persistent Writer instance.

    +

    Parameters

    -
    source : Manager
    -
    Source to copy.
    +
    source : Reader
    +
    Source to copy data from.
    conversion : str
    Conversion mode, None is equivalent to the default mode. Supported modes are:
    'consume'       - (Default) Reader object will be
                       consumed and explicitly deleted.
    @@ -1111,8 +1112,8 @@ 

    Static methods

    Returns

    -
    param : :class:Writer``
    -
    A writeable and persistent representation of the 'Reader' object.
    +
    param : Writer
    +
    A writeable and persistent representation of the Reader object.

    Raises

    @@ -1126,12 +1127,12 @@

    Raises

    @staticmethod
     def from_reader(reader, conversion=None):
    -    '''
    +    ''' Convert a `c3d.reader.Reader` to a persistent `c3d.writer.Writer` instance.
     
         Parameters
         ----------
    -    source : `c3d.manager.Manager`
    -        Source to copy.
    +    source : `c3d.reader.Reader`
    +        Source to copy data from.
         conversion : str
             Conversion mode, None is equivalent to the default mode. Supported modes are:
     
    @@ -1151,8 +1152,8 @@ 

    Raises

    Returns ------- - param : :class:`Writer` - A writeable and persistent representation of the 'Reader' object. + param : `c3d.writer.Writer` + A writeable and persistent representation of the `c3d.reader.Reader` object. Raises ------ From 4faef087e20777248cfc146b96601100bffd0fe0 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 23:34:20 +0200 Subject: [PATCH 098/120] documentation -> manual --- docs/c3d/index.html | 2 +- docs/examples.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/c3d/index.html b/docs/c3d/index.html index 1b42573..caac219 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -110,7 +110,7 @@

    Accessing Metadata

    returns a GroupReadonly instance. Convenience functions are provided for some of the common metadata fields such as Manager.frame_count. In the case you require specific -metadata fields, consider visiting the documentation for the C3D format and/or inspect +metadata fields, consider exploring the C3D format manual and/or inspect the file using the c3d-metadata script.

    Writing Metadata

    Once a Writer instance is created, to edit diff --git a/docs/examples.md b/docs/examples.md index 274f517..d5a429f 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -101,10 +101,10 @@ Note that for accessing parameters in the `c3d.reader.Reader`, `c3d.reader.Reade returns a `c3d.group.GroupReadonly` instance. Convenience functions are provided for some of the common metadata fields such as `c3d.manager.Manager.frame_count`. In the case you require specific -metadata fields, consider visiting the documentation for the [C3D format] and/or inspect +metadata fields, consider exploring the [C3D format manual] and/or inspect the file using the c3d-metadata script. -[C3D format]: https://c3d.org/docs/C3D_User_Guide.pdf +[C3D format manual]: https://c3d.org/docs/C3D_User_Guide.pdf Writing metadata ---------------- From 08b8269396ef4b6967bb660dd6fe51914187e631 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 23:53:41 +0200 Subject: [PATCH 099/120] Update example documentation --- docs/c3d/index.html | 21 ++++++++++----------- docs/examples.md | 21 ++++++++++----------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/docs/c3d/index.html b/docs/c3d/index.html index caac219..380d143 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -78,10 +78,10 @@

    Writing

    Editing

    Editing c3d files is possible by combining the use of Reader and Writer instances through the Reader.to_writer() method. To edit a file, open a file stream and pass -it to a Reader instance. Convert it using the Reader.to_writer() method to create -an independent Writer instance containing a heap copy of the file contents. -Rereading the data frames from the reader and inserting them in reverse into -the writer will then create a looped version of the .c3d file!

    +it to the Reader constructor. Use the Reader.to_writer() method to create +an independent Writer instance containing a heap copy of its file contents. +Rereading data frames from the reader and inserting them in reverse will then create a +looped version of the original motion sequence!

    import c3d
     
     with open('my-motion.c3d', 'rb') as file:
    @@ -99,19 +99,18 @@ 

    Accessing Metadata

    Writer instance.

    Header fields can be accessed from the common Manager.header attribute. Parameters are available through a parameter Group, and can be accessed -through the Manager.get() method:

    +through the Manager.get() and Group.get() methods:

    group = reader.get('POINT')
     param = group.get('LABELS')
     
    -

    or use the simpler method

    +

    or simply use

    param = reader.get('POINT:LABELS')
     

    Note that for accessing parameters in the Reader, Reader.get() returns a GroupReadonly instance. Convenience functions are provided -for some of the common metadata fields -such as Manager.frame_count. In the case you require specific -metadata fields, consider exploring the C3D format manual and/or inspect -the file using the c3d-metadata script.

    +for some of the common metadata fields such as Manager.frame_count. +In the case you require specific metadata fields, consider exploring +the C3D format manual and/or inspect the file using the c3d-metadata script.

    Writing Metadata

    Once a Writer instance is created, to edit parameter data Writer.get_create() a group:

    @@ -121,7 +120,7 @@

    Writing Metadata

    group.set('GEN_SCALE', 'Analog general scale factor', 4, '<f', value)
     

    In this case, one can use the Writer.set_analog_general_scale() method instead. -For serializing other types, see the source code for some of the convenience functions such as +For serializing other types, see the source code for some of the convenience functions. For example: Writer.set_point_labels() (2D string array) or Writer.set_analog_scales() (1D float array).

    diff --git a/docs/examples.md b/docs/examples.md index d5a429f..ca8eb42 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -62,10 +62,10 @@ Editing Editing c3d files is possible by combining the use of `c3d.reader.Reader` and `c3d.writer.Writer` instances through the `c3d.reader.Reader.to_writer` method. To edit a file, open a file stream and pass -it to a `c3d.reader.Reader` instance. Convert it using the `c3d.reader.Reader.to_writer` method to create -an independent `c3d.writer.Writer` instance containing a heap copy of the file contents. -Rereading the data frames from the reader and inserting them in reverse into -the writer will then create a looped version of the .c3d file! +it to the `c3d.reader.Reader` constructor. Use the `c3d.reader.Reader.to_writer` method to create +an independent `c3d.writer.Writer` instance containing a heap copy of its file contents. +Rereading data frames from the reader and inserting them in reverse will then create a +looped version of the original motion sequence! import c3d @@ -88,21 +88,20 @@ Reading metadata fields can be done though the `c3d.reader.Reader` but editing r `c3d.header.Header` fields can be accessed from the common `c3d.manager.Manager.header` attribute. Parameters are available through a parameter `c3d.group.Group`, and can be accessed -through the `c3d.manager.Manager.get` method: +through the `c3d.manager.Manager.get` and `c3d.group.Group.get` methods: group = reader.get('POINT') param = group.get('LABELS') -or use the simpler method +or simply use param = reader.get('POINT:LABELS') Note that for accessing parameters in the `c3d.reader.Reader`, `c3d.reader.Reader.get` returns a `c3d.group.GroupReadonly` instance. Convenience functions are provided -for some of the common metadata fields -such as `c3d.manager.Manager.frame_count`. In the case you require specific -metadata fields, consider exploring the [C3D format manual] and/or inspect -the file using the c3d-metadata script. +for some of the common metadata fields such as `c3d.manager.Manager.frame_count`. +In the case you require specific metadata fields, consider exploring +the [C3D format manual] and/or inspect the file using the c3d-metadata script. [C3D format manual]: https://c3d.org/docs/C3D_User_Guide.pdf @@ -119,6 +118,6 @@ and to write a float32 entry, use the `c3d.group.Group.add` or `c3d.group.Group. group.set('GEN_SCALE', 'Analog general scale factor', 4, ' Date: Sun, 4 Jul 2021 23:55:14 +0200 Subject: [PATCH 100/120] Update README.md --- c3d/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c3d/README.md b/c3d/README.md index 05c168c..1e74339 100644 --- a/c3d/README.md +++ b/c3d/README.md @@ -29,8 +29,8 @@ Docstring example ---------- aparam : `c3d.reader.Reader` This is the first input parameter. - bparam : str - The second parameter should be a string. + bparam : str, optional + The second parameter is an optional string argument. Returns ------- From 15411e2f80c3923de13b737e4e8fe6e901b77f95 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 23:57:46 +0200 Subject: [PATCH 101/120] Test .. code-block:: python --- c3d/{README.md => README.rst} | 2 ++ 1 file changed, 2 insertions(+) rename c3d/{README.md => README.rst} (98%) diff --git a/c3d/README.md b/c3d/README.rst similarity index 98% rename from c3d/README.md rename to c3d/README.rst index 1e74339..349ac6d 100644 --- a/c3d/README.md +++ b/c3d/README.rst @@ -19,6 +19,8 @@ Use [numpy style] doc strings. Docstring example ----------- +.. code-block:: python + def my_function(aparam, bparam): ''' Short summary. From 08f15f32db4cb3224e7bee6faf2a6047e5fbb8c6 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 4 Jul 2021 23:59:33 +0200 Subject: [PATCH 102/120] Update README.rst .rst working with .. code-block:: python --- c3d/README.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/c3d/README.rst b/c3d/README.rst index 349ac6d..75880ce 100644 --- a/c3d/README.rst +++ b/c3d/README.rst @@ -12,9 +12,9 @@ Pycodestyle can be installed with the pip command Docstrings ----------- -Use [numpy style] doc strings. +Use `numpy style`_ doc strings. -[numpy style]: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard +.. _`numpy style`: https://numpydoc.readthedocs.io/en/latest/format.html#docstring-standard Docstring example ----------- From 56c99da702782d10b0793eb394dfb0b8e32db94a Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 5 Jul 2021 14:47:41 +0200 Subject: [PATCH 103/120] Manager.get_screen_xy_strings Manager.get_screen_xy_axis --- c3d/manager.py | 73 ++++++-- c3d/parameter.py | 105 ++++++----- docs/c3d/dtypes.html | 1 + docs/c3d/group.html | 1 + docs/c3d/header.html | 1 + docs/c3d/index.html | 1 + docs/c3d/manager.html | 175 +++++++++++++++++-- docs/c3d/parameter.html | 375 +++++++++++++++++++++++++--------------- docs/c3d/reader.html | 2 + docs/c3d/utils.html | 1 + docs/c3d/writer.html | 2 + test/test_c3d.py | 2 +- 12 files changed, 528 insertions(+), 211 deletions(-) diff --git a/c3d/manager.py b/c3d/manager.py index 47ab6ef..9b196f9 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -1,4 +1,4 @@ -''' Manager base class defining common attributes for both the Reader and Writer instances. +''' Manager base class defining common attributes for both Reader and Writer instances. ''' import numpy as np import warnings @@ -337,8 +337,7 @@ def first_frame(self) -> int: param = self.get('TRIAL:ACTUAL_START_FIELD') if param is not None: # ACTUAL_START_FIELD is encoded in two 16 byte words... - words = param.uint16_array - return words[0] + words[1] * 65535 + return param.uint32_value return self.header.first_frame @property @@ -353,30 +352,35 @@ def last_frame(self) -> int: param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) - words = param.uint16_array - end_frame[1] = words[0] + words[1] * 65536 - # end_frame[1] = param.uint32_value + #words = param.uint16_array + #end_frame[1] = words[0] + words[1] * 65536 + end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: - # Encoded as float - end_frame[2] = int(param.float_value) + # 'Should be' encoded as float + if param.bytes_per_element >= 4: + end_frame[2] = int(param.float_value) + else: + end_frame[2] = param.uint16_value param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint - if param.bytes_per_element == 2: - end_frame[3] = param.uint16_value - else: + if param.bytes_per_element == 4: end_frame[3] = int(param.float_value) + else: + end_frame[3] = param.uint16_value # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) - def get_screen_axis(self): - ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group. + def get_screen_xy_strings(self): + ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. + + See `Manager.get_screen_xy_axis` to get numpy vectors instead. Returns ------- value : (str, str) or None - Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. + Touple containing X_SCREEN and Y_SCREEN strings, or None (if no parameters could be found). ''' X = self.get('POINT:X_SCREEN') Y = self.get('POINT:Y_SCREEN') @@ -384,6 +388,47 @@ def get_screen_axis(self): return (X.string_value, Y.string_value) return None + def get_screen_xy_axis(self): + ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors. + + Z axis can be computed using the cross product: + + \[ z = x \\times y \] + + To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: + + \[ p = | x^T y^T z^T |^T p_s \] + + + See `Manager.get_screen_xy_strings` to get the parameter as string values instead. + + Returns + ------- + value : ([3,], [3,]) or None + Touple $(x, y)$ containing X_SCREEN and Y_SCREEN as row vectors, or None. + ''' + # Axis conversion dictionary. + AXIS_DICT = { + 'X': np.array([1.0, 0, 0]), + '+X': np.array([1.0, 0, 0]), + '-X': np.array([-1.0, 0, 0]), + 'Y': np.array([0, 1.0, 0]), + '+Y': np.array([0, 1.0, 0]), + '-Y': np.array([0, -1.0, 0]), + 'Z': np.array([0, 0, 1.0]), + '+Z': np.array([0, 0, 1.0]), + '-Z': np.array([0, 0, -1.0]), + } + + val = self.get_screen_xy() + if val is None: + return None + axis_x, axis_y = val + + # Interpret using both X/Y_SCREEN + return AXIS_DICT[axis_x], AXIS_DICT[axis_y] + + def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets diff --git a/c3d/parameter.py b/c3d/parameter.py index 9e2e0f1..f0031a5 100644 --- a/c3d/parameter.py +++ b/c3d/parameter.py @@ -176,7 +176,7 @@ def dtypes(self): @property def dimensions(self) -> (int, ...): - ''' Shape of the parameter data (Fortran shape). ''' + ''' Shape of the parameter data (Fortran format). ''' return self._data.dimensions @property @@ -201,63 +201,86 @@ def binary_size(self) -> int: @property def int8_value(self): - '''Get the param as an 8-bit signed integer.''' + '''Get the parameter data as an 8-bit signed integer.''' return self._data._as(self.dtypes.int8) @property def uint8_value(self): - '''Get the param as an 8-bit unsigned integer.''' + '''Get the parameter data as an 8-bit unsigned integer.''' return self._data._as(self.dtypes.uint8) @property def int16_value(self): - '''Get the param as a 16-bit signed integer.''' + '''Get the parameter data as a 16-bit signed integer.''' return self._data._as(self.dtypes.int16) @property def uint16_value(self): - '''Get the param as a 16-bit unsigned integer.''' + '''Get the parameter data as a 16-bit unsigned integer.''' return self._data._as(self.dtypes.uint16) @property def int32_value(self): - '''Get the param as a 32-bit signed integer.''' + '''Get the parameter data as a 32-bit signed integer.''' return self._data._as(self.dtypes.int32) @property def uint32_value(self): - '''Get the param as a 32-bit unsigned integer.''' + '''Get the parameter data as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) + @property + def any_value(self): + ''' Get the parameter data as a value of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : int, float, or str + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_value + elif self.bytes_per_element >= 2: + return self.int16_value + elif self.bytes_per_element == -1: + return self.string_value + else: + return self.int8_value + @property def uint_value(self): - ''' Get the param as a unsigned integer of appropriate type. ''' - if self.total_bytes >= 4: + ''' Get the parameter data as a unsigned integer of appropriate type. ''' + if self.bytes_per_element >= 4: return self.uint32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.uint16_value else: return self.uint8_value @property def int_value(self): - ''' Get the param as a signed integer of appropriate type. ''' - if self.total_bytes >= 4: + ''' Get the parameter data as a signed integer of appropriate type. ''' + if self.bytes_per_element >= 4: return self.int32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.int16_value else: return self.int8_value @property def float_value(self): - '''Get the param as a floating point value of appropriate type.''' - if self.total_bytes > 4: + '''Get the parameter data as a floating point value of appropriate type.''' + if self.bytes_per_element > 4: if self.dtypes.is_dec: raise AttributeError("64 bit DEC floating point is not supported.") # 64-bit floating point is not a standard return self._data._as(self.dtypes.float64) - elif self.total_bytes == 4: + elif self.bytes_per_element == 4: if self.dtypes.is_dec: return DEC_to_IEEE(self._data._as(np.uint32)) else: # is_mips or is_ieee @@ -266,58 +289,58 @@ def float_value(self): raise AttributeError("Only 32 and 64 bit floating point is supported.") @property - def bytes_value(self): - '''Get the param as a raw byte string.''' + def bytes_value(self) -> bytes: + '''Get the raw byte string.''' return self._data.bytes @property def string_value(self): - '''Get the param as a unicode string.''' + '''Get the parameter data as a unicode string.''' return self.dtypes.decode_string(self._data.bytes) @property def int8_array(self): - '''Get the param as an array of 8-bit signed integers.''' + '''Get the parameter data as an array of 8-bit signed integers.''' return self._data._as_array(self.dtypes.int8) @property def uint8_array(self): - '''Get the param as an array of 8-bit unsigned integers.''' + '''Get the parameter data as an array of 8-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint8) @property def int16_array(self): - '''Get the param as an array of 16-bit signed integers.''' + '''Get the parameter data as an array of 16-bit signed integers.''' return self._data._as_array(self.dtypes.int16) @property def uint16_array(self): - '''Get the param as an array of 16-bit unsigned integers.''' + '''Get the parameter data as an array of 16-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint16) @property def int32_array(self): - '''Get the param as an array of 32-bit signed integers.''' + '''Get the parameter data as an array of 32-bit signed integers.''' return self._data._as_array(self.dtypes.int32) @property def uint32_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' + '''Get the parameter data as an array of 32-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint32) @property def int64_array(self): - '''Get the param as an array of 32-bit signed integers.''' + '''Get the parameter data as an array of 32-bit signed integers.''' return self._data._as_array(self.dtypes.int64) @property def uint64_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' + '''Get the parameter data as an array of 32-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint64) @property def float32_array(self): - '''Get the param as an array of 32-bit floats.''' + '''Get the parameter data as an array of 32-bit floats.''' # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC @@ -329,7 +352,7 @@ def float32_array(self): @property def float64_array(self): - '''Get the param as an array of 64-bit floats.''' + '''Get the parameter data as an array of 64-bit floats.''' # Convert float data if not IEEE processor if self.dtypes.is_dec: raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') @@ -338,7 +361,7 @@ def float64_array(self): @property def float_array(self): - '''Get the param as an array of 32 or 64 bit floats.''' + '''Get the parameter data as an array of 32 or 64 bit floats.''' # Convert float data if not IEEE processor if self.bytes_per_element == 4: return self.float32_array @@ -350,7 +373,7 @@ def float_array(self): @property def int_array(self): - '''Get the param as an array of integer values.''' + '''Get the parameter data as an array of integer values.''' # Convert float data if not IEEE processor if self.bytes_per_element == 1: return self.int8_array @@ -366,7 +389,7 @@ def int_array(self): @property def uint_array(self): - '''Get the param as an array of integer values.''' + '''Get the parameter data as an array of integer values.''' # Convert float data if not IEEE processor if self.bytes_per_element == 1: return self.uint8_array @@ -382,7 +405,7 @@ def uint_array(self): @property def bytes_array(self): - '''Get the param as an array of raw byte strings.''' + '''Get the parameter data as an array of raw byte strings.''' # Decode different dimensions if len(self.dimensions) == 0: return np.array([]) @@ -403,7 +426,7 @@ def bytes_array(self): @property def string_array(self): - '''Get the param as a python array of unicode strings.''' + '''Get the parameter data as a python array of unicode strings.''' # Decode different dimensions if len(self.dimensions) == 0: return np.array([]) @@ -418,17 +441,17 @@ def string_array(self): return byte_arr @property - def _as_integer_or_float_value(self): - ''' Get the param as either 32 bit float or unsigned integer. - Evaluates if an integer is stored as a floating point representation. + def _as_any_uint(self): + ''' Attempt to parse the parameter data as any unsigned integer format. + Checks if the integer is stored as a floating point value. ''' - if self.total_bytes >= 4: + if self.bytes_per_element >= 4: # Check if float value representation is an integer value = self.float_value - if int(value) == value: - return value + if float(value).is_integer(): + return int(value) return self.uint32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.uint16_value else: return self.uint8_value diff --git a/docs/c3d/dtypes.html b/docs/c3d/dtypes.html index c9473d4..a7953c6 100644 --- a/docs/c3d/dtypes.html +++ b/docs/c3d/dtypes.html @@ -12,6 +12,7 @@ + diff --git a/docs/c3d/group.html b/docs/c3d/group.html index bce40b9..08e892e 100644 --- a/docs/c3d/group.html +++ b/docs/c3d/group.html @@ -12,6 +12,7 @@ + diff --git a/docs/c3d/header.html b/docs/c3d/header.html index 95ee820..df07378 100644 --- a/docs/c3d/header.html +++ b/docs/c3d/header.html @@ -12,6 +12,7 @@ + diff --git a/docs/c3d/index.html b/docs/c3d/index.html index 380d143..c737ba7 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -13,6 +13,7 @@ + diff --git a/docs/c3d/manager.html b/docs/c3d/manager.html index bd64c3b..ed487ff 100644 --- a/docs/c3d/manager.html +++ b/docs/c3d/manager.html @@ -12,6 +12,7 @@ + @@ -399,13 +400,15 @@

    Module c3d.manager

    # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) - def get_screen_axis(self): - ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group. + def get_screen_xy(self): + ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. + + See `Manager.get_screen_axis` to get numpy vectors instead. Returns ------- value : (str, str) or None - Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. + Touple containing X_SCREEN and Y_SCREEN strings, or None (if no parameters could be found). ''' X = self.get('POINT:X_SCREEN') Y = self.get('POINT:Y_SCREEN') @@ -413,6 +416,47 @@

    Module c3d.manager

    return (X.string_value, Y.string_value) return None + def get_screen_axis(self): + ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors. + + Z axis can be computed using the cross product: + + \[ z = x \\times y \] + + To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: + + \[ p = | x^T y^T z^T |^T p_s \] + + + See `Manager.get_screen_xy` to get the parameter as string values instead. + + Returns + ------- + value : ([3,], [3,]) or None + Touple $(x, y)$ containing X_SCREEN and Y_SCREEN as row vectors, or None. + ''' + # Axis conversion dictionary. + AXIS_DICT = { + 'X': np.array([1.0, 0, 0]), + '+X': np.array([1.0, 0, 0]), + '-X': np.array([-1.0, 0, 0]), + 'Y': np.array([0, 1.0, 0]), + '+Y': np.array([0, 1.0, 0]), + '-Y': np.array([0, -1.0, 0]), + 'Z': np.array([0, 0, 1.0]), + '+Z': np.array([0, 0, 1.0]), + '-Z': np.array([0, 0, -1.0]), + } + + val = self.get_screen_xy() + if val is None: + return None + axis_x, axis_y = val + + # Interpret using both X/Y_SCREEN + return AXIS_DICT[axis_x], AXIS_DICT[axis_y] + + def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets @@ -870,13 +914,15 @@

    Attributes

    # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) - def get_screen_axis(self): - ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group. + def get_screen_xy(self): + ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. + + See `Manager.get_screen_axis` to get numpy vectors instead. Returns ------- value : (str, str) or None - Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found. + Touple containing X_SCREEN and Y_SCREEN strings, or None (if no parameters could be found). ''' X = self.get('POINT:X_SCREEN') Y = self.get('POINT:Y_SCREEN') @@ -884,6 +930,47 @@

    Attributes

    return (X.string_value, Y.string_value) return None + def get_screen_axis(self): + ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors. + + Z axis can be computed using the cross product: + + \[ z = x \\times y \] + + To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: + + \[ p = | x^T y^T z^T |^T p_s \] + + + See `Manager.get_screen_xy` to get the parameter as string values instead. + + Returns + ------- + value : ([3,], [3,]) or None + Touple $(x, y)$ containing X_SCREEN and Y_SCREEN as row vectors, or None. + ''' + # Axis conversion dictionary. + AXIS_DICT = { + 'X': np.array([1.0, 0, 0]), + '+X': np.array([1.0, 0, 0]), + '-X': np.array([-1.0, 0, 0]), + 'Y': np.array([0, 1.0, 0]), + '+Y': np.array([0, 1.0, 0]), + '-Y': np.array([0, -1.0, 0]), + 'Z': np.array([0, 0, 1.0]), + '+Z': np.array([0, 0, 1.0]), + '-Z': np.array([0, 0, -1.0]), + } + + val = self.get_screen_xy() + if val is None: + return None + axis_x, axis_y = val + + # Interpret using both X/Y_SCREEN + return AXIS_DICT[axis_x], AXIS_DICT[axis_y] + + def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets @@ -1366,23 +1453,88 @@

    Returns

    def get_screen_axis(self)
    -

    Get the X_SCREEN and Y_SCREEN parameters in the POINT group.

    +

    Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors.

    +

    Z axis can be computed using the cross product:

    +

    z = x \times y

    +

    To move a point coordinate $p_s$ as read from Reader.read_frames() out of the system basis do:

    +

    p = | x^T y^T z^T |^T p_s +

    +

    See Manager.get_screen_xy() to get the parameter as string values instead.

    Returns

    -
    value : (str, str) or None
    -
    Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
    +
    value : ([3,], [3,]) or None
    +
    Touple $(x, y)$ containing X_SCREEN and Y_SCREEN as row vectors, or None.
    Expand source code
    def get_screen_axis(self):
    -    ''' Get the X_SCREEN and Y_SCREEN parameters in the POINT group.
    +    ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors.
    +
    +    Z axis can be computed using the cross product:
    +
    +    \[ z = x \\times y \]
    +
    +    To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do:
    +
    +    \[ p = | x^T y^T z^T |^T p_s  \]
    +
    +
    +    See `Manager.get_screen_xy` to get the parameter as string values instead.
    +
    +    Returns
    +    -------
    +    value : ([3,], [3,]) or None
    +        Touple $(x, y)$ containing X_SCREEN and Y_SCREEN as row vectors, or None.
    +    '''
    +    # Axis conversion dictionary.
    +    AXIS_DICT = {
    +        'X': np.array([1.0, 0, 0]),
    +        '+X': np.array([1.0, 0, 0]),
    +        '-X': np.array([-1.0, 0, 0]),
    +        'Y': np.array([0, 1.0, 0]),
    +        '+Y': np.array([0, 1.0, 0]),
    +        '-Y': np.array([0, -1.0, 0]),
    +        'Z': np.array([0, 0, 1.0]),
    +        '+Z': np.array([0, 0, 1.0]),
    +        '-Z': np.array([0, 0, -1.0]),
    +    }
    +
    +    val = self.get_screen_xy()
    +    if val is None:
    +        return None
    +    axis_x, axis_y  = val
    +
    +    # Interpret using both X/Y_SCREEN
    +    return AXIS_DICT[axis_x], AXIS_DICT[axis_y]
    +
    +
    +
    +def get_screen_xy(self) +
    +
    +

    Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings.

    +

    See Manager.get_screen_axis() to get numpy vectors instead.

    +

    Returns

    +
    +
    value : (str, str) or None
    +
    Touple containing X_SCREEN and Y_SCREEN strings, or None (if no parameters could be found).
    +
    +
    + +Expand source code + +
    def get_screen_xy(self):
    +    ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings.
    +
    +    See `Manager.get_screen_axis` to get numpy vectors instead.
     
         Returns
         -------
         value : (str, str) or None
    -        Touple containing X_SCREEN and Y_SCREEN strings, or None if no parameters could be found.
    +        Touple containing X_SCREEN and Y_SCREEN strings, or None (if no parameters could be found).
         '''
         X = self.get('POINT:X_SCREEN')
         Y = self.get('POINT:Y_SCREEN')
    @@ -1559,6 +1711,7 @@ 

    Manager
  • get_int32
  • get_int8
  • get_screen_axis
  • +
  • get_screen_xy
  • get_string
  • get_uint16
  • get_uint32
  • diff --git a/docs/c3d/parameter.html b/docs/c3d/parameter.html index 8a28333..c86b866 100644 --- a/docs/c3d/parameter.html +++ b/docs/c3d/parameter.html @@ -12,6 +12,7 @@ + @@ -205,7 +206,7 @@

    Module c3d.parameter

    @property def dimensions(self) -> (int, ...): - ''' Shape of the parameter data (Fortran shape). ''' + ''' Shape of the parameter data (Fortran format). ''' return self._data.dimensions @property @@ -230,63 +231,86 @@

    Module c3d.parameter

    @property def int8_value(self): - '''Get the param as an 8-bit signed integer.''' + '''Get the parameter data as an 8-bit signed integer.''' return self._data._as(self.dtypes.int8) @property def uint8_value(self): - '''Get the param as an 8-bit unsigned integer.''' + '''Get the parameter data as an 8-bit unsigned integer.''' return self._data._as(self.dtypes.uint8) @property def int16_value(self): - '''Get the param as a 16-bit signed integer.''' + '''Get the parameter data as a 16-bit signed integer.''' return self._data._as(self.dtypes.int16) @property def uint16_value(self): - '''Get the param as a 16-bit unsigned integer.''' + '''Get the parameter data as a 16-bit unsigned integer.''' return self._data._as(self.dtypes.uint16) @property def int32_value(self): - '''Get the param as a 32-bit signed integer.''' + '''Get the parameter data as a 32-bit signed integer.''' return self._data._as(self.dtypes.int32) @property def uint32_value(self): - '''Get the param as a 32-bit unsigned integer.''' + '''Get the parameter data as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) + @property + def any_value(self): + ''' Get the parameter data as a value of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : int, float, or str + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_value + elif self.bytes_per_element >= 2: + return self.int16_value + elif self.bytes_per_element == -1: + return self.string_value + else: + return self.int8_value + @property def uint_value(self): - ''' Get the param as a unsigned integer of appropriate type. ''' - if self.total_bytes >= 4: + ''' Get the parameter data as a unsigned integer of appropriate type. ''' + if self.bytes_per_element >= 4: return self.uint32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.uint16_value else: return self.uint8_value @property def int_value(self): - ''' Get the param as a signed integer of appropriate type. ''' - if self.total_bytes >= 4: + ''' Get the parameter data as a signed integer of appropriate type. ''' + if self.bytes_per_element >= 4: return self.int32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.int16_value else: return self.int8_value @property def float_value(self): - '''Get the param as a floating point value of appropriate type.''' - if self.total_bytes > 4: + '''Get the parameter data as a floating point value of appropriate type.''' + if self.bytes_per_element > 4: if self.dtypes.is_dec: raise AttributeError("64 bit DEC floating point is not supported.") # 64-bit floating point is not a standard return self._data._as(self.dtypes.float64) - elif self.total_bytes == 4: + elif self.bytes_per_element == 4: if self.dtypes.is_dec: return DEC_to_IEEE(self._data._as(np.uint32)) else: # is_mips or is_ieee @@ -295,58 +319,58 @@

    Module c3d.parameter

    raise AttributeError("Only 32 and 64 bit floating point is supported.") @property - def bytes_value(self): - '''Get the param as a raw byte string.''' + def bytes_value(self) -> bytes: + '''Get the raw byte string.''' return self._data.bytes @property def string_value(self): - '''Get the param as a unicode string.''' + '''Get the parameter data as a unicode string.''' return self.dtypes.decode_string(self._data.bytes) @property def int8_array(self): - '''Get the param as an array of 8-bit signed integers.''' + '''Get the parameter data as an array of 8-bit signed integers.''' return self._data._as_array(self.dtypes.int8) @property def uint8_array(self): - '''Get the param as an array of 8-bit unsigned integers.''' + '''Get the parameter data as an array of 8-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint8) @property def int16_array(self): - '''Get the param as an array of 16-bit signed integers.''' + '''Get the parameter data as an array of 16-bit signed integers.''' return self._data._as_array(self.dtypes.int16) @property def uint16_array(self): - '''Get the param as an array of 16-bit unsigned integers.''' + '''Get the parameter data as an array of 16-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint16) @property def int32_array(self): - '''Get the param as an array of 32-bit signed integers.''' + '''Get the parameter data as an array of 32-bit signed integers.''' return self._data._as_array(self.dtypes.int32) @property def uint32_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' + '''Get the parameter data as an array of 32-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint32) @property def int64_array(self): - '''Get the param as an array of 32-bit signed integers.''' + '''Get the parameter data as an array of 32-bit signed integers.''' return self._data._as_array(self.dtypes.int64) @property def uint64_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' + '''Get the parameter data as an array of 32-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint64) @property def float32_array(self): - '''Get the param as an array of 32-bit floats.''' + '''Get the parameter data as an array of 32-bit floats.''' # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC @@ -358,7 +382,7 @@

    Module c3d.parameter

    @property def float64_array(self): - '''Get the param as an array of 64-bit floats.''' + '''Get the parameter data as an array of 64-bit floats.''' # Convert float data if not IEEE processor if self.dtypes.is_dec: raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') @@ -367,7 +391,7 @@

    Module c3d.parameter

    @property def float_array(self): - '''Get the param as an array of 32 or 64 bit floats.''' + '''Get the parameter data as an array of 32 or 64 bit floats.''' # Convert float data if not IEEE processor if self.bytes_per_element == 4: return self.float32_array @@ -379,7 +403,7 @@

    Module c3d.parameter

    @property def int_array(self): - '''Get the param as an array of integer values.''' + '''Get the parameter data as an array of integer values.''' # Convert float data if not IEEE processor if self.bytes_per_element == 1: return self.int8_array @@ -395,7 +419,7 @@

    Module c3d.parameter

    @property def uint_array(self): - '''Get the param as an array of integer values.''' + '''Get the parameter data as an array of integer values.''' # Convert float data if not IEEE processor if self.bytes_per_element == 1: return self.uint8_array @@ -411,7 +435,7 @@

    Module c3d.parameter

    @property def bytes_array(self): - '''Get the param as an array of raw byte strings.''' + '''Get the parameter data as an array of raw byte strings.''' # Decode different dimensions if len(self.dimensions) == 0: return np.array([]) @@ -432,7 +456,7 @@

    Module c3d.parameter

    @property def string_array(self): - '''Get the param as a python array of unicode strings.''' + '''Get the parameter data as a python array of unicode strings.''' # Decode different dimensions if len(self.dimensions) == 0: return np.array([]) @@ -447,17 +471,17 @@

    Module c3d.parameter

    return byte_arr @property - def _as_integer_or_float_value(self): - ''' Get the param as either 32 bit float or unsigned integer. - Evaluates if an integer is stored as a floating point representation. + def _as_any_uint(self): + ''' Attempt to parse the parameter data as any unsigned integer format. + Checks if the integer is stored as a floating point value. ''' - if self.total_bytes >= 4: + if self.bytes_per_element >= 4: # Check if float value representation is an integer value = self.float_value - if int(value) == value: - return value + if float(value).is_integer(): + return int(value) return self.uint32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.uint16_value else: return self.uint8_value @@ -562,6 +586,7 @@

    Inherited members

    • ParamReadonly:
        +
      • any_value
      • binary_size
      • bytes_array
      • bytes_per_element
      • @@ -936,7 +961,7 @@

        Parameters

        @property def dimensions(self) -> (int, ...): - ''' Shape of the parameter data (Fortran shape). ''' + ''' Shape of the parameter data (Fortran format). ''' return self._data.dimensions @property @@ -961,63 +986,86 @@

        Parameters

        @property def int8_value(self): - '''Get the param as an 8-bit signed integer.''' + '''Get the parameter data as an 8-bit signed integer.''' return self._data._as(self.dtypes.int8) @property def uint8_value(self): - '''Get the param as an 8-bit unsigned integer.''' + '''Get the parameter data as an 8-bit unsigned integer.''' return self._data._as(self.dtypes.uint8) @property def int16_value(self): - '''Get the param as a 16-bit signed integer.''' + '''Get the parameter data as a 16-bit signed integer.''' return self._data._as(self.dtypes.int16) @property def uint16_value(self): - '''Get the param as a 16-bit unsigned integer.''' + '''Get the parameter data as a 16-bit unsigned integer.''' return self._data._as(self.dtypes.uint16) @property def int32_value(self): - '''Get the param as a 32-bit signed integer.''' + '''Get the parameter data as a 32-bit signed integer.''' return self._data._as(self.dtypes.int32) @property def uint32_value(self): - '''Get the param as a 32-bit unsigned integer.''' + '''Get the parameter data as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) + @property + def any_value(self): + ''' Get the parameter data as a value of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : int, float, or str + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_value + elif self.bytes_per_element >= 2: + return self.int16_value + elif self.bytes_per_element == -1: + return self.string_value + else: + return self.int8_value + @property def uint_value(self): - ''' Get the param as a unsigned integer of appropriate type. ''' - if self.total_bytes >= 4: + ''' Get the parameter data as a unsigned integer of appropriate type. ''' + if self.bytes_per_element >= 4: return self.uint32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.uint16_value else: return self.uint8_value @property def int_value(self): - ''' Get the param as a signed integer of appropriate type. ''' - if self.total_bytes >= 4: + ''' Get the parameter data as a signed integer of appropriate type. ''' + if self.bytes_per_element >= 4: return self.int32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.int16_value else: return self.int8_value @property def float_value(self): - '''Get the param as a floating point value of appropriate type.''' - if self.total_bytes > 4: + '''Get the parameter data as a floating point value of appropriate type.''' + if self.bytes_per_element > 4: if self.dtypes.is_dec: raise AttributeError("64 bit DEC floating point is not supported.") # 64-bit floating point is not a standard return self._data._as(self.dtypes.float64) - elif self.total_bytes == 4: + elif self.bytes_per_element == 4: if self.dtypes.is_dec: return DEC_to_IEEE(self._data._as(np.uint32)) else: # is_mips or is_ieee @@ -1026,58 +1074,58 @@

        Parameters

        raise AttributeError("Only 32 and 64 bit floating point is supported.") @property - def bytes_value(self): - '''Get the param as a raw byte string.''' + def bytes_value(self) -> bytes: + '''Get the raw byte string.''' return self._data.bytes @property def string_value(self): - '''Get the param as a unicode string.''' + '''Get the parameter data as a unicode string.''' return self.dtypes.decode_string(self._data.bytes) @property def int8_array(self): - '''Get the param as an array of 8-bit signed integers.''' + '''Get the parameter data as an array of 8-bit signed integers.''' return self._data._as_array(self.dtypes.int8) @property def uint8_array(self): - '''Get the param as an array of 8-bit unsigned integers.''' + '''Get the parameter data as an array of 8-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint8) @property def int16_array(self): - '''Get the param as an array of 16-bit signed integers.''' + '''Get the parameter data as an array of 16-bit signed integers.''' return self._data._as_array(self.dtypes.int16) @property def uint16_array(self): - '''Get the param as an array of 16-bit unsigned integers.''' + '''Get the parameter data as an array of 16-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint16) @property def int32_array(self): - '''Get the param as an array of 32-bit signed integers.''' + '''Get the parameter data as an array of 32-bit signed integers.''' return self._data._as_array(self.dtypes.int32) @property def uint32_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' + '''Get the parameter data as an array of 32-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint32) @property def int64_array(self): - '''Get the param as an array of 32-bit signed integers.''' + '''Get the parameter data as an array of 32-bit signed integers.''' return self._data._as_array(self.dtypes.int64) @property def uint64_array(self): - '''Get the param as an array of 32-bit unsigned integers.''' + '''Get the parameter data as an array of 32-bit unsigned integers.''' return self._data._as_array(self.dtypes.uint64) @property def float32_array(self): - '''Get the param as an array of 32-bit floats.''' + '''Get the parameter data as an array of 32-bit floats.''' # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC @@ -1089,7 +1137,7 @@

        Parameters

        @property def float64_array(self): - '''Get the param as an array of 64-bit floats.''' + '''Get the parameter data as an array of 64-bit floats.''' # Convert float data if not IEEE processor if self.dtypes.is_dec: raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.') @@ -1098,7 +1146,7 @@

        Parameters

        @property def float_array(self): - '''Get the param as an array of 32 or 64 bit floats.''' + '''Get the parameter data as an array of 32 or 64 bit floats.''' # Convert float data if not IEEE processor if self.bytes_per_element == 4: return self.float32_array @@ -1110,7 +1158,7 @@

        Parameters

        @property def int_array(self): - '''Get the param as an array of integer values.''' + '''Get the parameter data as an array of integer values.''' # Convert float data if not IEEE processor if self.bytes_per_element == 1: return self.int8_array @@ -1126,7 +1174,7 @@

        Parameters

        @property def uint_array(self): - '''Get the param as an array of integer values.''' + '''Get the parameter data as an array of integer values.''' # Convert float data if not IEEE processor if self.bytes_per_element == 1: return self.uint8_array @@ -1142,7 +1190,7 @@

        Parameters

        @property def bytes_array(self): - '''Get the param as an array of raw byte strings.''' + '''Get the parameter data as an array of raw byte strings.''' # Decode different dimensions if len(self.dimensions) == 0: return np.array([]) @@ -1163,7 +1211,7 @@

        Parameters

        @property def string_array(self): - '''Get the param as a python array of unicode strings.''' + '''Get the parameter data as a python array of unicode strings.''' # Decode different dimensions if len(self.dimensions) == 0: return np.array([]) @@ -1178,17 +1226,17 @@

        Parameters

        return byte_arr @property - def _as_integer_or_float_value(self): - ''' Get the param as either 32 bit float or unsigned integer. - Evaluates if an integer is stored as a floating point representation. + def _as_any_uint(self): + ''' Attempt to parse the parameter data as any unsigned integer format. + Checks if the integer is stored as a floating point value. ''' - if self.total_bytes >= 4: + if self.bytes_per_element >= 4: # Check if float value representation is an integer value = self.float_value - if int(value) == value: - return value + if float(value).is_integer(): + return int(value) return self.uint32_value - elif self.total_bytes >= 2: + elif self.bytes_per_element >= 2: return self.uint16_value else: return self.uint8_value

    @@ -1199,6 +1247,44 @@

    Subclasses

    Instance variables

    +
    var any_value
    +
    +

    Get the parameter data as a value of 'traditional type'.

    +

    Traditional types are defined in the Parameter section in the user manual.

    +

    Returns

    +
    +
    value : int, float, or str
    +
    Depending on the bytes_per_element field, a traditional type can +be a either a signed byte, signed short, 32-bit float, or a string.
    +
    +
    + +Expand source code + +
    @property
    +def any_value(self):
    +    ''' Get the parameter data as a value of 'traditional type'.
    +
    +    Traditional types are defined in the Parameter section in the [user manual].
    +
    +    Returns
    +    -------
    +    value : int, float, or str
    +        Depending on the `bytes_per_element` field, a traditional type can
    +        be a either a signed byte, signed short, 32-bit float, or a string.
    +
    +    [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf
    +    '''
    +    if self.bytes_per_element >= 4:
    +        return self.float_value
    +    elif self.bytes_per_element >= 2:
    +        return self.int16_value
    +    elif self.bytes_per_element == -1:
    +        return self.string_value
    +    else:
    +        return self.int8_value
    +
    +
    var binary_size : int

    Return the number of bytes needed to store this parameter.

    @@ -1214,14 +1300,14 @@

    Instance variables

    var bytes_array
    -

    Get the param as an array of raw byte strings.

    +

    Get the parameter data as an array of raw byte strings.

    Expand source code
    @property
     def bytes_array(self):
    -    '''Get the param as an array of raw byte strings.'''
    +    '''Get the parameter data as an array of raw byte strings.'''
         # Decode different dimensions
         if len(self.dimensions) == 0:
             return np.array([])
    @@ -1254,16 +1340,16 @@ 

    Instance variables

    return self._data.bytes_per_element
    -
    var bytes_value
    +
    var bytes_value : bytes
    -

    Get the param as a raw byte string.

    +

    Get the raw byte string.

    Expand source code
    @property
    -def bytes_value(self):
    -    '''Get the param as a raw byte string.'''
    +def bytes_value(self) -> bytes:
    +    '''Get the raw byte string.'''
         return self._data.bytes
    @@ -1282,14 +1368,14 @@

    Instance variables

    var dimensions : (, Ellipsis)
    -

    Shape of the parameter data (Fortran shape).

    +

    Shape of the parameter data (Fortran format).

    Expand source code
    @property
     def dimensions(self) -> (int, ...):
    -    ''' Shape of the parameter data (Fortran shape). '''
    +    ''' Shape of the parameter data (Fortran format). '''
         return self._data.dimensions
    @@ -1308,14 +1394,14 @@

    Instance variables

    var float32_array
    -

    Get the param as an array of 32-bit floats.

    +

    Get the parameter data as an array of 32-bit floats.

    Expand source code
    @property
     def float32_array(self):
    -    '''Get the param as an array of 32-bit floats.'''
    +    '''Get the parameter data as an array of 32-bit floats.'''
         # Convert float data if not IEEE processor
         if self.dtypes.is_dec:
             # _as_array but for DEC
    @@ -1328,14 +1414,14 @@ 

    Instance variables

    var float64_array
    -

    Get the param as an array of 64-bit floats.

    +

    Get the parameter data as an array of 64-bit floats.

    Expand source code
    @property
     def float64_array(self):
    -    '''Get the param as an array of 64-bit floats.'''
    +    '''Get the parameter data as an array of 64-bit floats.'''
         # Convert float data if not IEEE processor
         if self.dtypes.is_dec:
             raise ValueError('Unable to convert bytes encoded in a 64 bit floating point DEC format.')
    @@ -1345,14 +1431,14 @@ 

    Instance variables

    var float_array
    -

    Get the param as an array of 32 or 64 bit floats.

    +

    Get the parameter data as an array of 32 or 64 bit floats.

    Expand source code
    @property
     def float_array(self):
    -    '''Get the param as an array of 32 or 64 bit floats.'''
    +    '''Get the parameter data as an array of 32 or 64 bit floats.'''
         # Convert float data if not IEEE processor
         if self.bytes_per_element == 4:
             return self.float32_array
    @@ -1365,20 +1451,20 @@ 

    Instance variables

    var float_value
    -

    Get the param as a floating point value of appropriate type.

    +

    Get the parameter data as a floating point value of appropriate type.

    Expand source code
    @property
     def float_value(self):
    -    '''Get the param as a floating point value of appropriate type.'''
    -    if self.total_bytes > 4:
    +    '''Get the parameter data as a floating point value of appropriate type.'''
    +    if self.bytes_per_element > 4:
             if self.dtypes.is_dec:
                 raise AttributeError("64 bit DEC floating point is not supported.")
             # 64-bit floating point is not a standard
             return self._data._as(self.dtypes.float64)
    -    elif self.total_bytes == 4:
    +    elif self.bytes_per_element == 4:
             if self.dtypes.is_dec:
                 return DEC_to_IEEE(self._data._as(np.uint32))
             else:  # is_mips or is_ieee
    @@ -1389,105 +1475,105 @@ 

    Instance variables

    var int16_array
    -

    Get the param as an array of 16-bit signed integers.

    +

    Get the parameter data as an array of 16-bit signed integers.

    Expand source code
    @property
     def int16_array(self):
    -    '''Get the param as an array of 16-bit signed integers.'''
    +    '''Get the parameter data as an array of 16-bit signed integers.'''
         return self._data._as_array(self.dtypes.int16)
    var int16_value
    -

    Get the param as a 16-bit signed integer.

    +

    Get the parameter data as a 16-bit signed integer.

    Expand source code
    @property
     def int16_value(self):
    -    '''Get the param as a 16-bit signed integer.'''
    +    '''Get the parameter data as a 16-bit signed integer.'''
         return self._data._as(self.dtypes.int16)
    var int32_array
    -

    Get the param as an array of 32-bit signed integers.

    +

    Get the parameter data as an array of 32-bit signed integers.

    Expand source code
    @property
     def int32_array(self):
    -    '''Get the param as an array of 32-bit signed integers.'''
    +    '''Get the parameter data as an array of 32-bit signed integers.'''
         return self._data._as_array(self.dtypes.int32)
    var int32_value
    -

    Get the param as a 32-bit signed integer.

    +

    Get the parameter data as a 32-bit signed integer.

    Expand source code
    @property
     def int32_value(self):
    -    '''Get the param as a 32-bit signed integer.'''
    +    '''Get the parameter data as a 32-bit signed integer.'''
         return self._data._as(self.dtypes.int32)
    var int64_array
    -

    Get the param as an array of 32-bit signed integers.

    +

    Get the parameter data as an array of 32-bit signed integers.

    Expand source code
    @property
     def int64_array(self):
    -    '''Get the param as an array of 32-bit signed integers.'''
    +    '''Get the parameter data as an array of 32-bit signed integers.'''
         return self._data._as_array(self.dtypes.int64)
    var int8_array
    -

    Get the param as an array of 8-bit signed integers.

    +

    Get the parameter data as an array of 8-bit signed integers.

    Expand source code
    @property
     def int8_array(self):
    -    '''Get the param as an array of 8-bit signed integers.'''
    +    '''Get the parameter data as an array of 8-bit signed integers.'''
         return self._data._as_array(self.dtypes.int8)
    var int8_value
    -

    Get the param as an 8-bit signed integer.

    +

    Get the parameter data as an 8-bit signed integer.

    Expand source code
    @property
     def int8_value(self):
    -    '''Get the param as an 8-bit signed integer.'''
    +    '''Get the parameter data as an 8-bit signed integer.'''
         return self._data._as(self.dtypes.int8)
    var int_array
    -

    Get the param as an array of integer values.

    +

    Get the parameter data as an array of integer values.

    Expand source code
    @property
     def int_array(self):
    -    '''Get the param as an array of integer values.'''
    +    '''Get the parameter data as an array of integer values.'''
         # Convert float data if not IEEE processor
         if self.bytes_per_element == 1:
             return self.int8_array
    @@ -1504,17 +1590,17 @@ 

    Instance variables

    var int_value
    -

    Get the param as a signed integer of appropriate type.

    +

    Get the parameter data as a signed integer of appropriate type.

    Expand source code
    @property
     def int_value(self):
    -    ''' Get the param as a signed integer of appropriate type. '''
    -    if self.total_bytes >= 4:
    +    ''' Get the parameter data as a signed integer of appropriate type. '''
    +    if self.bytes_per_element >= 4:
             return self.int32_value
    -    elif self.total_bytes >= 2:
    +    elif self.bytes_per_element >= 2:
             return self.int16_value
         else:
             return self.int8_value
    @@ -1548,14 +1634,14 @@

    Instance variables

    var string_array
    -

    Get the param as a python array of unicode strings.

    +

    Get the parameter data as a python array of unicode strings.

    Expand source code
    @property
     def string_array(self):
    -    '''Get the param as a python array of unicode strings.'''
    +    '''Get the parameter data as a python array of unicode strings.'''
         # Decode different dimensions
         if len(self.dimensions) == 0:
             return np.array([])
    @@ -1572,14 +1658,14 @@ 

    Instance variables

    var string_value
    -

    Get the param as a unicode string.

    +

    Get the parameter data as a unicode string.

    Expand source code
    @property
     def string_value(self):
    -    '''Get the param as a unicode string.'''
    +    '''Get the parameter data as a unicode string.'''
         return self.dtypes.decode_string(self._data.bytes)
    @@ -1598,105 +1684,105 @@

    Instance variables

    var uint16_array
    -

    Get the param as an array of 16-bit unsigned integers.

    +

    Get the parameter data as an array of 16-bit unsigned integers.

    Expand source code
    @property
     def uint16_array(self):
    -    '''Get the param as an array of 16-bit unsigned integers.'''
    +    '''Get the parameter data as an array of 16-bit unsigned integers.'''
         return self._data._as_array(self.dtypes.uint16)
    var uint16_value
    -

    Get the param as a 16-bit unsigned integer.

    +

    Get the parameter data as a 16-bit unsigned integer.

    Expand source code
    @property
     def uint16_value(self):
    -    '''Get the param as a 16-bit unsigned integer.'''
    +    '''Get the parameter data as a 16-bit unsigned integer.'''
         return self._data._as(self.dtypes.uint16)
    var uint32_array
    -

    Get the param as an array of 32-bit unsigned integers.

    +

    Get the parameter data as an array of 32-bit unsigned integers.

    Expand source code
    @property
     def uint32_array(self):
    -    '''Get the param as an array of 32-bit unsigned integers.'''
    +    '''Get the parameter data as an array of 32-bit unsigned integers.'''
         return self._data._as_array(self.dtypes.uint32)
    var uint32_value
    -

    Get the param as a 32-bit unsigned integer.

    +

    Get the parameter data as a 32-bit unsigned integer.

    Expand source code
    @property
     def uint32_value(self):
    -    '''Get the param as a 32-bit unsigned integer.'''
    +    '''Get the parameter data as a 32-bit unsigned integer.'''
         return self._data._as(self.dtypes.uint32)
    var uint64_array
    -

    Get the param as an array of 32-bit unsigned integers.

    +

    Get the parameter data as an array of 32-bit unsigned integers.

    Expand source code
    @property
     def uint64_array(self):
    -    '''Get the param as an array of 32-bit unsigned integers.'''
    +    '''Get the parameter data as an array of 32-bit unsigned integers.'''
         return self._data._as_array(self.dtypes.uint64)
    var uint8_array
    -

    Get the param as an array of 8-bit unsigned integers.

    +

    Get the parameter data as an array of 8-bit unsigned integers.

    Expand source code
    @property
     def uint8_array(self):
    -    '''Get the param as an array of 8-bit unsigned integers.'''
    +    '''Get the parameter data as an array of 8-bit unsigned integers.'''
         return self._data._as_array(self.dtypes.uint8)
    var uint8_value
    -

    Get the param as an 8-bit unsigned integer.

    +

    Get the parameter data as an 8-bit unsigned integer.

    Expand source code
    @property
     def uint8_value(self):
    -    '''Get the param as an 8-bit unsigned integer.'''
    +    '''Get the parameter data as an 8-bit unsigned integer.'''
         return self._data._as(self.dtypes.uint8)
    var uint_array
    -

    Get the param as an array of integer values.

    +

    Get the parameter data as an array of integer values.

    Expand source code
    @property
     def uint_array(self):
    -    '''Get the param as an array of integer values.'''
    +    '''Get the parameter data as an array of integer values.'''
         # Convert float data if not IEEE processor
         if self.bytes_per_element == 1:
             return self.uint8_array
    @@ -1713,17 +1799,17 @@ 

    Instance variables

    var uint_value
    -

    Get the param as a unsigned integer of appropriate type.

    +

    Get the parameter data as a unsigned integer of appropriate type.

    Expand source code
    @property
     def uint_value(self):
    -    ''' Get the param as a unsigned integer of appropriate type. '''
    -    if self.total_bytes >= 4:
    +    ''' Get the parameter data as a unsigned integer of appropriate type. '''
    +    if self.bytes_per_element >= 4:
             return self.uint32_value
    -    elif self.total_bytes >= 2:
    +    elif self.bytes_per_element >= 2:
             return self.uint16_value
         else:
             return self.uint8_value
    @@ -1767,6 +1853,7 @@

    Par
  • ParamReadonly

      +
    • any_value
    • binary_size
    • bytes_array
    • bytes_per_element
    • diff --git a/docs/c3d/reader.html b/docs/c3d/reader.html index de72e63..d676f0c 100644 --- a/docs/c3d/reader.html +++ b/docs/c3d/reader.html @@ -12,6 +12,7 @@ + @@ -1113,6 +1114,7 @@

      Inherited members

    • get_int32
    • get_int8
    • get_screen_axis
    • +
    • get_screen_xy
    • get_string
    • get_uint16
    • get_uint32
    • diff --git a/docs/c3d/utils.html b/docs/c3d/utils.html index dff3183..a79fbe9 100644 --- a/docs/c3d/utils.html +++ b/docs/c3d/utils.html @@ -12,6 +12,7 @@ + diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html index 9a5dc36..96a5aff 100644 --- a/docs/c3d/writer.html +++ b/docs/c3d/writer.html @@ -12,6 +12,7 @@ + @@ -1740,6 +1741,7 @@

      Inherited members

    • get_int32
    • get_int8
    • get_screen_axis
    • +
    • get_screen_xy
    • get_string
    • get_uint16
    • get_uint32
    • diff --git a/test/test_c3d.py b/test/test_c3d.py index da5a6a4..90c2e8d 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -124,7 +124,7 @@ def test_set_params(self): X, Y = '-Y', '+Z' w.set_screen_axis() w.set_screen_axis(X, Y) - X_v, Y_v = w.get_screen_axis() + X_v, Y_v = w.get_screen_xy_strings() assert X_v == X and Y == Y_v, 'Mismatch between set & get screen axis.' assert np.all(np.equal(r.point_labels, w.point_labels)), 'Expected labels to be equal.' From 4ddaa30a6162cf03f016baf38f7aa41cff93ac9e Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 5 Jul 2021 18:19:20 +0200 Subject: [PATCH 104/120] Returns array of single element if dimensions is empty. ParamReadonly.any_value() ParamReadonly.any_array() --- c3d/group.py | 4 +- c3d/manager.py | 2 +- c3d/parameter.py | 98 ++++++++++++++++++++++++++---------------------- c3d/writer.py | 8 ++-- 4 files changed, 60 insertions(+), 52 deletions(-) diff --git a/c3d/group.py b/c3d/group.py index 853e9ec..0390493 100644 --- a/c3d/group.py +++ b/c3d/group.py @@ -407,7 +407,7 @@ def add_str(self, name, desc, data, *dimensions): bytes=data.encode('utf-8'), dimensions=shape or [len(data)]) - def add_empty_array(self, name, desc='', bpe=1): + def add_empty_array(self, name, desc=''): ''' Add an empty parameter block. Parameters @@ -416,7 +416,7 @@ def add_empty_array(self, name, desc='', bpe=1): Parameter name. ''' self.add_param(name, desc=desc, - bytes_per_element=bpe, dimensions=[0]) + bytes_per_element=0, dimensions=[0]) # # Convenience functions for adding or overwriting parameters. diff --git a/c3d/manager.py b/c3d/manager.py index 9b196f9..b6c4759 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -420,7 +420,7 @@ def get_screen_xy_axis(self): '-Z': np.array([0, 0, -1.0]), } - val = self.get_screen_xy() + val = self.get_screen_xy_strings() if val is None: return None axis_x, axis_y = val diff --git a/c3d/parameter.py b/c3d/parameter.py index f0031a5..4aef7db 100644 --- a/c3d/parameter.py +++ b/c3d/parameter.py @@ -121,8 +121,8 @@ def _as(self, dtype): def _as_array(self, dtype, copy=True): '''Unpack the raw bytes of this param using the given data format.''' - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, dtype) + if not self.dimensions: + return [self._as(dtype)] elems = np.frombuffer(self.bytes, dtype=dtype) # Reverse shape as the shape is defined in fortran format view = elems.reshape(self.dimensions[::-1]) @@ -130,24 +130,6 @@ def _as_array(self, dtype, copy=True): return view.copy() return view - def _as_any(self, dtype): - '''Unpack the raw bytes of this param as either array or single value.''' - if 0 in self.dimensions[:]: # Check if any dimension is 0 (empty buffer) - return [] # Buffer is empty - - if len(self.dimensions) == 0: # Parse data as a single value - if dtype == np.float32: # Floats need to be parsed separately! - return self.float_value - return self._data._as(dtype) - else: # Parse data as array - if dtype == np.float32: - data = self.float_array - else: - data = self._data._as_array(dtype) - if len(self.dimensions) < 2: # Check if data is contained in a single dimension - return data.flatten() - return data - class ParamReadonly(object): ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry. @@ -229,29 +211,6 @@ def uint32_value(self): '''Get the parameter data as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) - @property - def any_value(self): - ''' Get the parameter data as a value of 'traditional type'. - - Traditional types are defined in the Parameter section in the [user manual]. - - Returns - ------- - value : int, float, or str - Depending on the `bytes_per_element` field, a traditional type can - be a either a signed byte, signed short, 32-bit float, or a string. - - [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf - ''' - if self.bytes_per_element >= 4: - return self.float_value - elif self.bytes_per_element >= 2: - return self.int16_value - elif self.bytes_per_element == -1: - return self.string_value - else: - return self.int8_value - @property def uint_value(self): ''' Get the parameter data as a unsigned integer of appropriate type. ''' @@ -344,8 +303,8 @@ def float32_array(self): # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32) + if not self.dimensions: + return [self.float_value] return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format else: # is_ieee or is_mips return self._data._as_array(self.dtypes.float32) @@ -440,10 +399,59 @@ def string_array(self): byte_arr[i] = self.dtypes.decode_string(byte_arr[i]) return byte_arr + @property + def any_value(self): + ''' Get the parameter data as a value of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : int, float, or str + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_value + elif self.bytes_per_element >= 2: + return self.int16_value + elif self.bytes_per_element == -1: + return self.string_value + else: + return self.int8_value + + @property + def any_array(self): + ''' Get the parameter data as an array of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : array + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_array + elif self.bytes_per_element >= 2: + return self.int16_array + elif self.bytes_per_element == -1: + return self.string_array + else: + return self.int8_array + @property def _as_any_uint(self): ''' Attempt to parse the parameter data as any unsigned integer format. Checks if the integer is stored as a floating point value. + + Can be used to read 'POINT:FRAMES' or 'POINT:LONG_FRAMES' + when not accessed through `c3d.manager.Manager.last_frame`. ''' if self.bytes_per_element >= 4: # Check if float value representation is an integer diff --git a/c3d/writer.py b/c3d/writer.py index 6b6edf5..896a95e 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -264,7 +264,7 @@ def set_point_labels(self, labels): ''' grp = self.point_group if labels is None: - grp.add_empty_array('LABELS', 'Point labels.', -1) + grp.add_empty_array('LABELS', 'Point labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) @@ -279,7 +279,7 @@ def set_analog_labels(self, labels): ''' grp = self.analog_group if labels is None: - grp.add_empty_array('LABELS', 'Analog labels.', -1) + grp.add_empty_array('LABELS', 'Analog labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) @@ -301,7 +301,7 @@ def set_analog_scales(self, values): data = np.array([v for v in values], dtype=np.float32) self.analog_group.set_array('SCALE', 'Analog channel scale factors', data) elif values is None: - self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4) + self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors') else: raise ValueError('Expected iterable containing analog scale factors.') @@ -317,7 +317,7 @@ def set_analog_offsets(self, values): data = np.array([v for v in values], dtype=np.int16) self.analog_group.set_array('OFFSET', 'Analog channel offsets', data) elif values is None: - self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2) + self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets') else: raise ValueError('Expected iterable containing analog data offsets.') From fb5ed86957e56950f782d12365f4f1cea3f337f1 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 5 Jul 2021 18:19:47 +0200 Subject: [PATCH 105/120] &= -> |= !!!!!!!!!!!!!! --- c3d/reader.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/c3d/reader.py b/c3d/reader.py index 0668673..da5fff4 100644 --- a/c3d/reader.py +++ b/c3d/reader.py @@ -241,7 +241,7 @@ def read_frames(self, copy=True, analog_transform=True, check_nan=True, camera_s if check_nan: is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) points[is_nan, :3] = 0.0 - invalid &= is_nan + invalid |= is_nan # Update discarded - sign points[invalid, 3] = -1 From 51a5f79bff5b39e1f8cadb8232cc2f6e110a157e Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 5 Jul 2021 20:22:17 +0200 Subject: [PATCH 106/120] License doc --- c3d/LICENSE | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 c3d/LICENSE diff --git a/c3d/LICENSE b/c3d/LICENSE new file mode 100644 index 0000000..b263f7c --- /dev/null +++ b/c3d/LICENSE @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright (c) 2013-2015 UT Vision, Cognition, and Action Lab + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + From fd74ca7c597ceccf00df949771e063822d0735d3 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 5 Jul 2021 20:47:27 +0200 Subject: [PATCH 107/120] pycodestyles, updated docs --- c3d/manager.py | 11 +- docs/README.rst | 2 +- docs/c3d/group.html | 14 +-- docs/c3d/index.html | 2 +- docs/c3d/manager.html | 138 ++++++++++++----------- docs/c3d/parameter.html | 242 +++++++++++++++++++++++++--------------- docs/c3d/reader.html | 10 +- docs/c3d/writer.html | 28 ++--- test/README.rst | 2 + 9 files changed, 255 insertions(+), 194 deletions(-) diff --git a/c3d/manager.py b/c3d/manager.py index b6c4759..97df4cf 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -352,8 +352,8 @@ def last_frame(self) -> int: param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) - #words = param.uint16_array - #end_frame[1] = words[0] + words[1] * 65536 + # words = param.uint16_array + # end_frame[1] = words[0] + words[1] * 65536 end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: @@ -393,11 +393,11 @@ def get_screen_xy_axis(self): Z axis can be computed using the cross product: - \[ z = x \\times y \] + $$ z = x \\times y $$ To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: - \[ p = | x^T y^T z^T |^T p_s \] + $$ p = | x^T y^T z^T |^T p_s $$ See `Manager.get_screen_xy_strings` to get the parameter as string values instead. @@ -423,12 +423,11 @@ def get_screen_xy_axis(self): val = self.get_screen_xy_strings() if val is None: return None - axis_x, axis_y = val + axis_x, axis_y = val # Interpret using both X/Y_SCREEN return AXIS_DICT[axis_x], AXIS_DICT[axis_y] - def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets diff --git a/docs/README.rst b/docs/README.rst index f484e1e..d6acdf7 100644 --- a/docs/README.rst +++ b/docs/README.rst @@ -13,7 +13,7 @@ Building the docs requires the pdoc3 package:: Once installed, documentation can be updated from the root directory with the command:: - pdoc --html c3d --force --config show_source_code=True --output-dir docs + pdoc --html c3d --force --config show_source_code=True --output-dir docs -c latex_math=True Once updated you can access the documentation in the `docs/c3d/`_ folder. diff --git a/docs/c3d/group.html b/docs/c3d/group.html index 08e892e..95d99b0 100644 --- a/docs/c3d/group.html +++ b/docs/c3d/group.html @@ -437,7 +437,7 @@

      Module c3d.group

      bytes=data.encode('utf-8'), dimensions=shape or [len(data)]) - def add_empty_array(self, name, desc='', bpe=1): + def add_empty_array(self, name, desc=''): ''' Add an empty parameter block. Parameters @@ -446,7 +446,7 @@

      Module c3d.group

      Parameter name. ''' self.add_param(name, desc=desc, - bytes_per_element=bpe, dimensions=[0]) + bytes_per_element=0, dimensions=[0]) # # Convenience functions for adding or overwriting parameters. @@ -682,7 +682,7 @@

      Classes

      bytes=data.encode('utf-8'), dimensions=shape or [len(data)]) - def add_empty_array(self, name, desc='', bpe=1): + def add_empty_array(self, name, desc=''): ''' Add an empty parameter block. Parameters @@ -691,7 +691,7 @@

      Classes

      Parameter name. ''' self.add_param(name, desc=desc, - bytes_per_element=bpe, dimensions=[0]) + bytes_per_element=0, dimensions=[0]) # # Convenience functions for adding or overwriting parameters. @@ -886,7 +886,7 @@

      Parameters

  • -def add_empty_array(self, name, desc='', bpe=1) +def add_empty_array(self, name, desc='')

    Add an empty parameter block.

    @@ -899,7 +899,7 @@

    Parameters

    Expand source code -
    def add_empty_array(self, name, desc='', bpe=1):
    +
    def add_empty_array(self, name, desc=''):
         ''' Add an empty parameter block.
     
         Parameters
    @@ -908,7 +908,7 @@ 

    Parameters

    Parameter name. ''' self.add_param(name, desc=desc, - bytes_per_element=bpe, dimensions=[0])
    + bytes_per_element=0, dimensions=[0])
    diff --git a/docs/c3d/index.html b/docs/c3d/index.html index c737ba7..69cd6bf 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -176,7 +176,7 @@

    Sub-modules

    c3d.manager
    -

    Manager base class defining common attributes for both the Reader and Writer instances.

    +

    Manager base class defining common attributes for both Reader and Writer instances.

    c3d.parameter
    diff --git a/docs/c3d/manager.html b/docs/c3d/manager.html index ed487ff..f4e33c9 100644 --- a/docs/c3d/manager.html +++ b/docs/c3d/manager.html @@ -5,7 +5,7 @@ c3d.manager API documentation - + @@ -23,12 +23,12 @@

    Module c3d.manager

    -

    Manager base class defining common attributes for both the Reader and Writer instances.

    +

    Manager base class defining common attributes for both Reader and Writer instances.

    Expand source code -
    ''' Manager base class defining common attributes for both the Reader and Writer instances.
    +
    ''' Manager base class defining common attributes for both Reader and Writer instances.
     '''
     import numpy as np
     import warnings
    @@ -367,8 +367,7 @@ 

    Module c3d.manager

    param = self.get('TRIAL:ACTUAL_START_FIELD') if param is not None: # ACTUAL_START_FIELD is encoded in two 16 byte words... - words = param.uint16_array - return words[0] + words[1] * 65535 + return param.uint32_value return self.header.first_frame @property @@ -383,27 +382,30 @@

    Module c3d.manager

    param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) - words = param.uint16_array - end_frame[1] = words[0] + words[1] * 65536 - # end_frame[1] = param.uint32_value + # words = param.uint16_array + # end_frame[1] = words[0] + words[1] * 65536 + end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: - # Encoded as float - end_frame[2] = int(param.float_value) + # 'Should be' encoded as float + if param.bytes_per_element >= 4: + end_frame[2] = int(param.float_value) + else: + end_frame[2] = param.uint16_value param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint - if param.bytes_per_element == 2: - end_frame[3] = param.uint16_value - else: + if param.bytes_per_element == 4: end_frame[3] = int(param.float_value) + else: + end_frame[3] = param.uint16_value # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) - def get_screen_xy(self): + def get_screen_xy_strings(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. - See `Manager.get_screen_axis` to get numpy vectors instead. + See `Manager.get_screen_xy_axis` to get numpy vectors instead. Returns ------- @@ -416,19 +418,19 @@

    Module c3d.manager

    return (X.string_value, Y.string_value) return None - def get_screen_axis(self): + def get_screen_xy_axis(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors. Z axis can be computed using the cross product: - \[ z = x \\times y \] + $$ z = x \\times y $$ To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: - \[ p = | x^T y^T z^T |^T p_s \] + $$ p = | x^T y^T z^T |^T p_s $$ - See `Manager.get_screen_xy` to get the parameter as string values instead. + See `Manager.get_screen_xy_strings` to get the parameter as string values instead. Returns ------- @@ -448,15 +450,14 @@

    Module c3d.manager

    '-Z': np.array([0, 0, -1.0]), } - val = self.get_screen_xy() + val = self.get_screen_xy_strings() if val is None: return None - axis_x, axis_y = val + axis_x, axis_y = val # Interpret using both X/Y_SCREEN return AXIS_DICT[axis_x], AXIS_DICT[axis_y] - def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets @@ -881,8 +882,7 @@

    Attributes

    param = self.get('TRIAL:ACTUAL_START_FIELD') if param is not None: # ACTUAL_START_FIELD is encoded in two 16 byte words... - words = param.uint16_array - return words[0] + words[1] * 65535 + return param.uint32_value return self.header.first_frame @property @@ -897,27 +897,30 @@

    Attributes

    param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) - words = param.uint16_array - end_frame[1] = words[0] + words[1] * 65536 - # end_frame[1] = param.uint32_value + # words = param.uint16_array + # end_frame[1] = words[0] + words[1] * 65536 + end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: - # Encoded as float - end_frame[2] = int(param.float_value) + # 'Should be' encoded as float + if param.bytes_per_element >= 4: + end_frame[2] = int(param.float_value) + else: + end_frame[2] = param.uint16_value param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint - if param.bytes_per_element == 2: - end_frame[3] = param.uint16_value - else: + if param.bytes_per_element == 4: end_frame[3] = int(param.float_value) + else: + end_frame[3] = param.uint16_value # Return the largest of the all (queue bad reading...) return int(np.max(end_frame)) - def get_screen_xy(self): + def get_screen_xy_strings(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. - See `Manager.get_screen_axis` to get numpy vectors instead. + See `Manager.get_screen_xy_axis` to get numpy vectors instead. Returns ------- @@ -930,19 +933,19 @@

    Attributes

    return (X.string_value, Y.string_value) return None - def get_screen_axis(self): + def get_screen_xy_axis(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors. Z axis can be computed using the cross product: - \[ z = x \\times y \] + $$ z = x \\times y $$ To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do: - \[ p = | x^T y^T z^T |^T p_s \] + $$ p = | x^T y^T z^T |^T p_s $$ - See `Manager.get_screen_xy` to get the parameter as string values instead. + See `Manager.get_screen_xy_strings` to get the parameter as string values instead. Returns ------- @@ -962,15 +965,14 @@

    Attributes

    '-Z': np.array([0, 0, -1.0]), } - val = self.get_screen_xy() + val = self.get_screen_xy_strings() if val is None: return None - axis_x, axis_y = val + axis_x, axis_y = val # Interpret using both X/Y_SCREEN return AXIS_DICT[axis_x], AXIS_DICT[axis_y] - def get_analog_transform_parameters(self): ''' Parse analog data transform parameters. ''' # Offsets @@ -1133,8 +1135,7 @@

    Instance variables

    param = self.get('TRIAL:ACTUAL_START_FIELD') if param is not None: # ACTUAL_START_FIELD is encoded in two 16 byte words... - words = param.uint16_array - return words[0] + words[1] * 65535 + return param.uint32_value return self.header.first_frame
    @@ -1183,20 +1184,23 @@

    Instance variables

    param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) - words = param.uint16_array - end_frame[1] = words[0] + words[1] * 65536 - # end_frame[1] = param.uint32_value + # words = param.uint16_array + # end_frame[1] = words[0] + words[1] * 65536 + end_frame[1] = param.uint32_value param = self.get('POINT:LONG_FRAMES') if param is not None: - # Encoded as float - end_frame[2] = int(param.float_value) + # 'Should be' encoded as float + if param.bytes_per_element >= 4: + end_frame[2] = int(param.float_value) + else: + end_frame[2] = param.uint16_value param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint - if param.bytes_per_element == 2: - end_frame[3] = param.uint16_value - else: + if param.bytes_per_element == 4: end_frame[3] = int(param.float_value) + else: + end_frame[3] = param.uint16_value # Return the largest of the all (queue bad reading...) return int(np.max(end_frame))
    @@ -1449,8 +1453,8 @@

    Returns

    return self.get(key).int8_value
    -
    -def get_screen_axis(self) +
    +def get_screen_xy_axis(self)

    Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors.

    @@ -1460,7 +1464,7 @@

    Returns

    p = | x^T y^T z^T |^T p_s

    -

    See Manager.get_screen_xy() to get the parameter as string values instead.

    +

    See Manager.get_screen_xy_strings() to get the parameter as string values instead.

    Returns

    value : ([3,], [3,]) or None
    @@ -1470,19 +1474,19 @@

    Returns

    Expand source code -
    def get_screen_axis(self):
    +
    def get_screen_xy_axis(self):
         ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as unit row vectors.
     
         Z axis can be computed using the cross product:
     
    -    \[ z = x \\times y \]
    +    $$ z = x \\times y $$
     
         To move a point coordinate $p_s$ as read from `c3d.reader.Reader.read_frames` out of the system basis do:
     
    -    \[ p = | x^T y^T z^T |^T p_s  \]
    +    $$ p = | x^T y^T z^T |^T p_s  $$
     
     
    -    See `Manager.get_screen_xy` to get the parameter as string values instead.
    +    See `Manager.get_screen_xy_strings` to get the parameter as string values instead.
     
         Returns
         -------
    @@ -1502,21 +1506,21 @@ 

    Returns

    '-Z': np.array([0, 0, -1.0]), } - val = self.get_screen_xy() + val = self.get_screen_xy_strings() if val is None: return None - axis_x, axis_y = val + axis_x, axis_y = val # Interpret using both X/Y_SCREEN return AXIS_DICT[axis_x], AXIS_DICT[axis_y]
    -
    -def get_screen_xy(self) +
    +def get_screen_xy_strings(self)

    Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings.

    -

    See Manager.get_screen_axis() to get numpy vectors instead.

    +

    See Manager.get_screen_xy_axis() to get numpy vectors instead.

    Returns

    value : (str, str) or None
    @@ -1526,10 +1530,10 @@

    Returns

    Expand source code -
    def get_screen_xy(self):
    +
    def get_screen_xy_strings(self):
         ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings.
     
    -    See `Manager.get_screen_axis` to get numpy vectors instead.
    +    See `Manager.get_screen_xy_axis` to get numpy vectors instead.
     
         Returns
         -------
    @@ -1710,8 +1714,8 @@ 

    Manager
  • get_int16
  • get_int32
  • get_int8
  • -
  • get_screen_axis
  • -
  • get_screen_xy
  • +
  • get_screen_xy_axis
  • +
  • get_screen_xy_strings
  • get_string
  • get_uint16
  • get_uint32
  • diff --git a/docs/c3d/parameter.html b/docs/c3d/parameter.html index c86b866..4a3c1be 100644 --- a/docs/c3d/parameter.html +++ b/docs/c3d/parameter.html @@ -151,8 +151,8 @@

    Module c3d.parameter

    def _as_array(self, dtype, copy=True): '''Unpack the raw bytes of this param using the given data format.''' - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, dtype) + if not self.dimensions: + return [self._as(dtype)] elems = np.frombuffer(self.bytes, dtype=dtype) # Reverse shape as the shape is defined in fortran format view = elems.reshape(self.dimensions[::-1]) @@ -160,24 +160,6 @@

    Module c3d.parameter

    return view.copy() return view - def _as_any(self, dtype): - '''Unpack the raw bytes of this param as either array or single value.''' - if 0 in self.dimensions[:]: # Check if any dimension is 0 (empty buffer) - return [] # Buffer is empty - - if len(self.dimensions) == 0: # Parse data as a single value - if dtype == np.float32: # Floats need to be parsed separately! - return self.float_value - return self._data._as(dtype) - else: # Parse data as array - if dtype == np.float32: - data = self.float_array - else: - data = self._data._as_array(dtype) - if len(self.dimensions) < 2: # Check if data is contained in a single dimension - return data.flatten() - return data - class ParamReadonly(object): ''' Wrapper exposing readonly attributes of a `c3d.parameter.ParamData` entry. @@ -259,29 +241,6 @@

    Module c3d.parameter

    '''Get the parameter data as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) - @property - def any_value(self): - ''' Get the parameter data as a value of 'traditional type'. - - Traditional types are defined in the Parameter section in the [user manual]. - - Returns - ------- - value : int, float, or str - Depending on the `bytes_per_element` field, a traditional type can - be a either a signed byte, signed short, 32-bit float, or a string. - - [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf - ''' - if self.bytes_per_element >= 4: - return self.float_value - elif self.bytes_per_element >= 2: - return self.int16_value - elif self.bytes_per_element == -1: - return self.string_value - else: - return self.int8_value - @property def uint_value(self): ''' Get the parameter data as a unsigned integer of appropriate type. ''' @@ -374,8 +333,8 @@

    Module c3d.parameter

    # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32) + if not self.dimensions: + return [self.float_value] return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format else: # is_ieee or is_mips return self._data._as_array(self.dtypes.float32) @@ -470,10 +429,59 @@

    Module c3d.parameter

    byte_arr[i] = self.dtypes.decode_string(byte_arr[i]) return byte_arr + @property + def any_value(self): + ''' Get the parameter data as a value of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : int, float, or str + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_value + elif self.bytes_per_element >= 2: + return self.int16_value + elif self.bytes_per_element == -1: + return self.string_value + else: + return self.int8_value + + @property + def any_array(self): + ''' Get the parameter data as an array of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : array + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_array + elif self.bytes_per_element >= 2: + return self.int16_array + elif self.bytes_per_element == -1: + return self.string_array + else: + return self.int8_array + @property def _as_any_uint(self): ''' Attempt to parse the parameter data as any unsigned integer format. Checks if the integer is stored as a floating point value. + + Can be used to read 'POINT:FRAMES' or 'POINT:LONG_FRAMES' + when not accessed through `c3d.manager.Manager.last_frame`. ''' if self.bytes_per_element >= 4: # Check if float value representation is an integer @@ -586,6 +594,7 @@

    Inherited members

    • ParamReadonly:
        +
      • any_array
      • any_value
      • binary_size
      • bytes_array
      • @@ -771,32 +780,14 @@

        Attributes

        def _as_array(self, dtype, copy=True): '''Unpack the raw bytes of this param using the given data format.''' - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, dtype) + if not self.dimensions: + return [self._as(dtype)] elems = np.frombuffer(self.bytes, dtype=dtype) # Reverse shape as the shape is defined in fortran format view = elems.reshape(self.dimensions[::-1]) if copy: return view.copy() - return view - - def _as_any(self, dtype): - '''Unpack the raw bytes of this param as either array or single value.''' - if 0 in self.dimensions[:]: # Check if any dimension is 0 (empty buffer) - return [] # Buffer is empty - - if len(self.dimensions) == 0: # Parse data as a single value - if dtype == np.float32: # Floats need to be parsed separately! - return self.float_value - return self._data._as(dtype) - else: # Parse data as array - if dtype == np.float32: - data = self.float_array - else: - data = self._data._as_array(dtype) - if len(self.dimensions) < 2: # Check if data is contained in a single dimension - return data.flatten() - return data

    + return view

    Instance variables

    @@ -1014,29 +1005,6 @@

    Parameters

    '''Get the parameter data as a 32-bit unsigned integer.''' return self._data._as(self.dtypes.uint32) - @property - def any_value(self): - ''' Get the parameter data as a value of 'traditional type'. - - Traditional types are defined in the Parameter section in the [user manual]. - - Returns - ------- - value : int, float, or str - Depending on the `bytes_per_element` field, a traditional type can - be a either a signed byte, signed short, 32-bit float, or a string. - - [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf - ''' - if self.bytes_per_element >= 4: - return self.float_value - elif self.bytes_per_element >= 2: - return self.int16_value - elif self.bytes_per_element == -1: - return self.string_value - else: - return self.int8_value - @property def uint_value(self): ''' Get the parameter data as a unsigned integer of appropriate type. ''' @@ -1129,8 +1097,8 @@

    Parameters

    # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32) + if not self.dimensions: + return [self.float_value] return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format else: # is_ieee or is_mips return self._data._as_array(self.dtypes.float32) @@ -1225,10 +1193,59 @@

    Parameters

    byte_arr[i] = self.dtypes.decode_string(byte_arr[i]) return byte_arr + @property + def any_value(self): + ''' Get the parameter data as a value of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : int, float, or str + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_value + elif self.bytes_per_element >= 2: + return self.int16_value + elif self.bytes_per_element == -1: + return self.string_value + else: + return self.int8_value + + @property + def any_array(self): + ''' Get the parameter data as an array of 'traditional type'. + + Traditional types are defined in the Parameter section in the [user manual]. + + Returns + ------- + value : array + Depending on the `bytes_per_element` field, a traditional type can + be a either a signed byte, signed short, 32-bit float, or a string. + + [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf + ''' + if self.bytes_per_element >= 4: + return self.float_array + elif self.bytes_per_element >= 2: + return self.int16_array + elif self.bytes_per_element == -1: + return self.string_array + else: + return self.int8_array + @property def _as_any_uint(self): ''' Attempt to parse the parameter data as any unsigned integer format. Checks if the integer is stored as a floating point value. + + Can be used to read 'POINT:FRAMES' or 'POINT:LONG_FRAMES' + when not accessed through `c3d.manager.Manager.last_frame`. ''' if self.bytes_per_element >= 4: # Check if float value representation is an integer @@ -1247,6 +1264,44 @@

    Subclasses

    Instance variables

    +
    var any_array
    +
    +

    Get the parameter data as an array of 'traditional type'.

    +

    Traditional types are defined in the Parameter section in the user manual.

    +

    Returns

    +
    +
    value : array
    +
    Depending on the bytes_per_element field, a traditional type can +be a either a signed byte, signed short, 32-bit float, or a string.
    +
    +
    + +Expand source code + +
    @property
    +def any_array(self):
    +    ''' Get the parameter data as an array of 'traditional type'.
    +
    +    Traditional types are defined in the Parameter section in the [user manual].
    +
    +    Returns
    +    -------
    +    value : array
    +        Depending on the `bytes_per_element` field, a traditional type can
    +        be a either a signed byte, signed short, 32-bit float, or a string.
    +
    +    [user manual]: https://www.c3d.org/docs/C3D_User_Guide.pdf
    +    '''
    +    if self.bytes_per_element >= 4:
    +        return self.float_array
    +    elif self.bytes_per_element >= 2:
    +        return self.int16_array
    +    elif self.bytes_per_element == -1:
    +        return self.string_array
    +    else:
    +        return self.int8_array
    +
    +
    var any_value

    Get the parameter data as a value of 'traditional type'.

    @@ -1405,8 +1460,8 @@

    Returns

    # Convert float data if not IEEE processor if self.dtypes.is_dec: # _as_array but for DEC - assert self.dimensions, \ - '{}: cannot get value as {} array!'.format(self.name, self.dtypes.float32) + if not self.dimensions: + return [self.float_value] return DEC_to_IEEE_BYTES(self._data.bytes).reshape(self.dimensions[::-1]) # Reverse fortran format else: # is_ieee or is_mips return self._data._as_array(self.dtypes.float32)
    @@ -1853,6 +1908,7 @@

    Par
  • ParamReadonly

      +
    • any_array
    • any_value
    • binary_size
    • bytes_array
    • diff --git a/docs/c3d/reader.html b/docs/c3d/reader.html index d676f0c..2e8d643 100644 --- a/docs/c3d/reader.html +++ b/docs/c3d/reader.html @@ -271,7 +271,7 @@

      Module c3d.reader

      if check_nan: is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) points[is_nan, :3] = 0.0 - invalid &= is_nan + invalid |= is_nan # Update discarded - sign points[invalid, 3] = -1 @@ -637,7 +637,7 @@

      Raises

      if check_nan: is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) points[is_nan, :3] = 0.0 - invalid &= is_nan + invalid |= is_nan # Update discarded - sign points[invalid, 3] = -1 @@ -1019,7 +1019,7 @@

      Returns

      if check_nan: is_nan = ~np.all(np.isfinite(points[:, :4]), axis=1) points[is_nan, :3] = 0.0 - invalid &= is_nan + invalid |= is_nan # Update discarded - sign points[invalid, 3] = -1 @@ -1113,8 +1113,8 @@

      Inherited members

    • get_int16
    • get_int32
    • get_int8
    • -
    • get_screen_axis
    • -
    • get_screen_xy
    • +
    • get_screen_xy_axis
    • +
    • get_screen_xy_strings
    • get_string
    • get_uint16
    • get_uint32
    • diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html index 96a5aff..7c9aae2 100644 --- a/docs/c3d/writer.html +++ b/docs/c3d/writer.html @@ -294,7 +294,7 @@

      Module c3d.writer

      ''' grp = self.point_group if labels is None: - grp.add_empty_array('LABELS', 'Point labels.', -1) + grp.add_empty_array('LABELS', 'Point labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) @@ -309,7 +309,7 @@

      Module c3d.writer

      ''' grp = self.analog_group if labels is None: - grp.add_empty_array('LABELS', 'Analog labels.', -1) + grp.add_empty_array('LABELS', 'Analog labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) @@ -331,7 +331,7 @@

      Module c3d.writer

      data = np.array([v for v in values], dtype=np.float32) self.analog_group.set_array('SCALE', 'Analog channel scale factors', data) elif values is None: - self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4) + self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors') else: raise ValueError('Expected iterable containing analog scale factors.') @@ -347,7 +347,7 @@

      Module c3d.writer

      data = np.array([v for v in values], dtype=np.int16) self.analog_group.set_array('OFFSET', 'Analog channel offsets', data) elif values is None: - self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2) + self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets') else: raise ValueError('Expected iterable containing analog data offsets.') @@ -836,7 +836,7 @@

      Parameters

      ''' grp = self.point_group if labels is None: - grp.add_empty_array('LABELS', 'Point labels.', -1) + grp.add_empty_array('LABELS', 'Point labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) @@ -851,7 +851,7 @@

      Parameters

      ''' grp = self.analog_group if labels is None: - grp.add_empty_array('LABELS', 'Analog labels.', -1) + grp.add_empty_array('LABELS', 'Analog labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels)) @@ -873,7 +873,7 @@

      Parameters

      data = np.array([v for v in values], dtype=np.float32) self.analog_group.set_array('SCALE', 'Analog channel scale factors', data) elif values is None: - self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4) + self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors') else: raise ValueError('Expected iterable containing analog scale factors.') @@ -889,7 +889,7 @@

      Parameters

      data = np.array([v for v in values], dtype=np.int16) self.analog_group.set_array('OFFSET', 'Analog channel offsets', data) elif values is None: - self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2) + self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets') else: raise ValueError('Expected iterable containing analog data offsets.') @@ -1467,7 +1467,7 @@

      Parameters

      ''' grp = self.analog_group if labels is None: - grp.add_empty_array('LABELS', 'Analog labels.', -1) + grp.add_empty_array('LABELS', 'Analog labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Analog labels.', label_str, label_max_size, len(labels))
      @@ -1499,7 +1499,7 @@

      Parameters

      data = np.array([v for v in values], dtype=np.int16) self.analog_group.set_array('OFFSET', 'Analog channel offsets', data) elif values is None: - self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets', 2) + self.analog_group.set_empty_array('OFFSET', 'Analog channel offsets') else: raise ValueError('Expected iterable containing analog data offsets.') @@ -1530,7 +1530,7 @@

      Parameters

      data = np.array([v for v in values], dtype=np.float32) self.analog_group.set_array('SCALE', 'Analog channel scale factors', data) elif values is None: - self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors', 4) + self.analog_group.set_empty_array('SCALE', 'Analog channel scale factors') else: raise ValueError('Expected iterable containing analog scale factors.')
      @@ -1559,7 +1559,7 @@

      Parameters

      ''' grp = self.point_group if labels is None: - grp.add_empty_array('LABELS', 'Point labels.', -1) + grp.add_empty_array('LABELS', 'Point labels.') else: label_str, label_max_size = utils.pack_labels(labels) grp.add_str('LABELS', 'Point labels.', label_str, label_max_size, len(labels)) @@ -1740,8 +1740,8 @@

      Inherited members

    • get_int16
    • get_int32
    • get_int8
    • -
    • get_screen_axis
    • -
    • get_screen_xy
    • +
    • get_screen_xy_axis
    • +
    • get_screen_xy_strings
    • get_string
    • get_uint16
    • get_uint32
    • diff --git a/test/README.rst b/test/README.rst index bf286c0..ed74c76 100644 --- a/test/README.rst +++ b/test/README.rst @@ -8,3 +8,5 @@ To run tests, use the following command from the root of the package directory:: Test scripts will automatically download test files from `c3d.org`_. .. _c3d.org: https://www.c3d.org/sampledata.html + +Currently no code style is enforced. From cb800a895b0df44697e1881abf1136d4f50ce963 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Mon, 5 Jul 2021 20:54:23 +0200 Subject: [PATCH 108/120] README --- README.rst | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/README.rst b/README.rst index cc3117c..859c9ee 100644 --- a/README.rst +++ b/README.rst @@ -49,7 +49,7 @@ To use the C3D library, just import the package and create a ``Reader`` and/or print('frame {}: point {}, analog {}'.format( i, points.shape, analog.shape)) -You can also get and set metadata fields using the library; see the `package +You can also edit metadata fields using the library; see the `package documentation`_ for more details. .. _package documentation: https://mattiasfredriksson.github.io/py-c3d/c3d/ @@ -58,17 +58,16 @@ Caveats ------- The package is tested against the `software examples`_ but may still not support -every possible format. For example, parameters serialized in multiple parameters -are not handled automatically (such as a LABELS field stored in both POINT:LABELS and -POINT:LABELS2). Reading and writing files from a big-endian system is also not supported. +every possible format. For example, parameters serialized in multiple parameters +are not handled automatically (LABELS field stored in both POINT:LABELS and +POINT:LABELS2). Reading and writing files from a big-endian system is also not supported. -Tests are currently only run on Windows, which means that Linux and Mac users may -experience some issues. If you experience issues with a file or feature, feel free -to post an issue (preferably by including example file/code/python exception) +Tests are currently only run on Windows, which means that Linux and Mac users may +experience some issues. If you experience issues with a file or feature, feel free +to post an issue (preferably by including example file/code/python exception) or make a pull request! -The package is also Python only, for other languages there are other packages such as `ezc3d`_. +The package is also Python only, for other languages there are other packages for example `ezc3d`_. .. _software examples: https://www.c3d.org/sampledata.html .. _ezc3d: https://github.com/pyomeca/ezc3d - From 509944d757e3a96c986fcf0b42e6027c1f2ca708 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Tue, 6 Jul 2021 14:56:46 +0200 Subject: [PATCH 109/120] Updated Manager.last_frame to comply better with documentation, removed convenience functions giving direct access to value attributes from `c3d.manager.Manager` --- c3d/manager.py | 68 ++++++++++++++---------------------------------- test/test_c3d.py | 8 +++--- 2 files changed, 23 insertions(+), 53 deletions(-) diff --git a/c3d/manager.py b/c3d/manager.py index 97df4cf..031063c 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -88,7 +88,7 @@ def _check_metadata(self): )) try: - start = self.get_uint16('POINT:DATA_START') + start = self.get('POINT:DATA_START').uint16_value if self._header.data_block != start: warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( self._header.data_block, start)) @@ -177,7 +177,7 @@ def _rename_group(self, group_id, new_group_id): Parameters ---------- - group_id : int, str, or 'Group' + group_id : int, str, or `c3d.group.Group` Group instance, name, or numerical identifier for the group. new_group_id : str, or int If string, it is the new name for the group. If integer, it will replace its numerical group id. @@ -344,33 +344,39 @@ def first_frame(self) -> int: def last_frame(self) -> int: ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' # Number of frames can be represented in many formats, first check if valid header values - if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: - return self.header.last_frame + #if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: + # return self.header.last_frame - # Check different parameter options where the frame can be encoded - end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] + # Try different parameters where the frame can be encoded + hlf = self.header.last_frame param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) # words = param.uint16_array # end_frame[1] = words[0] + words[1] * 65536 - end_frame[1] = param.uint32_value + end_frame = param.uint32_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:LONG_FRAMES') if param is not None: # 'Should be' encoded as float if param.bytes_per_element >= 4: - end_frame[2] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[2] = param.uint16_value + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint if param.bytes_per_element == 4: - end_frame[3] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[3] = param.uint16_value - # Return the largest of the all (queue bad reading...) - return int(np.max(end_frame)) + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame + # Return header value by default + return hlf def get_screen_xy_strings(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. @@ -456,39 +462,3 @@ def get_analog_transform(self): analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) return analog_scales, analog_offsets - - def get_int8(self, key): - '''Get a parameter value as an 8-bit signed integer.''' - return self.get(key).int8_value - - def get_uint8(self, key): - '''Get a parameter value as an 8-bit unsigned integer.''' - return self.get(key).uint8_value - - def get_int16(self, key): - '''Get a parameter value as a 16-bit signed integer.''' - return self.get(key).int16_value - - def get_uint16(self, key): - '''Get a parameter value as a 16-bit unsigned integer.''' - return self.get(key).uint16_value - - def get_int32(self, key): - '''Get a parameter value as a 32-bit signed integer.''' - return self.get(key).int32_value - - def get_uint32(self, key): - '''Get a parameter value as a 32-bit unsigned integer.''' - return self.get(key).uint32_value - - def get_float(self, key): - '''Get a parameter value as a 32-bit float.''' - return self.get(key).float_value - - def get_bytes(self, key): - '''Get a parameter value as a byte string.''' - return self.get(key).bytes_value - - def get_string(self, key): - '''Get a parameter value as a string.''' - return self.get(key).string_value diff --git a/test/test_c3d.py b/test/test_c3d.py index 90c2e8d..1152dd1 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -56,8 +56,8 @@ def test_paramsb(self): assert r.point_used == 26 assert r.point_rate == 50 assert r.analog_used == 16 - assert r.get_float('POINT:RATE') == 50 - assert r.get_float('ANALOG:RATE') == 200 + assert r.get('POINT:RATE').float_value == 50 + assert r.get('ANALOG:RATE').float_value == 200 def test_paramsc(self): r = c3d.Reader(Zipload._get('sample08.zip', 'TESTCPI.c3d')) @@ -102,7 +102,7 @@ def test_add_frames(self): h = io.BytesIO() w.set_point_labels(r.point_labels) w.set_analog_labels(r.analog_labels) - w.set_analog_general_scale(r.get_float('ANALOG:GEN_SCALE')) + w.set_analog_general_scale(r.get('ANALOG:GEN_SCALE').float_value) w.write(h) def test_set_params(self): @@ -118,7 +118,7 @@ def test_set_params(self): w.set_start_frame(255) w.set_point_labels(r.point_labels) w.set_analog_labels(r.analog_labels) - w.set_analog_general_scale(r.get_float('ANALOG:GEN_SCALE')) + w.set_analog_general_scale(r.get('ANALOG:GEN_SCALE').float_value) # Screen axis X, Y = '-Y', '+Z' From 9587aac5657b4a60f08bdcc7148cd3945a63e74e Mon Sep 17 00:00:00 2001 From: MattiasF Date: Tue, 6 Jul 2021 16:19:39 +0200 Subject: [PATCH 110/120] Updated docs --- docs/c3d/manager.html | 303 ++++++++---------------------------------- docs/c3d/reader.html | 9 -- docs/c3d/writer.html | 9 -- 3 files changed, 57 insertions(+), 264 deletions(-) diff --git a/docs/c3d/manager.html b/docs/c3d/manager.html index f4e33c9..eb17ef0 100644 --- a/docs/c3d/manager.html +++ b/docs/c3d/manager.html @@ -118,7 +118,7 @@

      Module c3d.manager

      )) try: - start = self.get_uint16('POINT:DATA_START') + start = self.get('POINT:DATA_START').uint16_value if self._header.data_block != start: warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( self._header.data_block, start)) @@ -207,7 +207,7 @@

      Module c3d.manager

      Parameters ---------- - group_id : int, str, or 'Group' + group_id : int, str, or `c3d.group.Group` Group instance, name, or numerical identifier for the group. new_group_id : str, or int If string, it is the new name for the group. If integer, it will replace its numerical group id. @@ -374,33 +374,39 @@

      Module c3d.manager

      def last_frame(self) -> int: ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' # Number of frames can be represented in many formats, first check if valid header values - if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: - return self.header.last_frame + #if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: + # return self.header.last_frame - # Check different parameter options where the frame can be encoded - end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] + # Try different parameters where the frame can be encoded + hlf = self.header.last_frame param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) # words = param.uint16_array # end_frame[1] = words[0] + words[1] * 65536 - end_frame[1] = param.uint32_value + end_frame = param.uint32_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:LONG_FRAMES') if param is not None: # 'Should be' encoded as float if param.bytes_per_element >= 4: - end_frame[2] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[2] = param.uint16_value + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint if param.bytes_per_element == 4: - end_frame[3] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[3] = param.uint16_value - # Return the largest of the all (queue bad reading...) - return int(np.max(end_frame)) + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame + # Return header value by default + return hlf def get_screen_xy_strings(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. @@ -485,43 +491,7 @@

      Module c3d.manager

      analog_scales *= gen_scale analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) - return analog_scales, analog_offsets - - def get_int8(self, key): - '''Get a parameter value as an 8-bit signed integer.''' - return self.get(key).int8_value - - def get_uint8(self, key): - '''Get a parameter value as an 8-bit unsigned integer.''' - return self.get(key).uint8_value - - def get_int16(self, key): - '''Get a parameter value as a 16-bit signed integer.''' - return self.get(key).int16_value - - def get_uint16(self, key): - '''Get a parameter value as a 16-bit unsigned integer.''' - return self.get(key).uint16_value - - def get_int32(self, key): - '''Get a parameter value as a 32-bit signed integer.''' - return self.get(key).int32_value - - def get_uint32(self, key): - '''Get a parameter value as a 32-bit unsigned integer.''' - return self.get(key).uint32_value - - def get_float(self, key): - '''Get a parameter value as a 32-bit float.''' - return self.get(key).float_value - - def get_bytes(self, key): - '''Get a parameter value as a byte string.''' - return self.get(key).bytes_value - - def get_string(self, key): - '''Get a parameter value as a string.''' - return self.get(key).string_value
      + return analog_scales, analog_offsets
  • @@ -633,7 +603,7 @@

    Attributes

    )) try: - start = self.get_uint16('POINT:DATA_START') + start = self.get('POINT:DATA_START').uint16_value if self._header.data_block != start: warnings.warn('inconsistent data block! {} header != {} POINT:DATA_START'.format( self._header.data_block, start)) @@ -722,7 +692,7 @@

    Attributes

    Parameters ---------- - group_id : int, str, or 'Group' + group_id : int, str, or `c3d.group.Group` Group instance, name, or numerical identifier for the group. new_group_id : str, or int If string, it is the new name for the group. If integer, it will replace its numerical group id. @@ -889,33 +859,39 @@

    Attributes

    def last_frame(self) -> int: ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' # Number of frames can be represented in many formats, first check if valid header values - if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: - return self.header.last_frame + #if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: + # return self.header.last_frame - # Check different parameter options where the frame can be encoded - end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] + # Try different parameters where the frame can be encoded + hlf = self.header.last_frame param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) # words = param.uint16_array # end_frame[1] = words[0] + words[1] * 65536 - end_frame[1] = param.uint32_value + end_frame = param.uint32_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:LONG_FRAMES') if param is not None: # 'Should be' encoded as float if param.bytes_per_element >= 4: - end_frame[2] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[2] = param.uint16_value + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint if param.bytes_per_element == 4: - end_frame[3] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[3] = param.uint16_value - # Return the largest of the all (queue bad reading...) - return int(np.max(end_frame)) + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame + # Return header value by default + return hlf def get_screen_xy_strings(self): ''' Get the POINT:X_SCREEN and POINT:Y_SCREEN parameters as strings. @@ -1000,43 +976,7 @@

    Attributes

    analog_scales *= gen_scale analog_scales = np.broadcast_to(analog_scales[:, np.newaxis], (self.analog_used, self.analog_per_frame)) analog_offsets = np.broadcast_to(analog_offsets[:, np.newaxis], (self.analog_used, self.analog_per_frame)) - return analog_scales, analog_offsets - - def get_int8(self, key): - '''Get a parameter value as an 8-bit signed integer.''' - return self.get(key).int8_value - - def get_uint8(self, key): - '''Get a parameter value as an 8-bit unsigned integer.''' - return self.get(key).uint8_value - - def get_int16(self, key): - '''Get a parameter value as a 16-bit signed integer.''' - return self.get(key).int16_value - - def get_uint16(self, key): - '''Get a parameter value as a 16-bit unsigned integer.''' - return self.get(key).uint16_value - - def get_int32(self, key): - '''Get a parameter value as a 32-bit signed integer.''' - return self.get(key).int32_value - - def get_uint32(self, key): - '''Get a parameter value as a 32-bit unsigned integer.''' - return self.get(key).uint32_value - - def get_float(self, key): - '''Get a parameter value as a 32-bit float.''' - return self.get(key).float_value - - def get_bytes(self, key): - '''Get a parameter value as a byte string.''' - return self.get(key).bytes_value - - def get_string(self, key): - '''Get a parameter value as a string.''' - return self.get(key).string_value
    + return analog_scales, analog_offsets

    Subclasses

      @@ -1176,33 +1116,39 @@

      Instance variables

      def last_frame(self) -> int: ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' # Number of frames can be represented in many formats, first check if valid header values - if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: - return self.header.last_frame + #if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: + # return self.header.last_frame - # Check different parameter options where the frame can be encoded - end_frame = [self.header.last_frame, 0.0, 0.0, 0.0] + # Try different parameters where the frame can be encoded + hlf = self.header.last_frame param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) # words = param.uint16_array # end_frame[1] = words[0] + words[1] * 65536 - end_frame[1] = param.uint32_value + end_frame = param.uint32_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:LONG_FRAMES') if param is not None: # 'Should be' encoded as float if param.bytes_per_element >= 4: - end_frame[2] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[2] = param.uint16_value + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame param = self.get('POINT:FRAMES') if param is not None: # Can be encoded either as 32 bit float or 16 bit uint if param.bytes_per_element == 4: - end_frame[3] = int(param.float_value) + end_frame = int(param.float_value) else: - end_frame[3] = param.uint16_value - # Return the largest of the all (queue bad reading...) - return int(np.max(end_frame)) + end_frame = param.uint16_value + if hlf <= end_frame: + return end_frame + # Return header value by default + return hlf
      var point_labels : list
      @@ -1383,76 +1329,6 @@

      Returns

      return gen_scale, analog_scales, analog_offsets -
      -def get_bytes(self, key) -
      -
      -

      Get a parameter value as a byte string.

      -
      - -Expand source code - -
      def get_bytes(self, key):
      -    '''Get a parameter value as a byte string.'''
      -    return self.get(key).bytes_value
      -
      -
      -
      -def get_float(self, key) -
      -
      -

      Get a parameter value as a 32-bit float.

      -
      - -Expand source code - -
      def get_float(self, key):
      -    '''Get a parameter value as a 32-bit float.'''
      -    return self.get(key).float_value
      -
      -
      -
      -def get_int16(self, key) -
      -
      -

      Get a parameter value as a 16-bit signed integer.

      -
      - -Expand source code - -
      def get_int16(self, key):
      -    '''Get a parameter value as a 16-bit signed integer.'''
      -    return self.get(key).int16_value
      -
      -
      -
      -def get_int32(self, key) -
      -
      -

      Get a parameter value as a 32-bit signed integer.

      -
      - -Expand source code - -
      def get_int32(self, key):
      -    '''Get a parameter value as a 32-bit signed integer.'''
      -    return self.get(key).int32_value
      -
      -
      -
      -def get_int8(self, key) -
      -
      -

      Get a parameter value as an 8-bit signed integer.

      -
      - -Expand source code - -
      def get_int8(self, key):
      -    '''Get a parameter value as an 8-bit signed integer.'''
      -    return self.get(key).int8_value
      -
      -
      def get_screen_xy_axis(self)
      @@ -1547,62 +1423,6 @@

      Returns

      return None -
      -def get_string(self, key) -
      -
      -

      Get a parameter value as a string.

      -
      - -Expand source code - -
      def get_string(self, key):
      -    '''Get a parameter value as a string.'''
      -    return self.get(key).string_value
      -
      -
      -
      -def get_uint16(self, key) -
      -
      -

      Get a parameter value as a 16-bit unsigned integer.

      -
      - -Expand source code - -
      def get_uint16(self, key):
      -    '''Get a parameter value as a 16-bit unsigned integer.'''
      -    return self.get(key).uint16_value
      -
      -
      -
      -def get_uint32(self, key) -
      -
      -

      Get a parameter value as a 32-bit unsigned integer.

      -
      - -Expand source code - -
      def get_uint32(self, key):
      -    '''Get a parameter value as a 32-bit unsigned integer.'''
      -    return self.get(key).uint32_value
      -
      -
      -
      -def get_uint8(self, key) -
      -
      -

      Get a parameter value as an 8-bit unsigned integer.

      -
      - -Expand source code - -
      def get_uint8(self, key):
      -    '''Get a parameter value as an 8-bit unsigned integer.'''
      -    return self.get(key).uint8_value
      -
      -
      def items(self)
      @@ -1709,17 +1529,8 @@

      Manager
    • get
    • get_analog_transform
    • get_analog_transform_parameters
    • -
    • get_bytes
    • -
    • get_float
    • -
    • get_int16
    • -
    • get_int32
    • -
    • get_int8
    • get_screen_xy_axis
    • get_screen_xy_strings
    • -
    • get_string
    • -
    • get_uint16
    • -
    • get_uint32
    • -
    • get_uint8
    • header
    • items
    • keys
    • diff --git a/docs/c3d/reader.html b/docs/c3d/reader.html index 2e8d643..359809b 100644 --- a/docs/c3d/reader.html +++ b/docs/c3d/reader.html @@ -1108,17 +1108,8 @@

      Inherited members

    • frame_count
    • get_analog_transform
    • get_analog_transform_parameters
    • -
    • get_bytes
    • -
    • get_float
    • -
    • get_int16
    • -
    • get_int32
    • -
    • get_int8
    • get_screen_xy_axis
    • get_screen_xy_strings
    • -
    • get_string
    • -
    • get_uint16
    • -
    • get_uint32
    • -
    • get_uint8
    • header
    • keys
    • last_frame
    • diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html index 7c9aae2..2b0e1b7 100644 --- a/docs/c3d/writer.html +++ b/docs/c3d/writer.html @@ -1735,17 +1735,8 @@

      Inherited members

    • get
    • get_analog_transform
    • get_analog_transform_parameters
    • -
    • get_bytes
    • -
    • get_float
    • -
    • get_int16
    • -
    • get_int32
    • -
    • get_int8
    • get_screen_xy_axis
    • get_screen_xy_strings
    • -
    • get_string
    • -
    • get_uint16
    • -
    • get_uint32
    • -
    • get_uint8
    • header
    • items
    • keys
    • From 673b2e2eb0cddfa66784aa3a7d4c0c1a9f1ed607 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 11 Jul 2021 12:30:18 +0200 Subject: [PATCH 111/120] consume -> convert in Writer.from_reader() --- c3d/writer.py | 6 +++--- docs/c3d/index.html | 4 ++-- docs/c3d/writer.html | 22 +++++++++++----------- docs/examples.md | 4 ++-- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/c3d/writer.py b/c3d/writer.py index 896a95e..742474a 100644 --- a/c3d/writer.py +++ b/c3d/writer.py @@ -63,8 +63,8 @@ def from_reader(reader, conversion=None): conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: - 'consume' - (Default) Reader object will be - consumed and explicitly deleted. + 'convert' - (Default) Convert the Reader to a Writer + instance and explicitly delete the Reader. 'copy' - Reader objects will be deep copied. @@ -93,7 +93,7 @@ def from_reader(reader, conversion=None): is_header_only = conversion == 'copy_header' is_meta_copy = conversion == 'copy_metadata' is_meta_only = is_header_only or is_meta_copy - is_consume = conversion == 'consume' or conversion is None + is_consume = conversion == 'convert' or conversion is None is_shallow_copy = conversion == 'shallow_copy' or is_header_only is_deep_copy = conversion == 'copy' or is_meta_copy # Verify mode diff --git a/docs/c3d/index.html b/docs/c3d/index.html index 69cd6bf..7a90562 100644 --- a/docs/c3d/index.html +++ b/docs/c3d/index.html @@ -81,8 +81,8 @@

      Editing

      instances through the Reader.to_writer() method. To edit a file, open a file stream and pass it to the Reader constructor. Use the Reader.to_writer() method to create an independent Writer instance containing a heap copy of its file contents. -Rereading data frames from the reader and inserting them in reverse will then create a -looped version of the original motion sequence!

      +To edit the sequence, one can now reread the data frames from the reader and through inserting the +frames in reverse to create a looped version of the original motion sequence!

      import c3d
       
       with open('my-motion.c3d', 'rb') as file:
      diff --git a/docs/c3d/writer.html b/docs/c3d/writer.html
      index 2b0e1b7..0d7b9e6 100644
      --- a/docs/c3d/writer.html
      +++ b/docs/c3d/writer.html
      @@ -93,8 +93,8 @@ 

      Module c3d.writer

      conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: - 'consume' - (Default) Reader object will be - consumed and explicitly deleted. + 'convert' - (Default) Convert the Reader to a Writer + instance and explicitly delete the Reader. 'copy' - Reader objects will be deep copied. @@ -123,7 +123,7 @@

      Module c3d.writer

      is_header_only = conversion == 'copy_header' is_meta_copy = conversion == 'copy_metadata' is_meta_only = is_header_only or is_meta_copy - is_consume = conversion == 'consume' or conversion is None + is_consume = conversion == 'convert' or conversion is None is_shallow_copy = conversion == 'shallow_copy' or is_header_only is_deep_copy = conversion == 'copy' or is_meta_copy # Verify mode @@ -635,8 +635,8 @@

      Parameters

      conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: - 'consume' - (Default) Reader object will be - consumed and explicitly deleted. + 'convert' - (Default) Convert the Reader to a Writer + instance and explicitly delete the Reader. 'copy' - Reader objects will be deep copied. @@ -665,7 +665,7 @@

      Parameters

      is_header_only = conversion == 'copy_header' is_meta_copy = conversion == 'copy_metadata' is_meta_only = is_header_only or is_meta_copy - is_consume = conversion == 'consume' or conversion is None + is_consume = conversion == 'convert' or conversion is None is_shallow_copy = conversion == 'shallow_copy' or is_header_only is_deep_copy = conversion == 'copy' or is_meta_copy # Verify mode @@ -1095,8 +1095,8 @@

      Parameters

      source : Reader
      Source to copy data from.
      conversion : str
      -
      Conversion mode, None is equivalent to the default mode. Supported modes are:
      'consume'       - (Default) Reader object will be
      -                  consumed and explicitly deleted.
      +
      Conversion mode, None is equivalent to the default mode. Supported modes are:
      'convert'       - (Default) Convert the Reader to a Writer
      +                  instance and explicitly delete the Reader.
       
       'copy'          - Reader objects will be deep copied.
       
      @@ -1137,8 +1137,8 @@ 

      Raises

      conversion : str Conversion mode, None is equivalent to the default mode. Supported modes are: - 'consume' - (Default) Reader object will be - consumed and explicitly deleted. + 'convert' - (Default) Convert the Reader to a Writer + instance and explicitly delete the Reader. 'copy' - Reader objects will be deep copied. @@ -1167,7 +1167,7 @@

      Raises

      is_header_only = conversion == 'copy_header' is_meta_copy = conversion == 'copy_metadata' is_meta_only = is_header_only or is_meta_copy - is_consume = conversion == 'consume' or conversion is None + is_consume = conversion == 'convert' or conversion is None is_shallow_copy = conversion == 'shallow_copy' or is_header_only is_deep_copy = conversion == 'copy' or is_meta_copy # Verify mode diff --git a/docs/examples.md b/docs/examples.md index ca8eb42..8b1ceba 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -64,8 +64,8 @@ Editing c3d files is possible by combining the use of `c3d.reader.Reader` and `c instances through the `c3d.reader.Reader.to_writer` method. To edit a file, open a file stream and pass it to the `c3d.reader.Reader` constructor. Use the `c3d.reader.Reader.to_writer` method to create an independent `c3d.writer.Writer` instance containing a heap copy of its file contents. -Rereading data frames from the reader and inserting them in reverse will then create a -looped version of the original motion sequence! +To edit the sequence, one can now reread the data frames from the reader and through inserting the +frames in reverse to create a looped version of the original motion sequence! import c3d From c27874491df5dc151f82f2a745608457d5726c04 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 11 Jul 2021 12:37:29 +0200 Subject: [PATCH 112/120] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 859c9ee..b4b9035 100644 --- a/README.rst +++ b/README.rst @@ -67,7 +67,7 @@ experience some issues. If you experience issues with a file or feature, feel fr to post an issue (preferably by including example file/code/python exception) or make a pull request! -The package is also Python only, for other languages there are other packages for example `ezc3d`_. +The package is Python only, support for other languages is available in other packages, for example see `ezc3d`_. .. _software examples: https://www.c3d.org/sampledata.html .. _ezc3d: https://github.com/pyomeca/ezc3d From fbc00053630a06364d0264f024fe8d1fce377398 Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 11 Jul 2021 12:38:11 +0200 Subject: [PATCH 113/120] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index b4b9035..4378b6a 100644 --- a/README.rst +++ b/README.rst @@ -59,7 +59,7 @@ Caveats The package is tested against the `software examples`_ but may still not support every possible format. For example, parameters serialized in multiple parameters -are not handled automatically (LABELS field stored in both POINT:LABELS and +are not handled automatically (such as a LABELS field stored in both POINT:LABELS and POINT:LABELS2). Reading and writing files from a big-endian system is also not supported. Tests are currently only run on Windows, which means that Linux and Mac users may From f4479c0c49990a2a2c1d8ee23b1bfcdc155d73ac Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 11 Jul 2021 12:40:09 +0200 Subject: [PATCH 114/120] Update README.rst --- README.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.rst b/README.rst index 4378b6a..da0c0d2 100644 --- a/README.rst +++ b/README.rst @@ -49,15 +49,15 @@ To use the C3D library, just import the package and create a ``Reader`` and/or print('frame {}: point {}, analog {}'.format( i, points.shape, analog.shape)) -You can also edit metadata fields using the library; see the `package -documentation`_ for more details. +The librart also provide functionality for editing both frame and metadata fields; +see the `package documentation`_ for more details. .. _package documentation: https://mattiasfredriksson.github.io/py-c3d/c3d/ Caveats ------- -The package is tested against the `software examples`_ but may still not support +The package is tested against the available `software examples`_ but may still not support every possible format. For example, parameters serialized in multiple parameters are not handled automatically (such as a LABELS field stored in both POINT:LABELS and POINT:LABELS2). Reading and writing files from a big-endian system is also not supported. From f0f5c50076d1d1c6060680bd5cafa67b9446c6fe Mon Sep 17 00:00:00 2001 From: MattiasFredriksson Date: Sun, 11 Jul 2021 12:40:54 +0200 Subject: [PATCH 115/120] Update README.rst --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index da0c0d2..1c81c8a 100644 --- a/README.rst +++ b/README.rst @@ -49,7 +49,7 @@ To use the C3D library, just import the package and create a ``Reader`` and/or print('frame {}: point {}, analog {}'.format( i, points.shape, analog.shape)) -The librart also provide functionality for editing both frame and metadata fields; +The library also provide functionality for editing both frame and metadata fields; see the `package documentation`_ for more details. .. _package documentation: https://mattiasfredriksson.github.io/py-c3d/c3d/ From 10038432ce8a581441ec02662d493a008fb8099d Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 15 Jul 2021 11:29:00 +0200 Subject: [PATCH 116/120] pycodestyle comments --- c3d/manager.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/c3d/manager.py b/c3d/manager.py index 031063c..816d6d6 100644 --- a/c3d/manager.py +++ b/c3d/manager.py @@ -342,16 +342,15 @@ def first_frame(self) -> int: @property def last_frame(self) -> int: - ''' Trial frame corresponding to the last frame recorded in the data (inclusive). ''' - # Number of frames can be represented in many formats, first check if valid header values - #if self.header.first_frame < self.header.last_frame and self.header.last_frame != 65535: - # return self.header.last_frame - - # Try different parameters where the frame can be encoded + ''' Trial frame corresponding to the last frame recorded in the data (inclusive). + ''' + # Number of frames can be represented in many formats. + # Start of by different parameters where the frame can be encoded hlf = self.header.last_frame param = self.get('TRIAL:ACTUAL_END_FIELD') if param is not None: # Encoded as 2 16 bit words (rather then 1 32 bit word) + # Manual refer to parsing the parameter as 2 16-bit words, but its equivalent to an uint32 # words = param.uint16_array # end_frame[1] = words[0] + words[1] * 65536 end_frame = param.uint32_value From 37a21e46f3b203fb4aafe975fa7fc1c2dac590b8 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 15 Jul 2021 13:08:31 +0200 Subject: [PATCH 117/120] pycodestyles --- examples/write.py | 2 +- scripts/c3d-metadata.py | 2 +- scripts/c3d-viewer.py | 6 +-- scripts/c3d2npz.py | 6 ++- test/test_c3d.py | 2 +- test/test_examples.py | 1 + test/test_group_accessors.py | 12 +++--- test/test_parameter_accessors.py | 8 ++-- test/test_parameter_bytes_conversion.py | 47 ++++++++++++----------- test/test_software_examples_write_read.py | 1 - test/verify.py | 10 ++--- 11 files changed, 49 insertions(+), 48 deletions(-) diff --git a/examples/write.py b/examples/write.py index 6a05b73..644d770 100644 --- a/examples/write.py +++ b/examples/write.py @@ -17,7 +17,7 @@ writer.set_point_labels(['RFT1', 'RFT2', 'RFT3', 'RFT4', 'LFT1', 'LFT2', 'LFT3', 'LFT4', 'RSK1', 'RSK2', 'RSK3', 'RSK4', 'LSK1', 'LSK2', 'LSK3', 'LSK4', 'RTH1', 'RTH2', 'RTH3', 'RTH4', 'LTH1', 'LTH2', 'LTH3', 'LTH4' - ]) + ]) writer.set_analog_labels(None) with open('random-points.c3d', 'wb') as h: diff --git a/scripts/c3d-metadata.py b/scripts/c3d-metadata.py index cb054f6..388fd6e 100644 --- a/scripts/c3d-metadata.py +++ b/scripts/c3d-metadata.py @@ -15,7 +15,7 @@ parser = argparse.ArgumentParser(description='Display C3D group and parameter information.') parser.add_argument('input', default='-', metavar='FILE', nargs='+', - help='process C3D data from this input FILE') + help='process C3D data from this input FILE') def print_metadata(reader): diff --git a/scripts/c3d-viewer.py b/scripts/c3d-viewer.py index b215e36..c7b252c 100644 --- a/scripts/c3d-viewer.py +++ b/scripts/c3d-viewer.py @@ -112,7 +112,7 @@ def __init__(self, c3d_reader, trace=None, paused=False): self.ry = 30 self.rz = 30 - #self.fps = pyglet.clock.ClockDisplay() + # self.fps = pyglet.clock.ClockDisplay() self.on_resize(self.width, self.height) @@ -158,7 +158,8 @@ def __init__(self, c3d_reader, trace=None, paused=False): len(vtx) // 3, idx, ('v3f/static', vtx), ('n3f/static', nrm)) def on_mouse_scroll(self, x, y, dx, dy): - if dy == 0: return + if dy == 0: + return self.zoom *= 1.1 ** (-1 if dy < 0 else 1) def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): @@ -170,7 +171,6 @@ def on_mouse_drag(self, x, y, dx, dy, buttons, modifiers): # roll self.ry += 0.2 * -dy self.rz += 0.2 * dx - #print('z', self.zoom, 't', self.ty, self.tz, 'r', self.ry, self.rz) def on_resize(self, width, height): glViewport(0, 0, width, height) diff --git a/scripts/c3d2npz.py b/scripts/c3d2npz.py index 4629b3c..843f83b 100644 --- a/scripts/c3d2npz.py +++ b/scripts/c3d2npz.py @@ -21,6 +21,7 @@ parser.add_argument('input', default='-', metavar='FILE', nargs='+', help='process data from this input FILE') parser.add_argument("-v", "--verbose", help="increase output verbosity", action="store_true") + def convert(filename, args): input = sys.stdin outname = '-' @@ -38,12 +39,13 @@ def convert(filename, args): np.savez(outname, points=points, analog=analog) print(outname + ': saved', len(points), "x", str(points[0].shape), "points,", - len(analog), analog[0].shape, 'analog' if len(analog) else () - ) + len(analog), analog[0].shape, 'analog' if len(analog) else () + ) if filename != '-': input.close() + def main(args): if args.verbose: logging.basicConfig(level=logging.DEBUG) diff --git a/test/test_c3d.py b/test/test_c3d.py index 1152dd1..c8bda28 100644 --- a/test/test_c3d.py +++ b/test/test_c3d.py @@ -135,8 +135,8 @@ def test_set_params(self): assert w.point_group.get(test_name).total_bytes == len(test_string), \ "Mismatch in number of bytes encoded by 'Group.add_str'" - w.write(h) + if __name__ == '__main__': unittest.main() diff --git a/test/test_examples.py b/test/test_examples.py index 7700b39..4724f40 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -46,5 +46,6 @@ def test_edit(self): # Raises 'FileNotFound' if the file was not generated os.remove(path) + if __name__ == '__main__': unittest.main() diff --git a/test/test_group_accessors.py b/test/test_group_accessors.py index 8d92531..a4e0a62 100644 --- a/test/test_group_accessors.py +++ b/test/test_group_accessors.py @@ -7,6 +7,7 @@ from test.zipload import Zipload from test.base import Base + class GroupSample(): ''' Helper object to verify group entries persist or terminate properly. ''' def __init__(self, manager): @@ -38,13 +39,13 @@ def sample(self): '''Call before applying changes. ''' self.s_grp_items, self.s_grp_list = self.fetch_groups - def assert_entry_count(self, delta=0): '''Assert all values in group still exist. Arguments --------- - delta: Number of entries added (+) or removed (-) since last sample. + delta : int + Number of entries added (+) or removed (-) since last sample. ''' grp_items, grp_list = self.fetch_groups @@ -193,7 +194,7 @@ def test_Manager_rename_group(self): writer.rename_group(new_names[0], new_names[1]) raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') except ValueError as e: - pass # Correct + pass # Correct def test_Manager_renumber_group(self): '''Test if renaming (renumbering) groups acts as intended.''' @@ -221,10 +222,7 @@ def test_Manager_renumber_group(self): writer.rename_group(max_key + 1, max_key + 2) raise RuntimeError('Overwriting existing numerical ID should raise a KeyError.') except ValueError as e: - pass # Correct - - - + pass # Correct if __name__ == '__main__': diff --git a/test/test_parameter_accessors.py b/test/test_parameter_accessors.py index 1be26ae..cbfa451 100644 --- a/test/test_parameter_accessors.py +++ b/test/test_parameter_accessors.py @@ -10,10 +10,12 @@ rnd = np.random.default_rng() + def add_dummy_param(group, name='TEST_NAME', shape=(10, 2), flt_range=(-1e6, 1e6)): arr = rnd.uniform(*flt_range, size=shape).astype(np.float32) group.add_param(name, bytes_per_element=4, dimensions=arr.shape, bytes=arr.T.tobytes()) + class ParamSample(): ''' Helper object to verify parameter entries persist or terminate properly. ''' def __init__(self, group): @@ -129,7 +131,7 @@ def test_Group_remove_param(self): ref.verify_add_parameter(100) def test_Group_rename_param(self): - '''Test if renaming groups acts as intended.''' + ''' Test if renaming groups acts as intended.''' reader = c3d.Reader(Zipload._get(self.ZIP, self.INTEL_REAL)) writer = reader.to_writer() @@ -149,9 +151,7 @@ def test_Group_rename_param(self): g.rename_param(new_names[0], new_names[1]) raise RuntimeError('Overwriting existing numerical ID should raise a ValueError.') except ValueError as e: - pass # Correct - - + pass # Correct if __name__ == '__main__': diff --git a/test/test_parameter_bytes_conversion.py b/test/test_parameter_bytes_conversion.py index 3525ccf..026dfc4 100644 --- a/test/test_parameter_bytes_conversion.py +++ b/test/test_parameter_bytes_conversion.py @@ -6,7 +6,7 @@ def genByteWordArr(word, shape): - ''' Generate a multi-dimensional byte array from a specific word. + ''' Generate a multi-dimensional byte array from a specific word. ''' arr = np.array(word) for d in shape[::-1]: @@ -15,7 +15,7 @@ def genByteWordArr(word, shape): def genRndByteArr(wordlen, shape, pad): - ''' Generate a multi-dimensional byte array with random data. + ''' Generate a multi-dimensional byte array with random data. ''' tot_len = wordlen + pad*wordlen arr = np.empty(shape, dtype=np.dtype('S'+str(tot_len))) @@ -28,16 +28,17 @@ def genRndByteArr(wordlen, shape, pad): def genRndFloatArr(shape, rnd, range=(-1e6, 1e6)): - ''' Generate a multi-dimensional array of 32 bit floating point data. + ''' Generate a multi-dimensional array of 32 bit floating point data. ''' return rnd.uniform(range[0], range[1], shape) + def make_param(*args, **kwargs): return ParamReadonly(ParamData(*args, **kwargs)) class ParameterValueTest(unittest.TestCase): - ''' Test read Parameter arrays + ''' Test read Parameter arrays ''' RANGE_8_BIT = (-127, 127) @@ -53,7 +54,7 @@ def setUp(self): self.dtypes = DataTypes(PROCESSOR_INTEL) def test_a_param_float32(self): - ''' Verify a single 32 bit floating point value is parsed correctly + ''' Verify a single 32 bit floating point value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.float32(self.rnd.uniform(*ParameterValueTest.RANGE_32_BIT)) @@ -64,7 +65,7 @@ def test_a_param_float32(self): (value_out, value) def test_b_param_int32(self): - ''' Verify a single 32 bit integer value is parsed correctly + ''' Verify a single 32 bit integer value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.int32(self.rnd.uniform(*ParameterValueTest.RANGE_32_BIT)) @@ -75,7 +76,7 @@ def test_b_param_int32(self): (value_out, value) def test_b_param_uint32(self): - ''' Verify a single 32 bit unsigned integer value is parsed correctly + ''' Verify a single 32 bit unsigned integer value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.uint32(self.rnd.uniform(*ParameterValueTest.RANGE_32_UNSIGNED_BIT)) @@ -86,7 +87,7 @@ def test_b_param_uint32(self): (value_out, value) def test_b_param_int16(self): - ''' Verify a single 16 bit integer value is parsed correctly + ''' Verify a single 16 bit integer value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.int16(self.rnd.uniform(*ParameterValueTest.RANGE_16_BIT)) @@ -97,7 +98,7 @@ def test_b_param_int16(self): (value_out, value) def test_b_param_uint16(self): - ''' Verify a single 16 bit unsigned integer value is parsed correctly + ''' Verify a single 16 bit unsigned integer value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.uint16(self.rnd.uniform(*ParameterValueTest.RANGE_16_UNSIGNED_BIT)) @@ -108,7 +109,7 @@ def test_b_param_uint16(self): (value_out, value) def test_b_param_int8(self): - ''' Verify a single 8 bit integer value is parsed correctly + ''' Verify a single 8 bit integer value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.int8(self.rnd.uniform(*ParameterValueTest.RANGE_8_BIT)) @@ -119,7 +120,7 @@ def test_b_param_int8(self): (value_out, value) def test_b_param_uint8(self): - ''' Verify a single 8 bit unsigned integer value is parsed correctly + ''' Verify a single 8 bit unsigned integer value is parsed correctly ''' for i in range(ParameterValueTest.TEST_ITERATIONS): value = np.uint8(self.rnd.uniform(*ParameterValueTest.RANGE_8_UNSIGNED_BIT)) @@ -131,7 +132,7 @@ def test_b_param_uint8(self): class ParameterArrayTest(unittest.TestCase): - ''' Test read Parameter arrays + ''' Test read Parameter arrays ''' SHAPES = [[7, 6, 5], [7, 5, 3], [7, 3], [19]] @@ -141,7 +142,7 @@ def setUp(self): self.dtypes = DataTypes(PROCESSOR_INTEL) def test_a_parse_float32_array(self): - ''' Verify array of 32 bit floating point values are parsed correctly + ''' Verify array of 32 bit floating point values are parsed correctly ''' flt_range = (-1e6, 1e6) @@ -153,7 +154,7 @@ def test_a_parse_float32_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading float array' def test_b_parse_float64_array(self): - ''' Verify array of 64 bit floating point values are parsed correctly + ''' Verify array of 64 bit floating point values are parsed correctly ''' flt_range = (-1e6, 1e6) @@ -165,7 +166,7 @@ def test_b_parse_float64_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading float array' def test_c_parse_int32_array(self): - ''' Verify array of 32 bit integer values are parsed correctly + ''' Verify array of 32 bit integer values are parsed correctly ''' flt_range = (-1e6, 1e6) @@ -177,7 +178,7 @@ def test_c_parse_int32_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading int32 array' def test_d_parse_uint32_array(self): - ''' Verify array of 32 bit unsigned integer values are parsed correctly + ''' Verify array of 32 bit unsigned integer values are parsed correctly ''' flt_range = (0, 1e6) @@ -189,7 +190,7 @@ def test_d_parse_uint32_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading uint32 array' def test_e_parse_int16_array(self): - ''' Verify array of 16 bit integer values are parsed correctly + ''' Verify array of 16 bit integer values are parsed correctly ''' flt_range = (-1e4, 1e4) @@ -201,7 +202,7 @@ def test_e_parse_int16_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading int32 array' def test_f_parse_uint16_array(self): - ''' Verify array of 16 bit unsigned integer values are parsed correctly + ''' Verify array of 16 bit unsigned integer values are parsed correctly ''' flt_range = (0, 1e4) @@ -213,7 +214,7 @@ def test_f_parse_uint16_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading uint32 array' def test_g_parse_int8_array(self): - ''' Verify array of 8 bit integer values are parsed correctly + ''' Verify array of 8 bit integer values are parsed correctly ''' flt_range = (-127, 127) @@ -225,7 +226,7 @@ def test_g_parse_int8_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading int32 array' def test_h_parse_uint8_array(self): - ''' Verify array of 8 bit unsigned integer values are parsed correctly + ''' Verify array of 8 bit unsigned integer values are parsed correctly ''' flt_range = (0, 255) @@ -237,7 +238,7 @@ def test_h_parse_uint8_array(self): assert np.all(arr.T == arr_out), 'Value mismatch when reading uint32 array' def test_i_parse_byte_array(self): - ''' Verify byte arrays are parsed correctly + ''' Verify byte arrays are parsed correctly ''' word = b'WRIST' @@ -269,7 +270,7 @@ def test_i_parse_byte_array(self): assert np.all(arr[i[::-1]] == arr_out[i]), "Mismatch in 'bytes_array' converted value at index %s" % str(i) def test_j_parse_string_array(self): - ''' Verify repeated word arrays are parsed correctly + ''' Verify repeated word arrays are parsed correctly ''' word = b'ANCLE' @@ -307,7 +308,7 @@ def test_j_parse_string_array(self): "Mismatch in 'string_array' converted value at index %s" % str(i) def test_k_parse_random_string_array(self): - ''' Verify random word arrays are parsed correctly + ''' Verify random word arrays are parsed correctly ''' ## # RND diff --git a/test/test_software_examples_write_read.py b/test/test_software_examples_write_read.py index 3f02ae9..df6125a 100644 --- a/test/test_software_examples_write_read.py +++ b/test/test_software_examples_write_read.py @@ -89,6 +89,5 @@ def test_read_write_examples(self): print('Done.') - if __name__ == '__main__': unittest.main() diff --git a/test/verify.py b/test/verify.py index 807ee80..37de442 100644 --- a/test/verify.py +++ b/test/verify.py @@ -110,13 +110,13 @@ def check_zipfile(file_path): analog_min = np.min(analog) analog_max = np.max(analog) - assert np.all(npoint == reader.frame_count), '\n' + \ + assert np.all(npoint == reader.frame_count), '\n' +\ 'Failed verifying POINT data in range ({}, {}).\n'.format(min_range, max_range) +\ 'Found a total of {} values outside plausible range.\n'.format( np.sum(np.abs(npoint - reader.frame_count), axis=0)) +\ 'Range for data was ({}, {})'.format(point_min, point_max) - assert np.all(nanalog == reader.analog_sample_count),'\n' + \ + assert np.all(nanalog == reader.analog_sample_count), '\n' +\ 'Failed verifying ANALOG data in range ({}, {}).\n'.format(min_range, max_range) +\ 'Found a total of {} values outside plausible range.\n'.format( np.abs(nanalog - reader.analog_sample_count)) +\ @@ -311,7 +311,7 @@ def data_is_equal(areader, breader, alabel, blabel): assert axis_notclose == 0, '\n' +\ 'Mismatched coordinates on {} axis between {} and {}.\n'.format(c[i], alabel, blabel) +\ 'Samples with absolute difference larger then {:0.4f}: {} of {}.\n'.format( - atol, axis_notclose, tot_points) +\ + atol, axis_notclose, tot_points) +\ 'Maximum difference: {}'.format(np.max(np.abs(apoint[:, i] - bpoint[:, i]))) # Word 4 (residual + camera bits) @@ -321,8 +321,8 @@ def data_is_equal(areader, breader, alabel, blabel): # Camera bit errors (warn if non identical, allow 1 cam bit diff, might be bad DEC implementation, or bad data) if cam_diff_non_equal > 0: - #print(apoint[~cam_close, 4]) - #print(bpoint[~cam_close, 4]) + # print(apoint[~cam_close, 4]) + # print(bpoint[~cam_close, 4]) cam_close = np.isclose(apoint[:, 4], bpoint[:, 4], atol=1.001) cam_diff = tot_points - np.sum(cam_close) assert cam_diff == 0, '\n' + \ From e103f4fb8cd64471250b48d3674ab153cea0601e Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 15 Jul 2021 13:12:35 +0200 Subject: [PATCH 118/120] pycodestyles --- test/README.rst | 2 -- 1 file changed, 2 deletions(-) diff --git a/test/README.rst b/test/README.rst index ed74c76..bf286c0 100644 --- a/test/README.rst +++ b/test/README.rst @@ -8,5 +8,3 @@ To run tests, use the following command from the root of the package directory:: Test scripts will automatically download test files from `c3d.org`_. .. _c3d.org: https://www.c3d.org/sampledata.html - -Currently no code style is enforced. From 4b4715dfd9ad6d0df373a32abbe62c0a4678ba08 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 15 Jul 2021 15:40:06 +0200 Subject: [PATCH 119/120] missing sys in c3d2npz and added info print to c3d-viewer.py --- scripts/c3d-viewer.py | 8 +++++--- scripts/c3d2npz.py | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/scripts/c3d-viewer.py b/scripts/c3d-viewer.py index b215e36..f3cc943 100644 --- a/scripts/c3d-viewer.py +++ b/scripts/c3d-viewer.py @@ -12,9 +12,11 @@ import collections import contextlib import numpy as np -import pyglet - -from pyglet.gl import * +try: + import pyglet + from pyglet.gl import * +except ModuleNotFoundError as e: + sys.stdout.write("View requires pyglet to be installed. Install pyglet through the command 'pip insall pyglet'") parser = argparse.ArgumentParser(description='A simple OpenGL viewer for C3D files.') parser.add_argument('inputs', nargs='+', metavar='FILE', help='show these c3d files') diff --git a/scripts/c3d2npz.py b/scripts/c3d2npz.py index 4629b3c..730c35d 100644 --- a/scripts/c3d2npz.py +++ b/scripts/c3d2npz.py @@ -9,6 +9,7 @@ import logging import gzip import numpy as np +import sys from tempfile import TemporaryFile try: import c3d From 36c377763edcfa348fb6e272a8455a69d63ef225 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Thu, 15 Jul 2021 15:52:22 +0200 Subject: [PATCH 120/120] removed the added info msg --- scripts/c3d-viewer.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/scripts/c3d-viewer.py b/scripts/c3d-viewer.py index 18cf8d2..6096019 100644 --- a/scripts/c3d-viewer.py +++ b/scripts/c3d-viewer.py @@ -12,11 +12,8 @@ import collections import contextlib import numpy as np -try: - import pyglet - from pyglet.gl import * -except ModuleNotFoundError as e: - sys.stdout.write("View requires pyglet to be installed. Install pyglet through the command 'pip insall pyglet'") +import pyglet +from pyglet.gl import * parser = argparse.ArgumentParser(description='A simple OpenGL viewer for C3D files.') parser.add_argument('inputs', nargs='+', metavar='FILE', help='show these c3d files')

    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 gfSnfBWHX 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 XO(lbF8(cEzo&hEQ_2K`c|Ka}t!@My? literal 0 HcmV?d00001 diff --git a/examples/read.py b/examples/read.py new file mode 100644 index 0000000..e85e885 --- /dev/null +++ b/examples/read.py @@ -0,0 +1,18 @@ +import numpy as np +import os +try: + import c3d +except ModuleNotFoundError: + # Force load package with no instalation + import sys + sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..\\')) + import c3d + +# My motion == Sample01.Eb015pi.c3d +file_path = os.path.join(os.path.dirname(__file__), 'my-motion.c3d') + +with open(file_path, 'rb') as file: + reader = c3d.Reader(file) + for i, points, analog in reader.read_frames(): + print('frame {}: point {}, analog {}'.format( + i, points.shape, analog.shape)) diff --git a/test/test_examples.py b/test/test_examples.py index aad4f8c..848857e 100644 --- a/test/test_examples.py +++ b/test/test_examples.py @@ -30,6 +30,16 @@ def test_write(self): # Raises 'FileNotFound' if the file was not generated os.remove(path) + def test_edit(self): + import edit + path = 'my-looped-motion.c3d' + + with open(path, 'rb') as f: + reader = c3d.Reader(f) + assert reader.frame_count == 900, \ + 'Expected 900 point frames in edit.py test, was {}'.format(reader.frame_count) + # Raises 'FileNotFound' if the file was not generated + os.remove(path) if __name__ == '__main__': unittest.main() From 914fb568986c05aeb4c337b39a8644a0259abfe2 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 15:37:16 +0200 Subject: [PATCH 068/120] Exclude looped motion --- .gitignore | 3 ++- examples/my-looped-motion.c3d | Bin 311808 -> 0 bytes 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 examples/my-looped-motion.c3d diff --git a/.gitignore b/.gitignore index d6eceab..8746a79 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ # C extensions *.so +*.c3d # Packages *.egg @@ -41,4 +42,4 @@ docs/_build .vscode/ # mypy -.mypy_cache/ \ No newline at end of file +.mypy_cache/ diff --git a/examples/my-looped-motion.c3d b/examples/my-looped-motion.c3d deleted file mode 100644 index 428d5b1d59f917c921d677692ba36aa9cbc77d85..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 311808 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">Zky|Jft)ntd7Y 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 From 3bbc73f19ee130b73fb8793ea6730b2aea0029f5 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 15:41:02 +0200 Subject: [PATCH 069/120] Added reference to example/ dir --- docs/examples.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/examples.md b/docs/examples.md index 6917c66..4c77c3a 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -4,6 +4,7 @@ Examples ======== Access to data blocks in a .c3d file is provided through the `c3d.reader.Reader` and `c3d.writer.Writer` classes. +Implementation of the examples are provided in the `example/` in the github repository. Reading ------- From a9e1771ac28d0536e22b96daa7940b9baed45155 Mon Sep 17 00:00:00 2001 From: MattiasF Date: Sun, 4 Jul 2021 15:45:09 +0200 Subject: [PATCH 070/120] Docs --- docs/c3d/dtypes.html | 127 ++++++++++ docs/c3d/group.html | 536 ++++++++++++++++++++++++++++++++++++++++ docs/c3d/header.html | 206 +++++++++++++++ docs/c3d/index.html | 176 +++++++++++++ docs/c3d/manager.html | 306 +++++++++++++++++++++++ docs/c3d/parameter.html | 401 ++++++++++++++++++++++++++++++ docs/c3d/reader.html | 239 ++++++++++++++++++ docs/c3d/utils.html | 163 ++++++++++++ docs/c3d/writer.html | 355 ++++++++++++++++++++++++++ docs/examples.md | 9 +- 10 files changed, 2517 insertions(+), 1 deletion(-) create mode 100644 docs/c3d/dtypes.html create mode 100644 docs/c3d/group.html create mode 100644 docs/c3d/header.html create mode 100644 docs/c3d/index.html create mode 100644 docs/c3d/manager.html create mode 100644 docs/c3d/parameter.html create mode 100644 docs/c3d/reader.html create mode 100644 docs/c3d/utils.html create mode 100644 docs/c3d/writer.html diff --git a/docs/c3d/dtypes.html b/docs/c3d/dtypes.html new file mode 100644 index 0000000..729ad33 --- /dev/null +++ b/docs/c3d/dtypes.html @@ -0,0 +1,127 @@ + + + + + + +c3d.dtypes API documentation + + + + + + + + + + + +