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:
stirling-image
2026-04-13 17:48:53 +08:00
committed by GitHub
co-authored by stirling-image
parent 34ec840b72
commit a8c7b92ca5
13 changed files with 1221 additions and 0 deletions
@@ -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<typeof settingsSchema>;
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),
});
}
},
);
}
+2
View File
@@ -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<void> {
{ 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 },
];