ezc3d: Potential bug when writing c3d file with rotations

Hi,

I am using python 3.12 and ezc3d 1.5.9 on a macOS system. I was working on writing c3d files using this great package and was thrilled to see that you added support for c3d files with rotation matrices last year. Reading in the files is really easy and works as expected.

However, when writing c3d files with rotations I noticed some unexpected behaviour (which might be because I am not fully aware of the proper usecase of the package). To exemplify I used this example file from the c-motion site. When reading in the data, everything works as expected:

from ezc3d import c3d
read_file = c3d("C3DRotationExample.c3d")
read_file["data"]["rotations"] # np.ndarray of shape (4, 4, 21, 340)
read_file["header"]["rotations"] # {'size': 21, 'frame_rate': 85.0, 'first_frame': 0.0, 'last_frame': 28899.0}

When writing files, it seems like the “rotations” are not expected:

from ezc3d import c3d
write_file = c3d()
write_file["data"].keys() # dict_keys(['points', 'meta_points', 'analogs'])
write_file["header"].keys() # dict_keys(['points', 'analogs', 'events'])

# manually try to add header info and data
import numpy as np
trans_mat = np.random.rand(4, 4, 100)
write_file["data"]["rotations"] =  trans_mat[:, :, np.newaxis, :] # works fine, just adding a new key
write_file["header"]["rotations"] = {'size': 1, 'frame_rate': 85.0, 'first_frame': 0.0, 'last_frame':100.0} # raises a TypeError `*** TypeError: 'Header' object does not support item assignment`

As you can see, the "rotations" is not pre-added to the data and header objects, whereas all other possibilities are. For the data that is not a big problem since you can just add a new key, however, that is not allowed for the headers. Is there a workaround for this?

ADDED LATER: I think I may have stumbled on another unexpected behaviour when writing rotations. When reading rotation matrixes where there are missing values, there are no real problems:

import ezc3d
file = ezc3d.c3d("C3DRotationExample.c3d")
print(file["data"]["rotations"].shape)
print(file["data"]["rotations"][:, :, 1, 0])
4, 4, 21, 340)
[[nan nan nan nan]
 [nan nan nan nan]
 [nan nan nan nan]
 [ 0.  0.  0.  1.]]

If I now want to write that same rotation matrix, and read it again, I get the following:

time = np.linspace(0, 1, 10, endpoint=False)
frame_rate = 10

# create empty points
c3d = ezc3d.c3d()
c3d["header"]["points"]["size"] = 0
c3d["header"]["points"]["frame_rate"] = frame_rate
c3d["header"]["points"]["first_frame"] = 0
c3d["header"]["points"]["last_frame"] = 9
c3d.add_parameter("POINT", "RATE", [frame_rate])
c3d.add_parameter("POINT", "LABELS", [])
c3d.add_parameter("POINT", "UNITS", ["m"])
c3d.add_parameter("POINT", "USED", [0])
c3d["data"]["points"] = np.empty((4, 0, len(time)))

# create rotations
c3d["data"]["rotations"] = file["data"]["rotations"][:, :, 1:2, 0:10]

c3d.add_parameter("ROTATION", "RATE", [frame_rate])
c3d.add_parameter("ROTATION", "LABELS", ["pelvis_4X4"])
c3d.add_parameter("ROTATION", "UNITS", [])
c3d.add_parameter("ROTATION", "USED", [1])

# before writing and reading
print(c3d["data"]["rotations"][:, :, 0, 0])

c3d.write("test.c3d")
file2 = ezc3d.c3d("test.c3d")

# after writing and reading
print(file2["data"]["rotations"].shape)
print(file2["data"]["rotations"][:, :, 0, 0])
[[nan nan nan nan]
 [nan nan nan nan]
 [nan nan nan nan]
 [ 0.  0.  0.  1.]]
(4, 4, 1, 10)
[[nan nan nan nan]
 [nan nan nan nan]
 [nan nan nan nan]
 [nan nan nan nan]]

As you can see, after writing and reading the full matrix is filled with nans. Although it is not a huge problem in analysis, since you can not read the transformations from the initial transoframtion matrices either. I do think it is unexpected behaviour. In my situation, it creates problems when writing tests for the kineticstoolkit where I want to test if we correctly parse the writing and reading of ezc3d commands.

About this issue

  • Original URL
  • State: closed
  • Created 4 months ago
  • Comments: 15 (9 by maintainers)

Most upvoted comments

I think all issues are fixed:

  • The last_frame in the header rotations object now represents the index of the last frame
  • The header object now has a initialized "rotations" key
  • If I change the start_frame in header["rotations"] it works similar as with the points and analogs

Thanks again!

Is this just something which is counterintuitive to me but actual logical as you shortly explained here?

Nah, that is just me being dumb. Obviously the computation should not be self.header.frameRate() * rotation_info.ratio() * (self.header.lastFrame() + 1) - 1 but simply rotation_info.ratio() * (self.header.lastFrame() + 1) - 1.

Fixed in #317 !

Hi @pariterre

Thanks for both explenations! I was not aware that the headers object was written automatically based on the parameters, but then it makes sense.

I understand your second point as well, and from a efficiency and functionality perspective you are totally right. From a users perspective I still think it is a bit unexpected to write an array with partially floats, and a get back fully nans, but I am also not aware of how much extra time/resources it would take to still do that. On top of that, you created a great package and your suggestion solves my current problem.

Thanks for the clear explenation and I think the issue can be closed!