nabu 2024.2.14__py3-none-any.whl → 2025.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. doc/doc_config.py +32 -0
  2. nabu/__init__.py +1 -1
  3. nabu/app/bootstrap_stitching.py +4 -2
  4. nabu/app/cast_volume.py +16 -14
  5. nabu/app/cli_configs.py +102 -9
  6. nabu/app/compare_volumes.py +1 -1
  7. nabu/app/composite_cor.py +2 -4
  8. nabu/app/diag_to_pix.py +5 -6
  9. nabu/app/diag_to_rot.py +10 -11
  10. nabu/app/double_flatfield.py +18 -5
  11. nabu/app/estimate_motion.py +75 -0
  12. nabu/app/multicor.py +28 -15
  13. nabu/app/parse_reconstruction_log.py +1 -0
  14. nabu/app/pcaflats.py +122 -0
  15. nabu/app/prepare_weights_double.py +1 -2
  16. nabu/app/reconstruct.py +1 -7
  17. nabu/app/reconstruct_helical.py +5 -9
  18. nabu/app/reduce_dark_flat.py +5 -4
  19. nabu/app/rotate.py +3 -1
  20. nabu/app/stitching.py +7 -2
  21. nabu/app/tests/test_reduce_dark_flat.py +2 -2
  22. nabu/app/validator.py +1 -4
  23. nabu/cuda/convolution.py +1 -1
  24. nabu/cuda/fft.py +1 -1
  25. nabu/cuda/medfilt.py +1 -1
  26. nabu/cuda/padding.py +1 -1
  27. nabu/cuda/src/backproj.cu +6 -6
  28. nabu/cuda/src/cone.cu +4 -0
  29. nabu/cuda/src/hierarchical_backproj.cu +14 -0
  30. nabu/cuda/utils.py +2 -2
  31. nabu/estimation/alignment.py +17 -31
  32. nabu/estimation/cor.py +27 -33
  33. nabu/estimation/cor_sino.py +2 -8
  34. nabu/estimation/focus.py +4 -8
  35. nabu/estimation/motion.py +557 -0
  36. nabu/estimation/tests/test_alignment.py +2 -0
  37. nabu/estimation/tests/test_motion_estimation.py +471 -0
  38. nabu/estimation/tests/test_tilt.py +1 -1
  39. nabu/estimation/tilt.py +6 -5
  40. nabu/estimation/translation.py +47 -1
  41. nabu/io/cast_volume.py +108 -18
  42. nabu/io/detector_distortion.py +5 -6
  43. nabu/io/reader.py +45 -6
  44. nabu/io/reader_helical.py +5 -4
  45. nabu/io/tests/test_cast_volume.py +2 -2
  46. nabu/io/tests/test_readers.py +41 -38
  47. nabu/io/tests/test_remove_volume.py +152 -0
  48. nabu/io/tests/test_writers.py +2 -2
  49. nabu/io/utils.py +8 -4
  50. nabu/io/writer.py +1 -2
  51. nabu/misc/fftshift.py +1 -1
  52. nabu/misc/fourier_filters.py +1 -1
  53. nabu/misc/histogram.py +1 -1
  54. nabu/misc/histogram_cuda.py +1 -1
  55. nabu/misc/padding_base.py +1 -1
  56. nabu/misc/rotation.py +1 -1
  57. nabu/misc/rotation_cuda.py +1 -1
  58. nabu/misc/tests/test_binning.py +1 -1
  59. nabu/misc/transpose.py +1 -1
  60. nabu/misc/unsharp.py +1 -1
  61. nabu/misc/unsharp_cuda.py +1 -1
  62. nabu/misc/unsharp_opencl.py +1 -1
  63. nabu/misc/utils.py +1 -1
  64. nabu/opencl/fft.py +1 -1
  65. nabu/opencl/padding.py +1 -1
  66. nabu/opencl/src/backproj.cl +6 -6
  67. nabu/opencl/utils.py +8 -8
  68. nabu/pipeline/config.py +2 -2
  69. nabu/pipeline/config_validators.py +46 -46
  70. nabu/pipeline/datadump.py +3 -3
  71. nabu/pipeline/estimators.py +271 -11
  72. nabu/pipeline/fullfield/chunked.py +103 -67
  73. nabu/pipeline/fullfield/chunked_cuda.py +5 -2
  74. nabu/pipeline/fullfield/computations.py +4 -1
  75. nabu/pipeline/fullfield/dataset_validator.py +0 -1
  76. nabu/pipeline/fullfield/get_double_flatfield.py +147 -0
  77. nabu/pipeline/fullfield/nabu_config.py +36 -17
  78. nabu/pipeline/fullfield/processconfig.py +41 -7
  79. nabu/pipeline/fullfield/reconstruction.py +14 -10
  80. nabu/pipeline/helical/dataset_validator.py +3 -4
  81. nabu/pipeline/helical/fbp.py +4 -4
  82. nabu/pipeline/helical/filtering.py +5 -4
  83. nabu/pipeline/helical/gridded_accumulator.py +10 -11
  84. nabu/pipeline/helical/helical_chunked_regridded.py +1 -0
  85. nabu/pipeline/helical/helical_reconstruction.py +12 -9
  86. nabu/pipeline/helical/helical_utils.py +1 -2
  87. nabu/pipeline/helical/nabu_config.py +2 -1
  88. nabu/pipeline/helical/span_strategy.py +1 -0
  89. nabu/pipeline/helical/weight_balancer.py +2 -3
  90. nabu/pipeline/params.py +20 -3
  91. nabu/pipeline/tests/__init__.py +0 -0
  92. nabu/pipeline/tests/test_estimators.py +240 -3
  93. nabu/pipeline/utils.py +1 -1
  94. nabu/pipeline/writer.py +1 -1
  95. nabu/preproc/alignment.py +0 -10
  96. nabu/preproc/ccd.py +53 -3
  97. nabu/preproc/ctf.py +8 -8
  98. nabu/preproc/ctf_cuda.py +1 -1
  99. nabu/preproc/double_flatfield_cuda.py +2 -2
  100. nabu/preproc/double_flatfield_variable_region.py +0 -1
  101. nabu/preproc/flatfield.py +307 -2
  102. nabu/preproc/flatfield_cuda.py +1 -2
  103. nabu/preproc/flatfield_variable_region.py +3 -3
  104. nabu/preproc/phase.py +2 -4
  105. nabu/preproc/phase_cuda.py +2 -2
  106. nabu/preproc/shift.py +4 -2
  107. nabu/preproc/shift_cuda.py +0 -1
  108. nabu/preproc/tests/test_ctf.py +4 -4
  109. nabu/preproc/tests/test_double_flatfield.py +1 -1
  110. nabu/preproc/tests/test_flatfield.py +1 -1
  111. nabu/preproc/tests/test_paganin.py +1 -3
  112. nabu/preproc/tests/test_pcaflats.py +154 -0
  113. nabu/preproc/tests/test_vshift.py +4 -1
  114. nabu/processing/azim.py +9 -5
  115. nabu/processing/convolution_cuda.py +6 -4
  116. nabu/processing/fft_base.py +7 -3
  117. nabu/processing/fft_cuda.py +25 -164
  118. nabu/processing/fft_opencl.py +28 -6
  119. nabu/processing/fftshift.py +1 -1
  120. nabu/processing/histogram.py +1 -1
  121. nabu/processing/muladd.py +0 -1
  122. nabu/processing/padding_base.py +1 -1
  123. nabu/processing/padding_cuda.py +0 -2
  124. nabu/processing/processing_base.py +12 -6
  125. nabu/processing/rotation_cuda.py +3 -1
  126. nabu/processing/tests/test_fft.py +2 -64
  127. nabu/processing/tests/test_fftshift.py +1 -1
  128. nabu/processing/tests/test_medfilt.py +1 -3
  129. nabu/processing/tests/test_padding.py +1 -1
  130. nabu/processing/tests/test_roll.py +1 -1
  131. nabu/processing/tests/test_rotation.py +4 -2
  132. nabu/processing/unsharp_opencl.py +1 -1
  133. nabu/reconstruction/astra.py +245 -0
  134. nabu/reconstruction/cone.py +39 -9
  135. nabu/reconstruction/fbp.py +7 -0
  136. nabu/reconstruction/fbp_base.py +36 -5
  137. nabu/reconstruction/filtering.py +59 -25
  138. nabu/reconstruction/filtering_cuda.py +22 -21
  139. nabu/reconstruction/filtering_opencl.py +10 -14
  140. nabu/reconstruction/hbp.py +26 -13
  141. nabu/reconstruction/mlem.py +55 -16
  142. nabu/reconstruction/projection.py +3 -5
  143. nabu/reconstruction/sinogram.py +1 -1
  144. nabu/reconstruction/sinogram_cuda.py +0 -1
  145. nabu/reconstruction/tests/test_cone.py +37 -2
  146. nabu/reconstruction/tests/test_deringer.py +4 -4
  147. nabu/reconstruction/tests/test_fbp.py +36 -15
  148. nabu/reconstruction/tests/test_filtering.py +27 -7
  149. nabu/reconstruction/tests/test_halftomo.py +28 -2
  150. nabu/reconstruction/tests/test_mlem.py +94 -64
  151. nabu/reconstruction/tests/test_projector.py +7 -2
  152. nabu/reconstruction/tests/test_reconstructor.py +1 -1
  153. nabu/reconstruction/tests/test_sino_normalization.py +0 -1
  154. nabu/resources/dataset_analyzer.py +210 -24
  155. nabu/resources/gpu.py +4 -4
  156. nabu/resources/logger.py +4 -4
  157. nabu/resources/nxflatfield.py +103 -37
  158. nabu/resources/tests/test_dataset_analyzer.py +37 -0
  159. nabu/resources/tests/test_extract.py +11 -0
  160. nabu/resources/tests/test_nxflatfield.py +5 -5
  161. nabu/resources/utils.py +16 -10
  162. nabu/stitching/alignment.py +8 -11
  163. nabu/stitching/config.py +44 -35
  164. nabu/stitching/definitions.py +2 -2
  165. nabu/stitching/frame_composition.py +8 -10
  166. nabu/stitching/overlap.py +4 -4
  167. nabu/stitching/sample_normalization.py +5 -5
  168. nabu/stitching/slurm_utils.py +2 -2
  169. nabu/stitching/stitcher/base.py +2 -0
  170. nabu/stitching/stitcher/dumper/base.py +0 -1
  171. nabu/stitching/stitcher/dumper/postprocessing.py +1 -1
  172. nabu/stitching/stitcher/post_processing.py +11 -9
  173. nabu/stitching/stitcher/pre_processing.py +37 -31
  174. nabu/stitching/stitcher/single_axis.py +2 -3
  175. nabu/stitching/stitcher_2D.py +2 -1
  176. nabu/stitching/tests/test_config.py +10 -11
  177. nabu/stitching/tests/test_sample_normalization.py +1 -1
  178. nabu/stitching/tests/test_slurm_utils.py +1 -2
  179. nabu/stitching/tests/test_y_preprocessing_stitching.py +11 -8
  180. nabu/stitching/tests/test_z_postprocessing_stitching.py +3 -3
  181. nabu/stitching/tests/test_z_preprocessing_stitching.py +27 -24
  182. nabu/stitching/utils/tests/__init__.py +0 -0
  183. nabu/stitching/utils/tests/test_post-processing.py +1 -0
  184. nabu/stitching/utils/utils.py +16 -18
  185. nabu/tests.py +0 -3
  186. nabu/testutils.py +62 -9
  187. nabu/utils.py +50 -20
  188. {nabu-2024.2.14.dist-info → nabu-2025.1.0.dist-info}/METADATA +7 -7
  189. nabu-2025.1.0.dist-info/RECORD +328 -0
  190. {nabu-2024.2.14.dist-info → nabu-2025.1.0.dist-info}/WHEEL +1 -1
  191. {nabu-2024.2.14.dist-info → nabu-2025.1.0.dist-info}/entry_points.txt +2 -1
  192. nabu/app/correct_rot.py +0 -70
  193. nabu/io/tests/test_detector_distortion.py +0 -178
  194. nabu-2024.2.14.dist-info/RECORD +0 -317
  195. /nabu/{stitching → app}/tests/__init__.py +0 -0
  196. {nabu-2024.2.14.dist-info → nabu-2025.1.0.dist-info}/licenses/LICENSE +0 -0
  197. {nabu-2024.2.14.dist-info → nabu-2025.1.0.dist-info}/top_level.txt +0 -0
nabu/pipeline/datadump.py CHANGED
@@ -126,7 +126,7 @@ class DataDumpManager:
126
126
  read_opts = self.processing_options["read_chunk"]
127
127
  if read_opts.get("process_file", None) is None:
128
128
  return None
129
- dump_start_z, dump_end_z = read_opts["dump_start_z"], read_opts["dump_end_z"]
129
+ dump_start_z, dump_end_z = read_opts["dump_start_z"], read_opts["dump_end_z"] # noqa: F841
130
130
  relative_start_z = self.z_min - dump_start_z
131
131
  relative_end_z = relative_start_z + self.delta_z
132
132
  # When using binning, every step after "read" results in smaller-sized data.
@@ -139,7 +139,7 @@ class DataDumpManager:
139
139
 
140
140
  def _check_resume_from_step(self):
141
141
  read_opts = self.processing_options["read_chunk"]
142
- expected_radios_shape = get_hdf5_dataset_shape(
142
+ expected_radios_shape = get_hdf5_dataset_shape( # noqa: F841
143
143
  read_opts["process_file"],
144
144
  read_opts["process_h5_path"],
145
145
  sub_region=self.get_read_dump_subregion(),
@@ -151,7 +151,7 @@ class DataDumpManager:
151
151
  return
152
152
  writer = self.data_dump[step_name]
153
153
  self.logger.info("Dumping data to %s" % writer.fname)
154
- if __has_pycuda__:
154
+ if __has_pycuda__: # noqa: SIM102
155
155
  if isinstance(data, garray.GPUArray):
156
156
  data = data.get()
157
157
 
@@ -9,6 +9,7 @@ import scipy.fft # pylint: disable=E0611
9
9
  from silx.io import get_data
10
10
  import math
11
11
  from scipy import ndimage as nd
12
+
12
13
  from ..preproc.flatfield import FlatField
13
14
  from ..estimation.cor import (
14
15
  CenterOfRotation,
@@ -17,8 +18,10 @@ from ..estimation.cor import (
17
18
  CenterOfRotationGrowingWindow,
18
19
  CenterOfRotationOctaveAccurate,
19
20
  )
21
+ from .. import version as nabu_version
20
22
  from ..estimation.cor_sino import SinoCorInterface, CenterOfRotationFourierAngles, CenterOfRotationVo
21
23
  from ..estimation.tilt import CameraTilt
24
+ from ..estimation.motion import MotionEstimation
22
25
  from ..estimation.utils import is_fullturn_scan
23
26
  from ..resources.logger import LoggerOrPrint
24
27
  from ..resources.utils import extract_parameters
@@ -298,7 +301,7 @@ class SinoCORFinder(CORFinderBase):
298
301
  """
299
302
  Initialize a SinoCORFinder object.
300
303
 
301
- Other parameters
304
+ Other Parameters
302
305
  ----------------
303
306
  The following keys can be set in cor_options.
304
307
 
@@ -341,7 +344,7 @@ class SinoCORFinder(CORFinderBase):
341
344
  self.angles = self.dataset_info.rotation_angles[::subsampling]
342
345
  self.subsampling = subsampling
343
346
  else: # Angular step
344
- raise NotImplementedError()
347
+ raise NotImplementedError
345
348
 
346
349
  def _load_raw_sinogram(self):
347
350
  if self.slice_idx is None:
@@ -441,7 +444,7 @@ class CompositeCORFinder(CORFinderBase):
441
444
 
442
445
  search_methods = {
443
446
  "composite-coarse-to-fine": {
444
- "class": CenterOfRotation, # Hack. Not used. Everything is done in the find_cor() func.
447
+ "class": CenterOfRotation, # Not used. Everything is done in the find_cor() func.
445
448
  }
446
449
  }
447
450
  _default_cor_options = {"low_pass": 0.4, "high_pass": 10, "side": "near", "near_pos": 0, "near_width": 40}
@@ -542,7 +545,7 @@ class CompositeCORFinder(CORFinderBase):
542
545
  else:
543
546
  my_flats = None
544
547
 
545
- if my_flats is not None and len(list(my_flats.keys())):
548
+ if my_flats is not None and len(list(my_flats.keys())) > 0:
546
549
  self.use_flat = True
547
550
  self.flatfield = FlatField(
548
551
  (len(self.absolute_indices), self.sy, self.sx),
@@ -750,15 +753,15 @@ class CompositeCORFinder(CORFinderBase):
750
753
  my_blurred_radio1 = np.fliplr(blurred_radio1)
751
754
  my_blurred_radio2 = np.fliplr(blurred_radio2)
752
755
 
753
- common_left = np.fliplr(my_radio1[:, ovsd_sx - my_z :])[:, : -int(math.ceil(self.ovs * self.high_pass * 2))]
756
+ common_left = np.fliplr(my_radio1[:, ovsd_sx - my_z :])[:, : -math.ceil(self.ovs * self.high_pass * 2)]
754
757
  # adopt a 'safe' margin considering high_pass value (possibly float)
755
- common_right = my_radio2[:, ovsd_sx - my_z : -int(math.ceil(self.ovs * self.high_pass * 2))]
758
+ common_right = my_radio2[:, ovsd_sx - my_z : -math.ceil(self.ovs * self.high_pass * 2)]
756
759
 
757
760
  common_blurred_left = np.fliplr(my_blurred_radio1[:, ovsd_sx - my_z :])[
758
- :, : -int(math.ceil(self.ovs * self.high_pass * 2))
761
+ :, : -math.ceil(self.ovs * self.high_pass * 2)
759
762
  ]
760
763
  # adopt a 'safe' margin considering high_pass value (possibly float)
761
- common_blurred_right = my_blurred_radio2[:, ovsd_sx - my_z : -int(math.ceil(self.ovs * self.high_pass * 2))]
764
+ common_blurred_right = my_blurred_radio2[:, ovsd_sx - my_z : -math.ceil(self.ovs * self.high_pass * 2)]
762
765
 
763
766
  if common_right.size == 0:
764
767
  continue
@@ -786,7 +789,7 @@ class CompositeCORFinder(CORFinderBase):
786
789
  elif self.norm_order == 1:
787
790
  return self.error_metric_l1(common_right, common_left, common_blurred_right, common_blurred_left)
788
791
  else:
789
- assert False, "this cannot happen"
792
+ raise RuntimeError("this cannot happen")
790
793
 
791
794
  def error_metric_l2(self, common_right, common_left):
792
795
  common = common_right - common_left
@@ -818,10 +821,10 @@ def oversample(radio, ovs_s):
818
821
  # Pre-initialisation: The original data falls exactly on the following strided positions in the new data array.
819
822
  result[:: ovs_s[0], :: ovs_s[1]] = radio
820
823
 
821
- for k in range(0, ovs_s[0]):
824
+ for k in range(ovs_s[0]):
822
825
  # interpolation coefficient for axis 0
823
826
  g = k / ovs_s[0]
824
- for i in range(0, ovs_s[1]):
827
+ for i in range(ovs_s[1]):
825
828
  if i == 0 and k == 0:
826
829
  # this case subset was already exactly matched from before the present double loop,
827
830
  # in the pre-initialisation line.
@@ -989,3 +992,260 @@ class DetectorTiltEstimator:
989
992
 
990
993
  # alias
991
994
  TiltFinder = DetectorTiltEstimator
995
+
996
+
997
+ def estimate_translations(dataset_info, do_flatfield=True): ...
998
+
999
+
1000
+ class TranslationsEstimator:
1001
+
1002
+ _default_extra_options = {
1003
+ "window_size": 300,
1004
+ }
1005
+
1006
+ def __init__(
1007
+ self,
1008
+ dataset_info,
1009
+ do_flatfield=True,
1010
+ rot_center=None,
1011
+ halftomo_side=None,
1012
+ angular_subsampling=10,
1013
+ deg_xy=2,
1014
+ deg_z=2,
1015
+ shifts_estimator="phase_cross_correlation",
1016
+ radios_filter=None,
1017
+ extra_options=None,
1018
+ ):
1019
+ self._configure_extra_options(extra_options)
1020
+ self.logger = LoggerOrPrint(dataset_info.logger)
1021
+ self.dataset_info = dataset_info
1022
+ self.angular_subsampling = angular_subsampling
1023
+ self.do_360 = self.dataset_info.is_360
1024
+ self.do_flatfield = do_flatfield
1025
+ self.radios_filter = radios_filter
1026
+ self.radios = None
1027
+ self._deg_xy = deg_xy
1028
+ self._deg_z = deg_z
1029
+ self._shifts_estimator = shifts_estimator
1030
+ self._shifts_estimator_kwargs = {}
1031
+ self._cor = rot_center
1032
+ self._configure_halftomo(halftomo_side)
1033
+ self._estimate_cor = self._cor is None
1034
+ self.sample_shifts_xy = None
1035
+ self.sample_shifts_z = None
1036
+
1037
+ def _configure_extra_options(self, extra_options):
1038
+ self.extra_options = self._default_extra_options.copy()
1039
+ self.extra_options.update(extra_options or {})
1040
+
1041
+ def _configure_halftomo(self, halftomo_side):
1042
+ if halftomo_side is False:
1043
+ # Force disable halftomo
1044
+ self.halftomo_side = False
1045
+ return
1046
+ self._start_x = None
1047
+ self._end_x = None
1048
+ if (halftomo_side is not None) and not (self.do_360):
1049
+ raise ValueError(
1050
+ "Expected 360° dataset for half-tomography, but this dataset does not look like a 360° dataset"
1051
+ )
1052
+ if halftomo_side is None:
1053
+ if self.dataset_info.is_halftomo:
1054
+ halftomo_side = "right"
1055
+ else:
1056
+ self.halftomo_side = False
1057
+ return
1058
+ self.halftomo_side = halftomo_side
1059
+ window_size = self.extra_options["window_size"]
1060
+ if self._cor is not None:
1061
+ # In this case we look for shifts around the CoR
1062
+ self._start_x = int(self._cor - window_size / 2)
1063
+ self._end_x = int(self._cor + window_size / 2)
1064
+ elif halftomo_side == "right":
1065
+ self._start_x = -window_size
1066
+ self._end_x = None
1067
+ elif halftomo_side == "left":
1068
+ self._start_x = 0
1069
+ self._end_x = window_size
1070
+ elif is_scalar(halftomo_side):
1071
+ # Expect approximate location of CoR, relative to left-most column
1072
+ self._start_x = int(halftomo_side - window_size / 2)
1073
+ self._end_x = int(halftomo_side + window_size / 2)
1074
+ else:
1075
+ raise ValueError(
1076
+ f"Expected 'halftomo_side' to be either 'left', 'right', or an integer (got {halftomo_side})"
1077
+ )
1078
+ self.logger.debug(f"[MotionEstimation] Half-tomo looking at [{self._start_x}:{self._end_x}]")
1079
+ # For half-tomo, skimage.registration.phase_cross_correlation might look a bit too far away
1080
+ if (
1081
+ self._shifts_estimator == "phase_cross_correlation"
1082
+ and self._shifts_estimator_kwargs.get("overlap_ratio", 0.3) >= 0.3
1083
+ ):
1084
+ self._shifts_estimator_kwargs.update({"overlap_ratio": 0.2})
1085
+ #
1086
+
1087
+ def _load_data(self):
1088
+ self.logger.debug("[MotionEstimation] reading data")
1089
+ if self.do_360:
1090
+ """
1091
+ In this case we compare pair of opposite projections.
1092
+ If rotation angles are arbitrary, we should do something like
1093
+ for angle in dataset_info.rotation_angles:
1094
+ img, angle_deg, idx = dataset_info.get_image_at_angle(
1095
+ np.degrees(angle)+180, return_angle_and_index=True
1096
+ )
1097
+ Most of the time (always ?), the dataset was acquired with a circular trajectory,
1098
+ so we can use angles:
1099
+ dataset_info.rotation_angles[::self.angular_subsampling]
1100
+ which amounts to reading one radio out of "angular_subsampling"
1101
+ """
1102
+
1103
+ # TODO account for more general rotation angles. The following will only work for circular trajectory and ordered angles
1104
+ self._reader = self.dataset_info.get_reader(
1105
+ sub_region=(slice(None, None, self.angular_subsampling), slice(None), slice(None))
1106
+ )
1107
+ self.radios = self._reader.load_data()
1108
+ self.angles = self.dataset_info.rotation_angles[:: self.angular_subsampling]
1109
+ self._radios_idx = self._reader.get_frames_indices()
1110
+ self.logger.debug("[MotionEstimation] This is a 360° scan, will use pairs of opposite projections")
1111
+ else:
1112
+ """
1113
+ In this case we use the "return projections", i.e special projections acquired at several angles
1114
+ (eg. [180, 90, 0]) before ending the scan
1115
+ """
1116
+ return_projs, return_angles_deg, return_idx = self.dataset_info.get_alignment_projections()
1117
+ self._angles_return = np.radians(return_angles_deg)
1118
+ self._radios_return = return_projs
1119
+ self._radios_idx_return = return_idx
1120
+
1121
+ projs = []
1122
+ angles_rad = []
1123
+ projs_idx = []
1124
+ for angle_deg in return_angles_deg:
1125
+ proj, rot_angle_deg, proj_idx = self.dataset_info.get_image_at_angle(
1126
+ angle_deg, image_type="projection", return_angle_and_index=True
1127
+ )
1128
+ projs.append(proj)
1129
+ angles_rad.append(np.radians(rot_angle_deg))
1130
+ projs_idx.append(proj_idx)
1131
+ self._radios_outwards = np.array(projs)
1132
+ self._angles_outward = np.array(angles_rad)
1133
+ self._radios_idx_outwards = np.array(projs_idx)
1134
+ self.logger.debug("[MotionEstimation] This is a 180° scan, will use 'return projections'")
1135
+
1136
+ def _apply_flatfield(self):
1137
+ if not (self.do_flatfield):
1138
+ return
1139
+ self.logger.debug("[MotionEstimation] flatfield")
1140
+ if self.do_360:
1141
+ self._flatfield = FlatField(
1142
+ self.radios.shape,
1143
+ flats=self.dataset_info.flats,
1144
+ darks=self.dataset_info.darks,
1145
+ radios_indices=self._radios_idx,
1146
+ )
1147
+ self._flatfield.normalize_radios(self.radios)
1148
+ else:
1149
+ # 180 + return projs
1150
+ self._flatfield_outwards = FlatField(
1151
+ self._radios_outwards.shape,
1152
+ flats=self.dataset_info.flats,
1153
+ darks=self.dataset_info.darks,
1154
+ radios_indices=self._radios_idx_outwards,
1155
+ )
1156
+ self._flatfield_outwards.normalize_radios(self._radios_outwards)
1157
+ self._flatfield_return = FlatField(
1158
+ self._radios_return.shape,
1159
+ flats=self.dataset_info.flats,
1160
+ darks=self.dataset_info.darks,
1161
+ radios_indices=self._radios_idx_return,
1162
+ )
1163
+ self._flatfield_outwards.normalize_radios(self._radios_return)
1164
+
1165
+ def estimate_motion(self):
1166
+ self._load_data()
1167
+ self._apply_flatfield()
1168
+ if self.radios_filter is not None:
1169
+ self.logger.debug("[MotionEstimation] applying radios filter")
1170
+ self.radios_filter(self.radios)
1171
+
1172
+ n_projs_tot = self.dataset_info.n_angles
1173
+ if self.do_360:
1174
+ n_a = self.radios.shape[0]
1175
+ # See notes above - this works only for circular trajectory / ordered angles
1176
+ projs_stack1 = self.radios[: n_a // 2]
1177
+ projs_stack2 = self.radios[n_a // 2 :]
1178
+ angles1 = self.angles[: n_a // 2]
1179
+ angles2 = self.angles[n_a // 2 :]
1180
+ indices1 = (self._radios_idx - self._radios_idx[0])[: n_a // 2]
1181
+ indices2 = (self._radios_idx - self._radios_idx[0])[n_a // 2 :]
1182
+ else:
1183
+ projs_stack1 = self._radios_outwards
1184
+ projs_stack2 = self._radios_return
1185
+ angles1 = self._angles_outward
1186
+ angles2 = self._angles_return
1187
+ indices1 = self._radios_idx_outwards - self._radios_idx_outwards.min()
1188
+ indices2 = self._radios_idx_return - self._radios_idx_outwards.min()
1189
+
1190
+ if self._start_x is not None:
1191
+ # Compute Motion Estimation on subset of images (eg. for half-tomo)
1192
+ projs_stack1 = projs_stack1[..., self._start_x : self._end_x]
1193
+ projs_stack2 = projs_stack2[..., self._start_x : self._end_x]
1194
+
1195
+ self.motion_estimator = MotionEstimation(
1196
+ projs_stack1,
1197
+ projs_stack2,
1198
+ angles1,
1199
+ angles2,
1200
+ indices1,
1201
+ indices2,
1202
+ n_projs_tot,
1203
+ shifts_estimator=self._shifts_estimator,
1204
+ shifts_estimator_kwargs=self._shifts_estimator_kwargs,
1205
+ )
1206
+
1207
+ self.logger.debug("[MotionEstimation] estimating shifts")
1208
+
1209
+ estimated_shifts_v = self.motion_estimator.estimate_vertical_motion(degree=self._deg_z)
1210
+ estimated_shifts_h, cor = self.motion_estimator.estimate_horizontal_motion(degree=self._deg_xy, cor=self._cor)
1211
+ if self._start_x is not None:
1212
+ cor += (self._start_x % self.radios.shape[-1]) + (projs_stack1.shape[-1] - 1) / 2.0
1213
+
1214
+ self.sample_shifts_xy = estimated_shifts_h
1215
+ self.sample_shifts_z = estimated_shifts_v
1216
+ if self._cor is None:
1217
+ self.logger.info(
1218
+ "[MotionEstimation] Estimated center of rotation (relative to left-most pixel): %.2f" % cor
1219
+ )
1220
+ return estimated_shifts_h, estimated_shifts_v, cor
1221
+
1222
+ def generate_translations_movements_file(self, filename, fmt="%.3f", only=None):
1223
+ if self.sample_shifts_xy is None:
1224
+ raise RuntimeError("Need to run estimate_motion() first")
1225
+
1226
+ angles = self.dataset_info.rotation_angles
1227
+ cor = self._cor or 0
1228
+ txy_est_all_angles = self.motion_estimator.apply_fit_horiz(angles=angles)
1229
+ tz_est_all_angles = self.motion_estimator.apply_fit_vertic(angles=angles)
1230
+ estimated_shifts_vu_all_angles = self.motion_estimator.convert_sample_motion_to_detector_shifts(
1231
+ txy_est_all_angles, tz_est_all_angles, angles, cor=cor
1232
+ )
1233
+ estimated_shifts_vu_all_angles[:, 1] -= cor
1234
+ correct_shifts_uv = -estimated_shifts_vu_all_angles[:, ::-1]
1235
+
1236
+ if only is not None:
1237
+ if only == "horizontal":
1238
+ correct_shifts_uv[:, 1] = 0
1239
+ elif only == "vertical":
1240
+ correct_shifts_uv[:, 0] = 0
1241
+ else:
1242
+ raise ValueError("Expected 'only' to be either None, 'horizontal' or 'vertical'")
1243
+
1244
+ header = f"Generated by nabu {nabu_version} : {str(self)}"
1245
+ np.savetxt(filename, correct_shifts_uv, fmt=fmt, header=header)
1246
+
1247
+ def __str__(self):
1248
+ ret = f"{self.__class__.__name__}(do_flatfield={self.do_flatfield}, rot_center={self._cor}, angular_subsampling={self.angular_subsampling})"
1249
+ if self.sample_shifts_xy is not None:
1250
+ ret += f", shifts_estimator={self.motion_estimator.shifts_estimator}"
1251
+ return ret
@@ -1,6 +1,5 @@
1
1
  from os import path
2
2
  from time import time
3
- from math import ceil
4
3
  import numpy as np
5
4
  from silx.io.url import DataUrl
6
5
 
@@ -10,7 +9,7 @@ from ...resources.utils import extract_parameters
10
9
  from ...misc.binning import binning as image_binning
11
10
  from ...io.reader import EDFStackReader, HDF5Loader, NXTomoReader
12
11
  from ...preproc.ccd import Log, CCDFilter
13
- from ...preproc.flatfield import FlatField
12
+ from ...preproc.flatfield import FlatField, PCAFlatsNormalizer
14
13
  from ...preproc.distortion import DistortionCorrection
15
14
  from ...preproc.shift import VerticalShift
16
15
  from ...preproc.double_flatfield import DoubleFlatField
@@ -18,7 +17,7 @@ from ...preproc.phase import PaganinPhaseRetrieval
18
17
  from ...preproc.ctf import CTFPhaseRetrieval, GeoPars
19
18
  from ...reconstruction.sinogram import SinoNormalization
20
19
  from ...reconstruction.filtering import SinoFilter
21
- from ...reconstruction.mlem import __have_corrct__, MLEMReconstructor
20
+ from ...reconstruction.mlem import MLEMReconstructor
22
21
  from ...processing.rotation import Rotation
23
22
  from ...reconstruction.rings import MunchDeringer, SinoMeanDeringer, VoDeringer
24
23
  from ...processing.unsharp import UnsharpMask
@@ -45,6 +44,7 @@ class ChunkedPipeline:
45
44
 
46
45
  backend = "numpy"
47
46
  FlatFieldClass = FlatField
47
+ PCAFlatFieldClass = PCAFlatsNormalizer
48
48
  DoubleFlatFieldClass = DoubleFlatField
49
49
  CCDCorrectionClass = CCDFilter
50
50
  PaganinPhaseRetrievalClass = PaganinPhaseRetrieval
@@ -99,7 +99,6 @@ class ChunkedPipeline:
99
99
 
100
100
  Notes
101
101
  ------
102
-
103
102
  Using `margin` results in a lesser number of reconstructed slices.
104
103
  More specifically, if `margin = (V, H)`, then there will be `delta_z - 2*V`
105
104
  reconstructed slices (if the sub-region is in the middle of the volume)
@@ -127,10 +126,10 @@ class ChunkedPipeline:
127
126
  if len(chunk_shape) != 3:
128
127
  raise ValueError("Expected chunk_shape to be a tuple of length 3 in the form (n_z, n_y, n_x)")
129
128
  self.chunk_shape = tuple(int(c) for c in chunk_shape) # cast to int, as numpy.int64 can make pycuda crash
130
- # TODO: sanity check (eg. compare to size of radios in dataset_info) ?
129
+ ss_start = getattr(self.process_config, "subsampling_start", 0)
131
130
  # (n_a, n_z, n_x)
132
131
  self.radios_shape = (
133
- ceil(self.chunk_shape[0] / self.process_config.subsampling_factor),
132
+ np.arange(self.chunk_shape[0])[ss_start :: self.process_config.subsampling_factor].size,
134
133
  self.chunk_shape[1] // self.process_config.binning[1],
135
134
  self.chunk_shape[2] // self.process_config.binning[0],
136
135
  )
@@ -175,7 +174,7 @@ class ChunkedPipeline:
175
174
  Data volume sub-region, in the form ((start_a, end_a), (start_z, end_z), (start_x, end_x))
176
175
  where the data volume has a layout (angles, Z, X)
177
176
  """
178
- n_angles = self.dataset_info.n_angles
177
+ # n_angles = self.dataset_info.n_angles
179
178
  n_x, n_z = self.dataset_info.radio_dims
180
179
  c_a, c_z, c_x = self.chunk_shape
181
180
  if sub_region is None:
@@ -190,7 +189,7 @@ class ChunkedPipeline:
190
189
  # check sub-region
191
190
  for i, start_end in enumerate(sub_region):
192
191
  start, end = start_end
193
- if start is not None and end is not None:
192
+ if start is not None and end is not None: # noqa: SIM102
194
193
  if end - start != self.chunk_shape[i]:
195
194
  raise ValueError(
196
195
  "Invalid (start, end)=(%d, %d) for sub-region (dimension %d): chunk shape is %s, but %d-%d=%d != %d"
@@ -340,13 +339,28 @@ class ChunkedPipeline:
340
339
  subs_z = None
341
340
  subs_x = None
342
341
  angular_sub_region = slice(*(self.sub_region[0]))
342
+
343
+ # exclude(subsample(.)) != subsample(exclude(.))
344
+ # Here we want the latter: first exclude the user-defined angular range, and then subsample the remaining indices
345
+ if len(self.dataset_info.get_excluded_projections_indices()) > 0:
346
+ angular_sub_region = np.array(
347
+ [
348
+ self.dataset_info.index_to_proj_number(i)
349
+ for i in sorted(list(self.dataset_info.projections.keys()))
350
+ ]
351
+ )
343
352
  if self.process_config.subsampling_factor:
344
353
  subs_angles = self.process_config.subsampling_factor
345
- angular_sub_region = slice(
346
- getattr(self.process_config, "subsampling_start", 0) + self.sub_region[0][0],
347
- self.sub_region[0][1],
348
- subs_angles,
349
- )
354
+ start = getattr(self.process_config, "subsampling_start", 0) + self.sub_region[0][0]
355
+ if isinstance(angular_sub_region, slice):
356
+ angular_sub_region = slice(
357
+ start,
358
+ self.sub_region[0][1],
359
+ subs_angles,
360
+ )
361
+ else:
362
+ angular_sub_region = angular_sub_region[start::subs_angles]
363
+
350
364
  reader_sub_region = (
351
365
  angular_sub_region,
352
366
  slice(*(self.sub_region[1]) + ((subs_z,) if subs_z else ())),
@@ -363,7 +377,7 @@ class ChunkedPipeline:
363
377
  if self.dataset_info.kind == "nx":
364
378
  self.chunk_reader = NXTomoReader(
365
379
  self.dataset_info.dataset_hdf5_url.file_path(),
366
- self.dataset_info.dataset_hdf5_url.data_path(),
380
+ data_path=self.dataset_info.dataset_hdf5_url.data_path(),
367
381
  sub_region=reader_sub_region,
368
382
  image_key=0,
369
383
  **other_reader_kwargs,
@@ -394,50 +408,68 @@ class ChunkedPipeline:
394
408
 
395
409
  @use_options("flatfield", "flatfield")
396
410
  def _init_flatfield(self):
397
- self._ff_options = self.processing_options["flatfield"].copy()
398
-
399
- # This won't work when resuming from a step (i.e before FF), because we rely on H5Loader()
400
- # which re-compacts the data. When data is re-compacted, we have to know the original radios positions.
401
- # These positions can be saved in the "file_dump" metadata, but it is not loaded for now
402
- # (the process_config object is re-built from scratch every time)
403
- self._ff_options["projs_indices"] = self.chunk_reader.get_frames_indices()
404
-
405
- if self._ff_options.get("normalize_srcurrent", False):
406
- a_start_idx, a_end_idx = self.sub_region[0]
407
- subs = self.process_config.subsampling_factor
408
- self._ff_options["radios_srcurrent"] = self._ff_options["radios_srcurrent"][a_start_idx:a_end_idx:subs]
409
-
410
- distortion_correction = None
411
- if self._ff_options["do_flat_distortion"]:
412
- self.logger.info("Flats distortion correction will be applied")
413
- self.FlatFieldClass = FlatField # no GPU implementation available, force this backend
414
- estimation_kwargs = {}
415
- estimation_kwargs.update(self._ff_options["flat_distortion_params"])
416
- estimation_kwargs["logger"] = self.logger
417
- distortion_correction = DistortionCorrection(
418
- estimation_method="fft-correlation", estimation_kwargs=estimation_kwargs, correction_method="interpn"
419
- )
411
+ if self.processing_options["flatfield"]:
412
+ self._ff_options = self.processing_options["flatfield"].copy()
413
+
414
+ # This won't work when resuming from a step (i.e before FF), because we rely on H5Loader()
415
+ # which re-compacts the data. When data is re-compacted, we have to know the original radios positions.
416
+ # These positions can be saved in the "file_dump" metadata, but it is not loaded for now
417
+ # (the process_config object is re-built from scratch every time)
418
+ self._ff_options["projs_indices"] = self.chunk_reader.get_frames_indices()
419
+
420
+ if self._ff_options.get("normalize_srcurrent", False):
421
+ a_start_idx, a_end_idx = self.sub_region[0]
422
+ subs = self.process_config.subsampling_factor
423
+ self._ff_options["radios_srcurrent"] = self._ff_options["radios_srcurrent"][a_start_idx:a_end_idx:subs]
424
+
425
+ distortion_correction = None
426
+ if self._ff_options["do_flat_distortion"]:
427
+ self.logger.info("Flats distortion correction will be applied")
428
+ self.FlatFieldClass = FlatField # no GPU implementation available, force this backend
429
+ estimation_kwargs = {}
430
+ estimation_kwargs.update(self._ff_options["flat_distortion_params"])
431
+ estimation_kwargs["logger"] = self.logger
432
+ distortion_correction = DistortionCorrection(
433
+ estimation_method="fft-correlation",
434
+ estimation_kwargs=estimation_kwargs,
435
+ correction_method="interpn",
436
+ )
420
437
 
421
- # Reduced darks/flats are loaded, but we have to crop them on the current sub-region
422
- # and possibly do apply some pre-processing (binning, distortion correction, ...)
423
- darks_flats = load_darks_flats(
424
- self.dataset_info,
425
- self.sub_region[1:],
426
- processing_func=self._ff_processing_function,
427
- processing_func_args=self._ff_processing_function_args,
428
- )
438
+ if self.processing_options["flatfield"]["method"].lower() != "pca":
439
+ # Reduced darks/flats are loaded, but we have to crop them on the current sub-region
440
+ # and possibly do apply some pre-processing (binning, distortion correction, ...)
441
+ darks_flats = load_darks_flats(
442
+ self.dataset_info,
443
+ self.sub_region[1:],
444
+ processing_func=self._ff_processing_function,
445
+ processing_func_args=self._ff_processing_function_args,
446
+ )
429
447
 
430
- # FlatField parameter "radios_indices" must account for subsampling
431
- self.flatfield = self.FlatFieldClass(
432
- self.radios_shape,
433
- flats=darks_flats["flats"],
434
- darks=darks_flats["darks"],
435
- radios_indices=self._ff_options["projs_indices"],
436
- interpolation="linear",
437
- distortion_correction=distortion_correction,
438
- radios_srcurrent=self._ff_options["radios_srcurrent"],
439
- flats_srcurrent=self._ff_options["flats_srcurrent"],
440
- )
448
+ # FlatField parameter "radios_indices" must account for subsampling
449
+ self.flatfield = self.FlatFieldClass(
450
+ self.radios_shape,
451
+ flats=darks_flats["flats"],
452
+ darks=darks_flats["darks"],
453
+ radios_indices=self._ff_options["projs_indices"],
454
+ interpolation="linear",
455
+ distortion_correction=distortion_correction,
456
+ radios_srcurrent=self._ff_options["radios_srcurrent"],
457
+ flats_srcurrent=self._ff_options["flats_srcurrent"],
458
+ )
459
+ else:
460
+ flats = self.process_config.dataset_info.flats
461
+ darks = self.process_config.dataset_info.darks
462
+ if len(darks) != 1:
463
+ raise ValueError(f"There should be only one reduced dark. Found {len(darks)}.")
464
+ else:
465
+ dark_key = list(darks.keys())[0]
466
+ nb_pca_components = len(flats) - 1
467
+ img_subregion = tuple(slice(*sr) for sr in self.sub_region[1:])
468
+ self.flatfield = self.PCAFlatFieldClass(
469
+ np.array([flats[k][img_subregion] for k in range(1, nb_pca_components)]),
470
+ darks[dark_key][img_subregion],
471
+ flats[0][img_subregion], # Mean
472
+ )
441
473
 
442
474
  @use_options("double_flatfield", "double_flatfield")
443
475
  def _init_double_flatfield(self):
@@ -630,13 +662,14 @@ class ChunkedPipeline:
630
662
  "clip_outer_circle": options["clip_outer_circle"],
631
663
  "outer_circle_value": options["outer_circle_value"],
632
664
  "filter_cutoff": options["fbp_filter_cutoff"],
665
+ "crop_filtered_data": options["crop_filtered_data"],
633
666
  },
634
667
  )
635
668
 
636
669
  if options["method"] == "mlem" and options["implementation"] in (None, "corrct"):
637
670
  self.reconstruction = self.MLEMClass( # pylint: disable=E1102
638
671
  (self.radios_shape[1],) + self.sino_shape,
639
- angles_rad=-options["angles"], # WARNING: mind the sign...
672
+ angles_rad=options["angles"],
640
673
  shifts_uv=self.dataset_info.translations, # In config file, one line per proj, each line is (tu,tv). Corrct expects one col per proj and (tv,tu).
641
674
  cor=options["rotation_axis_position"],
642
675
  n_iterations=options["iterations"],
@@ -647,9 +680,19 @@ class ChunkedPipeline:
647
680
  "v_max_for_v_shifts": None,
648
681
  "v_min_for_u_shifts": 0,
649
682
  "v_max_for_u_shifts": None,
683
+ "scale_factor": 1.0 / options["voxel_size_cm"][0],
684
+ "clip_outer_circle": options["clip_outer_circle"],
685
+ "outer_circle_value": options["outer_circle_value"],
686
+ "filter_cutoff": options["fbp_filter_cutoff"],
687
+ "crop_filtered_data": options["crop_filtered_data"],
650
688
  },
651
689
  )
652
690
 
691
+ if options.get("crop_filtered_data", True) is False:
692
+ self.logger.warning(
693
+ "Using [reconstruction] crop_filtered_data = False. This will use a large amount of memory."
694
+ )
695
+
653
696
  self._allocate_recs(*self.process_config.rec_shape, n_slices=n_slices)
654
697
  n_a, _, n_x = self.radios_cropped_shape
655
698
  self._tmp_sino = self._allocate_array((n_a, n_x), "f", name="tmp_sino")
@@ -806,15 +849,7 @@ class ChunkedPipeline:
806
849
  """
807
850
  This reconstructs the entire sinograms stack at once
808
851
  """
809
-
810
- n_angles, n_z, n_x = self.radios.shape
811
-
812
- # FIXME
813
- # can't do a discontiguous single copy...
814
- sinos_contig = self._allocate_array((n_z, n_angles, n_x), np.float32, "sinos_cone")
815
- for i in range(n_z):
816
- sinos_contig[i] = self.radios[:, i, :]
817
- # ---
852
+ sinos_discontig = self.radios.transpose(axes=(1, 0, 2)) # view
818
853
 
819
854
  # In principle radios are not cropped at this stage,
820
855
  # so self.sub_region[2][0] can be used instead of self.get_slice_start_index() instead of self.sub_region[2][0]
@@ -822,7 +857,8 @@ class ChunkedPipeline:
822
857
  n_z_tot = self.process_config.radio_shape(binning=True)[0]
823
858
 
824
859
  self.reconstruction.reconstruct( # pylint: disable=E1101
825
- sinos_contig,
860
+ # sinos_contig,
861
+ sinos_discontig,
826
862
  output=self.recs,
827
863
  relative_z_position=((z_min + z_max) / self.process_config.binning_z / 2) - n_z_tot / 2,
828
864
  )