Loading HDR image…
Method
Processing code for this image · Exact image-gen prompts
Inputs and dimensions
Start with the uploaded image O and two image-gen edits D and A, requested at −2 EV and +2 EV. Those labels are prompt instructions: there are no shutter times or calibrated exposure ratios. The generated edits synthesize detail. They are not additional camera captures. The sunlit image uses both edits; the revised storm grade excludes the bright edit and uses the dark edit only in highlights.
Pillow reads the source as RGB. OpenCV resizes each generated edit to the processing canvas with Lanczos4. For the new storm photo only, replicate one right-edge column and one bottom row: 415 × 739 becomes 416 × 740 so 4:2:0 codecs can use even dimensions. No source pixels are cropped. The original download stays 415 × 739.
Register each AI edit to the source — OpenCV
At half resolution, convert RGB to 8-bit grayscale and run CLAHE, clip limit 2.0, tile grid 12 × 12. Calculate dense Farnebäck flow from the source to each edit:
flow = calcOpticalFlowFarneback(sourceGray, editGray, pyr_scale=0.5, levels=5, winsize=35, iterations=5, poly_n=7, poly_sigma=1.5, flags=0) F = clip(GaussianBlur(2 × resize(flow, W,H), σ=2), −12, +12) registered(x,y) = edit(x + Fₓ(x,y), y + Fᵧ(x,y))Flow upsampling uses bilinear interpolation. Remapping uses cubic interpolation and BORDER_REFLECT_101. The ±12-pixel bound is per component. It limits deformation; it cannot guarantee that invented objects or textures match the original.
Exposure fusion — OpenCV MergeMertens
Supply registered D, O and A to
createMergeMertens(1.0, 0.15, 1.0). Inputs are RGB values divided by 255. For each input Iᵢ, OpenCV calculates contrast C, saturation S and well-exposedness E:grayᵢ = 0.299Iᵢ,R + 0.587Iᵢ,G + 0.114Iᵢ,B Cᵢ = |Laplacian(grayᵢ)| μᵢ = (Iᵢ,R + Iᵢ,G + Iᵢ,B) / 3 Sᵢ = √[Σc (Iᵢ,c − μᵢ)²] Eᵢ = ∏c exp[−(Iᵢ,c − 0.5)² / 0.08] Wᵢ = Cᵢ^1 · Sᵢ^0.15 · Eᵢ^1 + 10⁻¹² wᵢ = Wᵢ / Σj WⱼUse Gaussian pyramids of wᵢ and Laplacian pyramids of Iᵢ, sum the weighted image pyramid at each level, then collapse it. The largest pyramid level is floor(log₂(min(W,H))). Clip the fused RGB result M to [0,1]. This is exposure fusion; no Debevec camera-response or radiance calibration is performed.
Storm: use the source exposure, not exposure fusion
The brighter AI edit is excluded from this grade. The uploaded photograph supplies the cloud mass, silhouettes, fence and courtyard. Only the darker AI edit D is aligned and used to add restrained texture to existing bright sky openings. There is no shadow recovery or Mertens fusion in the storm grade.
L(c) = c/12.92 if c ≤ 0.04045 ((c + 0.055)/1.055)^2.4 otherwise Yd = 0.2126L(D)ᵣ + 0.7152L(D)ɡ + 0.0722L(D)ᵦ texture = clip[Yd/(GaussianBlur(Yd,σ=2)+0.00001), 0.8, 1]The texture term is at most 1. It can darken a highlight slightly to restore texture; it cannot brighten a shadow.
Retain more of the uploaded image in midtones
The following blend is applied in encoded RGB, before linearization. O is the original divided by 255. Define smoothstep s as:
t = clip((x − a)/(b − a), 0, 1) s(a,b,x) = t²(3 − 2t) yO = 0.2126Oᵣ + 0.7152Oɡ + 0.0722Oᵦ u = max[1 − s(0.10,0.40,yO), s(0.55,0.90,yO)] α = 0.45 + 0.50u B = clip[(1 − α)O + αM, 0, 1]B is the SDR rendition. Midtones use 45% fusion; the deepest shadows and brightest highlights use up to 95% fusion. Store the SDR JPEG at quality 95, 4:4:4, with an sRGB ICC profile.
Storm: protect dark tones and the foreground
s(a,b,x): t=clip((x−a)/(b−a),0,1); s=t²(3−2t) yO = 0.2126Oᵣ + 0.7152Oɡ + 0.0722Oᵦ v = pixel_row / image_height sky = 1 − s(0.51,0.555,v) support = s(0.72,0.98,yO) · sky Blinear = L(O) · [1 − 0.35·support·(1−texture)]The sky gate ends above the roof. The original-pixel brightness gate protects the dark central cloud and silhouettes. Wherever yO ≤ 0.72, the linear master is exactly the original linear RGB before encoding. That covers 97.308% of pixels. The SDR rendition is inverse-sRGB(Blinear), with no global shadow lift. Lossy delivery codecs can introduce small pixel differences.
Linearize RGB and build the 3,000-nit HDR master — NumPy
L(c) = c/12.92 if c ≤ 0.04045 ((c + 0.055)/1.055)^2.4 otherwise Y = 0.2126Lᵣ + 0.7152Lɡ + 0.0722Lᵦ m = s(0.12,0.85,Y)^1.25 k = [3000/(203·max(Y)) − 1] / max(m) H = L · (1 + k·m) H_nits = 203HThe gain is achromatic: the same multiplier is applied to R, G and B. Dark pixels receive little or no gain; the brightest pixels target 3,000 nits. For the preserved sunlit image max(m)=1; its saved code uses the equivalent simpler k expression. Store H as little-endian RGBA half-float, alpha 1, in BT.709 primaries relative to a 203-nit white. The encoder reads that buffer; this changes pixel values, not just a brightness tag.
Storm: apply 3,000-nit headroom only to existing light
Y = 0.2126Blinearᵣ + 0.7152Blinearɡ + 0.0722Blinearᵦ m = s(0.72,0.98,yO)² · sky T = 3000 / 203 k = min over pixels with m > 0.000001 of [(T−Y)/(Y·m)] H = Blinear · (1 + k·m) H_nits = 203HThe minimum selects the largest achromatic gain that keeps every active pixel at or below the target master luminance. For 97.322% of pixels m=0, so HDR adds no light there. Existing bright silver openings can reach 3,000 nits while the storm mass and foreground remain at their original exposure. H is saved as RGBA half-float in BT.709 primaries relative to 203-nit white.
Convert BT.709 to BT.2020 and encode ST 2084/PQ
For column-vector RGB, convert through XYZ using these matrices:
M709 = [[.4123907993, .3575843394, .1804807884], [.2126390059, .7151686788, .0721923154], [.0193308187, .1191947798, .9505321522]] M2020= [[.6369580483, .1446169036, .1688809752], [.2627002120, .6779980715, .0593017165], [0, .0280726930, 1.0609850577]] RGB2020_nits = 203 · inverse(M2020) · M709 · H p = max(RGB2020_nits/10000, 0)^m1 PQ = [(c1 + c2·p)/(1 + c3·p)]^m2 m1=2610/16384; m2=2523/32 c1=3424/4096; c2=2413/128; c3=2392/128 RGB48 = round[65535 · clip(PQ,0,1)]The intermediate PQ RGB48 buffer uses unsigned little-endian 16-bit channels. It is subsequently encoded at 10 bits. PQ is absolute luminance encoding; the 10,000-nit divisor is part of ST 2084, not this image's peak.
Encode the delivery files
File Encoder and parameters HEIC photo pillow-heif 1.8.1 → libheif 1.23.6 → x265 4.3. Quality 100, 10-bit, chroma 4:2:0, preset slow. NCLX/CICP: primaries 9, transfer 16, matrix 9, full range. Nominal diffuse white 203 nits. AVIF photo FFmpeg 6.1.1 → libaom-av1. CRF 4, cpu-used 6, still-picture 1, yuv420p10le, limited range, BT.2020/PQ. zscale converts PQ RGB to BT.2020 nonconstant-luminance YUV. HEVC video FFmpeg 6.1.1 → libx265. Stationary image, 2 seconds, 24 fps; preset slow, CRF 8, Main 10, hvc1, repeat-headers 0, HDR10 enabled, yuv420p10le, BT.2020/PQ, faststart. Gain-map JPEG Google libultrahdr 2.0.2. SDR and gain-map JPEG quality 95; single-channel map, full resolution, gamma 1, min boost 1, max boost 16, target display peak 3248 nits. Both ISO 21496-1 and XMP metadata are written. HEIC and HEVC declare a 4,000-nit mastering display. MaxCLL is ceil(max RGB channel nits of the input master); MaxFALL is ceil(mean luminance Y). These describe source pixels. Quantization and chroma subsampling can make a decoded file's peak differ from 3,000 nits; the overlay uses the decoded values shown below.
Gain-map representation
For this libultrahdr encoding path, the scalar map is based on the maximum linear RGB channel in each pixel. Conceptually, for channel maxima h and b in the same units, the ratio is (h+offsetHDR)/(b+offsetSDR), mapped into log₂(minBoost)…log₂(maxBoost), clamped, gamma-corrected, and quantized to an 8-bit secondary JPEG. The codec owns the exact offsets, metadata and quantization. The wrapper code and exact flags are in the processing-code download; codec source is linked below.
Decode the files and calculate the overlay
Decode HEIC with libheif/libde265, AVIF and HEVC with FFmpeg, and the JPEG's full HDR intent with libultrahdr. For PQ samples, invert ST 2084:
q = PQ^(1/m2) RGB_nits = 10000 · [max(q − c1,0)/(c2 − c3·q)]^(1/m1) Y_nits = (Mprimaries · RGB_nits)ᵧ maxNits = max(Y_nits) avgNits = mean(Y_nits) minNits = min(Y_nits)libheif returns left-aligned 10-bit samples in uint16 containers; divide those values by 65472 (=1023·64), not 65535. FFmpeg's RGB16 output uses 65535. SDR and Original are linearized as sRGB with an assumed 203-nit white. The video uses frame zero.
NumPy bins Y against horizontal image position: 512 x-bins for the 1024-pixel cloud image and 416 for the new 416-pixel image; 359 log-luminance bins from 0.01 to 4,000 nits. Density alpha is clip(log(1+count)/log(25),0,1). Matplotlib 3.10.8 renders the waveform. Zero samples go to the lower boundary. Grey is below 100 nits, cyan 100–203, green above 203.
For gamut buckets, transform decoded XYZ to each gamut's linear RGB. A pixel is inside when every channel ≥ −ε, where ε=0.0005·max(1,max|RGB|). Report disjoint buckets: BT.709, then Display-P3/D65 outside BT.709, then BT.2020 outside both, then outside all three if present. These are file-color classifications, not screen measurements.
Browser playback and comparison
Every fresh page load starts with Sunlit clouds. The Image dropdown selects either photograph. Auto selects stationary HDR10/HEVC on Apple browsers reporting Main 10 support; if decoding fails it falls back to a still. Other browsers use AVIF or gain-map JPEG. The page uses native media elements and dynamic-range-limit:no-limit. Hold for SDR temporarily displays B. The overlay is precomputed from each encoded file, so it does not rely on an SDR browser canvas. Display capability is reported separately from decoding. The screen can tone-map the file to its available brightness.
Decoded values for the selected image
| Rendition | Peak Y, nits | Mean Y, nits |
|---|
Processing: OpenCV 5.0.0, NumPy 2.5.4 (compositing) / 2.3.5 (encoding and analysis), Pillow 13.0.0. libultrahdr commit: 5f117294cb701170b9198ed8eae16270aa84896c. The original 3,000-nit cloud media are retained byte-for-byte. The new storm media are a separate job.
References: OpenCV Mertens implementation · libultrahdr · Gain-map format · Apple HDR formats.