← back to Interiordesignershowroom
lib/hotspot-providers/gemini.js
87 lines
// HOTSPOT PROVIDER: gemini — Vision-locate products inside a generated room-setting
// image so the UI can put a shoppable hotspot ON each actual piece. Uses Gemini 2.5
// Flash bounding-box detection (returns [ymin,xmin,ymax,xmax] normalized 0-1000). Any
// product the model can't confidently place is simply omitted — the frontend renders
// those as edge chips so nothing becomes unselectable. Cheap: one Flash text call per
// scene. Selected via HOTSPOT_PROVIDER=gemini (default) in lib/hotspots.js.
const fs = require('fs');
const MODEL = 'gemini-2.5-flash';
const COST_PER_CALL = 0.001; // ~1 image + small JSON out on Flash; shown to Steve
function buildPrompt(products) {
const list = products.map((p, i) => `${i + 1}. ${p.title}`).join('\n');
return [
'This is a photograph of a furnished room. Below is a numbered list of the products that appear in it.',
'For EACH product you can clearly locate, return its bounding box.',
'Respond with ONLY a compact JSON array, no prose, no code fence:',
'[{"i": <product number>, "box": [ymin, xmin, ymax, xmax]}]',
'Coordinates are normalized 0-1000 (y = top→bottom, x = left→right).',
'Omit any product you cannot confidently locate. Products:',
list,
].join('\n');
}
// Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
// on the same visual object as one already accepted (lookalike products the model
// tagged to the single instance it could actually see in the scene).
function iou(a, b) {
const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
const inter = ix * iy;
return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
}
function toHotspots(raw, products) {
const seen = new Set();
const hotspots = [];
for (const d of raw) {
const idx = Number(d.i) - 1;
const p = products[idx];
if (!p || seen.has(p.id) || !Array.isArray(d.box) || d.box.length !== 4) continue;
let [ymin, xmin, ymax, xmax] = d.box.map(Number);
const cl = (n) => Math.max(0, Math.min(1000, n || 0));
ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
if (xmax <= xmin || ymax <= ymin) continue;
const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
seen.add(p.id);
hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
}
return hotspots;
}
// products: [{id, title, image_url, price, sale_price, advertiser}]
// imagePath: absolute path to the generated PNG on disk
async function locateProducts(imagePath, products = []) {
const key = process.env.GEMINI_API_KEY;
if (!key || !products.length) return { hotspots: [], cost: 0 };
let b64;
try { b64 = fs.readFileSync(imagePath).toString('base64'); }
catch { return { hotspots: [], cost: 0 }; }
const prompt = buildPrompt(products);
let j;
try {
const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${key}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{ parts: [{ inlineData: { mimeType: 'image/png', data: b64 } }, { text: prompt }] }],
generationConfig: { temperature: 0, responseMimeType: 'application/json' },
}),
});
j = await res.json();
} catch (e) { return { hotspots: [], cost: 0, error: e.message }; }
if (j.error) return { hotspots: [], cost: 0, error: j.error.message };
const text = (j.candidates && j.candidates[0] && j.candidates[0].content
&& j.candidates[0].content.parts || []).map((p) => p.text || '').join('');
let raw;
try { raw = JSON.parse(text); } catch { raw = []; }
if (!Array.isArray(raw)) raw = [];
return { hotspots: toHotspots(raw, products), cost: COST_PER_CALL };
}
module.exports = { locateProducts, COST_PER_CALL };