Skip to content

Commit de7bbed

Browse files
authored
Merge pull request #257 from DiamondLightSource/optimize-stripe-removal-radway-87
Optimize `remove_all_stripe` and `remove_stripe_based_sorting`
2 parents 2102b0b + dc42aef commit de7bbed

6 files changed

Lines changed: 309 additions & 36 deletions

File tree

‎httomolibgpu/misc/sorting.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
# ---------------------------------------------------------------------------
4+
# Copyright 2026 Diamond Light Source Ltd.
5+
#
6+
# Licensed under the Apache License, Version 2.0 (the "License");
7+
# you may not use this file except in compliance with the License.
8+
# You may obtain a copy of the License at
9+
#
10+
# http://www.apache.org/licenses/LICENSE-2.0
11+
#
12+
# Unless required by applicable law or agreed to in writing, software
13+
# distributed under the License is distributed on an "AS IS" BASIS,
14+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either ecpress or implied.
15+
# See the License for the specific language governing permissions and
16+
# limitations under the License.
17+
# ---------------------------------------------------------------------------
18+
# Created By : Tomography Team at DLS <scientificsoftware@diamond.ac.uk>
19+
# Created Date: 22 April 2026
20+
# ---------------------------------------------------------------------------
21+
22+
from typing import Tuple
23+
import cupy as cp
24+
25+
26+
def argsort_with_reverse(
27+
data: cp.ndarray, axis: int = -1
28+
) -> Tuple[cp.ndarray, cp.ndarray]:
29+
"""
30+
Compute sorting indices for an 1D or 2D array, and efficiently compute the indices to revert the sort.
31+
"""
32+
dim = len(data.shape)
33+
if 1 <= dim <= 2:
34+
pass
35+
else:
36+
raise ValueError("only 1D and 2D arrays are supported")
37+
if axis < 0:
38+
axis = dim + axis
39+
if axis >= dim:
40+
raise ValueError("invalid axis")
41+
sort_indices = cp.argsort(data, axis=axis)
42+
reverse_sort_indices = cp.empty_like(sort_indices)
43+
if dim == 1:
44+
reverse_sort_indices[sort_indices] = cp.arange(0, data.size)
45+
elif axis == 0: # sort rows
46+
nrows, ncols = data.shape
47+
cols = cp.arange(ncols)[None, :]
48+
reverse_sort_indices[sort_indices, cols] = cp.arange(nrows)[:, None]
49+
else: # sort columns
50+
nrows, ncols = data.shape
51+
rows = cp.arange(nrows)[:, None]
52+
reverse_sort_indices[rows, sort_indices] = cp.arange(ncols)
53+
54+
return sort_indices, reverse_sort_indices

‎httomolibgpu/prep/stripe.py‎

Lines changed: 20 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import numpy as np
2424
import pywt
2525
from httomolibgpu import cupywrapper
26+
from httomolibgpu.misc.sorting import argsort_with_reverse
2627

2728
cp = cupywrapper.cp
2829
cupy_run = cupywrapper.cupy_run
@@ -116,21 +117,18 @@ def _rs_sort(sinogram, size, dim):
116117
"""
117118
Remove stripes using the sorting technique.
118119
"""
119-
sinogram = cp.transpose(sinogram)
120+
#: Sort each row of the sinogram by its grayscale values
121+
sortvals, sortvals_reverse = argsort_with_reverse(sinogram, axis=0)
120122

121-
#: Sort each column of the sinogram by its grayscale values
122-
#: Keep track of the sorting indices so we can reverse it below
123-
sortvals = cp.argsort(sinogram, axis=1)
124-
sortvals_reverse = cp.argsort(sortvals, axis=1)
125-
sino_sort = cp.take_along_axis(sinogram, sortvals, axis=1)
123+
sino_sort = cp.take_along_axis(sinogram, sortvals, axis=0)
126124

127125
#: Now apply the median filter on the sorted image along each row
128-
sino_sort = median_filter(sino_sort, (size, 1) if dim == 1 else (size, size))
126+
sino_sort = median_filter(sino_sort, (1, size) if dim == 1 else (size, size))
129127

130128
#: step 3: re-sort the smoothed image columns to the original rows
131-
sino_corrected = cp.take_along_axis(sino_sort, sortvals_reverse, axis=1)
129+
sino_corrected = cp.take_along_axis(sino_sort, sortvals_reverse, axis=0)
132130

