iorec 1.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.
- iorec/__init__.py +21 -0
- iorec/_utils.py +139 -0
- iorec/_validation.py +153 -0
- iorec/rec_2d.py +390 -0
- iorec/rec_3d.py +391 -0
- iorec/rec_appendix.py +178 -0
- iorec-1.1.0.dist-info/METADATA +274 -0
- iorec-1.1.0.dist-info/RECORD +10 -0
- iorec-1.1.0.dist-info/WHEEL +4 -0
- iorec-1.1.0.dist-info/licenses/LICENSE +674 -0
iorec/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""IO for .rec meshes in 3D or 2D.
|
|
2
|
+
|
|
3
|
+
Matthieu Perez (2023)
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from iorec._validation import RecFormatError
|
|
7
|
+
from iorec.rec_2d import load_2d_rec, load_2d_rec_with_metadata, save_2d_rec
|
|
8
|
+
from iorec.rec_3d import load_rec, load_rec_with_metadata, save_rec
|
|
9
|
+
from iorec.rec_appendix import load_rec_appendix, write_rec_appendix
|
|
10
|
+
|
|
11
|
+
__all__ = (
|
|
12
|
+
"RecFormatError",
|
|
13
|
+
"load_2d_rec",
|
|
14
|
+
"load_2d_rec_with_metadata",
|
|
15
|
+
"load_rec",
|
|
16
|
+
"load_rec_appendix",
|
|
17
|
+
"load_rec_with_metadata",
|
|
18
|
+
"save_2d_rec",
|
|
19
|
+
"save_rec",
|
|
20
|
+
"write_rec_appendix",
|
|
21
|
+
)
|
iorec/_utils.py
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Shared helpers for .rec/.arec files, used by both the 2D and 3D readers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import warnings
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, TextIO
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from numpy.typing import NDArray
|
|
11
|
+
|
|
12
|
+
from iorec._validation import RecFormatError, _parse_count
|
|
13
|
+
from iorec.rec_appendix import _iorec_version, write_rec_appendix
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _resolve_rec_path(filename: str | Path) -> Path:
|
|
17
|
+
filename = Path(filename).resolve()
|
|
18
|
+
if not filename.exists():
|
|
19
|
+
error = f"{filename} not found."
|
|
20
|
+
raise FileNotFoundError(error)
|
|
21
|
+
if filename.suffix not in (".rec", ".arec"):
|
|
22
|
+
error = f"{filename} is not a .rec or .arec file."
|
|
23
|
+
raise ValueError(error)
|
|
24
|
+
return filename
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_binary_bytes(data: bytes, chunk_size: int = 64) -> bool:
|
|
28
|
+
return b"\x00" in data[:chunk_size]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _is_binary(file_name: Path, chunk_size: int = 64) -> bool:
|
|
32
|
+
with Path.open(file_name, "rb") as f:
|
|
33
|
+
return _is_binary_bytes(f.read(chunk_size), chunk_size)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _write_geometry_with_appendix(
|
|
37
|
+
filename: str | Path,
|
|
38
|
+
write_geometry: Callable[[], None],
|
|
39
|
+
metadata: dict[str, Any] | None,
|
|
40
|
+
written_by: str | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
"""Write geometry, then an optional trailing metadata appendix.
|
|
43
|
+
|
|
44
|
+
`write_geometry` must write the full geometry file at `filename` (binary or text
|
|
45
|
+
mode); the appendix, if any, is appended afterwards so it stays trailing.
|
|
46
|
+
|
|
47
|
+
`written_by` names the program that produced the mesh. When `None`, iorec's own
|
|
48
|
+
version is stamped instead, unchanged from before this parameter existed. When
|
|
49
|
+
given, iorec's own version is stamped on a separate `writer_library` line instead,
|
|
50
|
+
so both facts survive.
|
|
51
|
+
"""
|
|
52
|
+
appendix_bytes = None
|
|
53
|
+
if metadata is not None:
|
|
54
|
+
if written_by is None:
|
|
55
|
+
appendix_bytes = write_rec_appendix(metadata, written_by=_iorec_version())
|
|
56
|
+
else:
|
|
57
|
+
appendix_bytes = write_rec_appendix(
|
|
58
|
+
metadata,
|
|
59
|
+
written_by=written_by,
|
|
60
|
+
writer_library=f"iorec {_iorec_version()}",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
write_geometry()
|
|
64
|
+
|
|
65
|
+
if appendix_bytes is not None:
|
|
66
|
+
with Path(filename).open("ab") as file:
|
|
67
|
+
file.write(appendix_bytes)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_text_count(
|
|
71
|
+
f: TextIO,
|
|
72
|
+
filename: Path | str,
|
|
73
|
+
what: str,
|
|
74
|
+
*,
|
|
75
|
+
validate: bool,
|
|
76
|
+
) -> int:
|
|
77
|
+
"""Read one count line of a text .rec file."""
|
|
78
|
+
line = f.readline()
|
|
79
|
+
if validate:
|
|
80
|
+
return _parse_count(filename, line, what)
|
|
81
|
+
return int(line.strip())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _read_text_rows(
|
|
85
|
+
f: TextIO,
|
|
86
|
+
count: int,
|
|
87
|
+
dtype: type[np.float64] | type[np.int64],
|
|
88
|
+
columns: int,
|
|
89
|
+
filename: Path | str,
|
|
90
|
+
what: str,
|
|
91
|
+
*,
|
|
92
|
+
validate: bool,
|
|
93
|
+
) -> NDArray[Any]:
|
|
94
|
+
"""Read `count` rows of a text .rec block, stopping exactly after the last one.
|
|
95
|
+
|
|
96
|
+
Without `validate`, behaviour is unchanged from earlier versions: `np.loadtxt`
|
|
97
|
+
errors propagate as they are, and a short block is returned short. With
|
|
98
|
+
`validate`, parse errors become `RecFormatError` and the caller checks the shape.
|
|
99
|
+
"""
|
|
100
|
+
if count <= 0:
|
|
101
|
+
return np.empty((0, columns), dtype=dtype)
|
|
102
|
+
if not validate:
|
|
103
|
+
return np.loadtxt(f, max_rows=count, dtype=dtype, ndmin=2)
|
|
104
|
+
try:
|
|
105
|
+
with warnings.catch_warnings():
|
|
106
|
+
# An empty block is reported by the shape check, with the file name.
|
|
107
|
+
warnings.simplefilter("ignore", UserWarning)
|
|
108
|
+
return np.loadtxt(f, max_rows=count, dtype=dtype, ndmin=2)
|
|
109
|
+
except ValueError as exc:
|
|
110
|
+
error = f"{filename}: invalid {what} block: {exc}"
|
|
111
|
+
raise RecFormatError(error) from exc
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _format_text_rec(
|
|
115
|
+
points: NDArray[Any],
|
|
116
|
+
cells: NDArray[Any],
|
|
117
|
+
labels: NDArray[Any],
|
|
118
|
+
dimension: int,
|
|
119
|
+
) -> str:
|
|
120
|
+
"""Return the text of a .rec file: counts, one row per point and per cell.
|
|
121
|
+
|
|
122
|
+
Each value is written with `repr` (the shortest text that reads back to the same
|
|
123
|
+
float64, or the integer's digits), exactly as formatting each NumPy scalar in an
|
|
124
|
+
f-string did in earlier versions; `tolist()` turns the scalars into Python numbers
|
|
125
|
+
without changing their values. Each block is built with one `%` operation instead
|
|
126
|
+
of a Python loop over rows.
|
|
127
|
+
"""
|
|
128
|
+
points = np.asarray(points)[:, :dimension]
|
|
129
|
+
records = np.empty((len(cells), dimension + 2), dtype=object)
|
|
130
|
+
records[:, :dimension] = np.asarray(cells)[:, :dimension]
|
|
131
|
+
records[:, dimension:] = np.asarray(labels)[:, :2]
|
|
132
|
+
point_row = " ".join(["%r"] * dimension) + "\n"
|
|
133
|
+
record_row = " ".join(["%r"] * (dimension + 2)) + "\n"
|
|
134
|
+
return (
|
|
135
|
+
f"{len(points)}\n"
|
|
136
|
+
+ (point_row * len(points)) % tuple(points.reshape(-1).tolist())
|
|
137
|
+
+ f"{len(records)}\n"
|
|
138
|
+
+ (record_row * len(records)) % tuple(records.reshape(-1).tolist())
|
|
139
|
+
)
|
iorec/_validation.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Opt-in structural checks for .rec/.arec files, run by the readers on `validate=True`.
|
|
2
|
+
|
|
3
|
+
Without `validate=True` the readers keep their historical, permissive behaviour: a
|
|
4
|
+
truncated file, a 2D file read as 3D, or out-of-range indices are returned as they come.
|
|
5
|
+
With it, any such problem raises `RecFormatError`, a `ValueError` subclass, naming the
|
|
6
|
+
file and the first offending record.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
from numpy.typing import NDArray
|
|
15
|
+
|
|
16
|
+
from iorec.rec_appendix import _appendix_marker
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RecFormatError(ValueError):
|
|
20
|
+
"""Raised by the readers, with `validate=True`, for a malformed .rec/.arec file."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _binary_geometry_size(
|
|
24
|
+
number_of_points: int,
|
|
25
|
+
number_of_cells: int,
|
|
26
|
+
dimension: int,
|
|
27
|
+
record_itemsize: int,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Return the exact byte size of a binary geometry block (without appendix)."""
|
|
30
|
+
return 8 + 8 * dimension * number_of_points + 8 + record_itemsize * number_of_cells
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _check_binary_room(
|
|
34
|
+
filename: Path | str,
|
|
35
|
+
available: int,
|
|
36
|
+
required: int,
|
|
37
|
+
what: str,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Raise if fewer than `required` bytes are available for the block `what`."""
|
|
40
|
+
if required > available:
|
|
41
|
+
error = (
|
|
42
|
+
f"{filename}: truncated binary {what} "
|
|
43
|
+
f"({available} bytes available, {required} needed)."
|
|
44
|
+
)
|
|
45
|
+
raise RecFormatError(error)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _check_binary_tail(filename: Path | str, tail: bytes) -> None:
|
|
49
|
+
"""Raise unless the bytes after the geometry are empty or a metadata appendix."""
|
|
50
|
+
if tail and not tail.startswith(_appendix_marker()):
|
|
51
|
+
error = (
|
|
52
|
+
f"{filename}: {len(tail)} unexpected trailing bytes after the binary "
|
|
53
|
+
"geometry (not a rec-appendix block)."
|
|
54
|
+
)
|
|
55
|
+
raise RecFormatError(error)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _check_text_tail(filename: Path | str, tail: str) -> None:
|
|
59
|
+
"""Raise unless the text after the geometry is blank or a metadata appendix."""
|
|
60
|
+
stripped = tail.lstrip()
|
|
61
|
+
if stripped and not stripped.startswith(_appendix_marker().decode("ascii")):
|
|
62
|
+
error = (
|
|
63
|
+
f"{filename}: unexpected content after the last record "
|
|
64
|
+
"(not a rec-appendix block)."
|
|
65
|
+
)
|
|
66
|
+
raise RecFormatError(error)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _parse_count(filename: Path | str, line: str, what: str) -> int:
|
|
70
|
+
"""Parse a text count line strictly."""
|
|
71
|
+
try:
|
|
72
|
+
value = int(line.strip())
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
error = f"{filename}: invalid {what} count {line.strip()!r}."
|
|
75
|
+
raise RecFormatError(error) from exc
|
|
76
|
+
if value < 0:
|
|
77
|
+
error = f"{filename}: {what} count must be non-negative, got {value}."
|
|
78
|
+
raise RecFormatError(error)
|
|
79
|
+
return value
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _check_mesh(
|
|
83
|
+
filename: Path | str,
|
|
84
|
+
points: NDArray[np.float64],
|
|
85
|
+
cells: NDArray[np.int64],
|
|
86
|
+
labels: NDArray[np.int64],
|
|
87
|
+
dimension: int,
|
|
88
|
+
counts: tuple[int, int],
|
|
89
|
+
) -> None:
|
|
90
|
+
"""Check shapes, coordinates, indices and labels of a parsed mesh.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
filename: used in error messages only.
|
|
94
|
+
points: parsed points, expected shape `(counts[0], dimension)`.
|
|
95
|
+
cells: parsed triangles (3D) or segments (2D), expected shape
|
|
96
|
+
`(counts[1], dimension)`.
|
|
97
|
+
labels: parsed labels, expected shape `(counts[1], 2)`.
|
|
98
|
+
dimension: 3 for triangle meshes, 2 for segment meshes.
|
|
99
|
+
counts: the point and cell counts declared in the file.
|
|
100
|
+
|
|
101
|
+
Raises:
|
|
102
|
+
RecFormatError: on the first violated condition.
|
|
103
|
+
"""
|
|
104
|
+
number_of_points, number_of_cells = counts
|
|
105
|
+
cell_name = "triangle" if dimension == 3 else "segment"
|
|
106
|
+
expected = {
|
|
107
|
+
"points": (points, (number_of_points, dimension)),
|
|
108
|
+
f"{cell_name}s": (cells, (number_of_cells, dimension)),
|
|
109
|
+
"labels": (labels, (number_of_cells, 2)),
|
|
110
|
+
}
|
|
111
|
+
for name, (array, shape) in expected.items():
|
|
112
|
+
if array.shape != shape:
|
|
113
|
+
error = (
|
|
114
|
+
f"{filename}: {name} have shape {array.shape}, expected {shape} "
|
|
115
|
+
f"(file declares {number_of_points} points and {number_of_cells} "
|
|
116
|
+
f"{cell_name}s in {dimension}D)."
|
|
117
|
+
)
|
|
118
|
+
raise RecFormatError(error)
|
|
119
|
+
|
|
120
|
+
not_finite = np.flatnonzero(~np.isfinite(points).all(axis=1))
|
|
121
|
+
if not_finite.size:
|
|
122
|
+
error = f"{filename}: point {not_finite[0]} has a non-finite coordinate."
|
|
123
|
+
raise RecFormatError(error)
|
|
124
|
+
|
|
125
|
+
if number_of_cells:
|
|
126
|
+
out_of_range = np.flatnonzero(
|
|
127
|
+
((cells < 0) | (cells >= number_of_points)).any(axis=1),
|
|
128
|
+
)
|
|
129
|
+
if out_of_range.size:
|
|
130
|
+
first = out_of_range[0]
|
|
131
|
+
error = (
|
|
132
|
+
f"{filename}: {cell_name} {first} has a vertex index outside "
|
|
133
|
+
f"[0, {number_of_points}): {cells[first].tolist()}."
|
|
134
|
+
)
|
|
135
|
+
raise RecFormatError(error)
|
|
136
|
+
|
|
137
|
+
negative = np.flatnonzero((labels < 0).any(axis=1))
|
|
138
|
+
if negative.size:
|
|
139
|
+
first = negative[0]
|
|
140
|
+
error = (
|
|
141
|
+
f"{filename}: {cell_name} {first} has a negative label: "
|
|
142
|
+
f"{labels[first].tolist()}."
|
|
143
|
+
)
|
|
144
|
+
raise RecFormatError(error)
|
|
145
|
+
|
|
146
|
+
same = np.flatnonzero(labels[:, 0] == labels[:, 1])
|
|
147
|
+
if same.size:
|
|
148
|
+
first = same[0]
|
|
149
|
+
error = (
|
|
150
|
+
f"{filename}: {cell_name} {first} separates label "
|
|
151
|
+
f"{labels[first, 0]} from itself."
|
|
152
|
+
)
|
|
153
|
+
raise RecFormatError(error)
|
iorec/rec_2d.py
ADDED
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
"""IO for 2D .rec meshes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import struct
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, TextIO
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
from numpy.typing import NDArray
|
|
12
|
+
|
|
13
|
+
from iorec._utils import (
|
|
14
|
+
_format_text_rec,
|
|
15
|
+
_is_binary,
|
|
16
|
+
_is_binary_bytes,
|
|
17
|
+
_read_text_count,
|
|
18
|
+
_read_text_rows,
|
|
19
|
+
_resolve_rec_path,
|
|
20
|
+
_write_geometry_with_appendix,
|
|
21
|
+
)
|
|
22
|
+
from iorec._validation import (
|
|
23
|
+
RecFormatError,
|
|
24
|
+
_binary_geometry_size,
|
|
25
|
+
_check_binary_room,
|
|
26
|
+
_check_binary_tail,
|
|
27
|
+
_check_mesh,
|
|
28
|
+
_check_text_tail,
|
|
29
|
+
)
|
|
30
|
+
from iorec.rec_appendix import _appendix_marker, _split_rec_bytes
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load_2d_rec(
|
|
34
|
+
filename: str | Path,
|
|
35
|
+
*,
|
|
36
|
+
validate: bool = False,
|
|
37
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
38
|
+
"""Read a 2D rec mesh file.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
filename (str | Path): The path to the file on disk.
|
|
42
|
+
validate (bool, optional): Check the file's structure and content, as
|
|
43
|
+
`load_rec` does with `validate=True`, for segments instead of triangles.
|
|
44
|
+
Defaults to False.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If the path given is not a .rec or .arec file.
|
|
48
|
+
FileNotFoundError: If the file doesn't exist.
|
|
49
|
+
RecFormatError: With `validate=True`, if the file is malformed (a
|
|
50
|
+
`ValueError` subclass).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
tuple[np.array, np.array, np.array]: (points, segments, labels), with:
|
|
54
|
+
- points: np.array of shape (num_points, 2) (float64)
|
|
55
|
+
- segments: np.array of shape (num_segments, 2) (int64)
|
|
56
|
+
- labels: np.array of shape (num_segments, 2) (int64)
|
|
57
|
+
"""
|
|
58
|
+
filename = _resolve_rec_path(filename)
|
|
59
|
+
return _load_2d_rec_file(filename, validate=validate)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_2d_rec_with_metadata(
|
|
63
|
+
filename: str | Path,
|
|
64
|
+
*,
|
|
65
|
+
validate: bool = False,
|
|
66
|
+
) -> tuple[
|
|
67
|
+
NDArray[np.float64],
|
|
68
|
+
NDArray[np.int64],
|
|
69
|
+
NDArray[np.int64],
|
|
70
|
+
dict[str, Any] | None,
|
|
71
|
+
]:
|
|
72
|
+
"""Read a 2D rec mesh file together with its metadata appendix, if any.
|
|
73
|
+
|
|
74
|
+
Reads the file once and splits geometry from metadata in memory -- unlike calling
|
|
75
|
+
`load_2d_rec` and `load_rec_appendix` separately, which would each read the whole
|
|
76
|
+
file on their own.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
filename (str | Path): The path to the file on disk.
|
|
80
|
+
validate (bool, optional): Check the geometry as `load_2d_rec` does with
|
|
81
|
+
`validate=True`. Defaults to False.
|
|
82
|
+
|
|
83
|
+
Raises:
|
|
84
|
+
ValueError: If the path given is not a .rec or .arec file.
|
|
85
|
+
FileNotFoundError: If the file doesn't exist.
|
|
86
|
+
RecFormatError: With `validate=True`, if the file is malformed (a
|
|
87
|
+
`ValueError` subclass).
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
tuple[np.array, np.array, np.array, dict | None]: (points, segments, labels,
|
|
91
|
+
metadata), with points/segments/labels as in `load_2d_rec`, and metadata as in
|
|
92
|
+
`load_rec_appendix` (`None` if the file has no appendix).
|
|
93
|
+
"""
|
|
94
|
+
filename = _resolve_rec_path(filename)
|
|
95
|
+
geometry_bytes, metadata = _split_rec_bytes(filename.read_bytes())
|
|
96
|
+
|
|
97
|
+
if _is_binary_bytes(geometry_bytes):
|
|
98
|
+
points, segments, labels = _parse_2d_binary_rec_bytes(
|
|
99
|
+
geometry_bytes,
|
|
100
|
+
validate=validate,
|
|
101
|
+
filename=filename,
|
|
102
|
+
)
|
|
103
|
+
else:
|
|
104
|
+
points, segments, labels = _parse_2d_text_rec_bytes(
|
|
105
|
+
geometry_bytes,
|
|
106
|
+
validate=validate,
|
|
107
|
+
filename=filename,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
return points, segments, labels, metadata
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# On-disk layout of binary files: little-endian whatever the machine. Existing files
|
|
114
|
+
# come from little-endian machines (x86-64, arm64), where this changes nothing; on a
|
|
115
|
+
# big-endian machine, files stay readable and portable instead of byte-swapped.
|
|
116
|
+
_DTYPE_SEGMENTS_AND_LABELS = np.dtype(
|
|
117
|
+
[("segments", "<i8", (2,)), ("labels", "<i4", (2,))],
|
|
118
|
+
)
|
|
119
|
+
_DTYPE_POINTS = np.dtype("<f8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _load_2d_rec_file(
|
|
123
|
+
filename: Path,
|
|
124
|
+
*,
|
|
125
|
+
validate: bool = False,
|
|
126
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
127
|
+
if _is_binary(filename):
|
|
128
|
+
return _load_2d_binary_rec_file(filename, validate=validate)
|
|
129
|
+
else:
|
|
130
|
+
return _load_2d_text_rec_file(filename, validate=validate)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _load_2d_binary_rec_file(
|
|
134
|
+
filename: Path,
|
|
135
|
+
*,
|
|
136
|
+
validate: bool = False,
|
|
137
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
138
|
+
size = filename.stat().st_size if validate else 0
|
|
139
|
+
with Path.open(filename, "rb") as f:
|
|
140
|
+
# First we read the number of vertices
|
|
141
|
+
header = f.read(8)
|
|
142
|
+
if validate:
|
|
143
|
+
_check_binary_room(filename, len(header), 8, "point count")
|
|
144
|
+
(number_of_points,) = struct.unpack("<Q", header)
|
|
145
|
+
if validate:
|
|
146
|
+
_check_binary_room(filename, size - 8, 16 * number_of_points + 8, "points")
|
|
147
|
+
# Then we read the vertices : they are in a 2D space
|
|
148
|
+
points = (
|
|
149
|
+
np.fromfile(f, count=2 * number_of_points, dtype=_DTYPE_POINTS)
|
|
150
|
+
.reshape((number_of_points, 2))
|
|
151
|
+
.astype(np.float64, copy=False)
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
# Number of segments
|
|
155
|
+
(number_of_segments,) = struct.unpack("<Q", f.read(8))
|
|
156
|
+
geometry_size = _binary_geometry_size(
|
|
157
|
+
number_of_points,
|
|
158
|
+
number_of_segments,
|
|
159
|
+
2,
|
|
160
|
+
_DTYPE_SEGMENTS_AND_LABELS.itemsize,
|
|
161
|
+
)
|
|
162
|
+
if validate:
|
|
163
|
+
_check_binary_room(filename, size, geometry_size, "segments")
|
|
164
|
+
# Each line defines a segment and 2 regions labels
|
|
165
|
+
segments_and_labels = np.fromfile(
|
|
166
|
+
f,
|
|
167
|
+
count=number_of_segments,
|
|
168
|
+
dtype=_DTYPE_SEGMENTS_AND_LABELS,
|
|
169
|
+
)
|
|
170
|
+
if validate:
|
|
171
|
+
_check_binary_tail(filename, f.read(len(_appendix_marker())))
|
|
172
|
+
|
|
173
|
+
segments = segments_and_labels["segments"].astype(np.int64)
|
|
174
|
+
labels = segments_and_labels["labels"].astype(np.int64)
|
|
175
|
+
if validate:
|
|
176
|
+
counts = (number_of_points, number_of_segments)
|
|
177
|
+
_check_mesh(filename, points, segments, labels, 2, counts)
|
|
178
|
+
return points, segments, labels
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _load_2d_text_rec_file(
|
|
182
|
+
filename: Path,
|
|
183
|
+
*,
|
|
184
|
+
validate: bool = False,
|
|
185
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
186
|
+
with Path.open(filename) as f:
|
|
187
|
+
return _read_2d_text_rec(f, filename, validate=validate)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _read_2d_text_rec(
|
|
191
|
+
f: TextIO,
|
|
192
|
+
filename: Path | str,
|
|
193
|
+
*,
|
|
194
|
+
validate: bool,
|
|
195
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
196
|
+
"""Read 2D text-mode geometry from an open stream (a file or a buffer)."""
|
|
197
|
+
# First we read the number of vertices
|
|
198
|
+
number_of_points = _read_text_count(f, filename, "point", validate=validate)
|
|
199
|
+
# Then we read the vertices : they are in a 2D space
|
|
200
|
+
points = _read_text_rows(
|
|
201
|
+
f,
|
|
202
|
+
number_of_points,
|
|
203
|
+
np.float64,
|
|
204
|
+
2,
|
|
205
|
+
filename,
|
|
206
|
+
"point",
|
|
207
|
+
validate=validate,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
# Number of segments
|
|
211
|
+
number_of_segments = _read_text_count(f, filename, "segment", validate=validate)
|
|
212
|
+
# Each line defines a segment and 2 regions labels
|
|
213
|
+
segments_and_labels = _read_text_rows(
|
|
214
|
+
f,
|
|
215
|
+
number_of_segments,
|
|
216
|
+
np.int64,
|
|
217
|
+
4,
|
|
218
|
+
filename,
|
|
219
|
+
"segment",
|
|
220
|
+
validate=validate,
|
|
221
|
+
)
|
|
222
|
+
segments = segments_and_labels[:, 0:2]
|
|
223
|
+
labels = segments_and_labels[:, 2:4]
|
|
224
|
+
|
|
225
|
+
if validate:
|
|
226
|
+
if segments_and_labels.shape[1:] != (4,):
|
|
227
|
+
error = (
|
|
228
|
+
f"{filename}: segment records have {segments_and_labels.shape[1]} "
|
|
229
|
+
"columns, expected 4 (two vertex indices and two labels)."
|
|
230
|
+
)
|
|
231
|
+
raise RecFormatError(error)
|
|
232
|
+
_check_text_tail(filename, f.read())
|
|
233
|
+
counts = (number_of_points, number_of_segments)
|
|
234
|
+
_check_mesh(filename, points, segments, labels, 2, counts)
|
|
235
|
+
return points, segments, labels
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _parse_2d_binary_rec_bytes(
|
|
239
|
+
data: bytes,
|
|
240
|
+
*,
|
|
241
|
+
validate: bool = False,
|
|
242
|
+
filename: Path | str = "<buffer>",
|
|
243
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
244
|
+
"""Parse geometry from an in-memory buffer.
|
|
245
|
+
|
|
246
|
+
E.g. the geometry half of a buffer already split by
|
|
247
|
+
`iorec.rec_appendix._split_rec_bytes`.
|
|
248
|
+
|
|
249
|
+
Kept separate from `_load_2d_binary_rec_file` (which streams from disk with
|
|
250
|
+
`np.fromfile`) rather than unifying the two: unifying would make plain
|
|
251
|
+
`load_2d_rec` hold the whole file in memory as `bytes` *and* as the parsed array at
|
|
252
|
+
once, which for very large files is a real peak-memory regression that
|
|
253
|
+
`load_2d_rec` shouldn't pay just so `load_2d_rec_with_metadata` can avoid a second
|
|
254
|
+
read.
|
|
255
|
+
"""
|
|
256
|
+
if validate:
|
|
257
|
+
_check_binary_room(filename, len(data), 8, "point count")
|
|
258
|
+
(number_of_points,) = struct.unpack_from("<Q", data)
|
|
259
|
+
offset = 8
|
|
260
|
+
points_count = 2 * number_of_points
|
|
261
|
+
if validate:
|
|
262
|
+
_check_binary_room(filename, len(data) - 8, 8 * points_count + 8, "points")
|
|
263
|
+
points = (
|
|
264
|
+
np.frombuffer(data, dtype=_DTYPE_POINTS, count=points_count, offset=offset)
|
|
265
|
+
.reshape((number_of_points, 2))
|
|
266
|
+
.astype(np.float64) # a native-endian copy, not a view on `data`
|
|
267
|
+
)
|
|
268
|
+
offset += points_count * 8
|
|
269
|
+
|
|
270
|
+
(number_of_segments,) = struct.unpack_from("<Q", data, offset)
|
|
271
|
+
offset += 8
|
|
272
|
+
if validate:
|
|
273
|
+
geometry_size = _binary_geometry_size(
|
|
274
|
+
number_of_points,
|
|
275
|
+
number_of_segments,
|
|
276
|
+
2,
|
|
277
|
+
_DTYPE_SEGMENTS_AND_LABELS.itemsize,
|
|
278
|
+
)
|
|
279
|
+
_check_binary_room(filename, len(data), geometry_size, "segments")
|
|
280
|
+
_check_binary_tail(filename, data[geometry_size:])
|
|
281
|
+
segments_and_labels = np.frombuffer(
|
|
282
|
+
data,
|
|
283
|
+
dtype=_DTYPE_SEGMENTS_AND_LABELS,
|
|
284
|
+
count=number_of_segments,
|
|
285
|
+
offset=offset,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
segments = segments_and_labels["segments"].astype(np.int64)
|
|
289
|
+
labels = segments_and_labels["labels"].astype(np.int64)
|
|
290
|
+
if validate:
|
|
291
|
+
counts = (number_of_points, number_of_segments)
|
|
292
|
+
_check_mesh(filename, points, segments, labels, 2, counts)
|
|
293
|
+
return points, segments, labels
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _parse_2d_text_rec_bytes(
|
|
297
|
+
data: bytes,
|
|
298
|
+
*,
|
|
299
|
+
validate: bool = False,
|
|
300
|
+
filename: Path | str = "<buffer>",
|
|
301
|
+
) -> tuple[NDArray[np.float64], NDArray[np.int64], NDArray[np.int64]]:
|
|
302
|
+
"""Parse geometry from an in-memory buffer.
|
|
303
|
+
|
|
304
|
+
Shares `_read_2d_text_rec` with `_load_2d_text_rec_file`: text parsing reads line
|
|
305
|
+
by line from a stream in both cases, so there is no peak-memory reason to keep two
|
|
306
|
+
copies.
|
|
307
|
+
"""
|
|
308
|
+
with io.StringIO(data.decode()) as f:
|
|
309
|
+
return _read_2d_text_rec(f, filename, validate=validate)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def save_2d_rec(
|
|
313
|
+
filename: str | Path,
|
|
314
|
+
points: NDArray[np.float64],
|
|
315
|
+
segments: NDArray[np.int64],
|
|
316
|
+
labels: NDArray[np.int64],
|
|
317
|
+
binary_mode: bool = False,
|
|
318
|
+
*,
|
|
319
|
+
metadata: dict[str, Any] | None = None,
|
|
320
|
+
written_by: str | None = None,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Save a 2D rec mesh file at filename. Binary or text mode are available.
|
|
323
|
+
|
|
324
|
+
Binary mode is more compact but might not be universally understood.
|
|
325
|
+
|
|
326
|
+
Both modes store every value exactly. Binary mode writes float64 coordinates,
|
|
327
|
+
int64 indices and int32 labels, little-endian on every machine, converting arrays
|
|
328
|
+
of other dtypes. Text mode writes each value with `repr`, the shortest text that
|
|
329
|
+
reads back to the same float64.
|
|
330
|
+
|
|
331
|
+
Args:
|
|
332
|
+
filename (str | Path): where to save the mesh file.
|
|
333
|
+
points (NDArray[np.float64]): Mesh points.
|
|
334
|
+
segments (NDArray[np.int64]): Mesh segments (topology).
|
|
335
|
+
labels (NDArray[np.int64]): Segments multimaterial labels.
|
|
336
|
+
binary_mode (bool, optional): Choose binary mode. Defaults to False.
|
|
337
|
+
metadata (dict[str, Any] | None, optional): appendix fields, e.g.
|
|
338
|
+
`coordinate_frame`, `axis_order`, `spacing`, `spacing_unit`,
|
|
339
|
+
`source_shape`, `crop_offset`. Defaults to `None`, which writes nothing
|
|
340
|
+
extra -- byte-for-byte the same file as before this option existed. When
|
|
341
|
+
supplied, a trailing metadata appendix is appended after the geometry data;
|
|
342
|
+
see `iorec.rec_appendix.write_rec_appendix`.
|
|
343
|
+
written_by (str | None, optional): a version string identifying the program
|
|
344
|
+
that produced the mesh, stamped on the appendix's `written_by` line.
|
|
345
|
+
Defaults to `None`, which stamps iorec's own version there instead --
|
|
346
|
+
unchanged from before this parameter existed. When supplied, iorec's own
|
|
347
|
+
version is stamped on a separate `writer_library` line instead, so both
|
|
348
|
+
facts survive: what produced the mesh, and what serialised it. Has no
|
|
349
|
+
effect if `metadata` is `None`, since no appendix is written in that case.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def write_geometry() -> None:
|
|
353
|
+
if binary_mode:
|
|
354
|
+
_save_2d_rec_binary(filename, points, segments, labels)
|
|
355
|
+
else:
|
|
356
|
+
_save_2d_rec_text(filename, points, segments, labels)
|
|
357
|
+
|
|
358
|
+
_write_geometry_with_appendix(filename, write_geometry, metadata, written_by)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _save_2d_rec_binary(
|
|
362
|
+
filename: str | Path,
|
|
363
|
+
points: NDArray[np.float64],
|
|
364
|
+
segments: NDArray[np.int64],
|
|
365
|
+
labels: NDArray[np.int64],
|
|
366
|
+
) -> None:
|
|
367
|
+
"""Save a 2D rec file at filename. Binary only."""
|
|
368
|
+
records = np.empty(len(segments), dtype=_DTYPE_SEGMENTS_AND_LABELS)
|
|
369
|
+
records["segments"] = segments
|
|
370
|
+
records["labels"] = labels
|
|
371
|
+
|
|
372
|
+
bytes_content = struct.pack("<Q", len(points))
|
|
373
|
+
bytes_content += np.ascontiguousarray(points, dtype=_DTYPE_POINTS).tobytes()
|
|
374
|
+
bytes_content += struct.pack("<Q", len(segments))
|
|
375
|
+
bytes_content += records.tobytes()
|
|
376
|
+
with Path(filename).open("wb") as file:
|
|
377
|
+
file.write(bytes_content)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _save_2d_rec_text(
|
|
381
|
+
filename: str | Path,
|
|
382
|
+
points: NDArray[np.float64],
|
|
383
|
+
segments: NDArray[np.int64],
|
|
384
|
+
labels: NDArray[np.int64],
|
|
385
|
+
) -> None:
|
|
386
|
+
"""Save a 2D rec file at filename. Text only."""
|
|
387
|
+
content = _format_text_rec(points, segments, labels, 2)
|
|
388
|
+
|
|
389
|
+
with Path(filename).open("w") as file:
|
|
390
|
+
file.write(content)
|