Skip to content

fix(filter): preserve callable type in filter design #1692

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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from

Conversation

Kaiyokun
Copy link

Previously, filter_type in _fbp_filter was converted to a string before the callable check, causing the condition to always evaluate as false for callable inputs. This change preserves the original object type to ensure correct handling.

Key changes:

  1. Removed the assignment filter_type = str(filter_type).lower() to preserve the original object type
  2. Introduced filter_type_in to store the string representation exclusively for predefined filter comparisons
  3. Maintained the existing filter type handling logic for strings ('ram-lak', 'shepp-logan', etc.)

The fix ensures:

  • Callable filters (e.g., custom functions) are correctly detected by callable() and executed
  • Predefined filter types continue to function via string matching
  • Backward compatibility with existing filter type names

Previously, `filter_type` in `_fbp_filter` was converted to a string before the callable check, causing the condition to always evaluate as false for callable inputs. This change preserves the original object type to ensure correct handling.

Key changes:
1. Removed the assignment `filter_type = str(filter_type).lower()` to preserve the original object type
2. Introduced `filter_type_in` to store the string representation exclusively for predefined filter comparisons
3. Maintained the existing filter type handling logic for strings ('ram-lak', 'shepp-logan', etc.)

The fix ensures:
- Callable filters (e.g., custom functions) are correctly detected by `callable()` and executed
- Predefined filter types continue to function via string matching
- Backward compatibility with existing filter type names
@Emvlt Emvlt self-assigned this Jul 14, 2025
@Emvlt
Copy link
Contributor

Emvlt commented Jul 14, 2025

This seems like an easy fix for a bug that passed through the tests.
@leftaroundabout what do you think about it?
We also should put "writing a test suite for the analytic package of tomo" on our todo list.

@leftaroundabout
Copy link
Contributor

leftaroundabout commented Jul 15, 2025

@Kaiyokun thanks for submitting.

I agree that the way the old version handled the callable case did not make sense. It's however worth to think about the question: does it even make sense that _fbp_filter can accept a callable in the first place? It doesn't really do much in that case, but only applies the provided function to the provided argument. I daresay it would be clearer to split up the logic in to two parts:

  • _parse_fbp_filter accepts only strings, and gives back a callable corresponding to the function mapping frequencies to attenuation.
  • _apply_fbp_filter accepts only callables, and applies them to an array of frequencies.

Alternatively, we can keep the single _fbp_filter that does both things, but IMO it should really be made clearer what's going on. This isn't a criticism of your PR but rather of the old version. It is mostly a matter of terminology: in my book, a filter is a signal-processing function, i.e. something you apply to an image and get back another image. In _fbp_filter the term "filter" is used rather vaguely, but mostly it seems to denote the frequency response of the filter (IOW the Fourier-transformed kernel). Or rather that frequency response evaluated at the specific frequencies.

Can we make this explicit? Like,

def _fbp_filter(norm_freq, filter_type, frequency_scaling):
    """Calculate the frequency-wise attenuation of a filter for FBP.

    Parameters
    ----------
    norm_freq : `array-like`
        Frequencies, normalized to lie in the interval [0, 1].
    filter_type : {'Ram-Lak', 'Shepp-Logan', 'Cosine', 'Hamming', 'Hann',
                   callable}
        The type of filter to be used.
        If a string is given, use one of the standard filters with that name.
        A callable should take an array of frequencies in [0, 1] and return the
        filter gain for each of these frequencies.
    frequency_scaling : float
        Scaling of the frequencies for the filter. All frequencies are scaled
        by this number, any relative frequency above ``frequency_scaling`` will
        be assigned gain 0.

    Returns
    -------
    gain : `numpy.ndarray`

    Examples
    --------
    Create an FBP filter in frequency space

    >>> norm_freq = np.linspace(0, 1, 10)
    >>> filt = _fbp_filter(norm_freq,
    ...                    filter_type='Hann',
    ...                    frequency_scaling=0.8)
    """
    if callable(filter_type):
        frequency_response = filter_type
        gain = frequency_response(norm_freq / frequency_scaling)
    elif filter_type == 'ram-lak':
        gain = np.copy(norm_freq)
    elif filter_type == 'shepp-logan':
        gain = norm_freq * np.sinc(norm_freq / (2 * frequency_scaling))
    elif filter_type == 'cosine':
        gain = norm_freq * np.cos(norm_freq * np.pi / (2 * frequency_scaling))
    elif filter_type == 'hamming':
        gain = norm_freq * (
            0.54 + 0.46 * np.cos(norm_freq * np.pi / (frequency_scaling)))
    elif filter_type == 'hann':
        gain = norm_freq * (
            np.cos(norm_freq * np.pi / (2 * frequency_scaling)) ** 2)
    else:
        raise ValueError('unknown `filter_type` ({})'
                         ''.format(filter_type))

    indicator = (norm_freq <= frequency_scaling)
    gain *= indicator
    return gain

N.B.

  1. I omitted the case-insensitive handling here, that would probably cause problems. (IMO there should never be any .lower() -fixing but instead one convention followed how the identifiers are written, but that's a separate thing to discuss)
  2. The old version did not actually apply the frequency_scaling in the callable case. I think it should do that, but I'm not completely sure how frequency scaling is actually used here. Definitely needs testing.

@Kaiyokun
Copy link
Author

@leftaroundabout I absolutely agree that ​the .lower() workaround should never be needed. I encountered this specific bug while reviewing the documentation for fbp_op, particularly regarding the filter_type parameter:

filter_type: optional
The type of filter to be used. The predefined options are, in approximate order from most noise sensitive to least noise sensitive: 'Ram-Lak', 'Shepp-Logan', 'Cosine', 'Hamming', and 'Hann'. A callable can also be provided. It must take an array of values in [0, 1] and return the filter for these frequencies.

This led to confusion about the expected prototype for the custom callable. To investigate, I initially passed print just to log the arguments being supplied.

In my opinion, filter_type could be significantly improved. It should ideally be either:

  • A ​standerd enum.Enum type​ listing the predefined options, or
  • Even better, ​predefined functions​ representing each filter (e.g., odl.tomo.analytic.filtered_back_projection.Ram_Lak).

This approach would allow users to pass the function itself directly (e.g., filter_type=Ram_Lak). The added benefit is the ​complete elimination​ of the mixin-callable-string-matching if-elif-else logic within the implementation.

Of course, supporting a custom callable remains valuable. To make this clear and usable, the API documentation needs ​more detailed explanation and an illustrative example​ demonstrating exactly how to define and use such a function.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants