Swift Expo Module that decodes raw images for use in react-native-skia. It's currently limited because it writes to 8-bit PNG for rn-skia compatibility. This would clip any extended dynamic range values, so I have to set extendedDynamicRangeAmount = 0 and apply some of Apple's local tone mapping to ensure a usable image.
Created
May 21, 2025 13:50
-
-
Save stevengoldberg/91a4deceb9e19aa08c8f4b5203b1792d to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* | |
| ------------------------------------------------------------- | |
| * decodeAsync → renders RAW to PNG using minimal processing | |
| • applies BaselineExposure | |
| • localToneMapAmount interpolated from measuredDR() | |
| * extractPreview → extracts embedded JPEG preview, writes to tmp, returns {uri,width,height} | |
| * CIRAW defaults: gamutMapping ON, lensCorrection ON, boostAmount 0, all noise‑reduction 0 | |
| * isPreview flag maps to CIRAWFilter.isDraftModeEnabled | |
| */ | |
| import CoreImage | |
| import ImageIO | |
| import UniformTypeIdentifiers | |
| import ExpoModulesCore | |
| // MARK: – JS option object --------------------------------------------------------- | |
| struct RawOptions: Record { | |
| @Field var scale: Double? | |
| @Field var isPreview: Bool? | |
| } | |
| // MARK: – local errors ------------------------------------------------------------- | |
| enum RawErr: Error, LocalizedError { | |
| case bad, fail(String) | |
| var errorDescription: String? { | |
| switch self { | |
| case .bad: "Unreadable RAW file." | |
| case .fail(let msg): msg | |
| } | |
| } | |
| } | |
| // MARK: – Expo module -------------------------------------------------------------- | |
| public class ExpoRawLoaderModule: Module { | |
| public func definition() -> ModuleDefinition { | |
| Name("ExpoRawLoader") | |
| // 1. RAW → PNG --------------------------------------------------------- | |
| AsyncFunction("decodeAsync") { (uri: String, opts: RawOptions?) async throws -> [String: Any] in | |
| try await self.renderRAW( | |
| uri: uri, | |
| scale: opts?.scale ?? 1.0, | |
| isPreview: opts?.isPreview ?? false | |
| ) | |
| } | |
| // 2. Embedded JPEG preview ------------------------------------------- | |
| AsyncFunction("extractPreview") { (uri: String) async throws -> [String: Any] in | |
| try self.extractPreviewJPEG(from: uri) | |
| } | |
| } | |
| // MARK: – 1. render RAW ---------------------------------------------------- | |
| private func renderRAW(uri: String, scale: Double, isPreview: Bool) async throws -> [String: Any] { | |
| let url = URL(string: uri)! | |
| guard let raw = CIRAWFilter(imageURL: url) else { throw RawErr.bad } | |
| let ctx = CIContext() | |
| print("scale factor: \(scale)") | |
| // ---- default processing flags -------------------------------------- | |
| raw.isGamutMappingEnabled = true | |
| raw.isLensCorrectionEnabled = true | |
| raw.isDraftModeEnabled = isPreview | |
| raw.boostAmount = 0 | |
| raw.luminanceNoiseReductionAmount = 0 | |
| raw.colorNoiseReductionAmount = 0 | |
| raw.scaleFactor = Float(scale) | |
| raw.extendedDynamicRangeAmount = 0 | |
| print("baselineExposure: \(raw.baselineExposure)") | |
| // ---- dynamic‑range‑based local tone‑map ---------------------------- | |
| let sceneDR = measuredDR(from: raw, ctx: ctx) // stops | |
| let factor = min(max((sceneDR - 2.0) / 3.0, 0.0), 1.0) // 0‑1 | |
| raw.localToneMapAmount = Float(factor * 0.5) // up to 0.5 | |
| print("measuredDR =", sceneDR, "→ localToneMapAmount =", raw.localToneMapAmount) | |
| // ---- render & write PNG -------------------------------------------- | |
| guard let ci = raw.outputImage else { throw RawErr.fail("RAW output nil") } | |
| let pngURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".png") | |
| try ctx.writePNGRepresentation(of: ci, | |
| to: pngURL, | |
| format: .RGBA8, | |
| colorSpace: CGColorSpace(name: CGColorSpace.sRGB)!) | |
| return [ | |
| "uri": pngURL.absoluteString, | |
| "width": Int(ci.extent.width), | |
| "height": Int(ci.extent.height) | |
| ] | |
| } | |
| // MARK: – 2. extract preview JPEG ---------------------------------------- | |
| private func extractPreviewJPEG(from uri: String) throws -> [String: Any] { | |
| let url = URL(string: uri)! | |
| guard let src = CGImageSourceCreateWithURL(url as CFURL, nil) else { throw RawErr.bad } | |
| // Ask Image I/O for the *embedded preview* (no upscale, no RAW decode) | |
| let opts: [CFString: Any] = [ | |
| kCGImageSourceCreateThumbnailFromImageIfAbsent: false, | |
| kCGImageSourceCreateThumbnailWithTransform: true | |
| ] | |
| guard let cgPreview = CGImageSourceCreateThumbnailAtIndex(src, 0, opts as CFDictionary) else { | |
| throw RawErr.fail("No embedded JPEG preview found") | |
| } | |
| // Encode CGImage → JPEG data (lossless quality) | |
| let data = NSMutableData() | |
| guard let dest = CGImageDestinationCreateWithData(data as CFMutableData, | |
| UTType.jpeg.identifier as CFString, | |
| 1, nil) else { | |
| throw RawErr.fail("CGImageDestination create failed") | |
| } | |
| CGImageDestinationAddImage(dest, cgPreview, nil) | |
| guard CGImageDestinationFinalize(dest) else { throw RawErr.fail("JPEG encode failed") } | |
| let outURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".jpg") | |
| try data.write(to: outURL, options: .atomic) | |
| return [ | |
| "uri": outURL.absoluteString, | |
| "width": cgPreview.width, | |
| "height": cgPreview.height | |
| ] | |
| } | |
| // MARK: – helper: measure scene dynamic range ---------------------------- | |
| private func measuredDR(from raw: CIRAWFilter, ctx: CIContext, thumbMax: CGFloat = 256) -> Double { | |
| guard let full = raw.outputImage else { return 0 } | |
| let longEdge = max(full.extent.width, full.extent.height) | |
| let scale = min(1.0, thumbMax / longEdge) | |
| let thumb = full.applyingFilter("CILanczosScaleTransform", parameters: ["inputScale": scale]) | |
| let w = Int(thumb.extent.width) | |
| let h = Int(thumb.extent.height) | |
| var buf = [Float](repeating: 0, count: w * h * 4) | |
| ctx.render(thumb, toBitmap: &buf, rowBytes: w * 4 * MemoryLayout<Float>.size, | |
| bounds: CGRect(x: 0, y: 0, width: w, height: h), | |
| format: .RGBAf, colorSpace: nil) | |
| var lums: [Double] = [] | |
| lums.reserveCapacity(w * h) | |
| for i in 0 ..< w * h { | |
| let r = Double(buf[i*4+0]) | |
| let g = Double(buf[i*4+1]) | |
| let b = Double(buf[i*4+2]) | |
| lums.append(0.299 * r + 0.587 * g + 0.114 * b) | |
| } | |
| let good = lums.filter { $0 > 1e-6 } | |
| guard !good.isEmpty else { return 0 } | |
| let sorted = good.sorted() | |
| let p05 = sorted[Int(Double(sorted.count) * 0.05)] | |
| let p95 = sorted[Int(Double(sorted.count) * 0.95)] | |
| return min(max(log2(p95 / max(p05, 1e-12)), 0), 20) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment