MicrocosmWorksInnover et Architecturer le Cosmos Numérique
À proposContact
MicrocosmWorksInnover et architecturer des cosmos numériques

Fournir des solutions informatiques qui comptent. Nous sommes passionnés par la technologie, la sécurité et aidons les entreprises à croître grâce à une infrastructure informatique fiable et innovante.

[email protected]
+91 7011868196
New Delhi, India

Hub de Croissance IA

Hub IAInnovation pour les startupsAccélérateur d'entreprise

Solutions

Toutes les solutionsApplications de bien-être et de fitnessPlateforme vidéo IADéveloppement d'agents IA

Ressources

PerspectivesGuides de l'industriePlans d'utilisationModèles d'architectureÉtudes de cas

Entreprise

À propos de nousContactNotre travail

Services

Consultation numériqueInfrastructure cloudDéveloppement SaaSDéveloppement IATechnologie vidéo
Développement ERPPersonnalisation ZohoDéveloppement OdooIntégration SalesforceDéveloppement CRM personnalisé
Intégration QuickBooksSolutions IoTDéveloppement Blockchain
Consultation en cybersécuritéSupport IT - L3

© 2026 MicrocosmWorks. Tous droits réservés.

Politique de confidentialitéConditions d'utilisation
Retour aux Perspectives
IoT Development

Recadrage de vidéo 360 dans Flutter : Projection sphérique, images clés à quaternions et exportation GPU

Recadrage de séquences équirectangulaires 360 dans Flutter utilisant la projection sphérique, les images clés à quaternions et l'exportation accélérée par GPU.

Saurav Kumar Gupta's image Saurav Kumar Gupta
•
July 10, 2026
•
Mis à jour July 24, 2026
•
5 min read
Developer using Flutter to edit and reframe a 360° video with spherical projection.webp
5 min read

Recadrage de vidéo 360° dans Flutter : Projection sphérique, images clés à quaternions et exportation GPU

Comment nous avons transformé des séquences brutes équirectangulaires à 360° en vidéo fluide, cadrée et plate — entièrement au sein d'une application Flutter.

Le problème dont personne ne vous avertit

Une vidéo normale a une chose évidente à vous montrer : le cadre vers lequel la caméra était pointée. Une vidéo 360° n'a pas cela. La caméra a enregistré tout — une sphère complète de pixels stockée sous forme de rectangle équirectangulaire plat (une image 2:1 où l'axe X est la longitude et l'axe Y est la latitude). Avant de pouvoir diffuser ces séquences sur Instagram, YouTube ou un écran de téléphone, quelqu'un doit répondre à une question à laquelle la caméra a délibérément refusé de répondre :

Où le spectateur doit-il regarder, et quand ?

"Recadrer" est l'acte de faire voler une caméra virtuelle à travers cette sphère au fil du temps — en panoramique, inclinaison, zoom — et de rendre une vidéo plate normale 16:9 (ou 9:16, ou 2.35:1). C'est la fonctionnalité autour de laquelle sont construits GoPro Player, Insta360 Studio et le plugin GoPro VR Reframe d'Adobe Premiere.

Nous l'avons entièrement construit en Flutter. Cet article est un tour d'horizon des parties qui ont été véritablement difficiles : le calcul de la projection, l'interpolation sans blocage de cardan, la couture de ±180° et un pipeline d'exportation GPU qui doit survivre aux décodeurs Android réels.

Le flux de bout en bout :

capture/importation vidéo 360
  → transcodage d'un proxy de prévisualisation léger
  → l'utilisateur touche / dessine / auto-détecte un sujet
  → le suivi d'objets produit un chemin
  → génération + lissage des images clés
  → interpolation SLERP de l'orientation de la caméra par image (prévisualisation en direct)
  → exportation GPU native vers une vidéo plate
  → post-traitement FFmpeg (atome moov + correction de synchronisation A/V)

Architecture en un coup d'œil

Nous avons maintenu le système en trois couches distinctes afin que les calculs soient testables isolément de Flutter :

CoucheSe situe dansResponsabilité
Modèleslib/data/models/reframe/SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject
Serviceslib/services/reframe/SphericalProjection (lancer de rayons), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService
Présentationlib/presentation/.../reframe/Riverpod ReframeNotifier, le OpenGL Spherical360Player natif, superposition de dessin de bbox, chronologie d'images clés

La règle d'or : toute la trigonométrie est en Dart pur sans importations Flutter. Cela signifie que chaque fonction de projection et d'interpolation est testable unitairement sans arbre de widgets, et que les mêmes calculs pilotent à la fois la prévisualisation en direct et l'exportation.

1. Le cœur du système : équirectangulaire ⟷ sphérique

Tout commence par un seul mappage. Une image équirectangulaire est simplement un rectangle où :

  • X (0 → largeur) balaye la longitude (yaw) de −180° à +180°
  • Y (0 → hauteur) balaye la latitude (pitch) de +90° (haut/zénith) à −90° (bas/nadir)

SphericalCoordinates est délibérément petit — juste le yaw et le pitch. Le Roll, le FOV et le zoom appartiennent à la caméra, pas à la direction. Voici la conversion sur laquelle repose toute la fonctionnalité :

factory SphericalCoordinates.fromEquirectangular({
  required double x, required double y,
  required double videoWidth, required double videoHeight,
}) {
  final normalizedX = x / videoWidth;
  final normalizedY = y / videoHeight;
  // X maps to yaw: 0->-180°, 0.5->0°, 1.0->180°
  final yaw = (normalizedX - 0.5) * 360.0;
  // Y maps to pitch: 0->90° (top), 0.5->0° (horizon), 1.0->-90° (bottom)
  final pitch = (0.5 - normalizedY) * 180.0;
  return SphericalCoordinates(
    yaw: yaw.clamp(-180.0, 180.0),
    pitch: pitch.clamp(-90.0, 90.0),
  );
}

L'inverse est l'image miroir (normalizedX = yaw/360 + 0.5), et nous l'utilisons constamment pour redessiner une boîte englobante suivie sur le lecteur.

"Appuyez sur ce que vous voyez" — lancer de rayons en perspective

L'utilisateur n'interagit pas avec le rectangle équirectangulaire. Il interagit avec une vue perspective rendue. Ainsi, lorsqu'il touche un point sur le lecteur, nous devons projeter un rayon à travers une caméra sténopé virtuelle et trouver la direction de la sphère qu'il a touchée. C'est SphericalProjection.screenToSpherical :

final ndcX = (2.0 * screenPoint.dx / playerSize.width) - 1.0;
final ndcY = 1.0 - (2.0 * screenPoint
dy / playerSize.height);
final aspectRatio = playerSize.width / playerSize.height;
final tanHalfFov = math.tan(cameraFov * math.pi / 360.0); // half-angle
double rayX = ndcX * aspectRatio * tanHalfFov;
double rayY = ndcY * tanHalfFov;
double rayZ = 1.0;
// ...normalize, rotate the ray by pitch (around X) then yaw (around Y)...
final sphericalYaw   = math.atan2(finalRayX, finalRayZ) * 180.0 / math.pi;
final sphericalPitch = math.asin(finalRayY.clamp(-1.0, 1.0)) * 180.0 / math.pi;

Le .clamp(-1.0, 1.0) avant asin semble paranoïaque, mais la dérive en virgule flottante peut vous donner 1.0000001 et asin renverra NaN — et un seul NaN contamine chaque image en aval. Le serrage défensif autour de la trigonométrie inverse est un thème récurrent dans cette base de code.

2. Mouvement fluide de la caméra : quaternions, pas angles d'Euler

L'approche naïve consiste à stocker le yaw/pitch à chaque image clé et à interpoler linéairement les nombres. Le résultat est terrible. L'interpolation des angles d'Euler entraîne un blocage de cardan, une vitesse angulaire non uniforme et des saccades inesthétiques près des pôles.

Nous interpolons l'orientation comme un quaternion en utilisant le SLERP (spherical linear interpolation). Chaque direction est convertie en un quaternion via une convention ZYX Euler :

