Skip to content

Commit 112f707

Browse files
authored
Merge pull request #30 from ChengLabResearch/main
Update Branch with Binary Output Fixes
2 parents 1f270df + 710a770 commit 112f707

File tree

5 files changed

+104
-40
lines changed

5 files changed

+104
-40
lines changed

python/ouroboros/helpers/files.py

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
from functools import partial
22
from multiprocessing.pool import ThreadPool
33
import os
4-
import shutil
5-
from threading import Thread
64

75
import numpy as np
86
from numpy.typing import ArrayLike
97
from pathlib import Path
10-
from tifffile import imread, TiffWriter, TiffFile
8+
from tifffile import TiffWriter, TiffFile
119
import time
1210

1311
from .shapes import DataShape
@@ -123,24 +121,33 @@ def num_digits_for_n_files(n: int) -> int:
123121
return len(str(n - 1))
124122

125123

126-
def np_convert(dtype: np.dtype, source: ArrayLike, normalize=True):
127-
if not normalize:
128-
return source.astype(dtype)
129-
if np.issubdtype(dtype, np.integer):
130-
dtype_range = np.iinfo(dtype).max - np.iinfo(dtype).min
124+
def np_convert(target_dtype: np.dtype, source: ArrayLike, normalize=True, safe_bool=False):
125+
""" TODO: Fix for Negative Values """
126+
if safe_bool and target_dtype == bool:
127+
return source.astype(target_dtype).astype(np.uint8)
128+
elif np.issubdtype(target_dtype, np.integer) and normalize:
129+
dtype_range = np.iinfo(target_dtype).max - np.iinfo(target_dtype).min
131130
source_range = np.max(source) - np.min(source)
132131

133132
# Avoid divide by 0, esp. as numpy segfaults when you do.
134133
if source_range == 0.0:
135134
source_range = 1.0
136135

137-
return (source * max(int(dtype_range / source_range), 1)).astype(dtype)
138-
elif np.issubdtype(dtype, np.floating):
139-
return source.astype(dtype)
136+
return (source * max(int(dtype_range / source_range), 1)).astype(target_dtype)
137+
elif np.issubdtype(target_dtype, np.floating) and normalize:
138+
source_range = np.max(source) - np.min(source)
139+
140+
# Avoid divide by 0, esp. as numpy segfaults when you do.
141+
if source_range == 0.0:
142+
source_range = 1.0
143+
144+
return (source / source_range).astype(target_dtype)
145+
else:
146+
return source.astype(target_dtype)
140147

141148

142149
def generate_tiff_write(write_func: callable, compression: str | None, micron_resolution: np.ndarray[float],
143-
backprojection_offset: np.ndarray, **kwargs):
150+
backprojection_offset: np.ndarray, **kwargs):
144151
# Volume cache resolution is in voxel size, but .tiff XY resolution is in voxels per unit, so we invert.
145152
resolution = [1.0 / voxel_size for voxel_size in micron_resolution[:2] * 0.0001]
146153
resolutionunit = "CENTIMETER"
@@ -217,6 +224,6 @@ def write_conv_vol(writer: callable, source_path, shape, dtype, *args, **kwargs)
217224
vol = volume_from_intermediates(source_path, shape)
218225
perf["Merge Volume"] = time.perf_counter() - start
219226
start = time.perf_counter()
220-
writer(*args, data=np_convert(dtype, vol.reshape(shape.Y, shape.X), False), **kwargs)
227+
writer(*args, data=np_convert(dtype, vol.reshape(shape.Y, shape.X), normalize=False, safe_bool=True), **kwargs)
221228
perf["Write Merged"] = time.perf_counter() - start
222229
return perf

python/ouroboros/helpers/slice.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -201,27 +201,37 @@ def backproject_box(bounding_box: BoundingBox, slice_rects: np.ndarray, slices:
201201
# No slices, just return
202202
return np.empty((0), dtype=np.uint32), np.empty(0, dtype=np.float32), np.empty(0, dtype=np.float32)
203203

204+
# Plot the backprojected X/Y/Z coordinates for each of the slice rects in this chunk.
205+
grid_call = partial(coordinate_grid, shape=slices[0].shape, floor=bounding_box.get_min(), flip=True)
206+
precise_points = np.concatenate(list(map(grid_call, slice_rects)))
207+
208+
# Flatten values to 1-Dimension Array for Efficient Allocation.
209+
# Use ZYX domain rather than XYZ as the former is what is written to disk.
204210
values = slices.flatten()
205211
zyx_shape = np.flip(bounding_box.get_shape())
206212
flat_shape = np.prod(zyx_shape)
207213

208-
grid_call = partial(coordinate_grid, shape=slices[0].shape, floor=bounding_box.get_min(), flip=True)
209-
precise_points = np.concatenate(list(map(grid_call, slice_rects)))
214+
# Determine minimum dtype for index of flattened shape.
215+
squish_type = np.min_scalar_type(flat_shape)
210216

217+
# Allocate the flattened data volume.
211218
volume = np.zeros((2, flat_shape), dtype=np.float32)
212-
squish_type = np.min_scalar_type(flat_shape)
213219

220+
# Get the top corner (integer) points and full weight matrix.
214221
points, weights = _points_and_weights(precise_points.reshape(-1, 3).T, zyx_shape, squish_type)
215222

223+
# Sequentially allocate the points and weights for each corner to the flattened array.
216224
for corner in np.array(list(np.ndindex(2, 2, 2))):
217225
w_values, c_weights = _apply_weights(values, weights, corner)
218226
point_inc = np.ravel_multi_index(corner, zyx_shape).astype(squish_type)
219227

220228
np.add.at(volume[0], points + point_inc, w_values)
221229
np.add.at(volume[1], points + point_inc, c_weights)
222230

231+
# Get indicies of the flattened Z-Y-X backprojected domain that have values.
223232
nz_vol = np.flatnonzero(volume[0])
224233

234+
# Return indicies and only the volume region with values.
225235
return nz_vol, volume[0, nz_vol].squeeze(), volume[1, nz_vol].squeeze()
226236

227237

python/ouroboros/helpers/volume_cache.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -252,22 +252,22 @@ def update_writable_rects(processed: np.ndarray, slice_rects: np.ndarray, min_di
252252
253253
Parameters:
254254
-----------
255-
processed (np.ndarray): Marker of which chunks are processed,
256-
by their (Z, Y, X) indicies.
255+
processed (np.ndarray): Marker of which chunks are processed,
256+
by their (Z, Y, X) indicies.
257257
slice_rects: All full-size slice rects from the straightened volume.
258258
min_dim (int): Minimum (z) dimension of the full object.
259259
writeable (np.ndarray): Tracker of writable Z-stacks (index 0 = z min_dim).
260-
Values: 0 (not writeable), 1 (writable), 2 (dispatched to writer).
260+
Values: 0 (not writeable), 1 (writable), 2 (dispatched to writer).
261261
chunk_size (int): Size of 3D chunk in (z) dimension.
262262
263263
Return:
264264
-------
265265
np.ndarray: Sorted values in the given dimension that ready to be written to.
266266
267267
"""
268-
# Each chunk covers part of chunk_size slice_rects in z (straightened) dimension,
269-
# except last may be shorter (so capped to length of slice_rects).
270-
# A full slice_rect is ready if all (y, x) chunks for it are processed.
268+
# Each chunk covers part of chunk_size slice_rects in z (straightened) dimension,
269+
# except last may be shorter (so capped to length of slice_rects).
270+
# A full slice_rect is ready if all (y, x) chunks for it are processed.
271271
processed_slices = np.repeat(np.all(processed, axis=(1, 2)), chunk_size)[:len(slice_rects)]
272272
if np.all(processed_slices):
273273
# All slice_rects processed, remaining z (backprojected) slices are to be written.

python/ouroboros/pipeline/backproject_pipeline.py

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -127,18 +127,25 @@ def _process(self, input_data: any) -> tuple[any, None] | tuple[None, any]:
127127
print(f"\nFront Projection Shape: {FPShape}")
128128
print(f"\nBack Projection Shape (Z/Y/X):{write_shape}")
129129

130-
pipeline_input.output_file_path = f"{config.output_file_name}_{'_'.join(map(str, full_bounding_box.get_min(np.uint32)))}"
130+
pipeline_input.output_file_path = (f"{config.output_file_name}_"
131+
f"{'_'.join(map(str, full_bounding_box.get_min(np.uint32)))}")
131132
folder_path = Path(config.output_file_folder, pipeline_input.output_file_path)
132133
folder_path.mkdir(exist_ok=True, parents=True)
133134

134135
i_path = Path(config.output_file_folder,
135136
f"{config.output_file_name}_t_{'_'.join(map(str, full_bounding_box.get_min(np.uint32)))}")
136137

137138
if config.make_single_file:
138-
is_big_tiff = calculate_gigabytes_from_dimensions(np.prod(write_shape), np.uint16) > 4 # Check Dtype
139+
is_big_tiff = calculate_gigabytes_from_dimensions(
140+
np.prod(write_shape),
141+
np.uint8 if config.make_backprojection_binary else np.uint16) > 4
139142
else:
140-
is_big_tiff = calculate_gigabytes_from_dimensions(np.prod(write_shape[1:]), np.uint16) > 4 # Check Dtype
143+
is_big_tiff = calculate_gigabytes_from_dimensions(
144+
np.prod(write_shape[1:]),
145+
np.uint8 if config.make_backprojection_binary else np.uint16) > 4
141146

147+
# Generate image writing function
148+
# Combining compression with binary images can cause issues.
142149
bp_offset = pipeline_input.backprojection_offset if config.backproject_min_bounding_box else None
143150
tif_write = partial(generate_tiff_write,
144151
compression=config.backprojection_compression,
@@ -205,7 +212,9 @@ def note_written(write_future):
205212
write_futures.append(write_executor.submit(
206213
write_conv_vol,
207214
tif_write(tifffile.imwrite), i_path.joinpath(f"i_{index:05}"),
208-
ImgSlice(*write_shape[1:]), np.uint16, folder_path.joinpath(f"{index:05}.tif")
215+
ImgSlice(*write_shape[1:]),
216+
bool if config.make_backprojection_binary else np.uint16,
217+
folder_path.joinpath(f"{index:05}.tif")
209218
))
210219
write_futures[-1].add_done_callback(note_written)
211220

@@ -265,8 +274,8 @@ def note_written(write_future):
265274
pipeline_input.backprojected_folder_path = folder_path
266275

267276
self.add_timing("export", time.perf_counter() - start)
268-
269-
if config.make_single_file:
277+
278+
if config.make_single_file:
270279
shutil.rmtree(folder_path)
271280

272281
return None
@@ -285,8 +294,13 @@ def process_chunk(
285294
start_total = time.perf_counter()
286295

287296
# Load the straightened volume
288-
straightened_volume = tifffile.memmap(straightened_volume_path, mode="r")
289-
durations["memmap"] = [time.perf_counter() - start_total]
297+
try:
298+
straightened_volume = tifffile.memmap(straightened_volume_path, mode="r")
299+
durations["memmap"] = [time.perf_counter() - start_total]
300+
except BaseException as be:
301+
print(f"Error loading Volume: {be} : {straightened_volume_path}")
302+
traceback.print_tb(be.__traceback__, file=sys.stderr)
303+
raise be
290304

291305
# Get the slices from the straightened volume Dumb but maybe bugfix?
292306
start = time.perf_counter()

python/test/helpers/test_files.py

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
from pathlib import Path
33

44
import numpy as np
5-
from tifffile import imwrite, TiffFile
65

76
from ouroboros.helpers.files import (
87
format_backproject_output_file,
@@ -169,14 +168,6 @@ def test_num_digits_for_n_files():
169168
assert result == 2
170169

171170

172-
def test_np_convert():
173-
float_data = np.linspace(0, 1, 16)
174-
int_data = np_convert(np.uint16, float_data)
175-
176-
assert np.all(int_data == np.arange(0, np.iinfo(np.uint16).max + 1, np.iinfo(np.uint16).max // 15))
177-
assert np.all(np_convert(np.float32, int_data) == int_data.astype(np.float32))
178-
179-
180171
def test_generate_tiff_write(tmp_path):
181172
micron_resolution = np.array([0.7, 0.7, 0.7])
182173
backprojection_offset = (55, 44, 77)
@@ -284,10 +275,52 @@ def test_increment_volume(tmp_path):
284275
assert np.allclose(volume[1, mapped_source[0]], np.sum(source_weights[[0, 2]]))
285276
assert np.all(np.nonzero(volume)[0] == np.array([0, 0, 1, 1]))
286277
assert np.all(np.nonzero(volume)[1] == np.array([3947, 3952, 3947, 3952]))
287-
278+
288279
assert not sample_path.exists()
289280

290281

282+
def test_np_convert_from_int():
283+
base = np.random.randint(0, 10, 6400).reshape(80, 80)
284+
285+
# Direct Conversion
286+
assert np.all(np_convert(np.float32, base, normalize=False) == base.astype(np.float32))
287+
288+
# Normalized Conversion
289+
assert np.all(np_convert(np.float32, base) == base.astype(np.float32) / (np.max(base) - np.min(base)))
290+
291+
# Safe Bool
292+
safe_bool = np_convert(bool, base, safe_bool=True)
293+
assert safe_bool.dtype == np.uint8
294+
assert np.all(safe_bool == (base > 0))
295+
296+
# Unsafe Bool - Raw bool datatype
297+
safe_bool = np_convert(bool, base, safe_bool=False)
298+
assert safe_bool.dtype == bool
299+
assert np.all(safe_bool == (base > 0))
300+
301+
302+
def test_np_convert_from_float():
303+
base = np.random.randint(0, 16, 6400).reshape(80, 80) * np.random.rand(80, 80)
304+
float_data = np.linspace(0, 1, 16)
305+
306+
# Direct Conversion
307+
assert np.all(np_convert(np.uint16, float_data, normalize=False) == [0] * 15 + [1])
308+
309+
# Normalized Conversion
310+
assert np.all(np_convert(np.uint16, float_data) ==
311+
np.arange(0, np.iinfo(np.uint16).max + 1, np.iinfo(np.uint16).max // 15))
312+
313+
# Safe Bool
314+
safe_bool = np_convert(bool, base, safe_bool=True)
315+
assert safe_bool.dtype == np.uint8
316+
assert np.all(safe_bool == (base > 0))
317+
318+
# Unsafe Bool - Raw bool datatype
319+
safe_bool = np_convert(bool, base, safe_bool=False)
320+
assert safe_bool.dtype == bool
321+
assert np.all(safe_bool == (base > 0))
322+
323+
291324
def test_volume_from_intermediates():
292325
pass
293326

0 commit comments

Comments
 (0)