|
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
|
const video = document.getElementById("camera-feed");
|
|
|
const startBtn = document.getElementById("start-camera");
|
|
|
const analyzeBtn = document.getElementById("analyze-button");
|
|
|
const uploadBtn = document.getElementById("upload-photo");
|
|
|
const photoInput = document.getElementById("photo-input");
|
|
|
const contextList = document.getElementById("context-list");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const cityInput = document.getElementById("city-input");
|
|
|
const attrIds = [
|
|
|
"soft_bag",
|
|
|
"foam",
|
|
|
"paper_cup",
|
|
|
"carton",
|
|
|
"greasy_or_wet",
|
|
|
|
|
|
"hazard",
|
|
|
];
|
|
|
const attrEls = Object.fromEntries(
|
|
|
attrIds.map((id) => [id, document.getElementById(`attr-${id}`)])
|
|
|
);
|
|
|
|
|
|
|
|
|
if (!analyzeBtn || analyzeBtn.dataset.bound === "true") return;
|
|
|
analyzeBtn.dataset.bound = "true";
|
|
|
|
|
|
let stream = null;
|
|
|
let inFlight = false;
|
|
|
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);
|
|
|
|
|
|
function collectContext() {
|
|
|
|
|
|
const city = cityInput?.value?.trim() || "default";
|
|
|
const attrs = {};
|
|
|
for (const [k, el] of Object.entries(attrEls)) {
|
|
|
if (el && typeof el.checked === "boolean") attrs[k] = !!el.checked;
|
|
|
}
|
|
|
return { city, attrs };
|
|
|
}
|
|
|
|
|
|
async function startCamera() {
|
|
|
const base = { width: { ideal: 1280 }, height: { ideal: 720 }, aspectRatio: { ideal: 16 / 9 } };
|
|
|
const envStrict = { video: { facingMode: { exact: "environment" }, ...base } };
|
|
|
const envLoose = { video: { facingMode: "environment", ...base } };
|
|
|
|
|
|
try {
|
|
|
stream = await navigator.mediaDevices.getUserMedia(envStrict);
|
|
|
} catch {
|
|
|
try {
|
|
|
stream = await navigator.mediaDevices.getUserMedia(envLoose);
|
|
|
} catch {
|
|
|
stream = await navigator.mediaDevices.getUserMedia({ video: true });
|
|
|
}
|
|
|
}
|
|
|
|
|
|
video.srcObject = stream;
|
|
|
video.setAttribute("playsinline", "");
|
|
|
video.muted = true;
|
|
|
|
|
|
await new Promise((r) => {
|
|
|
if (video.readyState >= 2) r();
|
|
|
else video.addEventListener("loadedmetadata", r, { once: true });
|
|
|
});
|
|
|
try { await video.play(); } catch (_) {}
|
|
|
|
|
|
analyzeBtn.disabled = false;
|
|
|
startBtn && (startBtn.textContent = "Camera On");
|
|
|
startBtn && (startBtn.disabled = true);
|
|
|
|
|
|
window.addEventListener("beforeunload", () => stream?.getTracks().forEach(t => t.stop()));
|
|
|
}
|
|
|
|
|
|
startBtn?.addEventListener("click", async () => {
|
|
|
try {
|
|
|
await startCamera();
|
|
|
} catch (e) {
|
|
|
console.error("Camera error:", e);
|
|
|
alert("Couldn’t open the camera. You can upload a photo instead.");
|
|
|
uploadBtn?.focus();
|
|
|
}
|
|
|
});
|
|
|
|
|
|
function grabFrameCanvas() {
|
|
|
const canvas = document.createElement("canvas");
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
|
|
|
const track = stream?.getVideoTracks?.()[0];
|
|
|
const s = track?.getSettings?.() || {};
|
|
|
let w = s.width || video.videoWidth || 640;
|
|
|
let h = s.height || video.videoHeight || 480;
|
|
|
|
|
|
|
|
|
const portraitScreen = isMobile && window.innerHeight > window.innerWidth;
|
|
|
const needRotate = portraitScreen && w > h;
|
|
|
|
|
|
if (needRotate) {
|
|
|
canvas.width = h; canvas.height = w;
|
|
|
ctx.save(); ctx.translate(h, 0); ctx.rotate(Math.PI / 2);
|
|
|
ctx.drawImage(video, 0, 0, w, h); ctx.restore();
|
|
|
} else {
|
|
|
canvas.width = w; canvas.height = h;
|
|
|
ctx.drawImage(video, 0, 0, w, h);
|
|
|
}
|
|
|
return canvas;
|
|
|
}
|
|
|
|
|
|
|
|
|
async function fileToJpegDataURL(file, { maxDim = 1600, quality = 0.85 } = {}) {
|
|
|
|
|
|
let bitmap;
|
|
|
try {
|
|
|
bitmap = await createImageBitmap(file);
|
|
|
} catch {
|
|
|
|
|
|
const url = URL.createObjectURL(file);
|
|
|
try {
|
|
|
bitmap = await new Promise((resolve, reject) => {
|
|
|
const img = new Image();
|
|
|
img.onload = () => resolve(img);
|
|
|
img.onerror = reject;
|
|
|
img.src = url;
|
|
|
});
|
|
|
} finally {
|
|
|
URL.revokeObjectURL(url);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
const { width: w0, height: h0 } = bitmap;
|
|
|
const scale = Math.min(1, maxDim / Math.max(w0, h0));
|
|
|
const w = Math.round(w0 * scale);
|
|
|
const h = Math.round(h0 * scale);
|
|
|
|
|
|
const canvas = document.createElement("canvas");
|
|
|
canvas.width = w;
|
|
|
canvas.height = h;
|
|
|
const ctx = canvas.getContext("2d");
|
|
|
ctx.drawImage(bitmap, 0, 0, w, h);
|
|
|
|
|
|
|
|
|
return canvas.toDataURL("image/jpeg", quality);
|
|
|
}
|
|
|
|
|
|
|
|
|
async function postJson(url, payload) {
|
|
|
const res = await fetch(url, {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
credentials: "same-origin",
|
|
|
body: JSON.stringify(payload),
|
|
|
});
|
|
|
|
|
|
const status = res.status;
|
|
|
const text = await res.text();
|
|
|
let data;
|
|
|
try { data = JSON.parse(text); } catch { data = { error: text || res.statusText || "Unknown error" }; }
|
|
|
|
|
|
if (!res.ok) {
|
|
|
let hint = "";
|
|
|
if (status === 413) hint = " (image too large — try a smaller photo)";
|
|
|
if (status === 415) hint = " (unsupported format — JPEG should fix)";
|
|
|
if (!data.error) data.error = `HTTP ${status}${hint}`;
|
|
|
else data.error += hint;
|
|
|
}
|
|
|
return data;
|
|
|
}
|
|
|
|
|
|
async function sendImagePayload(imageData) {
|
|
|
const { city, attrs } = collectContext();
|
|
|
return postJson("/process_image", { image_data: imageData, city, attrs });
|
|
|
}
|
|
|
|
|
|
async function sendCanvas(canvas) {
|
|
|
const imageData = canvas.toDataURL("image/jpeg", 0.85);
|
|
|
return sendImagePayload(imageData);
|
|
|
}
|
|
|
|
|
|
const esc = (s) =>
|
|
|
String(s ?? "").replace(/[&<>"']/g, (m) => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]));
|
|
|
|
|
|
|
|
|
function confidenceText(data) {
|
|
|
if (data?.confidence_text) return data.confidence_text;
|
|
|
const c = Number(data?.confidence);
|
|
|
if (!Number.isNaN(c)) {
|
|
|
const pct = c <= 1 ? c * 100 : c;
|
|
|
return `${pct.toFixed(1)} % Confidence Score`;
|
|
|
}
|
|
|
return "—";
|
|
|
}
|
|
|
|
|
|
function renderResult(data) {
|
|
|
|
|
|
const material = data.material ?? data.label ?? "Unknown";
|
|
|
const action = data.action ?? "Unknown";
|
|
|
const why = data.why ?? "";
|
|
|
const tip = data.tip ?? "";
|
|
|
const abstained = !!data.abstained;
|
|
|
|
|
|
const li = document.createElement("li");
|
|
|
li.className = `result-item ${abstained ? "result-item--abstained" : ""}`;
|
|
|
li.innerHTML = `
|
|
|
<div class="result-primary">
|
|
|
<strong class="result-action">${esc(action)}</strong>
|
|
|
<span class="dot">•</span>
|
|
|
<span class="result-material">${esc(material)}</span>
|
|
|
</div>
|
|
|
<div class="result-meta">
|
|
|
<span class="result-confidence">${esc(confidenceText(data))}</span>
|
|
|
</div>
|
|
|
${why ? `<div class="result-why">${esc(why)}</div>` : ``}
|
|
|
${tip ? `<div class="result-tip">Tip: ${esc(tip)}</div>` : ``}
|
|
|
`;
|
|
|
contextList?.appendChild(li);
|
|
|
}
|
|
|
|
|
|
async function analyzeFromCamera() {
|
|
|
if (!stream) { photoInput?.click(); return; }
|
|
|
if (video.readyState < 2) {
|
|
|
await new Promise(r => video.addEventListener("loadeddata", r, { once: true }));
|
|
|
}
|
|
|
const canvas = grabFrameCanvas();
|
|
|
return sendCanvas(canvas);
|
|
|
}
|
|
|
|
|
|
|
|
|
analyzeBtn.addEventListener("click", async (e) => {
|
|
|
e.preventDefault();
|
|
|
if (inFlight) return;
|
|
|
inFlight = true;
|
|
|
const prev = analyzeBtn.textContent;
|
|
|
analyzeBtn.textContent = "Analyzing…";
|
|
|
analyzeBtn.disabled = true;
|
|
|
|
|
|
try {
|
|
|
const data = await analyzeFromCamera();
|
|
|
if (data?.error) {
|
|
|
console.error(data.error);
|
|
|
alert("Error: " + data.error);
|
|
|
} else if (data) {
|
|
|
renderResult(data);
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.error("Analyze error:", err);
|
|
|
alert("Couldn’t analyze the image.");
|
|
|
} finally {
|
|
|
inFlight = false;
|
|
|
analyzeBtn.textContent = prev;
|
|
|
analyzeBtn.disabled = false;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
|
|
|
photoInput?.addEventListener("change", async (e) => {
|
|
|
const file = e.target.files?.[0];
|
|
|
if (!file) return;
|
|
|
|
|
|
const prev = analyzeBtn.textContent;
|
|
|
analyzeBtn.textContent = "Uploading…";
|
|
|
analyzeBtn.disabled = true;
|
|
|
|
|
|
try {
|
|
|
const dataUrl = await fileToJpegDataURL(file, { maxDim: 1600, quality: 0.85 });
|
|
|
const data = await sendImagePayload(dataUrl);
|
|
|
if (data?.error) {
|
|
|
console.error(data.error);
|
|
|
alert("Error: " + data.error);
|
|
|
} else {
|
|
|
renderResult(data);
|
|
|
}
|
|
|
} catch (err) {
|
|
|
console.error("Upload processing error:", err);
|
|
|
alert("Couldn’t process the uploaded photo.");
|
|
|
} finally {
|
|
|
photoInput.value = "";
|
|
|
analyzeBtn.textContent = prev;
|
|
|
analyzeBtn.disabled = false;
|
|
|
}
|
|
|
});
|
|
|
|
|
|
uploadBtn?.addEventListener("click", () => photoInput?.click());
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async function saveClassification({ label, confidence, city }) {
|
|
|
|
|
|
const confNum = (typeof confidence === "number" ? confidence : Number(confidence));
|
|
|
const normalized = Number.isFinite(confNum) ? confNum : null;
|
|
|
|
|
|
|
|
|
const LS_KEY = "recycloai_logs";
|
|
|
function load(){ try { return JSON.parse(localStorage.getItem(LS_KEY)||"[]"); } catch { return []; } }
|
|
|
function save(arr){ localStorage.setItem(LS_KEY, JSON.stringify(arr)); }
|
|
|
try {
|
|
|
const logs = load();
|
|
|
logs.push({
|
|
|
ts: Date.now(),
|
|
|
label,
|
|
|
confidence: normalized,
|
|
|
city: city || document.getElementById("city-input")?.value || ""
|
|
|
});
|
|
|
if (logs.length > 10000) logs.splice(0, logs.length - 10000);
|
|
|
save(logs);
|
|
|
} catch (e) {
|
|
|
console.warn("local log save failed", e);
|
|
|
}
|
|
|
|
|
|
|
|
|
try {
|
|
|
await fetch("/api/logs", {
|
|
|
method: "POST",
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
credentials: "same-origin",
|
|
|
body: JSON.stringify({
|
|
|
label,
|
|
|
confidence: normalized,
|
|
|
city: city || document.getElementById("city-input")?.value || ""
|
|
|
})
|
|
|
});
|
|
|
} catch (e) {
|
|
|
console.warn("remote log save failed", e);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
(function(){
|
|
|
const details = document.querySelector('details#tips-container[data-pref]');
|
|
|
if (!details) return;
|
|
|
|
|
|
const key = 'recycloai:' + details.dataset.pref;
|
|
|
|
|
|
|
|
|
try {
|
|
|
const saved = localStorage.getItem(key);
|
|
|
if (saved === 'open') details.setAttribute('open', '');
|
|
|
if (saved === 'closed') details.removeAttribute('open');
|
|
|
} catch(e){ }
|
|
|
|
|
|
|
|
|
details.addEventListener('toggle', () => {
|
|
|
try {
|
|
|
localStorage.setItem(key, details.open ? 'open' : 'closed');
|
|
|
} catch(e){ }
|
|
|
});
|
|
|
})();
|
|
|
|