domenica 12 ottobre 2025

Bike fitting con Movenet

Un test per usare Movenet per Bike Fitting

Il programma calcola in tempo reale l'angolo del ginocchio e del gomito sinistro 

Si apra da telefono l'indirizzo https://c1p81.altervista.org/movenet/ 

inquadrandosi da sinistra  

 

In realta' non tutto luccica come sembra...se si toglie la bici dai rulli si vede che il modello non riesce a stare dietro al flusso video
 


 e si' ...ho provato con dei tag fisici per vedere se qualita' era migliore...in particolare ho usato degli Apriltag da 3 cm di lato ma la camera non riusciva a risolverli ( e dimensioni piu' grandi sono da escludere a priori)

 

<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bike Fitting Live con MoveNet</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow/tfjs"></script>
<script src="https://cdn.jsdelivr.net/npm/@tensorflow-models/pose-detection"></script>
<style>
/* Stile di base per il canvas e il video */
.video-container {
position: relative;
width: 100%;
/* Modificato: Ridotta la dimensione massima del contenitore video/canvas */
max-width: 600px;
margin: 0 auto;
border-radius: 1rem;
overflow: hidden;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
background-color: #1f2937; /* Dark background */
}
video, canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
/* MANTENUTA la rotazione per la fotocamera posteriore capovolta */
/*transform: rotate(180deg);*/
}
video {
opacity: 0;
}
#status-message {
min-height: 2rem;
}

