plantcounter / index.html
dimensionalpulsar's picture
Update index.html
8dca267 verified
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AgriVision - Panel de Análisis IA</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- React & ReactDOM (Standalone) -->
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
<!-- Babel para procesar JSX en el navegador -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
</head>
<body class="bg-gray-50 text-gray-800 font-sans">
<div id="root"></div>
<script type="text/babel">
const { useState, useRef, useEffect, useMemo } = React;
// --- Íconos en formato SVG para entorno sin Bundler ---
const IconBase = ({ children, size = 24, className = "" }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
{children}
</svg>
);
const Activity = (p) => <IconBase {...p}><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></IconBase>;
const Upload = (p) => <IconBase {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></IconBase>;
const Move = (p) => <IconBase {...p}><polyline points="5 9 2 12 5 15"></polyline><polyline points="9 5 12 2 15 5"></polyline><polyline points="19 9 22 12 19 15"></polyline><polyline points="9 19 12 22 15 19"></polyline><line x1="2" y1="12" x2="22" y2="12"></line><line x1="12" y1="2" x2="12" y2="22"></line></IconBase>;
const Target = (p) => <IconBase {...p}><circle cx="12" cy="12" r="10"></circle><circle cx="12" cy="12" r="6"></circle><circle cx="12" cy="12" r="2"></circle></IconBase>;
const BarChart3 = (p) => <IconBase {...p}><line x1="18" y1="20" x2="18" y2="10"></line><line x1="12" y1="20" x2="12" y2="4"></line><line x1="6" y1="20" x2="6" y2="14"></line></IconBase>;
const AlertCircle = (p) => <IconBase {...p}><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="8" x2="12" y2="12"></line><line x1="12" y1="16" x2="12.01" y2="16"></line></IconBase>;
// --- Lógica Principal ---
const getSimulatedConfidence = (x, y) => {
// Zona 1: El cielo y las montañas
if (y < 35) return Math.random() * 0.2;
// Zona 2: Áreas donde están los trabajadores
if (x > 30 && x < 70 && y > 40 && y < 65) return Math.random() * 0.35;
// Zonas normales y fértiles del lote
return 0.65 + Math.random() * 0.34;
};
function App() {
const [imageSrc, setImageSrc] = useState("https://huggingface.co/spaces/dimensionalpulsar/plantcounter/resolve/main/demo.jpg");
const fileInputRef = useRef(null);
const containerRef = useRef(null);
const [boxes, setBoxes] = useState([
{ id: 1, x: 20, y: 30, w: 12, h: 18 },
{ id: 2, x: 45, y: 50, w: 14, h: 22 },
{ id: 3, x: 70, y: 40, w: 10, h: 15 },
{ id: 4, x: 30, y: 65, w: 15, h: 20 },
{ id: 5, x: 60, y: 75, w: 12, h: 16 },
{ id: 6, x: 80, y: 60, w: 11, h: 17 },
].map(b => ({ ...b, conf: getSimulatedConfidence(b.x + b.w / 2, b.y + b.h / 2) })));
const [draggingId, setDraggingId] = useState(null);
const handlePointerDown = (e, id) => {
e.stopPropagation();
e.target.setPointerCapture(e.pointerId);
setDraggingId(id);
};
const handlePointerMove = (e) => {
if (!draggingId || !containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
let xPercent = ((e.clientX - rect.left) / rect.width) * 100;
let yPercent = ((e.clientY - rect.top) / rect.height) * 100;
setBoxes(prev => prev.map(box => {
if (box.id === draggingId) {
const newX = Math.max(0, Math.min(xPercent - box.w / 2, 100 - box.w));
const newY = Math.max(0, Math.min(yPercent - box.h / 2, 100 - box.h));
const newConf = getSimulatedConfidence(newX + box.w / 2, newY + box.h / 2);
return { ...box, x: newX, y: newY, conf: newConf };
}
return box;
}));
};
const handlePointerUp = (e) => {
if (draggingId) {
e.target.releasePointerCapture(e.pointerId);
setDraggingId(null);
}
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
const imageUrl = URL.createObjectURL(file);
setImageSrc(imageUrl);
}
};
const stats = useMemo(() => {
let confSum = 0;
let validCount = 0;
const quadrants = { q1: 0, q2: 0, q3: 0, q4: 0 };
boxes.forEach(box => {
if (box.conf >= 0.5) {
confSum += box.conf;
validCount++;
const centerX = box.x + box.w / 2;
const centerY = box.y + box.h / 2;
if (centerX <= 50 && centerY <= 50) quadrants.q1++;
else if (centerX > 50 && centerY <= 50) quadrants.q2++;
else if (centerX <= 50 && centerY > 50) quadrants.q3++;
else quadrants.q4++;
}
});
return {
total: validCount,
avgConf: validCount ? (confSum / validCount * 100).toFixed(1) : 0,
quadrants
};
}, [boxes]);
return (
<div className="min-h-screen p-4 md:p-8">
<div className="max-w-6xl mx-auto space-y-6">
<div className="flex flex-col md:flex-row justify-between items-start md:items-center bg-white p-6 rounded-2xl shadow-sm border border-gray-100 gap-4">
<div>
<h1 className="text-2xl font-bold text-emerald-700 flex items-center gap-2">
<Activity size={28} /> AI Model Editor
</h1>
<p className="text-sm text-gray-500 mt-1">Mueve los recuadros verdes para ver cómo se actualizan los datos en tiempo real.</p>
</div>
<button
onClick={() => fileInputRef.current?.click()}
className="bg-emerald-600 hover:bg-emerald-700 text-white px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 transition-colors shadow-sm w-full md:w-auto justify-center"
>
<Upload size={18} /> Subir tu foto
</button>
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-100 flex flex-col">
<div className="flex justify-between items-center mb-4 px-2">
<h2 className="font-semibold text-gray-700 flex items-center gap-2">
<Target size={18} className="text-emerald-500" /> Área de Detección
</h2>
<span className="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full flex items-center gap-1">
<Move size={12} /> Arrastrable
</span>
</div>
<div
ref={containerRef}
className="relative w-full aspect-video bg-gray-200 rounded-xl overflow-hidden cursor-crosshair border border-gray-200 touch-none"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
>
<img
src={imageSrc}
alt="Cultivo"
className="w-full h-full object-cover pointer-events-none select-none"
/>
<div className="absolute inset-0 pointer-events-none flex">
<div className="w-1/2 h-full border-r border-white/20 border-dashed"></div>
<div className="w-full h-1/2 absolute top-1/2 border-t border-white/20 border-dashed"></div>
</div>
{boxes.map((box) => {
const isValid = box.conf >= 0.5;
return (
<div
key={box.id}
onPointerDown={(e) => handlePointerDown(e, box.id)}
style={{
left: `${box.x}%`,
top: `${box.y}%`,
width: `${box.w}%`,
height: `${box.h}%`,
}}
className={`absolute border-2 rounded-sm cursor-grab active:cursor-grabbing transition-colors duration-150 flex items-start justify-start group
${draggingId === box.id
? 'border-yellow-400 bg-yellow-400/20 z-10 scale-105'
: isValid
? 'border-emerald-500 bg-emerald-500/20 hover:bg-emerald-500/40'
: 'border-red-400 bg-red-400/10 hover:bg-red-400/30'}
`}
>
<div className={`absolute -top-5 left-[-2px] text-[10px] font-bold px-1.5 py-0.5 rounded-t-sm whitespace-nowrap text-white transition-colors
${draggingId === box.id
? 'bg-yellow-400 text-yellow-900'
: isValid
? 'bg-emerald-500'
: 'bg-red-500'}
`}>
{isValid ? `Arroz ${(box.conf * 100).toFixed(0)}%` : 'Baja Saturación'}
</div>
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Move size={16} className={draggingId === box.id ? 'text-yellow-400' : isValid ? 'text-emerald-300' : 'text-red-300'} />
</div>
</div>
);
})}
</div>
</div>
<div className="flex flex-col gap-6">
<div className="grid grid-cols-2 gap-4">
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100">
<p className="text-sm font-medium text-gray-500 mb-1">Plantas Detectadas</p>
<p className="text-4xl font-bold text-gray-800">{stats.total}</p>
<p className="text-xs text-emerald-600 mt-2 flex items-center gap-1 font-medium">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
Conteo activo
</p>
</div>
<div className="bg-white p-5 rounded-2xl shadow-sm border border-gray-100">
<p className="text-sm font-medium text-gray-500 mb-1">Confianza Media</p>
<p className="text-4xl font-bold text-blue-600">{stats.avgConf}%</p>
<p className="text-xs text-gray-400 mt-2">Precisión del modelo YOLO</p>
</div>
</div>
<div className="bg-white p-6 rounded-2xl shadow-sm border border-gray-100 flex-1">
<div className="flex items-center gap-2 mb-6">
<BarChart3 size={20} className="text-gray-400" />
<h3 className="font-semibold text-gray-700">Distribución Espacial</h3>
</div>
<div className="grid grid-cols-2 gap-6 h-48">
<div className="grid grid-cols-2 grid-rows-2 gap-2 p-2 bg-gray-50 rounded-xl border border-gray-100">
{[
{ id: 'q1', label: 'Sup. Izq', count: stats.quadrants.q1 },
{ id: 'q2', label: 'Sup. Der', count: stats.quadrants.q2 },
{ id: 'q3', label: 'Inf. Izq', count: stats.quadrants.q3 },
{ id: 'q4', label: 'Inf. Der', count: stats.quadrants.q4 }
].map(quad => {
const max = Math.max(...Object.values(stats.quadrants), 1);
const opacity = quad.count > 0 ? 0.2 + (quad.count / max) * 0.8 : 0.05;
return (
<div key={quad.id} className="relative flex flex-col items-center justify-center rounded-lg border border-emerald-100 transition-all duration-300" style={{ backgroundColor: `rgba(16, 185, 129, ${opacity})` }}>
<span className="text-xs font-medium text-gray-600 bg-white/80 px-1.5 rounded">{quad.label}</span>
<span className="text-xl font-bold text-emerald-900 drop-shadow-sm">{quad.count}</span>
</div>
)
})}
</div>
<div className="flex flex-col justify-end gap-3 h-full pb-2">
{[
{ label: 'Q1', value: stats.quadrants.q1 },
{ label: 'Q2', value: stats.quadrants.q2 },
{ label: 'Q3', value: stats.quadrants.q3 },
{ label: 'Q4', value: stats.quadrants.q4 },
].map(item => {
const max = Math.max(...Object.values(stats.quadrants), 1);
const heightPercent = max === 1 && item.value === 0 ? 0 : (item.value / max) * 100;
return (
<div key={item.label} className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-500 w-4">{item.label}</span>
<div className="flex-1 h-4 bg-gray-100 rounded-full overflow-hidden">
<div
className="h-full bg-blue-500 rounded-full transition-all duration-300 ease-out"
style={{ width: `${heightPercent}%` }}
></div>
</div>
<span className="text-xs font-bold text-gray-700 w-4">{item.value}</span>
</div>
)
})}
</div>
</div>
<div className="mt-6 bg-amber-50 p-3 rounded-lg flex gap-3 border border-amber-100">
<AlertCircle size={18} className="text-amber-600 shrink-0 mt-0.5" />
<p className="text-xs text-amber-800 leading-relaxed">
<strong>Análisis Espacial Dinámico:</strong> Al mover las cajas sobre áreas con claros (como la franja diagonal o la esquina superior derecha), el modelo detectará <strong>"Baja Saturación"</strong>, ocultará el porcentaje y excluirá ese sector de las métricas de densidad automáticamente.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>