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>

 

 

mercoledì 8 ottobre 2025

Trasformazione rigida di immagine con algoritmo Kabsch

 Ho un problema...la camera di ripresa di un sistema di monitoraggio, per la quale era presupposta la staticita', si muove...come correggere a posteriore la traslazione e la rotazione?

Si tratta di una trasformazione rigida (solo rototraslazione...gli oggetti in scena non mutano la loro posizione relativa)...questo e' un esempio semplificato

Le due immagini rappresentano lo stesso foglio fotografato da sue posizioni differente. 


Si calcolano le coordinate pixel dei punti rossi nei due fogli (per la calibrazione) ...le coordinate dei punti neri saranno usate per la validazione 

L'algoritmo di Kabsch e' piuttosto intuitivo e si puo' leggere qui 

Nelle matrice A e B sono riportate le coordinate dei punti di calibrazione

La differenza finale e' stata di 2 pixel 

import numpy as np
import cv2

def create_affine_matrix_2x3(R, t, s):
    """
    Create a 2x3 affine transformation matrix for OpenCV.
    M = [s*R | t]
    """
    M = np.zeros((2, 3))
    M[:2, :2] = s * R
    M[:2, 2] = t
    return M

def kabsch_transform(A, B, with_scale=False):
    assert A.shape == B.shape
    N = A.shape[0]
    centroid_A = A.mean(axis=0)
    centroid_B = B.mean(axis=0)
    A_c = A - centroid_A
    B_c = B - centroid_B
    H = A_c.T @ B_c
    U, S, Vt = np.linalg.svd(H)
    R = Vt.T @ U.T
    # fix reflection
    if np.linalg.det(R) < 0:
        Vt[-1,:] *= -1
        R = Vt.T @ U.T
    if with_scale:
        varA = (A_c**2).sum()
        s = S.sum()/varA
    else:
        s = 1.0
    t = centroid_B - s * (R @ centroid_A)
    return R, t, s

A = np.array([[175,218],[528,123],[439,523],[218,726],[554,688]])
B = np.array([[110,263],[457,128],[410,541],[211,768],[543,692]])

print(A)
R, t, s = kabsch_transform(A, B)
print("\nRotation matrix R:")
print(R)
print("\nTranslation vector t:")
print(t)
print("\nScale s:")
print(s)
print("-----------------------------")

A1 = np.array([[218,726],[554,688]])
B1 = np.array([[211,768],[543,693]])

controllo_transformed = s * (A1 @ R.T) + t
print(controllo_transformed)
print(B1)
print("\nDifferenza:")
print(np.abs(controllo_transformed - B1).max())

image = cv2.imread("IMG_8947.jpg")
output_shape = (image.shape[1], image.shape[0])

M = create_affine_matrix_2x3(R, t, s)
transformed = cv2.warpAffine(image, M, output_shape,
                                 flags=cv2.INTER_LINEAR,
                                 borderMode=cv2.BORDER_CONSTANT,
                                 borderValue=0)
cv2.imwrite('finale.jpg',transformed)


 

Analisi MNF su spettri di riflettanza di plastica

Devo cerca di lavorare su spettri di riflettanza di plastica e la prima domanda e': quale sono le bande significative? Sono partito dal ...