Skip to content

Contrast adjustment with 'eliminate outliers' failed for float images with high dynamic range #46

@PierreRaybaut

Description

@PierreRaybaut

🐞 Problem Summary

The hist_range_threshold function was designed to compute the value range covering a central percentage of the histogram mass (e.g. 98%), in order to eliminate symmetric outliers — similar to MATLAB’s Eliminate outliers.

This worked correctly for integer-valued images (e.g. 8-bit or 16-bit), where the first histogram bin typically corresponds to zero-valued pixels and can safely be ignored.

However, when applied to float-valued images, the function produced incorrect results due to a mismatch between hist and bin_edges.

⚠️ Observed Symptoms

  • The computed (vmin, vmax) bounds were sometimes shifted or too narrow/wide.
  • The assumption that the first bin should always be removed caused misalignment when bin_edges were non-integer floats (e.g. from np.linspace).
  • The end bin (i_bin_max) could point to the wrong edge when the alignment was lost.

✅ Resolution

We reimplemented the function to:

  • Remove the first bin only if bin_edges are of integer type (typically meaning the histogram comes from an integer-valued image where zero has a special meaning).
  • Maintain the correct alignment between hist[i] and the interval [bin_edges[i], bin_edges[i+1]).
  • Fix an off-by-one error that previously caused inconsistencies, especially for edge cases like percent = 0.

🎯 Additional Fix: Index Alignment

An important correction was made to the computation of the output range:

  • Previously, accessing bin_edges[i_bin_max] assumed that the end of the last bin was bin_edges[-1], which was not guaranteed after trimming.
  • Now, we properly return bin_edges[i_bin_min] and bin_edges[i_bin_max], based on explicitly corrected indices and consistent bin count logic.
  • As a result, setting percent = 0 now returns a (vmin, vmax) that spans exactly one bin — meaning that no contrast adjustment occurs in this edge case.

🧪 Final Function

import numpy as np

def hist_range_threshold(
    hist: np.ndarray, bin_edges: np.ndarray, percent: float
) -> tuple[float, float]:
    """
    Return the value range corresponding to the central `percent` of the histogram mass,
    optionally excluding the first bin (assumed to represent zero-valued pixels in integer images).

    Args:
        hist: Histogram values (length N)
        bin_edges: Bin edges (length N+1)
        percent: Percent of the histogram mass to retain (between 0 and 100)

    Returns:
        (vmin, vmax): Value range corresponding to the central mass
    """
    if not (0 <= percent <= 100):
        raise ValueError("percent must be in [0, 100]")

    hist_len = len(hist)
    i_offset = 0

    # Remove first bin only for integer-based histograms (e.g. zero-valued pixels)
    if np.issubdtype(bin_edges.dtype, np.integer):
        hist = hist[1:]
        i_offset = 1

    threshold = 0.5 * percent / 100 * hist.sum()

    i_bin_min = max(np.cumsum(hist).searchsorted(threshold) - i_offset, 0)
    i_bin_max = hist_len - np.searchsorted(np.cumsum(np.flipud(hist)), threshold)

    vmin, vmax = bin_edges[i_bin_min], bin_edges[i_bin_max]
    return vmin, vmax

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions