diff --git a/.gitignore b/.gitignore
index e76cea6..0c9285c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,3 +33,4 @@ coverage/
# Example build output
example/build/
example/.dart_tool/
+cp_test.dng
diff --git a/.pubignore b/.pubignore
index a2e6bd4..2a1e326 100644
--- a/.pubignore
+++ b/.pubignore
@@ -1 +1,4 @@
doc/
+build/
+example/build/
+*.dng
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e435c8f..93df057 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- **HDR / single-capture local tone mapping** — `CameraProController.captureHdr({stops})`
+ renders one tone-mapped HDR still. It captures a **single** frame (so there is
+ no motion ghosting), synthesizes an exposure stack from it by scaling in linear
+ light at each EV in `stops` (default `[-3, -1.5, 0, 1.5, 3]`), and fuses the
+ stack with **multi-scale Mertens exposure fusion** — contrast × saturation ×
+ well-exposedness weights blended through a Laplacian pyramid, so local contrast
+ is preserved with no halos. Implemented in the C core
+ (`camera_pro_local_tonemap` + a rewritten multi-scale `camera_pro_exposure_fusion`)
+ with a pure-Dart port for web (cross-checked to a few LSB). Exposed through the
+ backend contract as `renderHdr`, advertised via `capabilities.supportsHdr`, and
+ wired into both example apps (an HDR button). Verified live on the FaceTime HD
+ camera: the result is pixel-sharp and balanced (shadows opened, highlights held,
+ local contrast intact). The C harness gains fusion + tone-mapping tests, at 78
+ checks (arm64 + x86_64/Rosetta).
+
## [0.0.2] - 2026-07-07
### Changed
diff --git a/README.md b/README.md
index 942f9cc..0128c8d 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ A Flutter camera package built on a shared C/C++ core with a crash-proof Dart AP
> **Project status: working camera engine (v0.0.2, pre-release)**
>
-> On macOS the example app opens the real camera and does live preview, all six manual controls, five live visual-aid overlays (histogram, focus peaking, zebra, false color, waveform — GPU-accelerated via Metal where available), PNG + RAW/DNG capture with EXIF, burst, EV bracketing, and H.264 video recording — every one of those verified live against real hardware. The same AVFoundation backend compiles for iOS with sensor-level manual controls. **Web** runs in the browser too: a getUserMedia backend with live preview, capture, and the visual aids reimplemented in pure Dart — verified in Chrome with screenshots ([see below](#web)). Linux (V4L2) and Windows (Media Foundation) backends implement the full HAL contract and pass CI on real ubuntu/windows runners (camera-hardware runtime pending machines with cameras). Android is not started — see [ROADMAP.md](ROADMAP.md) for the honest gate on every remaining item.
+> On macOS the example app opens the real camera and does live preview, all six manual controls, five live visual-aid overlays (histogram, focus peaking, zebra, false color, waveform — GPU-accelerated via Metal where available), PNG + RAW/DNG capture with EXIF, burst, EV bracketing, single-capture HDR/local tone mapping, and H.264 video recording — every one of those verified live against real hardware. The same AVFoundation backend compiles for iOS with sensor-level manual controls. **Web** runs in the browser too: a getUserMedia backend with live preview, capture, and the visual aids reimplemented in pure Dart — verified in Chrome with screenshots ([see below](#web)). Linux (V4L2) and Windows (Media Foundation) backends implement the full HAL contract and pass CI on real ubuntu/windows runners (camera-hardware runtime pending machines with cameras). Android is not started — see [ROADMAP.md](ROADMAP.md) for the honest gate on every remaining item.
---
@@ -129,6 +129,25 @@ Burst and exposure bracketing run through the same capture path:

+`captureHdr()` renders one tone-mapped HDR still. A temporal bracket on a
+hand-held camera ghosts (the frames are ~⅓ s apart), so instead it captures a
+**single** frame and synthesizes an exposure stack from it — scaling it in
+linear light at a range of EV offsets — then fuses that stack with **multi-scale
+[Mertens exposure fusion](https://en.wikipedia.org/wiki/Exposure_fusion)**: each
+synthetic exposure is weighted per pixel by contrast (|Laplacian|), saturation,
+and well-exposedness, and blended through a Laplacian pyramid so local contrast
+is preserved with no seams or halos. Because every exposure comes from one
+instant, the result is **sharp and ghost-free** — genuine single-capture local
+tone mapping. The C core and the pure-Dart web port share the algorithm
+(cross-checked to a few LSB).
+
+
+
+Verified live on the FaceTime HD camera: the tone-mapped still is pixel-sharp
+(no ghosting) and balances the frame — shadows opened, highlights held, local
+contrast preserved. On a dark scene it lifts a mid exposure from ~9 to ~90 mean
+luma; on a bright scene it gently compresses the range.
+
| Feature | Status | Notes |
|---|---|---|
| `capturePhoto()` API surface | ✅ | Method exists, capability-guarded, typed error on failure |
@@ -137,7 +156,8 @@ Burst and exposure bracketing run through the same capture path:
| RAW/DNG capture | ✅ | Dependency-free linear-DNG writer with EXIF; ffmpeg-verified from the real camera |
| EXIF embedding | ✅ | ISO, exposure time, timestamps in the DNG's EXIF IFD |
| libjpeg-turbo integration | — | Skipped by design (PNG via dart:ui + DNG cover stills) |
-| Burst / EV bracket | ✅ | Verified: 5-shot burst ~1.2s; bracket YAVG 25.8/96.9/183.4. HDR fusion ❌ |
+| Burst / EV bracket | ✅ | Verified: 5-shot burst ~1.2s; bracket YAVG 25.8/96.9/183.4 |
+| HDR / local tone mapping | ✅ | `captureHdr()` — single-frame synthesis + multi-scale Mertens fusion. Verified live: sharp, ghost-free, balanced |
### Video
diff --git a/ROADMAP.md b/ROADMAP.md
index bdaefb5..ebcbada 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -57,7 +57,7 @@ unverifiable device code).
| Live histogram / focus peaking / zebra / false color / waveform | ✅ all five live overlays |
| RAW/DNG + EXIF (ISO, exposure, timestamps) | ✅ no libtiff/libexif needed |
| Burst / EV bracketing | ✅ |
-| HDR fusion (merge brackets into one image) | ❌ (brackets are captured; fusion algorithm not written) |
+| HDR / local tone mapping | ✅ `captureHdr()` — single-frame exposure synthesis + multi-scale Mertens fusion (C core + pure-Dart web port); sharp, ghost-free, verified live |
| libjpeg-turbo | skipped by design — PNG via dart:ui + DNG cover stills today |
## Phase 5 — GPU Visual Aids ✅ Metal · ⛔ others
@@ -111,4 +111,4 @@ unverifiable device code).
| Linux/Windows camera runtime validation | machines with cameras (CI validates compile + lifecycle) |
| Streaming transport | RTMP/SRT client implementation + an endpoint to verify against |
| Web WebGPU compute path | pure engineering — CPU pure-Dart kernels ship today; WebGPU is an optimization |
-| HDR fusion, HEVC/ProRes selection, texture-based preview, ffigen swap | pure engineering time — no hardware gate |
+| HEVC/ProRes selection, texture-based preview, ffigen swap | pure engineering time — no hardware gate (HDR fusion ✅ shipped) |
diff --git a/doc/diagrams/README.md b/doc/diagrams/README.md
index fb5fac7..858d3e7 100644
--- a/doc/diagrams/README.md
+++ b/doc/diagrams/README.md
@@ -64,6 +64,16 @@ takes three at −2 / 0 / +2 EV, with measured mean luminance.

+## HDR / single-capture local tone mapping
+
+`captureHdr()` captures one frame, synthesizes an exposure stack from it (scaling
+in linear light at several EV offsets), and fuses the stack with multi-scale
+Mertens exposure fusion — contrast × saturation × well-exposedness weights
+blended through a Laplacian pyramid. One instant in, so the tone-mapped result is
+sharp and ghost-free.
+
+
+
## CI matrix
`native.yml` runs on every push across macOS, Ubuntu, Windows, and web — every
diff --git a/doc/diagrams/hdr-fusion.svg b/doc/diagrams/hdr-fusion.svg
new file mode 100644
index 0000000..b829385
--- /dev/null
+++ b/doc/diagrams/hdr-fusion.svg
@@ -0,0 +1,73 @@
+
+
diff --git a/example/lib/main.dart b/example/lib/main.dart
index cdb8816..f077900 100644
--- a/example/lib/main.dart
+++ b/example/lib/main.dart
@@ -228,6 +228,18 @@ class _CapabilityPageState extends State {
}
}
+ Future _hdr() async {
+ final controller = _controller;
+ if (controller == null) return;
+ try {
+ final photo = await controller.captureHdr();
+ setState(() => _savedPath = photo.path);
+ _showSnack('HDR: tone-mapped → ${photo.width}x${photo.height}');
+ } on Object catch (e) {
+ setState(() => _error = '$e');
+ }
+ }
+
Future _toggleRecording() async {
final controller = _controller;
if (controller == null) return;
@@ -300,6 +312,13 @@ class _CapabilityPageState extends State {
child: const Icon(Icons.exposure),
),
const SizedBox(width: 12),
+ FloatingActionButton.small(
+ heroTag: 'hdr',
+ tooltip: 'HDR fusion (-2/0/+2)',
+ onPressed: _hdr,
+ child: const Icon(Icons.hdr_on),
+ ),
+ const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'capture',
onPressed: _attemptCapture,
diff --git a/example/lib/web_main.dart b/example/lib/web_main.dart
index 81f9022..c43a444 100644
--- a/example/lib/web_main.dart
+++ b/example/lib/web_main.dart
@@ -222,21 +222,36 @@ class _WebCameraPageState extends State {
if (controller == null) return;
try {
final photo = await controller.capturePhoto();
- final bytes = photo.bytes;
- if (bytes == null) return;
- final c = Completer();
- ui.decodeImageFromPixels(
- bytes, photo.width, photo.height, ui.PixelFormat.rgba8888, c.complete);
- final img = await c.future;
- setState(() {
- _captured?.dispose();
- _captured = img;
- });
+ await _showCaptured(photo);
} on Object catch (e) {
setState(() => _error = '$e');
}
}
+ Future _hdr() async {
+ final controller = _controller;
+ if (controller == null) return;
+ try {
+ final photo = await controller.captureHdr();
+ await _showCaptured(photo);
+ } on Object catch (e) {
+ setState(() => _error = '$e');
+ }
+ }
+
+ Future _showCaptured(CapturedPhoto photo) async {
+ final bytes = photo.bytes;
+ if (bytes == null) return;
+ final c = Completer();
+ ui.decodeImageFromPixels(
+ bytes, photo.width, photo.height, ui.PixelFormat.rgba8888, c.complete);
+ final img = await c.future;
+ setState(() {
+ _captured?.dispose();
+ _captured = img;
+ });
+ }
+
@override
void dispose() {
_timer?.cancel();
@@ -330,6 +345,13 @@ class _WebCameraPageState extends State {
label: Text(_recording ? 'Stop' : 'Record'),
),
const SizedBox(width: 12),
+ FloatingActionButton.small(
+ heroTag: 'hdr',
+ tooltip: 'HDR fusion (-2/0/+2 EV)',
+ onPressed: _hdr,
+ child: const Icon(Icons.hdr_on),
+ ),
+ const SizedBox(width: 12),
FloatingActionButton.extended(
heroTag: 'cap',
onPressed: _capture,
diff --git a/lib/src/controller/camera_backend.dart b/lib/src/controller/camera_backend.dart
index 849fc7d..c97d603 100644
--- a/lib/src/controller/camera_backend.dart
+++ b/lib/src/controller/camera_backend.dart
@@ -81,6 +81,19 @@ abstract interface class CameraBackend {
// ── Capture ──
Future capturePhoto({ImageFormat? format});
+
+ /// Tone-maps a single captured [frame] into an HDR still: synthesizes an
+ /// exposure stack from it at [stops] and runs multi-scale exposure fusion,
+ /// then encodes with this backend's still encoder. Single-frame, so the
+ /// result is sharp and ghost-free. Used by the controller's HDR capture path.
+ Future renderHdr(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ required List stops,
+ bool isBgra = true,
+ });
+
Future startVideoRecording(String path);
Future stopVideoRecording();
@@ -161,6 +174,16 @@ class StubCameraBackend implements CameraBackend {
Future capturePhoto({ImageFormat? format}) async =>
_unsupported('capturePhoto');
+ @override
+ Future renderHdr(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ required List stops,
+ bool isBgra = true,
+ }) async =>
+ _unsupported('renderHdr');
+
@override
Future startVideoRecording(String path) async => _unsupported('recording');
diff --git a/lib/src/controller/camera_pro_controller.dart b/lib/src/controller/camera_pro_controller.dart
index 6951aa3..b917c32 100644
--- a/lib/src/controller/camera_pro_controller.dart
+++ b/lib/src/controller/camera_pro_controller.dart
@@ -390,11 +390,61 @@ class CameraProController {
photos.add(await capturePhoto(format: format));
}
} finally {
- await setExposureCompensation(previous);
+ // Best-effort restore; a failure here must not mask a capture error.
+ try {
+ await setExposureCompensation(previous);
+ } on Object {
+ // ignore
+ }
}
return photos;
}
+ /// Captures a single frame and renders one HDR still from it with local tone
+ /// mapping: an exposure stack is synthesized from the frame (gain = 2^ev for
+ /// each ev in [stops], in linear light) and fused with multi-scale exposure
+ /// fusion, lifting shadows and taming highlights while preserving local
+ /// contrast. Because it uses one instant, the result is sharp and ghost-free.
+ ///
+ /// This is the right model for cameras without sensor-level exposure
+ /// bracketing (all current backends): a temporal bracket on a hand-held or
+ /// moving subject would ghost. Throws [CameraFeatureNotSupportedError] when
+ /// the backend can't render HDR.
+ Future captureHdr({
+ List stops = const [-3.0, -1.5, 0.0, 1.5, 3.0],
+ }) async {
+ if (!_capabilities.supportsHdr) {
+ throw CameraFeatureNotSupportedError(
+ feature: 'HDR fusion',
+ platformReason: 'Backend does not support HDR capture',
+ );
+ }
+ if (stops.isEmpty) {
+ throw CameraInvalidParameterError(message: 'HDR needs >= 1 EV stop');
+ }
+ if (!state.canCapture) {
+ throw CameraStateException('Cannot capture in state ${state.name}');
+ }
+ _stateMachine.transition(CameraState.capturing);
+ try {
+ final frame = _backend.latestFrame();
+ if (frame == null) {
+ throw CameraCaptureError(reason: CaptureFailureReason.noFrame);
+ }
+ return await _backend.renderHdr(
+ frame.bytes,
+ width: frame.width,
+ height: frame.height,
+ isBgra: frame.isBgra,
+ stops: stops,
+ );
+ } finally {
+ if (_stateMachine.canTransitionTo(CameraState.previewing)) {
+ _stateMachine.transition(CameraState.previewing);
+ }
+ }
+ }
+
/// Starts a live stream. The API is modelled; the native RTMP/SRT client is
/// roadmap, so this currently throws a typed error rather than pretending.
Future startStreaming(StreamConfig config) async {
diff --git a/lib/src/ffi/camera_pro_bindings.dart b/lib/src/ffi/camera_pro_bindings.dart
index 659a753..21829e6 100644
--- a/lib/src/ffi/camera_pro_bindings.dart
+++ b/lib/src/ffi/camera_pro_bindings.dart
@@ -264,6 +264,50 @@ external int camera_pro_box_blur(
int radius,
);
+// HDR exposure fusion — O(n*w*h) work, so NOT a leaf call.
+@ffi.Native<
+ ffi.Int32 Function(
+ ffi.Pointer,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Pointer,
+ )>()
+external int camera_pro_exposure_fusion(
+ ffi.Pointer frames,
+ int n,
+ int width,
+ int height,
+ int stride,
+ int isBgra,
+ ffi.Pointer out,
+);
+
+// Single-capture local tone mapping (synthesize stack from one frame, fuse).
+@ffi.Native<
+ ffi.Int32 Function(
+ ffi.Pointer,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Int32,
+ ffi.Pointer,
+ ffi.Int32,
+ ffi.Pointer,
+ )>()
+external int camera_pro_local_tonemap(
+ ffi.Pointer frame,
+ int width,
+ int height,
+ int stride,
+ int isBgra,
+ ffi.Pointer evs,
+ int nEv,
+ ffi.Pointer out,
+);
+
// ── Linear-DNG (RAW) writer ─────────────────────────────────────────────────
@ffi.Native<
diff --git a/lib/src/ffi/native_core.dart b/lib/src/ffi/native_core.dart
index 5e53f89..4eb47f0 100644
--- a/lib/src/ffi/native_core.dart
+++ b/lib/src/ffi/native_core.dart
@@ -240,6 +240,81 @@ class NativeCore {
pkg_ffi.malloc.free(buf);
}
}
+
+ /// Fuses an aligned exposure bracket into one tone-mapped RGBA image using
+ /// single-scale Mertens exposure fusion. [frames] must be same-sized,
+ /// tightly-packed RGBA/BGRA buffers (`width*height*4` bytes each). Returns a
+ /// new RGBA/BGRA buffer in the same channel order as the input.
+ static Uint8List exposureFusion(
+ List frames, {
+ required int width,
+ required int height,
+ bool isBgra = true,
+ }) {
+ if (frames.isEmpty) {
+ throw ArgumentError('exposureFusion needs at least one frame');
+ }
+ final n = frames.length;
+ final frameBytes = width * height * 4;
+ for (final f in frames) {
+ if (f.length != frameBytes) {
+ throw ArgumentError(
+ 'exposureFusion: every frame must be $frameBytes bytes '
+ '(${width}x$height RGBA); got ${f.length}');
+ }
+ }
+ final src = pkg_ffi.malloc(frameBytes * n);
+ final out = pkg_ffi.malloc(frameBytes);
+ try {
+ final srcList = src.asTypedList(frameBytes * n);
+ for (var i = 0; i < n; i++) {
+ srcList.setAll(i * frameBytes, frames[i]);
+ }
+ bindings.camera_pro_exposure_fusion(
+ src, n, width, height, width * 4, isBgra ? 1 : 0, out);
+ return Uint8List.fromList(out.asTypedList(frameBytes));
+ } finally {
+ pkg_ffi.malloc.free(src);
+ pkg_ffi.malloc.free(out);
+ }
+ }
+
+ /// Tone-maps a single RGBA/BGRA [frame] by synthesizing an exposure stack
+ /// from it (gain = 2^ev, in linear light, for each ev in [stops]) and running
+ /// multi-scale exposure fusion. Ghost-free — every synthetic exposure comes
+ /// from the same instant. Returns a new same-order buffer.
+ static Uint8List localTonemap(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ bool isBgra = true,
+ List stops = const [-3.0, -1.5, 0.0, 1.5, 3.0],
+ }) {
+ final frameBytes = width * height * 4;
+ if (frame.length != frameBytes) {
+ throw ArgumentError('localTonemap: frame must be $frameBytes bytes '
+ '(${width}x$height RGBA); got ${frame.length}');
+ }
+ if (stops.isEmpty) throw ArgumentError('localTonemap needs >= 1 stop');
+ final n = stops.length;
+ final src = pkg_ffi.malloc(frameBytes);
+ final evs = pkg_ffi.malloc(n);
+ final out = pkg_ffi.malloc(frameBytes);
+ try {
+ src.asTypedList(frameBytes).setAll(0, frame);
+ final el = evs.asTypedList(n);
+ for (var i = 0; i < n; i++) {
+ el[i] = stops[i];
+ }
+ bindings.camera_pro_local_tonemap(
+ src, width, height, width * 4, isBgra ? 1 : 0, evs, n, out);
+ return Uint8List.fromList(out.asTypedList(frameBytes));
+ } finally {
+ pkg_ffi.malloc.free(src);
+ pkg_ffi.malloc.free(evs);
+ pkg_ffi.malloc.free(out);
+ }
+ }
}
/// A managed handle to a native ring buffer pool.
diff --git a/lib/src/platform/apple/apple_camera_backend.dart b/lib/src/platform/apple/apple_camera_backend.dart
index d6dc978..dad20ac 100644
--- a/lib/src/platform/apple/apple_camera_backend.dart
+++ b/lib/src/platform/apple/apple_camera_backend.dart
@@ -20,6 +20,7 @@ import 'package:ffi/ffi.dart' as pkg_ffi;
import '../../controller/camera_backend.dart';
import '../../ffi/camera_pro_bindings.dart' as core;
import '../../ffi/hal_bindings.dart' as hal;
+import '../../ffi/native_core.dart';
import '../../models/camera_device.dart';
import '../../models/capabilities.dart';
import '../../models/capture_result.dart';
@@ -199,7 +200,7 @@ class AppleCameraBackend implements CameraBackend {
supportsRawCapture: true, // linear-DNG via the C core writer
supportsProRaw: false,
supportsBurstMode: true, // controller-level captureBurst
- supportsHdr: false,
+ supportsHdr: true, // controller-level captureHdr (fusion)
supportsBracketing: true, // controller-level captureExposureBracket
supportsDepthCapture: false,
supportsLidar: false,
@@ -493,6 +494,25 @@ class AppleCameraBackend implements CameraBackend {
);
}
+ @override
+ Future renderHdr(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ required List stops,
+ bool isBgra = true,
+ }) async {
+ final tonemapped = NativeCore.localTonemap(frame,
+ width: width, height: height, isBgra: isBgra, stops: stops);
+ final ts = DateTime.now();
+ return _encodePng(
+ PreviewFrame(
+ bytes: tonemapped, width: width, height: height, isBgra: isBgra),
+ '${Directory.systemTemp.path}/camera_pro_hdr_${ts.millisecondsSinceEpoch}.png',
+ ts,
+ );
+ }
+
String? _recordingPath;
DateTime? _recordingStart;
diff --git a/lib/src/web/native_core_web.dart b/lib/src/web/native_core_web.dart
index 2283dd1..b14ead9 100644
--- a/lib/src/web/native_core_web.dart
+++ b/lib/src/web/native_core_web.dart
@@ -7,6 +7,7 @@
/// et al. working identically on web and native.
library;
+import 'dart:math' as math;
import 'dart:typed_data';
import '../processing/histogram.dart';
@@ -346,6 +347,293 @@ class NativeCore {
}
}
}
+
+ /// Pure-Dart port of `camera_pro_exposure_fusion` — multi-scale Mertens
+ /// exposure fusion (contrast × saturation × well-exposedness, Laplacian
+ /// pyramid blend). Blends an aligned exposure bracket ([frames], same-sized
+ /// tightly-packed RGBA/BGRA buffers) into one tone-mapped image. Mirrors the C
+ /// core; float-pyramid rounding means it agrees with C to a few LSB, not bit-
+ /// exact.
+ static Uint8List exposureFusion(
+ List frames, {
+ required int width,
+ required int height,
+ bool isBgra = true,
+ }) {
+ if (frames.isEmpty) {
+ throw ArgumentError('exposureFusion needs at least one frame');
+ }
+ final n = frames.length;
+ final frameBytes = width * height * 4;
+ for (final f in frames) {
+ if (f.length != frameBytes) {
+ throw ArgumentError(
+ 'exposureFusion: every frame must be $frameBytes bytes '
+ '(${width}x$height RGBA); got ${f.length}');
+ }
+ }
+ final npx = width * height;
+ final imgs = Float64List(n * npx * 3);
+ for (var k = 0; k < n; k++) {
+ _mfLoadRgb(frames[k], width, height, width * 4, isBgra, imgs, k * npx * 3);
+ }
+ final fused = Float64List(npx * 3);
+ _mfFuse(imgs, n, width, height, fused);
+ return _mfStore(fused, width, height, isBgra);
+ }
+
+ /// Pure-Dart port of `camera_pro_local_tonemap`. Synthesizes an exposure
+ /// stack from a single [frame] (gain = 2^ev in linear light for each ev in
+ /// [stops]) and runs multi-scale exposure fusion. Ghost-free single-capture
+ /// local tone mapping.
+ static Uint8List localTonemap(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ bool isBgra = true,
+ List stops = const [-3.0, -1.5, 0.0, 1.5, 3.0],
+ }) {
+ final frameBytes = width * height * 4;
+ if (frame.length != frameBytes) {
+ throw ArgumentError('localTonemap: frame must be $frameBytes bytes '
+ '(${width}x$height RGBA); got ${frame.length}');
+ }
+ if (stops.isEmpty) throw ArgumentError('localTonemap needs >= 1 stop');
+ final npx = width * height;
+ final base = Float64List(npx * 3);
+ _mfLoadRgb(frame, width, height, width * 4, isBgra, base, 0);
+ final n = stops.length;
+ final imgs = Float64List(n * npx * 3);
+ for (var e = 0; e < n; e++) {
+ final gain = math.pow(2.0, stops[e]).toDouble();
+ final off = e * npx * 3;
+ for (var i = 0; i < npx * 3; i++) {
+ imgs[off + i] = _linToSrgb(_clamp01(_srgbToLin(base[i]) * gain));
+ }
+ }
+ final fused = Float64List(npx * 3);
+ _mfFuse(imgs, n, width, height, fused);
+ return _mfStore(fused, width, height, isBgra);
+ }
+}
+
+// ── Multi-scale exposure fusion (pure-Dart port of the C Mertens core) ──────
+
+const List _mfK = [1 / 16, 4 / 16, 6 / 16, 4 / 16, 1 / 16];
+
+double _clamp01(double v) => v < 0.0 ? 0.0 : (v > 1.0 ? 1.0 : v);
+double _srgbToLin(double c) =>
+ c <= 0.04045 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4).toDouble();
+double _linToSrgb(double c) {
+ if (c <= 0.0) return 0.0;
+ if (c >= 1.0) return 1.0;
+ return c <= 0.0031308
+ ? c * 12.92
+ : 1.055 * math.pow(c, 1.0 / 2.4).toDouble() - 0.055;
+}
+
+/// A Gaussian/Laplacian pyramid: `level[l]` is a `w[l]×h[l]` float plane.
+class _Pyr {
+ _Pyr(int width, int height) {
+ var m = width < height ? width : height, l = 1;
+ while (m > 1 && l < 16) {
+ m = (m + 1) >> 1;
+ l++;
+ }
+ levels = l;
+ var cw = width, ch = height;
+ level = [];
+ w = [];
+ h = [];
+ for (var i = 0; i < levels; i++) {
+ w.add(cw);
+ h.add(ch);
+ level.add(Float64List(cw * ch));
+ cw = (cw + 1) >> 1;
+ ch = (ch + 1) >> 1;
+ }
+ }
+ late final int levels;
+ late final List level;
+ late final List w;
+ late final List h;
+}
+
+void _mfReduce(Float64List src, int sw, int sh, Float64List dst, int dw, int dh) {
+ for (var y = 0; y < dh; y++) {
+ for (var x = 0; x < dw; x++) {
+ var acc = 0.0;
+ for (var q = -2; q <= 2; q++) {
+ var sy = 2 * y + q;
+ sy = sy < 0 ? 0 : (sy >= sh ? sh - 1 : sy);
+ for (var p = -2; p <= 2; p++) {
+ var sx = 2 * x + p;
+ sx = sx < 0 ? 0 : (sx >= sw ? sw - 1 : sx);
+ acc += _mfK[p + 2] * _mfK[q + 2] * src[sy * sw + sx];
+ }
+ }
+ dst[y * dw + x] = acc;
+ }
+ }
+}
+
+void _mfExpand(Float64List src, int sw, int sh, Float64List dst, int dw, int dh) {
+ for (var y = 0; y < dh; y++) {
+ for (var x = 0; x < dw; x++) {
+ var acc = 0.0;
+ for (var q = -2; q <= 2; q++) {
+ final yy = y - q;
+ if (yy & 1 != 0) continue;
+ var sy = yy >> 1;
+ sy = sy < 0 ? 0 : (sy >= sh ? sh - 1 : sy);
+ for (var p = -2; p <= 2; p++) {
+ final xx = x - p;
+ if (xx & 1 != 0) continue;
+ var sx = xx >> 1;
+ sx = sx < 0 ? 0 : (sx >= sw ? sw - 1 : sx);
+ acc += _mfK[p + 2] * _mfK[q + 2] * src[sy * sw + sx];
+ }
+ }
+ dst[y * dw + x] = 4.0 * acc;
+ }
+ }
+}
+
+void _mfGaussFill(_Pyr g) {
+ for (var l = 1; l < g.levels; l++) {
+ _mfReduce(g.level[l - 1], g.w[l - 1], g.h[l - 1], g.level[l], g.w[l], g.h[l]);
+ }
+}
+
+void _mfGaussToLap(_Pyr g, Float64List tmp) {
+ for (var l = 0; l < g.levels - 1; l++) {
+ _mfExpand(g.level[l + 1], g.w[l + 1], g.h[l + 1], tmp, g.w[l], g.h[l]);
+ final nn = g.w[l] * g.h[l];
+ for (var i = 0; i < nn; i++) {
+ g.level[l][i] -= tmp[i];
+ }
+ }
+}
+
+void _mfCollapse(_Pyr lap, Float64List tmp) {
+ for (var l = lap.levels - 2; l >= 0; l--) {
+ _mfExpand(lap.level[l + 1], lap.w[l + 1], lap.h[l + 1], tmp, lap.w[l], lap.h[l]);
+ final nn = lap.w[l] * lap.h[l];
+ for (var i = 0; i < nn; i++) {
+ lap.level[l][i] += tmp[i];
+ }
+ }
+}
+
+/// Fuse n interleaved-RGB float images ([0,1], w*h*3 each) into [out].
+void _mfFuse(Float64List imgs, int n, int w, int h, Float64List out) {
+ final npx = w * h;
+ const inv2s2 = 1.0 / (2.0 * 0.2 * 0.2);
+ final gray = Float64List(npx);
+ final wsum = Float64List(npx);
+ final tmp = Float64List(npx);
+ final wpyr = List<_Pyr>.generate(n, (_) => _Pyr(w, h));
+ final lpyr = _Pyr(w, h);
+ final acc = _Pyr(w, h);
+
+ for (var k = 0; k < n; k++) {
+ final base = k * npx * 3;
+ for (var i = 0; i < npx; i++) {
+ gray[i] = 0.299 * imgs[base + i * 3] +
+ 0.587 * imgs[base + i * 3 + 1] +
+ 0.114 * imgs[base + i * 3 + 2];
+ }
+ final wl = wpyr[k].level[0];
+ for (var y = 0; y < h; y++) {
+ for (var x = 0; x < w; x++) {
+ final i = y * w + x;
+ final xm = x > 0 ? x - 1 : 0, xp = x < w - 1 ? x + 1 : w - 1;
+ final ym = y > 0 ? y - 1 : 0, yp = y < h - 1 ? y + 1 : h - 1;
+ final lap = gray[y * w + xm] +
+ gray[y * w + xp] +
+ gray[ym * w + x] +
+ gray[yp * w + x] -
+ 4.0 * gray[i];
+ final c = lap < 0 ? -lap : lap;
+ final r = imgs[base + i * 3],
+ g = imgs[base + i * 3 + 1],
+ b = imgs[base + i * 3 + 2];
+ final m = (r + g + b) / 3.0;
+ final s = math.sqrt(
+ ((r - m) * (r - m) + (g - m) * (g - m) + (b - m) * (b - m)) / 3.0);
+ final zr = r - 0.5, zg = g - 0.5, zb = b - 0.5;
+ final e = math.exp(-(zr * zr + zg * zg + zb * zb) * inv2s2);
+ final wt = (c + 1e-5) * (s + 1e-5) * e;
+ wl[i] = wt;
+ wsum[i] += wt;
+ }
+ }
+ }
+ for (var k = 0; k < n; k++) {
+ final wl = wpyr[k].level[0];
+ for (var i = 0; i < npx; i++) {
+ wl[i] /= (wsum[i] + 1e-12);
+ }
+ _mfGaussFill(wpyr[k]);
+ }
+
+ for (var c = 0; c < 3; c++) {
+ for (var l = 0; l < acc.levels; l++) {
+ final al = acc.level[l];
+ for (var i = 0; i < al.length; i++) {
+ al[i] = 0.0;
+ }
+ }
+ for (var k = 0; k < n; k++) {
+ final base = k * npx * 3;
+ final l0 = lpyr.level[0];
+ for (var i = 0; i < npx; i++) {
+ l0[i] = imgs[base + i * 3 + c];
+ }
+ _mfGaussFill(lpyr);
+ _mfGaussToLap(lpyr, tmp);
+ for (var l = 0; l < acc.levels; l++) {
+ final nn = acc.w[l] * acc.h[l];
+ final wpl = wpyr[k].level[l], ll = lpyr.level[l], al = acc.level[l];
+ for (var i = 0; i < nn; i++) {
+ al[i] += wpl[i] * ll[i];
+ }
+ }
+ }
+ _mfCollapse(acc, tmp);
+ final r0 = acc.level[0];
+ for (var i = 0; i < npx; i++) {
+ out[i * 3 + c] = _clamp01(r0[i]);
+ }
+ }
+}
+
+void _mfLoadRgb(Uint8List frame, int w, int h, int stride, bool isBgra,
+ Float64List dst, int dstOff) {
+ final ri = isBgra ? 2 : 0, bi = isBgra ? 0 : 2;
+ for (var y = 0; y < h; y++) {
+ final row = y * stride;
+ for (var x = 0; x < w; x++) {
+ final p = row + x * 4;
+ final d = dstOff + (y * w + x) * 3;
+ dst[d] = frame[p + ri] / 255.0;
+ dst[d + 1] = frame[p + 1] / 255.0;
+ dst[d + 2] = frame[p + bi] / 255.0;
+ }
+ }
+}
+
+Uint8List _mfStore(Float64List src, int w, int h, bool isBgra) {
+ final ri = isBgra ? 2 : 0, bi = isBgra ? 0 : 2;
+ final out = Uint8List(w * h * 4);
+ for (var i = 0; i < w * h; i++) {
+ final o = i * 4;
+ out[o + ri] = _clampRound(src[i * 3] * 255.0);
+ out[o + 1] = _clampRound(src[i * 3 + 1] * 255.0);
+ out[o + bi] = _clampRound(src[i * 3 + 2] * 255.0);
+ out[o + 3] = 255;
+ }
+ return out;
}
/// Minimal pure-Dart buffer pool (web has no native ring buffer).
diff --git a/lib/src/web/web_camera_backend.dart b/lib/src/web/web_camera_backend.dart
index 765a450..8d16a64 100644
--- a/lib/src/web/web_camera_backend.dart
+++ b/lib/src/web/web_camera_backend.dart
@@ -187,7 +187,7 @@ class WebCameraBackend implements CameraBackend {
supportsRawCapture: true, // pure-Dart linear-DNG writer
supportsProRaw: false,
supportsBurstMode: true, // controller-level, works everywhere
- supportsHdr: false,
+ supportsHdr: true, // controller-level captureHdr (fusion), works everywhere
supportsBracketing: true, // controller-level, works everywhere
supportsDepthCapture: false,
supportsLidar: false,
@@ -382,6 +382,27 @@ class WebCameraBackend implements CameraBackend {
);
}
+ @override
+ Future renderHdr(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ required List stops,
+ bool isBgra = false,
+ }) async {
+ final tonemapped = NativeCore.localTonemap(frame,
+ width: width, height: height, isBgra: isBgra, stops: stops);
+ // Web can't write files; return the tone-mapped RGBA in memory (the sample
+ // app decodes it via decodeImageFromPixels, same as capturePhoto).
+ return CapturedPhoto(
+ width: width,
+ height: height,
+ format: ImageFormat.png,
+ timestamp: DateTime.now(),
+ bytes: tonemapped,
+ );
+ }
+
@override
Future startVideoRecording(String path) async {
final stream = _stream;
diff --git a/src/core/camera_pro_core.h b/src/core/camera_pro_core.h
index df08aea..f2ac2c0 100644
--- a/src/core/camera_pro_core.h
+++ b/src/core/camera_pro_core.h
@@ -157,6 +157,41 @@ camera_pro_box_blur(
int32_t stride,
int32_t radius);
+/* ── HDR exposure fusion (multi-scale Mertens) ─────────────────────────────
+ * Merges an aligned exposure bracket (`n` frames back-to-back, each
+ * height*stride bytes) into one tone-mapped 8-bit image. Each source is
+ * weighted per pixel by contrast × saturation × well-exposedness and blended
+ * through a Laplacian pyramid, so local contrast is preserved with no seams or
+ * halos. `out` must hold width*height*4 bytes. Returns CAMERA_OK or an error.
+ * ───────────────────────────────────────────────────────────────────────── */
+CAMERA_PRO_EXPORT int32_t
+camera_pro_exposure_fusion(
+ const uint8_t* frames,
+ int32_t n,
+ int32_t width,
+ int32_t height,
+ int32_t stride,
+ int32_t is_bgra,
+ uint8_t* out);
+
+/* ── Single-capture local tone mapping ─────────────────────────────────────
+ * One frame in, one tone-mapped frame out. Synthesises an exposure stack from
+ * the single frame (gain = 2^ev in linear light for each ev in `evs`) and runs
+ * multi-scale exposure fusion. Ghost-free (all exposures share one instant) —
+ * the HDR path for cameras without sensor-level bracketing. `out` must hold
+ * width*height*4 bytes. Returns CAMERA_OK or an error code.
+ * ───────────────────────────────────────────────────────────────────────── */
+CAMERA_PRO_EXPORT int32_t
+camera_pro_local_tonemap(
+ const uint8_t* frame,
+ int32_t width,
+ int32_t height,
+ int32_t stride,
+ int32_t is_bgra,
+ const float* evs,
+ int32_t n_ev,
+ uint8_t* out);
+
/* ── Luminance waveform monitor ────────────────────────────────────────────
* Builds a waveform: for each of `columns` horizontal buckets, a 256-bin
* distribution of luminance. `out` must hold columns*256 uint32_t and is
diff --git a/src/core/image_processor.c b/src/core/image_processor.c
index 72c4c52..6db99d2 100644
--- a/src/core/image_processor.c
+++ b/src/core/image_processor.c
@@ -259,6 +259,12 @@ static inline uint8_t clampf_u8(float v) {
return (uint8_t)(v + 0.5f);
}
+static inline uint8_t clampd_u8(double v) {
+ if (v < 0.0) return 0;
+ if (v > 255.0) return 255;
+ return (uint8_t)(v + 0.5);
+}
+
int32_t camera_pro_adjust_pixels(
uint8_t* px, int32_t width, int32_t height, int32_t stride,
int32_t is_bgra, float gain, float bias, float temp, float contrast) {
@@ -383,6 +389,292 @@ int32_t camera_pro_box_blur(
return CAMERA_OK;
}
+/* ── HDR exposure fusion (multi-scale Mertens) ─────────────────────────────
+ * Real exposure fusion (Mertens, Kautz & Van Reeth 2009): each source image is
+ * weighted per pixel by contrast (|Laplacian| of luma) × saturation (stddev of
+ * RGB) × well-exposedness (Gaussian around mid-grey), and the weighted blend is
+ * done through a Laplacian pyramid so local contrast is preserved and there are
+ * no seams or halos. A naive single-scale weighted average (the previous
+ * implementation) looks washed-out and haloed; the multi-resolution blend is
+ * what makes the result usable. Everything is computed in float [0,1].
+ * ───────────────────────────────────────────────────────────────────────── */
+
+#define MF_MAX_LEVELS 16
+
+typedef struct {
+ float* level[MF_MAX_LEVELS];
+ int32_t w[MF_MAX_LEVELS];
+ int32_t h[MF_MAX_LEVELS];
+ int32_t levels;
+} MfPyr;
+
+static const float MF_K[5] = {1.f/16, 4.f/16, 6.f/16, 4.f/16, 1.f/16};
+
+static inline float mf_clamp01(float v) { return v < 0.f ? 0.f : (v > 1.f ? 1.f : v); }
+
+/* sRGB <-> linear, so exposure gains are applied in physically-linear light. */
+static inline float mf_srgb_to_lin(float c) {
+ return c <= 0.04045f ? c / 12.92f : powf((c + 0.055f) / 1.055f, 2.4f);
+}
+static inline float mf_lin_to_srgb(float c) {
+ if (c <= 0.f) return 0.f;
+ if (c >= 1.f) return 1.f;
+ return c <= 0.0031308f ? c * 12.92f : 1.055f * powf(c, 1.f / 2.4f) - 0.055f;
+}
+
+static int32_t mf_levels_for(int32_t w, int32_t h) {
+ int32_t m = w < h ? w : h, l = 1;
+ while (m > 1 && l < MF_MAX_LEVELS) { m = (m + 1) / 2; l++; }
+ return l;
+}
+
+/* Binomial [1 4 6 4 1]/16 blur, subsample by 2 (border-replicated). */
+static void mf_reduce(const float* src, int32_t sw, int32_t sh,
+ float* dst, int32_t dw, int32_t dh) {
+ for (int32_t y = 0; y < dh; y++)
+ for (int32_t x = 0; x < dw; x++) {
+ float acc = 0.f;
+ for (int32_t q = -2; q <= 2; q++) {
+ int32_t sy = 2 * y + q;
+ sy = sy < 0 ? 0 : (sy >= sh ? sh - 1 : sy);
+ for (int32_t p = -2; p <= 2; p++) {
+ int32_t sx = 2 * x + p;
+ sx = sx < 0 ? 0 : (sx >= sw ? sw - 1 : sx);
+ acc += MF_K[p + 2] * MF_K[q + 2] * src[(size_t)sy * sw + sx];
+ }
+ }
+ dst[(size_t)y * dw + x] = acc;
+ }
+}
+
+/* Upsample src (sw×sh) to dst (dw×dh) via the same binomial kernel (×4 gain). */
+static void mf_expand(const float* src, int32_t sw, int32_t sh,
+ float* dst, int32_t dw, int32_t dh) {
+ for (int32_t y = 0; y < dh; y++)
+ for (int32_t x = 0; x < dw; x++) {
+ float acc = 0.f;
+ for (int32_t q = -2; q <= 2; q++) {
+ int32_t yy = y - q;
+ if (yy & 1) continue;
+ int32_t sy = yy / 2;
+ sy = sy < 0 ? 0 : (sy >= sh ? sh - 1 : sy);
+ for (int32_t p = -2; p <= 2; p++) {
+ int32_t xx = x - p;
+ if (xx & 1) continue;
+ int32_t sx = xx / 2;
+ sx = sx < 0 ? 0 : (sx >= sw ? sw - 1 : sx);
+ acc += MF_K[p + 2] * MF_K[q + 2] * src[(size_t)sy * sw + sx];
+ }
+ }
+ dst[(size_t)y * dw + x] = 4.f * acc;
+ }
+}
+
+static int mf_pyr_alloc(MfPyr* p, int32_t w, int32_t h) {
+ p->levels = mf_levels_for(w, h);
+ int32_t cw = w, ch = h;
+ for (int32_t l = 0; l < p->levels; l++) {
+ p->w[l] = cw; p->h[l] = ch;
+ p->level[l] = (float*)malloc((size_t)cw * ch * sizeof(float));
+ if (!p->level[l]) {
+ for (int32_t j = 0; j < l; j++) free(p->level[j]);
+ p->levels = 0; /* make mf_pyr_free a safe no-op on a failed pyramid */
+ return 0;
+ }
+ cw = (cw + 1) / 2; ch = (ch + 1) / 2;
+ }
+ return 1;
+}
+static void mf_pyr_free(MfPyr* p) {
+ for (int32_t l = 0; l < p->levels; l++) free(p->level[l]);
+}
+static void mf_gauss_fill(MfPyr* g) { /* level[0] must be set */
+ for (int32_t l = 1; l < g->levels; l++)
+ mf_reduce(g->level[l - 1], g->w[l - 1], g->h[l - 1],
+ g->level[l], g->w[l], g->h[l]);
+}
+/* Turn a filled Gaussian pyramid into a Laplacian pyramid in place. */
+static void mf_gauss_to_lap(MfPyr* g, float* tmp) {
+ for (int32_t l = 0; l < g->levels - 1; l++) {
+ mf_expand(g->level[l + 1], g->w[l + 1], g->h[l + 1], tmp, g->w[l], g->h[l]);
+ size_t nn = (size_t)g->w[l] * g->h[l];
+ for (size_t i = 0; i < nn; i++) g->level[l][i] -= tmp[i];
+ }
+}
+/* Collapse a Laplacian pyramid; result ends up in level[0]. */
+static void mf_collapse(MfPyr* lap, float* tmp) {
+ for (int32_t l = lap->levels - 2; l >= 0; l--) {
+ mf_expand(lap->level[l + 1], lap->w[l + 1], lap->h[l + 1], tmp, lap->w[l], lap->h[l]);
+ size_t nn = (size_t)lap->w[l] * lap->h[l];
+ for (size_t i = 0; i < nn; i++) lap->level[l][i] += tmp[i];
+ }
+}
+
+/* Fuse n interleaved-RGB float images ([0,1], w*h*3 each) into `out` (w*h*3). */
+static int32_t mf_fuse(const float* imgs, int32_t n, int32_t w, int32_t h, float* out) {
+ const size_t npx = (size_t)w * h;
+ const float inv2s2 = 1.f / (2.f * 0.2f * 0.2f);
+ int32_t rc = CAMERA_ERROR_OUT_OF_MEMORY;
+
+ float* gray = (float*)malloc(npx * sizeof(float));
+ float* wsum = (float*)calloc(npx, sizeof(float));
+ float* tmp = (float*)malloc(npx * sizeof(float));
+ MfPyr* wpyr = (MfPyr*)calloc((size_t)n, sizeof(MfPyr));
+ MfPyr lpyr, acc;
+ memset(&lpyr, 0, sizeof lpyr); /* levels=0 => mf_pyr_free is a safe no-op */
+ memset(&acc, 0, sizeof acc);
+ if (!gray || !wsum || !tmp || !wpyr) goto done;
+ if (!mf_pyr_alloc(&lpyr, w, h)) goto done;
+ if (!mf_pyr_alloc(&acc, w, h)) goto done;
+ for (int32_t k = 0; k < n; k++) {
+ if (!mf_pyr_alloc(&wpyr[k], w, h)) goto done;
+ }
+
+ /* Per-image weights: contrast × saturation × well-exposedness (+ floors). */
+ for (int32_t k = 0; k < n; k++) {
+ const float* im = imgs + (size_t)k * npx * 3;
+ for (size_t i = 0; i < npx; i++)
+ gray[i] = 0.299f * im[i*3] + 0.587f * im[i*3+1] + 0.114f * im[i*3+2];
+ for (int32_t y = 0; y < h; y++)
+ for (int32_t x = 0; x < w; x++) {
+ size_t i = (size_t)y * w + x;
+ int32_t xm = x > 0 ? x-1 : 0, xp = x < w-1 ? x+1 : w-1;
+ int32_t ym = y > 0 ? y-1 : 0, yp = y < h-1 ? y+1 : h-1;
+ float lap = gray[(size_t)y*w+xm] + gray[(size_t)y*w+xp]
+ + gray[(size_t)ym*w+x] + gray[(size_t)yp*w+x] - 4.f*gray[i];
+ float C = lap < 0 ? -lap : lap;
+ float R = im[i*3], G = im[i*3+1], B = im[i*3+2];
+ float m = (R + G + B) / 3.f;
+ float S = sqrtf(((R-m)*(R-m) + (G-m)*(G-m) + (B-m)*(B-m)) / 3.f);
+ float zr = R-0.5f, zg = G-0.5f, zb = B-0.5f;
+ float E = expf(-(zr*zr + zg*zg + zb*zb) * inv2s2);
+ float Wt = (C + 1e-5f) * (S + 1e-5f) * E;
+ wpyr[k].level[0][i] = Wt;
+ wsum[i] += Wt;
+ }
+ }
+ /* Normalise weights per pixel, then build their Gaussian pyramids. */
+ for (int32_t k = 0; k < n; k++) {
+ for (size_t i = 0; i < npx; i++)
+ wpyr[k].level[0][i] /= (wsum[i] + 1e-12f);
+ mf_gauss_fill(&wpyr[k]);
+ }
+
+ /* Blend each channel through the pyramid. */
+ for (int32_t c = 0; c < 3; c++) {
+ for (int32_t l = 0; l < acc.levels; l++)
+ memset(acc.level[l], 0, (size_t)acc.w[l] * acc.h[l] * sizeof(float));
+ for (int32_t k = 0; k < n; k++) {
+ const float* im = imgs + (size_t)k * npx * 3;
+ for (size_t i = 0; i < npx; i++) lpyr.level[0][i] = im[i*3 + c];
+ mf_gauss_fill(&lpyr);
+ mf_gauss_to_lap(&lpyr, tmp);
+ for (int32_t l = 0; l < acc.levels; l++) {
+ size_t nn = (size_t)acc.w[l] * acc.h[l];
+ const float* wl = wpyr[k].level[l];
+ const float* ll = lpyr.level[l];
+ float* al = acc.level[l];
+ for (size_t i = 0; i < nn; i++) al[i] += wl[i] * ll[i];
+ }
+ }
+ mf_collapse(&acc, tmp);
+ for (size_t i = 0; i < npx; i++) out[i*3 + c] = mf_clamp01(acc.level[0][i]);
+ }
+ rc = CAMERA_OK;
+
+done:
+ free(gray); free(wsum); free(tmp);
+ mf_pyr_free(&lpyr);
+ mf_pyr_free(&acc);
+ if (wpyr) { for (int32_t k = 0; k < n; k++) mf_pyr_free(&wpyr[k]); free(wpyr); }
+ return rc;
+}
+
+/* Load an RGBA/BGRA frame into canonical interleaved-RGB float [0,1]. */
+static void mf_load_rgb(const uint8_t* frame, int32_t w, int32_t h, int32_t stride,
+ int32_t is_bgra, float* dst) {
+ int ri = is_bgra ? 2 : 0, bi = is_bgra ? 0 : 2;
+ for (int32_t y = 0; y < h; y++) {
+ const uint8_t* row = frame + (size_t)y * stride;
+ for (int32_t x = 0; x < w; x++) {
+ const uint8_t* p = row + x * 4;
+ float* d = dst + ((size_t)y * w + x) * 3;
+ d[0] = p[ri] / 255.f; d[1] = p[1] / 255.f; d[2] = p[bi] / 255.f;
+ }
+ }
+}
+/* Store canonical RGB float back to an RGBA/BGRA buffer (alpha opaque). */
+static void mf_store_rgba(const float* src, int32_t w, int32_t h, int32_t is_bgra, uint8_t* out) {
+ int ri = is_bgra ? 2 : 0, bi = is_bgra ? 0 : 2;
+ for (size_t i = 0; i < (size_t)w * h; i++) {
+ uint8_t* o = out + i * 4;
+ o[ri] = clampd_u8(src[i*3] * 255.f);
+ o[1] = clampd_u8(src[i*3+1] * 255.f);
+ o[bi] = clampd_u8(src[i*3+2] * 255.f);
+ o[3] = 255;
+ }
+}
+
+int32_t camera_pro_exposure_fusion(
+ const uint8_t* frames, int32_t n, int32_t width, int32_t height,
+ int32_t stride, int32_t is_bgra, uint8_t* out) {
+
+ if (!frames || !out || n <= 0 || width <= 0 || height <= 0)
+ return CAMERA_ERROR_INVALID_PARAMETER;
+ if (stride <= 0) stride = width * 4;
+
+ const size_t npx = (size_t)width * height;
+ const size_t frame_bytes = (size_t)height * stride;
+ float* imgs = (float*)malloc((size_t)n * npx * 3 * sizeof(float));
+ float* fused = (float*)malloc(npx * 3 * sizeof(float));
+ if (!imgs || !fused) { free(imgs); free(fused); return CAMERA_ERROR_OUT_OF_MEMORY; }
+
+ for (int32_t k = 0; k < n; k++)
+ mf_load_rgb(frames + (size_t)k * frame_bytes, width, height, stride, is_bgra,
+ imgs + (size_t)k * npx * 3);
+ int32_t rc = mf_fuse(imgs, n, width, height, fused);
+ if (rc == CAMERA_OK) mf_store_rgba(fused, width, height, is_bgra, out);
+ free(imgs); free(fused);
+ return rc;
+}
+
+/* ── Single-capture local tone mapping ─────────────────────────────────────
+ * One frame in, one tone-mapped frame out. Synthesises an exposure stack from
+ * the single frame by scaling it in linear light at each EV in `evs`
+ * (gain = 2^ev), then runs multi-scale exposure fusion. Because every synthetic
+ * exposure comes from the same instant, the result is sharp and ghost-free —
+ * the right behaviour for cameras without sensor-level exposure bracketing.
+ * `out` is width*height*4. Returns CAMERA_OK or an error code.
+ * ───────────────────────────────────────────────────────────────────────── */
+int32_t camera_pro_local_tonemap(
+ const uint8_t* frame, int32_t width, int32_t height, int32_t stride,
+ int32_t is_bgra, const float* evs, int32_t n_ev, uint8_t* out) {
+
+ if (!frame || !out || !evs || n_ev <= 0 || width <= 0 || height <= 0)
+ return CAMERA_ERROR_INVALID_PARAMETER;
+ if (stride <= 0) stride = width * 4;
+
+ const size_t npx = (size_t)width * height;
+ float* base = (float*)malloc(npx * 3 * sizeof(float));
+ float* imgs = (float*)malloc((size_t)n_ev * npx * 3 * sizeof(float));
+ float* fused = (float*)malloc(npx * 3 * sizeof(float));
+ if (!base || !imgs || !fused) {
+ free(base); free(imgs); free(fused); return CAMERA_ERROR_OUT_OF_MEMORY;
+ }
+ mf_load_rgb(frame, width, height, stride, is_bgra, base);
+
+ for (int32_t e = 0; e < n_ev; e++) {
+ float gain = exp2f(evs[e]);
+ float* im = imgs + (size_t)e * npx * 3;
+ for (size_t i = 0; i < npx * 3; i++)
+ im[i] = mf_lin_to_srgb(mf_clamp01(mf_srgb_to_lin(base[i]) * gain));
+ }
+ int32_t rc = mf_fuse(imgs, n_ev, width, height, fused);
+ if (rc == CAMERA_OK) mf_store_rgba(fused, width, height, is_bgra, out);
+ free(base); free(imgs); free(fused);
+ return rc;
+}
+
/* ── Luminance waveform monitor ────────────────────────────────────────── */
int32_t camera_pro_compute_luma_waveform(
const uint8_t* rgba, int32_t width, int32_t height, int32_t stride,
diff --git a/src/tests/core_test.c b/src/tests/core_test.c
index 3d98b1f..ceedaf7 100644
--- a/src/tests/core_test.c
+++ b/src/tests/core_test.c
@@ -307,6 +307,95 @@ static void test_adjustments(void) {
free(px);
}
+static void test_exposure_fusion(void) {
+ printf("HDR exposure fusion\n");
+ const int32_t W = 2, H = 1, stride = W * 4;
+ const size_t fb = (size_t)stride * H; /* 8 bytes per frame */
+ /* Three-frame bracket of a 2-pixel scene. The shadow pixel is only
+ * well-exposed in the bright frame; the highlight pixel only in the dark
+ * frame — so a correct fusion must pull detail from opposite exposures.
+ * pixel 0 (shadow): dark=0, mid=30, bright=110
+ * pixel 1 (highlight): dark=150, mid=230, bright=255 */
+ uint8_t frames[3 * 8];
+ memset(frames, 0, sizeof(frames));
+ const uint8_t shadow[3] = {0, 30, 110};
+ const uint8_t highlight[3] = {150, 230, 255};
+ for (int k = 0; k < 3; k++) {
+ uint8_t* f = frames + (size_t)k * fb;
+ f[0] = f[1] = f[2] = shadow[k]; f[3] = 255; /* pixel 0 */
+ f[4] = f[5] = f[6] = highlight[k]; f[7] = 255; /* pixel 1 */
+ }
+ uint8_t out[8] = {0};
+ int32_t rc = camera_pro_exposure_fusion(frames, 3, W, H, stride, 0, out);
+ CHECK(rc == CAMERA_OK, "fusion returns OK");
+ CHECK(out[3] == 255 && out[7] == 255, "alpha stays opaque");
+ /* Shadow pixel is lifted toward the well-exposed bright frame (~110). */
+ CHECK(out[0] > 90, "shadow detail lifted from the bright exposure");
+ /* Highlight pixel is pulled down toward the well-exposed dark frame (~150). */
+ CHECK(out[4] < 180, "highlight detail recovered from the dark exposure");
+ /* Net effect: the two patches, 200 apart in the mid frame, end up much
+ * closer — the scene's dynamic range is compressed into the display range. */
+ CHECK(((int)out[4] - (int)out[0]) < (230 - 30),
+ "dynamic range compressed vs the mid exposure");
+
+ /* Single-frame fusion is the identity (within rounding). */
+ uint8_t out1[8] = {0};
+ CHECK(camera_pro_exposure_fusion(frames + fb, 1, W, H, stride, 0, out1)
+ == CAMERA_OK, "single-frame fusion returns OK");
+ CHECK(out1[0] == 30 && out1[4] == 230, "single-frame fusion is the identity");
+
+ /* Parameter validation. */
+ CHECK(camera_pro_exposure_fusion(NULL, 3, W, H, stride, 0, out)
+ == CAMERA_ERROR_INVALID_PARAMETER, "null frames rejected");
+ CHECK(camera_pro_exposure_fusion(frames, 0, W, H, stride, 0, out)
+ == CAMERA_ERROR_INVALID_PARAMETER, "n=0 rejected");
+ CHECK(camera_pro_exposure_fusion(frames, 3, W, H, stride, 0, NULL)
+ == CAMERA_ERROR_INVALID_PARAMETER, "null out rejected");
+}
+
+static void test_local_tonemap(void) {
+ printf("Local tone mapping (single frame)\n");
+ const int32_t W = 48, H = 48, stride = W * 4;
+ uint8_t* px = (uint8_t*)malloc((size_t)stride * H);
+ uint8_t* out = (uint8_t*)malloc((size_t)stride * H);
+ /* A single high-dynamic-range frame: dark | mid | bright vertical bands,
+ * each carrying a ±15 stripe texture (real scenes are never perfectly flat,
+ * and local tone mapping adapts through local contrast). A plain exposure
+ * crushes the dark band and clips the bright one. */
+ for (int32_t y = 0; y < H; y++)
+ for (int32_t x = 0; x < W; x++) {
+ int base = x < W / 3 ? 30 : (x < 2 * W / 3 ? 128 : 225);
+ int v = (y & 2) ? base + 15 : base - 15;
+ uint8_t* p = px + ((size_t)y * W + x) * 4;
+ p[0] = p[1] = p[2] = (uint8_t)v; p[3] = 255;
+ }
+ float evs[5] = {-3.f, -1.5f, 0.f, 1.5f, 3.f};
+ CHECK(camera_pro_local_tonemap(px, W, H, stride, 0, evs, 5, out) == CAMERA_OK,
+ "tonemap returns OK");
+ /* Compare region means: the shadow band should rise, the highlight fall. */
+ long in_dark = 0, out_dark = 0, in_bright = 0, out_bright = 0;
+ int nd = 0, nb = 0;
+ for (int32_t y = 0; y < H; y++)
+ for (int32_t x = 0; x < W; x++) {
+ size_t i = ((size_t)y * W + x) * 4;
+ if (x < W / 3) { in_dark += px[i]; out_dark += out[i]; nd++; }
+ else if (x >= 2 * W / 3) { in_bright += px[i]; out_bright += out[i]; nb++; }
+ }
+ CHECK(out[3] == 255, "alpha opaque");
+ CHECK(out_dark / nd > in_dark / nd, "shadow band lifted (region mean)");
+ CHECK(out_bright / nb < in_bright / nb, "highlight band compressed (region mean)");
+ CHECK((out_bright - out_dark) / nb < (in_bright - in_dark) / nb,
+ "single-frame dynamic range compressed");
+ /* Parameter validation. */
+ CHECK(camera_pro_local_tonemap(NULL, W, H, stride, 0, evs, 3, out)
+ == CAMERA_ERROR_INVALID_PARAMETER, "null frame rejected");
+ CHECK(camera_pro_local_tonemap(px, W, H, stride, 0, evs, 0, out)
+ == CAMERA_ERROR_INVALID_PARAMETER, "n_ev=0 rejected");
+ CHECK(camera_pro_local_tonemap(px, W, H, stride, 0, NULL, 3, out)
+ == CAMERA_ERROR_INVALID_PARAMETER, "null evs rejected");
+ free(px); free(out);
+}
+
static void test_dng_writer(void) {
printf("DNG writer\n");
const int32_t W = 32, H = 24, stride = W * 4;
@@ -366,6 +455,8 @@ int main(void) {
test_visual_aids();
test_waveform_falsecolor();
test_adjustments();
+ test_exposure_fusion();
+ test_local_tonemap();
test_dng_writer();
test_hal_stub();
printf("\n=== %d checks, %d failures ===\n", g_checks, g_failures);
diff --git a/test/controller/controller_test.dart b/test/controller/controller_test.dart
index 6a4c95e..9882fd4 100644
--- a/test/controller/controller_test.dart
+++ b/test/controller/controller_test.dart
@@ -1,3 +1,5 @@
+import 'dart:typed_data';
+
import 'package:camera_pro/camera_pro.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -112,5 +114,45 @@ void main() {
throwsA(isA()),
);
});
+
+ test('captureHdr renders from a single frame (no exposure walk)', () async {
+ final backend = RecordingBackend()
+ ..frame = PreviewFrame(
+ bytes: Uint8List(2 * 1 * 4), width: 2, height: 1, isBgra: false);
+ final controller = CameraProController.forTesting(
+ capabilities: fullCapabilities(),
+ backend: backend,
+ );
+ final photo = await controller.captureHdr(stops: const [-2.0, 0.0, 2.0]);
+ expect(photo.path, '/tmp/hdr.png');
+ expect(controller.state, CameraState.previewing);
+ expect(backend.calls, contains('hdr:3:2x1'));
+ // Single capture: it must NOT walk exposures (that path ghosts).
+ expect(backend.calls.where((c) => c.startsWith('ev:')), isEmpty);
+ });
+
+ test('captureHdr surfaces noFrame and recovers state', () async {
+ final backend = RecordingBackend(); // latestFrame() == null
+ final controller = CameraProController.forTesting(
+ capabilities: fullCapabilities(),
+ backend: backend,
+ );
+ await expectLater(
+ controller.captureHdr(),
+ throwsA(isA()),
+ );
+ expect(controller.state, CameraState.previewing);
+ });
+
+ test('captureHdr throws when HDR is unsupported', () async {
+ final controller = CameraProController.forTesting(
+ capabilities: standardCapabilities(),
+ backend: RecordingBackend(),
+ );
+ expect(
+ () => controller.captureHdr(),
+ throwsA(isA()),
+ );
+ });
});
}
diff --git a/test/ffi/native_core_test.dart b/test/ffi/native_core_test.dart
index 412db62..49656a0 100644
--- a/test/ffi/native_core_test.dart
+++ b/test/ffi/native_core_test.dart
@@ -12,6 +12,8 @@ import 'dart:typed_data';
import 'package:camera_pro/camera_pro.dart';
// ignore: implementation_imports
import 'package:camera_pro/src/ffi/camera_pro_bindings.dart' as bindings;
+// ignore: implementation_imports
+import 'package:camera_pro/src/web/native_core_web.dart' as webcore;
import 'package:ffi/ffi.dart' as pkg_ffi;
import 'package:flutter_test/flutter_test.dart';
@@ -172,6 +174,143 @@ void main() {
}
});
+ test('exposure fusion lifts shadows and recovers highlights', () {
+ // A 2-pixel scene captured as a 3-frame bracket. The shadow pixel is only
+ // well-exposed in the bright frame; the highlight only in the dark frame.
+ const w = 2, h = 1;
+ Uint8List frame(int shadow, int highlight) {
+ final b = Uint8List(w * h * 4);
+ b[0] = b[1] = b[2] = shadow;
+ b[3] = 255;
+ b[4] = b[5] = b[6] = highlight;
+ b[7] = 255;
+ return b;
+ }
+
+ final bracket = [
+ frame(0, 150), // dark
+ frame(30, 230), // mid
+ frame(110, 255), // bright
+ ];
+ final fused = NativeCore.exposureFusion(bracket,
+ width: w, height: h, isBgra: false);
+ // Shadow (mid=30) is pulled up toward the bright frame's 110.
+ expect(fused[0], greaterThan(90));
+ // Highlight (mid=230) is pulled down toward the dark frame's 150.
+ expect(fused[4], lessThan(180));
+ expect(fused[3], 255);
+ expect(fused[7], 255);
+ });
+
+ test('exposure fusion preserves channel order (not grayscale)', () {
+ // A saturated orange bracket (R > G > B). If the kernel swapped output
+ // channels or dropped saturation weighting, a grayscale test could not
+ // tell — this pins the color through.
+ const w = 2, h = 1;
+ Uint8List frame(int r, int g, int b) {
+ final px = Uint8List(w * h * 4);
+ for (var i = 0; i < w * h; i++) {
+ px[i * 4] = r;
+ px[i * 4 + 1] = g;
+ px[i * 4 + 2] = b;
+ px[i * 4 + 3] = 255;
+ }
+ return px;
+ }
+
+ final fused = NativeCore.exposureFusion(
+ [frame(40, 24, 12), frame(200, 120, 60), frame(255, 200, 150)],
+ width: w,
+ height: h,
+ isBgra: false,
+ );
+ expect(fused[0], greaterThan(fused[1])); // R > G
+ expect(fused[1], greaterThan(fused[2])); // G > B
+ expect(fused[0], greaterThan(150)); // red stays dominant
+ });
+
+ test('exposure fusion rejects mismatched frame sizes', () {
+ final ok = Uint8List(2 * 2 * 4);
+ final wrong = Uint8List(2 * 2 * 4 - 4);
+ expect(
+ () => NativeCore.exposureFusion([ok, wrong],
+ width: 2, height: 2),
+ throwsArgumentError,
+ );
+ });
+
+ test('local tone mapping lifts shadows and tames highlights', () {
+ // One high-DR frame: left half deep shadow, right half near-clipped, with
+ // a fine stripe so there is local contrast to adapt to.
+ const w = 32, h = 16;
+ final frame = Uint8List(w * h * 4);
+ for (var y = 0; y < h; y++) {
+ for (var x = 0; x < w; x++) {
+ final base = x < w ~/ 2 ? 28 : 224;
+ final v = (y & 2) != 0 ? base + 12 : base - 12;
+ final o = (y * w + x) * 4;
+ frame[o] = frame[o + 1] = frame[o + 2] = v;
+ frame[o + 3] = 255;
+ }
+ }
+ final out = NativeCore.localTonemap(frame, width: w, height: h, isBgra: false);
+ // Region means: shadow half rises, highlight half falls.
+ var inDark = 0, outDark = 0, inBright = 0, outBright = 0;
+ for (var y = 0; y < h; y++) {
+ for (var x = 0; x < w; x++) {
+ final o = (y * w + x) * 4;
+ if (x < w ~/ 2) {
+ inDark += frame[o];
+ outDark += out[o];
+ } else {
+ inBright += frame[o];
+ outBright += out[o];
+ }
+ }
+ }
+ expect(outDark, greaterThan(inDark), reason: 'shadows lifted');
+ expect(outBright, lessThan(inBright), reason: 'highlights tamed');
+ expect(out[3], 255);
+ });
+
+ test('exposure fusion + tonemap: C core and pure-Dart port stay close', () {
+ // Multi-scale float pyramids can't be bit-exact across the FFI/JS number
+ // models, but the ports must not diverge meaningfully.
+ const w = 24, h = 16, n = 3;
+ var seed = 0x51ED;
+ int rnd() => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) >> 8 & 0xff;
+ final bracket = List.generate(n, (_) {
+ final b = Uint8List(w * h * 4);
+ for (var i = 0; i < w * h; i++) {
+ b[i * 4] = rnd();
+ b[i * 4 + 1] = rnd();
+ b[i * 4 + 2] = rnd();
+ b[i * 4 + 3] = 255;
+ }
+ return b;
+ });
+
+ int maxDiff(Uint8List a, Uint8List b) {
+ var m = 0;
+ for (var i = 0; i < a.length; i++) {
+ final d = (a[i] - b[i]).abs();
+ if (d > m) m = d;
+ }
+ return m;
+ }
+
+ final fC = NativeCore.exposureFusion(bracket, width: w, height: h);
+ final fD = webcore.NativeCore.exposureFusion(bracket, width: w, height: h);
+ expect(maxDiff(fC, fD), lessThanOrEqualTo(4),
+ reason: 'fusion C vs Dart diverged');
+
+ final tC = NativeCore.localTonemap(bracket.first, width: w, height: h);
+ final tD =
+ webcore.NativeCore.localTonemap(bracket.first, width: w, height: h);
+ expect(maxDiff(tC, tD), lessThanOrEqualTo(4),
+ reason: 'tonemap C vs Dart diverged');
+ });
+
test('buffer pool acquires, drains, and releases', () {
final pool = NativeBufferPool.create(bufferSize: 1024, count: 2);
expect(pool, isNotNull);
diff --git a/test/helpers.dart b/test/helpers.dart
index 11092e2..4340ede 100644
--- a/test/helpers.dart
+++ b/test/helpers.dart
@@ -1,4 +1,6 @@
// Shared test fixtures.
+import 'dart:typed_data';
+
import 'package:camera_pro/camera_pro.dart';
/// A capability passport for a high-end device (full manual controls).
@@ -117,6 +119,13 @@ CameraCapabilities standardCapabilities() => CameraCapabilities(
class RecordingBackend implements CameraBackend {
final List calls = [];
+ /// The frame [latestFrame] returns; tests set it to exercise capture paths.
+ PreviewFrame? frame;
+
+ /// If non-empty, [latestFrame] dequeues from here first (lets a test feed a
+ /// changing sequence of frames, e.g. a mid-bracket resolution change).
+ final List frameQueue = [];
+
@override
Future enumerateDevices() async => const CameraList([
CameraDevice(index: 0, name: 'fake', direction: LensDirection.back),
@@ -141,7 +150,8 @@ class RecordingBackend implements CameraBackend {
Future stopFrameStream() async => calls.add('stopFrameStream');
@override
- PreviewFrame? latestFrame() => null;
+ PreviewFrame? latestFrame() =>
+ frameQueue.isNotEmpty ? frameQueue.removeAt(0) : frame;
@override
int get frameCount => 0;
@@ -192,6 +202,25 @@ class RecordingBackend implements CameraBackend {
);
}
+ @override
+ Future renderHdr(
+ Uint8List frame, {
+ required int width,
+ required int height,
+ required List stops,
+ bool isBgra = true,
+ }) async {
+ calls.add('hdr:${stops.length}:${width}x$height');
+ return CapturedPhoto(
+ width: width,
+ height: height,
+ format: ImageFormat.png,
+ timestamp: DateTime(2026),
+ bytes: frame,
+ path: '/tmp/hdr.png',
+ );
+ }
+
@override
Future startVideoRecording(String path) async =>
calls.add('startRecording:$path');
diff --git a/test/web/web_kernels_test.dart b/test/web/web_kernels_test.dart
index 5ab3944..e66b957 100644
--- a/test/web/web_kernels_test.dart
+++ b/test/web/web_kernels_test.dart
@@ -168,5 +168,85 @@ void main() {
}
expect(marked, greaterThan(0), reason: 'edge pixels highlighted');
});
+
+ test('exposure fusion lifts shadows and recovers highlights', () {
+ // 2-pixel scene, 3-frame bracket: the shadow pixel is only well-exposed
+ // in the bright frame, the highlight only in the dark frame.
+ const w = 2, h = 1;
+ Uint8List frame(int shadow, int highlight) {
+ final b = Uint8List(w * h * 4);
+ b[0] = b[1] = b[2] = shadow;
+ b[3] = 255;
+ b[4] = b[5] = b[6] = highlight;
+ b[7] = 255;
+ return b;
+ }
+
+ final fused = NativeCore.exposureFusion(
+ [frame(0, 150), frame(30, 230), frame(110, 255)],
+ width: w,
+ height: h,
+ isBgra: false,
+ );
+ expect(fused[0], greaterThan(90)); // shadow lifted toward 110
+ expect(fused[4], lessThan(180)); // highlight recovered toward 150
+ expect(fused[3], 255);
+ });
+
+ test('exposure fusion preserves channel order on a colored bracket', () {
+ // A saturated orange bracket — pins channel order and saturation, which a
+ // grayscale test (R=G=B) can't distinguish.
+ Uint8List frame(int r, int g, int b) {
+ final px = Uint8List(4);
+ px[0] = r;
+ px[1] = g;
+ px[2] = b;
+ px[3] = 255;
+ return px;
+ }
+
+ final fused = NativeCore.exposureFusion(
+ [frame(40, 24, 12), frame(200, 120, 60), frame(255, 200, 150)],
+ width: 1,
+ height: 1,
+ isBgra: false,
+ );
+ expect(fused[0], greaterThan(fused[1])); // R > G
+ expect(fused[1], greaterThan(fused[2])); // G > B
+ expect(fused[0], greaterThan(150));
+ });
+
+ test('local tone mapping lifts shadows and tames highlights', () {
+ // High-DR frame with fine stripe texture (local contrast to adapt to).
+ const w = 32, h = 16;
+ final frame = Uint8List(w * h * 4);
+ for (var y = 0; y < h; y++) {
+ for (var x = 0; x < w; x++) {
+ final base = x < w ~/ 2 ? 28 : 224;
+ final v = (y & 2) != 0 ? base + 12 : base - 12;
+ final o = (y * w + x) * 4;
+ frame[o] = frame[o + 1] = frame[o + 2] = v;
+ frame[o + 3] = 255;
+ }
+ }
+ final out =
+ NativeCore.localTonemap(frame, width: w, height: h, isBgra: false);
+ var inDark = 0, outDark = 0, inBright = 0, outBright = 0;
+ for (var y = 0; y < h; y++) {
+ for (var x = 0; x < w; x++) {
+ final o = (y * w + x) * 4;
+ if (x < w ~/ 2) {
+ inDark += frame[o];
+ outDark += out[o];
+ } else {
+ inBright += frame[o];
+ outBright += out[o];
+ }
+ }
+ }
+ expect(outDark, greaterThan(inDark));
+ expect(outBright, lessThan(inBright));
+ expect(out[3], 255);
+ });
});
}