MicrocosmWorksInnovere og Arkitektere Digitale Kosmos
OmKontakt
MicrocosmWorksInnoverer og arkitekterer digitale kosmos

Leverer IT-løsninger, der betyder noget. Vi brænder for teknologi, sikkerhed og at hjælpe virksomheder med at vokse gennem pålidelig, innovativ IT-infrastruktur.

[email protected]
+91 7011868196
New Delhi, India

AI Væksthub

AI HubStartup-innovationVirksomhedsaccelerator

Løsninger

Alle løsningerSundhed & Fitness AppsAI VideoplatformAI Agentudvikling

Ressourcer

IndsigterIndustri GuiderBrugssag BlueprintsArkitektur MønstreCase Studier

Virksomhed

Om OsKontaktVores Arbejde

Tjenester

Digital RÃ¥dgivningCloud InfrastrukturSaaS UdviklingAI UdviklingVideo Teknologi
ERP UdviklingZoho TilpasningOdoo UdviklingSalesforce IntegrationTilpasset CRM Udvikling
QuickBooks IntegrationIoT LøsningerBlockchain Udvikling
Cybersikkerhed RÃ¥dgivningIT-support - L3

© 2026 MicrocosmWorks. Alle rettigheder forbeholdes.

PrivatlivspolitikServicevilkår
Tilbage til indsigter
IoT Development

Genindramning af 360 Video i Flutter: Sfærisk Projektion, Quaternion Nøgleframes, og GPU Eksport

Genindramning af equirectangular 360 optagelser i Flutter ved hjælp af sfærisk projektion, quaternion keyframes og GPU-accelereret eksport.

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

Genindramning af 360° Video i Flutter: Sfærisk Projektion, Quaternion Nøgleframes, og GPU Eksport

Hvordan vi forvandlede rå equirectangular 360° optagelser til glat, indrammet, flad video — udelukkende inde i en Flutter app.

Problemet ingen advarer dig om

En normal video har én åbenlys ting at vise dig: den ramme kameraet pegede på. En 360° video har ikke sådan noget. Kameraet optog alt — en hel sfære af pixels gemt som en flad equirectangular rektangel (et 2:1 billede hvor X-aksen er længdegrad og Y-aksen er breddegrad). Før du kan lægge den optagelse på Instagram, YouTube, eller en telefonskærm, skal nogen besvare et spørgsmål, som kameraet bevidst nægtede at besvare:

Hvor skal seeren kigge hen, og hvornår?

"Reframing" er handlingen at flyve et virtuelt kamera gennem den sfære over tid — panorering, tilt, zoom — og rendere en normal flad 16:9 (eller 9:16, eller 2.35:1) video ud. Dette er den funktion, som GoPro Player, Insta360 Studio og Adobe Premiere's GoPro VR Reframe plugin er bygget op omkring.

Vi byggede det helt i Flutter. Dette indlæg er en gennemgang af de dele, der var virkelig svære: projektionsmatematikken, gimbal-lock-fri interpolation, ±180° sømmen, og en GPU eksport pipeline, der skal overleve rigtige Android decodere.

Den end-to-end flow:

optag/importer 360 video
  → transkod en letvægts preview proxy
  → bruger trykker / tegner / auto-detekterer et emne
  → objektsporing producerer en sti
  → generer + udglat keyframes
  → SLERP-interpolér per-frame kameraorientering (live preview)
  → native GPU eksport til en flad video
  → FFmpeg post-pass (moov atom + A/V sync fix)

Arkitektur ved første øjekast

Vi holdt systemet i tre rene lag, så matematikken er testbar isoleret fra Flutter:

LagLever iAnsvar
Modelslib/data/models/reframe/SphericalCoordinates/Quaternion, CameraOrientation/MotionConstraints, ReframeKeyframe, ReframeProject
Serviceslib/services/reframe/SphericalProjection (ray casting), CoordinateConverter, KeyframeInterpolator/Generator/Smoother, ReframeExportService
Presentationlib/presentation/.../reframe/Riverpod ReframeNotifier, den native OpenGL Spherical360Player, bbox-drawing overlay, keyframe timeline

Den gyldne regel: al trigonometri er ren Dart uden Flutter imports. Det betyder, at hver projektions- og interpolationsfunktion er enhedstestbar uden et widget tree, og den samme matematik driver både live preview og eksporten.

1. Kernen: equirectangular ⟷ sfærisk

Alt starter med én mapping. En equirectangular ramme er blot et rektangel, hvor:

  • X (0 → bredde) fejer længdegrad (yaw) fra −180° til +180°
  • Y (0 → højde) fejer breddegrad (pitch) fra +90° (top/zenit) til −90° (bund/nadir)

SphericalCoordinates er bevidst lille — kun yaw og pitch. Roll, FOV og zoom tilhører kameraet, ikke retningen. Her er konverteringen, som hele funktionen hviler på:

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),
  );
}

Det omvendte er et spejlbillede (normalizedX = yaw/360 + 0.5), og vi bruger det konstant til at tegne en sporet bounding box tilbage på afspilleren.

"Tryk på det, du ser" — perspektiv ray casting

Brugeren interagerer ikke med den equirectangular rektangel. De interagerer med et renderet perspektiv view. Så når de trykker på et punkt på afspilleren, skal vi kaste en ray gennem et virtuelt pinhole-kamera og finde ud af, hvilken retning på sfæren de ramte. Det er 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;

The .clamp(-1.0, 1.0) før asin ser paranoid ud, men floating-point drift vil give dig 1.0000001 og asin vil returnere NaN — og et enkelt NaN forgifter hver downstream-ramme. Defensiv clamping omkring inverse trig er et tilbagevendende tema i denne codebase.

2. Glat kamerabevægelse: Quaternions, ikke Euler-vinkler

Den naive tilgang er at gemme yaw/pitch ved hver keyframe og lineært interpolere tallene. Det ser forfærdeligt ud. Interpolation af Euler-vinkler giver dig gimbal lock, ujævn vinkelhastighed og grimme 'snaps' nær polerne.

Vi interpolerer orientering som en quaternion ved hjælp af SLERP (spherical linear interpolation). Hver retning konverteres til en quaternion via en ZYX Euler-konvention:

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);
}

Og SLERP'en selv har de to produktionsklare sikkerhedsventiler, som enhver implementering har brug for:

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; }   // tag den korteste halvkugle
  if (dotProduct > 0.9995) {                                     // nær-parallel -> falder tilbage til LERP
    return Quaternion(w: a.w + t*(b.w-a.w), x: a.x + t*(b.x-a.x),
                      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();
}

To ikke-forhandlingsbare detaljer:

  1. Korteste halvkugle — q og −q repræsenterer den samme orientering. Hvis dot-produktet er negativt, negerer du et input, ellers tager dit kamera den lange vej rundt om sfæren.
  2. Nær-parallel LERP fallback — når to keyframes er næsten identiske, går sinTheta0 → 0, og du dividerer med næsten nul. At falde tilbage til ren LERP over dot > 0.9995 undgår opblæsningen.

En subtil, men vigtig designbeslutning: vi SLERP'er retningen, men LERP'er FOV/zoom og angle-LERP'er roll. Sfærisk interpolation er det rigtige værktøj til retning; en zoom er blot en skalar og bør bevæge sig lineært:

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);

💡 Når man konverterer tilbage fra en quaternion til yaw/pitch, bruger vi atan2 i stedet for asin "for numerisk stabilitet (undgår asin-domæneproblemer)." Efter en SLERP kan quaternionen være meget let denormaliseret, og atan2 degraderer graciøst, hvor asin kaster NaN.

Easing: håndlavet, på den indkommende transition

Hver keyframe ejer easing-kurven for overgangen ind i den. Kurverne er skrevet ud i hånden, så de er identiske på alle platforme:

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

3. ±180° sømmen: den bug der hjemsøger enhver 360 funktion

Her er fælden. Yaw lever på en cirkel: +179° og −179° er 2° fra hinanden, ikke 358°. I det øjeblik dit emne går hen over bagsiden af sfæren, snapper naiv matematik kameraet halvvejs rundt om jorden. Dette ene problem opstår på fire forskellige lag i systemet, og hver kræver sin egen løsning.

Lag 1 — primitiver. To små hjælpere, som hele codebase'en er afhængig af:

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

// "KRITISK for søm-sikker sporing ... undgår 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;
}

Lag 2 — keyframe-generering akkumulerer en kontinuerlig (ubegrænset) yaw, så SLERP altid interpolerer den korte vej. I stedet for at klampe hver sample til ±180°, lægger vi de korteste deltas sammen og lader den løbende værdi afvige forbi 180° med vilje:

final normalizedContinuousYaw = SphericalCoordinates.normalizeYaw(continuousYaw);
final yawDelta = SphericalCoordinates.shortestYawDelta(normalizedContinuousYaw, rawCoords.yaw);
continuousYaw = continuousYaw + yawDelta;            // kan overstige ±180 med vilje
coords = SphericalCoordinates(yaw: continuousYaw, pitch: rawCoords.pitch);

Lag 3 — bounding-box rekonstruktion. Når brugeren tegner en boks, der strækker sig over sømmen, rapporterer de fire hjørner yaws som +170°, +175°, −175°, −170°. SphericalProjection detekterer dette (ethvert hjørne > 90° og ethvert hjørne < −90°) og genopbygger en kontinuerlig-yaw "seam-safe" bbox ved at flytte de negative med +360° før måling af dens span.

Lag 4 — live playeren udglatter mod sit mål ved hjælp af shortestYawDelta, så træk over sømmen aldrig hakker.

Hvis du husker én ting om at bygge 360 tooling: sømmen er ikke et edge case, det er en cross-cutting concern. Budgetter for det overalt, hvor yaw vises.

4. At få det til at føles som Insta360/GoPro

Glat interpolation giver dig et teknisk korrekt kamera. Det giver dig ikke et godt et. Tre stykker "feel engineering" lukker det hul.

Bevægelsesbegrænsninger — klampe hastighed og acceleration. Rå sporingsdata er nervøse. Vi sender keyframes gennem MotionConstraints, som begrænser, hvor hurtigt og hvor brat det virtuelle kamera kan bevæge sig (f.eks. maxYawSpeed = 120°/s, maxPitchSpeed = 90°/s, maxAcceleration = 180°/s²), med cinematic(), responsive() og production() forudindstillinger. Dette er forskellen mellem "sikkerhedskamera, der sporer en flue" og "operatør med en rolig hånd."

Adaptiv FOV — zoom for at tilpasse motivet. Vi måler motivets vinkelstørrelse i equirectangular rum og vælger en FOV, så den fylder en målrettet brøkdel af rammen:

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°

Bevægelsesføring + ansigt→krop blanding. Vi skubber indramningen ~15% af bbox-bredden fremad i bevægelsesretningen, så motivet ikke presses mod kanten, de bevæger sig mod. Og når ansigtsgenkendelsen falder ud, snapper vi ikke til kropscentret — vi LERP'er fra det sidst kendte ansigtscenter til kropscentret over ~10 frames.

5. Det to-video trick: preview proxy vs. fuld-opløsning original

Dette er den arkitektoniske beslutning, der gør det hele brugbart på rigtige telefoner.

En 360° GoPro/Insta360 fil er ofte 4096×2048 HEVC. Budget Android decodere kvæles, når de forsøger at afspille det flydende, endsige mens objektsporing kører ovenpå. Så vi kører sporing og live preview på en transcoded proxy (~1440×720, libx264 -preset veryfast -crf 28), og reserverer den fuld-opløselige original kun til den endelige eksport.

