Global Reinhard Tone Mapping
Apply global Reinhard tone mapping to compress HDR values to displayable range.
HDR images can have luminance values spanning many orders of magnitude (e.g., 0.001 to 10000+), but displays can only show values in [0, 1]. Tone mapping compresses this range while preserving visual quality.
The Reinhard operator is a classic global tone mapper:
Ldisplay=1+LL
Properties of this formula:
- When L is small: Ld≈L (preserves dark details)
- When L is large: Ld≈1 (compresses highlights)
- At L = 1: Ld=0.5 (mid-point mapping)
- Always maps to [0, 1) range
This creates an S-curve that mimics how human vision perceives brightness - we're more sensitive to dark regions and compress bright regions.
Example:
reinhard(1.0)
0.5
For L = 1.0:
- Apply formula: L_d = 1.0 / (1 + 1.0)
- Calculate: L_d = 1.0 / 2.0 = 0.5
- This is the mid-point - L=1 maps to exactly 0.5.
Constraints:
- L is HDR luminance value (non-negative float)
- Return tone-mapped value rounded to 4 decimal places
- Output should be in range [0, 1)
More from CV: Computational Photography
Tone mapping is used to convert high dynamic range (HDR) images (very large range of luminance values) into low dynamic range (LDR) images that a normal display can show, typically in the range [0,1]. HDR values might span from 10−4 (deep shadows) to 104 (sunlight), but displays and standard image formats cannot represent such extremes directly. The goal of a tone mapping operator (TMO) is to compress this range while preserving important visual details and contrast, especially in midtones and shadows where human vision is most sensitive.
The Reinhard global operator is a simple, widely used TMO that maps each luminance value L independently using a fixed curve:
Ldisplay=1+LL.This function behaves nearly linearly for small L (so dark regions are preserved) and asymptotically approaches 1 for large L (so highlights are compressed rather than clipped). It is called global because the same function is applied everywhere in the image, independent of local neighborhood or pixel position. In practice, you usually compute luminance from RGB, apply the tone mapping to that luminance, and then remap the RGB channels consistently so colors remain plausible.
1. Background Knowledge (Key Concepts)
-
HDR vs LDR:
-
HDR images store radiance or luminance values proportional to real-world light intensities, often as floating-point values well outside [0,1].
-
LDR images (e.g., 8-bit per channel) are limited to a small range with discrete steps, so direct display of HDR data would either clip or lose detail.
-
Global tone mapping operator:
-
A global TMO uses the same mapping function f(L) for all pixels, depending only on the pixel’s luminance, not its neighbors.
-
Reinhard’s basic global operator:
L_{d} = 1+LL
4. **Rescale RGB** (if starting from RGB HDR): - Compute a scaling factor based on how luminance changed. 5. **Clamp and convert**: - Ensure outputs are within $[0, 1]$. - Optionally convert to 8-bit integers by multiplying by 255 and rounding. In many educational tasks, you might be given a 1-channel HDR “luminance image” already; then you just apply the formula element-wise. --- ## 3. Step-by-Step Strategy Assuming input is an HDR image as a float array, either grayscale or RGB: ### Case A: Grayscale / luminance input 1. **Read the HDR image** as a floating-point array img of shape (H, W) with values in some HDR range (could be <0, but typically non-negative). 2. **Apply Reinhard operator element-wise**: ```python L = img # luminance L_display = L / (1.0 + L) ``` 3. **Clamp to [0, 1]** (for numerical safety): ```python L_display = np.clip(L_display, 0.0, 1.0) ``` 4. **Return or write out** L_display as the tone-mapped image. ### Case B: RGB HDR input If the assignment expects handling RGB: 1. **Compute luminance per pixel**: ```python L = 0.2126 * R + 0.7152 * G + 0.0722 * B # shape (H, W) ``` 2. **Apply Reinhard mapping to luminance**: ```python L_d = L / (1.0 + L) ``` 3. **Avoid division by zero**: - Define a small epsilon: ```python eps = 1e-8 scale = L_d / (L + eps) # shape (H, W) ``` 4. **Scale RGB channels**: ```python R_d = R * scale G_d = G * scale B_d = B * scale ``` 5. **Clamp to [0, 1]**: ```python R_d = np.clip(R_d, 0.0, 1.0) G_d = np.clip(G_d, 0.0, 1.0) B_d = np.clip(B_d, 0.0, 1.0) ``` 6. **Stack channels back** into an image. For an “Easy” problem, the exercise is usually just step 2: apply $L/(1+L)$ to every pixel. --- ## 4. Common Pitfalls - **Not using floating point**: - If you perform the mapping using integer types, L / (1 + L) will truncate and produce mostly 0 or 1. Ensure the array is float32 or float64. - **Negative luminance values**: - Real HDR luminance should be non-negative, but due to noise or earlier errors, you might have negatives. A robust implementation often clamps before mapping: ```python L = np.maximum(L, 0.0) ``` - **Per-channel mapping instead of luminance-based**: - Applying $f$ separately to R, G, B can shift colors undesirably. For simple problems this might be accepted, but proper tone mapping works on luminance and rescales RGB. - **Forgetting to clamp / normalize output**: - Due to numerical issues, some values may slightly exceed 1 or go below 0. Clamping ensures the result is valid for display. - **Division by zero when rescaling RGB**: - When $L = 0$, directly computing L_d / L is invalid. Use an epsilon, or conditionally set scale to 0 when L is 0. --- ## 5. Time & Space Complexity Let the image have $N = H \times W$ pixels. - **Time complexity**: - Each pixel requires a constant number of arithmetic operations (a few additions, multiplications, and one division). - Overall: - Grayscale: $O(N)$ - RGB: still $O(N)$ (constant factor ~3–5 higher). - **Space complexity**: - If you process in-place (overwrite the input), extra space is $O(1)$. - If you keep input and output separate, extra space is $O(N)$ for the output image (and possibly an $O(N)$ luminance buffer for RGB).