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:
f(L) = \frac{L}{1 + L}
maps [0, \infty) into [0, 1), with:
- L \ll 1 \Rightarrow f(L) \approx L (preserves shadows),
- L \gg 1 \Rightarrow f(L) \approx 1 (compresses highlights),
- L = 1 \Rightarrow f(L) = 0.5 (nice midtone mapping).
- **Luminance vs RGB:**
- Tone mapping is conceptually defined on **luminance** (perceived brightness), not individual RGB channels.
- A common luminance formula (for sRGB) is:
L = 0.2126 R + 0.7152 G + 0.0722 B.
- After tone mapping luminance, RGB values are scaled to match the new luminance, preserving approximate color ratios.
---
## 2. Algorithm / General Approach
For this specific problem (“Global Reinhard Tone Mapping”), the algorithm pattern is:
1. **Work per pixel**: treat each pixel independently (no neighborhood operations).
2. **Compute luminance (if needed)** from RGB.
3. **Apply Reinhard mapping**:
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).