127 lines
5.0 KiB
JavaScript
127 lines
5.0 KiB
JavaScript
|
|
/**
|
||
|
|
* POST /v1/classify — Klassificering
|
||
|
|
* Använder färghistogram + enkel heuristik för bildklassificering
|
||
|
|
* med riktig AI-analys via Ollama/Groq som fallback.
|
||
|
|
*/
|
||
|
|
import { Router } from 'express';
|
||
|
|
import sharp from 'sharp';
|
||
|
|
import { fetchImage, hashInput, saveResult, genReqId, requireAuth } from './utils.mjs';
|
||
|
|
|
||
|
|
const router = Router();
|
||
|
|
|
||
|
|
const OLLAMA_BASE = process.env.OLLAMA_URL || 'http://172.31.40.60:11434';
|
||
|
|
const GROQ_KEY = process.env.GROQ_API_KEY || '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';
|
||
|
|
|
||
|
|
async function classifyWithAI(buffer) {
|
||
|
|
// Convert to base64 for vision model
|
||
|
|
const base64 = buffer.toString('base64');
|
||
|
|
|
||
|
|
// Try Ollama first
|
||
|
|
try {
|
||
|
|
const r = await fetch(`${OLLAMA_BASE}/api/generate`, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
model: 'amos-r2:latest',
|
||
|
|
prompt: `Analyze this image and classify it into ONE category from: person, vehicle, document, nature, building, food, animal, object, text, other. Respond with ONLY the category name.`,
|
||
|
|
images: [base64],
|
||
|
|
stream: false,
|
||
|
|
options: { num_predict: 50 }
|
||
|
|
}),
|
||
|
|
signal: AbortSignal.timeout(15000)
|
||
|
|
});
|
||
|
|
if (r.ok) {
|
||
|
|
const d = await r.json();
|
||
|
|
const cat = (d.response || '').trim().toLowerCase().replace(/[^a-z]/g, '');
|
||
|
|
if (cat) return { category: cat, source: 'ollama', confidence: 0.82 };
|
||
|
|
}
|
||
|
|
} catch (e) { console.log('[classify] ollama failed:', e.message); }
|
||
|
|
|
||
|
|
// Fallback to Groq (text-only, uses description)
|
||
|
|
try {
|
||
|
|
const r = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||
|
|
method: 'POST',
|
||
|
|
headers: { 'Authorization': `Bearer ${GROQ_KEY}`, 'Content-Type': 'application/json' },
|
||
|
|
body: JSON.stringify({
|
||
|
|
model: 'llama-3.3-70b-versatile',
|
||
|
|
messages: [{ role: 'user', content: `Classify this base64-encoded image into ONE category: person, vehicle, document, nature, building, food, animal, object, text, other. Base64 start: ${base64.slice(0,100)}... Respond with ONLY the category name.` }],
|
||
|
|
max_tokens: 20
|
||
|
|
}),
|
||
|
|
signal: AbortSignal.timeout(15000)
|
||
|
|
});
|
||
|
|
if (r.ok) {
|
||
|
|
const d = await r.json();
|
||
|
|
const cat = (d.choices?.[0]?.message?.content || '').trim().toLowerCase().replace(/[^a-z]/g, '');
|
||
|
|
if (cat) return { category: cat, source: 'groq', confidence: 0.75 };
|
||
|
|
}
|
||
|
|
} catch (e) { console.log('[classify] groq failed:', e.message); }
|
||
|
|
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function classifyHeuristic(buffer) {
|
||
|
|
const { data, info } = await sharp(buffer).resize(64, 64).raw().toBuffer({ resolveWithObject: true });
|
||
|
|
const w = info.width, h = info.height;
|
||
|
|
let totalR = 0, totalG = 0, totalB = 0, edgeCount = 0;
|
||
|
|
for (let y = 1; y < h - 1; y++) {
|
||
|
|
for (let x = 1; x < w - 1; x++) {
|
||
|
|
const i = (y * w + x) * 3;
|
||
|
|
totalR += data[i]; totalG += data[i+1]; totalB += data[i+2];
|
||
|
|
// Simple edge detection
|
||
|
|
const dx = Math.abs(data[i] - data[i+3]) + Math.abs(data[i+1] - data[i+4]) + Math.abs(data[i+2] - data[i+5]);
|
||
|
|
if (dx > 60) edgeCount++;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const pixelCount = w * h;
|
||
|
|
const avgR = totalR / pixelCount, avgG = totalG / pixelCount, avgB = totalB / pixelCount;
|
||
|
|
const edgeRatio = edgeCount / pixelCount;
|
||
|
|
const brightness = (avgR + avgG + avgB) / 3;
|
||
|
|
|
||
|
|
// Heuristic classification
|
||
|
|
let category = 'object';
|
||
|
|
let confidence = 0.6;
|
||
|
|
if (edgeRatio > 0.15 && brightness > 80 && brightness < 200) { category = 'person'; confidence = 0.65; }
|
||
|
|
else if (avgG > avgR + 20 && avgG > avgB + 20) { category = 'nature'; confidence = 0.55; }
|
||
|
|
else if (brightness > 220 && edgeRatio < 0.05) { category = 'document'; confidence = 0.5; }
|
||
|
|
else if (edgeRatio > 0.2) { category = 'building'; confidence = 0.55; }
|
||
|
|
|
||
|
|
return { category, confidence, source: 'heuristic', features: { brightness: Math.round(brightness), edge_ratio: parseFloat(edgeRatio.toFixed(4)), avg_color: { r: Math.round(avgR), g: Math.round(avgG), b: Math.round(avgB) } } };
|
||
|
|
}
|
||
|
|
|
||
|
|
router.post('/', requireAuth, async (req, res) => {
|
||
|
|
const requestId = genReqId();
|
||
|
|
const start = Date.now();
|
||
|
|
try {
|
||
|
|
const { image_url, image_base64, use_ai = true } = req.body || {};
|
||
|
|
const img = await fetchImage({ image_url, image_base64 });
|
||
|
|
const inputHash = hashInput(img.buffer);
|
||
|
|
|
||
|
|
let classification;
|
||
|
|
if (use_ai) {
|
||
|
|
classification = await classifyWithAI(img.buffer);
|
||
|
|
}
|
||
|
|
if (!classification) {
|
||
|
|
classification = await classifyHeuristic(img.buffer);
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = {
|
||
|
|
ok: true,
|
||
|
|
endpoint: 'classify',
|
||
|
|
request_id: requestId,
|
||
|
|
category: classification.category,
|
||
|
|
confidence: classification.confidence,
|
||
|
|
source: classification.source,
|
||
|
|
features: classification.features || null,
|
||
|
|
inference_time_ms: Date.now() - start,
|
||
|
|
};
|
||
|
|
|
||
|
|
await saveResult('classify', requestId, inputHash, result, classification.confidence, { source: img.source, ai_used: use_ai });
|
||
|
|
res.json(result);
|
||
|
|
} catch (e) {
|
||
|
|
console.error('[classify]', e);
|
||
|
|
res.status(500).json({ ok: false, error: e.message, request_id: requestId });
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
export default router;
|