Skip to content

cylindra.widgets.subwidgets

Simulator

Methods are available in the namespace ui.simulator.

Source code in cylindra/widgets/subwidgets/simulator.py
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
@magicclass(use_native_menubar=False)
class Simulator(ChildWidget):
    @magicmenu(name="Create")
    class CreateMenu(ChildWidget):
        create_empty_image = abstractapi()
        create_straight_line = abstractapi()
        create_image_with_straight_line = abstractapi()

    @magicmenu(name="Simulate")
    class SimulateMenu(ChildWidget):
        simulate_tomogram = abstractapi()
        simulate_tomogram_from_tilt_series = abstractapi()
        simulate_tomogram_and_open = abstractapi()
        simulate_tilt_series = abstractapi()
        simulate_projection = abstractapi()

    @magictoolbar
    class SimulatorTools(ChildWidget):
        add_component = abstractapi()
        sep0 = Separator
        generate_molecules = abstractapi()
        expand = abstractapi()
        twist = abstractapi()
        dilate = abstractapi()
        displace = abstractapi()

    component_list = field(ComponentList, name="components")

    def _prep_radon(
        self,
        components: list[tuple[str, Path]],
        degrees: NDArray[np.floating],
        order: int = 3,
    ) -> ip.ImgArray:
        # noise-free tomogram generation from the current cylinder model
        main = self._get_main()
        tomo = main.tomogram
        scale = tomo.scale
        shape = tomo.image.shape
        simulator = TomogramSimulator(order=order, scale=scale)
        for layer_name, temp_path in components:
            mole = main.mole_layers[layer_name].molecules
            simulator.add_molecules(mole, pipe.from_file(temp_path))
        tilt_series = simulator.simulate_tilt_series(degrees=degrees, shape=shape)
        tilt_series = ip.asarray(
            tilt_series, axes=["degree", "y", "x"], name="Simulated"
        )
        return tilt_series.set_scale(y=scale, x=scale)

    def _get_proper_molecules_layers(self, *_):
        out = list[MoleculesLayer]()
        for layer in self._get_main().mole_layers:
            if layer.source_spline is None:
                continue
            mole = layer.molecules
            cols = mole.features.columns
            if Mole.nth in cols and Mole.pf in cols and mole.count() > 0:
                out.append(layer)
        return out

    _ModeledMoleculesLayer = Annotated[
        MoleculesLayer,
        {"choices": _get_proper_molecules_layers, "validator": _as_layer_name},
    ]

    @set_design(icon="fluent:cloud-add-16-filled", location=SimulatorTools)
    @do_not_record
    def add_component(
        self,
        layer: MoleculesLayerType,
        template_path: Path.Read[FileFilter.IMAGE],
    ):
        """
        Add a set of template and a molecules as a simulation component.

        A component defines which molecules corresponds to what template image.
        Multiple components can be added to simulate a tomogram with different
        materials.

        Parameters
        ----------
        layer : MoleculesLayer
            Layer to be used for simulation.
        template_path : Path
            Path to the template image that will be used to simulate the
            corresponding molecules layer.
        """
        layer = assert_layer(layer, self.parent_viewer)
        self.component_list.append(Component(template_path, layer))
        self.component_list._on_children_change()

    @set_design(text=capitalize, location=CreateMenu)
    @thread_worker.with_progress(desc="Creating an image")
    @confirm(
        text="You have an opened image. Run anyway?",
        condition="not self._get_main().tomogram.is_dummy",
    )
    def create_empty_image(
        self,
        size: _ImageSize = (60.0, 200.0, 60.0),
        scale: Annotated[nm, {"label": "pixel scale (nm/pixel)"}] = 0.25,
    ):  # fmt: skip
        """
        Create an empty image with the given size and scale, and send it to the viewer.

        Parameters
        ----------
        size : (nm, nm, nm), default (60., 200., 60.)
            Size of the image in nm, of (Z, Y, X).
        scale : nm, default 0.25
            Pixel size of the image.
        """
        main = self._get_main()
        shape = tuple(roundint(s / scale) for s in size)

        binsize = ceilint(0.96 / scale)
        # NOTE: zero-filled image breaks contrast limit calculation, and bad for
        # visual detection of the image edges.
        tomo = CylTomogram.dummy(scale=scale, binsize=binsize, shape=shape)
        main._init_macro_state()

        @thread_worker.callback
        def _out():
            main._send_tomogram_to_viewer(tomo)
            main._reserved_layers.image.bounding_box.visible = True

        return _out

    @set_design(text=capitalize, location=CreateMenu)
    def create_straight_line(self, start: _Point3D, end: _Point3D):
        """
        Create a straight line as a spline.

        Parameters
        ----------
        start : (nm, nm, nm)
            Start point of the line.
        end : (nm, nm, nm)
            End point of the line.
        """
        spl = CylSpline.line(start, end)
        main = self._get_main()
        main.tomogram.splines.append(spl)
        main._add_spline_instance(spl)
        return undo_callback(main.delete_spline).with_args(-1)

    @set_design(text=capitalize, location=CreateMenu)
    @thread_worker.with_progress(desc="Creating an image")
    @confirm(
        text="You have an opened image. Run anyway?",
        condition="not self._get_main().tomogram.is_dummy",
    )
    def create_image_with_straight_line(
        self,
        length: nm = 150.0,
        size: _ImageSize = (60.0, 200.0, 60.0),
        scale: Annotated[nm, {"label": "pixel scale (nm/pixel)"}] = 0.25,
        yxrotation: Annotated[float, {"max": 90, "step": 1, "label": "Rotation in YX plane (deg)"}] = 0.0,
        zxrotation: Annotated[float, {"max": 90, "step": 1, "label": "Rotation in ZX plane (deg)"}] = 0.0,
    ):  # fmt: skip
        """
        Create a straight line as a cylinder spline.

        Parameters
        ----------
        length : nm, default 150.0
            Length if the straight line in nm.
        size : (nm, nm, nm), (60.0, 200.0, 60.0)
            Size of the tomogram in which the spline will reside.
        scale : nm, default 0.25
            Scale of pixel in nm/pixel.
        yxrotation : float, optional
            Rotation in YX plane. This rotation will be applied before ZX rotation.
        zxrotation : float, optional
            Rotation in ZX plane. This rotation will be applied before YX rotation.
        """
        yxrot = Rotation.from_rotvec([np.deg2rad(yxrotation), 0.0, 0.0])
        zxrot = Rotation.from_rotvec([0.0, 0.0, np.deg2rad(zxrotation)])
        start_shift = zxrot.apply(yxrot.apply(np.array([0.0, -length / 2, 0.0])))
        end_shift = zxrot.apply(yxrot.apply(np.array([0.0, length / 2, 0.0])))
        center = np.array(size) / 2
        yield from self.create_empty_image.arun(size=size, scale=scale)
        cb = thread_worker.callback(self.create_straight_line)
        yield cb.with_args(start_shift + center, end_shift + center)

    def _get_spline_idx(self, *_) -> int:
        return self._get_main()._get_spline_idx()

    @set_design(icon="fluent:select-object-skew-20-regular", location=SimulatorTools)
    def generate_molecules(
        self,
        spline: Annotated[int, {"bind": _get_spline_idx}] = 0,
        spacing: Annotated[nm, {"min": 0.2, "max": 100.0, "step": 0.01, "label": "spacing (nm)"}] = 1.0,
        twist: Annotated[float, {"min": -45.0, "max": 45.0, "label": "twist (deg)"}] = 0.0,
        start: Annotated[int, {"min": -50, "max": 50, "label": "start"}] = 0,
        npf: Annotated[int, {"min": 1, "label": "number of PF"}] = 2,
        radius: Annotated[nm, {"min": 0.5, "max": 50.0, "step": 0.5, "label": "radius (nm)"}] = 10.0,
        offsets: tuple[float, float] = (0.0, 0.0),
        update_glob: Annotated[bool, {"label": "update spline global properties"}] = True,
    ):  # fmt: skip
        """
        Update cylinder model with new parameters.

        Local structural displacement will be deleted because this function may change
        the number of molecules. This function should be called first.

        Parameters
        ----------
        spacing : nm
            Axial spacing between molecules.
        twist : float
            Monomer twist of the cylinder.
        start : int
            The start number.
        npf : int
            Number of protofilaments.
        radius : nm
            Radius of the cylinder.
        """
        # NOTE: these parameters are hard-coded for microtubule for now.
        main = self._get_main()
        spl = main.splines[spline]
        model = self._prep_model(spl, spacing, twist, start, npf, radius, offsets)
        mole = model.to_molecules(spl)
        name = _make_simulated_mole_name(main.parent_viewer)
        layer = main.add_molecules(mole, name=name, source=spl)
        _set_simulation_model(layer, model)
        old_props = spl.props.glob
        if update_glob:
            cparams = spl.copy(copy_props=False).cylinder_params(
                spacing=spacing, twist=twist, start=start, npf=npf, radius=radius
            )
            spl.update_glob_by_cylinder_params(cparams)

        @undo_callback
        def _out():
            main._undo_callback_for_layer(layer).run()
            spl.props.glob = old_props

        return _out

    @impl_preview(generate_molecules, auto_call=True)
    def _preview_generate_molecules(
        self,
        spline: int,
        spacing: nm,
        twist: float,
        start: int,
        npf: int,
        radius: nm,
        offsets: tuple[float, float],
    ):
        main = self._get_main()
        spl = main.splines[spline]
        model = self._prep_model(spl, spacing, twist, start, npf, radius, offsets)
        out = model.to_molecules(spl)
        viewer = main.parent_viewer
        if PREVIEW_LAYER_NAME in viewer.layers:
            layer: Layer = viewer.layers[PREVIEW_LAYER_NAME]
            layer.data = out.pos
        else:
            layer = main.add_molecules(
                out, name=PREVIEW_LAYER_NAME, face_color="crimson"
            )
        is_active = yield
        if not is_active and layer in viewer.layers:
            viewer.layers.remove(layer)

    def _prep_model(
        self,
        spl: CylSpline,
        spacing: nm,
        twist: float,
        start: int,
        npf: int,
        radius: nm,
        offsets: tuple[float, float],
    ) -> CylinderModel:
        kwargs = {
            H.spacing: spacing,
            H.twist: twist,
            H.start: start,
            H.npf: npf,
            H.radius: radius,
        }
        model = spl.copy(copy_props=False).cylinder_model(offsets=offsets, **kwargs)
        return model

    def _get_components(self, *_):
        return self.component_list._as_input()

    @set_design(text=capitalize, location=SimulateMenu)
    @dask_thread_worker.with_progress(descs=_simulate_tomogram_iter)
    def simulate_tomogram(
        self,
        components: Annotated[Any, {"bind": _get_components}],
        save_dir: Annotated[Path.Save, {"label": "Save at"}],
        nsr: _NSRatios = [1.5],
        tilt_range: _TiltRange = (-60.0, 60.0),
        n_tilt: Annotated[int, {"label": "Number of tilts"}] = 21,
        interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
        seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
    ):  # fmt: skip
        """
        Simulate tomographic images using the current model and save the images.

        This function projects the template image to each tilt series, adding
        Gaussian noise, and back-projects the noisy tilt series to the tomogram.

        Parameters
        ----------
        components : list of (str, Path)
            List of tuples of layer name and path to the template image.
        save_dir : Path
            Path to the directory where the images will be saved.
        nsr : list of float
            Noise-to-signal ratio. It is defined by N/S, where S is the maximum
            value of the true monomer density and N is the standard deviation of
            the Gaussian noise. Duplicate values are allowed, which is useful
            for simulation of multiple images with the same noise level.
        tilt_range : tuple of float
            Minimum and maximum tilt angles in degree.
        n_tilt : int
            Number of tilt angles between minimum and maximum angles.
        interpolation : int
            Interpolation method used during the simulation.
        seed : int, optional
            Random seed used for the Gaussian noise.
        """
        save_dir = _norm_save_dir(save_dir)
        _assert_not_empty(components)
        nsr = [round(float(_nsr), 4) for _nsr in nsr]
        main = self._get_main()
        degrees = np.linspace(*tilt_range, n_tilt)
        sino = self._prep_radon(components, degrees, order=interpolation)

        yield _on_radon_finished.with_args(sino, degrees)

        rng = ip.random.default_rng(seed)
        imax = sino.max()
        for i, nsr_val in enumerate(nsr):
            sino_noise = sino + rng.normal(
                scale=imax * nsr_val, size=sino.shape, axes=sino.axes
            )
            rec = sino_noise.iradon(
                degrees,
                central_axis="y",
                height=main.tomogram.image.shape[0],
                order=interpolation,
            ).set_scale(zyx=main.tomogram.scale, unit="nm")
            yield _on_iradon_finished.with_args(rec.mean("z"), f"N/S = {nsr_val:.1f}")

            file_name = save_dir / f"image-{i}.mrc"
            rec.imsave(file_name)
            _Logger.print(f"Image saved at {file_name}.")

        main.save_project(save_dir / PROJECT_NAME, molecules_ext=".parquet")
        return None

    @set_design(text=capitalize, location=SimulateMenu)
    @dask_thread_worker.with_progress(descs=_simulate_tomogram_from_tilt_iter)
    @confirm(
        text="You have an opened image. Run anyway?",
        condition="not self._get_main().tomogram.is_dummy",
    )
    def simulate_tomogram_from_tilt_series(
        self,
        path: Path.Read[FileFilter.IMAGE],
        nsr: _NSRatio = 1.5,
        bin_size: Annotated[list[int], {"options": {"min": 1, "max": 32}}] = [1],
        tilt_range: _TiltRange = (-60.0, 60.0),
        height: Annotated[nm, {"label": "height (nm)"}] = 50,
        interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
        seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
    ):
        """
        Simulate tomographic images using a tilt series.

        Parameters
        ----------
        path : Path
            Path to the tilt series image.
        nsr : float
            Noise-to-signal ratio. It is defined by N/S, where S is the maximum
            value of the tilt series. If the input image is already noisy, you
            can set this value to zero to avoid adding more noises.
        bin_size : list of int
            Bin sizes used to create multi-scaled images from the simulated image.
        tilt_range : tuple of float
            Minimum and maximum tilt angles in degree.
        height : int
            Height of the simulated tomogram in nm.
        interpolation : int
            Interpolation method used during the simulation.
        seed : int, optional
            Random seed used for the Gaussian noise.
        """
        main = self._get_main()
        sino = ip.imread(path)
        scale = sino.scale.x
        if sino.ndim != 3:
            raise ValueError("Input image must be a 3D image.")
        degrees = np.linspace(*tilt_range, sino.shape[0])
        rng = ip.random.default_rng(seed)
        imax = sino.max()
        sino_noise = sino + rng.normal(
            scale=imax * nsr, size=sino.shape, axes=sino.axes
        )
        yield thread_worker.callback()
        rec = sino_noise.iradon(
            degrees,
            central_axis="y",
            height=roundint(height / scale),
            order=interpolation,
        ).set_scale(zyx=scale, unit="nm")
        yield _on_iradon_finished.with_args(rec.mean("z"), f"N/S = {nsr:.1f}")
        rec.name = SIMULATED_IMAGE_NAME
        tomo = CylTomogram.from_image(
            rec, scale=scale, tilt=tilt_range, binsize=bin_size
        )
        main._init_macro_state()
        return main._send_tomogram_to_viewer.with_args(tomo)

    @set_design(text=capitalize, location=SimulateMenu)
    @dask_thread_worker.with_progress(desc="Simulating tomogram...")
    @confirm(
        text="You have an opened image. Run anyway?",
        condition="not self._get_main().tomogram.is_dummy",
    )
    def simulate_tomogram_and_open(
        self,
        components: Annotated[Any, {"bind": _get_components}],
        nsr: _NSRatio = 1.5,
        bin_size: Annotated[list[int], {"options": {"min": 1, "max": 32}}] = [1],
        tilt_range: _TiltRange = (-60.0, 60.0),
        n_tilt: Annotated[int, {"label": "Number of tilts"}] = 21,
        interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
        seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
    ):  # fmt: skip
        """
        Simulate a tomogram and open the image immediately.

        This function projects the template image to each tilt series, adding
        Gaussian noise, and back-projects the noisy tilt series to the tomogram.

        Parameters
        ----------
        components : list of (str, Path)
            List of tuples of layer name and path to the template image.
        nsr : list of float
            Noise-to-signal ratio. It is defined by N/S, where S is the maximum
            value of the true monomer density and N is the standard deviation of
            the Gaussian noise.
        bin_size : list of int
            Bin sizes used to create multi-scaled images from the simulated image.
        tilt_range : tuple of float
            Minimum and maximum tilt angles in degree.
        n_tilt : int
            Number of tilt angles between minimum and maximum angles.
        interpolation : int
            Interpolation method used during the simulation.
        seed : int, optional
            Random seed used for the Gaussian noise.
        """
        nsr = round(float(nsr), 4)
        _assert_not_empty(components)
        main = self._get_main()
        degrees = np.linspace(*tilt_range, n_tilt)
        mole_layers = [main.mole_layers[layer_name] for layer_name, _ in components]
        sources = [layer.source_spline for layer in mole_layers]
        sino = self._prep_radon(components, degrees, order=interpolation)

        yield _on_radon_finished.with_args(sino, degrees)

        rng = ip.random.default_rng(seed)
        imax = sino.max()
        sino_noise = sino + rng.normal(
            scale=imax * nsr, size=sino.shape, axes=sino.axes
        )
        rec = sino_noise.iradon(
            degrees,
            central_axis="y",
            height=main.tomogram.image.shape[0],
            order=interpolation,
        ).set_scale(zyx=sino.scale.x, unit="nm")
        yield _on_iradon_finished.with_args(rec.mean("z"), f"N/S = {nsr:.1f}")

        rec.name = SIMULATED_IMAGE_NAME
        tomo = CylTomogram.from_image(
            rec, scale=sino.scale.x, tilt=tilt_range, binsize=bin_size
        )
        tomo.splines.extend(sources)
        yield main._send_tomogram_to_viewer.with_args(tomo)

        @thread_worker.callback
        def _on_return():
            for layer, source_spline in zip(mole_layers, sources, strict=True):
                main.parent_viewer.add_layer(layer)
                if source_spline is not None:
                    layer.source_component = source_spline
            if len(main.splines) > 0:
                main._update_splines_in_images()

        return _on_return

    @set_design(text=capitalize, location=SimulateMenu)
    @dask_thread_worker.with_progress(desc="Simulating tilt series...")
    def simulate_tilt_series(
        self,
        components: Annotated[Any, {"bind": _get_components}],
        save_dir: Annotated[Path.Save, {"label": "Save at"}],
        tilt_range: _TiltRange = (-60.0, 60.0),
        n_tilt: Annotated[int, {"label": "Number of tilts"}] = 21,
        interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
    ):  # fmt: skip
        """
        Simulate tilt series using the current model and save the images.

        Parameters
        ----------
        components : list of (str, Path)
            List of tuples of layer name and path to the template image.
        save_dir : Path
            Directory path where the tilt series will be saved.
        tilt_range : tuple of float
            Minimum and maximum tilt angles in degree.
        n_tilt : int
            Number of tilt angles between minimum and maximum angles.
        interpolation : int
            Interpolation method used during the simulation.
        """
        save_dir = _norm_save_dir(save_dir)
        _assert_not_empty(components)
        degrees = np.linspace(*tilt_range, n_tilt)
        sino = self._prep_radon(components, degrees, order=interpolation)
        scale = sino.scale.x
        save_path = save_dir / "image.mrc"
        sino.set_axes("zyx").set_scale(zyx=scale, unit="nm").imsave(save_path)
        _Logger.print(f"Tilt series saved at {save_path}.")
        self._get_main().save_project(save_dir / PROJECT_NAME, molecules_ext=".parquet")
        return None

    @set_design(text=capitalize, location=SimulateMenu)
    @dask_thread_worker.with_progress(desc="Simulating 2D projections...")
    def simulate_projection(
        self,
        components: Annotated[Any, {"bind": _get_components}],
        save_dir: Annotated[Path.Save, {"label": "Save at"}],
        nsr: _NSRatios = [1.5],
        interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
        seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
    ):  # fmt: skip
        """
        Simulate a projection without tilt (cryo-EM-like image).

        Parameters
        ----------
        components : list of (str, Path)
            List of tuples of layer name and path to the template image.
        save_dir : Path
            Path to the directory where the images will be saved.
        nsr : list of float
            Noise-to-signal ratio. It is defined by N/S, where S is the maximum
            value of the true monomer density and N is the standard deviation of
            the Gaussian noise. Duplicate values are allowed, which is useful
            for simulation of multiple images with the same noise level.
        interpolation : int
            Interpolation method used during the simulation.
        seed : int, optional
            Random seed used for the Gaussian noise.
        """
        save_dir = _norm_save_dir(save_dir)
        _assert_not_empty(components)
        proj = self._prep_radon(components, np.zeros(1), order=interpolation)[0]
        proj = proj.set_axes("yx").set_scale(yx=proj.scale.x, unit="nm")
        yield _on_iradon_finished.with_args(proj, "Projection (noise-free)")
        rng = ip.random.default_rng(seed)
        imax = proj.max()
        for i, nsr_val in enumerate(nsr):
            proj_noise = proj + rng.normal(
                scale=imax * nsr_val, size=proj.shape, axes=proj.axes
            )
            proj_noise.imsave(save_dir / f"image-{i}.tif")
        _Logger.print(f"Projections saved at {save_dir}.")
        self._get_main().save_project(save_dir / PROJECT_NAME, molecules_ext=".parquet")
        return None

    @set_design(icon="iconoir:expand-lines", location=SimulatorTools)
    def expand(
        self,
        layer: _ModeledMoleculesLayer,
        by: Annotated[float, {"min": -100, "max": 100}] = 0.0,
        yrange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
        arange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
        allev: bool = False,
    ):
        """
        Apply local expansion to molecules.

        Parameters
        ----------
        layer : MoleculesLayer
            Layer to be transformed.
        by : float
            Amount of expansion in nm.
        yrange : tuple of int
            Range of Y axis to be transformed. Range is [a, b).
        arange : tuple of int
            Range of angle axis to be transformed. Range is [a, b).
        allev : bool
            Alleviation of the local expansion. If true, the surrounding molecules
            will be shifted to alleviate the local expansion.
        """
        layer = assert_layer(layer, self.parent_viewer)
        spl, model = _local_transform(
            CylinderModel.expand, layer, by, yrange, arange, allev
        )
        layer.molecules = model.to_molecules(spl, layer.molecules.features)
        _set_simulation_model(layer, model)
        return None

    @set_design(icon="mingcute:rotate-x-line", location=SimulatorTools)
    def twist(
        self,
        layer: _ModeledMoleculesLayer,
        by: Annotated[float, {"min": -100, "max": 100}] = 0.0,
        yrange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
        arange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
        allev: bool = False,
    ):
        """
        Apply local twist to molecules.

        Parameters
        ----------
        layer : MoleculesLayer
            Layer to be transformed.
        by : float
            Amount of twist in degree.
        yrange : tuple of int
            Range of Y axis to be transformed. Range is [a, b).
        arange : tuple of int
            Range of angle axis to be transformed. Range is [a, b).
        allev : bool
            Alleviation of the local expansion. If true, the surrounding molecules
            will be shifted to alleviate the local expansion.
        """
        layer = assert_layer(layer, self.parent_viewer)
        spl, model = _local_transform(
            CylinderModel.twist, layer, np.deg2rad(by), yrange, arange, allev
        )
        layer.molecules = model.to_molecules(spl, layer.molecules.features)
        _set_simulation_model(layer, model)
        return None

    @set_design(icon="iconoir:scale-frame-enlarge", location=SimulatorTools)
    def dilate(
        self,
        layer: _ModeledMoleculesLayer,
        by: Annotated[float, {"min": -100, "max": 100}] = 0.0,
        yrange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
        arange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
        allev: bool = False,
    ):
        """
        Apply local dilation to molecules.

        Parameters
        ----------
        layer : MoleculesLayer
            Layer to be transformed.
        by : float
            Amount of dilation in nm.
        yrange : tuple of int
            Range of Y axis to be transformed. Range is [a, b).
        arange : tuple of int
            Range of angle axis to be transformed. Range is [a, b).
        allev : bool
            Alleviation of the local expansion. If true, the surrounding molecules
            will be shifted to alleviate the local expansion.
        """
        layer = assert_layer(layer, self.parent_viewer)
        spl, model = _local_transform(
            CylinderModel.dilate, layer, by, yrange, arange, allev
        )
        layer.molecules = model.to_molecules(spl, layer.molecules.features)
        _set_simulation_model(layer, model)
        return None

    @set_design(icon="fluent:arrow-move-20-filled", location=SimulatorTools)
    def displace(
        self,
        layer: _ModeledMoleculesLayer,
        expand: ExprStr.In[POLARS_NAMESPACE] = 0.0,
        twist: ExprStr.In[POLARS_NAMESPACE] = 0.0,
        dilate: ExprStr.In[POLARS_NAMESPACE] = 0.0,
    ):
        """
        Detailed local transformation of molecules.

        In this method, you'll have to specify the displacement for each molecule
        using polars expressions. For example, if you want to expand the molecules
        with odd numbering by 0.1 nm, you can set `expand` to
        >>> pl.when(pl.col("nth") % 2 == 0).then(0).otherwise(0.1)

        Parameters
        ----------
        layer : ModelLayer
            Layer to be transformed.
        expand : str, pl.Expr or constant
            Displacement in the longitudinal direction (nm).
        twist : str, pl.Expr or constant
            Displacement in the angular direction (degree).
        dilate : str, pl.Expr or constant
            Displacement from the center (nm).
        """
        layer = assert_layer(layer, self.parent_viewer)
        new_model = _get_shifted_model(layer, expand, twist, dilate)
        layer.molecules = new_model.to_molecules(
            layer.source_spline, layer.molecules.features
        )
        return _set_simulation_model(layer, new_model)

add_component(layer, template_path)

Add a set of template and a molecules as a simulation component.

A component defines which molecules corresponds to what template image. Multiple components can be added to simulate a tomogram with different materials.

Parameters:

Name Type Description Default
layer MoleculesLayer

Layer to be used for simulation.

required
template_path Path

Path to the template image that will be used to simulate the corresponding molecules layer.

required
Source code in cylindra/widgets/subwidgets/simulator.py
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
@set_design(icon="fluent:cloud-add-16-filled", location=SimulatorTools)
@do_not_record
def add_component(
    self,
    layer: MoleculesLayerType,
    template_path: Path.Read[FileFilter.IMAGE],
):
    """
    Add a set of template and a molecules as a simulation component.

    A component defines which molecules corresponds to what template image.
    Multiple components can be added to simulate a tomogram with different
    materials.

    Parameters
    ----------
    layer : MoleculesLayer
        Layer to be used for simulation.
    template_path : Path
        Path to the template image that will be used to simulate the
        corresponding molecules layer.
    """
    layer = assert_layer(layer, self.parent_viewer)
    self.component_list.append(Component(template_path, layer))
    self.component_list._on_children_change()

create_empty_image(size=(60.0, 200.0, 60.0), scale=0.25)

Create an empty image with the given size and scale, and send it to the viewer.

Parameters:

Name Type Description Default
size (nm, nm, nm)

Size of the image in nm, of (Z, Y, X).

(60., 200., 60.)
scale nm

Pixel size of the image.

0.25
Source code in cylindra/widgets/subwidgets/simulator.py
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
@set_design(text=capitalize, location=CreateMenu)
@thread_worker.with_progress(desc="Creating an image")
@confirm(
    text="You have an opened image. Run anyway?",
    condition="not self._get_main().tomogram.is_dummy",
)
def create_empty_image(
    self,
    size: _ImageSize = (60.0, 200.0, 60.0),
    scale: Annotated[nm, {"label": "pixel scale (nm/pixel)"}] = 0.25,
):  # fmt: skip
    """
    Create an empty image with the given size and scale, and send it to the viewer.

    Parameters
    ----------
    size : (nm, nm, nm), default (60., 200., 60.)
        Size of the image in nm, of (Z, Y, X).
    scale : nm, default 0.25
        Pixel size of the image.
    """
    main = self._get_main()
    shape = tuple(roundint(s / scale) for s in size)

    binsize = ceilint(0.96 / scale)
    # NOTE: zero-filled image breaks contrast limit calculation, and bad for
    # visual detection of the image edges.
    tomo = CylTomogram.dummy(scale=scale, binsize=binsize, shape=shape)
    main._init_macro_state()

    @thread_worker.callback
    def _out():
        main._send_tomogram_to_viewer(tomo)
        main._reserved_layers.image.bounding_box.visible = True

    return _out

create_image_with_straight_line(length=150.0, size=(60.0, 200.0, 60.0), scale=0.25, yxrotation=0.0, zxrotation=0.0)

Create a straight line as a cylinder spline.

Parameters:

Name Type Description Default
length nm

Length if the straight line in nm.

150.0
size ((nm, nm, nm), (60.0, 200.0, 60.0))

Size of the tomogram in which the spline will reside.

(60.0, 200.0, 60.0)
scale nm

Scale of pixel in nm/pixel.

0.25
yxrotation float

Rotation in YX plane. This rotation will be applied before ZX rotation.

0.0
zxrotation float

Rotation in ZX plane. This rotation will be applied before YX rotation.

0.0
Source code in cylindra/widgets/subwidgets/simulator.py
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
@set_design(text=capitalize, location=CreateMenu)
@thread_worker.with_progress(desc="Creating an image")
@confirm(
    text="You have an opened image. Run anyway?",
    condition="not self._get_main().tomogram.is_dummy",
)
def create_image_with_straight_line(
    self,
    length: nm = 150.0,
    size: _ImageSize = (60.0, 200.0, 60.0),
    scale: Annotated[nm, {"label": "pixel scale (nm/pixel)"}] = 0.25,
    yxrotation: Annotated[float, {"max": 90, "step": 1, "label": "Rotation in YX plane (deg)"}] = 0.0,
    zxrotation: Annotated[float, {"max": 90, "step": 1, "label": "Rotation in ZX plane (deg)"}] = 0.0,
):  # fmt: skip
    """
    Create a straight line as a cylinder spline.

    Parameters
    ----------
    length : nm, default 150.0
        Length if the straight line in nm.
    size : (nm, nm, nm), (60.0, 200.0, 60.0)
        Size of the tomogram in which the spline will reside.
    scale : nm, default 0.25
        Scale of pixel in nm/pixel.
    yxrotation : float, optional
        Rotation in YX plane. This rotation will be applied before ZX rotation.
    zxrotation : float, optional
        Rotation in ZX plane. This rotation will be applied before YX rotation.
    """
    yxrot = Rotation.from_rotvec([np.deg2rad(yxrotation), 0.0, 0.0])
    zxrot = Rotation.from_rotvec([0.0, 0.0, np.deg2rad(zxrotation)])
    start_shift = zxrot.apply(yxrot.apply(np.array([0.0, -length / 2, 0.0])))
    end_shift = zxrot.apply(yxrot.apply(np.array([0.0, length / 2, 0.0])))
    center = np.array(size) / 2
    yield from self.create_empty_image.arun(size=size, scale=scale)
    cb = thread_worker.callback(self.create_straight_line)
    yield cb.with_args(start_shift + center, end_shift + center)

create_straight_line(start, end)

Create a straight line as a spline.

Parameters:

Name Type Description Default
start (nm, nm, nm)

Start point of the line.

required
end (nm, nm, nm)

End point of the line.

required
Source code in cylindra/widgets/subwidgets/simulator.py
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
@set_design(text=capitalize, location=CreateMenu)
def create_straight_line(self, start: _Point3D, end: _Point3D):
    """
    Create a straight line as a spline.

    Parameters
    ----------
    start : (nm, nm, nm)
        Start point of the line.
    end : (nm, nm, nm)
        End point of the line.
    """
    spl = CylSpline.line(start, end)
    main = self._get_main()
    main.tomogram.splines.append(spl)
    main._add_spline_instance(spl)
    return undo_callback(main.delete_spline).with_args(-1)

dilate(layer, by=0.0, yrange=(0, 1), arange=(0, 1), allev=False)

Apply local dilation to molecules.

Parameters:

Name Type Description Default
layer MoleculesLayer

Layer to be transformed.

required
by float

Amount of dilation in nm.

0.0
yrange tuple of int

Range of Y axis to be transformed. Range is [a, b).

(0, 1)
arange tuple of int

Range of angle axis to be transformed. Range is [a, b).

(0, 1)
allev bool

Alleviation of the local expansion. If true, the surrounding molecules will be shifted to alleviate the local expansion.

False
Source code in cylindra/widgets/subwidgets/simulator.py
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
@set_design(icon="iconoir:scale-frame-enlarge", location=SimulatorTools)
def dilate(
    self,
    layer: _ModeledMoleculesLayer,
    by: Annotated[float, {"min": -100, "max": 100}] = 0.0,
    yrange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
    arange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
    allev: bool = False,
):
    """
    Apply local dilation to molecules.

    Parameters
    ----------
    layer : MoleculesLayer
        Layer to be transformed.
    by : float
        Amount of dilation in nm.
    yrange : tuple of int
        Range of Y axis to be transformed. Range is [a, b).
    arange : tuple of int
        Range of angle axis to be transformed. Range is [a, b).
    allev : bool
        Alleviation of the local expansion. If true, the surrounding molecules
        will be shifted to alleviate the local expansion.
    """
    layer = assert_layer(layer, self.parent_viewer)
    spl, model = _local_transform(
        CylinderModel.dilate, layer, by, yrange, arange, allev
    )
    layer.molecules = model.to_molecules(spl, layer.molecules.features)
    _set_simulation_model(layer, model)
    return None

displace(layer, expand=0.0, twist=0.0, dilate=0.0)

Detailed local transformation of molecules.

In this method, you'll have to specify the displacement for each molecule using polars expressions. For example, if you want to expand the molecules with odd numbering by 0.1 nm, you can set expand to

pl.when(pl.col("nth") % 2 == 0).then(0).otherwise(0.1)

Parameters:

Name Type Description Default
layer ModelLayer

Layer to be transformed.

required
expand (str, Expr or constant)

Displacement in the longitudinal direction (nm).

0.0
twist (str, Expr or constant)

Displacement in the angular direction (degree).

0.0
dilate (str, Expr or constant)

Displacement from the center (nm).

0.0
Source code in cylindra/widgets/subwidgets/simulator.py
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
@set_design(icon="fluent:arrow-move-20-filled", location=SimulatorTools)
def displace(
    self,
    layer: _ModeledMoleculesLayer,
    expand: ExprStr.In[POLARS_NAMESPACE] = 0.0,
    twist: ExprStr.In[POLARS_NAMESPACE] = 0.0,
    dilate: ExprStr.In[POLARS_NAMESPACE] = 0.0,
):
    """
    Detailed local transformation of molecules.

    In this method, you'll have to specify the displacement for each molecule
    using polars expressions. For example, if you want to expand the molecules
    with odd numbering by 0.1 nm, you can set `expand` to
    >>> pl.when(pl.col("nth") % 2 == 0).then(0).otherwise(0.1)

    Parameters
    ----------
    layer : ModelLayer
        Layer to be transformed.
    expand : str, pl.Expr or constant
        Displacement in the longitudinal direction (nm).
    twist : str, pl.Expr or constant
        Displacement in the angular direction (degree).
    dilate : str, pl.Expr or constant
        Displacement from the center (nm).
    """
    layer = assert_layer(layer, self.parent_viewer)
    new_model = _get_shifted_model(layer, expand, twist, dilate)
    layer.molecules = new_model.to_molecules(
        layer.source_spline, layer.molecules.features
    )
    return _set_simulation_model(layer, new_model)

expand(layer, by=0.0, yrange=(0, 1), arange=(0, 1), allev=False)

