feat: add utility tools (image info, compare, duplicates, color palette, QR, barcode)

Add 6 utility tools with API routes and frontend settings:
- info: read-only image metadata inspector with channel histogram
- compare: side-by-side pixel diff with similarity percentage
- find-duplicates: dHash perceptual hashing for duplicate detection
- color-palette: frequency-based dominant color extraction
- qr-generate: QR code generator from text/URL (custom JSON route)
- barcode-read: QR code reader using jsQR
This commit is contained in:
Siddharth Kumar Sah
2026-03-22 04:20:35 +08:00
parent aeaf783ee4
commit 0c3c64eeac
12 changed files with 1253 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
import sharp from "sharp";
import jsQR from "jsqr";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
/**
* Read QR codes and barcodes from uploaded images.
*/
export function registerBarcodeRead(app: FastifyInstance) {
app.post(
"/api/v1/tools/barcode-read",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
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);
filename = basename(part.filename ?? "image");
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
// Convert to RGBA raw pixel data for jsQR
const image = sharp(fileBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 0;
const height = metadata.height ?? 0;
const rawData = await image
.ensureAlpha()
.raw()
.toBuffer();
const code = jsQR(
new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
width,
height,
);
if (!code) {
return reply.send({
filename,
found: false,
text: null,
message: "No QR code found in the image",
});
}
return reply.send({
filename,
found: true,
text: code.data,
location: {
topLeft: code.location.topLeftCorner,
topRight: code.location.topRightCorner,
bottomLeft: code.location.bottomLeftCorner,
bottomRight: code.location.bottomRightCorner,
},
});
} catch (err) {
return reply.status(422).send({
error: "Barcode reading failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
@@ -0,0 +1,98 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { basename } from "node:path";
/**
* Simple k-means-like color quantization to extract dominant colors.
*/
function extractColors(pixels: Buffer, channelCount: number, maxColors: number): string[] {
// Build frequency map of quantized colors
const colorMap = new Map<string, number>();
for (let i = 0; i < pixels.length; i += channelCount) {
// Quantize to reduce noise (round to nearest 16)
const r = Math.round(pixels[i] / 16) * 16;
const g = Math.round(pixels[i + 1] / 16) * 16;
const b = Math.round(pixels[i + 2] / 16) * 16;
const key = `${r},${g},${b}`;
colorMap.set(key, (colorMap.get(key) ?? 0) + 1);
}
// Sort by frequency and pick top colors
const sorted = [...colorMap.entries()]
.sort((a, b) => b[1] - a[1]);
// Filter similar colors (merge colors within distance 40)
const results: Array<{ r: number; g: number; b: number; count: number }> = [];
for (const [key, count] of sorted) {
const [r, g, b] = key.split(",").map(Number);
const tooClose = results.some(
(c) =>
Math.abs(c.r - r) + Math.abs(c.g - g) + Math.abs(c.b - b) < 48,
);
if (!tooClose) {
results.push({ r, g, b, count });
}
if (results.length >= maxColors) break;
}
return results.map(({ r, g, b }) => {
const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
return hex;
});
}
export function registerColorPalette(app: FastifyInstance) {
app.post(
"/api/v1/tools/color-palette",
async (request, reply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
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);
filename = basename(part.filename ?? "image");
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
// Resize to small image for analysis
const raw = await sharp(fileBuffer)
.resize(50, 50, { fit: "fill" })
.removeAlpha()
.raw()
.toBuffer();
const colors = extractColors(raw, 3, 8);
return reply.send({
filename,
colors,
count: colors.length,
});
} catch (err) {
return reply.status(422).send({
error: "Color extraction failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
+112
View File
@@ -0,0 +1,112 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
/**
* Compare two images: compute a pixel-level diff and similarity score.
*/
export function registerCompare(app: FastifyInstance) {
app.post(
"/api/v1/tools/compare",
async (request, reply) => {
let bufferA: Buffer | null = null;
let bufferB: 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);
}
const buf = Buffer.concat(chunks);
if (!bufferA) {
bufferA = buf;
} else {
bufferB = buf;
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!bufferA || !bufferB) {
return reply.status(400).send({ error: "Two image files are required for comparison" });
}
try {
// Normalize both to same size for comparison
const metaA = await sharp(bufferA).metadata();
const metaB = await sharp(bufferB).metadata();
const w = Math.max(metaA.width ?? 100, metaB.width ?? 100);
const h = Math.max(metaA.height ?? 100, metaB.height ?? 100);
const rawA = await sharp(bufferA).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
const rawB = await sharp(bufferB).resize(w, h, { fit: "fill" }).ensureAlpha().raw().toBuffer();
// Compute pixel diff
const diffPixels = Buffer.alloc(w * h * 4);
let totalDiff = 0;
const pixelCount = w * h;
for (let i = 0; i < rawA.length; i += 4) {
const dr = Math.abs(rawA[i] - rawB[i]);
const dg = Math.abs(rawA[i + 1] - rawB[i + 1]);
const db = Math.abs(rawA[i + 2] - rawB[i + 2]);
const pixelDiff = (dr + dg + db) / 3;
totalDiff += pixelDiff;
// Red tint for differences, transparent for identical
if (pixelDiff > 10) {
diffPixels[i] = 255; // R
diffPixels[i + 1] = 0; // G
diffPixels[i + 2] = 0; // B
diffPixels[i + 3] = Math.min(255, Math.round(pixelDiff * 3)); // A
} else {
// Slightly show original
diffPixels[i] = rawA[i];
diffPixels[i + 1] = rawA[i + 1];
diffPixels[i + 2] = rawA[i + 2];
diffPixels[i + 3] = 128;
}
}
const similarity = Math.max(0, 100 - (totalDiff / (pixelCount * 255)) * 100);
const diffBuffer = await sharp(diffPixels, {
raw: { width: w, height: h, channels: 4 },
})
.png()
.toBuffer();
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const diffFilename = "diff.png";
const outputPath = join(workspacePath, "output", diffFilename);
await writeFile(outputPath, diffBuffer);
return reply.send({
jobId,
similarity: Math.round(similarity * 100) / 100,
dimensions: { width: w, height: h },
downloadUrl: `/api/v1/download/${jobId}/${diffFilename}`,
originalSize: bufferA.length + bufferB.length,
processedSize: diffBuffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "Comparison failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
@@ -0,0 +1,123 @@
import sharp from "sharp";
import type { FastifyInstance } from "fastify";
import { basename } from "node:path";
/**
* Compute a dHash (difference hash) for perceptual duplicate detection.
* Resize to 9x8 grayscale, compare adjacent pixels to create 64-bit hash.
*/
async function computeDHash(buffer: Buffer): Promise<string> {
const pixels = await sharp(buffer)
.resize(9, 8, { fit: "fill" })
.grayscale()
.raw()
.toBuffer();
let hash = "";
for (let y = 0; y < 8; y++) {
for (let x = 0; x < 8; x++) {
const left = pixels[y * 9 + x];
const right = pixels[y * 9 + x + 1];
hash += left > right ? "1" : "0";
}
}
return hash;
}
/**
* Compute hamming distance between two 64-bit hash strings.
*/
function hammingDistance(a: string, b: string): number {
let distance = 0;
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) distance++;
}
return distance;
}
export function registerFindDuplicates(app: FastifyInstance) {
app.post(
"/api/v1/tools/find-duplicates",
async (request, reply) => {
const files: Array<{ buffer: Buffer; filename: string }> = [];
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);
}
const buf = Buffer.concat(chunks);
if (buf.length > 0) {
files.push({
buffer: buf,
filename: basename(part.filename ?? `image-${files.length}`),
});
}
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (files.length < 2) {
return reply.status(400).send({ error: "At least 2 images are required for duplicate detection" });
}
try {
// Compute hashes for all images
const hashes: Array<{ filename: string; hash: string }> = [];
for (const file of files) {
const hash = await computeDHash(file.buffer);
hashes.push({ filename: file.filename, hash });
}
// Compare all pairs, group duplicates
const threshold = 10; // Hamming distance threshold for "similar"
const groups: Array<{
files: Array<{ filename: string; similarity: number }>;
}> = [];
const assigned = new Set<number>();
for (let i = 0; i < hashes.length; i++) {
if (assigned.has(i)) continue;
const group: Array<{ filename: string; similarity: number }> = [
{ filename: hashes[i].filename, similarity: 100 },
];
for (let j = i + 1; j < hashes.length; j++) {
if (assigned.has(j)) continue;
const dist = hammingDistance(hashes[i].hash, hashes[j].hash);
if (dist <= threshold) {
const similarity = Math.round((1 - dist / 64) * 10000) / 100;
group.push({ filename: hashes[j].filename, similarity });
assigned.add(j);
}
}
if (group.length > 1) {
assigned.add(i);
groups.push({ files: group });
}
}
return reply.send({
totalImages: files.length,
duplicateGroups: groups,
uniqueImages: files.length - assigned.size,
});
} catch (err) {
return reply.status(422).send({
error: "Duplicate detection failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
+80
View File
@@ -0,0 +1,80 @@
import sharp from "sharp";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { basename } from "node:path";
/**
* Image info route - read-only, returns JSON metadata.
* Does NOT use createToolRoute since it doesn't produce a processed file.
*/
export function registerInfo(app: FastifyInstance) {
app.post(
"/api/v1/tools/info",
async (request: FastifyRequest, reply: FastifyReply) => {
let fileBuffer: Buffer | null = null;
let filename = "image";
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);
filename = basename(part.filename ?? "image");
}
}
} catch (err) {
return reply.status(400).send({
error: "Failed to parse multipart request",
details: err instanceof Error ? err.message : String(err),
});
}
if (!fileBuffer || fileBuffer.length === 0) {
return reply.status(400).send({ error: "No image file provided" });
}
try {
const metadata = await sharp(fileBuffer).metadata();
const stats = await sharp(fileBuffer).stats();
// Build histogram data from stats
const histogram = stats.channels.map((ch, i) => ({
channel: ["red", "green", "blue", "alpha"][i] ?? `channel-${i}`,
min: ch.min,
max: ch.max,
mean: Math.round(ch.mean * 100) / 100,
stdev: Math.round(ch.stdev * 100) / 100,
}));
return reply.send({
filename,
fileSize: fileBuffer.length,
width: metadata.width ?? 0,
height: metadata.height ?? 0,
format: metadata.format ?? "unknown",
channels: metadata.channels ?? 0,
hasAlpha: metadata.hasAlpha ?? false,
colorSpace: metadata.space ?? "unknown",
density: metadata.density ?? null,
isProgressive: metadata.isProgressive ?? false,
orientation: metadata.orientation ?? null,
hasProfile: metadata.hasProfile ?? false,
hasExif: !!metadata.exif,
hasIcc: !!metadata.icc,
hasXmp: !!metadata.xmp,
bitDepth: metadata.depth ?? null,
pages: metadata.pages ?? 1,
histogram,
});
} catch (err) {
return reply.status(422).send({
error: "Failed to read image metadata",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}
+77
View File
@@ -0,0 +1,77 @@
import { z } from "zod";
import QRCode from "qrcode";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { randomUUID } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { createWorkspace } from "../../lib/workspace.js";
const settingsSchema = z.object({
text: z.string().min(1).max(2000),
size: z.number().min(100).max(2000).default(400),
errorCorrection: z.enum(["L", "M", "Q", "H"]).default("M"),
foreground: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#000000"),
background: z.string().regex(/^#[0-9a-fA-F]{6}$/).default("#FFFFFF"),
});
/**
* QR code generator - custom route (not factory) since it generates
* images from text input, not from uploaded files.
*/
export function registerQrGenerate(app: FastifyInstance) {
app.post(
"/api/v1/tools/qr-generate",
async (request: FastifyRequest, reply: FastifyReply) => {
let body: unknown;
try {
body = request.body;
} catch {
return reply.status(400).send({ error: "Invalid request body" });
}
const result = settingsSchema.safeParse(body);
if (!result.success) {
return reply.status(400).send({
error: "Invalid settings",
details: result.error.issues.map((i) => ({
path: i.path.join("."),
message: i.message,
})),
});
}
const settings = result.data;
try {
const buffer = await QRCode.toBuffer(settings.text, {
width: settings.size,
errorCorrectionLevel: settings.errorCorrection,
color: {
dark: settings.foreground,
light: settings.background,
},
type: "png",
margin: 2,
});
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = "qrcode.png";
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, buffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: 0,
processedSize: buffer.length,
});
} catch (err) {
return reply.status(422).send({
error: "QR code generation failed",
details: err instanceof Error ? err.message : "Unknown error",
});
}
},
);
}