Skip to content

Commit d20c345

Browse files
authored
initial commit
0 parents  commit d20c345

16 files changed

+624
-0
lines changed

LICENSE.txt

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2024 Ruel Nathaniel Alarcon
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# cpf3d
2+
3+
> A python library to read and edit [3cpf](https://github.com/ruelalarcon/3cpf) files. Requires
4+
> python 3.8.
5+
6+
**Features**
7+
8+
- Read 3cpf files into easily usable data
9+
- Create new points and frames
10+
- Apply basic position, scale, and rotation transformations to all points in a 3cpf file
11+
- Write 3cpf data into 3cpf files
12+
13+
## Installation
14+
15+
The package can be installed with `pip`.
16+
17+
```bash
18+
pip install cpf3d
19+
```
20+
21+
## Usage
22+
23+
### Reading files
24+
25+
The `cpf3d.load` function can be used to load a 3cpf files as a `cpf3d.PointFrames` object, which has points and frames.
26+
27+
```python
28+
import cpf3d
29+
30+
# Load a 3cpf file
31+
pf = cpf3d.load('miku_example.3cpf')
32+
33+
# From here, we can access any necessary data
34+
print('# of Points:', len(pf.points))
35+
print('# of Frames:', len(pf.frames))
36+
37+
# Point colors
38+
print('\nColors of First 5 Points:')
39+
for point in pf.points[:5]:
40+
print(point.color)
41+
42+
# Position of specific point at specific frame
43+
print('\nPositions of Point 0 Throughout First 5 Frames:')
44+
for i in range(5):
45+
print(pf.get_position(0, i))
46+
```
47+
Output:
48+
```
49+
# of Points: 600
50+
# of Frames: 60
51+
52+
Colors of First 5 Points:
53+
(112, 112, 112)
54+
(112, 112, 112)
55+
(0, 0, 0)
56+
(112, 112, 112)
57+
(112, 112, 112)
58+
59+
Positions of Point 0 Throughout First 5 Frames:
60+
[-0.01653824 0.05377164 1.10009 ]
61+
[-0.01205087 0.05788074 1.0995283 ]
62+
[-0.00502312 0.06201064 1.1008564 ]
63+
[0.00529542 0.06594937 1.1022888 ]
64+
[0.01859665 0.06944766 1.1035271 ]
65+
```
66+
67+
If your use-case uses a different coordinate order (XYZ vs. YXZ for example), you can load a 3cpf with any coordinate order of your choice.
68+
```python
69+
import cpf3d
70+
71+
# Load a 3cpf file, with dimensions in the order of xzy, rather than the default xyz
72+
pf = cpf3d.load('miku_example.3cpf', 'xzy')
73+
```
74+
75+
### Editing and Creating 3cpf Files
76+
77+
You can edit instances of `cpf3d.PointFrames` and save them as 3cpf files.
78+
79+
```python
80+
import cpf3d
81+
82+
# Editing an existing 3cpf file
83+
pf = nbtlib.load('miku_example.3cpf')
84+
85+
# Rotate entire animation 90 degrees along the Z-axis
86+
# Scale to half size across all dimensions
87+
# And move 1 along the X-axis
88+
pf.apply_rotation(0, 0, 90) \
89+
.apply_scale(.5, .5, .5) \
90+
.apply_offset(1, 0, 0)
91+
92+
# Save the now-transformed point frames into a new file
93+
pf.save('miku_example_transformed.3cpf')
94+
```
95+
96+
Or write a 3cpf file from scratch.
97+
```python
98+
import cpf3d
99+
from cpf3d import PointFrames, Point, Frame
100+
101+
# Creating a 3cpf file from scratch
102+
custom_pf = PointFrames()
103+
104+
point_r = Point(255, 0, 0) # Red point
105+
point_g = Point(0, 255, 0) # Green point
106+
107+
custom_pf.add_point(point_r)
108+
custom_pf.add_point(point_g)
109+
110+
# First frame: Red point at 1,1,1. Green point at 2,2,2
111+
frame_1 = Frame([[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]])
112+
113+
# Second frame: Red point at 2,2,2. Green point at 4,4,4
114+
frame_2 = Frame([[2.0, 2.0, 2.0], [4.0, 4.0, 4.0]])
115+
custom_pf.add_frame(frame_1)
116+
custom_pf.add_frame(frame_2)
117+
118+
# Scale it all by ten times
119+
custom_pf.apply_scale(10, 10, 10)
120+
121+
# Export to a 3cpf file
122+
custom_pf.save('my_pointframes.3cpf')
123+
```
124+
125+
### Adding Points or Frames to Existing 3cpf Data
126+
127+
There are a variety of restrictions related to adding points or frames to existing 3cpf animations.
128+
129+
**Firstly,** you cannot add frames *before* adding any points.
130+
```python
131+
pf = PointFrames()
132+
133+
# Will cause an error, as there are no points to attach this positional data to
134+
frame = Frame([[1.0, 2.0, 3.0]])
135+
```
136+
137+
**Secondly,** when adding new frames, you must have one positional entry for each point.
138+
```python
139+
# Assume this 3cpf file contains 2 points
140+
pf = cpf3d.load('2points.3cpf')
141+
142+
# Will cause an error, as we are adding a frame with 1 position, but there are 2 points
143+
frame = Frame([[1.0, 2.0, 3.0]])
144+
pf.add_frame(frame)
145+
146+
# Similarly, this would also error
147+
frame = Frame([[1.0, 2.0, 3.0], [1.0, 2.0, 3.0], [1.0, 2.0, 3.0]])
148+
pf.add_frame(frame)
149+
```
150+
151+
**Lastly,** when adding new points, if you already have frames in your 3cpf file, you must provide positions for your new point at each frame.
152+
153+
```python
154+
# Assume this 3cpf file contains 2 points and 2 frames
155+
pf = cpf3d.load('2points2frames.3cpf')
156+
157+
# Will cause an error, as there are already frames in this 3cpf animation
158+
point = Point(255, 255, 255)
159+
pf.add_point(point)
160+
```
161+
162+
We would instead need to do something like this:
163+
```python
164+
pf = cpf3d.load('2points2frames.3cpf')
165+
166+
point = Point(255, 255, 255)
167+
168+
# This point moves from 1,1,1 to 2,2,2 through frames 0 and 1
169+
positions = [[1.0, 1.0, 1.0], [2.0, 2.0, 2.0]]
170+
171+
pf.add_frame(point, positions) # This works fine
172+
```
173+
174+
This is necessary due to the nature of 3cpf files, in that each point must have corresponding positional data within each frame chunk.
175+
176+
## Contributing
177+
178+
Contributions are welcome. This project is packaged with python's built-in [setuptools](https://setuptools.pypa.io/en/latest/).
179+
180+
To install this package locally for development, you can clone or download this repository and navigate to it in your terminal.
181+
182+
You should now be able to install it locally, including development dependencies.
183+
184+
```bash
185+
pip install -e .[dev]
186+
```
187+
188+
You can run the tests by simply executing pytest from the top directory.
189+
190+
```bash
191+
pytest
192+
```

cpf3d/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .frame import Frame
2+
from .point import Point
3+
from .point_frames import PointFrames, load

cpf3d/frame.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from typing import List, Tuple, Union
2+
3+
import numpy as np
4+
5+
6+
class Frame:
7+
def __init__(self, positions: Union[np.ndarray, List[Tuple[float, float, float]]]):
8+
self.positions = np.array(positions)
9+
10+
def __str__(self):
11+
return f'Frame(#positions={len(self.positions)})'
12+
13+
def __repr__(self):
14+
with np.printoptions(threshold=np.inf):
15+
return f'Frame(positions={self.positions})'

cpf3d/point.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
class Point:
2+
def __init__(self, r: int, g: int, b: int):
3+
self.color = (r, g, b)
4+
5+
def __str__(self):
6+
return f'Point(color={self.color})'
7+
8+
def __repr__(self):
9+
return f'Point(color={self.color})'

cpf3d/point_frames.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import struct
2+
from typing import List, Tuple, Union
3+
from zlib import crc32
4+
5+
import numpy as np
6+
7+
from .frame import Frame
8+
from .point import Point
9+
10+
11+
class PointFrames:
12+
def __init__(self):
13+
self.points = []
14+
self.frames = []
15+
16+
def add_point(self, point: Point, positions: Union[np.ndarray, List[Tuple[float, float, float]]] = None):
17+
if len(self.frames) != 0 and (positions is None or len(positions) != len(self.frames)):
18+
raise ValueError('When adding a new point to a PointFrame that already has frames, you must provide a positions array for its position at each frame')
19+
self.points.append(point)
20+
21+
if positions is None:
22+
return
23+
24+
for frame, position in zip(self.frames, positions):
25+
frame.positions = np.append(frame.positions, [position], axis=0)
26+
27+
def add_frame(self, frame: Frame):
28+
if len(self.points) == 0:
29+
raise ValueError('Cannot add frames to a PointFrame with no points')
30+
31+
if len(frame.positions) != len(self.points):
32+
raise ValueError('Frame must contain 1 position for each existing point')
33+
self.frames.append(frame)
34+
35+
def get_position(self, point_index: int, frame_index: int) -> Tuple[float, float, float]:
36+
return self.frames[frame_index].positions[point_index]
37+
38+
def get_positions(self, frame_index: int) -> np.ndarray:
39+
return self.frames[frame_index].positions
40+
41+
def apply_offset(self, ax1: float, ax2: float, ax3: float):
42+
offset = (ax1, ax2, ax3)
43+
for frame in self.frames:
44+
frame.positions += np.array(offset)
45+
46+
return self
47+
48+
def apply_rotation(self, deg1: float, deg2: float, deg3: float):
49+
degrees = np.radians((deg1, deg2, deg3))
50+
51+
ax1 = np.array([[1, 0, 0],
52+
[0, np.cos(degrees[0]), -np.sin(degrees[0])],
53+
[0, np.sin(degrees[0]), np.cos(degrees[0])]])
54+
55+
ax2 = np.array([[np.cos(degrees[1]), 0, np.sin(degrees[1])],
56+
[0, 1, 0],
57+
[-np.sin(degrees[1]), 0, np.cos(degrees[1])]])
58+
59+
ax3 = np.array([[np.cos(degrees[2]), -np.sin(degrees[2]), 0],
60+
[np.sin(degrees[2]), np.cos(degrees[2]), 0],
61+
[0, 0, 1]])
62+
63+
rotation_matrix = np.dot(ax3, np.dot(ax2, ax1))
64+
65+
for frame in self.frames:
66+
frame.positions = np.dot(frame.positions, rotation_matrix.T)
67+
68+
return self
69+
70+
def apply_scale(self, ax1: float, ax2: float, ax3: float):
71+
scale = (ax1, ax2, ax3)
72+
for frame in self.frames:
73+
frame.positions *= np.array(scale)
74+
75+
return self
76+
77+
def save(self, file_path: str):
78+
with open(file_path, 'wb') as file:
79+
version = 1
80+
81+
file.write(b'3CPF')
82+
file.write(struct.pack('I', version))
83+
checksum_offset = file.tell()
84+
file.write(b'\x00\x00\x00\x00')
85+
file.write(struct.pack('I', len(self.points)))
86+
file.write(struct.pack('I', len(self.frames)))
87+
88+
point_color_data = bytearray()
89+
frame_position_data = bytearray()
90+
91+
for point in self.points:
92+
point_color_data += struct.pack('3B', *point.color)
93+
94+
for frame in self.frames:
95+
for position in frame.positions:
96+
frame_position_data += struct.pack('3f', *position)
97+
98+
file.write(point_color_data)
99+
file.write(frame_position_data)
100+
101+
checksum = crc32(point_color_data + frame_position_data) & 0xffffffff
102+
103+
with open(file_path, 'r+b') as file:
104+
file.seek(checksum_offset)
105+
file.write(struct.pack('I', checksum))
106+
107+
def __str__(self):
108+
return f'PointFrames(#points={len(self.points)}, #frames={len(self.frames)})'
109+
110+
def __repr__(self):
111+
return f'PointFrames(points={repr(self.points)}, frames={repr(self.frames)})'
112+
113+
def load(file_path: str, coordinate_order: str = 'xyz') -> PointFrames:
114+
animation = PointFrames()
115+
116+
coordinate_order = coordinate_order.lower()
117+
if len(coordinate_order) != 3 or set(coordinate_order) != {'x', 'y', 'z'}:
118+
raise ValueError("coordinate_order must be a 3-letter string containing 'x', 'y', and 'z'")
119+
120+
with open(file_path, 'rb') as file:
121+
magic_number = file.read(4)
122+
if magic_number != b'3CPF':
123+
raise ValueError('Invalid file format')
124+
125+
version_number, checksum, total_points, total_frames = struct.unpack('4I', file.read(16))
126+
127+
data_bytes = file.read()
128+
calculated_checksum = crc32(data_bytes) & 0xffffffff
129+
if calculated_checksum != checksum:
130+
raise ValueError('Data corruption detected')
131+
132+
offset = 0
133+
for _ in range(total_points):
134+
r_color, g_color, b_color = struct.unpack_from('3B', data_bytes, offset)
135+
animation.points.append(Point(r_color, g_color, b_color))
136+
offset += 3
137+
138+
indices = [coordinate_order.index('x'), coordinate_order.index('y'), coordinate_order.index('z')]
139+
for _ in range(total_frames):
140+
positions = np.empty((total_points, 3), dtype=np.float32)
141+
for point_index in range(total_points):
142+
coords = struct.unpack_from('3f', data_bytes, offset)
143+
positions[point_index] = [coords[i] for i in indices]
144+
offset += 12
145+
animation.frames.append(Frame(positions))
146+
147+
return animation

setup.cfg

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[metadata]
2+
description-file = README.md

0 commit comments

Comments
 (0)