mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: AVIF sidecar crash, edit-metadata silent no-op, passport batch blank images, color-palette hex overflow, OCR log noise
- Convert all AI bridge inputs to PNG before writing to disk so PIL can read AVIF/WebP/TIFF (7 bridge files; face-detection and OCR already had this pattern) - Add title/author aliases to edit-metadata schema so common field names actually write EXIF tags instead of being silently stripped by Zod - Port extend/pad crop logic from passport-photo single endpoint to the batch pipeline so crop regions extending beyond the image get filled with background color instead of producing all-white output - Clamp quantized color channels to 255 in color-palette to prevent Math.round(255/16)*16=256 from producing invalid hex like #100100100 - Compare OCR fallback warning against expected engine name per tier instead of comparing engine name against tier name (always mismatch)
This commit is contained in:
@@ -137,6 +137,8 @@ export async function writeMetadata(
|
|||||||
|
|
||||||
/** Settings shape that buildTagArgs accepts */
|
/** Settings shape that buildTagArgs accepts */
|
||||||
export interface EditMetadataSettings {
|
export interface EditMetadataSettings {
|
||||||
|
title?: string;
|
||||||
|
author?: string;
|
||||||
artist?: string;
|
artist?: string;
|
||||||
copyright?: string;
|
copyright?: string;
|
||||||
imageDescription?: string;
|
imageDescription?: string;
|
||||||
@@ -165,11 +167,16 @@ export interface EditMetadataSettings {
|
|||||||
export function buildTagArgs(settings: EditMetadataSettings): string[] {
|
export function buildTagArgs(settings: EditMetadataSettings): string[] {
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
|
|
||||||
|
// Common aliases
|
||||||
|
const artist = settings.artist || settings.author;
|
||||||
|
const description = settings.imageDescription || settings.title;
|
||||||
|
|
||||||
// Basic EXIF fields
|
// Basic EXIF fields
|
||||||
if (settings.artist) args.push(`-Artist=${settings.artist}`);
|
if (artist) args.push(`-Artist=${artist}`);
|
||||||
if (settings.copyright) args.push(`-Copyright=${settings.copyright}`);
|
if (settings.copyright) args.push(`-Copyright=${settings.copyright}`);
|
||||||
if (settings.imageDescription) args.push(`-ImageDescription=${settings.imageDescription}`);
|
if (description) args.push(`-ImageDescription=${description}`);
|
||||||
if (settings.software) args.push(`-Software=${settings.software}`);
|
if (settings.software) args.push(`-Software=${settings.software}`);
|
||||||
|
if (settings.title) args.push(`-XMP:Title=${settings.title}`);
|
||||||
|
|
||||||
// Date fields
|
// Date fields
|
||||||
if (settings.dateTime) args.push(`-ModifyDate=${settings.dateTime}`);
|
if (settings.dateTime) args.push(`-ModifyDate=${settings.dateTime}`);
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ function extractColors(pixels: Buffer, channelCount: number, maxColors: number):
|
|||||||
|
|
||||||
for (let i = 0; i < pixels.length; i += channelCount) {
|
for (let i = 0; i < pixels.length; i += channelCount) {
|
||||||
// Quantize to reduce noise (round to nearest 16)
|
// Quantize to reduce noise (round to nearest 16)
|
||||||
const r = Math.round(pixels[i] / 16) * 16;
|
const r = Math.min(Math.round(pixels[i] / 16) * 16, 255);
|
||||||
const g = Math.round(pixels[i + 1] / 16) * 16;
|
const g = Math.min(Math.round(pixels[i + 1] / 16) * 16, 255);
|
||||||
const b = Math.round(pixels[i + 2] / 16) * 16;
|
const b = Math.min(Math.round(pixels[i + 2] / 16) * 16, 255);
|
||||||
const key = `${r},${g},${b}`;
|
const key = `${r},${g},${b}`;
|
||||||
colorMap.set(key, (colorMap.get(key) ?? 0) + 1);
|
colorMap.set(key, (colorMap.get(key) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { createWorkspace } from "../../lib/workspace.js";
|
|||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
|
title: z.string().optional(),
|
||||||
|
author: z.string().optional(),
|
||||||
artist: z.string().optional(),
|
artist: z.string().optional(),
|
||||||
copyright: z.string().optional(),
|
copyright: z.string().optional(),
|
||||||
imageDescription: z.string().optional(),
|
imageDescription: z.string().optional(),
|
||||||
|
|||||||
@@ -150,10 +150,12 @@ export function registerOcr(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.engine && result.engine !== tier) {
|
const expectedEngine =
|
||||||
|
tier === "fast" ? "tesseract" : tier === "balanced" ? "paddleocr" : "paddleocr-vl";
|
||||||
|
if (result.engine && result.engine !== expectedEngine) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
{ toolId: "ocr", requested: tier, actual: result.engine },
|
{ toolId: "ocr", requested: tier, expected: expectedEngine, actual: result.engine },
|
||||||
`OCR engine fallback: requested ${tier} but used ${result.engine}`,
|
`OCR engine fallback: requested ${tier} (${expectedEngine}) but used ${result.engine}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -560,11 +560,18 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
const docSpec = countrySpec.documents.find((d) => d.type === s.documentType);
|
const docSpec = countrySpec.documents.find((d) => d.type === s.documentType);
|
||||||
if (!docSpec) throw new Error(`No ${s.documentType} spec for ${s.countryCode}`);
|
if (!docSpec) throw new Error(`No ${s.documentType} spec for ${s.countryCode}`);
|
||||||
|
|
||||||
// Convert normalized landmarks (0-1) to pixel coordinates
|
// Use actual bg-removed image dimensions (may differ from original)
|
||||||
const crownYPx = (landmarks.crown.y + s.adjustY) * imgH;
|
const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata();
|
||||||
const chinYPx = (landmarks.chin.y + s.adjustY) * imgH;
|
const actualW = bgRemovedMeta.width ?? imgW;
|
||||||
const eyeYPx = (landmarks.eyeCenter.y + s.adjustY) * imgH;
|
const actualH = bgRemovedMeta.height ?? imgH;
|
||||||
const faceCenterXPx = (landmarks.faceCenterX + s.adjustX) * imgW;
|
const scaleX = actualW / imgW;
|
||||||
|
const scaleY = actualH / imgH;
|
||||||
|
|
||||||
|
// Convert normalized landmarks (0-1) to pixel coordinates in bg-removed space
|
||||||
|
const crownYPx = (landmarks.crown.y + s.adjustY) * imgH * scaleY;
|
||||||
|
const chinYPx = (landmarks.chin.y + s.adjustY) * imgH * scaleY;
|
||||||
|
const eyeYPx = (landmarks.eyeCenter.y + s.adjustY) * imgH * scaleY;
|
||||||
|
const faceCenterXPx = (landmarks.faceCenterX + s.adjustX) * imgW * scaleX;
|
||||||
|
|
||||||
const targetHeadRatio = (docSpec.headHeightMin + docSpec.headHeightMax) / 2;
|
const targetHeadRatio = (docSpec.headHeightMin + docSpec.headHeightMax) / 2;
|
||||||
const headHeightPx = chinYPx - crownYPx;
|
const headHeightPx = chinYPx - crownYPx;
|
||||||
@@ -575,40 +582,58 @@ export function registerPassportPhoto(app: FastifyInstance) {
|
|||||||
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
|
const topY = eyeYPx - photoHeightPx * (1 - docSpec.eyeLineFromBottom);
|
||||||
const leftX = faceCenterXPx - photoWidthPx / 2;
|
const leftX = faceCenterXPx - photoWidthPx / 2;
|
||||||
|
|
||||||
const cropW = Math.min(Math.round(photoWidthPx), imgW);
|
|
||||||
const cropH = Math.min(Math.round(photoHeightPx), imgH);
|
|
||||||
let cropLeft = Math.max(0, Math.round(leftX));
|
|
||||||
let cropTop = Math.max(0, Math.round(topY));
|
|
||||||
if (cropLeft + cropW > imgW) cropLeft = imgW - cropW;
|
|
||||||
if (cropTop + cropH > imgH) cropTop = imgH - cropH;
|
|
||||||
cropLeft = Math.max(0, cropLeft);
|
|
||||||
cropTop = Math.max(0, cropTop);
|
|
||||||
|
|
||||||
// Composite onto background
|
// Composite onto background
|
||||||
const hex = s.bgColor.replace("#", "");
|
const hex = s.bgColor.replace("#", "");
|
||||||
const bgR = Number.parseInt(hex.slice(0, 2), 16);
|
const bgR = Number.parseInt(hex.slice(0, 2), 16);
|
||||||
const bgG = Number.parseInt(hex.slice(2, 4), 16);
|
const bgG = Number.parseInt(hex.slice(2, 4), 16);
|
||||||
const bgB = Number.parseInt(hex.slice(4, 6), 16);
|
const bgB = Number.parseInt(hex.slice(4, 6), 16);
|
||||||
|
const bgRgb = { r: bgR, g: bgG, b: bgB, alpha: 1 };
|
||||||
|
|
||||||
const bgRemovedMeta = await sharp(bgRemovedBuffer).metadata();
|
|
||||||
const bgLayer = await sharp({
|
const bgLayer = await sharp({
|
||||||
create: {
|
create: {
|
||||||
width: bgRemovedMeta.width ?? imgW,
|
width: actualW,
|
||||||
height: bgRemovedMeta.height ?? imgH,
|
height: actualH,
|
||||||
channels: 4,
|
channels: 4,
|
||||||
background: { r: bgR, g: bgG, b: bgB, alpha: 1 },
|
background: bgRgb,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.composite([{ input: bgRemovedBuffer, blend: "over" }])
|
.composite([{ input: bgRemovedBuffer, blend: "over" }])
|
||||||
.png()
|
.png()
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|
||||||
|
// Pad instead of clamp so the crop region can extend beyond the image
|
||||||
|
const rawLeft = Math.round(leftX);
|
||||||
|
const rawTop = Math.round(topY);
|
||||||
|
const rawW = Math.round(photoWidthPx);
|
||||||
|
const rawH = Math.round(photoHeightPx);
|
||||||
|
|
||||||
|
const padLeft = Math.max(0, -rawLeft);
|
||||||
|
const padTop = Math.max(0, -rawTop);
|
||||||
|
const padRight = Math.max(0, rawLeft + rawW - actualW);
|
||||||
|
const padBottom = Math.max(0, rawTop + rawH - actualH);
|
||||||
|
|
||||||
|
let sourceForCrop = bgLayer;
|
||||||
|
if (padLeft > 0 || padTop > 0 || padRight > 0 || padBottom > 0) {
|
||||||
|
sourceForCrop = await sharp(bgLayer)
|
||||||
|
.extend({
|
||||||
|
top: padTop,
|
||||||
|
bottom: padBottom,
|
||||||
|
left: padLeft,
|
||||||
|
right: padRight,
|
||||||
|
background: bgRgb,
|
||||||
|
})
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cropLeft = rawLeft + padLeft;
|
||||||
|
const cropTop = rawTop + padTop;
|
||||||
|
|
||||||
const MM_PER_INCH = 25.4;
|
const MM_PER_INCH = 25.4;
|
||||||
const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi);
|
const targetWidthPx = Math.round((docSpec.width / MM_PER_INCH) * docSpec.dpi);
|
||||||
const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi);
|
const targetHeightPx = Math.round((docSpec.height / MM_PER_INCH) * docSpec.dpi);
|
||||||
|
|
||||||
const result = await sharp(bgLayer)
|
const result = await sharp(sourceForCrop)
|
||||||
.extract({ left: cropLeft, top: cropTop, width: cropW, height: cropH })
|
.extract({ left: cropLeft, top: cropTop, width: rawW, height: rawH })
|
||||||
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
.resize(targetWidthPx, targetHeightPx, { fit: "fill" })
|
||||||
.jpeg({ quality: 95 })
|
.jpeg({ quality: 95 })
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ export async function removeBackground(
|
|||||||
const inputPath = join(tmpdir(), `rembg_in_${id}.png`);
|
const inputPath = join(tmpdir(), `rembg_in_${id}.png`);
|
||||||
const outputPath = join(outputDir, `rembg_out_${id}.png`);
|
const outputPath = join(outputDir, `rembg_out_${id}.png`);
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngBuffer);
|
||||||
try {
|
try {
|
||||||
const meta = await sharp(inputBuffer).metadata();
|
const meta = await sharp(inputBuffer).metadata();
|
||||||
const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
|
const megapixels = ((meta.width ?? 0) * (meta.height ?? 0)) / 1_000_000;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
export interface ColorizeOptions {
|
export interface ColorizeOptions {
|
||||||
@@ -23,7 +24,8 @@ export async function colorize(
|
|||||||
const inputPath = join(outputDir, "input_colorize.png");
|
const inputPath = join(outputDir, "input_colorize.png");
|
||||||
const outputPath = join(outputDir, "output_colorize.png");
|
const outputPath = join(outputDir, "output_colorize.png");
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngBuffer);
|
||||||
const { stdout } = await runPythonWithProgress(
|
const { stdout } = await runPythonWithProgress(
|
||||||
"colorize.py",
|
"colorize.py",
|
||||||
[inputPath, outputPath, JSON.stringify(options)],
|
[inputPath, outputPath, JSON.stringify(options)],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
export interface EnhanceFacesOptions {
|
export interface EnhanceFacesOptions {
|
||||||
@@ -25,7 +26,8 @@ export async function enhanceFaces(
|
|||||||
const inputPath = join(outputDir, "input_enhance_faces.png");
|
const inputPath = join(outputDir, "input_enhance_faces.png");
|
||||||
const outputPath = join(outputDir, "output_enhance_faces.png");
|
const outputPath = join(outputDir, "output_enhance_faces.png");
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngBuffer);
|
||||||
const { stdout } = await runPythonWithProgress(
|
const { stdout } = await runPythonWithProgress(
|
||||||
"enhance_faces.py",
|
"enhance_faces.py",
|
||||||
[inputPath, outputPath, JSON.stringify(options)],
|
[inputPath, outputPath, JSON.stringify(options)],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
export async function inpaint(
|
export async function inpaint(
|
||||||
@@ -12,8 +13,10 @@ export async function inpaint(
|
|||||||
const maskPath = join(outputDir, "mask_inpaint.png");
|
const maskPath = join(outputDir, "mask_inpaint.png");
|
||||||
const outputPath = join(outputDir, "output_inpaint.png");
|
const outputPath = join(outputDir, "output_inpaint.png");
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngInput = await sharp(inputBuffer).png().toBuffer();
|
||||||
await writeFile(maskPath, maskBuffer);
|
const pngMask = await sharp(maskBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngInput);
|
||||||
|
await writeFile(maskPath, pngMask);
|
||||||
|
|
||||||
const { stdout } = await runPythonWithProgress("inpaint.py", [inputPath, maskPath, outputPath], {
|
const { stdout } = await runPythonWithProgress("inpaint.py", [inputPath, maskPath, outputPath], {
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
export interface NoiseRemovalOptions {
|
export interface NoiseRemovalOptions {
|
||||||
@@ -28,7 +29,8 @@ export async function noiseRemoval(
|
|||||||
const inputPath = join(outputDir, "input_denoise.png");
|
const inputPath = join(outputDir, "input_denoise.png");
|
||||||
const outputPath = join(outputDir, "output_denoise.png");
|
const outputPath = join(outputDir, "output_denoise.png");
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngBuffer);
|
||||||
const { stdout } = await runPythonWithProgress(
|
const { stdout } = await runPythonWithProgress(
|
||||||
"noise_removal.py",
|
"noise_removal.py",
|
||||||
[inputPath, outputPath, JSON.stringify(options)],
|
[inputPath, outputPath, JSON.stringify(options)],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
export interface RestorePhotoOptions {
|
export interface RestorePhotoOptions {
|
||||||
@@ -32,7 +33,8 @@ export async function restorePhoto(
|
|||||||
const inputPath = join(outputDir, "input_restore.png");
|
const inputPath = join(outputDir, "input_restore.png");
|
||||||
const outputPath = join(outputDir, "output_restore.png");
|
const outputPath = join(outputDir, "output_restore.png");
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngBuffer);
|
||||||
const { stdout } = await runPythonWithProgress(
|
const { stdout } = await runPythonWithProgress(
|
||||||
"restore.py",
|
"restore.py",
|
||||||
[inputPath, outputPath, JSON.stringify(options)],
|
[inputPath, outputPath, JSON.stringify(options)],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { readFile, writeFile } from "node:fs/promises";
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import sharp from "sharp";
|
||||||
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
import { type ProgressCallback, parseStdoutJson, runPythonWithProgress } from "./bridge.js";
|
||||||
|
|
||||||
export interface UpscaleOptions {
|
export interface UpscaleOptions {
|
||||||
@@ -28,7 +29,8 @@ export async function upscale(
|
|||||||
const inputPath = join(outputDir, "input_upscale.png");
|
const inputPath = join(outputDir, "input_upscale.png");
|
||||||
const outputPath = join(outputDir, "output_upscale.png");
|
const outputPath = join(outputDir, "output_upscale.png");
|
||||||
|
|
||||||
await writeFile(inputPath, inputBuffer);
|
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
|
||||||
|
await writeFile(inputPath, pngBuffer);
|
||||||
const { stdout } = await runPythonWithProgress(
|
const { stdout } = await runPythonWithProgress(
|
||||||
"upscale.py",
|
"upscale.py",
|
||||||
[inputPath, outputPath, JSON.stringify(options)],
|
[inputPath, outputPath, JSON.stringify(options)],
|
||||||
|
|||||||
Reference in New Issue
Block a user