133-
return cp.transpose(sino_corrected)
131+
return sino_corrected
134132

135133

136134
def remove_stripe_ti(
@@ -821,6 +819,7 @@ def remove_all_stripe(
821819
la_size: int = 61,
822820
sm_size: int = 21,
823821
dim: Literal[1, 2] = 1,
822+
normalize: bool = False,
824823
) -> cp.ndarray:
825824
"""
826825
Remove all types of stripe artifacts from sinogram using Nghia Vo's
@@ -839,6 +838,8 @@ def remove_all_stripe(
839838
Window size of the median filter to remove small-to-medium stripes.
840839
dim : {1, 2},
841840
Dimension of the window.
841+
normalize : bool,
842+
Controls whether to normalize while removing large stripes
842843
843844
Returns
844845
-------
@@ -859,10 +860,10 @@ def remove_all_stripe(
859860
__check_variable_type(dim, [int], "dim", [1, 2], methods_name)
860861
###################################
861862

862-
matindex = _create_matindex(data.shape[2], data.shape[0])
863863
for m in range(data.shape[1]):
864864
sino = data[:, m, :]
865-
sino = _rs_dead(sino, snr, la_size, matindex)
865+
sino = _rs_dead(sino, snr, la_size)
866+
sino = _rs_large(sino, snr, la_size, normalize)
866867
sino = _rs_sort(sino, sm_size, dim)
867868
sino = cp.nan_to_num(sino)
868869
data[:, m, :] = sino
@@ -910,15 +911,17 @@ def _detect_stripe(listdata, snr):
910911
return listmask
911912

912913

913-
def _rs_large(sinogram, snr, size, matindex, drop_ratio=0.1, norm=True):
914+
def _rs_large(sinogram, snr, size, normalize, drop_ratio=0.1):
914915
"""
915916
Remove large stripes.
916917
"""
917918
drop_ratio = max(min(drop_ratio, 0.8), 0) # = cp.clip(drop_ratio, 0.0, 0.8)
918919
nrow, ncol = sinogram.shape
919920
ndrop = int(0.5 * drop_ratio * nrow)
920-
sinosort = cp.sort(sinogram, axis=0)
921+
sort_indices, sort_indices_reverse = argsort_with_reverse(sinogram, axis=0)
922+
sinosort = cp.take_along_axis(sinogram, sort_indices, axis=0)
921923
sinosmooth = median_filter(sinosort, (1, size))
924+
922925
list1 = cp.mean(sinosort[ndrop : nrow - ndrop], axis=0)
923926
list2 = cp.mean(sinosmooth[ndrop : nrow - ndrop], axis=0)
924927
listfact = list1 / list2
@@ -927,30 +930,16 @@ def _rs_large(sinogram, snr, size, matindex, drop_ratio=0.1, norm=True):
927930
listmask = _detect_stripe(listfact, snr)
928931
listmask = binary_dilation(listmask, iterations=1).astype(listmask.dtype)
929932
matfact = cp.tile(listfact, (nrow, 1))
930-
# Normalize
931-
if norm is True:
933+
if normalize:
932934
sinogram = sinogram / matfact
933-
sinogram1 = cp.transpose(sinogram)
934-
matcombine = cp.asarray(cp.dstack((matindex, sinogram1)))
935-
936-
ids = cp.argsort(matcombine[:, :, 1], axis=1)
937-
matsort = matcombine.copy()
938-
matsort[:, :, 0] = cp.take_along_axis(matsort[:, :, 0], ids, axis=1)
939-
matsort[:, :, 1] = cp.take_along_axis(matsort[:, :, 1], ids, axis=1)
940-
941-
matsort[:, :, 1] = cp.transpose(sinosmooth)
942-
ids = cp.argsort(matsort[:, :, 0], axis=1)
943-
matsortback = matsort.copy()
944-
matsortback[:, :, 0] = cp.take_along_axis(matsortback[:, :, 0], ids, axis=1)
945-
matsortback[:, :, 1] = cp.take_along_axis(matsortback[:, :, 1], ids, axis=1)
946-
947-
sino_corrected = cp.transpose(matsortback[:, :, 1])
935+
sort_indices, sort_indices_reverse = argsort_with_reverse(sinogram, axis=0)
936+
sino_corrected = cp.take_along_axis(sinosmooth, sort_indices_reverse, axis=0)
948937
listxmiss = cp.where(listmask > 0.0)[0]
949938
sinogram[:, listxmiss] = sino_corrected[:, listxmiss]
950939
return sinogram
951940

952941

953-
def _rs_dead(sinogram, snr, size, matindex, norm=True):
942+
def _rs_dead(sinogram, snr, size):
954943
"""remove unresponsive and fluctuating stripes"""
955944
sinogram = cp.copy(sinogram) # Make it mutable
956945
nrow, _ = sinogram.shape
@@ -979,9 +968,6 @@ def _rs_dead(sinogram, snr, size, matindex, norm=True):
979968
sinogram[:, listx[ids]] - sinogram[:, listx[ids - 1]]
980969
)
981970

982-
# Remove residual stripes
983-
if norm is True:
984-
sinogram = _rs_large(sinogram, snr, size, matindex)
985971
return sinogram
986972

987973

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,8 @@ dev = [
6060
"imageio",
6161
"h5py",
6262
"pre-commit",
63-
"pyfftw"
63+
"pyfftw",
64+
"tomophantom"
6465
]
6566

6667

‎tests/test_misc/test_sorting.py‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import pytest
2+
import cupy as cp
3+
4+
from httomolibgpu.misc.sorting import argsort_with_reverse
5+
6+
7+
class TestArgsort1D:
8+
def test_1d_sorted(self):
9+
data = cp.array([1, 2, 3, 4, 5])
10+
sort_idx, rev_idx = argsort_with_reverse(data)
11+
cp.testing.assert_array_equal(sort_idx, cp.array([0, 1, 2, 3, 4]))
12+
cp.testing.assert_array_equal(data[sort_idx], data)
13+
cp.testing.assert_array_equal(rev_idx[sort_idx], cp.arange(5))
14+
15+
def test_1d_reverse_sorted(self):
16+
data = cp.array([5, 4, 3, 2, 1])
17+
sort_idx, rev_idx = argsort_with_reverse(data)
18+
cp.testing.assert_array_equal(sort_idx, cp.array([4, 3, 2, 1, 0]))
19+
cp.testing.assert_array_equal(data[sort_idx], cp.array([1, 2, 3, 4, 5]))
20+
cp.testing.assert_array_equal(rev_idx[sort_idx], cp.arange(5))
21+
22+
def test_1d_random(self):
23+
data = cp.array([10, 3, 7, 1, 9])
24+
sort_idx, rev_idx = argsort_with_reverse(data)
25+
sorted_data = data[sort_idx]
26+
assert cp.all(sorted_data[:-1] <= sorted_data[1:])
27+
cp.testing.assert_array_equal(rev_idx[sort_idx], cp.arange(5))
28+
29+
def test_1d_duplicates(self):
30+
data = cp.array([2, 1, 2, 1])
31+
sort_idx, rev_idx = argsort_with_reverse(data)
32+
sorted_data = data[sort_idx]
33+
assert cp.all(sorted_data[:-1] <= sorted_data[1:])
34+
cp.testing.assert_array_equal(rev_idx[sort_idx], cp.arange(4))
35+
36+
def test_1d_single_element(self):
37+
data = cp.array([42])
38+
sort_idx, rev_idx = argsort_with_reverse(data)
39+
cp.testing.assert_array_equal(sort_idx, cp.array([0]))
40+
cp.testing.assert_array_equal(rev_idx[sort_idx], cp.array([0]))
41+
42+
def test_1d_negative_axis(self):
43+
data = cp.array([3, 1, 2])
44+
sort_idx, rev_idx = argsort_with_reverse(data, axis=-1)
45+
cp.testing.assert_array_equal(sort_idx, cp.argsort(data))
46+
cp.testing.assert_array_equal(rev_idx[sort_idx], cp.arange(3))
47+
48+
49+
class TestArgsort2D:
50+
def test_2d_axis0_sorted(self):
51+
data = cp.array([[1, 2], [3, 4], [5, 6]])
52+
sort_idx, rev_idx = argsort_with_reverse(data, axis=0)
53+
sorted_data = data[sort_idx, cp.arange(data.shape[1])[None, :]]
54+
assert cp.all(sorted_data[:-1, :] <= sorted_data[1:, :])
55+
rows, cols = data.shape
56+
col_indices = cp.arange(cols)[None, :]
57+
cp.testing.assert_array_equal(
58+
rev_idx[sort_idx, col_indices], cp.tile(cp.arange(rows)[:, None], [1, 2])
59+
)
60+
61+
def test_2d_axis0_random(self):
62+
data = cp.array([[5, 1], [2, 4], [3, 6]])
63+
sort_idx, rev_idx = argsort_with_reverse(data, axis=0)
64+
rows, cols = data.shape
65+
col_indices = cp.arange(cols)[None, :]
66+
cp.testing.assert_array_equal(
67+
rev_idx[sort_idx, col_indices], cp.tile(cp.arange(rows)[:, None], [1, 2])
68+
)
69+
70+
def test_2d_axis0_negative_axis(self):
71+
data = cp.array([[1, 2], [3, 4]])
72+
sort_idx, rev_idx = argsort_with_reverse(data, axis=-2)
73+
rows, cols = data.shape
74+
col_indices = cp.arange(cols)[None, :]
75+
cp.testing.assert_array_equal(
76+
rev_idx[sort_idx, col_indices], cp.tile(cp.arange(rows)[:, None], [1, 2])
77+
)
78+
79+
def test_2d_axis1_sorted(self):
80+
data = cp.array([[1, 2, 3], [4, 5, 6]])
81+
sort_idx, rev_idx = argsort_with_reverse(data, axis=1)
82+
rows, cols = data.shape
83+
row_indices = cp.arange(rows)[:, None]
84+
cp.testing.assert_array_equal(
85+
rev_idx[row_indices, sort_idx], cp.tile(cp.arange(cols), [2, 1])
86+
)
87+
88+
def test_2d_axis1_random(self):
89+
data = cp.array([[3, 1, 2], [6, 4, 5]])
90+
sort_idx, rev_idx = argsort_with_reverse(data, axis=1)
91+
rows, cols = data.shape
92+
row_indices = cp.arange(rows)[:, None]
93+
cp.testing.assert_array_equal(
94+
rev_idx[row_indices, sort_idx], cp.tile(cp.arange(cols), [2, 1])
95+
)
96+
97+
def test_2d_axis1_negative_axis(self):
98+
data = cp.array([[1, 2], [3, 4]])
99+
sort_idx, rev_idx = argsort_with_reverse(data, axis=-1)
100+
rows, cols = data.shape
101+
row_indices = cp.arange(rows)[:, None]
102+
cp.testing.assert_array_equal(
103+
rev_idx[row_indices, sort_idx], cp.tile(cp.arange(cols), [2, 1])
104+
)
105+
106+
def test_2d_single_row(self):
107+
data = cp.array([[3, 1, 2]])
108+
sort_idx, rev_idx = argsort_with_reverse(data, axis=1)
109+
rows, cols = data.shape
110+
row_indices = cp.arange(rows)[:, None]
111+
cp.testing.assert_array_equal(
112+
rev_idx[row_indices, sort_idx], cp.arange(cols)[None, :]
113+
)
114+
115+
def test_2d_single_col(self):
116+
data = cp.array([[3], [1], [2]])
117+
sort_idx, rev_idx = argsort_with_reverse(data, axis=0)
118+
rows, cols = data.shape
119+
col_indices = cp.arange(cols)[None, :]
120+
cp.testing.assert_array_equal(
121+
rev_idx[sort_idx, col_indices], cp.arange(rows)[:, None]
122+
)
123+
124+
125+
class TestArgsortErrors:
126+
def test_invalid_dim_3d(self):
127+
data = cp.ones((2, 2, 2))
128+
with pytest.raises(ValueError):
129+
argsort_with_reverse(data)
130+
131+
def test_invalid_dim_0d(self):
132+
data = cp.array(1)
133+
with pytest.raises(ValueError):
134+
argsort_with_reverse(data)
135+
136+
def test_invalid_axis_1d(self):
137+
data = cp.array([1, 2, 3])
138+
with pytest.raises(ValueError):
139+
argsort_with_reverse(data, axis=1)
140+
141+
def test_invalid_axis_2d(self):
142+
data = cp.array([[1, 2], [3, 4]])
143+
with pytest.raises(ValueError):
144+
argsort_with_reverse(data, axis=2)

0 commit comments

Comments
 (0)