// "mere pålidelig end MediaMetadataRetriever for 360° videoer
//  som ofte bruger usædvanlige codecs (HEVC, høj opløsning)"
final maxSafeWidth = 2048, maxSafeHeight = 1080;
// 2:1 4K (4096×2048) er usikker -> trigger transcodeForPlayback()

Fælden du skal håndtere: keyframe bounding boxes beregnes i proxy-koordinater og skal skaleres op til originale koordinater før eksport. Går denne skalering galt, indrammer din eksport et lidt andet område end brugeren forhåndsviste. (Vi bruger også ffprobe, ikke MediaMetadataRetriever, til metadata og udtrækning af første frame — Androids indbyggede retriever er ustabil på de usædvanlige codecs, som 360-kameraer udsender.)

6. GPU eksport: OpenGL + MediaCodec over en MethodChannel

At genprojektere hver frame af en 4K sfære gennem FFmpeg's v360 filter på en telefon CPU er alt for langsomt. (Vi beholder dog v360-strengen — den dokumenterer pænt projektionssemantikken, v360=e:flat:... betyder "equirectangular ind, flad ud" — men det er ikke den live vej.)

Den egentlige eksport giver keyframes til en native OpenGL ES + MediaCodec renderer over en 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,
});

Vi beskytter dette bag et capability check (Android API 21+, en hardware H.264 encoder, OpenGL ES 3.0) og streamer fremskridt tilbage over en EventChannel. Et par hårdt-vundne faldgruber:

  • Den −90° yaw offset. Vores Dart-konvention placerer yaw = 0 i midten af det equirectangular billede (u = 0.5); OpenGL-rendereren placerer yaw = 0 ved u = 0.75. SÃ¥ hver keyframe yaw ommappes med −90° (og genindpakkes til ±180°) pÃ¥ vejen over kanalen — symmetrisk anvendt pÃ¥ bÃ¥de eksportkanalen og live-afspillerkanalen, sÃ¥ preview og eksport stemmer overens.
  • Degenererede keyframe-antal. Nul keyframes → syntetiser to identiske fra den initiale orientering (statisk eksport). Én keyframe → dupliker den. Rendereren ønsker altid mindst en start og en slutning.
  • Trimming re-baserer timestamps. En trimmet eksport forskydder hver keyframe med t − trimStartMs.

FFmpeg post-passet ingen forventer

