-
-
Notifications
You must be signed in to change notification settings - Fork 2.2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Added ND butterworth filter #5382
Conversation
Hello @din14970! Thanks for updating this PR. We checked the lines you've touched for PEP 8 issues, and found:
Comment last updated at 2021-06-20 17:07:46 UTC |
Historically we have used |
Ok, was a bit more complicated than I expected to make channel work for any axis so it looks a bit hacky. N dimensional images are supported and any axis index can be passed as channel index. By default it is assumed all axes are spatial dimensions, a For large n-dimensional images the creation of the Fourier mask may pose a memory issue due to the reliance on meshgrid. More memory friendly may be a "loopy" implementation but for this I would need numba as I'm not familiar with cython. Are there any plans to allow numba as a dependency? |
Also, I feel like a large portion of the |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @din14970. I left a first round of review comments here. A number of just simple style suggestions for the docstrings.
Note that you don't have to commit each suggestion individually, but can add them all to a single batch from the "Files changed" tab here on GitHub. Alternatively, you can manually apply them offline and push a commit the traditional way.
im = np.zeros((4, 4)) | ||
filtered = butterworth(im) | ||
assert filtered.shape == im.shape | ||
assert np.all(im == filtered) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let me think about other possible tests for correctness.
The current tests are testing that things run without crashing for a range of dimensions / channel axes, but aren't really checking any property of the filtered image or comparing to a reference result.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes I didn't have any good ideas on this. I thought first of constructing a dummy FFT by creating a zero array and then initializing a few pixels to 1, inverse fourrier transforming, then passing this image into the bw filter, then fourrier transforming it back. Unfortunately this no longer resembles the original FFT. I guess if one wisely constructs the FFT it should work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I came up with another basic test case that just verified highpass vs. lowpass behavior, but want to resolve the one remaining issue regarding preserve_range
first.
Also not entirely sure why the CI is failing, something to do with the docs I suppose |
Nice job updating this to use the rfftn and irfftn functions! In searching for references to use of this function in image processing, I sometimes saw the filter defined without any square root in the denominator. For example here, or in John C. Russ's "Image Processing Handbook" (3rd. ed.), where the filter equation is listed as: Other sources such as the Wikipedia article and this one or this one seem to agree with the form implemented here. Perhaps we should make the exact equation explicit in the Notes of the docstring here to avoid potential confusion over these differing conventions |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The nd implementation is gorgeous @din14970! regarding the memory concerns for large nD images, we typically leave that to later generations to address. 😂 It's much better to have a working and slow implementation merged to get further improvements down the line as needed.
There's a couple of comments from @grlee77 that need addressing (order
/sqrt
conventions), as well as the improved testing idea, but other than that this is ready for me.
Regarding testing, in many situations, a test is hard to define from first principles. In those cases, we typically use one or more of the following options:
- Run this for 1D arrays and for "2D multichannel" arrays (ie a series of 1D arrays), and compare it to the output from SciPy.
- Run this on a known image (e.g. a tiny crop
data.camera()
), check that the output makes sense, save it as a .npy file, and check against that. There's a few examples in e.g.skimage/restoration/tests
. Make this small so that they don't take up too much space. Converting to uint8 is also a useful trick for compression. - Do the same as above but compare to a 3rd party library.
@grlee77 I tried to dig into this a bit but am still not entirely sure. I thought first it might be a dimension issue. The references where there is a square root in the denominator always pertain to 1D signals; whenever it concerns 2D signals there is no square root. I couldn't find any reference explaining the difference however. So my hypothesis was that one could extrapolate to any dimension with import scipy.fft as fft
import numpy as np
import matplotlib.pyplot as plt
def funk(R):
return R
w1 = 0.1
order = 8
# 1D
x = np.arange(100)
y1 = funk(x)
ify1 = butterworth(y1, w1, False, order, 0.5)
ify1b = butterworth(y1, w1, False, order, 0.5)
# 2D
X1, X2 = np.meshgrid(x, x, sparse=True)
R = np.sqrt(X1**2 + X2**2)
y2 = funk(R)
ify2 = butterworth(y2, w1, False, order, 0.5)
ify2b = butterworth(y2, w1, False, order, 1)
# 3D
X1, X2, X3 = np.meshgrid(x, x, x, sparse=True)
R = np.sqrt(X1**2 + X2**2 + X3**2)
y3 = funk(R)
ify3 = butterworth(y3, w1, False, order, 0.5)
ify3b = butterworth(y3, w1, False, order, 1.5)
fig, ax = plt.subplots(ncols = 3, figsize=(15, 5))
ax[0].plot(x, y1, label="1D")
ax[0].plot(x, y2[0], label="2D")
ax[0].plot(x, y3[0,0], label="3D")
ax[0].legend()
ax[0].set_title("Original function")
ax[1].plot(x, ify1, label="1D")
ax[1].plot(x, ify2[0], label="2D")
ax[1].plot(x, ify3[0,0], label="3D")
ax[1].legend()
ax[1].set_title("Butter filter exponent 0.5")
ax[2].plot(x, ify1b, label="1D")
ax[2].plot(x, ify2b[0], label="2D")
ax[2].plot(x, ify3b[0,0], label="3D")
ax[2].legend()
ax[2].set_title("Butter filter adjusted exponent") Basically I generate some N-D images based on some function of R on a 1D, 2D and 3D grid. Looking along a single axis, all these functions look identical (figure 1). I then apply the butterworth filter and plot again the result along one dimension. I modified the butterworth function to also take the parameter The middle image is where the exponent is 0.5 for all dimensions, the right one is where the exponent is adjusted according my hypothesis. Basically it looks like it doesn't matter very much. I looked into different functions, different orders, different w0 but couldn't find a clear reason to use one or the other. Therefore, since the primary use-case for this thing would be to highlight/remove certain frequencies in an image, and since a user will have to tune parameters on a case by case basis anyway, I propose we go with exponent 1 in the denominator for simplicity and speed. The filter has sufficient flexibility with the @jni I think I'll go with the second approach, though this breaks down if the filter is ever changed for some reason (i.e. people want the square root in the denominator) |
The doc errors were due to LaTeX formatting issues, but this isn't that clear from the message. The solution is using |
@grlee77 awesome thanks a lot for looking into this! Weird because I also considered this but I saw multiple examples e.g.
\ appears to be enough. Anyway with these changes it seems to be working!
|
Yes, but I think those cases start the docstring with |
skimage/filters/_fft_based.py
Outdated
butterfilt = fft.irfftn(wfilt * fft.rfftn(image, axes=axes), | ||
s=fft_shape, axes=axes) | ||
out_range = dtype_limits(image) if preserve_range else (0.0, 1.0) | ||
butterfilt = rescale_intensity(butterfilt, out_range=out_range) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
butterfilt = fft.irfftn(wfilt * fft.rfftn(image, axes=axes), | |
s=fft_shape, axes=axes) | |
out_range = dtype_limits(image) if preserve_range else (0.0, 1.0) | |
butterfilt = rescale_intensity(butterfilt, out_range=out_range) | |
if not preserve_range: | |
image = img_as_float(image) | |
butterfilt = fft.irfftn(wfilt * fft.rfftn(image, axes=axes), | |
s=fft_shape, axes=axes) |
I would have expected behavior like the above for preserve_range
. The current code will rescale the range for pretty much all inputs even when preserve_range is True. For example, floating point inputs will not have their range preserved.
@jni, thoughts? If we change this here we will need to change the data files used in the tests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Gah. I think you're right @grlee77. 🤦
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
sorry for replying so late, didn't have time for a couple days. I think I implemented it in this way because the fft + ifft may introduce image intensities outside the original data-type range. For example if you put in a uint8 image, you may get negative intensities. This is because when you remove some spatial frequencies, you might get "jumps" in places where there are edges. My original interpretation of preserve_range
was that the output image should fit in the same dtype range as the input image so that it could also be cast as the same dtype. This is why I do the rescaling after the filter. But I now see your point that this has the potentially unwanted side effect of stretching the histogram to the extrema, so probably your solution is better. Note however that in your implementation, it is still possible to get an image that is not rescaled to a range [0, 1] or [-1, 1] if preserve_range=False
, but I don't see another appropriate alternative that won't stretch the histogram. I suggest the following:
- I change the implementation to what @grlee77 suggests
- I add some clarification in the
Notes
section aboutpreserve_range
that it is possible to still get some outlier intensities - I remove the test data files and hardcode new test data into the test files.
Alternatively, since the option doesn't seem to make much sense anyway I remove the preserve_range
argument. Any vote on this?
I decided to get rid of |
set flake8 to ignore whitespace and line length errors for the data stored in test_fft_based.py
test_butterworth_2D just verifies high-pass vs. low-pass behavior via relative energy in the FFT domain.
These seem unrelated and should be resolved by #5434 |
I am okay with having these relatively small arrays in-line in the test file, but let's see what other maintainers think. I updated setup.cfg to ask it to ignore some whitespace and line-length issues that would be reported during style checking. Please see the two new test cases added in 0a769f7 and see if they look reasonable to you. I wanted to test something with non-zero input that would at least verify that the spectrum of the output had been modified in a roughly high-pass or low-pass nature. The specific 0.1 and 0.9 thresholds are basically arbitrary and not tuned to the specific filter settings. |
Thanks for adding some tests, they are definitely valuable. I just left two minor comments which may just be misunderstandings on my part. |
@din14970 CI should pass if you merge |
I am also okay with this practice. Technically, this choice reverts #5415 but this is fine and was contemplated in the first place.
Wonderful! |
Okay, the one failure here is unrelated and seems to be a new warning from the just released scipy 1.7.0. I can make a new PR to fix it
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for sticking with this @din14970, it looks good to me now.
Description
In high resolution electron microscopy, FFT based filters are commonly used to enhance features in periodic images. One such common filter is a high pass 2D Butterworth filter, which masks low spatial frequencies (usually background) with minimal introduction of artifacts. The Butterworth filter is implemented for 1D signals in scipy but not for 2D arrays. I use scikit-image quite often but was missing this filter, so I hope I can contribute it. For images of the general sort this can be either a low-pass or high-pass filter for spatial frequencies, or it can be combined to create a bandpass filter.
On the astronaut image:
Not sure whether I put it in a good place and whether it requires its own example
Checklist
./doc/examples
(new features only)./benchmarks
, if your changes aren't covered by anexisting benchmark
For reviewers
later.
__init__.py
.doc/release/release_dev.rst
.