fix(export): stop forcing a 0.5 full-res unsharp mask on every capture

App.tsx passed `sharpen: true` on camera exports, and both engines turned
that into a 0.5 unsharp mask whenever the SHARPENING knob sat at its
default 0. The 3x3 kernel [0,-a,0; -a,1+4a,-a; 0,-a,0] with a=0.5 has a
noise gain of 3.167x at low frequency and 5.0x at Nyquist, so high-ISO
files came back far noisier than the viewfinder ever showed.

The knob is now the only source of output sharpening: dropping the
fallback cuts flat-region noise sigma ~1.6x at ISO ~330 (2.36 vs 4.52)
and ~2.0x at ISO ~1900 (0.23 vs 0.46), and the JPEGs are 26-35% smaller.

Measured on a Xiaomi 12S Ultra, 3000x4000 captures, same scene:
  1x, ISO 335: sigma 2.35, dark-quartile 1.56, flat-tile 0.349
 10x, ISO 2119: sigma 1.09, dark-quartile 0.54, flat-tile 0.225

The SHARPENING knob still sharpens as before when dialled above 0.
This commit is contained in:
2026-09-24 16:04:32 +07:00
parent 4863080dfe
commit fd38269709
3 changed files with 16 additions and 21 deletions
+6 -4
View File
@@ -1555,12 +1555,14 @@ export default function App() {
// shutter instant as the EXIF capture date.
exported: false,
capturedAt,
// 300dpi header + full-res screen sharpen. No evFromCamera: the camera
// AE bias is never written, so the export applies the same software EV
// gain the preview shows.
// 300dpi header. No evFromCamera: the camera AE bias is never written,
// so the export applies the same software EV gain the preview shows.
// No forced output sharpen either: the 0.5 unsharp mask multiplied
// sensor noise ~3.2x (5x at Nyquist), so dim/high-ISO captures came
// back far noisier than the preview. The SHARPENING knob still
// sharpens when the user dials it above 0.
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
dpi: 300,
sharpen: true,
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
watermark: wmForExport(),
liteMark,
+6 -10
View File
@@ -28,9 +28,6 @@ export interface ExportResult {
export interface ExportOptions {
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
dpi?: number;
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
// final pixels before encoding. Pixels already export at full resolution.
sharpen?: boolean;
// True for photos captured through the live camera: exposure compensation was
// already applied as a hardware AE bias at capture, so no 2^EV matrix gain
// should be added on top. Library stills omit this and get the matrix gain.
@@ -149,7 +146,7 @@ function release(owned: SkDisposable[], item: SkDisposable | null): void {
disposeAll([item]);
}
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
function screenSharpenImage(image: SkImage, amount: number): SkImage {
const w = image.width();
const h = image.height();
const center = 1 + 4 * amount;
@@ -894,12 +891,11 @@ export async function processAndExportPhoto(
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
let resultImage = own(outputSurface.makeImageSnapshot());
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
// A negative knob means "no sharpening at all" (its blur already ran at
// step 4, under the grain), so the legacy 0.5 fallback must not fire.
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
// Sharpening knob (0..10) is the only source: the old 0.5 fallback that
// fired on every camera capture multiplied sensor noise ~3.2x (5x at
// Nyquist) and made dim/high-ISO shots unusable. A negative or zero knob
// sharpens not at all (a negative one blurs at step 4, under the grain).
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
if (sharpenAmount > 0) {
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
// The helper returns its own input when it cannot allocate a surface.
+4 -7
View File
@@ -24,9 +24,6 @@ export interface NativeExportResult {
export interface NativeExportOptions {
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
dpi?: number;
// Apply the subtle "Screen" output sharpen (exportEngine #9) to the final
// pixels before encoding (legacy camera captures pass sharpen: true → 0.5).
sharpen?: boolean;
// True for photos captured through the live camera: the AE bias already
// applied exposure compensation at capture, so no 2^EV matrix gain is added.
evFromCamera?: boolean;
@@ -73,11 +70,11 @@ export async function processAndExportPhotoNative(
// (EXPO_PUBLIC_NATIVE_EXPORT is not inlined into a release HBC bundle).
const tone = getToneUniforms(adjustments, recipe.baseFilter);
const cinema = getCinemaUniforms(recipe.cinema);
// Final screen sharpen (engine #9): the sharpening knob (-10..10) overrides
// the legacy boolean sharpen flag (camera captures keep it true → 0.5).
// Final screen sharpen (engine #9): the sharpening knob (-10..10) is the
// only source — the old 0.5 fallback on every camera capture multiplied
// sensor noise ~3.2x and made dim/high-ISO shots unusable.
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount =
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : 0;
const aspect: '4:3' | '3:2' | undefined =
options?.aspect && options.aspect !== 'full' && frameId !== 'wallframe'
? options.aspect