Last active
December 9, 2022 21:53
-
-
Save alisterburt/809ab352caa3ae7c0890040a7f1811d7 to your computer and use it in GitHub Desktop.
super basic tilt-series simulator
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import os | |
| from pathlib import Path | |
| from typing import Tuple, Optional | |
| import einops | |
| import fast_histogram | |
| import mmdf | |
| import mrcfile | |
| import numpy as np | |
| from scipy.stats import special_ortho_group | |
| import typer | |
| cli = typer.Typer(add_completion=False) | |
| def model_to_xyz(model_file: os.PathLike) -> np.ndarray: | |
| df = mmdf.read(str(model_file)) | |
| return df[['x', 'y', 'z']].to_numpy() | |
| def center_xyz(xyz: np.ndarray) -> np.ndarray: | |
| return xyz - xyz.mean(axis=0) | |
| def random_rotations(n: int) -> np.ndarray: | |
| return special_ortho_group.rvs(dim=3, size=n) | |
| def random_positions(n: int, grid_shape: Tuple[int, int, int]) -> np.ndarray: | |
| z, y, x = [ | |
| np.random.uniform(low=0, high=dim_length, size=n) | |
| for dim_length in grid_shape | |
| ] | |
| return einops.rearrange([z, y, x], 'zyx b -> b zyx') | |
| def random_rotate_xyz(xyz: np.ndarray, n: int) -> np.ndarray: # (b, n, 3) | |
| rotations = random_rotations(n) # (n, 3, 3) | |
| xyz = einops.rearrange(xyz, 'b xyz -> b 1 xyz 1') | |
| rotated_xyz = rotations @ xyz # (b, n, 3, 1) | |
| return einops.rearrange(rotated_xyz, 'b n xyz 1 -> b n xyz') | |
| def rasterise_3d( | |
| xyz: np.ndarray, grid_shape: Tuple[int, int, int] | |
| ) -> np.ndarray: | |
| bd, bh, bw = [np.arange(i + 1) - 0.5 for i in grid_shape] | |
| zyx = xyz[:, ::-1] | |
| image, _ = np.histogramdd(zyx, bins=[bw, bh, bd]) | |
| return image | |
| def rasterise_2d( | |
| xy: np.ndarray, grid_shape: Tuple[int, int] | |
| ) -> np.ndarray: | |
| yx = xy[:, ::-1] | |
| h, w = grid_shape | |
| image = fast_histogram.histogramdd( | |
| sample=yx, bins=(h, w), range=[[-0.5, h - 0.5], [-0.5, w - 0.5]] | |
| ) | |
| return image | |
| def promote_2d_shifts_to_3d(shifts: np.ndarray) -> np.ndarray: | |
| """Promote arrays of 2D shifts to 3D with zeros in the last column. | |
| Last dimension of array should be of length 2. | |
| Parameters | |
| ---------- | |
| shifts: torch.Tensor | |
| `(..., 2)` array of 2D shifts | |
| Returns | |
| ------- | |
| output: torch.Tensor | |
| `(..., 3)` array of 3D shifts with 0 in the last column. | |
| """ | |
| shifts = np.asarray(shifts) | |
| if shifts.ndim == 1: | |
| shifts = einops.rearrange(shifts, 's -> 1 s') | |
| if shifts.shape[-1] != 2: | |
| raise ValueError('last dimension must have length 2.') | |
| shifts = np.pad(shifts, pad_width=((0, 0), (0, 1))) | |
| return np.squeeze(shifts) | |
| def homogenise_coordinates(coords: np.ndarray) -> np.ndarray: | |
| """3D coordinates to 4D homogenous coordinates with ones in the last column. | |
| Parameters | |
| ---------- | |
| coords: torch.Tensor | |
| `(..., 3)` array of 3D coordinates | |
| Returns | |
| ------- | |
| output: torch.Tensor | |
| `(..., 4)` array of homogenous coordinates | |
| """ | |
| return np.c_[coords, np.ones(len(coords))] | |
| def S(shifts: np.ndarray) -> np.ndarray: | |
| """4x4 matrices for shifts. | |
| Shifts supplied can be 2D or 3D. | |
| """ | |
| shifts = np.asarray(shifts) | |
| if shifts.shape[-1] == 2: | |
| shifts = promote_2d_shifts_to_3d(shifts) | |
| shifts = shifts.reshape((-1, 3)) | |
| matrices = einops.repeat(np.eye(4), 'i j -> n i j', n=shifts.shape[0]) | |
| matrices = np.ascontiguousarray(matrices) | |
| matrices[:, 0:3, 3] = shifts | |
| return np.squeeze(matrices) | |
| def Rx(angles_degrees: np.ndarray) -> np.ndarray: | |
| """4x4 matrices for a rotation of homogenous coordinates (xyzw) around the X-axis.""" | |
| angles_degrees = np.asarray(angles_degrees).reshape(-1) | |
| angles_radians = np.deg2rad(angles_degrees) | |
| c = np.cos(angles_radians) | |
| s = np.sin(angles_radians) | |
| matrices = einops.repeat(np.eye(4), 'i j -> n i j', n=len(angles_degrees)) | |
| matrices = np.ascontiguousarray(matrices) | |
| matrices[:, 1, 1] = c | |
| matrices[:, 1, 2] = -s | |
| matrices[:, 2, 1] = s | |
| matrices[:, 2, 2] = c | |
| return np.squeeze(matrices) | |
| def Ry(angles_degrees: np.ndarray) -> np.ndarray: | |
| """4x4 matrices for a rotation of homogenous coordinates (xyzw) around the Y-axis.""" | |
| angles_degrees = np.asarray(angles_degrees).reshape(-1) | |
| angles_radians = np.deg2rad(angles_degrees) | |
| c = np.cos(angles_radians) | |
| s = np.sin(angles_radians) | |
| matrices = einops.repeat(np.eye(4), 'i j -> n i j', n=len(angles_degrees)) | |
| matrices = np.ascontiguousarray(matrices) | |
| matrices[:, 0, 0] = c | |
| matrices[:, 0, 2] = s | |
| matrices[:, 2, 0] = -s | |
| matrices[:, 2, 2] = c | |
| return np.squeeze(matrices) | |
| def Rz(angles_degrees: np.ndarray) -> np.ndarray: | |
| """4x4 matrices for a rotation of homogenous coordinates (xyzw) around the Z-axis.""" | |
| angles_degrees = np.asarray(angles_degrees).reshape(-1) | |
| angles_radians = np.deg2rad(angles_degrees) | |
| c = np.cos(angles_radians) | |
| s = np.sin(angles_radians) | |
| matrices = einops.repeat(np.eye(4), 'i j -> n i j', n=len(angles_degrees)) | |
| matrices = np.ascontiguousarray(matrices) | |
| matrices[:, 0, 0] = c | |
| matrices[:, 0, 1] = -s | |
| matrices[:, 1, 0] = s | |
| matrices[:, 1, 1] = c | |
| return np.squeeze(matrices) | |
| @cli.command(no_args_is_help=True) | |
| def simulate_tilt_series( | |
| input_model_file: Path = typer.Option(...), | |
| output_tilt_series_file: Path = typer.Option(...), | |
| output_volume_file: Optional[Path] = None, | |
| output_pixel_spacing: float = 10, | |
| output_image_shape: Tuple[int, int] = (512, 512), | |
| n_tilt_images: int = 41, | |
| n_particles: int = 100, | |
| minimum_angle: float = -60, | |
| maximum_angle: float = 60, | |
| apply_random_2d_shifts: bool = False | |
| ): | |
| # load molecular model, center and rescale | |
| particle_xyz = model_to_xyz(input_model_file) | |
| particle_xyz = center_xyz(particle_xyz) | |
| particle_xyz /= output_pixel_spacing | |
| # randomly orient particles and place in volume | |
| oriented_particles_xyz = random_rotate_xyz(particle_xyz, n=n_particles) | |
| volume_shape = (*output_image_shape, output_image_shape[0] // 3) | |
| particle_positions = random_positions(n_particles, grid_shape=volume_shape) | |
| positioned_particle_xyz = oriented_particles_xyz + particle_positions | |
| # set up projection geometry | |
| tilt = np.linspace(minimum_angle, maximum_angle, num=n_tilt_images) | |
| shift_std = output_image_shape[0] / 50 | |
| if apply_random_2d_shifts is True: | |
| shifts = np.random.normal(scale=shift_std, size=(n_tilt_images, 2)) | |
| else: | |
| shifts = np.array([[0, 0]]) | |
| volume_center = np.array(volume_shape) // 2 | |
| tilt_image_center = np.array(output_image_shape) // 2 | |
| s0 = S(-volume_center) | |
| r1 = Ry(tilt) | |
| s1 = S(shifts) | |
| s2 = S(tilt_image_center) | |
| T = s2 @ s1 @ r1 @ s0 | |
| Txy = T[..., :2, :] # only need to keep xy component of output | |
| # setup particle coordinates for projection | |
| positioned_particle_xyz = einops.rearrange( | |
| positioned_particle_xyz, 'b particle xyz -> (b particle) xyz' | |
| ) | |
| particle_xyzw = homogenise_coordinates(positioned_particle_xyz) | |
| particle_xyzw = einops.rearrange(particle_xyzw, 'b xyzw -> b xyzw 1') | |
| # project into xy plane and render | |
| output_image = np.zeros(shape=(n_tilt_images, *output_image_shape)) | |
| for tilt_idx, projection_matrix in enumerate(Txy): | |
| projected_xy = projection_matrix @ particle_xyzw | |
| end = '\r' if tilt_idx < len(Txy) - 1 else None | |
| print(f'rendering image {tilt_idx + 1}/{n_tilt_images}', end=end) | |
| projected_xy = einops.rearrange(projected_xy, 'b xy 1 -> b xy') | |
| output_image[tilt_idx] = rasterise_2d(projected_xy, | |
| grid_shape=output_image_shape) | |
| # write outputs | |
| print(f'writing tilt-series to {output_tilt_series_file}') | |
| mrcfile.write( | |
| name=output_tilt_series_file, | |
| data=output_image.astype(np.float32), | |
| voxel_size=output_pixel_spacing, | |
| overwrite=True | |
| ) | |
| if apply_random_2d_shifts is True: | |
| output_directory = output_tilt_series_file.parent | |
| shifts_file_name = f'{output_tilt_series_file.stem}_xy_shifts.txt' | |
| shifts_file = output_directory / shifts_file_name | |
| print(f'writing shifts to {shifts_file}') | |
| np.savetxt( | |
| fname=shifts_file, | |
| X=shifts[:, ::-1], | |
| fmt='%04f', | |
| ) | |
| if output_volume_file is not None: | |
| print('rendering volume') | |
| volume = rasterise_3d(positioned_particle_xyz, volume_shape) | |
| print(f'writing volume to {output_volume_file}') | |
| mrcfile.write( | |
| name=output_volume_file, | |
| data=volume.astype(np.float32), | |
| voxel_size=output_pixel_spacing, | |
| overwrite=True, | |
| ) | |
| volume_directory = output_volume_file.parent | |
| positions_filename = f'{output_volume_file.stem}_particle_positions_xyz.txt' | |
| positions_file = volume_directory / positions_filename | |
| print(f'writing particle positions to {positions_file}') | |
| np.savetxt( | |
| fname=positions_file, | |
| X=positioned_particle_xyz, | |
| fmt='%04f', | |
| ) | |
| if __name__ == '__main__': | |
| cli() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment