RiceBorer-CLS / index.html
csepartha's picture
Update index.html
f894548 verified
Raw
History Blame Contribute Delete
22.4 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Browser-based rice stem-borer symptom classification using a YOLO26s-cls ONNX model." />
<title>RiceBorer-CLS: Rice Stem-Borer Symptom Classification using YOLO26</title>
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/ort.min.js"></script>
<style>
:root {
--bg: #f4f9fc;
--card: #ffffff;
--primary: #176b87;
--primary-dark: #0d5067;
--light: #eaf5fa;
--border: #cfe3ec;
--text: #17323e;
--muted: #617783;
--success: #217a4a;
--warning: #9a6500;
--danger: #a83232;
--shadow: 0 10px 28px rgba(31, 78, 99, 0.11);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background: linear-gradient(180deg, #eef8fc 0%, var(--bg) 50%, #fbfdfe 100%);
line-height: 1.55;
}
a { color: var(--primary); }
.page { width: min(1160px, calc(100% - 28px)); margin: 0 auto; padding: 26px 0 42px; }
.panel {
background: var(--card);
border: 1px solid var(--border);
border-radius: 20px;
box-shadow: var(--shadow);
}
header { padding: 26px; background: linear-gradient(135deg, #fff, #eff9fd); }
.heading { display: flex; gap: 17px; align-items: center; }
.icon { width: 62px; height: 62px; display: grid; place-items: center; border-radius: 18px; background: var(--light); font-size: 34px; flex: 0 0 auto; }
h1 { margin: 0; color: var(--primary-dark); font-size: clamp(1.55rem, 4vw, 2.35rem); line-height: 1.18; }
.lead { margin: 9px 0 0; color: var(--muted); }
.developer { margin: 6px 0 0; font-size: .96rem; }
.badges { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 17px; }
.badge { display: inline-flex; align-items: center; gap: 7px; padding: 7px 11px; border: 1px solid var(--border); background: #fff; border-radius: 999px; color: var(--muted); font-size: .86rem; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: #a1adb3; }
.dot.loading { background: var(--warning); }
.dot.ready { background: var(--success); }
.dot.error { background: var(--danger); }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 22px; }
.card-title { margin: 0; padding: 16px 19px; background: var(--light); border-bottom: 1px solid var(--border); color: var(--primary-dark); font-size: 1.08rem; }
.body { padding: 19px; }
.upload {
min-height: 365px;
display: grid;
place-items: center;
padding: 18px;
border: 2px dashed #79b9d1;
border-radius: 16px;
background: #f8fcfe;
text-align: center;
cursor: pointer;
transition: .2s ease;
}
.upload:hover, .upload.dragover { border-color: var(--primary); background: #edf8fc; transform: translateY(-1px); }
#fileInput { display: none; }
#preview { display: none; max-width: 100%; max-height: 430px; object-fit: contain; border-radius: 13px; }
.upload-icon { font-size: 44px; }
.upload-title { margin: 8px 0 0; font-weight: 750; }
.help { margin: 7px 0 0; color: var(--muted); font-size: .9rem; }
.buttons { display: flex; gap: 11px; margin-top: 15px; }
button { min-height: 45px; border: 0; border-radius: 11px; padding: 10px 17px; font-weight: 750; font-size: .95rem; cursor: pointer; }
button:disabled { opacity: .55; cursor: not-allowed; }
.primary { flex: 1; color: #fff; background: var(--primary); }
.secondary { color: var(--primary-dark); background: var(--light); border: 1px solid var(--border); }
.placeholder { min-height: 180px; display: grid; place-items: center; text-align: center; color: var(--muted); padding: 20px; }
.summary, .probabilities, .interpretation { display: none; }
.summary { padding: 15px; border: 1px solid var(--border); border-radius: 14px; background: #f8fcfe; margin-bottom: 17px; }
.summary h3 { margin: 0; color: var(--primary-dark); font-size: 1.28rem; }
.summary p { margin: 6px 0 0; color: var(--muted); white-space: pre-line; }
.prob-row { margin-bottom: 15px; }
.prob-top { display: flex; justify-content: space-between; gap: 12px; margin-bottom: 6px; font-size: .93rem; }
.prob-label { font-weight: 750; }
.track { height: 13px; border-radius: 999px; background: #e4eef3; overflow: hidden; }
.bar { height: 100%; width: 0%; border-radius: inherit; background: linear-gradient(90deg, #a4d8eb, var(--primary)); transition: width .4s ease; }
.interpretation { margin-top: 16px; padding: 14px; border-left: 4px solid #74b7d0; border-radius: 8px; background: var(--light); font-size: .92rem; white-space: pre-line; }
.error { display: none; margin-top: 15px; padding: 13px; border: 1px solid #efb5b5; border-radius: 11px; background: #fff3f3; color: var(--danger); white-space: pre-wrap; }
.info-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; margin-top: 22px; }
table { width: 100%; border-collapse: collapse; font-size: .91rem; }
th, td { padding: 9px 7px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: top; }
th { width: 42%; color: var(--primary-dark); }
.notice { margin-top: 22px; padding: 17px 19px; border: 1px solid #ead39e; border-radius: 15px; background: #fffaf0; color: #614b19; }
footer { padding: 22px 10px 0; text-align: center; color: var(--muted); font-size: .87rem; }
.spinner { display: inline-block; width: 14px; height: 14px; margin-right: 8px; border: 2px solid rgba(255,255,255,.45); border-top-color: #fff; border-radius: 50%; animation: spin .8s linear infinite; vertical-align: -2px; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 850px) { .grid, .info-grid { grid-template-columns: 1fr; } .upload { min-height: 300px; } }
@media (max-width: 520px) { .page { width: calc(100% - 18px); padding-top: 10px; } header { padding: 19px; } .icon { width: 50px; height: 50px; font-size: 27px; } .body { padding: 14px; } .buttons { flex-direction: column; } }
</style>
</head>
<body>
<main class="page">
<header class="panel">
<div class="heading">
<div class="icon" aria-hidden="true">🌾</div>
<div>
<h1>RiceBorer-CLS: Rice Stem-Borer Symptom Classification using YOLO26</h1>
<p class="lead">This research prototype classifies uploaded rice-plant images into <strong>Healthy</strong>, <strong>Dead Heart</strong>, or <strong>White Head</strong> using the trained <strong>YOLO26s-cls Ultralytics image-classification model exported to ONNX</strong>. Developed by <strong>Partha Pratim Ray, Sikkim University</strong>, on <strong>31 July 2026</strong>.</p>
</div>
</div>
<div class="badges">
<span class="badge"><span id="statusDot" class="dot loading"></span><span id="statusText">Loading model…</span></span>
<span class="badge">Input: 320 × 320</span>
<span class="badge">Execution: Browser/WASM</span>
<span class="badge">Images remain on your device</span>
</div>
</header>
<section class="grid">
<article class="panel">
<h2 class="card-title">1. Upload a rice-plant image</h2>
<div class="body">
<label id="dropZone" class="upload" for="fileInput">
<div id="prompt">
<div class="upload-icon">🖼️</div>
<p class="upload-title">Click or drag an image here</p>
<p class="help">Use a clear JPG, JPEG, PNG, WEBP, or other browser-supported image.</p>
</div>
<img id="preview" alt="Uploaded rice-plant image preview" />
</label>
<input id="fileInput" type="file" accept="image/*" />
<div class="buttons">
<button id="classifyBtn" class="primary" type="button" disabled>Classify Image</button>
<button id="clearBtn" class="secondary" type="button">Clear</button>
</div>
<div id="errorBox" class="error" role="alert"></div>
</div>
</article>
<article class="panel">
<h2 class="card-title">2. Classification result</h2>
<div class="body">
<div id="placeholder" class="placeholder">Upload an image and select <strong>Classify Image</strong>.</div>
<section id="summary" class="summary">
<h3 id="predictedClass"></h3>
<p id="summaryText"></p>
</section>
<section id="probabilities" class="probabilities" aria-label="Class probabilities"></section>
<section id="interpretation" class="interpretation"></section>
</div>
</article>
</section>
<section class="info-grid">
<article class="panel">
<h2 class="card-title">Model information</h2>
<div class="body">
<table>
<tr><th>Model</th><td>YOLO26s-cls</td></tr>
<tr><th>Format</th><td>ONNX, dynamic export, opset 17</td></tr>
<tr><th>Task</th><td>Three-class image classification</td></tr>
<tr><th>Classes</th><td>Healthy, Dead Heart, White Head</td></tr>
<tr><th>Input size</th><td>320 × 320 pixels</td></tr>
<tr><th>Runtime</th><td>ONNX Runtime Web with WebAssembly</td></tr>
</table>
</div>
</article>
<article class="panel">
<h2 class="card-title">Dataset information</h2>
<div class="body">
<table>
<tr><th>Dataset</th><td>Symptom-Labeled Image Dataset of Rice Plants for Stem Borer Infestation Classification</td></tr>
<tr><th>Authors</th><td>Chonchal Khan and Md Assaduzzaman</td></tr>
<tr><th>Original images</th><td>2,096</td></tr>
<tr><th>Distribution</th><td>Healthy 1,106; Dead Heart 439; White Head 551</td></tr>
<tr><th>DOI</th><td><a href="https://doi.org/10.17632/hnfjs42d5g.1" target="_blank" rel="noopener">10.17632/hnfjs42d5g.1</a></td></tr>
<tr><th>Licence</th><td>CC BY 4.0</td></tr>
</table>
</div>
</article>
</section>
<aside class="notice"><strong>Important limitation:</strong> This application classifies visible image symptoms only. It does not directly detect an insect or establish the causal organism. It must not be used independently for pesticide selection, pesticide dosage, or other crop-management decisions.</aside>
<footer>
RiceBorer-CLS · Partha Pratim Ray · Sikkim University · 31 July 2026<br />
<a href="https://github.com/ParthaPRay/RiceBorer-CLS" target="_blank" rel="noopener">GitHub repository</a> ·
<a href="https://data.mendeley.com/datasets/hnfjs42d5g/1" target="_blank" rel="noopener">Source dataset</a>
</footer>
</main>
<canvas id="canvas" width="320" height="320" hidden></canvas>
<script>
"use strict";
const MODEL_URL = "./YOLO26s-cls_rice_stem_borer.onnx";
const IMAGE_SIZE = 320;
// Ultralytics ClassificationDataset uses alphabetical ImageFolder indexing.
// Dataset folders: Dead_Heart, Healthy, White_Head.
const CLASS_NAMES = ["Dead Heart", "Healthy", "White Head"];
const INTERPRETATIONS = {
"Healthy": "The image shows visual characteristics most consistent with the Healthy class represented in the training dataset.",
"Dead Heart": "The image shows visual characteristics most consistent with the Dead Heart symptom class, which is associated with stem-borer damage during the vegetative stage.",
"White Head": "The image shows visual characteristics most consistent with the White Head symptom class, which is associated with stem-borer damage during the reproductive stage."
};
const el = id => document.getElementById(id);
const fileInput = el("fileInput");
const dropZone = el("dropZone");
const prompt = el("prompt");
const preview = el("preview");
const classifyBtn = el("classifyBtn");
const clearBtn = el("clearBtn");
const statusDot = el("statusDot");
const statusText = el("statusText");
const placeholder = el("placeholder");
const summary = el("summary");
const predictedClass = el("predictedClass");
const summaryText = el("summaryText");
const probabilities = el("probabilities");
const interpretation = el("interpretation");
const errorBox = el("errorBox");
const canvas = el("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true });
let session = null;
let selectedImage = null;
let objectUrl = null;
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/";
ort.env.wasm.numThreads = Math.min(navigator.hardwareConcurrency || 1, 4);
function setStatus(state, message) {
statusDot.className = `dot ${state}`;
statusText.textContent = message;
}
function showError(message) {
errorBox.textContent = message;
errorBox.style.display = "block";
}
function hideError() {
errorBox.textContent = "";
errorBox.style.display = "none";
}
function formatError(error) {
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
}
async function loadModel() {
setStatus("loading", "Loading ONNX model…");
try {
session = await ort.InferenceSession.create(MODEL_URL, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all"
});
console.log("Input names:", session.inputNames);
console.log("Output names:", session.outputNames);
console.log("Input metadata:", session.inputMetadata);
console.log("Output metadata:", session.outputMetadata);
setStatus("ready", "Model ready");
updateButton();
} catch (error) {
console.error(error);
setStatus("error", "Model failed to load");
showError(`The ONNX model could not be loaded.\n\n${formatError(error)}\n\nConfirm that YOLO26s-cls_rice_stem_borer.onnx is in the root of the Space.`);
}
}
function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error("Browser image decoding failed."));
image.src = src;
});
}
async function acceptFile(file) {
hideError();
resetResults();
if (!file || !file.type.startsWith("image/")) {
showError("Please choose a valid image file.");
return;
}
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = URL.createObjectURL(file);
try {
selectedImage = await loadImage(objectUrl);
preview.src = objectUrl;
preview.style.display = "block";
prompt.style.display = "none";
updateButton();
} catch (error) {
selectedImage = null;
showError(formatError(error));
updateButton();
}
}
fileInput.addEventListener("change", event => acceptFile(event.target.files?.[0]));
dropZone.addEventListener("dragover", event => { event.preventDefault(); dropZone.classList.add("dragover"); });
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("dragover"));
dropZone.addEventListener("drop", event => {
event.preventDefault();
dropZone.classList.remove("dragover");
acceptFile(event.dataTransfer.files?.[0]);
});
function preprocess(image) {
const width = image.naturalWidth || image.width;
const height = image.naturalHeight || image.height;
if (!width || !height) throw new Error("The uploaded image has invalid dimensions.");
// Equivalent to Ultralytics validation/inference Resize + CenterCrop for crop_fraction=1.0:
// resize the shorter side to 320, then take a 320 × 320 centre crop.
const scale = IMAGE_SIZE / Math.min(width, height);
const resizedWidth = width * scale;
const resizedHeight = height * scale;
const offsetX = (IMAGE_SIZE - resizedWidth) / 2;
const offsetY = (IMAGE_SIZE - resizedHeight) / 2;
ctx.clearRect(0, 0, IMAGE_SIZE, IMAGE_SIZE);
ctx.drawImage(image, offsetX, offsetY, resizedWidth, resizedHeight);
const rgba = ctx.getImageData(0, 0, IMAGE_SIZE, IMAGE_SIZE).data;
const area = IMAGE_SIZE * IMAGE_SIZE;
const chw = new Float32Array(3 * area);
// Ultralytics classify inference uses ToTensor and identity normalization (mean=0, std=1).
// Therefore the ONNX input is RGB in [0,1], not ImageNet mean/std normalization.
for (let i = 0; i < area; i++) {
const j = i * 4;
chw[i] = rgba[j] / 255.0;
chw[area + i] = rgba[j + 1] / 255.0;
chw[2 * area + i] = rgba[j + 2] / 255.0;
}
return new ort.Tensor("float32", chw, [1, 3, IMAGE_SIZE, IMAGE_SIZE]);
}
function toProbabilities(values) {
const clean = values.map(Number);
if (clean.some(value => !Number.isFinite(value))) throw new Error("Model output contains non-finite values.");
const sum = clean.reduce((a, b) => a + b, 0);
const isProbabilityVector = clean.every(v => v >= 0 && v <= 1) && Math.abs(sum - 1) < 0.01;
if (isProbabilityVector) return clean.map(v => v / sum);
const maxValue = Math.max(...clean);
const exp = clean.map(v => Math.exp(v - maxValue));
const denominator = exp.reduce((a, b) => a + b, 0);
return exp.map(v => v / denominator);
}
function confidenceCategory(value) {
if (value >= 0.85) return "High confidence";
if (value >= 0.70) return "Moderate confidence";
return "Low confidence";
}
function referralMessage(value) {
if (value >= 0.85) return "The model produced a comparatively confident visual classification. Field verification is nevertheless recommended.";
if (value >= 0.70) return "The model produced a moderately confident result. Careful review and confirmation by an agricultural expert are recommended.";
return "The prediction is uncertain. Expert examination is recommended because the image may be unclear, may show overlapping symptoms, or may represent a condition outside the trained classes.";
}
function renderResults(probs, milliseconds) {
const records = probs.map((probability, index) => ({ className: CLASS_NAMES[index], probability })).sort((a, b) => b.probability - a.probability);
const best = records[0];
predictedClass.textContent = `Predicted class: ${best.className}`;
summaryText.textContent = `Prediction confidence: ${best.probability.toFixed(4)} (${(best.probability * 100).toFixed(2)}%)\nConfidence category: ${confidenceCategory(best.probability)}\nModel: YOLO26s-cls (ONNX)\nBrowser inference time: ${milliseconds.toFixed(1)} ms`;
probabilities.innerHTML = "";
for (const record of records) {
const row = document.createElement("div");
row.className = "prob-row";
row.innerHTML = `<div class="prob-top"><span class="prob-label">${record.className}</span><span>${(record.probability * 100).toFixed(2)}%</span></div><div class="track"><div class="bar"></div></div>`;
probabilities.appendChild(row);
requestAnimationFrame(() => { row.querySelector(".bar").style.width = `${Math.max(0, Math.min(100, record.probability * 100))}%`; });
}
interpretation.textContent = `${INTERPRETATIONS[best.className]}\n\n${referralMessage(best.probability)}\n\nThis research prototype classifies visible image symptoms only and must not be used independently for pesticide selection or dosage.`;
placeholder.style.display = "none";
summary.style.display = "block";
probabilities.style.display = "block";
interpretation.style.display = "block";
}
async function classify() {
hideError();
if (!session) return showError("The model is not ready.");
if (!selectedImage) return showError("Please upload an image first.");
setBusy(true);
try {
const start = performance.now();
const tensor = preprocess(selectedImage);
const inputName = session.inputNames[0];
const outputName = session.outputNames[0];
const outputs = await session.run({ [inputName]: tensor });
const output = outputs[outputName];
if (!output) throw new Error("The model returned no output tensor.");
const raw = Array.from(output.data);
if (raw.length !== CLASS_NAMES.length) throw new Error(`Expected ${CLASS_NAMES.length} class outputs but received ${raw.length}.`);
renderResults(toProbabilities(raw), performance.now() - start);
} catch (error) {
console.error(error);
showError(`Prediction could not be completed.\n\n${formatError(error)}\n\nOpen the browser console for ONNX input/output diagnostics.`);
} finally {
setBusy(false);
}
}
function updateButton() { classifyBtn.disabled = !session || !selectedImage; }
function setBusy(value) {
classifyBtn.disabled = value;
clearBtn.disabled = value;
classifyBtn.innerHTML = value ? '<span class="spinner"></span>Classifying…' : "Classify Image";
if (!value) updateButton();
}
function resetResults() {
placeholder.style.display = "grid";
placeholder.innerHTML = 'Upload an image and select <strong>Classify Image</strong>.';
summary.style.display = "none";
probabilities.style.display = "none";
interpretation.style.display = "none";
probabilities.innerHTML = "";
}
function clearAll() {
fileInput.value = "";
selectedImage = null;
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = null;
preview.removeAttribute("src");
preview.style.display = "none";
prompt.style.display = "block";
hideError();
resetResults();
updateButton();
}
classifyBtn.addEventListener("click", classify);
clearBtn.addEventListener("click", clearAll);
resetResults();
loadModel();
</script>
</body>
</html>