scigantic-headers 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.
- scigantic_headers/__init__.py +52 -0
- scigantic_headers/benchmark.py +107 -0
- scigantic_headers/cli.py +50 -0
- scigantic_headers/cryosparc.py +144 -0
- scigantic_headers/decoders.py +389 -0
- scigantic_headers/optics.py +48 -0
- scigantic_headers/parquet.py +184 -0
- scigantic_headers/sources.py +199 -0
- scigantic_headers/star.py +114 -0
- scigantic_headers-0.1.0.dist-info/METADATA +203 -0
- scigantic_headers-0.1.0.dist-info/RECORD +15 -0
- scigantic_headers-0.1.0.dist-info/WHEEL +5 -0
- scigantic_headers-0.1.0.dist-info/entry_points.txt +3 -0
- scigantic_headers-0.1.0.dist-info/licenses/LICENSE +4 -0
- scigantic_headers-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""scigantic-headers, decode scientific file headers into typed context.
|
|
2
|
+
|
|
3
|
+
Bytes in, typed fields out. Pure, zero-dependency core (decoders) plus bounded
|
|
4
|
+
parallel I/O readers (sources). One implementation shared by the cloud probe's
|
|
5
|
+
twin, the notebook, and the edge box, so a file is described the same way
|
|
6
|
+
everywhere.
|
|
7
|
+
|
|
8
|
+
from scigantic_headers import decode_file, decode_paths
|
|
9
|
+
hdr = decode_file("session/frame.mrc")
|
|
10
|
+
print(hdr.summary) # "MRC stack 4096x4096x16, float32"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from .cryosparc import parse_cryosparc_optics, read_cryosparc_optics
|
|
14
|
+
from .optics import read_session_optics
|
|
15
|
+
from .parquet import decode_parquet
|
|
16
|
+
from .decoders import (
|
|
17
|
+
HEADER_BYTES,
|
|
18
|
+
DecodedHeader,
|
|
19
|
+
decode_bytes,
|
|
20
|
+
decode_cryosparc_header,
|
|
21
|
+
decode_mrc_header,
|
|
22
|
+
decode_nifti_header,
|
|
23
|
+
decode_npy_header,
|
|
24
|
+
extension_of,
|
|
25
|
+
has_decoder_for,
|
|
26
|
+
register_decoder,
|
|
27
|
+
)
|
|
28
|
+
from .sources import (
|
|
29
|
+
DEFAULT_WORKERS,
|
|
30
|
+
decode_file,
|
|
31
|
+
decode_paths,
|
|
32
|
+
decode_url,
|
|
33
|
+
decode_urls,
|
|
34
|
+
is_decodable,
|
|
35
|
+
iter_decodable_files,
|
|
36
|
+
read_leading_bytes,
|
|
37
|
+
read_leading_bytes_url,
|
|
38
|
+
)
|
|
39
|
+
from .star import parse_relion_optics, read_star_optics
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
__all__ = [
|
|
43
|
+
"HEADER_BYTES", "DecodedHeader", "decode_bytes", "decode_mrc_header",
|
|
44
|
+
"decode_nifti_header", "decode_npy_header", "decode_cryosparc_header",
|
|
45
|
+
"parse_cryosparc_optics", "read_cryosparc_optics", "read_session_optics",
|
|
46
|
+
"decode_parquet",
|
|
47
|
+
"extension_of", "has_decoder_for", "register_decoder",
|
|
48
|
+
"DEFAULT_WORKERS", "decode_file", "decode_paths", "decode_url",
|
|
49
|
+
"decode_urls", "is_decodable", "iter_decodable_files", "read_leading_bytes",
|
|
50
|
+
"read_leading_bytes_url", "parse_relion_optics", "read_star_optics",
|
|
51
|
+
"__version__",
|
|
52
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Measure where the time actually goes, honestly, not by assertion.
|
|
2
|
+
|
|
3
|
+
Two things worth measuring, matching the two real speed levers:
|
|
4
|
+
|
|
5
|
+
1. Bounded read vs full read. Decoding reads HEADER_BYTES; it does not read
|
|
6
|
+
the file. This shows the header read is ~constant and tiny while a full
|
|
7
|
+
read scales with file size, the single biggest win, and the reason a
|
|
8
|
+
multi-GB movie costs the same to decode as a 1 KB one.
|
|
9
|
+
|
|
10
|
+
2. Serial vs bounded-parallel over many *remote* files. Header reads are
|
|
11
|
+
independent and I/O-bound, so a thread pool overlaps their latency. Run
|
|
12
|
+
against real EMPIAR URLs so the numbers are real network latency, not a
|
|
13
|
+
simulation, this reproduces the EMPIAR range-reader's finding at the
|
|
14
|
+
file-set level.
|
|
15
|
+
|
|
16
|
+
scigantic-headers-bench # local read-size bench (offline)
|
|
17
|
+
scigantic-headers-bench --empiar # + remote serial-vs-parallel (network)
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import os
|
|
24
|
+
import struct
|
|
25
|
+
import tempfile
|
|
26
|
+
import time
|
|
27
|
+
|
|
28
|
+
from .decoders import decode_bytes
|
|
29
|
+
from .sources import DEFAULT_WORKERS, decode_file, decode_urls, read_leading_bytes
|
|
30
|
+
|
|
31
|
+
# A handful of real EMPIAR-10002 movie stacks (public, no auth). Header pulled
|
|
32
|
+
# via Range, nothing is downloaded.
|
|
33
|
+
_EMPIAR_BASE = "https://ftp.ebi.ac.uk/empiar/world_availability/10002/data"
|
|
34
|
+
_EMPIAR_FILES = [
|
|
35
|
+
"15_movie_gc.mrcs", "16_movie_gc.mrcs", "17_movie_gc.mrcs", "18_movie_gc.mrcs",
|
|
36
|
+
"20_movie_gc.mrcs", "21_movie_gc.mrcs", "22_movie_gc.mrcs", "23_movie_gc.mrcs",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _make_mrc_file(path: str, size_mb: int) -> None:
|
|
41
|
+
"""Write a valid-header MRC file of `size_mb` megabytes so we can time a full
|
|
42
|
+
read against a bounded read at a realistic size."""
|
|
43
|
+
buf = bytearray(1024)
|
|
44
|
+
struct.pack_into("<4i", buf, 0, 4096, 4096, 16, 2)
|
|
45
|
+
struct.pack_into("<i", buf, 28, 4096)
|
|
46
|
+
buf[208:212] = b"MAP "
|
|
47
|
+
with open(path, "wb") as fh:
|
|
48
|
+
fh.write(buf)
|
|
49
|
+
fh.write(b"\0" * (size_mb * 1024 * 1024))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _time(fn, repeat=5):
|
|
53
|
+
best = float("inf")
|
|
54
|
+
for _ in range(repeat):
|
|
55
|
+
t0 = time.perf_counter()
|
|
56
|
+
fn()
|
|
57
|
+
best = min(best, time.perf_counter() - t0)
|
|
58
|
+
return best
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def bench_read_size(size_mb: int = 200) -> None:
|
|
62
|
+
print(f"\n[1] bounded read vs full read ({size_mb} MB file)")
|
|
63
|
+
with tempfile.TemporaryDirectory() as d:
|
|
64
|
+
path = os.path.join(d, "big.mrc")
|
|
65
|
+
_make_mrc_file(path, size_mb)
|
|
66
|
+
|
|
67
|
+
def bounded():
|
|
68
|
+
decode_bytes(path, read_leading_bytes(path))
|
|
69
|
+
|
|
70
|
+
def full():
|
|
71
|
+
with open(path, "rb") as fh:
|
|
72
|
+
decode_bytes(path, fh.read())
|
|
73
|
+
|
|
74
|
+
tb = _time(bounded)
|
|
75
|
+
tf = _time(full)
|
|
76
|
+
print(f" header read + decode (1 KiB): {tb * 1e6:8.1f} us")
|
|
77
|
+
print(f" full file read + decode ({size_mb} MB): {tf * 1e3:8.1f} ms")
|
|
78
|
+
print(f" bounded read is ~{tf / tb:,.0f}x cheaper, and flat in file size")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def bench_parallel_remote(workers: int = DEFAULT_WORKERS) -> None:
|
|
82
|
+
urls = [f"{_EMPIAR_BASE}/{name}" for name in _EMPIAR_FILES]
|
|
83
|
+
print(f"\n[2] serial vs {workers}-way parallel over {len(urls)} real EMPIAR headers (network)")
|
|
84
|
+
t_serial = _time(lambda: decode_urls(urls, workers=1), repeat=1)
|
|
85
|
+
t_par = _time(lambda: decode_urls(urls, workers=workers), repeat=1)
|
|
86
|
+
got = len(decode_urls(urls, workers=workers))
|
|
87
|
+
print(f" serial (1 worker): {t_serial:6.2f} s")
|
|
88
|
+
print(f" parallel ({workers} workers): {t_par:6.2f} s")
|
|
89
|
+
if t_par > 0:
|
|
90
|
+
print(f" speedup: {t_serial / t_par:.1f}x ({got}/{len(urls)} decoded)")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main(argv=None) -> int:
|
|
94
|
+
ap = argparse.ArgumentParser(prog="scigantic-headers-bench", description=__doc__)
|
|
95
|
+
ap.add_argument("--size-mb", type=int, default=200, help="file size for the read-size bench")
|
|
96
|
+
ap.add_argument("--empiar", action="store_true", help="also run the remote parallel bench (needs network)")
|
|
97
|
+
ap.add_argument("--workers", type=int, default=DEFAULT_WORKERS)
|
|
98
|
+
args = ap.parse_args(argv)
|
|
99
|
+
|
|
100
|
+
bench_read_size(args.size_mb)
|
|
101
|
+
if args.empiar:
|
|
102
|
+
bench_parallel_remote(args.workers)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
raise SystemExit(main())
|
scigantic_headers/cli.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""scigantic-headers, decode a file or URL header into typed fields.
|
|
2
|
+
|
|
3
|
+
scigantic-headers session/frame.mrc
|
|
4
|
+
scigantic-headers https://ftp.ebi.ac.uk/empiar/.../img.mrcs
|
|
5
|
+
scigantic-headers --dir /mnt/flashblade/sessions --workers 8
|
|
6
|
+
|
|
7
|
+
A URL is read with an HTTP Range request, so a header is pulled from a remote
|
|
8
|
+
archive without downloading the file.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from .decoders import has_decoder_for
|
|
18
|
+
from .sources import decode_file, decode_paths, decode_url, iter_decodable_files
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main(argv=None) -> int:
|
|
22
|
+
ap = argparse.ArgumentParser(prog="scigantic-headers", description=__doc__)
|
|
23
|
+
ap.add_argument("source", nargs="?", help="a file path or http(s) URL")
|
|
24
|
+
ap.add_argument("--dir", help="decode every recognized file under this directory")
|
|
25
|
+
ap.add_argument("--workers", type=int, default=8, help="parallel workers for --dir")
|
|
26
|
+
args = ap.parse_args(argv)
|
|
27
|
+
|
|
28
|
+
if args.dir:
|
|
29
|
+
results = decode_paths(iter_decodable_files(args.dir), workers=args.workers)
|
|
30
|
+
print(json.dumps(
|
|
31
|
+
{p: h.to_dict() for p, h in results.items()}, indent=2, default=str))
|
|
32
|
+
print(f"# {len(results)} decoded", file=sys.stderr)
|
|
33
|
+
return 0
|
|
34
|
+
|
|
35
|
+
if not args.source:
|
|
36
|
+
ap.error("give a file/URL, or --dir")
|
|
37
|
+
if not has_decoder_for(args.source):
|
|
38
|
+
print(f"no decoder for '{args.source}' (extension not recognized)", file=sys.stderr)
|
|
39
|
+
return 1
|
|
40
|
+
|
|
41
|
+
hdr = decode_url(args.source) if args.source.startswith(("http://", "https://")) else decode_file(args.source)
|
|
42
|
+
if hdr is None:
|
|
43
|
+
print(f"header did not decode for {args.source} (invalid or unreadable)", file=sys.stderr)
|
|
44
|
+
return 1
|
|
45
|
+
print(json.dumps({"source": args.source, **hdr.to_dict()}, indent=2, default=str))
|
|
46
|
+
return 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Read acquisition optics from a CryoSPARC .cs metadata file.
|
|
2
|
+
|
|
3
|
+
A .cs file is an .npy with a structured dtype, one record per particle, with
|
|
4
|
+
fields like 'blob/psize_A' (pixel size), 'ctf/accel_kv' (voltage), 'ctf/cs_mm'
|
|
5
|
+
(spherical aberration). The header gives the schema; the values are in the data,
|
|
6
|
+
so this reads the first record and extracts the optics fields. Same job as the
|
|
7
|
+
RELION STAR reader (star.py), different container.
|
|
8
|
+
|
|
9
|
+
Records are a flat C-packed buffer, so a field's value is at a computed offset.
|
|
10
|
+
That holds as long as no field is an object dtype ('|O'), object fields make
|
|
11
|
+
.npy store a pickle instead of a flat buffer, so this bails (returns {}) in that
|
|
12
|
+
case rather than reading garbage. CryoSPARC stores paths as fixed-width strings,
|
|
13
|
+
not objects, so real .cs files parse.
|
|
14
|
+
|
|
15
|
+
Pure stdlib. Verified against numpy-written structured arrays (numpy is the
|
|
16
|
+
reference for the .npy format).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import math
|
|
22
|
+
import struct
|
|
23
|
+
from typing import Dict, Optional
|
|
24
|
+
|
|
25
|
+
from .decoders import npy_preamble
|
|
26
|
+
|
|
27
|
+
# Header + first record for typical CryoSPARC schemas (many fields, some
|
|
28
|
+
# fixed-string paths). Generous; the first record is right after the header.
|
|
29
|
+
CS_SCAN_BYTES = 64 * 1024
|
|
30
|
+
|
|
31
|
+
# CryoSPARC field name -> (output key, rounding). All are float32 in .cs.
|
|
32
|
+
_CS_OPTICS = {
|
|
33
|
+
"blob/psize_A": ("pixelSizeA", 4),
|
|
34
|
+
"ctf/accel_kv": ("voltageKv", 1),
|
|
35
|
+
"ctf/cs_mm": ("sphericalAberrationMm", 3),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
_NUM_STRUCT = {
|
|
39
|
+
("f", 2): "e", ("f", 4): "f", ("f", 8): "d",
|
|
40
|
+
("i", 1): "b", ("i", 2): "h", ("i", 4): "i", ("i", 8): "q",
|
|
41
|
+
("u", 1): "B", ("u", 2): "H", ("u", 4): "I", ("u", 8): "Q",
|
|
42
|
+
("b", 1): "?",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _base(ts: str) -> str:
|
|
47
|
+
return ts[1:] if ts[:1] in "<>|=" else ts
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _itemsize(ts) -> Optional[int]:
|
|
51
|
+
"""Bytes one element of typestr `ts` occupies, or None if it is an object
|
|
52
|
+
dtype or otherwise not a flat fixed-size element."""
|
|
53
|
+
if not isinstance(ts, str):
|
|
54
|
+
return None
|
|
55
|
+
s = _base(ts)
|
|
56
|
+
if len(s) < 2 or not s[1:].isdigit():
|
|
57
|
+
return None
|
|
58
|
+
kind, size = s[0], int(s[1:])
|
|
59
|
+
if kind == "O":
|
|
60
|
+
return None # object -> pickled, not a flat buffer
|
|
61
|
+
if kind == "U":
|
|
62
|
+
return size * 4 # unicode: 4 bytes/char
|
|
63
|
+
if kind in ("S", "a", "V", "f", "i", "u", "c", "b"):
|
|
64
|
+
return size
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _num_fmt(ts: str) -> Optional[str]:
|
|
69
|
+
s = _base(ts)
|
|
70
|
+
ch = _NUM_STRUCT.get((s[0], int(s[1:]))) if len(s) >= 2 and s[1:].isdigit() else None
|
|
71
|
+
if ch is None:
|
|
72
|
+
return None
|
|
73
|
+
return (">" if ts[:1] == ">" else "<") + ch
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _field_layout(descr):
|
|
77
|
+
"""{name: (offset, typestr)} plus the record size, or None if any field is
|
|
78
|
+
object/unknown dtype (so the array is not a flat readable buffer)."""
|
|
79
|
+
offset = 0
|
|
80
|
+
layout = {}
|
|
81
|
+
for spec in descr:
|
|
82
|
+
try:
|
|
83
|
+
name, ts = spec[0], spec[1]
|
|
84
|
+
shape = spec[2] if len(spec) > 2 else ()
|
|
85
|
+
except (TypeError, IndexError):
|
|
86
|
+
return None
|
|
87
|
+
base = _itemsize(ts)
|
|
88
|
+
if base is None:
|
|
89
|
+
return None
|
|
90
|
+
count = 1
|
|
91
|
+
for d in (shape if isinstance(shape, (tuple, list)) else (shape,)):
|
|
92
|
+
count *= int(d)
|
|
93
|
+
layout[name] = (offset, ts)
|
|
94
|
+
offset += base * count
|
|
95
|
+
return layout, offset
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_cryosparc_optics(data: bytes) -> Dict[str, float]:
|
|
99
|
+
"""Extract {pixelSizeA, voltageKv, sphericalAberrationMm} (those present)
|
|
100
|
+
from the first record of a .cs buffer. {} if not a flat structured array or
|
|
101
|
+
the fields aren't in the bytes we have."""
|
|
102
|
+
pre = npy_preamble(data)
|
|
103
|
+
if pre is None:
|
|
104
|
+
return {}
|
|
105
|
+
meta, data_off = pre
|
|
106
|
+
descr = meta.get("descr")
|
|
107
|
+
if not isinstance(descr, list):
|
|
108
|
+
return {}
|
|
109
|
+
lay = _field_layout(descr)
|
|
110
|
+
if lay is None:
|
|
111
|
+
return {}
|
|
112
|
+
layout, _rec_size = lay
|
|
113
|
+
|
|
114
|
+
out: Dict[str, float] = {}
|
|
115
|
+
for cs_name, (key, ndigits) in _CS_OPTICS.items():
|
|
116
|
+
if cs_name not in layout:
|
|
117
|
+
continue
|
|
118
|
+
foff, ts = layout[cs_name]
|
|
119
|
+
fmt = _num_fmt(ts)
|
|
120
|
+
size = _itemsize(ts)
|
|
121
|
+
if fmt is None or size is None:
|
|
122
|
+
continue
|
|
123
|
+
pos = data_off + foff
|
|
124
|
+
if pos + size > len(data):
|
|
125
|
+
continue # field lies past the bytes we read
|
|
126
|
+
try:
|
|
127
|
+
(val,) = struct.unpack_from(fmt, data, pos)
|
|
128
|
+
except struct.error:
|
|
129
|
+
continue
|
|
130
|
+
if isinstance(val, float) and not math.isfinite(val):
|
|
131
|
+
continue
|
|
132
|
+
out[key] = round(float(val), ndigits)
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def read_cryosparc_optics(path: str, max_bytes: int = CS_SCAN_BYTES) -> Dict[str, float]:
|
|
137
|
+
"""Read a .cs file's leading bytes (header + first record) and parse its
|
|
138
|
+
optics. {} if unreadable or not a flat structured array."""
|
|
139
|
+
try:
|
|
140
|
+
with open(path, "rb") as fh:
|
|
141
|
+
data = fh.read(max_bytes)
|
|
142
|
+
except OSError:
|
|
143
|
+
return {}
|
|
144
|
+
return parse_cryosparc_optics(data)
|