Quaternion toQuaternion() {
  final yawRad = yaw * math.pi / 180.0;
  final pitchRad = pitch * math.pi / 180.0;
  final cy = math.cos(yawRad * 0.5); final sy = math.sin(yawRad * 0.5);
  final cp = math.cos(pitchRad * 0.5); final sp = math.sin(pitchRad * 0.5);
  return Quaternion(w: cy * cp, x: cy * sp, y: sy * cp, z: -sy * sp);
}

Et le SLERP lui-même dispose des deux soupapes de sécurité de qualité production dont chaque implémentation a besoin :

static Quaternion slerp(Quaternion a, Quaternion b, double t) {
  a = a.normalize(); b = b.normalize();
  var dotProduct = a.dot(b);
  if (dotProduct < 0.0) { b = -b; dotProduct = -dotProduct; }   // take the shortest hemisphere
  if (dotProduct > 0.9995) {                                     // near-parallel -> fall back to LERP
    return Quaternion(w: a.w + t*(b.w-a.w), x: a.x + t*(b
                      y: a.y + t*(b.y-a.y), z: a.z + t*(b.z-a.z)).normalize();
  }
  final theta0 = math.acos(dotProduct);
  final theta = theta0 * t;
  final sinTheta = math.sin(theta);
  final sinTheta0 = math.sin(theta0);
  final s0 = math.cos(theta) - dotProduct * sinTheta / sinTheta0;
final s1 = sinTheta / sinTheta0;
  return Quaternion(w: s0*a.w + s1*b.w, /* ...*/).normalize();
}

Deux détails non négociables :

  1. Hémisphère le plus court — q et −q représentent la même orientation. Si le produit scalaire est négatif, vous inversez l'une des entrées ou votre caméra fera le long chemin autour de la sphère.
  2. Retour à LERP en cas de quasi-parallélisme — lorsque deux images clés sont presque identiques, sinTheta0 → 0 et vous divisez par presque zéro. Le recours à une LERP simple au-dessus d'un produit scalaire > 0.9995 évite l'explosion.

Une décision de conception subtile mais importante : nous SLERPons la direction, mais LERPons le FOV/zoom et LERPons le roulis angulaire. L'interpolation sphérique est l'outil approprié pour la direction ; un zoom n'est qu'un scalaire et doit se déplacer linéairement :

final qInterp = Quaternion.slerp(from.direction.toQuaternion(), to.direction.toQuaternion(), t);
final interpDirection = SphericalCoordinates.fromQuaternion(qInterp).normalize();
final interpFov  = _lerp(from.fov, to.fov, t);
final interpRoll = _lerpAngle(from.roll, to.roll, t); // wraps across ±180
final interpZoom = _lerp(from.zoom, to.zoom, t);

💡 Lors de la conversion inverse d'un quaternion en yaw/pitch, nous utilisons atan2 plutôt que asin "pour la stabilité numérique (évite les problèmes de domaine d'asin)". Après un SLERP, le quaternion peut être très légèrement dénormalisé, et atan2 se dégrade gracieusement là où asin renvoie NaN.

Easing : fait à la main, sur la transition entrante

Chaque image clé possède la courbe d'atténuation (easing curve) pour la transition vers elle. Les courbes sont écrites à la main afin qu'elles soient identiques sur chaque plateforme :

case KeyframeEasing.linear:    return t;
case KeyframeEasing.easeInOut: return t * t * (3 - 2 * t); // smoothstep S-curve
case KeyframeEasing.easeIn:    return t * t;               // quadratic
case KeyframeEasing.easeOut:   return 1 - (1 - t) * (1 - t);

3. La couture de ±180° : le bogue qui hante chaque fonctionnalité 360

Voici le piège. Le yaw vit sur un cercle : +179° et −179° sont séparés de 2°, pas de 358°. Dès que votre sujet traverse l'arrière de la sphère, un calcul naïf fait pivoter la caméra à l'autre bout du monde. Ce problème unique apparaît à quatre couches différentes du système, et chacune nécessite sa propre solution.

Couche 1 — primitives. Deux petites fonctions d'aide sur lesquelles repose toute la base de code :

static double normalizeYaw(double yaw) {
  while (yaw > 180) { yaw -= 360; }
  while (yaw < -180) { yaw += 360; }
  return yaw;
}

