From a8c7b92ca5ef787ebe95ea4ed4116cfa77e14a17 Mon Sep 17 00:00:00 2001 From: stirling-image Date: Mon, 13 Apr 2026 17:48:53 +0800 Subject: [PATCH] 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 --- .../api/src/routes/tools/image-enhancement.ts | 116 +++++ apps/api/src/routes/tools/index.ts | 2 + .../tools/image-enhancement-settings.tsx | 460 ++++++++++++++++++ apps/web/src/lib/suggested-tools.ts | 1 + apps/web/src/lib/tool-registry.tsx | 13 + packages/image-engine/src/index.ts | 1 + .../src/operations/auto-enhance.ts | 279 +++++++++++ packages/image-engine/src/types.ts | 42 ++ packages/shared/src/constants.ts | 8 + packages/shared/src/i18n/en.ts | 5 + tests/e2e/tools-process.spec.ts | 15 + tests/integration/api.test.ts | 104 ++++ tests/unit/auto-enhance.test.ts | 175 +++++++ 13 files changed, 1221 insertions(+) create mode 100644 apps/api/src/routes/tools/image-enhancement.ts create mode 100644 apps/web/src/components/tools/image-enhancement-settings.tsx create mode 100644 packages/image-engine/src/operations/auto-enhance.ts create mode 100644 tests/unit/auto-enhance.test.ts diff --git a/apps/api/src/routes/tools/image-enhancement.ts b/apps/api/src/routes/tools/image-enhancement.ts new file mode 100644 index 00000000..2128bffa --- /dev/null +++ b/apps/api/src/routes/tools/image-enhancement.ts @@ -0,0 +1,116 @@ +import { analyzeImage, applyCorrections } from "@stirling-image/image-engine"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { autoOrient } from "../../lib/auto-orient.js"; +import { validateImageBuffer } from "../../lib/file-validation.js"; +import { sanitizeFilename } from "../../lib/filename.js"; +import { decodeHeic } from "../../lib/heic-converter.js"; +import { resolveOutputFormat } from "../../lib/output-format.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + mode: z.enum(["auto", "portrait", "landscape", "low-light", "food", "document"]).default("auto"), + intensity: z.number().min(0).max(100).default(50), + corrections: z + .object({ + exposure: z.boolean().default(true), + contrast: z.boolean().default(true), + whiteBalance: z.boolean().default(true), + saturation: z.boolean().default(true), + sharpness: z.boolean().default(true), + denoise: z.boolean().default(true), + }) + .default({}), +}); + +type EnhancementSettings = z.infer; + +async function processImageEnhancement( + inputBuffer: Buffer, + settings: EnhancementSettings, + filename: string, +) { + const outputFormat = await resolveOutputFormat(inputBuffer, filename); + const analysis = await analyzeImage(inputBuffer); + + let image = sharp(inputBuffer); + image = applyCorrections( + image, + analysis.corrections, + settings.mode, + settings.intensity, + settings.corrections, + ); + + const buffer = await image + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + + return { buffer, filename, contentType: outputFormat.contentType }; +} + +export function registerImageEnhancement(app: FastifyInstance) { + createToolRoute(app, { + toolId: "image-enhancement", + settingsSchema, + process: processImageEnhancement, + }); + + app.post( + "/api/v1/tools/image-enhancement/analyze", + async (request: FastifyRequest, reply: FastifyReply) => { + let fileBuffer: Buffer | null = null; + + try { + const parts = request.parts(); + for await (const part of parts) { + if (part.type === "file") { + const chunks: Buffer[] = []; + for await (const chunk of part.file) { + chunks.push(chunk); + } + fileBuffer = Buffer.concat(chunks); + break; + } + } + } catch (err) { + return reply.status(400).send({ + error: "Failed to parse request", + details: err instanceof Error ? err.message : String(err), + }); + } + + if (!fileBuffer || fileBuffer.length === 0) { + return reply.status(400).send({ error: "No image file provided" }); + } + + const validation = await validateImageBuffer(fileBuffer); + if (!validation.valid) { + return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); + } + + if (validation.format === "heif") { + try { + fileBuffer = await decodeHeic(fileBuffer); + } catch (err) { + return reply.status(422).send({ + error: "Failed to decode HEIC file", + details: err instanceof Error ? err.message : String(err), + }); + } + } + + try { + fileBuffer = await autoOrient(fileBuffer); + const analysis = await analyzeImage(fileBuffer); + return reply.send(analysis); + } catch (err) { + return reply.status(422).send({ + error: "Analysis failed", + details: err instanceof Error ? err.message : String(err), + }); + } + }, + ); +} diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index f369d020..c680e424 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -20,6 +20,7 @@ import { registerEraseObject } from "./erase-object.js"; import { registerFavicon } from "./favicon.js"; import { registerFindDuplicates } from "./find-duplicates.js"; import { registerGifTools } from "./gif-tools.js"; +import { registerImageEnhancement } from "./image-enhancement.js"; import { registerImageToPdf } from "./image-to-pdf.js"; import { registerInfo } from "./info.js"; import { registerOcr } from "./ocr.js"; @@ -125,6 +126,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "blur-faces", register: registerBlurFaces }, { id: "erase-object", register: registerEraseObject }, { id: "smart-crop", register: registerSmartCrop }, + { id: "image-enhancement", register: registerImageEnhancement }, { id: "content-aware-resize", register: registerContentAwareResize }, ]; diff --git a/apps/web/src/components/tools/image-enhancement-settings.tsx b/apps/web/src/components/tools/image-enhancement-settings.tsx new file mode 100644 index 00000000..4d906e7b --- /dev/null +++ b/apps/web/src/components/tools/image-enhancement-settings.tsx @@ -0,0 +1,460 @@ +import { + Download, + FileText, + Moon, + Mountain, + Sparkles, + User, + UtensilsCrossed, + X, +} from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document"; + +interface AnalysisScores { + exposure: number; + contrast: number; + whiteBalance: number; + saturation: number; + sharpness: number; + noise: number; +} + +interface CorrectionParams { + brightness: number; + contrast: number; + temperature: number; + saturation: number; + sharpness: number; + denoise: number; +} + +interface AnalysisData { + scores: AnalysisScores; + corrections: CorrectionParams; + issues: string[]; + suggestedMode: EnhancementMode; +} + +const MODES: { value: EnhancementMode; label: string; icon: typeof Sparkles }[] = [ + { value: "auto", label: "Auto", icon: Sparkles }, + { value: "portrait", label: "Portrait", icon: User }, + { value: "landscape", label: "Landscape", icon: Mountain }, + { value: "low-light", label: "Low Light", icon: Moon }, + { value: "food", label: "Food", icon: UtensilsCrossed }, + { value: "document", label: "Document", icon: FileText }, +]; + +const PRESET_MULTIPLIERS: Record> = { + 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, + }, +}; + +const ISSUE_LABELS: Record = { + underexposed: "Low Exposure", + overexposed: "Overexposed", + "low-contrast": "Flat Contrast", + "color-cast": "Color Cast", + desaturated: "Desaturated", + "soft-focus": "Soft Focus", + noisy: "Noisy", +}; + +const ISSUE_TO_TOGGLE: Record = { + underexposed: "exposure", + overexposed: "exposure", + "low-contrast": "contrast", + "color-cast": "whiteBalance", + desaturated: "saturation", + "soft-focus": "sharpness", + noisy: "denoise", +}; + +interface ImageEnhancementControlsProps { + settings?: Record; + onChange?: (settings: Record) => void; + onPreviewFilter?: (filter: string) => void; +} + +export function ImageEnhancementControls({ + settings: initialSettings, + onChange, + onPreviewFilter, +}: ImageEnhancementControlsProps) { + const { files } = useFileStore(); + const [mode, setMode] = useState("auto"); + const [intensity, setIntensity] = useState(50); + const [analysis, setAnalysis] = useState(null); + const [analyzing, setAnalyzing] = useState(false); + const [toggles, setToggles] = useState>({ + exposure: true, + contrast: true, + whiteBalance: true, + saturation: true, + sharpness: true, + denoise: true, + }); + + const analyzeAbortRef = useRef(null); + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + // Analyze image when files change + useEffect(() => { + if (files.length === 0) { + setAnalysis(null); + return; + } + + analyzeAbortRef.current?.abort(); + const controller = new AbortController(); + analyzeAbortRef.current = controller; + + setAnalyzing(true); + const formData = new FormData(); + formData.append("file", files[0]); + + fetch("/api/v1/tools/image-enhancement/analyze", { + method: "POST", + body: formData, + signal: controller.signal, + }) + .then((res) => (res.ok ? res.json() : Promise.reject(new Error("Analysis failed")))) + .then((data: AnalysisData) => { + setAnalysis(data); + if (data.suggestedMode !== "auto") { + setMode(data.suggestedMode); + } + }) + .catch((err) => { + if (err.name !== "AbortError") { + console.error("Analysis error:", err); + } + }) + .finally(() => setAnalyzing(false)); + + return () => controller.abort(); + }, [files]); + + // Emit settings when mode/intensity/toggles change + useEffect(() => { + onChangeRef.current?.({ mode, intensity, corrections: toggles }); + }, [mode, intensity, toggles]); + + // CSS filter preview + useEffect(() => { + if (!onPreviewFilter || !analysis) { + onPreviewFilter?.(""); + return; + } + + const presets = PRESET_MULTIPLIERS[mode]; + const scale = intensity / 50; + const c = analysis.corrections; + const parts: string[] = []; + + if (toggles.exposure && Math.abs(c.brightness) > 2) { + const adj = c.brightness * (presets.brightness ?? 1) * scale; + parts.push(`brightness(${1 + adj / 100})`); + } + if (toggles.contrast && Math.abs(c.contrast) > 2) { + const adj = c.contrast * (presets.contrast ?? 1) * scale; + parts.push(`contrast(${1 + adj / 100})`); + } + if (toggles.saturation && Math.abs(c.saturation) > 2) { + const adj = c.saturation * (presets.saturation ?? 1) * scale; + parts.push(`saturate(${1 + adj / 100})`); + } + if (toggles.whiteBalance && Math.abs(c.temperature) > 2) { + parts.push("url(#stirling-enhance-temp-filter)"); + } + if (toggles.sharpness && c.sharpness > 2) { + parts.push("url(#stirling-enhance-sharpen-filter)"); + } + + onPreviewFilter(parts.join(" ")); + }, [analysis, mode, intensity, toggles, onPreviewFilter]); + + const toggleCorrection = (key: string) => { + setToggles((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + const tempAdj = analysis + ? (analysis.corrections.temperature * (PRESET_MULTIPLIERS[mode].temperature ?? 1) * intensity) / + 50 / + 100 + : 0; + const sharpAdj = analysis + ? (analysis.corrections.sharpness * (PRESET_MULTIPLIERS[mode].sharpness ?? 1) * intensity) / + 50 / + 100 + : 0; + + return ( + <> + {/* Hidden SVG filters for preview */} + {toggles.whiteBalance && Math.abs(tempAdj) > 0.02 && ( + + + + + + )} + {toggles.sharpness && sharpAdj > 0.02 && ( + + + + + + )} + + {/* Mode selector */} +

+ Enhancement Mode +

+
+ {MODES.map(({ value, label, icon: Icon }) => ( + + ))} +
+ + {/* Intensity slider */} +
+
+

+ Intensity +

+ {intensity}% +
+ setIntensity(Number(e.target.value))} + className="w-full mt-1" + /> +
+ + {/* Analysis badges */} + {analyzing && ( +
+
+ Analyzing image... +
+ )} + + {analysis && !analyzing && ( +
+

+ Detected Issues +

+ {analysis.issues.length === 0 ? ( +

+ Image looks good. Fine-tune with the intensity slider. +

+ ) : ( +
+ {analysis.issues.map((issue) => { + const toggleKey = ISSUE_TO_TOGGLE[issue]; + const isEnabled = toggleKey ? toggles[toggleKey] !== false : true; + return ( + + ); + })} +
+ )} + + {/* Score indicators */} +
+ {( + [ + ["Exposure", analysis.scores.exposure], + ["Contrast", analysis.scores.contrast], + ["White Bal", analysis.scores.whiteBalance], + ["Saturation", analysis.scores.saturation], + ["Sharpness", analysis.scores.sharpness], + ["Noise", analysis.scores.noise], + ] as const + ).map(([label, score]) => ( +
+
+
65 ? "bg-blue-500" : "bg-emerald-500" + }`} + style={{ width: `${score}%` }} + /> +
+ {label} +
+ ))} +
+
+ )} + + ); +} + +// Wrapper with process/download flow + +export function ImageEnhancementSettings({ + onPreviewFilter, +}: { + onPreviewFilter?: (filter: string) => void; +}) { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("image-enhancement"); + const [settings, setSettings] = useState>({}); + + const hasFile = files.length > 0; + + const handleProcess = () => { + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (hasFile && !processing) handleProcess(); + }; + + return ( +
+ + + {error &&

{error}

} + + {originalSize != null && processedSize != null && ( +
+

Original: {(originalSize / 1024).toFixed(1)} KB

+

Enhanced: {(processedSize / 1024).toFixed(1)} KB

+
+ )} + + {processing ? ( + 1 ? `Enhancing ${files.length} images` : "Enhancing image"} + percent={progress.percent} + elapsed={progress.elapsed} + /> + ) : ( + + )} + + {downloadUrl && files.length <= 1 && ( + + + Download + + )} + + ); +} diff --git a/apps/web/src/lib/suggested-tools.ts b/apps/web/src/lib/suggested-tools.ts index f451f2bc..e61c03c1 100644 --- a/apps/web/src/lib/suggested-tools.ts +++ b/apps/web/src/lib/suggested-tools.ts @@ -10,6 +10,7 @@ const TOOL_SUGGESTIONS: Record = { "remove-background": ["resize", "compress", "convert"], upscale: ["compress", "convert"], "smart-crop": ["resize", "compress"], + "image-enhancement": ["adjust-colors", "upscale", "compress"], "watermark-text": ["compress", "convert"], "watermark-image": ["compress", "convert"], "text-overlay": ["compress", "convert"], diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 77afc137..899f8870 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -234,6 +234,11 @@ const SmartCropSettings = lazy(() => default: m.SmartCropSettings, })), ); +const ImageEnhancementSettings = lazy(() => + import("@/components/tools/image-enhancement-settings").then((m) => ({ + default: m.ImageEnhancementSettings, + })), +); // ── Color tool wrapper ───────────────────────────────────────────── // Color tools share a single component but differ by toolId. @@ -351,6 +356,14 @@ export const toolRegistry = new Map([ }, ], ["smart-crop", { displayMode: "before-after", Settings: SmartCropSettings }], + [ + "image-enhancement", + { + displayMode: "live-preview" as DisplayMode, + livePreview: true, + Settings: ImageEnhancementSettings as never, + }, + ], ]); export function getToolRegistryEntry(toolId: string): ToolRegistryEntry | undefined { diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 22f8b7ad..757b33ea 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -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"; diff --git a/packages/image-engine/src/operations/auto-enhance.ts b/packages/image-engine/src/operations/auto-enhance.ts new file mode 100644 index 00000000..7eaa0727 --- /dev/null +++ b/packages/image-engine/src/operations/auto-enhance.ts @@ -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 { + 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, +): 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)); +} diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index fe02edd7..37f43824 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -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; +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index dfccda47..4519c009 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -161,6 +161,14 @@ export const TOOLS: Tool[] = [ icon: "Focus", route: "/smart-crop", }, + { + id: "image-enhancement", + name: "Image Enhancement", + description: "One-click auto-improve with smart analysis", + category: "ai", + icon: "Sparkles", + route: "/image-enhancement", + }, // Watermark & Overlay { id: "watermark-text", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index d6836c59..9b1a4dcf 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -71,6 +71,11 @@ export const en = { name: "Smart Crop", description: "Smart subject, face, or trim-based cropping", }, + "image-enhancement": { + name: "Image Enhancement", + description: + "One-click auto-improve with smart exposure, contrast, color, and sharpness correction", + }, "content-aware-resize": { name: "Content-Aware Resize", description: "Intelligently resize images while preserving important content", diff --git a/tests/e2e/tools-process.spec.ts b/tests/e2e/tools-process.spec.ts index 5ddf6937..0b6daabd 100644 --- a/tests/e2e/tools-process.spec.ts +++ b/tests/e2e/tools-process.spec.ts @@ -120,6 +120,21 @@ test.describe("Tool processing (core tools)", () => { }); }); + test("image-enhancement processes image", async ({ loggedInPage: page }) => { + await page.goto("/image-enhancement"); + await uploadTestImage(page); + // Wait for analysis to complete (badges appear) + await expect( + page.locator("text=Intensity").or(page.locator("text=Enhancement Mode")), + ).toBeVisible({ timeout: 10_000 }); + // Click Enhance button + await page.getByRole("button", { name: /^enhance$/i }).click(); + await waitForProcessing(page); + await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({ + timeout: 15_000, + }); + }); + test("border processes image", async ({ loggedInPage: page }) => { await page.goto("/border"); await uploadTestImage(page); diff --git a/tests/integration/api.test.ts b/tests/integration/api.test.ts index a5d2353b..38965f1a 100644 --- a/tests/integration/api.test.ts +++ b/tests/integration/api.test.ts @@ -3952,3 +3952,107 @@ describe("Edit metadata", () => { }); }); }); + +describe("Image Enhancement", () => { + it("POST /api/v1/tools/image-enhancement processes an image", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 }, + { + name: "settings", + content: JSON.stringify({ + mode: "auto", + intensity: 50, + corrections: { + exposure: true, + contrast: true, + whiteBalance: true, + saturation: true, + sharpness: true, + denoise: true, + }, + }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.jobId).toBeDefined(); + expect(body.downloadUrl).toBeDefined(); + expect(body.processedSize).toBeGreaterThan(0); + }); + + it("POST /api/v1/tools/image-enhancement/analyze returns analysis data", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement/analyze", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.scores).toBeDefined(); + expect(body.corrections).toBeDefined(); + expect(body.issues).toBeInstanceOf(Array); + expect(body.suggestedMode).toBeDefined(); + expect(typeof body.scores.exposure).toBe("number"); + }); + + it("preserves JPEG format through enhancement", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG_100x100 }, + { + name: "settings", + content: JSON.stringify({ + mode: "auto", + intensity: 50, + }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.downloadUrl).toMatch(/\.jpg/); + }); + + it("rejects empty file", async () => { + const { body: payload, contentType } = createMultipartPayload([ + { name: "file", filename: "empty.png", contentType: "image/png", content: Buffer.alloc(0) }, + { + name: "settings", + content: JSON.stringify({ mode: "auto" }), + }, + ]); + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image-enhancement", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + payload, + }); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/tests/unit/auto-enhance.test.ts b/tests/unit/auto-enhance.test.ts new file mode 100644 index 00000000..34b462f0 --- /dev/null +++ b/tests/unit/auto-enhance.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { analyzeImage, applyCorrections, scaleCorrections } from "@stirling-image/image-engine"; +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; + +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); + +describe("analyzeImage", () => { + it("returns scores, corrections, issues, and suggestedMode", async () => { + const result = await analyzeImage(PNG_200x150); + expect(result.scores).toBeDefined(); + expect(result.corrections).toBeDefined(); + expect(result.issues).toBeInstanceOf(Array); + expect(result.suggestedMode).toBeDefined(); + + for (const key of Object.keys(result.scores) as (keyof typeof result.scores)[]) { + expect(result.scores[key]).toBeGreaterThanOrEqual(0); + expect(result.scores[key]).toBeLessThanOrEqual(100); + } + }); + + it("detects underexposure on a dark image", async () => { + const darkBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 20, g: 20, b: 20 } }, + }) + .png() + .toBuffer(); + + const result = await analyzeImage(darkBuffer); + expect(result.scores.exposure).toBeLessThan(30); + expect(result.issues).toContain("underexposed"); + expect(result.corrections.brightness).toBeGreaterThan(0); + }); + + it("detects overexposure on a bright image", async () => { + const brightBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 240, g: 240, b: 240 } }, + }) + .png() + .toBuffer(); + + const result = await analyzeImage(brightBuffer); + expect(result.scores.exposure).toBeGreaterThan(70); + expect(result.issues).toContain("overexposed"); + expect(result.corrections.brightness).toBeLessThan(0); + }); + + it("detects low contrast on a flat image", async () => { + const flatBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .png() + .toBuffer(); + + const result = await analyzeImage(flatBuffer); + expect(result.scores.contrast).toBeLessThan(40); + expect(result.corrections.contrast).toBeGreaterThan(0); + }); + + it("handles grayscale images without white balance issues", async () => { + const grayBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 128, g: 128, b: 128 } }, + }) + .grayscale() + .png() + .toBuffer(); + + const result = await analyzeImage(grayBuffer); + expect(result.scores.whiteBalance).toBe(50); + // Grayscale PNG from .grayscale() retains 3 channels with zero spread, + // so saturation formula yields channelSpread * 1.2 + 20 = 20 + expect(result.scores.saturation).toBe(20); + }); + + it("suggests low-light mode for very dark images", async () => { + const darkBuffer = await sharp({ + create: { width: 100, height: 100, channels: 3, background: { r: 15, g: 15, b: 15 } }, + }) + .png() + .toBuffer(); + + const result = await analyzeImage(darkBuffer); + expect(result.suggestedMode).toBe("low-light"); + }); +}); + +describe("scaleCorrections", () => { + it("scales corrections by intensity 50 (1x) without change", () => { + const base = { + brightness: 20, + contrast: 10, + temperature: 5, + saturation: 15, + sharpness: 30, + denoise: 3, + }; + const scaled = scaleCorrections(base, "auto", 50); + expect(scaled.brightness).toBe(20); + expect(scaled.contrast).toBe(10); + }); + + it("scales corrections to zero at intensity 0", () => { + const base = { + brightness: 20, + contrast: 10, + temperature: 5, + saturation: 15, + sharpness: 30, + denoise: 3, + }; + const scaled = scaleCorrections(base, "auto", 0); + expect(scaled.brightness).toBe(0); + expect(scaled.contrast).toBe(0); + expect(scaled.sharpness).toBe(0); + }); + + it("applies preset multipliers for portrait mode", () => { + const base = { + brightness: 20, + contrast: 10, + temperature: 5, + saturation: 15, + sharpness: 30, + denoise: 3, + }; + const scaled = scaleCorrections(base, "portrait", 50); + expect(scaled.brightness).toBe(16); + expect(scaled.contrast).toBe(7); + }); +}); + +describe("applyCorrections", () => { + it("produces a valid output buffer", async () => { + const corrections = { + brightness: -20, + contrast: 10, + temperature: 0, + saturation: 10, + sharpness: 20, + denoise: 0, + }; + const image = sharp(PNG_200x150); + const enhanced = applyCorrections(image, corrections, "auto", 50, {}); + const buffer = await enhanced.toBuffer(); + expect(buffer.length).toBeGreaterThan(0); + }); + + it("respects toggle overrides", async () => { + const corrections = { + brightness: 40, + contrast: 30, + temperature: 20, + saturation: 20, + sharpness: 30, + denoise: 3, + }; + const toggles = { + exposure: false, + contrast: false, + whiteBalance: false, + saturation: false, + sharpness: false, + denoise: false, + }; + const image = sharp(PNG_200x150); + const enhanced = applyCorrections(image, corrections, "auto", 50, toggles); + const enhancedBuf = await enhanced.toBuffer(); + const originalMeta = await sharp(PNG_200x150).metadata(); + const enhancedMeta = await sharp(enhancedBuf).metadata(); + expect(enhancedMeta.width).toBe(originalMeta.width); + expect(enhancedMeta.height).toBe(originalMeta.height); + }); +});