essimaging 24.9.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.
@@ -0,0 +1,12 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ # ruff: noqa: E402, F401
4
+
5
+ import importlib.metadata
6
+
7
+ try:
8
+ __version__ = importlib.metadata.version("essimaging")
9
+ except importlib.metadata.PackageNotFoundError:
10
+ __version__ = "0.0.0"
11
+
12
+ del importlib
ess/imaging/data.py ADDED
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ import pathlib
4
+
5
+ import pooch
6
+
7
+ _version = '0'
8
+
9
+
10
+ def _make_pooch():
11
+ return pooch.create(
12
+ path=pooch.os_cache('essimaging'),
13
+ env='BEAMLIME_DATA_DIR',
14
+ retry_if_failed=3,
15
+ base_url='https://public.esss.dk/groups/scipp/ess/imaging/',
16
+ version=_version,
17
+ registry={
18
+ 'small_ymir_images.hdf': 'md5:cf83695d5da29e686c10a31b402b8bdb',
19
+ 'README.md': 'md5:0f375972d4008de6060b065ac13ba17f',
20
+ },
21
+ )
22
+
23
+
24
+ _pooch = _make_pooch()
25
+ _pooch.fetch('README.md')
26
+
27
+
28
+ def get_path(name: str) -> pathlib.Path:
29
+ """
30
+ Return the path to a data file bundled with ess.imaging test helpers.
31
+
32
+ This function only works with example data and cannot handle
33
+ paths to custom files.
34
+ """
35
+
36
+ return pathlib.Path(_pooch.fetch(name))
37
+
38
+
39
+ def get_ymir_images_path() -> pathlib.Path:
40
+ """
41
+ Return the path to the small YMIR images HDF5 file.
42
+ """
43
+
44
+ return get_path('small_ymir_images.hdf')
ess/imaging/io.py ADDED
@@ -0,0 +1,360 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ import warnings
4
+ from collections.abc import Callable, Generator, Iterable
5
+ from enum import Enum
6
+ from itertools import pairwise
7
+ from pathlib import Path
8
+ from typing import NewType
9
+
10
+ import scipp as sc
11
+ import scippnexus as snx
12
+ from tifffile import imwrite
13
+
14
+ from ess.reduce.nexus.types import FilePath
15
+
16
+ from .types import (
17
+ DEFAULT_HISTOGRAM_PATH,
18
+ HistogramModeDetectorsPath,
19
+ ImageDetectorName,
20
+ ImageKeyLogs,
21
+ RotationLogs,
22
+ RotationMotionSensorName,
23
+ )
24
+
25
+ FileLock = NewType("FileLock", bool)
26
+ """File lock mode for reading nexus file."""
27
+ DEFAULT_FILE_LOCK = FileLock(True)
28
+
29
+ HistogramModeDetector = NewType("HistogramModeDetector", sc.DataGroup)
30
+ """Histogram mode detector data group."""
31
+ HistogramModeDetectorData = NewType("HistogramModeDetectorData", sc.DataArray)
32
+ """Histogram mode detector data."""
33
+ ImageKeyCoord = NewType("ImageKeyCoord", sc.Variable)
34
+ """Image key coordinate."""
35
+ SampleImageStacksWithLogs = NewType("SampleImageStacksWithLogs", sc.DataArray)
36
+ """Raw image stacks separated by ImageKey values via timestamp."""
37
+ RotationAngleCoord = NewType("RotationAngleCoord", sc.Variable)
38
+ """Rotation angle coordinate."""
39
+
40
+ RawSampleImageStacks = NewType("RawSampleImageStacks", sc.DataArray)
41
+ """Sample image stacks."""
42
+ OpenBeamImageStacks = NewType("OpenBeamImageStacks", sc.DataArray)
43
+ """Open beam image stacks."""
44
+ DarkCurrentImageStacks = NewType("DarkCurrentImageStacks", sc.DataArray)
45
+ """Dark current image stacks."""
46
+
47
+
48
+ IMAGE_KEY_COORD_NAME = "image_key"
49
+ """Image key coordinate name."""
50
+ TIME_COORD_NAME = "time"
51
+ """Time coordinate name."""
52
+ ROTATION_ANGLE_COORD_NAME = "rotation_angle"
53
+ """Rotation angle coordinate name."""
54
+ DIM1_COORD_NAME = "dim_1"
55
+ """Dimension 1 coordinate name."""
56
+ DIM2_COORD_NAME = "dim_2"
57
+ """Dimension 2 coordinate name."""
58
+
59
+
60
+ class ImageKey(Enum):
61
+ """Image key values."""
62
+
63
+ SAMPLE = 0
64
+ OPEN_BEAM = 1
65
+ DARK_CURRENT = 2
66
+
67
+ @classmethod
68
+ def as_index(cls, key: "ImageKey", target_da: sc.DataArray | None) -> sc.Variable:
69
+ if target_da is None:
70
+ return sc.scalar(cls(key).value, unit=None)
71
+ elif IMAGE_KEY_COORD_NAME in target_da.coords:
72
+ return sc.scalar(
73
+ cls(key).value,
74
+ unit=target_da.coords[IMAGE_KEY_COORD_NAME].unit,
75
+ dtype=target_da.coords[IMAGE_KEY_COORD_NAME].dtype,
76
+ )
77
+ else:
78
+ return sc.scalar(cls(key).value, unit=target_da.unit, dtype=target_da.dtype)
79
+
80
+
81
+ def load_nexus_histogram_mode_detector(
82
+ *,
83
+ file_path: FilePath,
84
+ image_detector_name: ImageDetectorName,
85
+ histogram_mode_detectors_path: HistogramModeDetectorsPath = DEFAULT_HISTOGRAM_PATH,
86
+ locking: FileLock = DEFAULT_FILE_LOCK,
87
+ ) -> HistogramModeDetector:
88
+ try:
89
+ with snx.File(file_path, mode="r", locking=locking) as f:
90
+ img_path = f"{histogram_mode_detectors_path}/{image_detector_name}"
91
+ dg: sc.DataGroup = f[img_path][()]
92
+ except PermissionError as e:
93
+ raise PermissionError(
94
+ f"Permission denied to read the nexus file [{file_path}]. "
95
+ "Please check the permission of the file or the directory. "
96
+ "Consider using the `file_lock` parameter to avoid file locking "
97
+ "if the file system is mounted on a network file system. "
98
+ "and it is safe to read the file without locking."
99
+ ) from e
100
+
101
+ # Manually assign unit to the histogram detector mode data
102
+ img: sc.DataArray = dg['data']
103
+ if (original_unit := img.unit) != 'counts':
104
+ img.unit = 'counts'
105
+ warnings.warn(
106
+ f"The unit of the histogram detector data is [{original_unit}]. "
107
+ f"It is expected to be [{img.unit}]. "
108
+ f"The loader manually assigned the unit to be [{img.unit}].",
109
+ stacklevel=0,
110
+ )
111
+ return HistogramModeDetector(dg)
112
+
113
+
114
+ MinDim1 = NewType("MinDim1", sc.Variable | None)
115
+ """Minimum value of the first dimension."""
116
+ MaxDim1 = NewType("MaxDim1", sc.Variable | None)
117
+ """Maximum value of the first dimension."""
118
+ MinDim2 = NewType("MinDim2", sc.Variable | None)
119
+ """Minimum value of the second dimension."""
120
+ MaxDim2 = NewType("MaxDim2", sc.Variable | None)
121
+
122
+
123
+ def _make_coord_if_needed(da: sc.DataArray, dim: str) -> None:
124
+ if dim not in da.coords.keys():
125
+ da.coords[dim] = sc.arange(dim=dim, start=0, stop=da.sizes[dim])
126
+
127
+
128
+ def separate_detector_images(
129
+ dg: HistogramModeDetector,
130
+ min_dim_1: MinDim1,
131
+ max_dim_1: MaxDim1,
132
+ min_dim_2: MinDim2,
133
+ max_dim_2: MaxDim2,
134
+ ) -> HistogramModeDetectorData:
135
+ da: sc.DataArray = sc.sort(dg['data'], 'time')
136
+ # Assign position coordinates to the detector data
137
+ _make_coord_if_needed(da, DIM1_COORD_NAME)
138
+ _make_coord_if_needed(da, DIM2_COORD_NAME)
139
+ # Crop the detector data by the given coordinates
140
+ da = da[DIM1_COORD_NAME, min_dim_1:max_dim_1][DIM2_COORD_NAME, min_dim_2:max_dim_2]
141
+ return HistogramModeDetectorData(da)
142
+
143
+
144
+ def separate_image_key_logs(*, dg: HistogramModeDetector) -> ImageKeyLogs:
145
+ return ImageKeyLogs(sc.sort(dg['image_key']['value'], key='time'))
146
+
147
+
148
+ def load_nexus_rotation_logs(
149
+ file_path: FilePath,
150
+ motion_sensor_name: RotationMotionSensorName,
151
+ locking: FileLock = DEFAULT_FILE_LOCK,
152
+ ) -> RotationLogs:
153
+ log_path = f"entry/instrument/{motion_sensor_name}/rotation_stage_readback"
154
+ with snx.File(file_path, mode="r", locking=locking) as f:
155
+ return RotationLogs(f[log_path][()]['value'])
156
+
157
+
158
+ def derive_log_coord_by_range(da: sc.DataArray, log: sc.DataArray) -> sc.Variable:
159
+ """Sort the logs by time and decide which log entry corresponds to each time bin.
160
+
161
+ It assumes a log value is valid until the next log entry.
162
+ """
163
+ log = sc.sort(log, TIME_COORD_NAME)
164
+ indices = [*log.coords[TIME_COORD_NAME], None]
165
+ return sc.concat(
166
+ [
167
+ sc.broadcast(
168
+ log.data[TIME_COORD_NAME, i_time],
169
+ dims=[TIME_COORD_NAME],
170
+ shape=(da[TIME_COORD_NAME, start:end].sizes[TIME_COORD_NAME],),
171
+ )
172
+ for i_time, (start, end) in enumerate(pairwise(indices))
173
+ ],
174
+ TIME_COORD_NAME,
175
+ )
176
+
177
+
178
+ def _slice_da_by_keys(
179
+ da: sc.DataArray, image_keys: ImageKeyLogs, image_key: ImageKey
180
+ ) -> Generator[sc.DataArray, None, None]:
181
+ matching_value = image_key.as_index(image_key, image_keys)
182
+ time_coord = image_keys.coords[TIME_COORD_NAME]
183
+ time_intervals = image_keys.sizes[TIME_COORD_NAME]
184
+ for i_time, (cur_time, image_key) in enumerate(
185
+ zip(time_coord, image_keys.data, strict=True)
186
+ ):
187
+ if image_key == matching_value:
188
+ if i_time == time_intervals - 1:
189
+ yield da[TIME_COORD_NAME, cur_time:]
190
+ else:
191
+ next_time = time_coord[i_time + 1]
192
+ yield da[TIME_COORD_NAME, cur_time:next_time]
193
+
194
+
195
+ def _retrieve_image_stacks_by_key(
196
+ da: HistogramModeDetectorData, image_keys: ImageKeyLogs, image_key: ImageKey
197
+ ) -> sc.DataArray:
198
+ images = [
199
+ sliced_da
200
+ for sliced_da in _slice_da_by_keys(da, image_keys, image_key)
201
+ if da.sizes[TIME_COORD_NAME] > 0
202
+ ]
203
+ if len(images) == 0:
204
+ raise ValueError(f"No images found for {image_key}.")
205
+ elif len(images) == 1:
206
+ return images[0]
207
+ return sc.concat(images, 'time')
208
+
209
+
210
+ AllImageStacks = NewType("AllImageStacks", dict[ImageKey, sc.DataArray])
211
+
212
+
213
+ def separate_image_by_keys(
214
+ da: HistogramModeDetectorData,
215
+ image_keys: ImageKeyLogs,
216
+ ) -> AllImageStacks:
217
+ return AllImageStacks(
218
+ {key: _retrieve_image_stacks_by_key(da, image_keys, key) for key in ImageKey}
219
+ )
220
+
221
+
222
+ def retrieve_open_beam_images(
223
+ da: HistogramModeDetectorData, image_keys: ImageKeyLogs
224
+ ) -> OpenBeamImageStacks:
225
+ return OpenBeamImageStacks(
226
+ _retrieve_image_stacks_by_key(da, image_keys, ImageKey.OPEN_BEAM)
227
+ )
228
+
229
+
230
+ def retrieve_dark_current_images(
231
+ da: HistogramModeDetectorData, image_keys: ImageKeyLogs
232
+ ) -> DarkCurrentImageStacks:
233
+ return DarkCurrentImageStacks(
234
+ _retrieve_image_stacks_by_key(da, image_keys, ImageKey.DARK_CURRENT)
235
+ )
236
+
237
+
238
+ def retrieve_sample_images(
239
+ da: HistogramModeDetectorData, image_keys: ImageKeyLogs
240
+ ) -> RawSampleImageStacks:
241
+ return RawSampleImageStacks(
242
+ _retrieve_image_stacks_by_key(da, image_keys, ImageKey.SAMPLE)
243
+ )
244
+
245
+
246
+ def apply_logs_as_coords(
247
+ samples: RawSampleImageStacks, rotation_angles: RotationLogs
248
+ ) -> SampleImageStacksWithLogs:
249
+ # Make sure the data has the same range as the rotation angle coordinate
250
+ min_log_time = rotation_angles.coords[TIME_COORD_NAME].min(TIME_COORD_NAME)
251
+ sliced = samples[TIME_COORD_NAME, min_log_time:].copy(deep=False)
252
+ if sliced.sizes != samples.sizes:
253
+ warnings.warn(
254
+ "The sample data has been sliced to match the rotation angle coordinate.",
255
+ stacklevel=0,
256
+ )
257
+ rotation_angle_coord = derive_log_coord_by_range(samples, rotation_angles)
258
+ sliced.coords['rotation_angle'] = rotation_angle_coord
259
+ return SampleImageStacksWithLogs(sliced)
260
+
261
+
262
+ DEFAULT_IMAGE_NAME_PREFIX_MAP = {
263
+ ImageKey.SAMPLE: "sample",
264
+ ImageKey.DARK_CURRENT: "dc",
265
+ ImageKey.OPEN_BEAM: "ob",
266
+ }
267
+
268
+
269
+ def dummy_progress_wrapper(core_iterator: Iterable) -> Iterable:
270
+ yield from core_iterator
271
+
272
+
273
+ def _save_merged_images(
274
+ *, image_stacks: SampleImageStacksWithLogs, image_prefix: str, output_dir: Path
275
+ ) -> None:
276
+ image_path = output_dir / Path(
277
+ f"{image_prefix}_0000_{image_stacks.sizes['time']:04d}.tiff"
278
+ )
279
+ imwrite(image_path, image_stacks.values)
280
+
281
+
282
+ def _save_individual_images(
283
+ *,
284
+ image_stacks: SampleImageStacksWithLogs,
285
+ image_prefix: str,
286
+ output_dir: Path,
287
+ progress_wrapper: Callable[[Iterable], Iterable] = dummy_progress_wrapper,
288
+ ) -> None:
289
+ for i_image in progress_wrapper(range(image_stacks.sizes['time'])):
290
+ cur_image = image_stacks['time', i_image]
291
+ image_path = output_dir / Path(f"{image_prefix}_{i_image:04d}.tiff")
292
+ imwrite(image_path, cur_image.values)
293
+
294
+
295
+ def _validate_output_dir(output_dir: str | Path) -> None:
296
+ output_dir = Path(output_dir)
297
+ if not output_dir.exists():
298
+ # make sure the output directory exists
299
+ output_dir.mkdir(parents=True, exist_ok=False)
300
+ elif not output_dir.is_dir():
301
+ raise ValueError(f"Output directory {output_dir} is not a directory.")
302
+ elif next(output_dir.iterdir(), None) is not None:
303
+ raise RuntimeError(f"Output directory {output_dir} is not empty.")
304
+
305
+
306
+ def export_image_stacks_as_tiff(
307
+ *,
308
+ output_dir: str | Path,
309
+ image_stacks: AllImageStacks,
310
+ merge_image_by_key: bool,
311
+ overwrite: bool,
312
+ progress_wrapper: Callable[[Iterable], Iterable] = dummy_progress_wrapper,
313
+ image_prefix_map: dict[ImageKey, str] = DEFAULT_IMAGE_NAME_PREFIX_MAP,
314
+ ) -> None:
315
+ """Save images into disk.
316
+
317
+ Parameters
318
+ ----------
319
+ output_dir:
320
+ Output directory to save images.
321
+
322
+ image_stacks:
323
+ Image stacks to save.
324
+
325
+ merge_image_by_key:
326
+ Flag to merge images into one file.
327
+
328
+ overwrite:
329
+ Flag to overwrite existing files.
330
+ If True, it will clear the output directory before saving images.
331
+
332
+ image_prefix_map:
333
+ Map of image name prefixes to their corresponding image key.
334
+
335
+ """
336
+ # Remove existing files if overwrite is True
337
+ if (
338
+ overwrite
339
+ and (output_path := Path(output_dir)).exists()
340
+ and output_path.is_dir()
341
+ ):
342
+ for file in output_path.iterdir():
343
+ file.unlink()
344
+
345
+ _validate_output_dir(output_path)
346
+
347
+ for image_key, cur_images in progress_wrapper(image_stacks.items()):
348
+ if merge_image_by_key:
349
+ _save_merged_images(
350
+ image_stacks=SampleImageStacksWithLogs(cur_images),
351
+ image_prefix=image_prefix_map[image_key],
352
+ output_dir=output_path,
353
+ )
354
+ else:
355
+ _save_individual_images(
356
+ image_stacks=SampleImageStacksWithLogs(cur_images),
357
+ image_prefix=image_prefix_map[image_key],
358
+ output_dir=output_path,
359
+ progress_wrapper=progress_wrapper,
360
+ )
@@ -0,0 +1,262 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+
4
+ import warnings
5
+ from typing import NewType
6
+
7
+ import scipp as sc
8
+
9
+ from .io import (
10
+ TIME_COORD_NAME,
11
+ DarkCurrentImageStacks,
12
+ OpenBeamImageStacks,
13
+ SampleImageStacksWithLogs,
14
+ )
15
+
16
+ AverageBackgroundPixelCounts = NewType("AverageBackgroundPixelCounts", sc.Variable)
17
+ """mean(background)."""
18
+ AverageSamplePixelCounts = NewType("AverageSamplePixelCounts", sc.Variable)
19
+ """mean(sample)."""
20
+ ScaleFactor = NewType("ScaleFactor", sc.Variable)
21
+ """AverageBackgroundPixelCounts / AverageSamplePixelCounts."""
22
+
23
+ OpenBeamImage = NewType("OpenBeamImage", sc.DataArray)
24
+ """Open beam image. mean(OpenBeam)"""
25
+ DarkCurrentImage = NewType("DarkCurrentImage", sc.DataArray)
26
+ """Dark current image. mean(DarkCurrentImages)"""
27
+ CleansedOpenBeamImage = NewType("CleansedOpenBeamImage", sc.DataArray)
28
+ """OpenBeam - DarkCrrent"""
29
+ CleansedSampleImages = NewType("CleansedSampleImages", sc.DataArray)
30
+ """SampleImageStack - DarkCurrent"""
31
+ SampleImageStacks = NewType("SampleImageStacks", sc.DataArray)
32
+ """Sample image stack ready to be used for normalization."""
33
+ BackgroundImage = NewType("BackgroundImage", sc.DataArray)
34
+ """Background image ready to be used for normalization."""
35
+ NormalizedSampleImages = NewType("NormalizedSampleImages", sc.DataArray)
36
+ """Normalized sample image stack. SampleImages / Background * ScaleFactor"""
37
+
38
+
39
+ BackgroundPixelThreshold = NewType("BackgroundPixelThreshold", sc.Variable)
40
+ """Threshold of the background pixel values."""
41
+ SamplePixelThreshold = NewType("SamplePixelThreshold", sc.Variable)
42
+ """Threshold of the sample pixel values."""
43
+
44
+
45
+ def _warn_constant_exposure_time(target: str) -> None:
46
+ warning_message = f"Computing {target.strip()} assuming constant exposure time."
47
+ warnings.warn(warning_message, stacklevel=1)
48
+
49
+
50
+ def _mean_all_dims(data: sc.Variable) -> sc.Variable:
51
+ """Calculate the mean of all dimensions one by one to avoid overflow."""
52
+ if data.shape == (): # scalar
53
+ return data
54
+ return _mean_all_dims(data.mean(dim=data.dims[0]))
55
+
56
+
57
+ def average_open_beam_images(open_beam: OpenBeamImageStacks) -> OpenBeamImage:
58
+ """Average the open beam image stack.
59
+
60
+ .. math::
61
+
62
+ OpenBeam = mean(OpenBeam, dim=\\text{'time'})
63
+
64
+ """
65
+ _warn_constant_exposure_time("average open beam image")
66
+ return OpenBeamImage(sc.mean(open_beam, dim=TIME_COORD_NAME))
67
+
68
+
69
+ def average_dark_current_images(
70
+ dark_current: DarkCurrentImageStacks,
71
+ ) -> DarkCurrentImage:
72
+ """Average the dark current image stack.
73
+
74
+ .. math::
75
+
76
+ DarkCurrent = mean(DarkCurrent, dim=\\text{'time'})
77
+
78
+ """
79
+ _warn_constant_exposure_time("average dark current image")
80
+ return DarkCurrentImage(sc.mean(dark_current, dim=TIME_COORD_NAME))
81
+
82
+
83
+ def cleanse_open_beam_image(
84
+ open_beam: OpenBeamImage, dark_current: DarkCurrentImage
85
+ ) -> CleansedOpenBeamImage:
86
+ """Calculate the background image stack.
87
+
88
+ .. math::
89
+
90
+ Background = OpenBeam - DarkCurrent
91
+
92
+ Parameters
93
+ ----------
94
+ open_beam:
95
+ Open beam image.
96
+
97
+ dark_current:
98
+ Dark current image.
99
+
100
+ """
101
+ return CleansedOpenBeamImage(open_beam - dark_current)
102
+
103
+
104
+ def cleanse_sample_images(
105
+ sample_images: SampleImageStacksWithLogs, dark_current: DarkCurrentImage
106
+ ) -> CleansedSampleImages:
107
+ """Cleanse the sample image stack.
108
+
109
+ We subtract the averaged dark current image from the sample image stack.
110
+
111
+ .. math::
112
+
113
+ CleansedSample_{i} = Sample_{i} - mean(DarkCurrent, dim=\\text{'time'})
114
+
115
+ \\text{where } i \\text{ is an index of an image.}
116
+
117
+ Parameters
118
+ ------
119
+ sample_images:
120
+ Sample image stack.
121
+
122
+ dark_current:
123
+ Dark current image.
124
+
125
+ """
126
+ return CleansedSampleImages(sample_images - dark_current)
127
+
128
+
129
+ def average_background_pixel_counts(
130
+ background: BackgroundImage,
131
+ ) -> AverageBackgroundPixelCounts:
132
+ """Calculate the average background pixel counts."""
133
+ _warn_constant_exposure_time("average background pixel counts")
134
+ return AverageBackgroundPixelCounts(background.data.mean())
135
+
136
+
137
+ def average_sample_pixel_counts(
138
+ sample_images: SampleImageStacks,
139
+ ) -> AverageSamplePixelCounts:
140
+ """Calculate the average sample pixel counts.
141
+
142
+ Notes
143
+ -----
144
+ For performance reason, we tried calculating
145
+ the mean of sample images and dark current images
146
+ first and subtract them afterwards,
147
+ instead of using the subtracted image stack directly.
148
+ It was to utilize that the integer operation is faster than
149
+ the floating point operation.
150
+
151
+ However, we are ceiling negative values to zero
152
+ after cleansing the sample images with dark current images.
153
+
154
+ Therefore we need to calculate the mean of the cleansed sample images
155
+ to avoid negative values in the average calculation.
156
+
157
+ We don't calculate ``mean(cleansed_sample_images)`` at once
158
+ since it is a large array and it may cause memory issues.
159
+
160
+ There was an example of 361 images of 2048x2048 pixels with 32-bit integer data
161
+ exceeded the limit of the maximum integer so the average calculation failed
162
+ and returned negative values.
163
+ """
164
+ _warn_constant_exposure_time("average sample pixel counts")
165
+ return AverageSamplePixelCounts(_mean_all_dims(sample_images.data))
166
+
167
+
168
+ def calculate_scale_factor(
169
+ average_bg: AverageBackgroundPixelCounts, average_sample: AverageSamplePixelCounts
170
+ ) -> ScaleFactor:
171
+ """Calculate the scale factor from average background and sample pixel counts.
172
+
173
+ .. math::
174
+
175
+ ScaleFactor = AverageBackgroundPixelCounts / AverageSamplePixelCounts
176
+
177
+ """
178
+ return ScaleFactor(average_bg / average_sample)
179
+
180
+
181
+ def apply_threshold_to_sample_images(
182
+ samples: CleansedSampleImages, sample_threshold: SamplePixelThreshold
183
+ ) -> SampleImageStacks:
184
+ """Apply the threshold to the sample image stack.
185
+
186
+ Parameters
187
+ ----------
188
+ samples:
189
+ Sample image stack.
190
+
191
+ sample_threshold:
192
+ Threshold for the sample pixel values.
193
+ Any pixel values less than ``sample_threshold``
194
+ are replaced with ``sample_threshold``.
195
+
196
+ """
197
+ samples = CleansedSampleImages(samples.copy(deep=False))
198
+ samples.data = sc.where(
199
+ samples.data < sample_threshold, sample_threshold, samples.data
200
+ )
201
+ return SampleImageStacks(samples)
202
+
203
+
204
+ def apply_threshold_to_background_image(
205
+ background: CleansedOpenBeamImage, background_threshold: BackgroundPixelThreshold
206
+ ) -> BackgroundImage:
207
+ """Apply the threshold to the background image.
208
+
209
+ Parameters
210
+ ----------
211
+ background:
212
+ Background image.
213
+
214
+ background_threshold:
215
+ Threshold for the background pixel values.
216
+ Any pixel values less than ``background_threshold``
217
+ are replaced with ``background_threshold``.
218
+
219
+ """
220
+ background = CleansedOpenBeamImage(background.copy(deep=False))
221
+ background.data = sc.where(
222
+ background.data < background_threshold, background_threshold, background.data
223
+ )
224
+ return BackgroundImage(background)
225
+
226
+
227
+ def normalize_sample_images(
228
+ *, samples: SampleImageStacks, background: BackgroundImage, factor: ScaleFactor
229
+ ) -> NormalizedSampleImages:
230
+ """Normalize the sample image stack.
231
+
232
+ .. math::
233
+
234
+ NormalizedImages = SampleImages / Background * ScaleFactor
235
+
236
+ Parameters
237
+ ----------
238
+
239
+ samples:
240
+ Sample image stack to be normalized.
241
+
242
+ background:
243
+ Background image to be used for normalization.
244
+
245
+ factor:
246
+ Scale factor for the normalization.
247
+
248
+
249
+ Raises
250
+ ------
251
+ ValueError:
252
+ If the scale factor is negative.
253
+ It is for the safety of the calculation on short data type.
254
+ Depending on how you calculate the scale factor,
255
+ the operation might fail and return negative values.
256
+
257
+ """
258
+ if factor < 0:
259
+ raise ValueError(f"Scale factor must be positive, but got {factor}.")
260
+ _warn_constant_exposure_time("normalized sample image stack")
261
+ # For performance reason, background / factor is calculated first.
262
+ return NormalizedSampleImages(samples / (background / factor))
ess/imaging/py.typed ADDED
File without changes
ess/imaging/types.py ADDED
@@ -0,0 +1,24 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ from typing import NewType
4
+
5
+ import scipp as sc
6
+
7
+ ImageDetectorName = NewType('ImageDetectorName', str)
8
+ """Histogram mode detector name."""
9
+
10
+ ImageKeyLogs = NewType('ImageKeyLogs', sc.DataArray)
11
+ """Image key logs."""
12
+
13
+ RotationMotionSensorName = NewType('RotationMotionSensorName', str)
14
+ """Rotation sensor name."""
15
+
16
+ RotationLogs = NewType('RotationLogs', sc.DataArray)
17
+ """Rotation logs data."""
18
+
19
+ HistogramModeDetectorsPath = NewType('HistogramModeDetectorsPath', str)
20
+ """Path to the histogram mode detectors in a nexus file."""
21
+
22
+ DEFAULT_HISTOGRAM_PATH = HistogramModeDetectorsPath(
23
+ "/entry/instrument/histogram_mode_detectors"
24
+ )
@@ -0,0 +1,121 @@
1
+ # SPDX-License-Identifier: BSD-3-Clause
2
+ # Copyright (c) 2024 Scipp contributors (https://github.com/scipp)
3
+ import sciline as sl
4
+ import scipp as sc
5
+
6
+ from .io import (
7
+ DEFAULT_FILE_LOCK,
8
+ FileLock,
9
+ MaxDim1,
10
+ MaxDim2,
11
+ MinDim1,
12
+ MinDim2,
13
+ apply_logs_as_coords,
14
+ load_nexus_histogram_mode_detector,
15
+ load_nexus_rotation_logs,
16
+ retrieve_dark_current_images,
17
+ retrieve_open_beam_images,
18
+ retrieve_sample_images,
19
+ separate_detector_images,
20
+ separate_image_by_keys,
21
+ separate_image_key_logs,
22
+ )
23
+ from .normalize import (
24
+ BackgroundPixelThreshold,
25
+ SamplePixelThreshold,
26
+ apply_threshold_to_background_image,
27
+ apply_threshold_to_sample_images,
28
+ average_background_pixel_counts,
29
+ average_dark_current_images,
30
+ average_open_beam_images,
31
+ average_sample_pixel_counts,
32
+ calculate_scale_factor,
33
+ cleanse_open_beam_image,
34
+ cleanse_sample_images,
35
+ normalize_sample_images,
36
+ )
37
+ from .types import (
38
+ DEFAULT_HISTOGRAM_PATH,
39
+ HistogramModeDetectorsPath,
40
+ ImageDetectorName,
41
+ RotationMotionSensorName,
42
+ )
43
+
44
+ _IO_PROVIDERS = (
45
+ apply_logs_as_coords,
46
+ load_nexus_histogram_mode_detector,
47
+ load_nexus_rotation_logs,
48
+ retrieve_dark_current_images,
49
+ retrieve_open_beam_images,
50
+ retrieve_sample_images,
51
+ separate_detector_images,
52
+ separate_image_by_keys,
53
+ separate_image_key_logs,
54
+ )
55
+ _NORMALIZATION_PROVIDERS = (
56
+ apply_threshold_to_background_image,
57
+ apply_threshold_to_sample_images,
58
+ average_background_pixel_counts,
59
+ average_dark_current_images,
60
+ average_open_beam_images,
61
+ average_sample_pixel_counts,
62
+ calculate_scale_factor,
63
+ cleanse_open_beam_image,
64
+ cleanse_sample_images,
65
+ normalize_sample_images,
66
+ )
67
+ _DEFAULT_BACKGROUND_THRESHOLD = BackgroundPixelThreshold(sc.scalar(1.0, unit="counts"))
68
+ _DEFAULT_SAMPLE_THRESHOLD = SamplePixelThreshold(sc.scalar(0.0, unit="counts"))
69
+
70
+
71
+ def YmirImageNormalizationWorkflow() -> sl.Pipeline:
72
+ """
73
+ Ymir histogram mode imaging normalization workflow.
74
+
75
+ Default Normalization Formula
76
+ -----------------------------
77
+
78
+ .. math::
79
+
80
+ NormalizedSample_{i} = SampleImageStacks_{i} / BackgroundImage * ScaleFactor
81
+
82
+
83
+ .. math::
84
+
85
+ ScaleFactor = AverageBackgroundPixelCounts / AverageSamplePixelCounts
86
+
87
+
88
+ .. math::
89
+
90
+ SampleImageStacks_{i} = Sample_{i} - mean(DarkCurrent, dim=\\text{'time'})
91
+
92
+ \\text{where } i \\text{ is an index of an image.}
93
+
94
+ \\text{Pixel values less than sample_threshold}
95
+
96
+ \\text{ are replaced with sample_threshold}.
97
+
98
+ .. math::
99
+
100
+ BackgroundImage = mean(OpenBeam, dim=\\text{'time'})
101
+ - mean(DarkCurrent, dim=\\text{'time'})
102
+
103
+ \\text{Pixel values less than } \\text{background_threshold}
104
+
105
+ \\text{ are replaced with } \\text{background_threshold}.
106
+ """
107
+ return sl.Pipeline(
108
+ (*_IO_PROVIDERS, *_NORMALIZATION_PROVIDERS),
109
+ params={
110
+ MinDim1: MinDim1(None),
111
+ MaxDim1: MaxDim1(None),
112
+ MinDim2: MinDim2(None),
113
+ MaxDim2: MaxDim2(None),
114
+ HistogramModeDetectorsPath: DEFAULT_HISTOGRAM_PATH,
115
+ ImageDetectorName: ImageDetectorName('orca'),
116
+ RotationMotionSensorName: RotationMotionSensorName('motion_cabinet_2'),
117
+ BackgroundPixelThreshold: _DEFAULT_BACKGROUND_THRESHOLD,
118
+ SamplePixelThreshold: _DEFAULT_SAMPLE_THRESHOLD,
119
+ FileLock: DEFAULT_FILE_LOCK,
120
+ },
121
+ )
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2024, Scipp contributors (https://github.com/scipp)
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,80 @@
1
+ Metadata-Version: 2.1
2
+ Name: essimaging
3
+ Version: 24.9.0
4
+ Summary: Imaging data reduction for the European Spallation Source
5
+ Author: Scipp contributors
6
+ License: BSD 3-Clause License
7
+
8
+ Copyright (c) 2024, Scipp contributors (https://github.com/scipp)
9
+ All rights reserved.
10
+
11
+ Redistribution and use in source and binary forms, with or without
12
+ modification, are permitted provided that the following conditions are met:
13
+
14
+ 1. Redistributions of source code must retain the above copyright notice, this
15
+ list of conditions and the following disclaimer.
16
+
17
+ 2. Redistributions in binary form must reproduce the above copyright notice,
18
+ this list of conditions and the following disclaimer in the documentation
19
+ and/or other materials provided with the distribution.
20
+
21
+ 3. Neither the name of the copyright holder nor the names of its
22
+ contributors may be used to endorse or promote products derived from
23
+ this software without specific prior written permission.
24
+
25
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
26
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
27
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
28
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
29
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
30
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
31
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
32
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
33
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
34
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
35
+
36
+ Project-URL: Bug Tracker, https://github.com/scipp/essimaging/issues
37
+ Project-URL: Documentation, https://scipp.github.io/essimaging
38
+ Project-URL: Source, https://github.com/scipp/essimaging
39
+ Classifier: Intended Audience :: Science/Research
40
+ Classifier: License :: OSI Approved :: BSD License
41
+ Classifier: Natural Language :: English
42
+ Classifier: Operating System :: OS Independent
43
+ Classifier: Programming Language :: Python :: 3
44
+ Classifier: Programming Language :: Python :: 3 :: Only
45
+ Classifier: Programming Language :: Python :: 3.10
46
+ Classifier: Programming Language :: Python :: 3.11
47
+ Classifier: Programming Language :: Python :: 3.12
48
+ Classifier: Topic :: Scientific/Engineering
49
+ Classifier: Typing :: Typed
50
+ Requires-Python: >=3.10
51
+ Description-Content-Type: text/markdown
52
+ License-File: LICENSE
53
+ Requires-Dist: dask
54
+ Requires-Dist: graphviz
55
+ Requires-Dist: plopp[all]
56
+ Requires-Dist: sciline >=23.9.1
57
+ Requires-Dist: scipp >=23.8.0
58
+ Requires-Dist: scippnexus >=23.11.1
59
+ Requires-Dist: essreduce
60
+ Requires-Dist: tifffile
61
+ Provides-Extra: test
62
+ Requires-Dist: pytest ; extra == 'test'
63
+ Requires-Dist: pooch ; extra == 'test'
64
+
65
+ [![Contributor Covenant](https://img.shields.io/badge/Contributor%20Covenant-2.1-4baaaa.svg)](CODE_OF_CONDUCT.md)
66
+ [![PyPI badge](http://img.shields.io/pypi/v/essimaging.svg)](https://pypi.python.org/pypi/essimaging)
67
+ [![Anaconda-Server Badge](https://anaconda.org/scipp/essimaging/badges/version.svg)](https://anaconda.org/scipp/essimaging)
68
+ [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](LICENSE)
69
+
70
+ # ESSimaging
71
+
72
+ ## About
73
+
74
+ Imaging data reduction for the European Spallation Source
75
+
76
+ ## Installation
77
+
78
+ ```sh
79
+ python -m pip install essimaging
80
+ ```
@@ -0,0 +1,12 @@
1
+ ess/imaging/__init__.py,sha256=x8w4NyzTnfZ3IDYuwfo_TftgHFUgh2maZBR0Ej82_8w,313
2
+ ess/imaging/data.py,sha256=OxKAm-NBSGOKWQ6AmHnnxMXBd3TR7WNyYqpERO_rZ9U,1060
3
+ ess/imaging/io.py,sha256=hZku7nr0KYmxSnXxONGGvUZgrGQAe31zP9NYh0DiRa0,12203
4
+ ess/imaging/normalize.py,sha256=uCI_PxoLN_M4wMxz0dYar-TosS4dzWvB0_jvGRcjxek,8084
5
+ ess/imaging/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ ess/imaging/types.py,sha256=HI3o6wcqRgszf5x7nx13xtjKqhyWNdf9C4qsIpu2Pvo,736
7
+ ess/imaging/workflow.py,sha256=ImrA_jH-NmBGF6JpjtN2ASqVcwUE8ieZF_ktw5_et0k,3455
8
+ essimaging-24.9.0.dist-info/LICENSE,sha256=nVEiume4Qj6jMYfSRjHTM2jtJ4FGu0g-5Sdh7osfEYw,1553
9
+ essimaging-24.9.0.dist-info/METADATA,sha256=1swB3bGlyob0_zrXzLJtZpX7RrTzlmv2dwa-0BuSYRU,3637
10
+ essimaging-24.9.0.dist-info/WHEEL,sha256=GV9aMThwP_4oNCtvEC2ec3qUYutgWeAzklro_0m4WJQ,91
11
+ essimaging-24.9.0.dist-info/top_level.txt,sha256=0JxTCgMKPLKtp14wb1-RKisQPQWX7i96innZNvHBr-s,4
12
+ essimaging-24.9.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.1.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ ess