// "CRITICAL for seam-safe tracking ... avoids 180° snaps"
static double shortestYawDelta(double from, double to) {
  double delta = to - from;
  if (delta > 180) delta -= 360;
  if (delta < -180) delta += 360;
  return delta;
}

Couche 2 — la génération d'images clés accumule un yaw continu (non borné) afin que SLERP interpole toujours le chemin le plus court. Au lieu de restreindre chaque échantillon à ±180°, nous additionnons les deltas les plus courts et laissons la valeur courante dépasser 180° volontairement :

final normalizedContinuousYaw = SphericalCoordinates.normalizeYaw(continuousYaw);
final yawDelta = SphericalCoordinates.shortestYawDelta(normalizedContinuousYaw, rawCoords.yaw);
continuousYaw = continuousYaw + yawDelta;            // may exceed ±180 on purpose
coords = SphericalCoordinates(yaw: continuousYaw, pitch: rawCoords.pitch);

Couche 3 — reconstruction de la boîte englobante. Lorsque l'utilisateur dessine une boîte qui chevauche la couture, les quatre coins rapportent des yaws comme +170°, +175°, −175°, −170°. SphericalProjection détecte cela (tout coin > 90° et tout coin < −90°) et reconstruit une bbox continue de "yaw sûr à la couture" en décalant les négatifs de +360° avant de mesurer son étendue.

Couche 4 — le lecteur en direct se lisse vers sa cible en utilisant shortestYawDelta afin que le glisser sur la couture ne provoque jamais de saccades.

Si vous ne retenez qu'une chose sur la création d'outils 360 : la couture n'est pas un cas limite, c'est une préoccupation transversale. Prévoyez-la partout où le yaw apparaît.

4. Le rendre agréable comme Insta360/GoPro

Une interpolation fluide vous donne une caméra techniquement correcte. Elle ne vous en donne pas une bonne. Trois éléments de "feel engineering" comblent cette lacune.

Contraintes de mouvement — limitation de la vitesse et de l'accélération. Les données de suivi brutes sont saccadées. Nous faisons passer les images clés par MotionConstraints, ce qui limite la vitesse et la brusquerie des mouvements de la caméra virtuelle (par exemple, maxYawSpeed = 120°/s, maxPitchSpeed = 90°/s, maxAcceleration = 180°/s²), avec des préréglages cinematic(), responsive() et production(). C'est la différence entre "une caméra de sécurité qui suit une mouche" et "un opérateur avec une main stable."

FOV adaptatif — zoom pour s'adapter au sujet. Nous mesurons la taille angulaire du sujet dans l'espace équirectangulaire et choisissons un FOV afin qu'il remplisse une fraction cible du cadre :

final angularWidth  = (bboxWidth  / videoWidth)  * 360.0;
final angularHeight = (bboxHeight / videoHeight) * 180.0;
final objectAngularSize = math.max(angularWidth, angularHeight);
final idealFov = objectAngularSize / targetFillRatio; // targetFillRatio ≈ 0.4
return idealFov.clamp(minFov, maxFov);                 // 45°–120°

Avance de mouvement + fusion visage→corps. Nous décalons le cadrage d'environ 15% de la largeur de la bbox en avant dans la direction du mouvement, afin que le sujet ne soit pas collé au bord vers lequel il se dirige. Et lorsque la détection de visage disparaît, nous ne nous fixons pas au centre du corps — nous effectuons une LERP du dernier centre de visage connu vers le centre du corps sur environ 10 images.

5. L'astuce des deux vidéos : proxy de prévisualisation vs. original pleine résolution

C'est la décision architecturale qui rend l'ensemble utilisable sur de vrais téléphones.

Un fichier GoPro/Insta360 360° est souvent un HEVC 4096×2048. Les décodeurs Android d'entrée de gamme peinent à le lire fluidement, sans parler de l'exécution du suivi d'objets en plus. Nous utilisons donc le suivi et la prévisualisation en direct sur un proxy transcodé (~1440×720, libx264 -preset veryfast -crf 28), et réservons l'original pleine résolution pour l'exportation finale uniquement.

// "more reliable than MediaMetadataRetriever for 360° videos
//  which often use unusual codecs (HEVC, high-res)"
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048) is unsafe -> trigger transcodeForPlayback()

