mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: SOTA image enhancement with one-click auto-improve (#55)
* feat(image-enhancement): add analysis and correction types
* feat(image-enhancement): implement auto-enhance analysis and correction engine
* test(image-enhancement): add unit tests for auto-enhance engine
* feat(image-enhancement): add API route with analyze endpoint and register in constants/i18n
* feat(image-enhancement): add UI component with mode selector, intensity slider, and analysis badges
* test(image-enhancement): add integration and e2e tests
* fix(image-enhancement): use modulate instead of gamma for exposure correction
Sharp's gamma() only accepts values between 1.0 and 3.0, but brightening
underexposed images computed gamma < 1.0. Switch to modulate({ brightness })
which handles both brightening and darkening correctly.
---------
Co-authored-by: stirling-image <stirling-image@users.noreply.github.com>
This commit is contained in:
co-authored by
stirling-image
parent
34ec840b72
commit
a8c7b92ca5
@@ -1,5 +1,6 @@
|
||||
export * from "./engine.js";
|
||||
export * from "./formats/detect.js";
|
||||
export { analyzeImage, applyCorrections, scaleCorrections } from "./operations/auto-enhance.js";
|
||||
export { brightness } from "./operations/brightness.js";
|
||||
export { colorChannels } from "./operations/color-channels.js";
|
||||
export { compress } from "./operations/compress.js";
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import sharp from "sharp";
|
||||
import type {
|
||||
AnalysisResult,
|
||||
AnalysisScores,
|
||||
CorrectionParams,
|
||||
EnhancementMode,
|
||||
Sharp,
|
||||
} from "../types.js";
|
||||
|
||||
/**
|
||||
* Preset multipliers applied to auto-computed corrections.
|
||||
* Each value scales the corresponding correction (1.0 = unchanged).
|
||||
*/
|
||||
const PRESET_MULTIPLIERS: Record<
|
||||
EnhancementMode,
|
||||
{
|
||||
brightness: number;
|
||||
contrast: number;
|
||||
temperature: number;
|
||||
saturation: number;
|
||||
sharpness: number;
|
||||
denoise: number;
|
||||
}
|
||||
> = {
|
||||
auto: {
|
||||
brightness: 1.0,
|
||||
contrast: 1.0,
|
||||
temperature: 1.0,
|
||||
saturation: 1.0,
|
||||
sharpness: 1.0,
|
||||
denoise: 1.0,
|
||||
},
|
||||
portrait: {
|
||||
brightness: 0.8,
|
||||
contrast: 0.7,
|
||||
temperature: 1.2,
|
||||
saturation: 0.6,
|
||||
sharpness: 0.5,
|
||||
denoise: 1.5,
|
||||
},
|
||||
landscape: {
|
||||
brightness: 1.0,
|
||||
contrast: 1.3,
|
||||
temperature: 1.0,
|
||||
saturation: 1.4,
|
||||
sharpness: 1.5,
|
||||
denoise: 0.5,
|
||||
},
|
||||
"low-light": {
|
||||
brightness: 1.8,
|
||||
contrast: 1.5,
|
||||
temperature: 1.0,
|
||||
saturation: 0.8,
|
||||
sharpness: 1.2,
|
||||
denoise: 2.0,
|
||||
},
|
||||
food: {
|
||||
brightness: 0.8,
|
||||
contrast: 1.1,
|
||||
temperature: 1.3,
|
||||
saturation: 1.3,
|
||||
sharpness: 1.2,
|
||||
denoise: 0.5,
|
||||
},
|
||||
document: {
|
||||
brightness: 1.5,
|
||||
contrast: 2.0,
|
||||
temperature: 1.0,
|
||||
saturation: 0.0,
|
||||
sharpness: 2.0,
|
||||
denoise: 2.0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Analyze an image buffer and return quality scores + computed corrections.
|
||||
* Uses Sharp's stats() for per-channel histogram statistics.
|
||||
*/
|
||||
export async function analyzeImage(buffer: Buffer): Promise<AnalysisResult> {
|
||||
const image = sharp(buffer);
|
||||
const stats = await image.stats();
|
||||
const meta = await image.metadata();
|
||||
|
||||
const channels = stats.channels;
|
||||
const isGrayscale = channels.length === 1;
|
||||
|
||||
const rCh = channels[0];
|
||||
const gCh = channels[Math.min(1, channels.length - 1)];
|
||||
const bCh = channels[Math.min(2, channels.length - 1)];
|
||||
|
||||
// Overall luminance approximation (BT.601 weights)
|
||||
const meanLuminance = rCh.mean * 0.299 + gCh.mean * 0.587 + bCh.mean * 0.114;
|
||||
const stdevLuminance = rCh.stdev * 0.299 + gCh.stdev * 0.587 + bCh.stdev * 0.114;
|
||||
|
||||
const scores = computeScores(
|
||||
rCh,
|
||||
gCh,
|
||||
bCh,
|
||||
meanLuminance,
|
||||
stdevLuminance,
|
||||
isGrayscale,
|
||||
stats.entropy,
|
||||
);
|
||||
const corrections = computeCorrections(scores);
|
||||
const issues = detectIssues(scores);
|
||||
const suggestedMode = suggestMode(scores, meta);
|
||||
|
||||
return { scores, corrections, issues, suggestedMode };
|
||||
}
|
||||
|
||||
function computeScores(
|
||||
rCh: sharp.ChannelStats,
|
||||
gCh: sharp.ChannelStats,
|
||||
bCh: sharp.ChannelStats,
|
||||
meanLum: number,
|
||||
stdevLum: number,
|
||||
isGrayscale: boolean,
|
||||
entropy: number,
|
||||
): AnalysisScores {
|
||||
const exposureScore = clamp(Math.round((meanLum / 255) * 100), 0, 100);
|
||||
|
||||
const idealStdev = 60;
|
||||
const contrastDeviation = Math.abs(stdevLum - idealStdev) / idealStdev;
|
||||
const contrastScore = clamp(Math.round((1 - contrastDeviation) * 50 + 25), 0, 100);
|
||||
|
||||
const meanR = rCh.mean;
|
||||
const meanG = gCh.mean;
|
||||
const meanB = bCh.mean;
|
||||
const channelSpread = Math.max(meanR, meanG, meanB) - Math.min(meanR, meanG, meanB);
|
||||
const wbScore = isGrayscale ? 50 : clamp(Math.round(50 - channelSpread * 0.8), 0, 100);
|
||||
|
||||
const satScore = isGrayscale ? 50 : clamp(Math.round(channelSpread * 1.2 + 20), 0, 100);
|
||||
|
||||
const sharpnessScore = clamp(Math.round(stdevLum * 0.8 + 10), 0, 100);
|
||||
|
||||
const noiseScore = clamp(Math.round(100 - (entropy - 5) * 20), 0, 100);
|
||||
|
||||
return {
|
||||
exposure: exposureScore,
|
||||
contrast: contrastScore,
|
||||
whiteBalance: wbScore,
|
||||
saturation: satScore,
|
||||
sharpness: sharpnessScore,
|
||||
noise: noiseScore,
|
||||
};
|
||||
}
|
||||
|
||||
function computeCorrections(scores: AnalysisScores): CorrectionParams {
|
||||
const brightness = clamp(Math.round((50 - scores.exposure) * 1.2), -60, 60);
|
||||
const contrast = clamp(Math.round((50 - scores.contrast) * 0.8), -40, 40);
|
||||
const temperature = clamp(Math.round((50 - scores.whiteBalance) * 0.5), -30, 30);
|
||||
|
||||
const saturation =
|
||||
scores.saturation < 40
|
||||
? clamp(Math.round((40 - scores.saturation) * 0.6), 0, 30)
|
||||
: scores.saturation > 60
|
||||
? clamp(Math.round((60 - scores.saturation) * 0.4), -20, 0)
|
||||
: 0;
|
||||
|
||||
const sharpness =
|
||||
scores.sharpness < 40 ? clamp(Math.round((40 - scores.sharpness) * 1.0), 0, 50) : 0;
|
||||
|
||||
const denoise = scores.noise < 25 ? 5 : scores.noise < 35 ? 3 : 0;
|
||||
|
||||
return { brightness, contrast, temperature, saturation, sharpness, denoise };
|
||||
}
|
||||
|
||||
function detectIssues(scores: AnalysisScores): string[] {
|
||||
const issues: string[] = [];
|
||||
if (scores.exposure < 35) issues.push("underexposed");
|
||||
if (scores.exposure > 70) issues.push("overexposed");
|
||||
if (scores.contrast < 35) issues.push("low-contrast");
|
||||
if (scores.whiteBalance < 35) issues.push("color-cast");
|
||||
if (scores.saturation < 30) issues.push("desaturated");
|
||||
if (scores.sharpness < 35) issues.push("soft-focus");
|
||||
if (scores.noise < 30) issues.push("noisy");
|
||||
return issues;
|
||||
}
|
||||
|
||||
function suggestMode(scores: AnalysisScores, _meta: sharp.Metadata): EnhancementMode {
|
||||
if (scores.exposure < 30) return "low-light";
|
||||
if (scores.contrast > 60 && scores.saturation < 30) return "document";
|
||||
return "auto";
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auto-enhancement corrections to a Sharp pipeline.
|
||||
*/
|
||||
export function applyCorrections(
|
||||
image: Sharp,
|
||||
corrections: CorrectionParams,
|
||||
mode: EnhancementMode,
|
||||
intensity: number,
|
||||
toggles: Record<string, boolean>,
|
||||
): Sharp {
|
||||
const presets = PRESET_MULTIPLIERS[mode];
|
||||
const scale = intensity / 50;
|
||||
|
||||
let result = image;
|
||||
|
||||
if (toggles.exposure !== false) {
|
||||
const adj = corrections.brightness * presets.brightness * scale;
|
||||
if (Math.abs(adj) > 2) {
|
||||
const multiplier = clamp(1 + adj / 100, 0.2, 3.0);
|
||||
result = result.modulate({ brightness: multiplier });
|
||||
}
|
||||
}
|
||||
|
||||
if (toggles.contrast !== false) {
|
||||
const adj = corrections.contrast * presets.contrast * scale;
|
||||
if (Math.abs(adj) > 2) {
|
||||
const slope = 1 + adj / 100;
|
||||
const intercept = 128 * (1 - slope);
|
||||
result = result.linear(slope, intercept);
|
||||
}
|
||||
}
|
||||
|
||||
if (toggles.whiteBalance !== false) {
|
||||
const adj = corrections.temperature * presets.temperature * scale;
|
||||
if (Math.abs(adj) > 2) {
|
||||
const t = adj / 100;
|
||||
result = result.recomb([
|
||||
[1 + t * 0.15, 0, 0],
|
||||
[0, 1 + t * 0.05, 0],
|
||||
[0, 0, 1 - t * 0.15],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
if (toggles.saturation !== false) {
|
||||
const adj = corrections.saturation * presets.saturation * scale;
|
||||
if (Math.abs(adj) > 2) {
|
||||
result = result.modulate({ saturation: 1 + adj / 100 });
|
||||
}
|
||||
}
|
||||
|
||||
if (toggles.sharpness !== false) {
|
||||
const adj = corrections.sharpness * presets.sharpness * scale;
|
||||
if (adj > 2) {
|
||||
const sigma = 0.5 + (adj / 100) * 4;
|
||||
result = result.sharpen({ sigma });
|
||||
}
|
||||
}
|
||||
|
||||
if (toggles.denoise !== false) {
|
||||
const adj = corrections.denoise * presets.denoise * scale;
|
||||
if (adj >= 2) {
|
||||
const kernel = adj >= 4 ? 5 : 3;
|
||||
result = result.median(kernel);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scale corrections by intensity and preset multipliers, returning
|
||||
* CSS-compatible values for the frontend live preview.
|
||||
*/
|
||||
export function scaleCorrections(
|
||||
corrections: CorrectionParams,
|
||||
mode: EnhancementMode,
|
||||
intensity: number,
|
||||
): CorrectionParams {
|
||||
const presets = PRESET_MULTIPLIERS[mode];
|
||||
const scale = intensity / 50;
|
||||
return {
|
||||
brightness: Math.round(corrections.brightness * presets.brightness * scale),
|
||||
contrast: Math.round(corrections.contrast * presets.contrast * scale),
|
||||
temperature: Math.round(corrections.temperature * presets.temperature * scale),
|
||||
saturation: Math.round(corrections.saturation * presets.saturation * scale),
|
||||
sharpness: Math.round(corrections.sharpness * presets.sharpness * scale),
|
||||
denoise: Math.round(corrections.denoise * presets.denoise * scale),
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
@@ -108,3 +108,45 @@ export interface ColorChannelOptions {
|
||||
export interface SharpenOptions {
|
||||
value: number; // 0 to 100
|
||||
}
|
||||
|
||||
export type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document";
|
||||
|
||||
export interface AnalysisScores {
|
||||
/** 0-100, 50 = ideal exposure */
|
||||
exposure: number;
|
||||
/** 0-100, 50 = ideal contrast */
|
||||
contrast: number;
|
||||
/** 0-100, 50 = neutral white balance */
|
||||
whiteBalance: number;
|
||||
/** 0-100, 50 = ideal saturation */
|
||||
saturation: number;
|
||||
/** 0-100, 50 = ideally sharp */
|
||||
sharpness: number;
|
||||
/** 0-100, 50 = no significant noise */
|
||||
noise: number;
|
||||
}
|
||||
|
||||
export interface AnalysisResult {
|
||||
scores: AnalysisScores;
|
||||
/** CSS-filter-compatible correction values for live preview */
|
||||
corrections: CorrectionParams;
|
||||
/** Human-readable issue labels, e.g. ["underexposed", "color-cast"] */
|
||||
issues: string[];
|
||||
/** Best-guess preset for this image */
|
||||
suggestedMode: EnhancementMode;
|
||||
}
|
||||
|
||||
export interface CorrectionParams {
|
||||
/** Maps to CSS brightness() and Sharp gamma. -100 to +100. */
|
||||
brightness: number;
|
||||
/** Maps to CSS contrast() and Sharp linear(). -100 to +100. */
|
||||
contrast: number;
|
||||
/** Maps to recomb matrix / CSS feColorMatrix. -100 to +100. */
|
||||
temperature: number;
|
||||
/** Maps to CSS saturate() and Sharp modulate(). -100 to +100. */
|
||||
saturation: number;
|
||||
/** Maps to SVG feConvolveMatrix and Sharp sharpen(). 0 to 100. */
|
||||
sharpness: number;
|
||||
/** Denoise strength. 0 = off, 1-5 = median kernel size. */
|
||||
denoise: number;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user