ultrahdr-core 0.3.1

Core gain map math and metadata for Ultra HDR - no codec dependencies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
# ultrahdr [![CI](https://img.shields.io/github/actions/workflow/status/imazen/ultrahdr/ci.yml?style=flat-square)](https://github.com/imazen/ultrahdr/actions/workflows/ci.yml) [![crates.io](https://img.shields.io/crates/v/ultrahdr-rs?style=flat-square)](https://crates.io/crates/ultrahdr-rs) [![docs.rs](https://img.shields.io/docsrs/ultrahdr-rs?style=flat-square)](https://docs.rs/ultrahdr-rs) [![codecov](https://img.shields.io/codecov/c/github/imazen/ultrahdr?style=flat-square)](https://codecov.io/gh/imazen/ultrahdr) [![MSRV](https://img.shields.io/badge/MSRV-1.92-blue?style=flat-square)](https://doc.rust-lang.org/cargo/reference/manifest.html#the-rust-version-field) [![license](https://img.shields.io/crates/l/ultrahdr-rs?style=flat-square)](https://github.com/imazen/ultrahdr#license)

Pure Rust implementation of [Ultra HDR](https://developer.android.com/media/platform/hdr-image-format) (gain map HDR) encoding and decoding.

Ultra HDR is a backward-compatible HDR image format that embeds a gain map in a standard JPEG, allowing HDR-capable displays to reconstruct the full HDR image while remaining viewable as SDR on legacy displays.

## Crates

| Crate | Description |
|-------|-------------|
| [`ultrahdr-rs`](ultrahdr/) | Full encoder/decoder with zenjpeg JPEG codec |
| [`ultrahdr-core`](ultrahdr-core/) | Pure math and metadata - no codec dependency, WASM-compatible |

## Features

- **Encode**: Create Ultra HDR JPEGs from HDR images (with optional SDR input)
- **Decode**: Extract and apply gain maps to reconstruct HDR content
- **Tone mapping**: Automatic SDR generation from HDR-only input
- **Adaptive tonemapping**: Learn tone curves from existing HDR/SDR pairs
- **Metadata**: Full XMP (hdrgm namespace) and ISO 21496-1 support
- **Pure Rust**: No C dependencies, uses [zenjpeg](https://github.com/imazen/zenjpeg) for JPEG
- **WASM**: `ultrahdr-core` compiles to WebAssembly

## Comparison with C++ libultrahdr

| Feature | ultrahdr-rs | C++ libultrahdr |
|---------|:-----------:|:---------------:|
| **Encoding** | | |
| HDR + SDR → Ultra HDR JPEG | Yes | Yes |
| HDR-only (auto-tonemap SDR) | Yes | Yes |
| Adaptive tonemapping (learn curves) | Yes | No |
| Streaming encode (low memory) | Yes | No |
| Multi-channel gain map | Yes | Yes |
| **Decoding** | | |
| Ultra HDR → HDR reconstruct | Yes | Yes |
| Display boost parameter | Yes | Yes |
| Gain map extraction (raw JPEG) | Yes | Yes |
| **Metadata** | | |
| XMP (hdrgm namespace) | Yes | Yes |
| ISO 21496-1 binary | Yes | Yes |
| MPF (Multi-Picture Format) | Yes | Yes |
| **Pixel Formats** | | |
| RGBA 8-bit (SDR) | Yes | Yes |
| RGBA 32F / 16F (HDR) | Yes | Yes |
| P010 (10-bit YUV) | Yes | Yes |
| RGBA 1010102 (PQ/HLG) | Yes | Yes |
| **Transfer Functions** | | |
| sRGB | Yes | Yes |
| PQ (ST.2084) | Yes | Yes |
| HLG (BT.2100) | Yes | Yes |
| **Color Gamuts** | | |
| BT.709 / sRGB | Yes | Yes |
| Display P3 | Yes | Yes |
| BT.2100 / BT.2020 | Yes | Yes |
| **Platform** | | |
| Pure Rust (no C deps) | Yes | No (C++) |
| WASM support | Yes (`ultrahdr-core`) | No |
| `no_std` support | Yes (`ultrahdr-core`) | No |
| JPEG codec bundled | Optional (zenjpeg) | Yes (built-in) |
| **Not Yet Implemented** | | |
| JPEG-R (ISO 21496-1 container) | No | Yes |
| Editing API (in-place metadata update) | No | Yes |
| GPU acceleration | No | Yes (OpenGL) |

## Usage

### Encoding

```rust
use ultrahdr_rs::{Encoder, RawImage, PixelFormat, ColorGamut, ColorTransfer};

// Create HDR image (linear float RGB, BT.2020 gamut)
let hdr_image = RawImage {
    width: 1920,
    height: 1080,
    format: PixelFormat::Rgba32F,
    gamut: ColorGamut::Bt2100,
    transfer: ColorTransfer::Linear,
    data: hdr_pixels,
    stride: 1920 * 16,
};

// Encode to Ultra HDR JPEG (SDR is auto-generated via tone mapping)
let ultrahdr_jpeg = Encoder::new()
    .set_hdr_image(hdr_image)
    .set_quality(90, 85)  // base quality, gainmap quality
    .set_gainmap_scale(4) // 1/4 resolution gain map
    .set_target_display_peak(1000.0) // nits
    .encode()?;

std::fs::write("output.jpg", &ultrahdr_jpeg)?;
```

### Decoding

```rust
use ultrahdr_rs::Decoder;

let data = std::fs::read("ultrahdr.jpg")?;
let decoder = Decoder::new(&data)?;

if decoder.is_ultrahdr() {
    // Get HDR output (4x display boost)
    let hdr = decoder.decode_hdr(4.0)?;

    // Or just get SDR
    let sdr = decoder.decode_sdr()?;

    // Inspect metadata
    let metadata = decoder.metadata();
    println!("HDR capacity: {:.1}x", metadata.unwrap().hdr_capacity_max);
}
```

### Adaptive Tonemapping (Preserve Artistic Intent)

When editing HDR content, use `AdaptiveTonemapper` to learn the original tone curve and reproduce it:

```rust
use ultrahdr_core::color::{AdaptiveTonemapper, FitConfig};

// Learn tone curve from original HDR/SDR pair
let tonemapper = AdaptiveTonemapper::fit(&original_hdr, &original_sdr)?;

// Apply to edited HDR - preserves the original artistic intent
let new_sdr = tonemapper.apply(&edited_hdr)?;
```

## Supported Formats

### Input (HDR)
- `Rgba32F` - Linear float RGBA
- `Rgba16F` - Half-float RGBA
- `P010` - 10-bit YUV (BT.2020)

### Input (SDR)
- `Rgba8` - 8-bit sRGB RGBA
- `Rgb8` - 8-bit sRGB RGB

### Output (HDR)
- `LinearFloat` - Linear RGB float
- `Pq1010102` - PQ-encoded 10-bit packed
- `Srgb8` - Clipped to SDR range

## Metadata Formats

Both XMP and ISO 21496-1 metadata are supported for maximum compatibility:

- **XMP**: Adobe hdrgm namespace, embedded in APP1 marker
- **ISO 21496-1**: Binary format with fractions, typically in APP2

## Transfer Functions

- sRGB (IEC 61966-2-1)
- PQ/ST.2084 (HDR10)
- HLG (ITU-R BT.2100)

## Color Gamuts

- BT.709 (sRGB)
- Display P3
- BT.2100/BT.2020

## Pipeline Architecture

Understanding the correct sequencing is critical for both quality and memory efficiency.

### Streaming Encode Pipeline (Recommended)

```
┌─────────────────────────────────────────────────────────────────────────────┐
│                         STREAMING ENCODE PIPELINE                           │
│                        (4 MB peak vs 165 MB batch)                          │
└─────────────────────────────────────────────────────────────────────────────┘

  HDR Source                                              Output Files
  (AVIF/JXL/                                              ┌──────────────┐
   EXR/etc)                                               │ Ultra HDR    │
      │                                                   │ JPEG         │
      ▼                                                   │ ┌──────────┐ │
┌───────────┐     ┌─────────────────────────────────┐     │ │ SDR JPEG │ │
│ Streaming │     │      COLOR MANAGEMENT           │     │ │ (primary)│ │
│ Decoder   │────▶│  ┌─────────────────────────┐    │     │ ├──────────┤ │
│ (rows)    │     │  │ 1. Input Transform      │    │     │ │ Gain Map │ │
└───────────┘     │  │    PQ/HLG → Linear      │    │     │ │ (APP15)  │ │
                  │  │    BT.2100 → Working    │    │     │ ├──────────┤ │
   16 rows        │  │    (use moxcms)         │    │     │ │ XMP      │ │
   at a time      │  └───────────┬─────────────┘    │     │ │ Metadata │ │
                  │              │                  │     │ └──────────┘ │
                  │              ▼                  │     └──────────────┘
                  │  ┌─────────────────────────┐    │
                  │  │ 2. Linear Working Space │    │
                  │  │    (HDR, scene-referred)│    │
                  │  └───────────┬─────────────┘    │
                  │              │                  │
                  │      ┌───────┴───────┐         │
                  │      │               │         │
                  │      ▼               ▼         │
                  │  ┌───────┐    ┌────────────┐   │
                  │  │ Keep  │    │ 3. Tonemap │   │
                  │  │ HDR   │    │ HDR → SDR  │   │
                  │  │ Linear│    │ (filmic/   │   │
                  │  └───┬───┘    │  adaptive) │   │
                  │      │        └─────┬──────┘   │
                  │      │              │          │
                  │      │              ▼          │
                  │      │    ┌─────────────────┐  │
                  │      │    │ 4. Output OETF  │  │
                  │      │    │    Linear→sRGB  │  │
                  │      │    │    (use moxcms) │  │
                  │      │    └────────┬────────┘  │
                  └──────│─────────────│───────────┘
                         │             │
                         ▼             ▼
               ┌─────────────────────────────────┐
               │        GAIN MAP ENCODER         │
               │  (RowEncoder / StreamEncoder)   │
               │                                 │
               │  Computes: gain = HDR/SDR       │
               │  Per-block, streaming output    │
               └────────────────┬────────────────┘
                                │
                    ┌───────────┴───────────┐
                    │                       │
                    ▼                       ▼
          ┌─────────────────┐    ┌─────────────────┐
          │  SDR JPEG       │    │  Gain Map JPEG  │
          │  Encoder        │    │  Encoder        │
          │  (streaming)    │    │  (streaming)    │
          │                 │    │                 │
          │  push_row()     │    │  push_row()     │
          └────────┬────────┘    └────────┬────────┘
                   │                      │
                   └──────────┬───────────┘
                              │
                              ▼
                   ┌─────────────────────┐
                   │   MPF Container     │
                   │   Assembly          │
                   │   + XMP Metadata    │
                   └─────────────────────┘
```

### Color Management: Where moxcms Fits

```
┌────────────────────────────────────────────────────────────────────────┐
│                    COLOR MANAGEMENT STAGES                              │
│                                                                         │
│  ┌─────────────┐      ┌─────────────┐      ┌─────────────┐            │
│  │   INPUT     │      │   WORKING   │      │   OUTPUT    │            │
│  │   SPACE     │ ───▶ │   SPACE     │ ───▶ │   SPACE     │            │
│  └─────────────┘      └─────────────┘      └─────────────┘            │
│                                                                         │
│  Examples:            Always:               Examples:                   │
│  • PQ BT.2100        • Linear              • sRGB (SDR output)        │
│  • HLG BT.2100       • Scene-referred      • Display P3               │
│  • Linear BT.2020    • Wide gamut          • PQ (HDR output)          │
│                        (BT.2020 or         • Linear (gain map)        │
│                         AP0/ACES)                                      │
│                                                                         │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │                         moxcms handles:                          │  │
│  │  • EOTF/OETF (PQ, HLG, sRGB transfer functions)                 │  │
│  │  • Chromatic adaptation (D65 ↔ D50)                              │  │
│  │  • Gamut mapping (BT.2100 → sRGB with perceptual intent)        │  │
│  │  • ICC profile generation and parsing                            │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│                                                                         │
│  ⚠️  CRITICAL: Tonemapping happens in LINEAR WORKING SPACE            │
│      Never tonemap PQ-encoded or sRGB-encoded values!                  │
│                                                                         │
└────────────────────────────────────────────────────────────────────────┘
```

### Streaming Decode Pipeline

```
┌─────────────────────────────────────────────────────────────────────────┐
│                        STREAMING DECODE PIPELINE                        │
│                         (2 MB peak vs 166 MB)                           │
└─────────────────────────────────────────────────────────────────────────┘

  Ultra HDR JPEG
        │
        ▼
┌───────────────────┐
│ Parse MPF Header  │──────────────────────────────────────┐
│ Extract offsets   │                                      │
└────────┬──────────┘                                      │
         │                                                 │
    ┌────┴────┐                                           │
    │         │                                           │
    ▼         ▼                                           ▼
┌────────┐  ┌────────────┐                        ┌─────────────┐
│ SDR    │  │ Gain Map   │                        │ XMP/ISO     │
│ JPEG   │  │ JPEG       │                        │ Metadata    │
│ Decode │  │ Decode     │                        │ Parse       │
│(stream)│  │ (full or   │                        └──────┬──────┘
└───┬────┘  │  stream)   │                               │
    │       └─────┬──────┘                               │
    │             │                                      │
    │    ┌────────┴─────────────────────────────────────┐│
    │    │         GainMapMetadata                      ││
    │    │  • min/max_content_boost                     ││
    │    │  • gamma, offsets                            ││
    │    │  • hdr_capacity_min/max                      ││
    │    └────────┬─────────────────────────────────────┘│
    │             │                                      │
    ▼             ▼                                      │
┌─────────────────────────────────────┐                  │
│        HDR RECONSTRUCTION           │◀─────────────────┘
│        (RowDecoder/StreamDecoder)   │
│                                     │
│  For each pixel:                    │
│  1. Decode gain from gain map       │
│  2. Apply: HDR = (SDR + offset_sdr) │
│            × gain^weight            │
│            - offset_hdr             │
│  3. Bilinear upsample gain map      │
└──────────────────┬──────────────────┘
                   │
                   ▼
           ┌─────────────────┐
           │ OUTPUT TRANSFORM│
           │ (if needed)     │
           │ Linear → PQ/HLG │
           └────────┬────────┘
                    │
                    ▼
              HDR Output
```

### Memory Comparison

```
┌────────────────────────────────────────────────────────────────────────┐
│                     MEMORY USAGE: 4K (3840×2160)                       │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  BATCH ENCODE (full images in memory)                                  │
│  ═══════════════════════════════════                                   │
│                                                                        │
│  Stage              Memory                                             │
│  ─────              ──────                                             │
│  Decode HDR         132 MB  ████████████████████████████████████████  │
│  + Resize buffer    +33 MB  ██████████                                │
│  + SDR copy         +33 MB  ██████████                                │
│  + Gain map          +1 MB  ▌                                         │
│  ─────────────────────────                                             │
│  PEAK:              165 MB                                             │
│                                                                        │
│  STREAMING ENCODE (row buffers)                                        │
│  ══════════════════════════════                                        │
│                                                                        │
│  Component          Memory                                             │
│  ─────────          ──────                                             │
│  Decoder buffer      1.0 MB  ███                                       │
│  Resize buffer       0.5 MB  ██                                        │
│  Tonemap (in-place)  0.0 MB                                            │
│  RowEncoder buffers  1.0 MB  ███                                       │
│  JPEG encoders       1.5 MB  █████                                     │
│  ─────────────────────────                                             │
│  PEAK:               4.0 MB  (40× reduction!)                          │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘
```

### Common Mistakes to Avoid

```
┌────────────────────────────────────────────────────────────────────────┐
│                          ❌ WRONG                                      │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  1. Tonemapping PQ-encoded values                                      │
│     ✗ let sdr = tonemap(pq_pixel);  // PQ is perceptual, not linear!  │
│     ✓ let linear = pq_eotf(pq_pixel);                                 │
│       let sdr = tonemap(linear);                                       │
│                                                                        │
│  2. Computing gain map from sRGB (not linear)                          │
│     ✗ gain = srgb_hdr / srgb_sdr;  // Wrong! sRGB is nonlinear        │
│     ✓ gain = linear_hdr / linear_sdr;                                 │
│                                                                        │
│  3. Loading full image when streaming works                            │
│     ✗ let full_image = decoder.decode_all()?;  // 132 MB              │
│     ✓ for row in decoder.rows() { ... }        // 1 MB                │
│                                                                        │
│  4. Applying sRGB OETF before gain map computation                     │
│     ✗ let sdr = srgb_oetf(linear_sdr);                                │
│       compute_gainmap(hdr_linear, sdr);  // Mixing linear and sRGB!   │
│     ✓ compute_gainmap(hdr_linear, sdr_linear);                        │
│       let sdr_output = srgb_oetf(sdr_linear);                         │
│                                                                        │
│  5. Ignoring color gamut conversion                                    │
│     ✗ SDR in BT.2020 gamut (out-of-range values)                      │
│     ✓ Convert BT.2020 → sRGB with gamut mapping before SDR output     │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘
```

### Correct Pipeline Order

```
┌────────────────────────────────────────────────────────────────────────┐
│                          ✓ CORRECT ORDER                               │
├────────────────────────────────────────────────────────────────────────┤
│                                                                        │
│  ENCODE:                                                               │
│  ═══════                                                               │
│  1. Decode HDR source (get encoded pixels)                             │
│  2. Apply EOTF (PQ/HLG → Linear)           ← moxcms                   │
│  3. Convert gamut to working space         ← moxcms                   │
│  4. Tonemap (linear HDR → linear SDR)      ← ultrahdr-core            │
│  5. Compute gain map (both in linear!)     ← ultrahdr-core            │
│  6. Convert SDR gamut to output space      ← moxcms                   │
│  7. Apply OETF (Linear → sRGB)             ← moxcms                   │
│  8. Encode SDR JPEG                        ← zenjpeg                  │
│  9. Encode gain map JPEG                   ← zenjpeg                  │
│  10. Assemble MPF container + XMP          ← ultrahdr-core            │
│                                                                        │
│  DECODE:                                                               │
│  ═══════                                                               │
│  1. Parse MPF, extract SDR + gain map JPEGs                            │
│  2. Parse XMP/ISO metadata                  ← ultrahdr-core           │
│  3. Decode SDR JPEG                         ← zenjpeg                 │
│  4. Decode gain map JPEG                    ← zenjpeg                 │
│  5. Apply EOTF to SDR (sRGB → Linear)      ← moxcms                   │
│  6. Apply gain map (in linear space!)       ← ultrahdr-core           │
│  7. Convert gamut if needed                 ← moxcms                   │
│  8. Apply OETF for output (Linear → PQ)    ← moxcms                   │
│                                                                        │
└────────────────────────────────────────────────────────────────────────┘
```

## Streaming APIs (Low Memory)

For memory-constrained environments, `ultrahdr-core` provides streaming APIs that process images row-by-row:

```rust
use ultrahdr_core::gainmap::streaming::{RowDecoder, RowEncoder};
```

| Type | Direction | Memory | Use Case |
|------|-----------|--------|----------|
| `RowDecoder` | SDR+gainmap→HDR | Full gainmap in RAM | Gainmap fits in memory |
| `StreamDecoder` | SDR+gainmap→HDR | 16-row ring buffer | Parallel JPEG decode |
| `RowEncoder` | HDR+SDR→gainmap | Synchronized batches | Same-rate inputs |
| `StreamEncoder` | HDR+SDR→gainmap | Independent buffers | Parallel decode sources |

### Streaming Decode Example

```rust
use ultrahdr_core::gainmap::streaming::RowDecoder;
use ultrahdr_core::ColorGamut;

// Load gainmap fully, then stream SDR rows (linear f32)
let mut decoder = RowDecoder::new(
    gainmap, metadata, width, height, 4.0, ColorGamut::Bt709
)?;

// Process in 16-row batches (JPEG MCU alignment)
// Note: SDR input must be linear f32 RGB (3 floats per pixel)
for batch_start in (0..height).step_by(16) {
    let batch_height = 16.min(height - batch_start);
    let sdr_batch = jpeg_decoder.next_rows(batch_height); // linear f32
    let hdr_batch = decoder.process_sdr_rows(&sdr_batch, batch_height)?;
    write_output(&hdr_batch);
}
```

### Memory Savings (4K image)

| API | Peak Memory |
|-----|-------------|
| Full decode | ~166 MB |
| Streaming (16 rows) | ~2 MB |

## Streaming Tonemapper

`StreamingTonemapper` provides high-quality HDR→SDR tonemapping in a single streaming pass with local adaptation.

### Semantics

```
┌────────────────────────────────────────────────────────────────────────┐
│                    STREAMING TONEMAPPER FLOW                           │
│                                                                         │
│   Input                    Internal                      Output         │
│   ──────                   ────────                      ──────         │
│                                                                         │
│   Row 0  ─────┐                                                        │
│   Row 1  ─────┤       ┌─────────────────────┐                          │
│   Row 2  ─────┼──────▶│   Lookahead Buffer  │                          │
│    ...   ─────┤       │   (ring buffer)     │                          │
│   Row N  ─────┘       │   Default: 64 rows  │                          │
│                       └──────────┬──────────┘                          │
│                                  │                                     │
│                                  ▼                                     │
│                       ┌─────────────────────┐                          │
│                       │  Local Adaptation   │                          │
│                       │  Grid (1/8 res)     │                          │
│                       │  • Per-cell stats   │                          │
│                       │  • Key (geo mean)   │                          │
│                       │  • White point      │                          │
│                       └──────────┬──────────┘                          │
│                                  │                                     │
│   ⚠️ OUTPUT LAG                  │                                     │
│   ═════════════                  ▼                                     │
│                       ┌─────────────────────┐       Row 0 ────▶       │
│   After pushing       │    Tonemap with     │       Row 1 ────▶       │
│   row 32, you get     │  local adaptation   │       Row 2 ────▶       │
│   row 0 out           │  • AgX highlights   │        ...              │
│                       │  • Shadow lift      │                          │
│   Lag = lookahead/2   └─────────────────────┘                          │
│       = 32 rows                                                        │
│                                                                         │
└────────────────────────────────────────────────────────────────────────┘
```

**Key points:**
- **Output lag**: Rows come out `lookahead_rows / 2` behind input (default: 32 rows)
- **Row order preserved**: Output row indices match input, just delayed
- **Call `finish()`**: Required to flush remaining rows after all input is pushed
- **Memory**: ~6 MB for 4K (grid + row buffer)

### API

```rust
use ultrahdr_core::color::{StreamingTonemapper, StreamingTonemapConfig};

// Configure (defaults shown)
let config = StreamingTonemapConfig {
    channels: 4,          // 3 for RGB, 4 for RGBA
    lookahead_rows: 64,   // Buffer size (affects quality & lag)
    cell_size: 8,         // Local adaptation grid: image_size / cell_size
    target_key: 0.18,     // Target mid-gray
    contrast: 1.1,        // Subtle contrast boost
    saturation: 0.95,     // Slight highlight desaturation
    shadow_lift: 0.02,    // Lift shadows slightly
    desat_threshold: 0.5, // Start desaturating at 50% of white
};

let mut tm = StreamingTonemapper::new(width, height, config)?;

// Push rows: (data, stride, num_rows)
// stride = elements between row starts (>= width * channels)
let outputs = tm.push_rows(&hdr_buffer, stride, num_rows)?;

// Process outputs as they become ready
for out in outputs {
    // out.row_index: which row this is (may not be sequential!)
    // out.sdr_linear: linear f32 SDR data, ready for OETF
    let srgb = tm.linear_to_srgb8(&out.sdr_linear);
    jpeg_encoder.push_row(&srgb)?;
}

// Flush remaining rows (REQUIRED!)
for out in tm.finish()? {
    let srgb = tm.linear_to_srgb8(&out.sdr_linear);
    jpeg_encoder.push_row(&srgb)?;
}
```

### Output Ordering

Because of the lookahead buffer, outputs may not arrive in order during streaming.
The `row_index` field tells you which row each output corresponds to.

```rust
// If you need sequential output (e.g., for JPEG encoder), buffer and sort:
let mut pending: BTreeMap<u32, Vec<f32>> = BTreeMap::new();
let mut next_to_emit = 0u32;

for out in tm.push_rows(&data, stride, rows)? {
    pending.insert(out.row_index, out.sdr_linear);

    // Emit any consecutive rows starting from next_to_emit
    while let Some(row) = pending.remove(&next_to_emit) {
        jpeg_encoder.push_row(&tm.linear_to_srgb8(&row))?;
        next_to_emit += 1;
    }
}
```

### Memory Usage

| Image Size | Lookahead | Grid | Buffers | Total |
|------------|-----------|------|---------|-------|
| 1920×1080  | 64 rows   | 0.5 MB | 2 MB  | ~2.5 MB |
| 3840×2160  | 64 rows   | 2 MB   | 4 MB  | ~6 MB   |
| 7680×4320  | 64 rows   | 8 MB   | 8 MB  | ~16 MB  |

Compare to full-frame tonemapping: 132 MB for 4K (entire image in RAM).

## Using ultrahdr-core with zenjpeg Directly

For more control, use `ultrahdr-core` (math + metadata only) with `zenjpeg` for JPEG operations:

### Encoding UltraHDR

```rust
use ultrahdr_core::{
    gainmap::compute::{compute_gainmap, GainMapConfig},
    metadata::xmp::generate_xmp,
    RawImage, PixelFormat, ColorGamut, ColorTransfer, Unstoppable,
};
use zenjpeg::encoder::{EncoderConfig, PixelLayout, ChromaSubsampling, Unstoppable as ZenjpegStop};

// 1. Compute gain map from HDR + SDR
let config = GainMapConfig::default();
let (gainmap, metadata) = compute_gainmap(&hdr_image, &sdr_image, &config, Unstoppable)?;

// 2. Encode gain map to JPEG
let gainmap_jpeg = {
    let cfg = EncoderConfig::grayscale(75.0);
    let mut enc = cfg.encode_from_bytes(gainmap.width, gainmap.height, PixelLayout::Gray8Srgb)?;
    enc.push_packed(&gainmap.data, ZenjpegStop)?;
    enc.finish()?
};

// 3. Generate XMP metadata
let xmp = generate_xmp(&metadata, gainmap_jpeg.len());

// 4. Encode UltraHDR with embedded gain map
let ultrahdr = {
    let cfg = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter)
        .add_gainmap(gainmap_jpeg);
    let mut enc = cfg.request()
        .xmp(xmp.as_bytes())
        .encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
    enc.push_packed(&sdr_rgb, ZenjpegStop)?;
    enc.finish()?
};
```

### Decoding UltraHDR

```rust
use ultrahdr_core::{
    gainmap::apply::{apply_gainmap, HdrOutputFormat},
    metadata::xmp::parse_xmp,
    GainMap, RawImage, Unstoppable,
};
use zenjpeg::decoder::{Decoder, PreserveConfig};

// 1. Decode with metadata preservation
let decoded = Decoder::new()
    .preserve(PreserveConfig::default())
    .decode(&ultrahdr_jpeg, Unstoppable)?;

let extras = decoded.extras().expect("extras");

// 2. Parse XMP metadata
let xmp_str = extras.xmp().expect("XMP");
let (metadata, _) = parse_xmp(xmp_str)?;

// 3. Decode gain map JPEG
let gainmap_jpeg = extras.gainmap().expect("gainmap");
let gainmap_decoded = Decoder::new().decode(gainmap_jpeg, Unstoppable)?;

// 4. Build RawImage and GainMap structs
let sdr = RawImage::from_data(
    decoded.width(), decoded.height(),
    PixelFormat::Rgba8, ColorGamut::Bt709, ColorTransfer::Srgb,
    rgba_pixels,
)?;
let gainmap = GainMap {
    width: gainmap_decoded.width(),
    height: gainmap_decoded.height(),
    channels: 1,
    data: gainmap_decoded.pixels_u8().unwrap().to_vec(),
};

// 5. Apply gain map to reconstruct HDR
let hdr = apply_gainmap(&sdr, &gainmap, &metadata, 4.0, HdrOutputFormat::LinearFloat, Unstoppable)?;
```

### Lossless Round-Trip (Edit SDR, Preserve Gain Map)

```rust
// Decode
let decoded = Decoder::new().preserve(PreserveConfig::default()).decode(&ultrahdr, Unstoppable)?;
let extras = decoded.extras().unwrap();

// Edit SDR pixels...
let edited_sdr: Vec<u8> = /* your edits */;

// Re-encode preserving XMP + gainmap
let encoder_segments = extras.to_encoder_segments();
let cfg = EncoderConfig::ycbcr(90.0, ChromaSubsampling::Quarter)
    .with_segments(encoder_segments);  // Preserves XMP + gainmap
let mut enc = cfg.encode_from_bytes(width, height, PixelLayout::Rgb8Srgb)?;
enc.push_packed(&edited_sdr, ZenjpegStop)?;
let re_encoded = enc.finish()?;
```

## Cooperative Cancellation

Long-running operations accept an `impl Stop` parameter from the [`enough`](https://crates.io/crates/enough) crate for cooperative cancellation:

```rust
use ultrahdr_core::{Unstoppable, Stop};
use enough::AtomicStop;

// Simple usage - no cancellation
let (gainmap, metadata) = compute_gainmap(&hdr, &sdr, &config, Unstoppable)?;

// With cancellation support
let stop = AtomicStop::new();
let stop_clone = stop.clone();
std::thread::spawn(move || {
    std::thread::sleep(Duration::from_secs(5));
    stop_clone.stop();
});
let result = compute_gainmap(&hdr, &sdr, &config, &stop);
```

## Known Differences from libultrahdr

This implementation aims for compatibility with [Google's libultrahdr](https://github.com/google/libultrahdr) reference implementation, but has the following known differences:

### XMP Metadata Validation (ultrahdr-core)

| Behavior | libultrahdr | This Implementation |
|----------|-------------|---------------------|
| `BaseRenditionIsHDR="True"` | **Rejected** with error | ⚠️ Accepted (should reject) |
| Required fields (Version, GainMapMax, HDRCapacityMax) | **All required** | ⚠️ Only checks if Version OR GainMapMax present |
| Unparseable field values | **Error** | ⚠️ Silently uses defaults |

### JPEG Boundary Detection

| Behavior | libultrahdr | This Implementation |
|----------|-------------|---------------------|
| Primary method | JpegScanner (SOI/EOI markers) | MPF directory parsing |
| Fallback | N/A | SOI/EOI marker scanning |
| Marker-aware scanning | Yes (skips marker payloads) | ⚠️ Simple scan in ultrahdr-core, robust scan in zenjpeg |
| >2 images warning | Yes | No |

### Practical Impact

- Files with `BaseRenditionIsHDR="True"` (rare) may decode incorrectly
- Files with missing required XMP fields may use incorrect default values
- Detection should work for all standard Ultra HDR files

### Tracking

These differences are tracked for future fixes. Contributions welcome.

## Image tech I maintain

| | |
|:--|:--|
| State of the art codecs* | [zenjpeg] · [zenpng] · [zenwebp] · [zengif] · [zenavif] ([rav1d-safe] · [zenrav1e] · [zenavif-parse] · [zenavif-serialize]) · [zenjxl] ([jxl-encoder] · [zenjxl-decoder]) · [zentiff] · [zenbitmaps] · [heic] · [zenraw] · [zenpdf] · **ultrahdr** · [mozjpeg-rs] · [webpx] |
| Compression | [zenflate] · [zenzop] |
| Processing | [zenresize] · [zenfilters] · [zenquant] · [zenblend] |
| Metrics | [zensim] · [fast-ssim2] · [butteraugli] · [resamplescope-rs] · [codec-eval] · [codec-corpus] |
| Pixel types & color | [zenpixels] · [zenpixels-convert] · [linear-srgb] · [garb] |
| Pipeline | [zenpipe] · [zencodec] · [zencodecs] · [zenlayout] · [zennode] |
| ImageResizer | [ImageResizer] (C#) — 24M+ NuGet downloads across all packages |
| [Imageflow][] | Image optimization engine (Rust) — [.NET][imageflow-dotnet] · [node][imageflow-node] · [go][imageflow-go] — 9M+ NuGet downloads across all packages |
| [Imageflow Server][] | [The fast, safe image server](https://www.imazen.io/) (Rust+C#) — 552K+ NuGet downloads, deployed by Fortune 500s and major brands |

<sub>* as of 2026</sub>

### General Rust awesomeness

[archmage] · [magetypes] · [enough] · [whereat] · [zenbench] · [cargo-copter]

[And other projects](https://www.imazen.io/open-source) · [GitHub @imazen](https://github.com/imazen) · [GitHub @lilith](https://github.com/lilith) · [lib.rs/~lilith](https://lib.rs/~lilith) · [NuGet](https://www.nuget.org/profiles/imazen) (over 30 million downloads / 87 packages)

## License

Apache-2.0

## AI-Generated Code Notice

This library was developed with assistance from Claude (Anthropic). The implementation has been tested against reference Ultra HDR images and passes comprehensive unit tests. Not all code has been manually reviewed - please review critical paths before production use.

[zenjpeg]: https://github.com/imazen/zenjpeg
[zenpng]: https://github.com/imazen/zenpng
[zenwebp]: https://github.com/imazen/zenwebp
[zengif]: https://github.com/imazen/zengif
[zenavif]: https://github.com/imazen/zenavif
[zenjxl]: https://github.com/imazen/zenjxl
[zentiff]: https://github.com/imazen/zentiff
[zenbitmaps]: https://github.com/imazen/zenbitmaps
[heic]: https://github.com/imazen/heic-decoder-rs
[zenraw]: https://github.com/imazen/zenraw
[zenpdf]: https://github.com/imazen/zenpdf
[jxl-encoder]: https://github.com/imazen/jxl-encoder
[zenjxl-decoder]: https://github.com/imazen/zenjxl-decoder
[rav1d-safe]: https://github.com/imazen/rav1d-safe
[zenrav1e]: https://github.com/imazen/zenrav1e
[mozjpeg-rs]: https://github.com/imazen/mozjpeg-rs
[zenavif-parse]: https://github.com/imazen/zenavif-parse
[zenavif-serialize]: https://github.com/imazen/zenavif-serialize
[webpx]: https://github.com/imazen/webpx
[zenflate]: https://github.com/imazen/zenflate
[zenzop]: https://github.com/imazen/zenzop
[zenresize]: https://github.com/imazen/zenresize
[zenfilters]: https://github.com/imazen/zenfilters
[zenquant]: https://github.com/imazen/zenquant
[zenblend]: https://github.com/imazen/zenblend
[zensim]: https://github.com/imazen/zensim
[fast-ssim2]: https://github.com/imazen/fast-ssim2
[butteraugli]: https://github.com/imazen/butteraugli
[zenpixels]: https://github.com/imazen/zenpixels
[zenpixels-convert]: https://github.com/imazen/zenpixels
[linear-srgb]: https://github.com/imazen/linear-srgb
[garb]: https://github.com/imazen/garb
[zenpipe]: https://github.com/imazen/zenpipe
[zencodec]: https://github.com/imazen/zencodec
[zencodecs]: https://github.com/imazen/zencodecs
[zenlayout]: https://github.com/imazen/zenlayout
[zennode]: https://github.com/imazen/zennode
[Imageflow]: https://github.com/imazen/imageflow
[Imageflow Server]: https://github.com/imazen/imageflow-server
[imageflow-dotnet]: https://github.com/imazen/imageflow-dotnet
[imageflow-node]: https://github.com/imazen/imageflow-node
[imageflow-go]: https://github.com/imazen/imageflow-go
[ImageResizer]: https://github.com/imazen/resizer
[archmage]: https://github.com/imazen/archmage
[magetypes]: https://github.com/imazen/archmage
[enough]: https://github.com/imazen/enough
[whereat]: https://github.com/lilith/whereat
[zenbench]: https://github.com/imazen/zenbench
[cargo-copter]: https://github.com/imazen/cargo-copter
[resamplescope-rs]: https://github.com/imazen/resamplescope-rs
[codec-eval]: https://github.com/imazen/codec-eval
[codec-corpus]: https://github.com/imazen/codec-corpus