Skip to content

Commit 56a9e55

Browse files
Rotation conventions agree at runtime; no ambiguity in rotation convention
1 parent 94c85eb commit 56a9e55

14 files changed

Lines changed: 215 additions & 141 deletions

cryojax/jax_util/_errors.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@
22
from typing import TypeVar
33

44
import equinox as eqx
5-
from jaxtyping import ArrayLike, Bool, PyTree
5+
from jaxtyping import ArrayLike, Bool
66

77
from .._config import CRYOJAX_ENABLE_CHECKS
88

99

10-
T = TypeVar("T", bound="PyTree")
10+
T = TypeVar("T")
1111

1212

1313
def maybe_error_if(x: T, pred_fn: Callable[[T], Bool[ArrayLike, "..."]], msg: str) -> T:

cryojax/simulator/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
IndependentAtomVolume as IndependentAtomVolume,
7474
LobatoScatteringFactor as LobatoScatteringFactor,
7575
PengScatteringFactor as PengScatteringFactor,
76+
RealVoxelCloudVolume as RealVoxelCloudVolume,
7677
RealVoxelGridVolume as RealVoxelGridVolume,
7778
RealVoxelProjection as RealVoxelProjection,
7879
)

cryojax/simulator/_api_utils.py

Lines changed: 34 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from collections.abc import Callable
33
from typing import Any, Literal, overload
44

5-
import equinox as eqx
65
import equinox.internal as eqxi
76
import jax.numpy as jnp
87
import mmdf
@@ -42,28 +41,14 @@
4241
FourierVoxelSplineVolume,
4342
GaussianMixtureVolume,
4443
IndependentAtomVolume,
44+
RealVoxelCloudVolume,
4545
RealVoxelGridVolume,
4646
)
4747

4848

4949
identity_fn = eqxi.doc_repr(lambda x, _: x, "identity_fn")
5050

5151

52-
def _maybe_invert_rotation(
53-
pose: AbstractPose,
54-
volume: AbstractVolumeParametrization,
55-
rotation_convention: Literal["object", "frame"],
56-
) -> AbstractPose:
57-
jaxpr_fn = eqx.filter_make_jaxpr(lambda vol: vol.to_representation())
58-
_, out_dynamic, out_static = jaxpr_fn(volume)
59-
out_struct = eqx.combine(out_dynamic, out_static)
60-
conventions = [rotation_convention, out_struct.rotation_convention]
61-
if conventions[0] != conventions[1]:
62-
pose = pose.to_inverse_rotation()
63-
64-
return pose
65-
66-
6752
@overload
6853
def make_image_model(
6954
volume: AbstractVolumeParametrization,
@@ -79,7 +64,6 @@ def make_image_model(
7964
signal_centering: Literal["bg", "mean"] = "mean",
8065
translate_mode: Literal["fft", "atom", "none"] = "fft",
8166
quantity_mode: Literal["none"] = "none",
82-
rotation_convention: Literal["object", "frame"] = "object",
8367
) -> ProjectionImageModel: ...
8468

8569

@@ -98,7 +82,6 @@ def make_image_model( # pyright: ignore[reportOverlappingOverload]
9882
signal_centering: Literal["bg", "mean"] = "mean",
9983
translate_mode: Literal["fft", "atom", "none"] = "fft",
10084
quantity_mode: Literal["none"] = "none",
101-
rotation_convention: Literal["object", "frame"] = "object",
10285
) -> LinearImageModel: ...
10386

10487

@@ -117,7 +100,6 @@ def make_image_model(
117100
signal_centering: Literal["bg", "mean"] = "mean",
118101
translate_mode: Literal["fft", "atom", "none"] = "fft",
119102
quantity_mode: Literal["contrast"] = "contrast",
120-
rotation_convention: Literal["object", "frame"] = "object",
121103
) -> ContrastImageModel: ...
122104

123105

@@ -136,7 +118,6 @@ def make_image_model(
136118
signal_centering: Literal["bg", "mean"] = "mean",
137119
translate_mode: Literal["fft", "atom", "none"] = "fft",
138120
quantity_mode: Literal["intensity"] = "intensity",
139-
rotation_convention: Literal["object", "frame"] = "object",
140121
) -> IntensityImageModel: ...
141122

142123

@@ -155,7 +136,6 @@ def make_image_model(
155136
signal_centering: Literal["bg", "mean"] = "mean",
156137
translate_mode: Literal["fft", "atom", "none"] = "fft",
157138
quantity_mode: Literal["counts"] = "counts",
158-
rotation_convention: Literal["object", "frame"] = "object",
159139
) -> ElectronCountsImageModel: ...
160140

161141

@@ -173,7 +153,6 @@ def make_image_model(
173153
signal_centering: Literal["bg", "mean"] = "mean",
174154
translate_mode: Literal["fft", "atom", "none"] = "fft",
175155
quantity_mode: Literal["contrast", "intensity", "counts", "none"] = "none",
176-
rotation_convention: Literal["object", "frame"] = "object",
177156
) -> AbstractImageModel:
178157
"""Construct an [`cryojax.simulator.AbstractImageModel`][] for
179158
most common use-cases.
@@ -281,20 +260,6 @@ def make_image_model(
281260
Uses the [`cryojax.simulator.ElectronCountsImageModel`][]
282261
to simulate electron counts.
283262
If this is passed, a `detector` must also be passed.
284-
- `rotation_convention`:
285-
If `'object'`, the rotation given by `pose` is of the object.
286-
If `'frame'`, the rotation given by `pose` is of the frame. These
287-
are related by transpose.
288-
289-
!!! info
290-
The `make_image_model` function enforces agreement between
291-
rotation conventions of different volumes via the
292-
`rotation_convention` argument. Lower level `cryojax` APIs
293-
will not enforce this agreement, such as if the user instantiates
294-
an [`cryojax.simulator.AbstractImageModel`][] directly.
295-
296-
In these cases, agreement can be acheived with a manual transpose
297-
via `pose.to_inverse_rotation()`.
298263
299264
**Returns:**
300265
@@ -309,13 +274,6 @@ def make_image_model(
309274
[`cryojax.simulator.ElectronCountsImageModel`][] depending on
310275
the value of `quantity_mode`.
311276
"""
312-
# Invert pose if
313-
if rotation_convention not in ["object", "frame"]:
314-
raise ValueError(
315-
f"Found `rotation_convention = {rotation_convention}`, but valid "
316-
"values are 'object' and 'frame'."
317-
)
318-
pose = _maybe_invert_rotation(pose, volume, rotation_convention)
319277
options = dict(
320278
normalizes_signal=normalizes_signal,
321279
signal_centering=signal_centering,
@@ -581,14 +539,31 @@ def render_voxel_volume(
581539
) -> RealVoxelGridVolume: ...
582540

583541

542+
@overload
543+
def render_voxel_volume(
544+
atom_volume: AbstractAtomVolume,
545+
render_fn: AbstractVolumeRenderFn,
546+
*,
547+
output_type: type[RealVoxelCloudVolume] = RealVoxelCloudVolume,
548+
) -> RealVoxelCloudVolume: ...
549+
550+
584551
def render_voxel_volume(
585552
atom_volume: AbstractAtomVolume,
586553
render_fn: AbstractVolumeRenderFn,
587554
*,
588555
output_type: type[
589-
FourierVoxelGridVolume | FourierVoxelSplineVolume | RealVoxelGridVolume
556+
FourierVoxelGridVolume
557+
| FourierVoxelSplineVolume
558+
| RealVoxelGridVolume
559+
| RealVoxelCloudVolume
590560
] = FourierVoxelGridVolume,
591-
) -> FourierVoxelGridVolume | FourierVoxelSplineVolume | RealVoxelGridVolume:
561+
) -> (
562+
FourierVoxelGridVolume
563+
| FourierVoxelSplineVolume
564+
| RealVoxelGridVolume
565+
| RealVoxelCloudVolume
566+
):
592567
"""Render a voxel volume representation from an atomistic one.
593568
594569
!!! example "Simulate an image with Fourier slice extraction"
@@ -624,7 +599,8 @@ def render_voxel_volume(
624599
Either [`cryojax.simulator.FourierVoxelGridVolume`][] /
625600
[`cryojax.simulator.FourierVoxelSplineVolume`][] for
626601
fourier-space representations, or
627-
[`cryojax.simulator.RealVoxelGridVolume`][] for real-space.
602+
[`cryojax.simulator.RealVoxelGridVolume`][] /
603+
[`cryojax.simulator.RealVoxelCloudVolume`][] for real-space.
628604
629605
630606
**Returns:**
@@ -650,14 +626,20 @@ def render_voxel_volume(
650626
else:
651627
spline_coefficients = compute_spline_coefficients(fourier_voxel_grid)
652628
return FourierVoxelSplineVolume(spline_coefficients, frequency_slice)
653-
elif output_type == RealVoxelGridVolume:
629+
elif output_type == RealVoxelGridVolume or output_type == RealVoxelCloudVolume:
654630
coordinate_grid = make_coordinate_grid(render_fn.shape)
655631
real_voxel_grid = render_fn(atom_volume, outputs_real_space=True)
656-
return RealVoxelGridVolume(real_voxel_grid, coordinate_grid)
632+
if output_type == RealVoxelGridVolume:
633+
return RealVoxelGridVolume(real_voxel_grid, coordinate_grid)
634+
else:
635+
return RealVoxelCloudVolume.from_real_voxel_grid(
636+
real_voxel_grid, coordinate_grid_in_pixels=coordinate_grid
637+
)
657638
else:
658639
raise ValueError(
659-
"Only `output_type` equal to `FourierVoxelGridVolume`, "
660-
"`FourierVoxelSplineVolume`, or `RealVoxelGridVolume` "
661-
"are supported."
662-
f"Got `output_type = {output_type}`."
640+
f"Got `output_type = {output_type}`, but this is "
641+
"not supported by `render_voxel_volume(..., output_type=...)`."
642+
"Valid values for `output_type` are `FourierVoxelGridVolume`, "
643+
"`FourierVoxelSplineVolume`, `RealVoxelGridVolume`, or "
644+
"`RealVoxelCloudVolume`."
663645
)

cryojax/simulator/_volume/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
PengScatteringFactor as PengScatteringFactor,
3030
)
3131
from .real_voxels import (
32+
RealVoxelCloudVolume as RealVoxelCloudVolume,
3233
RealVoxelGridVolume as RealVoxelGridVolume,
3334
RealVoxelProjection as RealVoxelProjection,
3435
)

cryojax/simulator/_volume/auto_select.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
FFTAtomRenderFn,
2929
IndependentAtomVolume,
3030
)
31-
from .real_voxels import RealVoxelGridVolume, RealVoxelProjection
31+
from .real_voxels import RealVoxelCloudVolume, RealVoxelProjection
3232

3333

3434
class AutoVolumeProjection(
@@ -47,10 +47,13 @@ class AutoVolumeProjection(
4747
| [`cryojax.simulator.GaussianMixtureVolume`][] | [`cryojax.simulator.GaussianMixtureProjection`][] | atom |
4848
| [`cryojax.simulator.IndependentAtomVolume`][] | [`cryojax.simulator.FFTAtomProjection`][] | atom |
4949
| [`cryojax.simulator.FourierVoxelGridVolume`][] or [`cryojax.simulator.FourierVoxelSplineVolume`][] | [`cryojax.simulator.FourierSliceExtraction`][] | voxel |
50-
| [`cryojax.simulator.RealVoxelGridVolume`][] | [`cryojax.simulator.RealVoxelProjection`][] | voxel |
50+
| [`cryojax.simulator.RealVoxelCloudVolume`][] | [`cryojax.simulator.RealVoxelProjection`][] | voxel |
51+
52+
Note that [`cryojax.simulator.RealVoxelGridVolume`][] does not have an associated projection method. To
53+
compute projections from real-space voxels, use [`cryojax.simulator.RealVoxelCloudVolume`][].
5154
5255
To use advanced options for a given projection method,
53-
see each respective class.
56+
instantiate each respective class directly.
5457
5558
!!! warning
5659
If using [`cryojax.simulator.FFTAtomRenderFn`][] or [`cryojax.simulator.RealVoxelProjection`][], [`jax-finufft`](https://github.com/flatironinstitute/jax-finufft)
@@ -67,14 +70,15 @@ def _select_projection_method(
6770
integrator = FourierSliceExtraction()
6871
elif isinstance(volume, GaussianMixtureVolume):
6972
integrator = GaussianMixtureProjection()
70-
elif isinstance(volume, RealVoxelGridVolume):
73+
elif isinstance(volume, RealVoxelCloudVolume):
7174
integrator = RealVoxelProjection()
7275
elif isinstance(volume, IndependentAtomVolume):
7376
integrator = FFTAtomProjection()
7477
else:
7578
raise ValueError(
7679
"Could not use `AutoVolumeProjection` for volume of "
77-
f"type {type(volume).__name__}. If using a custom volume, "
80+
f"type {type(volume).__name__}. See the documentation for "
81+
"supported types. If using a custom volume, "
7882
"please directly pass an integrator."
7983
)
8084
return integrator

cryojax/simulator/_volume/base_volume.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import abc
2-
from typing import Generic, Literal, TypeVar
2+
from typing import Generic, TypeVar
33
from typing_extensions import Self, override
44

55
import equinox as eqx
@@ -118,10 +118,10 @@ class AbstractVolumeRepresentation(AbstractVolumeParametrization, strict=True):
118118
classes for imaging.
119119
"""
120120

121-
rotation_convention: eqx.AbstractClassVar[Literal["object", "frame"]]
121+
is_frame_rotation: eqx.AbstractClassVar[bool]
122122

123123
@abc.abstractmethod
124-
def rotate_to_pose(self, pose: AbstractPose, inverse: bool = False) -> Self:
124+
def rotate_to_pose(self, pose: AbstractPose) -> Self:
125125
"""Rotate the coordinate system of the volume."""
126126
raise NotImplementedError
127127

@@ -174,7 +174,7 @@ class AbstractVoxelVolume(AbstractVolumeRepresentation, strict=True):
174174
@property
175175
@abc.abstractmethod
176176
def shape(self) -> tuple[int, ...]:
177-
"""The shape of the voxel array."""
177+
"""The shape of the voxel grid."""
178178
raise NotImplementedError
179179

180180
@classmethod

cryojax/simulator/_volume/fourier_voxels.py

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
Fourier voxel-based representations of a volume.
33
"""
44

5-
from typing import ClassVar, Literal, cast
5+
from typing import ClassVar, cast
66
from typing_extensions import Self, override
77

88
import equinox as eqx
@@ -41,12 +41,14 @@ class AbstractFourierVoxelVolume(AbstractVoxelVolume, strict=True):
4141
frequency_slice_in_pixels: eqx.AbstractVar[Float[Array, "1 dim dim 3"]]
4242

4343
@override
44-
def rotate_to_pose(self, pose: AbstractPose, inverse: bool = False) -> Self:
44+
def rotate_to_pose(self, pose: AbstractPose) -> Self:
4545
"""Return a new volume with a rotated `frequency_slice_in_pixels`."""
4646
return eqx.tree_at(
4747
lambda d: d.frequency_slice_in_pixels,
4848
self,
49-
pose.rotate_coordinates(self.frequency_slice_in_pixels, inverse=inverse),
49+
pose.rotate_coordinates(
50+
self.frequency_slice_in_pixels, inverse=self.is_frame_rotation
51+
),
5052
)
5153

5254

@@ -56,7 +58,7 @@ class FourierVoxelGridVolume(AbstractFourierVoxelVolume, strict=True):
5658
fourier_voxel_grid: Complex[Array, "dim dim dim"]
5759
frequency_slice_in_pixels: Float[Array, "1 dim dim 3"]
5860

59-
rotation_convention: ClassVar[Literal["frame"]] = "frame"
61+
is_frame_rotation: ClassVar[bool] = True
6062

6163
def __init__(
6264
self,
@@ -146,7 +148,7 @@ class FourierVoxelSplineVolume(AbstractFourierVoxelVolume, strict=True):
146148
spline_coefficients: Complex[Array, "coeff_dim coeff_dim coeff_dim"]
147149
frequency_slice_in_pixels: Float[Array, "1 dim dim 3"]
148150

149-
rotation_convention: ClassVar[Literal["frame"]] = "frame"
151+
is_frame_rotation: ClassVar[bool] = True
150152

151153
def __init__(
152154
self,
@@ -317,8 +319,10 @@ def integrate(
317319
)
318320
else:
319321
raise ValueError(
320-
"Supported types for `volume_representation` are "
321-
"`FourierVoxelGridVolume` and FourierVoxelSplineVolume`."
322+
"Got unsupported type for `volume_representation` in "
323+
"`FourierSliceExtraction.integrate`. Expected `FourierVoxelGridVolume` "
324+
"or `FourierVoxelSplineVolume`, "
325+
f"but got `{volume_representation.__class__.__name__}`."
322326
)
323327

324328
# Resize the image to match the AbstractImageConfig.padded_shape
@@ -435,8 +439,10 @@ def integrate(
435439
)
436440
else:
437441
raise ValueError(
438-
"Supported types for `volume_representation` are "
439-
"`FourierVoxelGridVolume` and `FourierVoxelSplineVolume`."
442+
"Got unsupported type for `volume_representation` in "
443+
"`EwaldSphereExtraction.integrate`. Expected `FourierVoxelGridVolume` "
444+
"or `FourierVoxelSplineVolume`, "
445+
f"but got `{volume_representation.__class__.__name__}`."
440446
)
441447

442448
# Resize the image to match the AbstractImageConfig.padded_shape

cryojax/simulator/_volume/gaussian_volume.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class GaussianMixtureVolume(AbstractAtomVolume, strict=True):
6565
amplitudes: Float[Array, "n_positions n_gaussians"]
6666
variances: Float[Array, " n_positions n_gaussians"]
6767

68-
rotation_convention: ClassVar[Literal["object"]] = "object"
68+
is_frame_rotation: ClassVar[bool] = False
6969

7070
def __init__(
7171
self,
@@ -197,12 +197,12 @@ def from_tabulated_parameters(
197197
return cls(atom_positions, amplitudes, b_factor_to_variance(b_factors))
198198

199199
@override
200-
def rotate_to_pose(self, pose: AbstractPose, inverse: bool = False) -> Self:
200+
def rotate_to_pose(self, pose: AbstractPose) -> Self:
201201
"""Return a new potential with rotated `positions`."""
202202
return eqx.tree_at(
203203
lambda d: d.positions,
204204
self,
205-
pose.rotate_coordinates(self.positions, inverse=inverse),
205+
pose.rotate_coordinates(self.positions, inverse=self.is_frame_rotation),
206206
)
207207

208208
@override

0 commit comments

Comments
 (0)