Le piège à gérer : les boîtes englobantes des images clés sont calculées en coordonnées proxy et doivent être mises à l'échelle aux coordonnées d'origine avant l'exportation. Si cette mise à l'échelle est incorrecte, votre exportation cadrera une région légèrement différente de celle que l'utilisateur a prévisualisée. (Nous utilisons également ffprobe, et non MediaMetadataRetriever, pour les métadonnées et l'extraction de la première image — le récupérateur intégré d'Android est instable avec les codecs inhabituels émis par les caméras 360.)

6. Exportation GPU : OpenGL + MediaCodec via un MethodChannel

Re-projeter chaque image d'une sphère 4K via le filtre v360 de FFmpeg sur le CPU d'un téléphone est beaucoup trop lent. (Nous gardons tout de même la chaîne v360 — elle documente proprement la sémantique de projection, v360=e:flat:... signifiant "équirectangulaire en entrée, plat en sortie" — mais ce n'est pas le chemin en direct.)

La véritable exportation transmet les images clés à un moteur de rendu natif OpenGL ES + MediaCodec via un MethodChannel :

final result = await _methodChannel.invokeMethod('exportVideoGPU', {
  'videoPath': resolvedVideoPath,
  'outputPath': outputPath,
  'keyframes': keyframesList,
  'initialOrientation': initialOrientationMap,
  'width': settings.exportWidth,
  'height': settings.exportHeight,
  'fps': settings.outputFps.toInt(),
  'includeAudio': settings.includeAudio,
  if (isTrimmed) 'trimStartMs': trimStartMs,
  if (isTrimmed) 'trimEndMs': trimEndMs,
});

Nous protégeons cela derrière une vérification de capacités (Android API 21+, un encodeur matériel H.264, OpenGL ES 3.0) et diffusons la progression via un EventChannel. Quelques pièges durement appris :

  • Le décalage de yaw de -90°. Notre convention Dart place le yaw = 0 au centre de l'image équirectangulaire (u = 0.5) ; le moteur de rendu OpenGL place le yaw = 0 à u = 0.75. Ainsi, chaque yaw d'image clé est remappé de -90° (et ré-enveloppé à ±180°) lors du passage sur le canal — appliqué symétriquement aux canaux d'exportation et de lecteur en direct, afin que la prévisualisation et l'exportation soient cohérentes.
  • Comptes d'images clés dégénérés. Zéro image clé → synthétise deux images identiques à partir de l'orientation initiale (exportation statique). Une image clé → la duplique. Le moteur de rendu veut toujours au moins un début et une fin.
  • Le rognage (trimming) réinitialise les horodatages. Une exportation rognée décale chaque image clé de t − trimStartMs.

Le post-traitement FFmpeg que personne n'attend

On pourrait penser que l'exportation GPU est la ligne d'arrivée. Ce n'est pas le cas — car le MediaMuxer d'Android écrit l'atome moov à la fin du fichier et peut transporter le décalage PTS audio de la source. Le résultat : ExoPlayer (et le video_player de Flutter) gèle l'image tandis que l'horloge continue de tourner. Donc, après le passage GPU, FFmpeg effectue un nettoyage rapide et sans perte :

// copy video (no re-encode -> zero quality loss), re-encode audio to fix PTS,
// move moov atom to the front for instant playback.
-c:v copy -af "aresample=async=1:first_pts=0" -movflags +faststart

Et un petit piège brutal le protège : nous sondons d'abord pour une piste audio, car passer des drapeaux audio à une vidéo qui n'a pas de piste audio fait bloquer FFmpeg indéfiniment sur certains appareils. L'ensemble du post-traitement est enveloppé dans un timeout de 60 secondes avec annulation en cas de timeout.

7. La prévisualisation en direct est aussi un lecteur natif

La prévisualisation dans l'éditeur n'est pas un Flutter CustomPaint — c'est une surface OpenGL native intégrée via AndroidView (avec un viewType comme 'your_app/spherical360player'), avec son propre canal méthode/événement par vue. Le Dart Spherical360PlayerController reflète le yaw/pitch/roll/fov et applique le même décalage de -90° avant l'envoi.

Les détails de l'expérience utilisateur qui la rendent agréable :

  • Lissage indépendant de la fréquence d'images — factor = 1 - exp(-speed * dt) afin que l'atténuation (easing) soit la même à 60fps et 90fps, avec le yaw de chemin le plus court sécurisé pour la couture intégré.
  • Momentum/inertie après un mouvement rapide, avec une décroissance basée sur la friction — désactivé pendant la lecture, car pendant la lecture, la caméra doit obéir aux images clés, et non au dernier mouvement de l'utilisateur.
  • Un seul GestureDetector dont onScaleUpdate effectue le panoramique et le pincement-zoom simultanément ; le double-tap active/désactive la lecture/pause. Le FOV est limité à 30°-120°, le pitch à ±90°, à la limite du contrôleur.

Une petite mais révélatrice correction UX : avant de passer du "suivi" à la "génération d'images clés", nous insérons un délai de 120 ms pour que Flutter affiche réellement "100%". Sans cela, l'image de progression finale et le changement de statut atterrissent dans le même lot de microtâches et l'utilisateur ne voit jamais la finalisation.

Machine d'état, en bref

Tout cela est coordonné par un Riverpod ReframeNotifier pilotant un enum de statut :

idle → loadingVideo → transcoding
    → (detecting | selectingObject | drawingBboxOnPlayer | autoDetectingOnPlayer)
    → tracking → generatingKeyframes → editing → exporting → completed | error

Plusieurs points d'entrée alimentent le même traqueur : toucher pour détecter (superposition YOLO), bbox manuelle, ou le geste de dessiner directement sur le lecteur à la manière d'Insta360. Ils convergent tous vers KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...), et à partir de là, l'interpolateur pilote chaque image prévisualisée et exportée.