Apply local expansion to molecules.

Parameters:

Name Type Description Default
layer MoleculesLayer

Layer to be transformed.

required
by float

Amount of expansion in nm.

0.0
yrange tuple of int

Range of Y axis to be transformed. Range is [a, b).

(0, 1)
arange tuple of int

Range of angle axis to be transformed. Range is [a, b).

(0, 1)
allev bool

Alleviation of the local expansion. If true, the surrounding molecules will be shifted to alleviate the local expansion.

False
Source code in cylindra/widgets/subwidgets/simulator.py
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
@set_design(icon="iconoir:expand-lines", location=SimulatorTools)
def expand(
    self,
    layer: _ModeledMoleculesLayer,
    by: Annotated[float, {"min": -100, "max": 100}] = 0.0,
    yrange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
    arange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
    allev: bool = False,
):
    """
    Apply local expansion to molecules.

    Parameters
    ----------
    layer : MoleculesLayer
        Layer to be transformed.
    by : float
        Amount of expansion in nm.
    yrange : tuple of int
        Range of Y axis to be transformed. Range is [a, b).
    arange : tuple of int
        Range of angle axis to be transformed. Range is [a, b).
    allev : bool
        Alleviation of the local expansion. If true, the surrounding molecules
        will be shifted to alleviate the local expansion.
    """
    layer = assert_layer(layer, self.parent_viewer)
    spl, model = _local_transform(
        CylinderModel.expand, layer, by, yrange, arange, allev
    )
    layer.molecules = model.to_molecules(spl, layer.molecules.features)
    _set_simulation_model(layer, model)
    return None

generate_molecules(spline=0, spacing=1.0, twist=0.0, start=0, npf=2, radius=10.0, offsets=(0.0, 0.0), update_glob=True)

Update cylinder model with new parameters.

Local structural displacement will be deleted because this function may change the number of molecules. This function should be called first.

Parameters:

Name Type Description Default
spacing nm

Axial spacing between molecules.

1.0
twist float

Monomer twist of the cylinder.

0.0
start int

The start number.

0
npf int

Number of protofilaments.

2
radius nm

Radius of the cylinder.

10.0
Source code in cylindra/widgets/subwidgets/simulator.py
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
@set_design(icon="fluent:select-object-skew-20-regular", location=SimulatorTools)
def generate_molecules(
    self,
    spline: Annotated[int, {"bind": _get_spline_idx}] = 0,
    spacing: Annotated[nm, {"min": 0.2, "max": 100.0, "step": 0.01, "label": "spacing (nm)"}] = 1.0,
    twist: Annotated[float, {"min": -45.0, "max": 45.0, "label": "twist (deg)"}] = 0.0,
    start: Annotated[int, {"min": -50, "max": 50, "label": "start"}] = 0,
    npf: Annotated[int, {"min": 1, "label": "number of PF"}] = 2,
    radius: Annotated[nm, {"min": 0.5, "max": 50.0, "step": 0.5, "label": "radius (nm)"}] = 10.0,
    offsets: tuple[float, float] = (0.0, 0.0),
    update_glob: Annotated[bool, {"label": "update spline global properties"}] = True,
):  # fmt: skip
    """
    Update cylinder model with new parameters.

    Local structural displacement will be deleted because this function may change
    the number of molecules. This function should be called first.

    Parameters
    ----------
    spacing : nm
        Axial spacing between molecules.
    twist : float
        Monomer twist of the cylinder.
    start : int
        The start number.
    npf : int
        Number of protofilaments.
    radius : nm
        Radius of the cylinder.
    """
    # NOTE: these parameters are hard-coded for microtubule for now.
    main = self._get_main()
    spl = main.splines[spline]
    model = self._prep_model(spl, spacing, twist, start, npf, radius, offsets)
    mole = model.to_molecules(spl)
    name = _make_simulated_mole_name(main.parent_viewer)
    layer = main.add_molecules(mole, name=name, source=spl)
    _set_simulation_model(layer, model)
    old_props = spl.props.glob
    if update_glob:
        cparams = spl.copy(copy_props=False).cylinder_params(
            spacing=spacing, twist=twist, start=start, npf=npf, radius=radius
        )
        spl.update_glob_by_cylinder_params(cparams)

    @undo_callback
    def _out():
        main._undo_callback_for_layer(layer).run()
        spl.props.glob = old_props

    return _out

simulate_projection(components, save_dir, nsr=[1.5], interpolation=3, seed=None)

Simulate a projection without tilt (cryo-EM-like image).

Parameters:

Name Type Description Default
components list of (str, Path)

List of tuples of layer name and path to the template image.

required
save_dir Path

Path to the directory where the images will be saved.

required
nsr list of float

Noise-to-signal ratio. It is defined by N/S, where S is the maximum value of the true monomer density and N is the standard deviation of the Gaussian noise. Duplicate values are allowed, which is useful for simulation of multiple images with the same noise level.

[1.5]
interpolation int

Interpolation method used during the simulation.

3
seed int

Random seed used for the Gaussian noise.

None
Source code in cylindra/widgets/subwidgets/simulator.py
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
@set_design(text=capitalize, location=SimulateMenu)
@dask_thread_worker.with_progress(desc="Simulating 2D projections...")
def simulate_projection(
    self,
    components: Annotated[Any, {"bind": _get_components}],
    save_dir: Annotated[Path.Save, {"label": "Save at"}],
    nsr: _NSRatios = [1.5],
    interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
    seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
):  # fmt: skip
    """
    Simulate a projection without tilt (cryo-EM-like image).

    Parameters
    ----------
    components : list of (str, Path)
        List of tuples of layer name and path to the template image.
    save_dir : Path
        Path to the directory where the images will be saved.
    nsr : list of float
        Noise-to-signal ratio. It is defined by N/S, where S is the maximum
        value of the true monomer density and N is the standard deviation of
        the Gaussian noise. Duplicate values are allowed, which is useful
        for simulation of multiple images with the same noise level.
    interpolation : int
        Interpolation method used during the simulation.
    seed : int, optional
        Random seed used for the Gaussian noise.
    """
    save_dir = _norm_save_dir(save_dir)
    _assert_not_empty(components)
    proj = self._prep_radon(components, np.zeros(1), order=interpolation)[0]
    proj = proj.set_axes("yx").set_scale(yx=proj.scale.x, unit="nm")
    yield _on_iradon_finished.with_args(proj, "Projection (noise-free)")
    rng = ip.random.default_rng(seed)
    imax = proj.max()
    for i, nsr_val in enumerate(nsr):
        proj_noise = proj + rng.normal(
            scale=imax * nsr_val, size=proj.shape, axes=proj.axes
        )
        proj_noise.imsave(save_dir / f"image-{i}.tif")
    _Logger.print(f"Projections saved at {save_dir}.")
    self._get_main().save_project(save_dir / PROJECT_NAME, molecules_ext=".parquet")
    return None

simulate_tilt_series(components, save_dir, tilt_range=(-60.0, 60.0), n_tilt=21, interpolation=3)

Simulate tilt series using the current model and save the images.

Parameters:

Name Type Description Default
components list of (str, Path)

List of tuples of layer name and path to the template image.

required
save_dir Path

Directory path where the tilt series will be saved.

required
tilt_range tuple of float

Minimum and maximum tilt angles in degree.

(-60.0, 60.0)
n_tilt int

