From 58cdbe50b434ec009fbbd4c23ea519ddd25d5cca Mon Sep 17 00:00:00 2001 From: stirling-image Date: Mon, 13 Apr 2026 19:12:09 +0800 Subject: [PATCH] feat: SOTA sharpening tool with 3 methods and 7 presets (#56) * feat(sharpening): add SharpenAdvancedOptions type for multi-method sharpening * feat(sharpening): implement 3-method sharpen engine (adaptive, USM, high-pass) * feat(sharpening): add dedicated API route with Zod validation * feat(sharpening): register tool in constants, i18n, and suggested tools * feat(sharpening): add settings UI with presets, methods, and advanced controls * feat(sharpening): register tool in frontend registry with before-after display --------- Co-authored-by: stirling-image --- apps/api/src/routes/tools/index.ts | 2 + apps/api/src/routes/tools/sharpening.ts | 58 +++ .../components/tools/sharpening-settings.tsx | 445 ++++++++++++++++++ apps/web/src/lib/suggested-tools.ts | 1 + apps/web/src/lib/tool-registry.tsx | 8 + packages/image-engine/src/engine.ts | 5 +- packages/image-engine/src/index.ts | 2 +- .../image-engine/src/operations/sharpen.ts | 94 +++- packages/image-engine/src/types.ts | 22 + packages/shared/src/constants.ts | 8 + packages/shared/src/i18n/en.ts | 4 + 11 files changed, 646 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/routes/tools/sharpening.ts create mode 100644 apps/web/src/components/tools/sharpening-settings.tsx diff --git a/apps/api/src/routes/tools/index.ts b/apps/api/src/routes/tools/index.ts index c680e424..6dc90130 100644 --- a/apps/api/src/routes/tools/index.ts +++ b/apps/api/src/routes/tools/index.ts @@ -30,6 +30,7 @@ import { registerRemoveBackground } from "./remove-background.js"; import { registerReplaceColor } from "./replace-color.js"; import { registerResize } from "./resize.js"; import { registerRotate } from "./rotate.js"; +import { registerSharpening } from "./sharpening.js"; import { registerSmartCrop } from "./smart-crop.js"; import { registerSplit } from "./split.js"; import { registerStitch } from "./stitch.js"; @@ -84,6 +85,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise { { id: "strip-metadata", register: registerStripMetadata }, { id: "edit-metadata", register: registerEditMetadata }, { id: "color-adjustments", register: registerColorAdjustments }, + { id: "sharpening", register: registerSharpening }, // Watermark & Overlay { id: "watermark-text", register: registerWatermarkText }, diff --git a/apps/api/src/routes/tools/sharpening.ts b/apps/api/src/routes/tools/sharpening.ts new file mode 100644 index 00000000..387e9d29 --- /dev/null +++ b/apps/api/src/routes/tools/sharpening.ts @@ -0,0 +1,58 @@ +import { sharpenAdvanced } from "@stirling-image/image-engine"; +import type { FastifyInstance } from "fastify"; +import sharp from "sharp"; +import { z } from "zod"; +import { resolveOutputFormat } from "../../lib/output-format.js"; +import { createToolRoute } from "../tool-factory.js"; + +const settingsSchema = z.object({ + method: z.enum(["adaptive", "unsharp-mask", "high-pass"]).default("adaptive"), + // Adaptive + sigma: z.number().min(0.5).max(10).default(1.0), + m1: z.number().min(0).max(10).default(1.0), + m2: z.number().min(0).max(20).default(3.0), + x1: z.number().min(0).max(10).default(2.0), + y2: z.number().min(0).max(50).default(12), + y3: z.number().min(0).max(50).default(20), + // Unsharp Mask + amount: z.number().min(0).max(500).default(100), + radius: z.number().min(0.1).max(5).default(1.0), + threshold: z.number().min(0).max(255).default(0), + // High-Pass + strength: z.number().min(0).max(100).default(50), + kernelSize: z.union([z.literal(3), z.literal(5)]).default(3), + // Noise reduction + denoise: z.enum(["off", "light", "medium", "strong"]).default("off"), +}); + +export function registerSharpening(app: FastifyInstance) { + createToolRoute(app, { + toolId: "sharpening", + settingsSchema, + process: async (inputBuffer, settings, filename) => { + const outputFormat = await resolveOutputFormat(inputBuffer, filename); + let image = sharp(inputBuffer); + + image = await sharpenAdvanced(image, { + method: settings.method, + sigma: settings.sigma, + m1: settings.m1, + m2: settings.m2, + x1: settings.x1, + y2: settings.y2, + y3: settings.y3, + amount: settings.amount, + radius: settings.radius, + threshold: settings.threshold, + strength: settings.strength, + kernelSize: settings.kernelSize, + denoise: settings.denoise, + }); + + const buffer = await image + .toFormat(outputFormat.format, { quality: outputFormat.quality }) + .toBuffer(); + return { buffer, filename, contentType: outputFormat.contentType }; + }, + }); +} diff --git a/apps/web/src/components/tools/sharpening-settings.tsx b/apps/web/src/components/tools/sharpening-settings.tsx new file mode 100644 index 00000000..d916f8ea --- /dev/null +++ b/apps/web/src/components/tools/sharpening-settings.tsx @@ -0,0 +1,445 @@ +import { ChevronDown, ChevronRight, Download } from "lucide-react"; +import type React from "react"; +import { useState } from "react"; +import { ProgressCard } from "@/components/common/progress-card"; +import { useToolProcessor } from "@/hooks/use-tool-processor"; +import { useFileStore } from "@/stores/file-store"; + +type Method = "adaptive" | "unsharp-mask" | "high-pass"; +type Denoise = "off" | "light" | "medium" | "strong"; + +interface Preset { + name: string; + sigma: number; + m1: number; + m2: number; + x1: number; + y2: number; + y3: number; +} + +const PRESETS: Preset[] = [ + { name: "Light", sigma: 0.5, m1: 0.5, m2: 1.5, x1: 2.0, y2: 8, y3: 15 }, + { name: "Medium", sigma: 1.0, m1: 1.0, m2: 3.0, x1: 2.0, y2: 12, y3: 20 }, + { name: "Strong", sigma: 1.5, m1: 1.5, m2: 5.0, x1: 2.0, y2: 15, y3: 25 }, + { name: "Portrait", sigma: 1.0, m1: 0.0, m2: 2.5, x1: 3.0, y2: 8, y3: 15 }, + { name: "Landscape", sigma: 0.8, m1: 1.0, m2: 4.0, x1: 1.5, y2: 12, y3: 20 }, + { name: "Detail", sigma: 0.5, m1: 2.0, m2: 5.0, x1: 1.0, y2: 15, y3: 25 }, + { name: "Print", sigma: 1.5, m1: 1.5, m2: 3.5, x1: 2.0, y2: 15, y3: 25 }, +]; + +export function SharpeningSettings() { + const { files } = useFileStore(); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("sharpening"); + + const [method, setMethod] = useState("adaptive"); + const [sigma, setSigma] = useState(1.0); + const [m1, setM1] = useState(1.0); + const [m2, setM2] = useState(3.0); + const [x1, setX1] = useState(2.0); + const [y2, setY2] = useState(12); + const [y3, setY3] = useState(20); + const [amount, setAmount] = useState(100); + const [radius, setRadius] = useState(1.0); + const [threshold, setThreshold] = useState(0); + const [strength, setStrength] = useState(50); + const [kernelSize, setKernelSize] = useState<3 | 5>(3); + const [denoise, setDenoise] = useState("off"); + const [advancedOpen, setAdvancedOpen] = useState(false); + const [activePreset, setActivePreset] = useState("Medium"); + + const applyPreset = (preset: Preset) => { + setMethod("adaptive"); + setSigma(preset.sigma); + setM1(preset.m1); + setM2(preset.m2); + setX1(preset.x1); + setY2(preset.y2); + setY3(preset.y3); + setActivePreset(preset.name); + }; + + const clearPreset = () => setActivePreset(null); + + const handleProcess = () => { + const settings = { + method, + sigma, + m1, + m2, + x1, + y2, + y3, + amount, + radius, + threshold, + strength, + kernelSize, + denoise, + }; + if (files.length > 1) { + processAllFiles(files, settings); + } else { + processFiles(files, settings); + } + }; + + const hasFile = files.length > 0; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (hasFile && !processing) handleProcess(); + }; + + return ( +
+ {/* Method selector */} + Method +
+ {(["adaptive", "unsharp-mask", "high-pass"] as const).map((m) => ( + + ))} +
+ + {/* Presets (adaptive only) */} + {method === "adaptive" && ( + <> + Presets +
+ {PRESETS.map((p) => ( + + ))} +
+ + )} + + {/* Primary slider per method */} + + {method === "adaptive" + ? "Texture Amount" + : method === "unsharp-mask" + ? "Amount" + : "Strength"} + +
+ {method === "adaptive" && ( + { + setM2(v); + clearPreset(); + }} + min={0} + max={20} + step={0.1} + /> + )} + {method === "unsharp-mask" && ( + { + setAmount(v); + clearPreset(); + }} + min={0} + max={500} + step={1} + hint="%" + /> + )} + {method === "high-pass" && ( + { + setStrength(v); + clearPreset(); + }} + min={0} + max={100} + step={1} + /> + )} +
+ + {/* Noise reduction */} + Noise Reduction +
+ {(["off", "light", "medium", "strong"] as const).map((d) => ( + + ))} +
+ + {/* Advanced controls */} + + + {advancedOpen && ( +
+ {method === "adaptive" && ( + <> + { + setSigma(v); + clearPreset(); + }} + min={0.5} + max={10} + step={0.1} + hint="sigma" + /> + { + setM1(v); + clearPreset(); + }} + min={0} + max={10} + step={0.1} + hint="smooth areas" + /> + { + setX1(v); + clearPreset(); + }} + min={0} + max={10} + step={0.1} + hint="flat vs texture" + /> + { + setY2(v); + clearPreset(); + }} + min={0} + max={50} + step={1} + /> + { + setY3(v); + clearPreset(); + }} + min={0} + max={50} + step={1} + /> + + )} + {method === "unsharp-mask" && ( + <> + { + setRadius(v); + clearPreset(); + }} + min={0.1} + max={5} + step={0.1} + hint="px" + /> + { + setThreshold(v); + clearPreset(); + }} + min={0} + max={255} + step={1} + hint="edge sensitivity" + /> + + )} + {method === "high-pass" && ( + <> + Kernel Size +
+ {([3, 5] as const).map((k) => ( + + ))} +
+ + )} +
+ )} + + {error &&

{error}

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

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

+

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

+
+ )} + + {processing ? ( + + ) : ( + + )} + + {downloadUrl && files.length <= 1 && ( + + + Download + + )} + + ); +} + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + +function SliderControl({ + label, + value, + onChange, + min, + max, + step, + color, + hint, +}: { + label: string; + value: number; + onChange: (v: number) => void; + min: number; + max: number; + step?: number; + color?: string; + hint?: string; +}) { + const id = `sharpen-slider-${label.toLowerCase().replace(/\s+/g, "-")}`; + const displayValue = step && step < 1 ? value.toFixed(1) : String(value); + return ( +
+
+ + + {displayValue} + +
+ onChange(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ ); +} diff --git a/apps/web/src/lib/suggested-tools.ts b/apps/web/src/lib/suggested-tools.ts index e61c03c1..c2e80677 100644 --- a/apps/web/src/lib/suggested-tools.ts +++ b/apps/web/src/lib/suggested-tools.ts @@ -14,6 +14,7 @@ const TOOL_SUGGESTIONS: Record = { "watermark-text": ["compress", "convert"], "watermark-image": ["compress", "convert"], "text-overlay": ["compress", "convert"], + sharpening: ["adjust-colors", "compress", "convert", "resize"], border: ["compress", "convert", "resize"], }; diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index 899f8870..f4bb30b1 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -96,6 +96,11 @@ const EditMetadataSettings = lazy(() => const ColorSettings = lazy(() => import("@/components/tools/color-settings").then((m) => ({ default: m.ColorSettings })), ); +const SharpeningSettings = lazy(() => + import("@/components/tools/sharpening-settings").then((m) => ({ + default: m.SharpeningSettings, + })), +); const WatermarkTextSettings = lazy(() => import("@/components/tools/watermark-text-settings").then((m) => ({ default: m.WatermarkTextSettings, @@ -293,6 +298,9 @@ export const toolRegistry = new Map([ }, ], + // Sharpening + ["sharpening", { displayMode: "before-after", Settings: SharpeningSettings }], + // Watermark & Overlay ["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }], ["watermark-image", { displayMode: "before-after", Settings: WatermarkImageSettings }], diff --git a/packages/image-engine/src/engine.ts b/packages/image-engine/src/engine.ts index 56ca516d..42f2aac0 100644 --- a/packages/image-engine/src/engine.ts +++ b/packages/image-engine/src/engine.ts @@ -13,7 +13,7 @@ import { resize } from "./operations/resize.js"; import { rotate } from "./operations/rotate.js"; import { saturation } from "./operations/saturation.js"; import { sepia } from "./operations/sepia.js"; -import { sharpen } from "./operations/sharpen.js"; +import { sharpen, sharpenAdvanced } from "./operations/sharpen.js"; import { stripMetadata } from "./operations/strip-metadata.js"; import type { BrightnessOptions, @@ -30,6 +30,7 @@ import type { RotateOptions, SaturationOptions, Sharp, + SharpenAdvancedOptions, SharpenOptions, StripMetadataOptions, } from "./types.js"; @@ -58,6 +59,8 @@ const OPERATION_MAP: Record< grayscale: (img) => grayscale(img), sepia: (img) => sepia(img), sharpen: (img, opts) => sharpen(img, opts as unknown as SharpenOptions), + "sharpen-advanced": (img, opts) => + sharpenAdvanced(img, opts as unknown as SharpenAdvancedOptions), invert: (img) => invert(img), "edit-metadata": (img, opts) => editMetadata(img, opts as unknown as EditMetadataOptions), }; diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index 757b33ea..d629c220 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -15,7 +15,7 @@ export { resize } from "./operations/resize.js"; export { rotate } from "./operations/rotate.js"; export { saturation } from "./operations/saturation.js"; export { sepia } from "./operations/sepia.js"; -export { sharpen } from "./operations/sharpen.js"; +export { sharpen, sharpenAdvanced } from "./operations/sharpen.js"; export { stripMetadata } from "./operations/strip-metadata.js"; export * from "./types.js"; export * from "./utils/metadata.js"; diff --git a/packages/image-engine/src/operations/sharpen.ts b/packages/image-engine/src/operations/sharpen.ts index 8988a8d5..d7182fdf 100644 --- a/packages/image-engine/src/operations/sharpen.ts +++ b/packages/image-engine/src/operations/sharpen.ts @@ -1,4 +1,4 @@ -import type { Sharp, SharpenOptions } from "../types.js"; +import type { Sharp, SharpenAdvancedOptions, SharpenOptions } from "../types.js"; export async function sharpen(image: Sharp, options: SharpenOptions): Promise { const { value } = options; @@ -13,3 +13,95 @@ export async function sharpen(image: Sharp, options: SharpenOptions): Promise = { + light: 3, + medium: 5, + strong: 7, +}; + +export async function sharpenAdvanced( + image: Sharp, + options: SharpenAdvancedOptions, +): Promise { + const { method, denoise } = options; + + // Optional noise reduction pre-pass + if (denoise && denoise !== "off") { + const kernelSize = DENOISE_KERNEL[denoise]; + if (kernelSize) { + image = image.median(kernelSize); + } + } + + switch (method) { + case "adaptive": + return sharpenAdaptive(image, options); + case "unsharp-mask": + return sharpenUnsharpMask(image, options); + case "high-pass": + return sharpenHighPass(image, options); + default: + throw new Error(`Unknown sharpening method: ${method}`); + } +} + +function sharpenAdaptive(image: Sharp, options: SharpenAdvancedOptions): Sharp { + const sigma = options.sigma ?? 1.0; + const m1 = options.m1 ?? 1.0; + const m2 = options.m2 ?? 3.0; + const x1 = options.x1 ?? 2.0; + const y2 = options.y2 ?? 12; + const y3 = options.y3 ?? 20; + return image.sharpen({ sigma, m1, m2, x1, y2, y3 }); +} + +function sharpenUnsharpMask(image: Sharp, options: SharpenAdvancedOptions): Sharp { + const amount = options.amount ?? 100; + const radius = options.radius ?? 1.0; + const threshold = options.threshold ?? 0; + const sigma = radius; + const intensity = amount / 100; + const x1 = (threshold / 255) * 10; + return image.sharpen({ sigma, m1: intensity, m2: intensity, x1, y2: 15, y3: 25 }); +} + +function sharpenHighPass(image: Sharp, options: SharpenAdvancedOptions): Sharp { + const strength = options.strength ?? 50; + const kernelSize = options.kernelSize ?? 3; + const s = strength / 100; + + if (kernelSize === 5) { + const k = [ + 0, + -s, + -s, + -s, + 0, + -s, + s, + s * 2, + s, + -s, + -s, + s * 2, + 1 + s * 8, + s * 2, + -s, + -s, + s, + s * 2, + s, + -s, + 0, + -s, + -s, + -s, + 0, + ]; + return image.convolve({ width: 5, height: 5, kernel: k }); + } + + const k = [0, -s, 0, -s, 1 + 4 * s, -s, 0, -s, 0]; + return image.convolve({ width: 3, height: 3, kernel: k }); +} diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 37f43824..858a1ea2 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -109,6 +109,28 @@ export interface SharpenOptions { value: number; // 0 to 100 } +export type SharpenMethod = "adaptive" | "unsharp-mask" | "high-pass"; + +export interface SharpenAdvancedOptions { + method: SharpenMethod; + // Adaptive method params + sigma?: number; // 0.5-10, Gaussian blur radius + m1?: number; // 0-10, flat area sharpening + m2?: number; // 0-20, textured area sharpening + x1?: number; // 0-10, flat/jagged threshold + y2?: number; // 0-50, max brightening (halo clamp) + y3?: number; // 0-50, max darkening (halo clamp) + // Unsharp mask params + amount?: number; // 0-500, intensity percentage + radius?: number; // 0.1-5.0, blur radius + threshold?: number; // 0-255, minimum edge brightness + // High-pass params + strength?: number; // 0-100, blend strength + kernelSize?: 3 | 5; // 3x3 or 5x5 kernel + // Noise reduction + denoise?: "off" | "light" | "medium" | "strong"; +} + export type EnhancementMode = "auto" | "portrait" | "landscape" | "low-light" | "food" | "document"; export interface AnalysisScores { diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 4519c009..6be516f8 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -104,6 +104,14 @@ export const TOOLS: Tool[] = [ icon: "SlidersHorizontal", route: "/adjust-colors", }, + { + id: "sharpening", + name: "Sharpening", + description: "Adaptive, unsharp mask, and high-pass sharpening with presets", + category: "adjustments", + icon: "Focus", + route: "/sharpening", + }, { id: "replace-color", name: "Replace & Invert Color", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 9b1a4dcf..b2a65366 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -49,6 +49,10 @@ export const en = { description: "Brightness, contrast, exposure, saturation, temperature, sharpness, and effects", }, + sharpening: { + name: "Sharpening", + description: "Adaptive, unsharp mask, and high-pass sharpening with presets", + }, "replace-color": { name: "Replace & Invert Color", description: "Replace specific colors or invert",