Leçons apprises

  1. Gardez les calculs purs. Pas d'importations Flutter dans la couche de projection/interpolation. C'est la seule raison pour laquelle cela était testable.
  2. SLERP de quaternion pour la direction ; LERP pour les scalaires. N'interpolez pas les angles d'Euler et ne SLERPez pas votre zoom.
  3. La couture de ±180° est une préoccupation transversale, pas un cas limite. Elle réapparaît dans les primitives, la génération, la projection et le lecteur.
  4. Harmonisez vos conventions une fois, symétriquement. Le décalage de yaw de -90° entre Dart et OpenGL est appliqué aux deux limites de canal afin que la prévisualisation soit égale à l'exportation.
  5. Proxy pour l'interaction, original pour la sortie. N'oubliez simplement pas de mettre à l'échelle vos coordonnées entre les deux.
  6. "Rendu terminé" ≠ "lecture correcte". Prévoyez un post-traitement FFmpeg pour corriger le placement de l'atome moov et le PTS audio, et sondez avant d'ajouter des drapeaux audio.
  7. Le ressenti est une fonctionnalité. Les contraintes de mouvement, le FOV adaptatif et l'avance de mouvement sont ce qui sépare le "recadré techniquement" du "ressemble à un pro qui l'a filmé".

Le résultat : un éditeur de recadrage 360° entièrement sur l'appareil — touchez un sujet, laissez-le suivre, ajustez les images clés et exportez une vidéo plate et nette — construit en Flutter avec une fine couche GPU native faisant uniquement le travail que Dart ne peut pas faire assez rapidement.




 

Flutter360VideoOpenGLQuaternions
Saurav Kumar Gupta's image

À propos de l'auteur

Saurav Kumar Gupta

AI & Cloud Solutions Expert at MicrocosmWorks

Building innovative AI-powered solutions and helping businesses transform through cutting-edge technology.

Vous souhaitez en savoir plus ?

Contactez-nous pour discuter de la façon dont nous pouvons vous aider à mettre en œuvre ces solutions pour votre entreprise.

Contactez-nous

Questions Fréquemment Posées

Accurate reframing requires handling spherical projection, seamless rotation across the ±180° boundary, smooth camera interpolation, and synchronized GPU rendering to produce stable, cinematic video output.

Comments (0)

Share your thoughts and join the conversation

Leave a Comment

Your email will not be published

No comments yet

Be the first to share your thoughts!