Number of tilt angles between minimum and maximum angles.

21
interpolation int

Interpolation method used during the simulation.

3
Source code in cylindra/widgets/subwidgets/simulator.py
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
@set_design(text=capitalize, location=SimulateMenu)
@dask_thread_worker.with_progress(desc="Simulating tilt series...")
def simulate_tilt_series(
    self,
    components: Annotated[Any, {"bind": _get_components}],
    save_dir: Annotated[Path.Save, {"label": "Save at"}],
    tilt_range: _TiltRange = (-60.0, 60.0),
    n_tilt: Annotated[int, {"label": "Number of tilts"}] = 21,
    interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
):  # fmt: skip
    """
    Simulate tilt series using the current model and save the images.

    Parameters
    ----------
    components : list of (str, Path)
        List of tuples of layer name and path to the template image.
    save_dir : Path
        Directory path where the tilt series will be saved.
    tilt_range : tuple of float
        Minimum and maximum tilt angles in degree.
    n_tilt : int
        Number of tilt angles between minimum and maximum angles.
    interpolation : int
        Interpolation method used during the simulation.
    """
    save_dir = _norm_save_dir(save_dir)
    _assert_not_empty(components)
    degrees = np.linspace(*tilt_range, n_tilt)
    sino = self._prep_radon(components, degrees, order=interpolation)
    scale = sino.scale.x
    save_path = save_dir / "image.mrc"
    sino.set_axes("zyx").set_scale(zyx=scale, unit="nm").imsave(save_path)
    _Logger.print(f"Tilt series saved at {save_path}.")
    self._get_main().save_project(save_dir / PROJECT_NAME, molecules_ext=".parquet")
    return None

simulate_tomogram(components, save_dir, nsr=[1.5], tilt_range=(-60.0, 60.0), n_tilt=21, interpolation=3, seed=None)

Simulate tomographic images using the current model and save the images.

This function projects the template image to each tilt series, adding Gaussian noise, and back-projects the noisy tilt series to the tomogram.

Parameters:

Name Type Description Default
components list of (str, Path)

List of tuples of layer name and path to the template image.

required
save_dir Path

Path to the directory where the images will be saved.

required
nsr list of float

Noise-to-signal ratio. It is defined by N/S, where S is the maximum value of the true monomer density and N is the standard deviation of the Gaussian noise. Duplicate values are allowed, which is useful for simulation of multiple images with the same noise level.

[1.5]
tilt_range tuple of float

Minimum and maximum tilt angles in degree.

(-60.0, 60.0)
n_tilt int

Number of tilt angles between minimum and maximum angles.

21
interpolation int

Interpolation method used during the simulation.

3
seed int

Random seed used for the Gaussian noise.

None
Source code in cylindra/widgets/subwidgets/simulator.py
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
@set_design(text=capitalize, location=SimulateMenu)
@dask_thread_worker.with_progress(descs=_simulate_tomogram_iter)
def simulate_tomogram(
    self,
    components: Annotated[Any, {"bind": _get_components}],
    save_dir: Annotated[Path.Save, {"label": "Save at"}],
    nsr: _NSRatios = [1.5],
    tilt_range: _TiltRange = (-60.0, 60.0),
    n_tilt: Annotated[int, {"label": "Number of tilts"}] = 21,
    interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
    seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
):  # fmt: skip
    """
    Simulate tomographic images using the current model and save the images.

    This function projects the template image to each tilt series, adding
    Gaussian noise, and back-projects the noisy tilt series to the tomogram.

    Parameters
    ----------
    components : list of (str, Path)
        List of tuples of layer name and path to the template image.
    save_dir : Path
        Path to the directory where the images will be saved.
    nsr : list of float
        Noise-to-signal ratio. It is defined by N/S, where S is the maximum
        value of the true monomer density and N is the standard deviation of
        the Gaussian noise. Duplicate values are allowed, which is useful
        for simulation of multiple images with the same noise level.
    tilt_range : tuple of float
        Minimum and maximum tilt angles in degree.
    n_tilt : int
        Number of tilt angles between minimum and maximum angles.
    interpolation : int
        Interpolation method used during the simulation.
    seed : int, optional
        Random seed used for the Gaussian noise.
    """
    save_dir = _norm_save_dir(save_dir)
    _assert_not_empty(components)
    nsr = [round(float(_nsr), 4) for _nsr in nsr]
    main = self._get_main()
    degrees = np.linspace(*tilt_range, n_tilt)
    sino = self._prep_radon(components, degrees, order=interpolation)

    yield _on_radon_finished.with_args(sino, degrees)

    rng = ip.random.default_rng(seed)
    imax = sino.max()
    for i, nsr_val in enumerate(nsr):
        sino_noise = sino + rng.normal(
            scale=imax * nsr_val, size=sino.shape, axes=sino.axes
        )
        rec = sino_noise.iradon(
            degrees,
            central_axis="y",
            height=main.tomogram.image.shape[0],
            order=interpolation,
        ).set_scale(zyx=main.tomogram.scale, unit="nm")
        yield _on_iradon_finished.with_args(rec.mean("z"), f"N/S = {nsr_val:.1f}")

        file_name = save_dir / f"image-{i}.mrc"
        rec.imsave(file_name)
        _Logger.print(f"Image saved at {file_name}.")

    main.save_project(save_dir / PROJECT_NAME, molecules_ext=".parquet")
    return None

simulate_tomogram_and_open(components, nsr=1.5, bin_size=[1], tilt_range=(-60.0, 60.0), n_tilt=21, interpolation=3, seed=None)

Simulate a tomogram and open the image immediately.

This function projects the template image to each tilt series, adding Gaussian noise, and back-projects the noisy tilt series to the tomogram.

Parameters:

Name Type Description Default
components list of (str, Path)

List of tuples of layer name and path to the template image.

required
nsr list of float

Noise-to-signal ratio. It is defined by N/S, where S is the maximum value of the true monomer density and N is the standard deviation of the Gaussian noise.

1.5
bin_size list of int

Bin sizes used to create multi-scaled images from the simulated image.

[1]
tilt_range tuple of float

Minimum and maximum tilt angles in degree.

(-60.0, 60.0)
n_tilt int

Number of tilt angles between minimum and maximum angles.

21
interpolation int

Interpolation method used during the simulation.

3
seed int

Random seed used for the Gaussian noise.

