probixi 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.
- probixi/__init__.py +1 -0
- probixi/cli.py +206 -0
- probixi/indexer/__init__.py +17 -0
- probixi/indexer/forward.py +107 -0
- probixi/indexer/indexer.py +712 -0
- probixi/indexer/integrate.py +93 -0
- probixi/indexer/lattice.py +140 -0
- probixi/indexer/predict.py +149 -0
- probixi/indexer/refine.py +243 -0
- probixi/indexer/seed.py +145 -0
- probixi/io/__init__.py +21 -0
- probixi/io/cell.py +141 -0
- probixi/io/frames.py +241 -0
- probixi/io/geometry.py +181 -0
- probixi/io/metadata.py +100 -0
- probixi/io/writer.py +430 -0
- probixi/peakfinding/__init__.py +29 -0
- probixi/peakfinding/noise/__init__.py +22 -0
- probixi/peakfinding/noise/_diagnostics.py +220 -0
- probixi/peakfinding/noise/_drift.py +39 -0
- probixi/peakfinding/noise/_eigen_background.py +72 -0
- probixi/peakfinding/noise/_panel.py +101 -0
- probixi/peakfinding/noise/_pixel.py +45 -0
- probixi/peakfinding/noise/_radial.py +84 -0
- probixi/peakfinding/noise/calibrate.py +542 -0
- probixi/peakfinding/noise/model.py +480 -0
- probixi/peakfinding/noise/scale.py +147 -0
- probixi/peakfinding/peaks/__init__.py +10 -0
- probixi/peakfinding/peaks/blobs.py +278 -0
- probixi/peakfinding/peaks/neighborhood.py +171 -0
- probixi/peakfinding/peaks/peakfinder.py +754 -0
- probixi/probixi.py +519 -0
- probixi-0.1.0.dist-info/METADATA +231 -0
- probixi-0.1.0.dist-info/RECORD +37 -0
- probixi-0.1.0.dist-info/WHEEL +4 -0
- probixi-0.1.0.dist-info/entry_points.txt +2 -0
- probixi-0.1.0.dist-info/licenses/LICENSE +21 -0
probixi/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from .probixi import Probixi as Probixi
|
probixi/cli.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Literal, Optional, cast
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
import torch
|
|
7
|
+
|
|
8
|
+
from probixi.io import DataOffloader, PeakOffloader
|
|
9
|
+
from probixi.probixi import Probixi
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.option(
|
|
14
|
+
"-i",
|
|
15
|
+
"--input",
|
|
16
|
+
"list_file",
|
|
17
|
+
required=True,
|
|
18
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
19
|
+
help="CrystFEL list file (.lst) of HDF5 inputs.",
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
"-g",
|
|
23
|
+
"--geometry",
|
|
24
|
+
"geometry_file",
|
|
25
|
+
required=True,
|
|
26
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
27
|
+
help="CrystFEL geometry file (.geom).",
|
|
28
|
+
)
|
|
29
|
+
@click.option(
|
|
30
|
+
"-p",
|
|
31
|
+
"--cell",
|
|
32
|
+
"cell_file",
|
|
33
|
+
default=None,
|
|
34
|
+
type=click.Path(exists=True, dir_okay=False),
|
|
35
|
+
help="CrystFEL unit-cell file (.cell). Required unless --peaks-only.",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"-o",
|
|
39
|
+
"--output",
|
|
40
|
+
"output",
|
|
41
|
+
required=True,
|
|
42
|
+
type=click.Path(dir_okay=False, writable=True),
|
|
43
|
+
help="Output CrystFEL-style .stream file.",
|
|
44
|
+
)
|
|
45
|
+
@click.option(
|
|
46
|
+
"--peaks-only",
|
|
47
|
+
is_flag=True,
|
|
48
|
+
help="Only run peak finding and export a peaks-only stream "
|
|
49
|
+
"Does not require a unit cell.",
|
|
50
|
+
)
|
|
51
|
+
@click.option(
|
|
52
|
+
"--gif",
|
|
53
|
+
"gif",
|
|
54
|
+
default=None,
|
|
55
|
+
type=click.Path(dir_okay=False, writable=True),
|
|
56
|
+
help="Also write a noise-model diagnostic GIF over the seed frames.",
|
|
57
|
+
)
|
|
58
|
+
@click.option("--start", type=int, default=None, help="First frame index (inclusive).")
|
|
59
|
+
@click.option("--stop", type=int, default=None, help="Stop frame index (exclusive).")
|
|
60
|
+
@click.option(
|
|
61
|
+
"--batch-size",
|
|
62
|
+
type=int,
|
|
63
|
+
default=8,
|
|
64
|
+
show_default=True,
|
|
65
|
+
help="Frames per batched refinement pass.",
|
|
66
|
+
)
|
|
67
|
+
@click.option("--device", default=None, help="Torch device (default: auto).")
|
|
68
|
+
@click.option(
|
|
69
|
+
"--noise-mode",
|
|
70
|
+
type=click.Choice(["online", "per_frame"]),
|
|
71
|
+
default="online",
|
|
72
|
+
show_default=True,
|
|
73
|
+
help="Noise-model update mode.",
|
|
74
|
+
)
|
|
75
|
+
@click.option("--warmup-frames", type=int, default=16, show_default=True)
|
|
76
|
+
@click.option(
|
|
77
|
+
"--seed-frames",
|
|
78
|
+
type=int,
|
|
79
|
+
default=32,
|
|
80
|
+
show_default=True,
|
|
81
|
+
help="Frames used to calibrate the noise model and detection threshold.",
|
|
82
|
+
)
|
|
83
|
+
@click.option(
|
|
84
|
+
"--target-noise-peaks",
|
|
85
|
+
type=float,
|
|
86
|
+
default=5.0,
|
|
87
|
+
show_default=True,
|
|
88
|
+
help="Calibrate the detection threshold so a signal-free frame "
|
|
89
|
+
"yields at most this many noise blobs.",
|
|
90
|
+
)
|
|
91
|
+
@click.option(
|
|
92
|
+
"--panel",
|
|
93
|
+
default="0",
|
|
94
|
+
show_default=True,
|
|
95
|
+
help="Fallback panel name for peaks outside all geometry panels.",
|
|
96
|
+
)
|
|
97
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress per-frame progress.")
|
|
98
|
+
def main(
|
|
99
|
+
list_file: str,
|
|
100
|
+
geometry_file: str,
|
|
101
|
+
cell_file: Optional[str],
|
|
102
|
+
output: str,
|
|
103
|
+
peaks_only: bool,
|
|
104
|
+
gif: Optional[str],
|
|
105
|
+
start: Optional[int],
|
|
106
|
+
stop: Optional[int],
|
|
107
|
+
batch_size: int,
|
|
108
|
+
device: Optional[str],
|
|
109
|
+
noise_mode: str,
|
|
110
|
+
warmup_frames: int,
|
|
111
|
+
seed_frames: int,
|
|
112
|
+
target_noise_peaks: float,
|
|
113
|
+
panel: str,
|
|
114
|
+
quiet: bool,
|
|
115
|
+
) -> None:
|
|
116
|
+
"""Run the probixi pipeline and write indexed frames to a CrystFEL stream.
|
|
117
|
+
|
|
118
|
+
With --peaks-only, peak finding runs but indexing does not, and a
|
|
119
|
+
peaks-only stream is written instead.
|
|
120
|
+
"""
|
|
121
|
+
if not peaks_only and cell_file is None:
|
|
122
|
+
raise click.UsageError(
|
|
123
|
+
"a unit cell (-p/--cell) is required unless --peaks-only"
|
|
124
|
+
)
|
|
125
|
+
dev = torch.device(device) if device else None
|
|
126
|
+
probixi = Probixi(
|
|
127
|
+
list_file=list_file,
|
|
128
|
+
geometry_file=geometry_file,
|
|
129
|
+
cell_file=cell_file,
|
|
130
|
+
noise_mode=cast("Literal['online', 'per_frame']", noise_mode),
|
|
131
|
+
warmup_frames=warmup_frames,
|
|
132
|
+
device=dev,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
meta = probixi.metadata
|
|
136
|
+
if not quiet:
|
|
137
|
+
click.echo(f"Loaded {meta.n_frames} frames from {meta.n_files} file(s).")
|
|
138
|
+
|
|
139
|
+
if gif:
|
|
140
|
+
probixi.noise_diagnostics(
|
|
141
|
+
gif, stop=seed_frames, batch_size=max(2, seed_frames // 8)
|
|
142
|
+
)
|
|
143
|
+
if not quiet:
|
|
144
|
+
click.echo(f"Wrote noise diagnostic GIF to {gif}")
|
|
145
|
+
|
|
146
|
+
cal = probixi.calibrate(n_seed=seed_frames, target_noise_peaks=target_noise_peaks)
|
|
147
|
+
if not quiet:
|
|
148
|
+
tc = probixi.threshold_calibration
|
|
149
|
+
msg = (
|
|
150
|
+
f"Calibrated on {seed_frames} frames: kappa={cal.kappa:.2f} "
|
|
151
|
+
f"prior_peak={cal.prior_peak:.4f} var_scale={cal.var_scale:.3f}"
|
|
152
|
+
)
|
|
153
|
+
if tc is not None:
|
|
154
|
+
msg += f" mf_threshold={tc.threshold:.2f}"
|
|
155
|
+
click.echo(msg)
|
|
156
|
+
|
|
157
|
+
frames = probixi.frames(start=start, stop=stop)
|
|
158
|
+
|
|
159
|
+
if peaks_only:
|
|
160
|
+
peaks = probixi.peak_stream(
|
|
161
|
+
frames, start_index=start or 0, estimate_scale=False
|
|
162
|
+
)
|
|
163
|
+
with PeakOffloader(
|
|
164
|
+
output,
|
|
165
|
+
geometry=probixi.geometry,
|
|
166
|
+
geometry_file=geometry_file,
|
|
167
|
+
files=meta.files,
|
|
168
|
+
panel=panel,
|
|
169
|
+
) as off:
|
|
170
|
+
n = 0
|
|
171
|
+
for result in peaks:
|
|
172
|
+
if len(result) == 0:
|
|
173
|
+
continue
|
|
174
|
+
off.write(result)
|
|
175
|
+
n += 1
|
|
176
|
+
if not quiet:
|
|
177
|
+
click.echo(f" frame {result.frame_index}: {len(result)} peaks")
|
|
178
|
+
click.echo(f"Wrote peaks for {n} frame(s) to {output}")
|
|
179
|
+
return
|
|
180
|
+
|
|
181
|
+
stream = probixi.index_stream(frames, batch_size=batch_size, start_index=start or 0)
|
|
182
|
+
|
|
183
|
+
with DataOffloader(
|
|
184
|
+
output,
|
|
185
|
+
geometry=probixi.geometry,
|
|
186
|
+
cell=probixi.target_cell,
|
|
187
|
+
geometry_file=geometry_file,
|
|
188
|
+
files=meta.files,
|
|
189
|
+
panel=panel,
|
|
190
|
+
) as off:
|
|
191
|
+
n = 0
|
|
192
|
+
for result in stream:
|
|
193
|
+
off.write(result)
|
|
194
|
+
n += 1
|
|
195
|
+
if not quiet:
|
|
196
|
+
click.echo(
|
|
197
|
+
f" frame {result.frame_index}: "
|
|
198
|
+
f"{result.n_indexed}/{result.n_peaks} peaks indexed "
|
|
199
|
+
f"(rmsd {result.rmsd:.4f})"
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
click.echo(f"Wrote {n} indexed frame(s) to {output}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .indexer import (
|
|
2
|
+
CellMatchConfig,
|
|
3
|
+
Indexer,
|
|
4
|
+
IndexStream,
|
|
5
|
+
IntegrateConfig,
|
|
6
|
+
RefineConfig,
|
|
7
|
+
SeedConfig,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"Indexer",
|
|
12
|
+
"IndexStream",
|
|
13
|
+
"CellMatchConfig",
|
|
14
|
+
"IntegrateConfig",
|
|
15
|
+
"RefineConfig",
|
|
16
|
+
"SeedConfig",
|
|
17
|
+
]
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Optional
|
|
4
|
+
|
|
5
|
+
import torch
|
|
6
|
+
from torch import Tensor
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _geometry_constants(
|
|
10
|
+
geometry: dict,
|
|
11
|
+
device: Optional[torch.device] = None,
|
|
12
|
+
dtype: torch.dtype = torch.float64,
|
|
13
|
+
) -> dict:
|
|
14
|
+
bc = geometry["beam_center"]
|
|
15
|
+
return {
|
|
16
|
+
"bc_row": torch.tensor(float(bc[0]), dtype=dtype, device=device),
|
|
17
|
+
"bc_col": torch.tensor(float(bc[1]), dtype=dtype, device=device),
|
|
18
|
+
"clen_A": torch.tensor(
|
|
19
|
+
float(geometry["clen"]) * 1e10, dtype=dtype, device=device
|
|
20
|
+
),
|
|
21
|
+
"pix_A": torch.tensor(
|
|
22
|
+
float(geometry["pixel_size"]) * 1e10, dtype=dtype, device=device
|
|
23
|
+
),
|
|
24
|
+
"wavelength_A": torch.tensor(
|
|
25
|
+
float(geometry["wavelength"]), dtype=dtype, device=device
|
|
26
|
+
),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def detector_to_q(
|
|
31
|
+
positions: Tensor,
|
|
32
|
+
geometry: dict,
|
|
33
|
+
frame_rotation: Optional[Tensor] = None,
|
|
34
|
+
dtype: torch.dtype = torch.float64,
|
|
35
|
+
) -> Tensor:
|
|
36
|
+
"""Map detector pixel positions to reciprocal-space q-vectors.
|
|
37
|
+
|
|
38
|
+
Elastic (Ewald) construction with the beam along +z: a pixel at lab position
|
|
39
|
+
``(x, y, clen)`` defines a scattered unit direction ``s_hat`` and
|
|
40
|
+
``q = (s_hat - s0_hat) / lambda`` with ``s0_hat = (0, 0, 1)``.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
positions : Tensor
|
|
45
|
+
(N, 2) detector ``(row, col)`` pixel positions.
|
|
46
|
+
geometry : dict
|
|
47
|
+
Geometry dict (``beam_center``, ``clen``, ``pixel_size``, ``wavelength``).
|
|
48
|
+
frame_rotation : Tensor, optional
|
|
49
|
+
(3, 3) rotation taking q from lab into the crystal frame.
|
|
50
|
+
dtype : torch.dtype, optional
|
|
51
|
+
Working dtype (default ``torch.float64``).
|
|
52
|
+
|
|
53
|
+
Returns
|
|
54
|
+
-------
|
|
55
|
+
Tensor
|
|
56
|
+
(N, 3) reciprocal-space q-vectors (A^-1).
|
|
57
|
+
"""
|
|
58
|
+
if positions.ndim != 2 or positions.shape[-1] != 2:
|
|
59
|
+
raise ValueError("positions must be (N, 2)")
|
|
60
|
+
device = positions.device
|
|
61
|
+
g = _geometry_constants(geometry, device=device, dtype=dtype)
|
|
62
|
+
pos = positions.to(device=device, dtype=dtype)
|
|
63
|
+
rows, cols = pos[:, 0], pos[:, 1]
|
|
64
|
+
# pixel offset from beam center translation to lab-frame coordinates \AA
|
|
65
|
+
x_lab = (cols - g["bc_col"]) * g["pix_A"]
|
|
66
|
+
y_lab = (rows - g["bc_row"]) * g["pix_A"]
|
|
67
|
+
z_lab = g["clen_A"].expand_as(x_lab)
|
|
68
|
+
r = torch.sqrt(x_lab * x_lab + y_lab * y_lab + z_lab * z_lab)
|
|
69
|
+
inv_lambda = 1.0 / g["wavelength_A"]
|
|
70
|
+
qx = (x_lab / r) * inv_lambda
|
|
71
|
+
qy = (y_lab / r) * inv_lambda
|
|
72
|
+
qz = (z_lab / r - 1.0) * inv_lambda
|
|
73
|
+
q_lab = torch.stack([qx, qy, qz], dim=-1)
|
|
74
|
+
if frame_rotation is None:
|
|
75
|
+
return q_lab
|
|
76
|
+
R = frame_rotation.to(device=device, dtype=dtype)
|
|
77
|
+
if R.shape != (3, 3):
|
|
78
|
+
raise ValueError("frame_rotation must be (3, 3)")
|
|
79
|
+
return q_lab @ R
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def q_to_detector(
|
|
83
|
+
q: Tensor,
|
|
84
|
+
geometry: dict,
|
|
85
|
+
frame_rotation: Optional[Tensor] = None,
|
|
86
|
+
dtype: torch.dtype = torch.float64,
|
|
87
|
+
) -> Tensor:
|
|
88
|
+
# S = q*lambda + s0
|
|
89
|
+
# Intersect the ray with z = clen.
|
|
90
|
+
if q.ndim != 2 or q.shape[-1] != 3:
|
|
91
|
+
raise ValueError("q must be (N, 3)")
|
|
92
|
+
device = q.device
|
|
93
|
+
g = _geometry_constants(geometry, device=device, dtype=dtype)
|
|
94
|
+
qv = q.to(device=device, dtype=dtype)
|
|
95
|
+
if frame_rotation is not None:
|
|
96
|
+
R = frame_rotation.to(device=device, dtype=dtype)
|
|
97
|
+
if R.shape != (3, 3):
|
|
98
|
+
raise ValueError("frame_rotation must be (3, 3)")
|
|
99
|
+
qv = qv @ R.transpose(-1, -2)
|
|
100
|
+
inv_lambda = 1.0 / g["wavelength_A"]
|
|
101
|
+
Sx = qv[:, 0] / inv_lambda
|
|
102
|
+
Sy = qv[:, 1] / inv_lambda
|
|
103
|
+
Sz = qv[:, 2] / inv_lambda + 1.0
|
|
104
|
+
scale = g["clen_A"] / Sz.clamp_min(1e-30)
|
|
105
|
+
col = (Sx * scale) / g["pix_A"] + g["bc_col"]
|
|
106
|
+
row = (Sy * scale) / g["pix_A"] + g["bc_row"]
|
|
107
|
+
return torch.stack([row, col], dim=-1)
|