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
@@ -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),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user