Initial commit: Image moderation service using local vision LLM
This commit is contained in:
11
.env.example
Normal file
11
.env.example
Normal file
@@ -0,0 +1,11 @@
|
||||
PORT=8100
|
||||
VISION_AI_URL=http://localhost:8000/v1/chat/completions
|
||||
VISION_AI_MODEL=gemma3:4b
|
||||
VISION_AI_TIMEOUT=90000
|
||||
TILE_THRESHOLD=1024
|
||||
|
||||
# Callbacks when image is flagged unsafe
|
||||
UPLOAD_SERVICE_REPLACE_URL=http://192.168.0.145:3003/internal/replace
|
||||
UPLOAD_SERVICE_SECRET=<same as IMAGE_UPLOAD_SECRET on NAS>
|
||||
PAROCHIA_CALLBACK_URL=https://parochia.org/api/images/moderation-callback
|
||||
PAROCHIA_CALLBACK_SECRET=<same as MODERATION_CALLBACK_SECRET on NAS>
|
||||
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
2395
package-lock.json
generated
Normal file
2395
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
24
package.json
Normal file
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "image-moderation-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Standalone image moderation service using local vision AI",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^5.1.0",
|
||||
"multer": "^1.4.5-lts.2",
|
||||
"node-windows": "^1.0.0-beta.8",
|
||||
"sharp": "^0.33.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.2",
|
||||
"@types/multer": "^1.4.12",
|
||||
"tsx": "^4.19.4",
|
||||
"typescript": "^5.8.2"
|
||||
}
|
||||
}
|
||||
27
service-install.js
Normal file
27
service-install.js
Normal file
@@ -0,0 +1,27 @@
|
||||
const Service = require("node-windows").Service;
|
||||
const path = require("path");
|
||||
|
||||
const svc = new Service({
|
||||
name: "Image Moderation Service",
|
||||
description: "Standalone image moderation service using local vision AI",
|
||||
script: path.join(__dirname, "dist", "server.js"),
|
||||
nodeOptions: [],
|
||||
env: [
|
||||
{ name: "PATH", value: process.env.PATH },
|
||||
],
|
||||
});
|
||||
|
||||
svc.on("install", () => {
|
||||
console.log("Service installed. Starting...");
|
||||
svc.start();
|
||||
});
|
||||
|
||||
svc.on("start", () => {
|
||||
console.log("Service started!");
|
||||
});
|
||||
|
||||
svc.on("error", (err) => {
|
||||
console.error("Error:", err);
|
||||
});
|
||||
|
||||
svc.install();
|
||||
13
service-uninstall.js
Normal file
13
service-uninstall.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const Service = require("node-windows").Service;
|
||||
const path = require("path");
|
||||
|
||||
const svc = new Service({
|
||||
name: "Image Moderation Service",
|
||||
script: path.join(__dirname, "dist", "server.js"),
|
||||
});
|
||||
|
||||
svc.on("uninstall", () => {
|
||||
console.log("Service uninstalled.");
|
||||
});
|
||||
|
||||
svc.uninstall();
|
||||
218
src/moderate.ts
Normal file
218
src/moderate.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
// --- Config ---
|
||||
|
||||
const VISION_AI_URL =
|
||||
process.env.VISION_AI_URL || "http://localhost:8000/v1/chat/completions";
|
||||
const VISION_AI_MODEL = process.env.VISION_AI_MODEL || "gemma3:4b";
|
||||
const VISION_AI_TIMEOUT = parseInt(
|
||||
process.env.VISION_AI_TIMEOUT || "30000",
|
||||
10
|
||||
);
|
||||
const TILE_THRESHOLD = parseInt(process.env.TILE_THRESHOLD || "1024", 10);
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface ModerationResult {
|
||||
safe: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
// --- Prompts ---
|
||||
|
||||
const PROMPTS: Record<string, string> = {
|
||||
general: `You are a content moderation system. Analyze this image for safety.
|
||||
|
||||
Check for:
|
||||
- Nudity or sexually explicit content
|
||||
- Graphic violence or gore
|
||||
- Hate symbols or extremist imagery
|
||||
- Drug use or paraphernalia
|
||||
|
||||
Respond with exactly one word on the first line: SAFE or UNSAFE
|
||||
If UNSAFE, add a brief reason on the second line.`,
|
||||
|
||||
church: `You are a content moderation system for Catholic parish websites. Analyze this image for safety.
|
||||
|
||||
Check for:
|
||||
- Nudity or sexually explicit content
|
||||
- Graphic violence or gore
|
||||
- Hate symbols or extremist imagery
|
||||
- Drug use or paraphernalia
|
||||
|
||||
Note: Religious imagery (crucifixes, saints, stained glass, statues, liturgical art) is SAFE and expected. Depictions of the crucifixion or martyrdom in a religious context are SAFE.
|
||||
|
||||
Respond with exactly one word on the first line: SAFE or UNSAFE
|
||||
If UNSAFE, add a brief reason on the second line.`,
|
||||
};
|
||||
|
||||
// --- Vision AI call ---
|
||||
|
||||
async function callVisionAI(
|
||||
imageBase64: string,
|
||||
mimeType: string,
|
||||
context: string
|
||||
): Promise<string> {
|
||||
const prompt = PROMPTS[context] || PROMPTS.general;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), VISION_AI_TIMEOUT);
|
||||
|
||||
try {
|
||||
const response = await fetch(VISION_AI_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
signal: controller.signal,
|
||||
body: JSON.stringify({
|
||||
model: VISION_AI_MODEL,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: prompt },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:${mimeType};base64,${imageBase64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
max_tokens: 100,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Vision AI returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
choices?: { message?: { content?: string } }[];
|
||||
};
|
||||
return data.choices?.[0]?.message?.content?.trim() || "";
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function parseVerdict(response: string): ModerationResult {
|
||||
const firstLine = response.split("\n")[0].trim().toUpperCase();
|
||||
|
||||
if (firstLine.startsWith("UNSAFE")) {
|
||||
const lines = response.split("\n");
|
||||
const reason = lines
|
||||
.slice(1)
|
||||
.map((l: string) => l.trim())
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return { safe: false, reason: reason || "Content flagged as unsafe" };
|
||||
}
|
||||
|
||||
// Only explicit UNSAFE triggers rejection — everything else (SAFE, garbled, empty) passes
|
||||
return { safe: true };
|
||||
}
|
||||
|
||||
// --- Tile slicing ---
|
||||
|
||||
interface TileBuffer {
|
||||
buffer: Buffer;
|
||||
mimeType: string;
|
||||
}
|
||||
|
||||
async function sliceIntoTiles(
|
||||
imageBuffer: Buffer,
|
||||
width: number,
|
||||
height: number
|
||||
): Promise<TileBuffer[]> {
|
||||
const gridSize = width > 2048 || height > 2048 ? 3 : 2;
|
||||
const tileW = Math.floor(width / gridSize);
|
||||
const tileH = Math.floor(height / gridSize);
|
||||
const targetSize = 512;
|
||||
|
||||
const tiles: Promise<TileBuffer>[] = [];
|
||||
|
||||
for (let row = 0; row < gridSize; row++) {
|
||||
for (let col = 0; col < gridSize; col++) {
|
||||
const left = col * tileW;
|
||||
const top = row * tileH;
|
||||
// Last tile in row/col extends to edge to avoid missing pixels
|
||||
const extractW = col === gridSize - 1 ? width - left : tileW;
|
||||
const extractH = row === gridSize - 1 ? height - top : tileH;
|
||||
|
||||
tiles.push(
|
||||
sharp(imageBuffer)
|
||||
.extract({ left, top, width: extractW, height: extractH })
|
||||
.resize(targetSize, targetSize, { fit: "inside" })
|
||||
.jpeg({ quality: 80 })
|
||||
.toBuffer()
|
||||
.then((buffer) => ({ buffer, mimeType: "image/jpeg" }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.all(tiles);
|
||||
}
|
||||
|
||||
// --- Main moderation function ---
|
||||
|
||||
export async function moderateImage(
|
||||
imageBuffer: Buffer,
|
||||
context: string = "general"
|
||||
): Promise<ModerationResult> {
|
||||
try {
|
||||
const metadata = await sharp(imageBuffer).metadata();
|
||||
const width = metadata.width || 0;
|
||||
const height = metadata.height || 0;
|
||||
|
||||
if (width === 0 || height === 0) {
|
||||
console.warn("Could not read image dimensions — allowing image");
|
||||
return { safe: true };
|
||||
}
|
||||
|
||||
const isLarge = width > TILE_THRESHOLD || height > TILE_THRESHOLD;
|
||||
|
||||
// Always analyze the full image (resized to fit within 1024px)
|
||||
const fullImageBuffer = await sharp(imageBuffer)
|
||||
.resize(1024, 1024, { fit: "inside" })
|
||||
.jpeg({ quality: 85 })
|
||||
.toBuffer();
|
||||
const fullBase64 = fullImageBuffer.toString("base64");
|
||||
|
||||
if (!isLarge) {
|
||||
// Small image: just analyze the full image
|
||||
const response = await callVisionAI(fullBase64, "image/jpeg", context);
|
||||
return parseVerdict(response);
|
||||
}
|
||||
|
||||
// Large image: full image + tiles in parallel
|
||||
const tiles = await sliceIntoTiles(imageBuffer, width, height);
|
||||
|
||||
const allPromises = [
|
||||
callVisionAI(fullBase64, "image/jpeg", context),
|
||||
...tiles.map((tile) =>
|
||||
callVisionAI(tile.buffer.toString("base64"), tile.mimeType, context)
|
||||
),
|
||||
];
|
||||
|
||||
const results = await Promise.all(allPromises);
|
||||
|
||||
// If ANY result is UNSAFE, reject the image
|
||||
for (const response of results) {
|
||||
const verdict = parseVerdict(response);
|
||||
if (!verdict.safe) {
|
||||
return verdict;
|
||||
}
|
||||
}
|
||||
|
||||
return { safe: true };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Moderation error:",
|
||||
error instanceof Error ? error.message : error
|
||||
);
|
||||
// Fail open — allow the image if vision AI is unavailable
|
||||
return { safe: true };
|
||||
}
|
||||
}
|
||||
180
src/server.ts
Normal file
180
src/server.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import "dotenv/config";
|
||||
import express, { Request, Response, NextFunction } from "express";
|
||||
import multer from "multer";
|
||||
import { moderateImage } from "./moderate";
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "8100", 10);
|
||||
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
|
||||
|
||||
// Callback config — where to report unsafe images
|
||||
const UPLOAD_SERVICE_REPLACE_URL =
|
||||
process.env.UPLOAD_SERVICE_REPLACE_URL || "";
|
||||
const UPLOAD_SERVICE_SECRET = process.env.UPLOAD_SERVICE_SECRET || "";
|
||||
const PAROCHIA_CALLBACK_URL = process.env.PAROCHIA_CALLBACK_URL || "";
|
||||
const PAROCHIA_CALLBACK_SECRET = process.env.PAROCHIA_CALLBACK_SECRET || "";
|
||||
|
||||
const ALLOWED_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
const app = express();
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: {
|
||||
fileSize: MAX_FILE_SIZE,
|
||||
files: 1,
|
||||
},
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (ALLOWED_MIMES.has(file.mimetype)) {
|
||||
cb(null, true);
|
||||
} else {
|
||||
cb(new Error("Invalid file type. Allowed: JPEG, PNG, WebP, GIF"));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// --- Callback helpers ---
|
||||
|
||||
async function notifyUnsafe(
|
||||
imagePath: string,
|
||||
userId: string,
|
||||
siteId: string,
|
||||
reason: string
|
||||
): Promise<void> {
|
||||
// Tell upload service to replace image with stub
|
||||
if (UPLOAD_SERVICE_REPLACE_URL && UPLOAD_SERVICE_SECRET) {
|
||||
try {
|
||||
const res = await fetch(UPLOAD_SERVICE_REPLACE_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-secret": UPLOAD_SERVICE_SECRET,
|
||||
},
|
||||
body: JSON.stringify({ imagePath }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Replace callback failed: ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Replace callback error:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Tell Parochia to flag the user
|
||||
if (PAROCHIA_CALLBACK_URL && PAROCHIA_CALLBACK_SECRET) {
|
||||
try {
|
||||
const res = await fetch(PAROCHIA_CALLBACK_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-callback-secret": PAROCHIA_CALLBACK_SECRET,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
userId,
|
||||
siteId,
|
||||
reason: "explicit_image",
|
||||
details: reason,
|
||||
}),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
console.error(`Parochia callback failed: ${res.status}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(
|
||||
"Parochia callback error:",
|
||||
err instanceof Error ? err.message : err
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// Health check
|
||||
app.get("/health", (_req: Request, res: Response) => {
|
||||
res.json({ status: "ok", timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
// Moderation endpoint
|
||||
// Accepts: file (image), context, imagePath, userId, siteId
|
||||
// If called with metadata fields, will fire callbacks on UNSAFE result
|
||||
app.post(
|
||||
"/moderate",
|
||||
upload.single("file"),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const file = req.file;
|
||||
if (!file) {
|
||||
res.status(400).json({ error: "No file provided" });
|
||||
return;
|
||||
}
|
||||
|
||||
const context =
|
||||
typeof req.body?.context === "string" ? req.body.context : "general";
|
||||
|
||||
const result = await moderateImage(file.buffer, context);
|
||||
|
||||
// If unsafe and metadata was provided, fire callbacks
|
||||
if (!result.safe && req.body?.imagePath) {
|
||||
const { imagePath, userId, siteId } = req.body;
|
||||
// Fire-and-forget — don't block the response
|
||||
notifyUnsafe(
|
||||
imagePath,
|
||||
userId || "",
|
||||
siteId || "",
|
||||
result.reason || "Content flagged as unsafe"
|
||||
).catch((err) =>
|
||||
console.error("Callback dispatch error:", err)
|
||||
);
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// 404 catch-all
|
||||
app.use((_req: Request, res: Response) => {
|
||||
res.status(404).json({ error: "Not found" });
|
||||
});
|
||||
|
||||
// Error handler
|
||||
app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
|
||||
if (
|
||||
err instanceof multer.MulterError &&
|
||||
err.code === "LIMIT_FILE_SIZE"
|
||||
) {
|
||||
res.status(413).json({ error: "File too large. Maximum size is 20MB." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (err.message.startsWith("Invalid file type")) {
|
||||
res.status(400).json({ error: err.message });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error("Internal error:", err.message);
|
||||
res.status(500).json({ error: "Internal server error" });
|
||||
});
|
||||
|
||||
app.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Image moderation service listening on port ${PORT}`);
|
||||
console.log(
|
||||
`Vision AI: ${process.env.VISION_AI_URL || "http://localhost:8000/v1/chat/completions"}`
|
||||
);
|
||||
console.log(`Model: ${process.env.VISION_AI_MODEL || "gemma3:4b"}`);
|
||||
console.log(
|
||||
`Callbacks: replace=${UPLOAD_SERVICE_REPLACE_URL ? "configured" : "none"}, parochia=${PAROCHIA_CALLBACK_URL ? "configured" : "none"}`
|
||||
);
|
||||
});
|
||||
17
tsconfig.json
Normal file
17
tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user