None
Source code in cylindra/widgets/subwidgets/simulator.py
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
@set_design(text=capitalize, location=SimulateMenu)
@dask_thread_worker.with_progress(desc="Simulating tomogram...")
@confirm(
    text="You have an opened image. Run anyway?",
    condition="not self._get_main().tomogram.is_dummy",
)
def simulate_tomogram_and_open(
    self,
    components: Annotated[Any, {"bind": _get_components}],
    nsr: _NSRatio = 1.5,
    bin_size: Annotated[list[int], {"options": {"min": 1, "max": 32}}] = [1],
    tilt_range: _TiltRange = (-60.0, 60.0),
    n_tilt: Annotated[int, {"label": "Number of tilts"}] = 21,
    interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
    seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
):  # fmt: skip
    """
    Simulate a tomogram and open the image immediately.

    This function projects the template image to each tilt series, adding
    Gaussian noise, and back-projects the noisy tilt series to the tomogram.

    Parameters
    ----------
    components : list of (str, Path)
        List of tuples of layer name and path to the template image.
    nsr : list of float
        Noise-to-signal ratio. It is defined by N/S, where S is the maximum
        value of the true monomer density and N is the standard deviation of
        the Gaussian noise.
    bin_size : list of int
        Bin sizes used to create multi-scaled images from the simulated image.
    tilt_range : tuple of float
        Minimum and maximum tilt angles in degree.
    n_tilt : int
        Number of tilt angles between minimum and maximum angles.
    interpolation : int
        Interpolation method used during the simulation.
    seed : int, optional
        Random seed used for the Gaussian noise.
    """
    nsr = round(float(nsr), 4)
    _assert_not_empty(components)
    main = self._get_main()
    degrees = np.linspace(*tilt_range, n_tilt)
    mole_layers = [main.mole_layers[layer_name] for layer_name, _ in components]
    sources = [layer.source_spline for layer in mole_layers]
    sino = self._prep_radon(components, degrees, order=interpolation)

    yield _on_radon_finished.with_args(sino, degrees)

    rng = ip.random.default_rng(seed)
    imax = sino.max()
    sino_noise = sino + rng.normal(
        scale=imax * nsr, size=sino.shape, axes=sino.axes
    )
    rec = sino_noise.iradon(
        degrees,
        central_axis="y",
        height=main.tomogram.image.shape[0],
        order=interpolation,
    ).set_scale(zyx=sino.scale.x, unit="nm")
    yield _on_iradon_finished.with_args(rec.mean("z"), f"N/S = {nsr:.1f}")

    rec.name = SIMULATED_IMAGE_NAME
    tomo = CylTomogram.from_image(
        rec, scale=sino.scale.x, tilt=tilt_range, binsize=bin_size
    )
    tomo.splines.extend(sources)
    yield main._send_tomogram_to_viewer.with_args(tomo)

    @thread_worker.callback
    def _on_return():
        for layer, source_spline in zip(mole_layers, sources, strict=True):
            main.parent_viewer.add_layer(layer)
            if source_spline is not None:
                layer.source_component = source_spline
        if len(main.splines) > 0:
            main._update_splines_in_images()

    return _on_return

simulate_tomogram_from_tilt_series(path, nsr=1.5, bin_size=[1], tilt_range=(-60.0, 60.0), height=50, interpolation=3, seed=None)

Simulate tomographic images using a tilt series.

Parameters:

Name Type Description Default
path Path

Path to the tilt series image.

required
nsr float

Noise-to-signal ratio. It is defined by N/S, where S is the maximum value of the tilt series. If the input image is already noisy, you can set this value to zero to avoid adding more noises.

1.5
bin_size list of int

Bin sizes used to create multi-scaled images from the simulated image.

[1]
tilt_range tuple of float

Minimum and maximum tilt angles in degree.

(-60.0, 60.0)
height int

Height of the simulated tomogram in nm.

50
interpolation int

Interpolation method used during the simulation.

3
seed int

Random seed used for the Gaussian noise.

None
Source code in cylindra/widgets/subwidgets/simulator.py
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
@set_design(text=capitalize, location=SimulateMenu)
@dask_thread_worker.with_progress(descs=_simulate_tomogram_from_tilt_iter)
@confirm(
    text="You have an opened image. Run anyway?",
    condition="not self._get_main().tomogram.is_dummy",
)
def simulate_tomogram_from_tilt_series(
    self,
    path: Path.Read[FileFilter.IMAGE],
    nsr: _NSRatio = 1.5,
    bin_size: Annotated[list[int], {"options": {"min": 1, "max": 32}}] = [1],
    tilt_range: _TiltRange = (-60.0, 60.0),
    height: Annotated[nm, {"label": "height (nm)"}] = 50,
    interpolation: Annotated[int, {"choices": INTERPOLATION_CHOICES}] = 3,
    seed: Optional[Annotated[int, {"min": 0, "max": 1e8}]] = None,
):
    """
    Simulate tomographic images using a tilt series.

    Parameters
    ----------
    path : Path
        Path to the tilt series image.
    nsr : float
        Noise-to-signal ratio. It is defined by N/S, where S is the maximum
        value of the tilt series. If the input image is already noisy, you
        can set this value to zero to avoid adding more noises.
    bin_size : list of int
        Bin sizes used to create multi-scaled images from the simulated image.
    tilt_range : tuple of float
        Minimum and maximum tilt angles in degree.
    height : int
        Height of the simulated tomogram in nm.
    interpolation : int
        Interpolation method used during the simulation.
    seed : int, optional
        Random seed used for the Gaussian noise.
    """
    main = self._get_main()
    sino = ip.imread(path)
    scale = sino.scale.x
    if sino.ndim != 3:
        raise ValueError("Input image must be a 3D image.")
    degrees = np.linspace(*tilt_range, sino.shape[0])
    rng = ip.random.default_rng(seed)
    imax = sino.max()
    sino_noise = sino + rng.normal(
        scale=imax * nsr, size=sino.shape, axes=sino.axes
    )
    yield thread_worker.callback()
    rec = sino_noise.iradon(
        degrees,
        central_axis="y",
        height=roundint(height / scale),
        order=interpolation,
    ).set_scale(zyx=scale, unit="nm")
    yield _on_iradon_finished.with_args(rec.mean("z"), f"N/S = {nsr:.1f}")
    rec.name = SIMULATED_IMAGE_NAME
    tomo = CylTomogram.from_image(
        rec, scale=scale, tilt=tilt_range, binsize=bin_size
    )
    main._init_macro_state()
    return main._send_tomogram_to_viewer.with_args(tomo)

twist(layer, by=0.0, yrange=(0, 1), arange=(0, 1), allev=False)

Apply local twist to molecules.

Parameters:

Name Type Description Default
layer MoleculesLayer

Layer to be transformed.

required
by float

Amount of twist in degree.

0.0
yrange tuple of int

Range of Y axis to be transformed. Range is [a, b).

(0, 1)
arange tuple of int

Range of angle axis to be transformed. Range is [a, b).

(0, 1)
allev bool

Alleviation of the local expansion. If true, the surrounding molecules will be shifted to alleviate the local expansion.

False
Source code in cylindra/widgets/subwidgets/simulator.py
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
@set_design(icon="mingcute:rotate-x-line", location=SimulatorTools)
def twist(
    self,
    layer: _ModeledMoleculesLayer,
    by: Annotated[float, {"min": -100, "max": 100}] = 0.0,
    yrange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
    arange: Annotated[tuple[int, int], {"widget_type": RangeSlider}] = (0, 1),
    allev: bool = False,
):
    """
    Apply local twist to molecules.

    Parameters
    ----------
    layer : MoleculesLayer
        Layer to be transformed.
    by : float
        Amount of twist in degree.
    yrange : tuple of int
        Range of Y axis to be transformed. Range is [a, b).
    arange : tuple of int
        Range of angle axis to be transformed. Range is [a, b).
    allev : bool
        Alleviation of the local expansion. If true, the surrounding molecules
        will be shifted to alleviate the local expansion.
    """
    layer = assert_layer(layer, self.parent_viewer)
    spl, model = _local_transform(
        CylinderModel.twist, layer, np.deg2rad(by), yrange, arange, allev
    )
    layer.molecules = model.to_molecules(spl, layer.molecules.features)
    _set_simulation_model(layer, model)
    return None