Du ville tro, at GPU-eksport var målstregen. Det er det ikke — fordi Androids MediaMuxer skriver moov atommet i slutningen af filen og kan bære kildens audio PTS offset. Resultatet: ExoPlayer (og Flutter's video_player) fryser billedet, mens uret fortsætter. Så efter GPU-passet udfører FFmpeg en hurtig, tabsfri oprydning:

// kopier video (ingen re-encode -> nul kvalitetstab), re-encode audio for at rette PTS,
// flyt moov atom til fronten for øjeblikkelig afspilning.
-c:v copy -af "aresample=async=1:first_pts=0" -movflags +faststart

Og en brutal lille faldgrube, der beskytter det: vi sonderer først efter et lydspor, fordi at sende lydflag til en video, der ikke har et lydspor, får FFmpeg til at hænge for evigt på nogle enheder. Hele post-passet er pakket ind i en 60-sekunders timeout med annullering ved timeout.

7. Live preview er også en native afspiller

In-editor preview er ikke en Flutter CustomPaint — det er en native OpenGL surface indlejret via AndroidView (med en viewType som 'your_app/spherical360player'), med sin egen per-view method/event channel. Dart Spherical360PlayerController spejler yaw/pitch/roll/fov og anvender den samme −90° offset, før den sender.

De følelsesmæssige detaljer, der gør det behageligt:

  • Frame-rate-uafhængig udglatning — faktor = 1 - exp(-speed * dt), sÃ¥ easing ser ens ud ved 60fps og 90fps, med den sømsikre shortest-path yaw indbygget.
  • Momentum/inerti efter et kast, med friktionsbaseret forfald — deaktiveret under afspilning, fordi kameraet under afspilning skal adlyde keyframes, ikke brugerens sidste flick.
  • Én GestureDetector, hvis onScaleUpdate udfører pan og pinch-zoom sammen; dobbelttryk skifter afspil/pause. FOV klemmes til 30°–120°, pitch til ±90°, ved controller-grænsen.

En lille, men sigende UX-rettelse: før vi skifter fra "tracking" til "generating keyframes," indsætter vi en 120 ms forsinkelse, så Flutter faktisk maler "100%." Uden den lander den endelige fremskridtsramme og statusændringen i samme microtask batch, og brugeren ser aldrig fuldførelsen.

Tilstandsmaskine, kort fortalt

Alt dette koordineres af en Riverpod ReframeNotifier, der driver en status enum:

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

Flere indgangspunkter føder den samme tracker: tryk-for-at-detektere (YOLO overlay), manuel bbox, eller den Insta360-stil tegn-direkte-på-afspiller-gestus. De konvergerer alle på KeyframeGenerator.production().generate(...) → KeyframeSmoother.smooth(...), og derfra driver interpolatoren hver previewede og eksporterede frame.

Lærte lektioner

  1. Hold matematikken ren. Ingen Flutter imports i projektions-/interpolationslaget. Det er den eneste grund til, at dette var testbart.
  2. Quaternion SLERP for retning; LERP for skalarer. Interpoler ikke Euler-vinkler, og SLERP ikke din zoom.
  3. ±180° sømmen er en cross-cutting concern, ikke et edge case. Den genopstår i primitiver, generering, projektion og afspilleren.
  4. Forene dine konventioner én gang, symmetrisk. Den −90° yaw offset mellem Dart og OpenGL anvendes ved begge kanalgrænser, så preview == eksport.
  5. Proxy for interaktion, original for output. Husk blot at skalere dine koordinater mellem de to.
  6. "Færdig med rendering" ≠ "spiller korrekt." Budgetter for en FFmpeg post-pass for at rette moov atom placering og audio PTS, og sonder før du tilføjer audio flags.
  7. Følelse er en funktion. Bevægelsesbegrænsninger, adaptiv FOV og bevægelsesføring er det, der adskiller "teknisk genindrammet" fra "ser ud som om en professionel har optaget det."

Resultatet: en fuldkommen on-device 360° reframe editor — tryk på et motiv, lad det spore, juster keyframes, og eksporter en ren flad video — bygget i Flutter med et tyndt native GPU-lag, der kun udfører det arbejde, Dart ikke kan gøre hurtigt nok.

Flutter360VideoOpenGLQuaternions
Saurav Kumar Gupta's image

Om forfatteren

Saurav Kumar Gupta

AI & Cloud Solutions Expert at MicrocosmWorks

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

Vil du lære mere?

Kontakt os for at diskutere, hvordan vi kan hjælpe med at implementere disse løsninger for din virksomhed.

Kom i Kontakt

Ofte stillede spørgsmål

360° video reframing is the process of converting equirectangular 360° footage into a standard flat video by controlling a virtual camera's direction, zoom, and field of view over time.

Quaternions enable smooth camera rotations without gimbal lock or abrupt transitions, making them ideal for creating natural-looking camera movements in 360° video reframing.

Flutter uses spherical projection for live previews, quaternion-based interpolation for camera motion, and a native OpenGL GPU pipeline to export high-quality reframed videos efficiently.

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.

GPU acceleration significantly reduces rendering time by processing projection and video encoding on the graphics hardware, making high-resolution 360° video export practical on mobile devices.

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!