import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; import { createToolRoute } from "../tool-factory.js"; const settingsSchema = z .object({ scale: z.enum(["linear", "log"]).default("linear"), }) .passthrough(); function medianFromBins(bins: Uint32Array, total: number): number { const half = total / 2; let cumulative = 0; for (let i = 0; i < 256; i++) { cumulative += bins[i]; if (cumulative >= half) return i; } return 255; } function stdevFromBins(bins: Uint32Array, mean: number, total: number): number { let sumSqDiff = 0; for (let i = 0; i < 256; i++) { const diff = i - mean; sumSqDiff += diff * diff * bins[i]; } return Math.round(Math.sqrt(sumSqDiff / total) * 100) / 100; } export function registerHistogram(app: FastifyInstance) { createToolRoute(app, { toolId: "histogram", settingsSchema, process: async () => { throw new Error("histogram is v2-only"); }, processV2: async (ctx) => { const inputBuffer = ctx.inputs[0].buffer; const filename = ctx.inputs[0].filename; // Extract raw RGB pixel data (no alpha) const { data } = await sharp(inputBuffer) .removeAlpha() .raw() .toBuffer({ resolveWithObject: true }); // Build 256-bin histograms per channel + luminance in a single pass const rBins = new Uint32Array(256); const gBins = new Uint32Array(256); const bBins = new Uint32Array(256); const lumBins = new Uint32Array(256); let rSum = 0; let gSum = 0; let bSum = 0; let lumSum = 0; const pixelCount = data.length / 3; for (let i = 0; i < data.length; i += 3) { const r = data[i]; const g = data[i + 1]; const b = data[i + 2]; rBins[r]++; gBins[g]++; bBins[b]++; rSum += r; gSum += g; bSum += b; const lum = Math.round(0.299 * r + 0.587 * g + 0.114 * b); lumBins[lum]++; lumSum += lum; } // Find max bin value for normalization (RGB only for the PNG) let maxBin = 0; let rMax = 0; let gMax = 0; let bMax = 0; for (let i = 0; i < 256; i++) { if (rBins[i] > maxBin) maxBin = rBins[i]; if (gBins[i] > maxBin) maxBin = gBins[i]; if (bBins[i] > maxBin) maxBin = bBins[i]; if (rBins[i] > rMax) rMax = rBins[i]; if (gBins[i] > gMax) gMax = gBins[i]; if (bBins[i] > bMax) bMax = bBins[i]; } // Per-channel statistics const rMean = Math.round(rSum / pixelCount); const gMean = Math.round(gSum / pixelCount); const bMean = Math.round(bSum / pixelCount); const lumMean = Math.round(lumSum / pixelCount); const stats = { r: { mean: rMean, median: medianFromBins(rBins, pixelCount), stdev: stdevFromBins(rBins, rSum / pixelCount, pixelCount), }, g: { mean: gMean, median: medianFromBins(gBins, pixelCount), stdev: stdevFromBins(gBins, gSum / pixelCount, pixelCount), }, b: { mean: bMean, median: medianFromBins(bBins, pixelCount), stdev: stdevFromBins(bBins, bSum / pixelCount, pixelCount), }, lum: { mean: lumMean, median: medianFromBins(lumBins, pixelCount), stdev: stdevFromBins(lumBins, lumSum / pixelCount, pixelCount), }, }; // Render a 512x320 SVG with three semi-transparent polylines const svgW = 512; const svgH = 320; const scaleX = svgW / 255; const scaleY = maxBin > 0 ? svgH / maxBin : 1; const buildPoints = (bins: Uint32Array): string => { const pts: string[] = []; for (let i = 0; i < 256; i++) { const x = Math.round(i * scaleX); const y = Math.round(svgH - bins[i] * scaleY); pts.push(`${x},${y}`); } return pts.join(" "); }; const svg = Buffer.from( `` + `` + `` + `` + `` + ``, ); // Rasterize to PNG const buffer = await sharp(svg).png().toBuffer(); const base = filename.replace(/\.[^.]+$/, ""); return { buffer, filename: `${base}_histogram.png`, contentType: "image/png", resultPayload: { bins: { r: Array.from(rBins), g: Array.from(gBins), b: Array.from(bBins), lum: Array.from(lumBins), }, stats, // Backward-compat fields mean: { r: rMean, g: gMean, b: bMean }, max: { r: rMax, g: gMax, b: bMax }, }, }; }, }); }