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

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions