bin2path 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
bin2path/features.py ADDED
@@ -0,0 +1,155 @@
1
+ """Extract features from a path for clustering."""
2
+
3
+ from bin2path.path import Path3D
4
+ import numpy as np
5
+
6
+
7
+ def features(path: Path3D) -> dict:
8
+ """
9
+ Extract features from a 3D path for clustering.
10
+
11
+ Args:
12
+ path: A Path3D object.
13
+
14
+ Returns:
15
+ Dictionary with feature values.
16
+ """
17
+ vertices = np.array(path.vertices)
18
+ edges = path.edges
19
+
20
+ # Basic metrics
21
+ num_vertices = len(vertices)
22
+ num_edges = len(edges)
23
+
24
+ # Path length (number of steps)
25
+ path_length = num_edges
26
+
27
+ # Calculate turn count from edges
28
+ turns = _count_turns(path)
29
+
30
+ # Center of mass
31
+ center = vertices.mean(axis=0)
32
+
33
+ # Bounding box
34
+ min_coords = vertices.min(axis=0)
35
+ max_coords = vertices.max(axis=0)
36
+ bbox_size = max_coords - min_coords
37
+
38
+ # Start and end points
39
+ start = path.vertices[0]
40
+ end = path.vertices[-1]
41
+ displacement = (
42
+ end[0] - start[0],
43
+ end[1] - start[1],
44
+ end[2] - start[2],
45
+ )
46
+ direct_distance = np.linalg.norm(displacement)
47
+
48
+ # Direction histogram
49
+ dir_histogram = _direction_histogram(path)
50
+
51
+ # Self-intersections (simplified check)
52
+ self_intersections = _count_self_intersections(path)
53
+
54
+ # Straight segment lengths histogram
55
+ straight_segments = _straight_segment_lengths(path)
56
+
57
+ return {
58
+ "path_length": path_length,
59
+ "turns": turns,
60
+ "vertices_count": num_vertices,
61
+ "center_x": float(center[0]),
62
+ "center_y": float(center[1]),
63
+ "center_z": float(center[2]),
64
+ "bbox_x": float(bbox_size[0]),
65
+ "bbox_y": float(bbox_size[1]),
66
+ "bbox_z": float(bbox_size[2]),
67
+ "displacement_x": displacement[0],
68
+ "displacement_y": displacement[1],
69
+ "displacement_z": displacement[2],
70
+ "direct_distance": direct_distance,
71
+ "straightness": direct_distance / num_edges if num_edges > 0 else 0,
72
+ "direction_histogram": dir_histogram,
73
+ "self_intersections": self_intersections,
74
+ "straight_segments": straight_segments,
75
+ "original_number": path.metadata.original_number,
76
+ "bits_length": path.metadata.bits_length,
77
+ }
78
+
79
+
80
+ def _count_turns(path: Path3D) -> int:
81
+ """Count the number of direction changes (bits = 0)."""
82
+ # Turns = total bits - number of steps (edges) = bits_length - num_edges
83
+ # Это количество битов которые были 0
84
+ return path.metadata.bits_length - len(path.edges)
85
+
86
+
87
+ def _direction_histogram(path: Path3D) -> dict:
88
+ """Count steps in each of 6 directions."""
89
+ histogram = {"+X": 0, "-X": 0, "+Y": 0, "-Y": 0, "+Z": 0, "-Z": 0}
90
+
91
+ for from_idx, to_idx in path.edges:
92
+ from_v = path.vertices[from_idx]
93
+ to_v = path.vertices[to_idx]
94
+ dx = to_v[0] - from_v[0]
95
+ dy = to_v[1] - from_v[1]
96
+ dz = to_v[2] - from_v[2]
97
+
98
+ if dx == 1:
99
+ histogram["+X"] += 1
100
+ elif dx == -1:
101
+ histogram["-X"] += 1
102
+ elif dy == 1:
103
+ histogram["+Y"] += 1
104
+ elif dy == -1:
105
+ histogram["-Y"] += 1
106
+ elif dz == 1:
107
+ histogram["+Z"] += 1
108
+ elif dz == -1:
109
+ histogram["-Z"] += 1
110
+
111
+ return histogram
112
+
113
+
114
+ def _count_self_intersections(path: Path3D) -> int:
115
+ """Count vertex revisits (simple intersection check)."""
116
+ seen = {}
117
+ intersections = 0
118
+
119
+ for i, v in enumerate(path.vertices):
120
+ if v in seen:
121
+ intersections += 1
122
+ else:
123
+ seen[v] = i
124
+
125
+ return intersections
126
+
127
+
128
+ def _straight_segment_lengths(path: Path3D) -> dict:
129
+ """Histogram of straight segment lengths along any single axis."""
130
+ lengths: list[int] = []
131
+ current_length = 0
132
+ current_dir: tuple[int, int, int] | None = None
133
+
134
+ for from_idx, to_idx in path.edges:
135
+ from_v = path.vertices[from_idx]
136
+ to_v = path.vertices[to_idx]
137
+ edge_dir = (to_v[0] - from_v[0], to_v[1] - from_v[1], to_v[2] - from_v[2])
138
+
139
+ if current_dir is None or edge_dir == current_dir:
140
+ current_length += 1
141
+ current_dir = edge_dir
142
+ else:
143
+ if current_length > 0:
144
+ lengths.append(current_length)
145
+ current_length = 1
146
+ current_dir = edge_dir
147
+
148
+ if current_length > 0:
149
+ lengths.append(current_length)
150
+
151
+ hist: dict[int, int] = {}
152
+ for l in lengths:
153
+ hist[l] = hist.get(l, 0) + 1
154
+
155
+ return hist
bin2path/orient.py ADDED
@@ -0,0 +1,107 @@
1
+ """Orientation (local frame) utilities for bin2path.
2
+
3
+ We interpret symbolic steps L/R/U/D as *relative* moves based on a current
4
+ orientation defined by two orthonormal axis-aligned vectors:
5
+ - forward: where "R" moves (go forward)
6
+ - up: local up direction (used as yaw axis)
7
+
8
+ Right vector is computed as cross(forward, up) (right-hand rule).
9
+
10
+ Step semantics (each step includes the move):
11
+ - R: move forward
12
+ - L: yaw left (rotate forward 90° around up), then move forward
13
+ - U: pitch up (rotate forward 90° around right), then move forward
14
+ - D: pitch down(rotate forward 90° around right), then move forward
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Dict, Iterable, List, Tuple
20
+
21
+ Vec3 = Tuple[int, int, int]
22
+
23
+
24
+ def _cross(a: Vec3, b: Vec3) -> Vec3:
25
+ ax, ay, az = a
26
+ bx, by, bz = b
27
+ return (ay * bz - az * by, az * bx - ax * bz, ax * by - ay * bx)
28
+
29
+
30
+ def _neg(v: Vec3) -> Vec3:
31
+ return (-v[0], -v[1], -v[2])
32
+
33
+
34
+ def apply_step(sym: str, forward: Vec3, up: Vec3) -> tuple[Vec3, Vec3, Vec3]:
35
+ """Apply a symbolic step and return (delta, new_forward, new_up)."""
36
+ if sym not in ("L", "R", "U", "D"):
37
+ raise ValueError(f"Invalid direction symbol {sym}")
38
+
39
+ right = _cross(forward, up)
40
+
41
+ if sym == "R":
42
+ new_forward = forward
43
+ new_up = up
44
+ delta = new_forward
45
+ return delta, new_forward, new_up
46
+
47
+ if sym == "L":
48
+ # yaw left around up: forward -> -right
49
+ new_forward = _neg(right)
50
+ new_up = up
51
+ delta = new_forward
52
+ return delta, new_forward, new_up
53
+
54
+ if sym == "U":
55
+ # pitch up around right: forward -> up, up -> -forward
56
+ new_forward = up
57
+ new_up = _neg(forward)
58
+ delta = new_forward
59
+ return delta, new_forward, new_up
60
+
61
+ # sym == "D"
62
+ # pitch down around right: forward -> -up, up -> forward
63
+ new_forward = _neg(up)
64
+ new_up = forward
65
+ delta = new_forward
66
+ return delta, new_forward, new_up
67
+
68
+
69
+ def infer_dirs_from_path(vertices: Iterable[Vec3], edges: Iterable[tuple[int, int]]) -> List[str]:
70
+ """Infer L/R/U/D symbols from a path using the local-frame rules."""
71
+ verts = list(vertices)
72
+ eds = list(edges)
73
+ if not verts:
74
+ return []
75
+ if not eds:
76
+ return []
77
+
78
+ forward: Vec3 = (0, 0, 1)
79
+ up: Vec3 = (0, 1, 0)
80
+
81
+ dirs: List[str] = []
82
+ for from_idx, to_idx in eds:
83
+ fv = verts[from_idx]
84
+ tv = verts[to_idx]
85
+ vec: Vec3 = (tv[0] - fv[0], tv[1] - fv[1], tv[2] - fv[2])
86
+
87
+ right = _cross(forward, up)
88
+ candidates: Dict[str, Vec3] = {
89
+ "R": forward,
90
+ "L": _neg(right),
91
+ "U": up,
92
+ "D": _neg(up),
93
+ }
94
+
95
+ sym = None
96
+ for k, v in candidates.items():
97
+ if vec == v:
98
+ sym = k
99
+ break
100
+ if sym is None:
101
+ raise ValueError(f"Unknown step vector {vec}, cannot infer direction symbol")
102
+
103
+ dirs.append(sym)
104
+ _, forward, up = apply_step(sym, forward, up)
105
+
106
+ return dirs
107
+
bin2path/path.py ADDED
@@ -0,0 +1,70 @@
1
+ """Core data structures for bin2path."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class PathMetadata:
8
+ """Metadata for a 3D path."""
9
+ original_number: int
10
+ bits_length: int
11
+ first_one_pos: int = 0 # position of first 1-bit (number of leading zeros)
12
+ step_positions: list[int] = None # positions of all 1-bits
13
+ # Start forward direction used by the encoding/decoding scheme (defaults to +Z)
14
+ start_direction: tuple[int, int, int] = (0, 0, 1)
15
+
16
+ def __post_init__(self):
17
+ if self.step_positions is None:
18
+ self.step_positions = []
19
+
20
+
21
+ @dataclass
22
+ class Path3D:
23
+ """
24
+ 3D path representation.
25
+
26
+ Attributes:
27
+ vertices: List of (x, y, z) tuples representing path vertices.
28
+ edges: List of (from_idx, to_idx) tuples representing directed edges.
29
+ metadata: Additional metadata about the path.
30
+ """
31
+ vertices: list[tuple[int, int, int]]
32
+ edges: list[tuple[int, int]]
33
+ metadata: PathMetadata
34
+
35
+ def __post_init__(self):
36
+ if not self.vertices:
37
+ raise ValueError("Path must have at least one vertex")
38
+ if len(self.edges) != len(self.vertices) - 1:
39
+ raise ValueError("Number of edges must be equal to vertices count minus 1")
40
+ if self.metadata.bits_length < len(self.edges):
41
+ raise ValueError(
42
+ "Invalid metadata: bits_length must be >= number of edges "
43
+ f"(bits_length={self.metadata.bits_length}, edges={len(self.edges)})"
44
+ )
45
+
46
+ @property
47
+ def start(self) -> tuple[int, int, int]:
48
+ """Return the starting vertex."""
49
+ return self.vertices[0] if self.vertices else (0, 0, 0)
50
+
51
+ @property
52
+ def end(self) -> tuple[int, int, int]:
53
+ """Return the ending vertex."""
54
+ return self.vertices[-1] if self.vertices else (0, 0, 0)
55
+
56
+ @property
57
+ def num_steps(self) -> int:
58
+ """Return the number of steps (edges) in the path."""
59
+ return len(self.edges)
60
+
61
+ @property
62
+ def num_turns(self) -> int:
63
+ """
64
+ Return the number of turn bits (0-bits) in the encoding.
65
+
66
+ In this scheme, 1-bits produce forward steps (edges), and 0-bits produce turns.
67
+ Since each bit corresponds to one symbolic action, the turn count is:
68
+ turns = bits_length - edges_count
69
+ """
70
+ return self.metadata.bits_length - len(self.edges)
bin2path/serialize.py ADDED
@@ -0,0 +1,97 @@
1
+ """Serialize and deserialize Path3D objects."""
2
+
3
+ import json
4
+ from bin2path.path import Path3D, PathMetadata
5
+
6
+
7
+ def serialize(path: Path3D) -> dict:
8
+ """
9
+ Serialize a Path3D object to a dictionary (JSON-compatible).
10
+
11
+ Args:
12
+ path: Path3D object.
13
+
14
+ Returns:
15
+ Dictionary representation.
16
+ """
17
+ return {
18
+ "vertices": path.vertices,
19
+ "edges": path.edges,
20
+ "metadata": {
21
+ "original_number": path.metadata.original_number,
22
+ "bits_length": path.metadata.bits_length,
23
+ "first_one_pos": path.metadata.first_one_pos,
24
+ "step_positions": path.metadata.step_positions,
25
+ "start_direction": path.metadata.start_direction,
26
+ },
27
+ }
28
+
29
+
30
+ def deserialize(data: dict) -> Path3D:
31
+ """
32
+ Deserialize a dictionary to a Path3D object.
33
+
34
+ Args:
35
+ data: Dictionary with 'vertices', 'edges', 'metadata' keys.
36
+
37
+ Returns:
38
+ Path3D object.
39
+
40
+ Raises:
41
+ ValueError: If data is invalid.
42
+ """
43
+ if not isinstance(data, dict):
44
+ raise TypeError("Data must be a dictionary")
45
+
46
+ if "vertices" not in data or "edges" not in data or "metadata" not in data:
47
+ raise ValueError("Data must contain 'vertices', 'edges', and 'metadata'")
48
+
49
+ vertices = data["vertices"]
50
+ edges = data["edges"]
51
+ metadata = data["metadata"]
52
+
53
+ # Validate structure
54
+ if not isinstance(vertices, list) or not isinstance(edges, list):
55
+ raise ValueError("Vertices and edges must be lists")
56
+
57
+ # Convert to tuples
58
+ vertices = [tuple(v) for v in vertices]
59
+ edges = [tuple(e) for e in edges]
60
+
61
+ path_metadata = PathMetadata(
62
+ original_number=metadata.get("original_number", 0),
63
+ bits_length=metadata.get("bits_length", 0),
64
+ first_one_pos=metadata.get("first_one_pos", 0),
65
+ step_positions=metadata.get("step_positions", []),
66
+ start_direction=tuple(metadata.get("start_direction", (1, 0, 0))),
67
+ )
68
+
69
+ return Path3D(vertices=vertices, edges=edges, metadata=path_metadata)
70
+
71
+
72
+ def to_json(path: Path3D, indent: int = 2) -> str:
73
+ """
74
+ Convert Path3D to JSON string.
75
+
76
+ Args:
77
+ path: Path3D object.
78
+ indent: JSON indentation level.
79
+
80
+ Returns:
81
+ JSON string.
82
+ """
83
+ return json.dumps(serialize(path), indent=indent)
84
+
85
+
86
+ def from_json(json_str: str) -> Path3D:
87
+ """
88
+ Parse JSON string to Path3D.
89
+
90
+ Args:
91
+ json_str: JSON string.
92
+
93
+ Returns:
94
+ Path3D object.
95
+ """
96
+ data = json.loads(json_str)
97
+ return deserialize(data)
bin2path/validate.py ADDED
@@ -0,0 +1,101 @@
1
+ """Validate Path3D objects."""
2
+
3
+ from bin2path.path import Path3D
4
+
5
+
6
+ def validate(path: Path3D) -> tuple[bool, list[str]]:
7
+ """
8
+ Validate a Path3D object.
9
+
10
+ Args:
11
+ path: Path3D object to validate.
12
+
13
+ Returns:
14
+ Tuple of (is_valid, list_of_errors).
15
+ """
16
+ errors = []
17
+
18
+ # Check vertices exist
19
+ if not path.vertices:
20
+ errors.append("Path must have at least one vertex")
21
+ return False, errors
22
+
23
+ # Check vertices are valid tuples
24
+ for i, v in enumerate(path.vertices):
25
+ if not isinstance(v, (tuple, list)) or len(v) != 3:
26
+ errors.append(f"Vertex {i} must be a 3D coordinate tuple")
27
+ elif not all(isinstance(c, int) for c in v):
28
+ errors.append(f"Vertex {i} coordinates must be integers")
29
+
30
+ # Check edges exist
31
+ # Special-case: allow the trivial path for 0 (single vertex, no edges)
32
+ if not path.edges:
33
+ if len(path.vertices) == 1 and path.metadata and path.metadata.bits_length == 1:
34
+ if errors:
35
+ return False, errors
36
+ return True, []
37
+ errors.append("Path must have at least one edge (two vertices), unless it is the trivial 0-path")
38
+ return False, errors
39
+
40
+ # Check edges are valid
41
+ for i, e in enumerate(path.edges):
42
+ if not isinstance(e, (tuple, list)) or len(e) != 2:
43
+ errors.append(f"Edge {i} must be a tuple of two indices")
44
+ continue
45
+
46
+ from_idx, to_idx = e
47
+
48
+ if from_idx < 0 or from_idx >= len(path.vertices):
49
+ errors.append(f"Edge {i}: from_index {from_idx} out of range")
50
+ if to_idx < 0 or to_idx >= len(path.vertices):
51
+ errors.append(f"Edge {i}: to_index {to_idx} out of range")
52
+
53
+ # Check edge direction is valid (must be one of 6 axis-aligned unit directions)
54
+ if from_idx < len(path.vertices) and to_idx < len(path.vertices):
55
+ from_v = path.vertices[from_idx]
56
+ to_v = path.vertices[to_idx]
57
+ edge_dir = (
58
+ to_v[0] - from_v[0],
59
+ to_v[1] - from_v[1],
60
+ to_v[2] - from_v[2],
61
+ )
62
+ if edge_dir not in {
63
+ (-1, 0, 0),
64
+ (1, 0, 0),
65
+ (0, 1, 0),
66
+ (0, -1, 0),
67
+ (0, 0, 1),
68
+ (0, 0, -1),
69
+ }:
70
+ errors.append(f"Edge {i}: invalid direction {edge_dir}")
71
+
72
+ # Check edge count matches vertices - 1
73
+ if len(path.edges) != len(path.vertices) - 1:
74
+ errors.append(f"Edges count ({len(path.edges)}) must equal vertices count ({len(path.vertices)}) - 1")
75
+
76
+ # Check metadata
77
+ if not path.metadata:
78
+ errors.append("Path must have metadata")
79
+ elif path.metadata.bits_length <= 0:
80
+ errors.append("bits_length must be positive")
81
+
82
+ # Check path is connected (simple check)
83
+ for i in range(len(path.edges) - 1):
84
+ if path.edges[i][1] != path.edges[i + 1][0]:
85
+ errors.append(f"Path is not continuous at edge {i}")
86
+
87
+ is_valid = len(errors) == 0
88
+ return is_valid, errors
89
+
90
+
91
+ def is_valid(path: Path3D) -> bool:
92
+ """
93
+ Quick validation check.
94
+
95
+ Args:
96
+ path: Path3D object.
97
+
98
+ Returns:
99
+ True if valid, False otherwise.
100
+ """
101
+ return validate(path)[0]