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: ![burst and EV bracket](doc/diagrams/burst-bracket.svg) +`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). + +![HDR exposure fusion](doc/diagrams/hdr-fusion.svg) + +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. ![Burst and EV bracket](burst-bracket.svg) +## 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. + +![HDR / local tone mapping](hdr-fusion.svg) + ## 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 @@ + + + camera_pro single-capture HDR / local tone mapping + captureHdr captures one frame, synthesizes an exposure stack from it by scaling in linear light at several EV offsets, then fuses the stack with multi-scale Mertens exposure fusion (contrast, saturation and well-exposedness weights blended through a Laplacian pyramid). Because every exposure comes from one instant, the tone-mapped result is sharp and ghost-free. + + + + + + + + + + + captureHdr() · ONE frame, no temporal bracket → ghost-free + + + + 1 frame · one instant + + LIVE + + + + + + synthesize · gain = 2^EV · linear light + + −EV shadows open + + 0 midtones + + +EV highlights hold + + + + + + + + + + + multi-scale Mertens fusion + contrast × saturation × well-exposed + + Laplacian pyramid blend · no halos + C core ≈ pure-Dart web + + + + + + tone-mapped · sharp + + HDR + + + + + + + + One capture, one instant — sharp and ghost-free. Shadows opened, highlights held, local contrast preserved. + Single-image exposure fusion = local tone mapping. Works wherever the digital pipeline runs · macOS + web. + 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); + }); }); }