Spaces:
Running
Running
| <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> |