/* Colori per i feedback */
.text-feedback-ok { color: #84cc16; } /* Lime 500 */
.text-feedback-eretto { color: #f97316; } /* Orange 500 */
.text-feedback-aggressivo { color: #ef4444; } /* Red 500 */
.text-feedback-min { color: #f97316; }
.text-feedback-max { color: #ef4444; }
.text-feedback-default { color: #facc15; } /* Yellow 400 */
.text-feedback-off { color: #9ca3af; } /* Gray 400 */
/* Logica di avviso per l'orientamento */
@media screen and (orientation: landscape) {
body > *:not(#orientation-warning) {
display: none !important;
}
#orientation-warning {
display: flex !important;
}
}
@media screen and (orientation: portrait) {
#orientation-warning {
display: none !important;
}
}
</style>
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100 font-sans p-4">

<div class="max-w-2xl mx-auto">
<!--<h1 class="text-4xl font-extrabold text-center mb-2 text-indigo-600 dark:text-indigo-400">
My Bike Fitting (Live Analysis)
</h1>-->

<div id="video-canvas-wrapper" class="video-container aspect-video mb-4">
<video id="webcam-video" autoplay playsinline muted></video>
<canvas id="skeleton-canvas"></canvas>
<div id="loading-overlay" class="absolute inset-0 flex flex-col items-center justify-center bg-gray-900 bg-opacity-90 transition-opacity duration-300">
<div class="animate-spin rounded-full h-12 w-12 border-4 border-t-4 border-indigo-500 border-t-transparent mb-3"></div>
<p class="text-white font-medium text-lg" id="loading-text">Caricamento del modello MoveNet...</p>
</div>
</div>

<div id="results-container" class="bg-white dark:bg-gray-800 p-4 rounded-lg shadow-xl grid grid-cols-2 gap-4 text-center">
<p class="col-span-2 text-gray-500">In attesa dei dati di rilevamento...</p>
</div>



<div id="target-values-container" class="bg-indigo-100 dark:bg-indigo-900 p-4 rounded-lg shadow-md">
<h3 class="text-lg font-bold text-indigo-700 dark:text-indigo-300 mb-2">Valori Target (Road/Endurance)</h3>
<div id="target-values-list" class="grid grid-cols-2 gap-2 text-sm"></div>
</div>

<div id="status-message" class="text-center bg-white dark:bg-gray-800 p-3 rounded-lg shadow-md text-sm transition-all duration-300 mt-4">
In attesa dell'accesso alla fotocamera...
</div>

</div>

<div id="orientation-warning" class="hidden fixed inset-0 bg-red-900 bg-opacity-90 z-50 flex items-center justify-center p-8 text-center">
<p class="text-white text-2xl font-bold">
Per favore, ruota il telefono in **modalità verticale (Portrait)** per avviare l'analisi Bike Fitting.
</p>
</div>

<button id="stop-analysis-btn"
class="bg-red-600 hover:bg-red-700 text-white font-bold py-3 px-6 rounded-lg shadow-md transition-colors duration-200 text-sm hidden"
onclick="endSessionAndPrintResults()">
STOP & RIEPILOGO
</button>
</div>

<div id="final-results-container" class="hidden mt-4 bg-white dark:bg-gray-800 p-6 rounded-lg shadow-2xl">
<h3 class="text-xl font-bold text-center mb-4 text-indigo-600 dark:text-indigo-400">Riepilogo Sessione di Bike Fitting</h3>
</div>


<script>
// Elementi DOM
const video = document.getElementById('webcam-video');
const canvas = document.getElementById('skeleton-canvas');
const ctx = canvas.getContext('2d');
const statusMessage = document.getElementById('status-message');
const loadingOverlay = document.getElementById('loading-overlay');
const loadingText = document.getElementById('loading-text');
const resultsContainer = document.getElementById('results-container');

let detector;
let animationFrameId;

// Configurazione del modello
const modelConfig = {
modelType: poseDetection.movenet.modelType.SINGLEPOSE_LIGHTNING
};
const DRAW_CONFIG = {
KEYPOINT_RADIUS: 5,
KEYPOINT_COLOR: 'rgb(255, 100, 100)',
SKELETON_COLOR: 'rgb(100, 255, 100)',
SKELETON_LINE_WIDTH: 3,
MIN_SCORE: 0.3
};
const connections = [
[0, 1], [0, 2], [1, 3], [2, 4],
[5, 6], [5, 11], [6, 12],
[11, 12], [11, 13], [13, 15],
[12, 14], [14, 16],
[5, 7], [7, 9],
[6, 8], [8, 10]
];

// *** CONFIGURAZIONE ANGOLI: SOLO LATO SINISTRO + TORSO ***
const TORSO_ANALYSIS = {
name: "TORSO (Orizz.)",
keypoints: ["left_shoulder", "left_hip"],
targetRange: [40, 55]
};
const BIKE_FITTING_ANGLES = [
{ name: "GINOCCHIO SX", keypoints: ["left_hip", "left_knee", "left_ankle"], targetRange: [30, 40] },
{ name: "GOMITO SX", keypoints: ["left_shoulder", "left_elbow", "left_wrist"], targetRange: [160, 180] },
];

const sessionStats = {
'GINOCCHIO SX': { min: Infinity, max: -Infinity },
'GOMITO SX': { min: Infinity, max: -Infinity },
'TORSO (Orizz.)': { min: Infinity, max: -Infinity }
};

const targetValuesList = document.getElementById('target-values-list');

function displayTargetValues() {
let targetHtml = '';
// Aggiunge Ginocchio SX e Gomito SX
BIKE_FITTING_ANGLES.forEach(angleDef => {
const [min, max] = angleDef.targetRange;
targetHtml += `
<div class="p-1">
<p class="font-semibold text-gray-800 dark:text-gray-200">${angleDef.name}</p>
<p class="text-xs text-indigo-600 dark:text-indigo-400">Min: ${min}° / Max: ${max}°</p>
</div>
`;
});
// Aggiunge Torso (che occupa due colonne)
const [minT, maxT] = TORSO_ANALYSIS.targetRange;
targetHtml += `
<div class="col-span-2 p-1">
<p class="font-semibold text-gray-800 dark:text-gray-200">${TORSO_ANALYSIS.name}</p>
<p class="text-xs text-indigo-600 dark:text-indigo-400">Min: ${minT}° / Max: ${maxT}°</p>
</div>
`;

targetValuesList.innerHTML = targetHtml;
}


function calculateHorizontalAngle(p1, p2) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
// Calcola l'angolo assoluto con l'orizzontale
let angle = Math.abs(Math.atan2(p1.y - p2.y, p2.x - p1.x) * (180 / Math.PI));
// Se l'angolo è maggiore di 90 gradi, usa il complemento a 180 per l'angolo interno
if (angle > 90) angle = 180 - angle;
return angle;
}
function calculateAngle(pA, pB, pC) {
const Bx = pB.x, By = pB.y;
const BAvx = pA.x - Bx, BAvy = pA.y - By;
const BCvx = pC.x - Bx, BCvy = pC.y - By;

const dotProduct = BAvx * BCvx + BAvy * BCvy;
const magnitudeBA = Math.sqrt(BAvx * BAvx + BAvy * BAvy);
const magnitudeBC = Math.sqrt(BCvx * BCvx + BCvy * BCvy);

if (magnitudeBA === 0 || magnitudeBC === 0) return 0;

let angleRad = Math.acos(dotProduct / (magnitudeBA * magnitudeBC));
return angleRad * (180 / Math.PI);
}

/**
* Tenta di bloccare l'orientamento dello schermo in modalità "portrait".
*/
function lockScreenOrientation() {
if (screen.orientation && screen.orientation.lock) {
screen.orientation.lock('portrait')
.then(() => console.log("Orientamento bloccato in Portrait."))
.catch((error) => console.warn("Impossibile bloccare l'orientamento:", error));
}
}


async function loadModel() {
try {
loadingText.textContent = "Caricamento del modello MoveNet...";
detector = await poseDetection.createDetector(poseDetection.SupportedModels.MoveNet, modelConfig);
statusMessage.textContent = "Modello MoveNet caricato con successo.";
loadingText.textContent = "Modello caricato. Avvio telecamera...";
} catch (error) {
console.error("Errore nel caricamento del modello:", error);
statusMessage.textContent = `ERRORE: Impossibile caricare il modello. ${error.message}`;
}
}

async function setupWebcam() {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
'video': {
facingMode: 'environment',
width: 640,
height: 480
}
});
video.srcObject = stream;
return new Promise((resolve) => {
video.onloadedmetadata = () => resolve(video);
});
} catch (error) {
console.error("Errore nell'accesso alla telecamera:", error);
statusMessage.textContent = "ERRORE: Accesso alla telecamera negato.";
loadingOverlay.style.opacity = 0;
loadingOverlay.style.pointerEvents = 'none';
return null;
}
} else {
statusMessage.textContent = "ERRORE: La tua piattaforma non supporta l'API getUserMedia.";
return null;
}
}

async function detectionLoop() {
if (!detector) return;
const poses = await detector.estimatePoses(video, { flipHorizontal: false });

const videoWidth = video.videoWidth;
const videoHeight = video.videoHeight;
canvas.width = videoWidth;
canvas.height = videoHeight;
ctx.clearRect(0, 0, videoWidth, videoHeight);
if (poses && poses.length > 0) {
drawSkeleton(poses[0]);
}

animationFrameId = requestAnimationFrame(detectionLoop);
}

/**
* Funzione per disegnare keypoints, connessioni scheletriche e gestire la visualizzazione ESTERNA
*/
function drawSkeleton(pose) {
const keypoints = pose.keypoints;
const findKeypoint = (name) => keypoints.find(k => k.name === name);
let htmlOutput = '';
// 1. Disegna scheletro e keypoints sul canvas
// Disegna le connessioni (scheletro)
ctx.strokeStyle = DRAW_CONFIG.SKELETON_COLOR;
ctx.lineWidth = DRAW_CONFIG.SKELETON_LINE_WIDTH;

for (let i = 0; i < connections.length; i++) {
const [p1Index, p2Index] = connections[i];
const p1 = keypoints[p1Index];
const p2 = keypoints[p2Index];

if (p1.score >= DRAW_CONFIG.MIN_SCORE && p2.score >= DRAW_CONFIG.MIN_SCORE) {
ctx.beginPath();
ctx.moveTo(p1.x, p1.y);
ctx.lineTo(p2.x, p2.y);
ctx.stroke();
}
}

// Disegna i keypoints (punti giunture)
ctx.fillStyle = DRAW_CONFIG.KEYPOINT_COLOR;

for (let i = 0; i < keypoints.length; i++) {
const keypoint = keypoints[i];
if (keypoint.score >= DRAW_CONFIG.MIN_SCORE) {
ctx.beginPath();
ctx.arc(keypoint.x, keypoint.y, DRAW_CONFIG.KEYPOINT_RADIUS, 0, 2 * Math.PI);
ctx.fill();
}
}

// --- LOGICA DI CALCOLO E VISUALIZZAZIONE DATI ESTERNA ---
const detectedVertices = [];

// 2. Calcola e Visualizza gli Angoli di Bike Fitting (Ginocchio SX / Gomito SX)
BIKE_FITTING_ANGLES.forEach(angleDef => {
const [nameA, nameB, nameC] = angleDef.keypoints;
const pA = findKeypoint(nameA);
const pB = findKeypoint(nameB); // Vertice
const pC = findKeypoint(nameC);
let textColorClass = 'text-feedback-off';
let angleText = 'Non Rilevato';
let currentAngle = null;
if (pA && pB && pC &&
pA.score >= DRAW_CONFIG.MIN_SCORE &&
pB.score >= DRAW_CONFIG.MIN_SCORE &&
pC.score >= DRAW_CONFIG.MIN_SCORE) {

const angle = calculateAngle(pA, pB, pC);
currentAngle = angle;
const target = angleDef.targetRange;
angleText = `${angle.toFixed(1)}°`;
/*if (angle >= target[0] && angle <= target[1]) {
textColorClass = 'text-feedback-ok';
angleText += " (OK)";
} else if (angle < target[0]) {
textColorClass = 'text-feedback-min';
angleText += " (< MIN)";
} else {
textColorClass = 'text-feedback-max';
angleText += " (> MAX)";
}*/
textColorClass = 'text-feedback-ok';
detectedVertices.push({ x: pB.x, y: pB.y, color: textColorClass });
}


// USO DI text-lg e text-xs per mantenere i caratteri più piccoli
htmlOutput += `
<div class="p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<p class="text-xs font-semibold uppercase text-gray-500">${angleDef.name}</p>
<p class="text-lg font-bold ${textColorClass}">${angleText}</p>
</div>
`;
});
// 3. Analisi dell'Angolo del Torso (Re-introdotta)
const s = findKeypoint(TORSO_ANALYSIS.keypoints[0]); // Spalla
const h = findKeypoint(TORSO_ANALYSIS.keypoints[1]); // Fianco

let torsoTextColorClass = 'text-feedback-off';
let torsoAngleText = 'Non Rilevato';

if (s && h && s.score >= DRAW_CONFIG.MIN_SCORE && h.score >= DRAW_CONFIG.MIN_SCORE) {
const torsoAngle = calculateHorizontalAngle(s, h);
const target = TORSO_ANALYSIS.targetRange;
torsoAngleText = `${torsoAngle.toFixed(1)}°`;

/*if (torsoAngle >= target[0] && torsoAngle <= target[1]) {
torsoTextColorClass = 'text-feedback-ok';
torsoAngleText += " (OK)";
} else if (torsoAngle < target[0]) {
torsoTextColorClass = 'text-feedback-eretto';
torsoAngleText += " (Eretto)";
} else {
torsoTextColorClass = 'text-feedback-aggressivo';
torsoAngleText += " (Aggressivo)";
}*/
torsoTextColorClass = 'text-feedback-ok';
// Disegna la linea di riferimento sul canvas (Torso)
ctx.strokeStyle = '#3b82f6'; // Blu
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(h.x, h.y);
ctx.stroke();

// Disegna la linea orizzontale di riferimento
ctx.strokeStyle = '#3b82f6'; // Blu
ctx.setLineDash([5, 5]); // Linea tratteggiata
ctx.beginPath();
ctx.moveTo(s.x, s.y);
ctx.lineTo(h.x, s.y); // Linea orizzontale
ctx.stroke();
ctx.setLineDash([]); // Ripristina linea continua
}

// USO DI text-lg e col-span-2 per l'angolo del Torso
htmlOutput += `
<div class="col-span-2 p-2 border border-gray-200 dark:border-gray-700 rounded-lg">
<p class="text-xs font-semibold uppercase text-gray-500">${TORSO_ANALYSIS.name}</p>
<p class="text-lg font-bold ${torsoTextColorClass}">${torsoAngleText}</p>
</div>
`;
// 4. Aggiorna il contenitore dei risultati con l'HTML costruito
if(htmlOutput === '') {
resultsContainer.innerHTML = '<p class="col-span-2 text-gray-500">Nessun punto chiave rilevato.</p>';
} else {
resultsContainer.innerHTML = htmlOutput;
}
// 5. Ridisegna i vertici sul canvas con il colore del feedback
detectedVertices.forEach(v => {
ctx.beginPath();
let color;
if(v.color.includes('ok')) color = '#84cc16';
else if(v.color.includes('eretto') || v.color.includes('min')) color = '#f97316';
else if(v.color.includes('aggressivo') || v.color.includes('max')) color = '#ef4444';
else color = '#facc15';
ctx.arc(v.x, v.y, DRAW_CONFIG.KEYPOINT_RADIUS + 2, 0, 2 * Math.PI);
ctx.fillStyle = color;
ctx.fill();
});


// Aggiorna lo stato
const detectedCount = keypoints.filter(k => k.score >= DRAW_CONFIG.MIN_SCORE).length;
statusMessage.textContent = `Analisi Bike Fitting in corso. Punti chiave trovati: ${detectedCount}/17`;
}


/**
* Funzione di inizializzazione principale
*/
async function init() {
try {
await loadModel();
const videoReady = await setupWebcam();
if (!videoReady) return;

loadingOverlay.style.opacity = 0;
loadingOverlay.style.pointerEvents = 'none';
video.style.opacity = 1;

// *** CHIAMATA ALLA FUNZIONE DISPLAY TARGET ***
displayTargetValues();
// 3. Avvia il loop di rilevamento e blocca l'orientamento
video.addEventListener('loadeddata', () => {
const aspectRatio = video.videoHeight / video.videoWidth;
const wrapper = document.getElementById('video-canvas-wrapper');
wrapper.style.paddingTop = `${aspectRatio * 100}%`;

statusMessage.textContent = "Telecamera attiva. Rilevamento in corso...";
detectionLoop();
// Tentativo di blocco dell'orientamento in Portrait
lockScreenOrientation();
}, { once: true });


} catch (error) {
console.error("Errore critico nell'applicazione:", error);
statusMessage.textContent = `ERRORE CRITICO: ${error.message}`;
}
}

// Avvia l'applicazione all'apertura della pagina
window.onload = init;

window.onbeforeunload = () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
};

</script>

</body>
</html>

 

 

Nessun commento:

Posta un commento

Algoritmo Reed Solomon

 Sto progettando una trasmissione radio di immagini ed uno dei vincoli e' che non e' garantita la perfetta qualita' della trasmi...