Skip to content

360video

Projection

Bases: Enum

same as https://ffmpeg.org/ffmpeg-filters.html#v360.

make_seam_mask

make_seam_mask(width, height, feather_deg=8.0)

Column mask for feather-blending two hemispheres on an equirectangular canvas: 0 where the front lens (yaw 0) should be used, 255 for the back lens (yaw 180), with a linear ramp of ±feather_deg around the seams at longitude ±90°. Args: width (int): Mask width in pixels (full 360° canvas). height (int): Mask height in pixels. feather_deg (float): Half-width of the blend ramp in degrees. Returns: np.ndarray: uint8 mask of shape (height, width).

Source code in musicalgestures/_360video.py
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
def make_seam_mask(width: int, height: int, feather_deg: float = 8.0):
    """
    Column mask for feather-blending two hemispheres on an equirectangular
    canvas: 0 where the front lens (yaw 0) should be used, 255 for the back
    lens (yaw 180), with a linear ramp of ±feather_deg around the seams at
    longitude ±90°.
    Args:
        width (int): Mask width in pixels (full 360° canvas).
        height (int): Mask height in pixels.
        feather_deg (float): Half-width of the blend ramp in degrees.
    Returns:
        np.ndarray: uint8 mask of shape (height, width).
    """
    import numpy as np

    lon = (np.arange(width) + 0.5) / width * 360.0 - 180.0
    dist = np.minimum(np.abs(lon - 90.0), np.abs(lon + 90.0))
    back = np.abs(lon) > 90.0
    ramp = np.where(back, 128 + 127 * (dist / feather_deg),
                    128 - 127 * (dist / feather_deg))
    row = np.where(dist >= feather_deg,
                   np.where(back, 255.0, 0.0), ramp).astype(np.uint8)
    return np.tile(row, (height, 1))

calibrate_dual_fisheye_fov

calibrate_dual_fisheye_fov(front_file, back_file, time_s=1.0, candidates=None, print_result=False)

Estimate the effective lens field of view of a dual-fisheye pair (e.g. the two .insv files of an Insta360 camera) by projecting one frame of each lens to equirectangular at candidate FOVs and measuring the photometric mismatch in the seam bands at longitude ±90°. Args: front_file (str): Video of the front lens. back_file (str): Video of the back lens. time_s (float): Timestamp of the probe frame. candidates (list): FOVs (degrees) to try. Default 185–205. Returns: float: The FOV with the smallest seam mismatch.

Source code in musicalgestures/_360video.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
def calibrate_dual_fisheye_fov(front_file, back_file, time_s: float = 1.0,
                               candidates=None, print_result: bool = False):
    """
    Estimate the effective lens field of view of a dual-fisheye pair
    (e.g. the two .insv files of an Insta360 camera) by projecting one frame
    of each lens to equirectangular at candidate FOVs and measuring the
    photometric mismatch in the seam bands at longitude ±90°.
    Args:
        front_file (str): Video of the front lens.
        back_file (str): Video of the back lens.
        time_s (float): Timestamp of the probe frame.
        candidates (list): FOVs (degrees) to try. Default 185–205.
    Returns:
        float: The FOV with the smallest seam mismatch.
    """
    import cv2
    import numpy as np

    if candidates is None:
        # up to 205: Garmin VIRB 360 RAW-mode hemispheres are ~200 deg
        candidates = [185, 188, 191, 193, 195, 197, 200, 203, 205]
    with tempfile.TemporaryDirectory() as tmp:
        frames = {}
        for name, f in (("front", front_file), ("back", back_file)):
            out = os.path.join(tmp, f"{name}.png")
            subprocess.run(["ffmpeg", "-y", "-v", "error", "-ss", str(time_s),
                            "-i", str(f), "-frames:v", "1", out], check=True)
            frames[name] = out
        best = (None, float("inf"))
        for fov in candidates:
            eqs = {}
            for name, yaw in (("front", 0), ("back", 180)):
                out = os.path.join(tmp, f"{name}_{fov}.png")
                vf = (f"v360=input=fisheye:output=e:ih_fov={fov}:"
                      f"iv_fov={fov}:yaw={yaw}:w=1440:h=720")
                subprocess.run(["ffmpeg", "-y", "-v", "error", "-i",
                                frames[name], "-vf", vf, out], check=True)
                eqs[name] = cv2.imread(out, cv2.IMREAD_GRAYSCALE).astype(float)
            h, w = eqs["front"].shape
            band = int(w * 6 / 360)
            errs = []
            for c in (w // 4, 3 * w // 4):
                fb = eqs["front"][h // 5:4 * h // 5, c - band:c + band]
                bb = eqs["back"][h // 5:4 * h // 5, c - band:c + band]
                valid = (fb > 8) & (bb > 8)
                if valid.sum() > 100:
                    errs.append(np.abs(fb - bb)[valid].mean())
            score = float(np.mean(errs)) if errs else float("inf")
            if print_result:
                print(f"  fov {fov}: seam mismatch {score:.2f}")
            if score < best[1]:
                best = (float(fov), score)
    if print_result:
        print(f"=> calibrated lens FOV: {best[0]}")
    return best[0]

stitch_dual_fisheye

stitch_dual_fisheye(front_file, back_file, target_name=None, fov=None, feather_deg=8.0, width=None, height=None, crf=21, preset='fast', print_cmd=False)

Stitch a dual-fisheye pair (two single-lens files, e.g. Insta360 _00_/_10_ .insv) into one equirectangular video with a feathered seam blend. Each lens is projected to equirectangular separately (back lens at yaw 180) and the two are merged with a soft column mask, which avoids the hard seams of a plain v360=dfisheye conversion. Audio is taken from the front-lens file when present. Also fits Garmin VIRB 360 RAW-mode recordings, which store the two ~200-degree hemispheres as separate files. Args: front_file (str): Video of the front lens. back_file (str): Video of the back lens. target_name (str): Output path. Defaults to <front>_equirect.mp4. fov (float): Lens FOV in degrees. None runs calibrate_dual_fisheye_fov on a probe frame first. feather_deg (float): Half-width of the seam blend in degrees. width, height (int): Output size. Defaults to lens height × 2 by lens height (2:1 equirectangular). crf (int), preset (str): x264 rate control. Returns: str: Path of the stitched video.

Source code in musicalgestures/_360video.py
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
def stitch_dual_fisheye(front_file, back_file, target_name: str = None,
                        fov: float = None, feather_deg: float = 8.0,
                        width: int = None, height: int = None,
                        crf: int = 21, preset: str = "fast",
                        print_cmd: bool = False):
    """
    Stitch a dual-fisheye pair (two single-lens files, e.g. Insta360
    `_00_`/`_10_` .insv) into one equirectangular video with a feathered
    seam blend. Each lens is projected to equirectangular separately
    (back lens at yaw 180) and the two are merged with a soft column mask,
    which avoids the hard seams of a plain `v360=dfisheye` conversion.
    Audio is taken from the front-lens file when present.
    Also fits Garmin VIRB 360 RAW-mode recordings, which store the two
    ~200-degree hemispheres as separate files.
    Args:
        front_file (str): Video of the front lens.
        back_file (str): Video of the back lens.
        target_name (str): Output path. Defaults to `<front>_equirect.mp4`.
        fov (float): Lens FOV in degrees. None runs
            `calibrate_dual_fisheye_fov` on a probe frame first.
        feather_deg (float): Half-width of the seam blend in degrees.
        width, height (int): Output size. Defaults to lens height × 2 by
            lens height (2:1 equirectangular).
        crf (int), preset (str): x264 rate control.
    Returns:
        str: Path of the stitched video.
    """
    import cv2

    front_file, back_file = str(front_file), str(back_file)
    if fov is None:
        fov = calibrate_dual_fisheye_fov(front_file, back_file,
                                         print_result=print_cmd)
    if width is None or height is None:
        _, lens_h = get_widthheight(front_file)
        height = height or lens_h // 2
        width = width or 2 * height
    if target_name is None:
        target_name = os.path.splitext(front_file)[0] + "_equirect.mp4"
    target_name = generate_outfilename(target_name)

    mask_file = os.path.join(tempfile.mkdtemp(prefix="mgt360_"), "seam.png")
    cv2.imwrite(mask_file, make_seam_mask(width, height, feather_deg))

    proj = (f"v360=input=fisheye:output=e:ih_fov={fov}:iv_fov={fov}"
            f":w={width}:h={height}")
    # the mask is fed as a single frame (NOT -loop 1): framesync's default
    # eof_action=repeat holds it for the whole run while the lens streams
    # set the duration — an infinitely looped mask would keep maskedmerge
    # producing frames forever on inputs that have no audio stream to trip
    # ffmpeg's -shortest
    graph = (f"[0:v]{proj},format=gbrp[f];"
             f"[1:v]{proj}:yaw=180,format=gbrp[b];"
             f"[2:v]format=gray,scale={width}:{height}[m];"
             f"[f][b][m]maskedmerge,format=yuv420p[out]")
    cmds = ["ffmpeg", "-y", "-i", front_file, "-i", back_file,
            "-i", mask_file,
            "-filter_complex", graph, "-map", "[out]"]
    if has_audio(front_file):
        cmds += ["-map", "0:a:0", "-c:a", "aac", "-b:a", "192k"]
    cmds += ["-shortest", "-c:v", "libx264", "-crf", str(crf),
             "-preset", preset, target_name]
    ffmpeg_cmd(cmds, get_length(front_file),
               pb_prefix="Stitching dual fisheye:", print_cmd=print_cmd)
    return target_name

detect_projection

detect_projection(filename)

Guess the projection of a 360 video file. First looks for spherical metadata (the Spherical Mapping side data that GoPro MAX exports, Insta360 Studio, Garmin VIRB, and the RICOH THETA app all write to their equirectangular files), then falls back to the frame geometry: an exact 2:1 aspect ratio is taken as equirectangular, 1:1 as dual fisheye stacked in one square frame is NOT assumed (too ambiguous). Args: filename (str): Path to the video file. Returns: Projection: The detected projection, or None if undetectable.

Source code in musicalgestures/_360video.py
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
def detect_projection(filename: str):
    """
    Guess the projection of a 360 video file. First looks for spherical
    metadata (the `Spherical Mapping` side data that GoPro MAX exports,
    Insta360 Studio, Garmin VIRB, and the RICOH THETA app all write to
    their equirectangular files), then falls back to the frame geometry:
    an exact 2:1 aspect ratio is taken as equirectangular, 1:1 as dual
    fisheye stacked in one square frame is NOT assumed (too ambiguous).
    Args:
        filename (str): Path to the video file.
    Returns:
        Projection: The detected projection, or None if undetectable.
    """
    if os.path.splitext(filename)[1].lower() == ".360":
        return Projection.gopro_360
    try:
        out = subprocess.run(
            ["ffprobe", "-v", "error", "-select_streams", "v:0",
             "-print_format", "json", "-show_streams", filename],
            capture_output=True, check=True, text=True).stdout
        stream = json.loads(out)["streams"][0]
    except (subprocess.CalledProcessError, KeyError, IndexError,
            json.JSONDecodeError, FileNotFoundError):
        return None
    for sd in stream.get("side_data_list", []):
        if "spherical" in str(sd.get("side_data_type", "")).lower():
            proj = str(sd.get("projection", "equirectangular")).lower()
            if "equirect" in proj:
                return Projection.equirect
            if "cubemap" in proj:
                return Projection.c3x2
    w, h = stream.get("width"), stream.get("height")
    if w and h and w == 2 * h:
        return Projection.equirect
    return None

Bases: MgVideo

Class for 360 videos.

Args: filename (str): Path to the video file. projection (str, Projection, optional): Projection type. Defaults to None, which auto-detects via detect_projection (spherical metadata, .360 extension, or 2:1 equirectangular geometry) and raises ValueError if nothing can be inferred. camera (str): Camera type.

Source code in musicalgestures/_360video.py
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
def __init__(
    self,
    filename: str,
    projection: Union[str, Projection] = None,
    camera: str = None,
    **kwargs,
):
    """
    Args:
        filename (str): Path to the video file.
        projection (str, Projection, optional): Projection type. Defaults
            to None, which auto-detects via `detect_projection` (spherical
            metadata, .360 extension, or 2:1 equirectangular geometry) and
            raises ValueError if nothing can be inferred.
        camera (str): Camera type.
    """
    super().__init__(filename, **kwargs)
    self.filename = os.path.abspath(self.filename)
    if projection is None:
        projection = detect_projection(self.filename)
        if projection is None:
            raise ValueError(
                f"Could not detect the projection of {self.filename} "
                "(no spherical metadata, not 2:1). Pass projection= "
                "explicitly; see `Projection` for the options.")
    self.projection = self._parse_projection(projection)

    if camera is None:
        self.camera = None
    elif camera.lower() in CAMERA:
        self.camera = CAMERA[camera.lower()]
    else:
        raise Warning(f"Camera type '{camera}' not recognized.")

    # override self.show() with extra ipython_kwarg embed=True
    self.show = partial(self.show, embed=True)

filename instance-attribute

filename = os.path.abspath(self.filename)

projection instance-attribute

projection = self._parse_projection(projection)

camera instance-attribute

camera = None

show instance-attribute

show = partial(self.show, embed=True)

anglegram

anglegram(n_bins=360, latitude_weighting=True, title=None, cmap='inferno', target_name=None, overwrite=True, azimuth_convention='ambisonics')

Render the visual anglegram of an equirectangular 360 video: a time x azimuth heat map of visual motion energy, after Guo's ambiviz. Each column of the equirectangular inter-frame difference is collapsed (latitude-weighted mean over image rows) into motion energy at one azimuth, so horizontal position in the scene becomes readable as direction. The y-axis matches the audio anglegram of the sister toolbox ambiscape, making the two directly comparable side by side.

The video is streamed frame by frame (downscaled to n_bins columns with area interpolation), so memory use is independent of duration.

Parameters:

Name Type Description Default
n_bins int

Number of azimuth bins (also the horizontal downscaling target). Defaults to 360 (one-degree bins).

360
latitude_weighting bool

Weight image rows by cos(latitude) to compensate the polar oversampling of the equirectangular projection. Defaults to True.

True
title str

Optionally add a title to the figure. Defaults to None, which uses "Anglegram (visual motion)".

None
cmap str

Matplotlib colormap name. Defaults to 'inferno'.

'inferno'
target_name str

Target output name for the figure. Defaults to None (which uses the input filename with the suffix "_anglegram.png").

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames. Defaults to True.

True
azimuth_convention str

"ambisonics" (default; +90 = left, matches ambiscape) or "image" (azimuth increases with image x). See the module docstring on why this may need verifying per rig.

'ambisonics'

Returns:

Name Type Description
MgFigure 'MgFigure'

An MgFigure object referring to the figure and its data (data['anglegram'] of shape (n_bins, T-1), data['azimuth'], data['times']).

Source code in musicalgestures/_anglegram.py
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
def mg_anglegram(self, n_bins: int = 360, latitude_weighting: bool = True,
                 title: str | None = None, cmap: str = 'inferno',
                 target_name: str | None = None, overwrite: bool = True,
                 azimuth_convention: str = "ambisonics") -> "MgFigure":
    """
    Render the visual anglegram of an equirectangular 360 video: a time x
    azimuth heat map of visual motion energy, after Guo's ambiviz. Each
    column of the equirectangular inter-frame difference is collapsed
    (latitude-weighted mean over image rows) into motion energy at one
    azimuth, so horizontal position in the scene becomes readable as
    direction. The y-axis matches the audio anglegram of the sister toolbox
    ambiscape, making the two directly comparable side by side.

    The video is streamed frame by frame (downscaled to `n_bins` columns
    with area interpolation), so memory use is independent of duration.

    Args:
        n_bins (int, optional): Number of azimuth bins (also the horizontal
            downscaling target). Defaults to 360 (one-degree bins).
        latitude_weighting (bool, optional): Weight image rows by cos(latitude)
            to compensate the polar oversampling of the equirectangular
            projection. Defaults to True.
        title (str, optional): Optionally add a title to the figure. Defaults
            to None, which uses "Anglegram (visual motion)".
        cmap (str, optional): Matplotlib colormap name. Defaults to 'inferno'.
        target_name (str, optional): Target output name for the figure. Defaults
            to None (which uses the input filename with the suffix "_anglegram.png").
        overwrite (bool, optional): Whether to allow overwriting existing files
            or to automatically increment target filenames. Defaults to True.
        azimuth_convention (str, optional): "ambisonics" (default; +90 = left,
            matches ambiscape) or "image" (azimuth increases with image x).
            See the module docstring on why this may need verifying per rig.

    Returns:
        MgFigure: An MgFigure object referring to the figure and its data
            (`data['anglegram']` of shape (n_bins, T-1), `data['azimuth']`,
            `data['times']`).
    """
    from musicalgestures._360video import Projection
    if getattr(self, "projection", Projection.equirect) != Projection.equirect:
        raise ValueError(
            f"anglegram requires an equirectangular video, got projection "
            f"'{self.projection}'. Run convert_projection('equirect') first.")

    vidcap = cv2.VideoCapture(self.filename)
    fps = vidcap.get(cv2.CAP_PROP_FPS) or self.fps
    length = int(vidcap.get(cv2.CAP_PROP_FRAME_COUNT))
    height = int(vidcap.get(cv2.CAP_PROP_FRAME_HEIGHT))
    ds_h = min(height, 256)      # collapse target; keeps latitudes resolved

    if latitude_weighting:
        lat = (0.5 - (np.arange(ds_h) + 0.5) / ds_h) * np.pi
        row_w = np.cos(lat).astype(np.float32)
        row_w /= row_w.mean()
    else:
        row_w = np.ones(ds_h, dtype=np.float32)

    pb = MgProgressbar(total=length, prefix='Rendering anglegram:')
    columns, prev = [], None
    i = 0
    while vidcap.isOpened():
        ret, frame = vidcap.read()
        if not ret:
            pb.progress(length)
            break
        gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
        small = cv2.resize(gray, (n_bins, ds_h),
                           interpolation=cv2.INTER_AREA).astype(np.float32)
        if prev is not None:
            diff = np.abs(small - prev)
            columns.append(row_w @ diff / ds_h)       # (n_bins,)
        prev = small
        pb.progress(i)
        i += 1
    vidcap.release()

    gram = np.array(columns).T                        # (n_bins, T-1)
    gram = gram / (gram.max() + 1e-12)
    az = (np.arange(n_bins) + 0.5) / n_bins * 360.0 - 180.0
    if azimuth_convention == "ambisonics":
        az = -az[::-1]
        gram = gram[::-1]
    elif azimuth_convention != "image":
        raise ValueError("azimuth_convention must be 'ambisonics' or 'image'")
    times = (np.arange(gram.shape[1]) + 1) / fps

    fig, ax = plt.subplots(figsize=(12, 4), dpi=300)
    fig.patch.set_facecolor('white')
    fig.patch.set_alpha(1)
    if title is None:
        title = 'Anglegram (visual motion)'
    fig.suptitle(title, fontsize=16)
    ax.imshow(gram, extent=[0, times[-1] if len(times) else 1, az[0] - 180.0 / n_bins,
                            az[-1] + 180.0 / n_bins],
              origin='lower', norm=colors.PowerNorm(gamma=1.0 / 2.0),
              aspect='auto', cmap=cmap)
    ax.set_yticks([-180, -90, 0, 90, 180])
    ax.set_ylabel('Azimuth [Degrees]')
    ax.set_xlabel('Time [Seconds]')

    target_name = resolve_filename(self.of, '_anglegram.png', target_name, overwrite)
    plt.savefig(target_name, format='png', transparent=False)
    plt.close()

    data = {
        "FPS": fps,
        "path": self.of,
        "times": times,
        "azimuth": az,
        "anglegram": gram,
        "azimuth_convention": azimuth_convention,
    }
    mgf = MgFigure(figure=fig, figure_type='video.anglegram', data=data,
                   layers=None, image=target_name)
    return mgf

aem_overlay

aem_overlay(aem_file, on='video', n_bins=72, strip_height=0.15, cmap='magma', alpha=0.6, time_bin=1.0, title=None, target_name=None, overwrite=True, azimuth_convention='ambisonics')

Overlay an azimuthal Audio Energy Map (AEM, after Guo's ambiviz) on the equirectangular video or on the visual anglegram, so where the sound energy comes from can be read against where the pixels move. The audio side enters through a file only (see load_aem for the expected CSV/TSV format, typically exported from ambiscape) — ambiscape is not imported.

With on='video', a translucent heat strip is rendered along the bottom of every frame: horizontal position is azimuth (aligned with the equirectangular longitude axis under the chosen convention), color is the audio energy at that azimuth around that time. With on='anglegram', the visual anglegram is drawn and the binned AEM is overlaid on the same time/azimuth axes as translucent filled contours.

Parameters:

Name Type Description Default
aem_file str

Path to the AEM CSV/TSV file (see load_aem).

required
on str

'video' or 'anglegram'. Defaults to 'video'.

'video'
n_bins int

Azimuth bins for the AEM grid. Defaults to 72 (5-degree bins — ambisonic localisation is far coarser than pixels).

72
strip_height float

Height of the heat strip as a fraction of the frame height (only for on='video'). Defaults to 0.15.

0.15
cmap str

Matplotlib colormap for the audio energy. Defaults to 'magma'.

'magma'
alpha float

Maximum opacity of the overlay in [0, 1]. Defaults to 0.6.

0.6
time_bin float

Width of the AEM time bins in seconds. Defaults to 1.0 (ambiscape's native rate).

1.0
title str

Figure title (only for on='anglegram'). Defaults to None.

None
target_name str

Target output name. Defaults to None (input filename + "_aem.mp4" or "_anglegram_aem.png").

None
overwrite bool

Whether to allow overwriting existing files or to automatically increment target filenames. Defaults to True.

True
azimuth_convention str

"ambisonics" (default) or "image"; must match how the anglegram/video is read. See module docstring.

'ambisonics'

Returns:

Name Type Description
MgVideo

For on='video', a new MgVideo of the overlaid video (original audio is muxed back in when present).

MgFigure

For on='anglegram', the combined figure (figure_type='video.anglegram_aem').

Source code in musicalgestures/_anglegram.py
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
def mg_aem_overlay(self, aem_file: str, on: str = 'video',
                   n_bins: int = 72, strip_height: float = 0.15,
                   cmap: str = 'magma', alpha: float = 0.6,
                   time_bin: float = 1.0, title: str | None = None,
                   target_name: str | None = None, overwrite: bool = True,
                   azimuth_convention: str = "ambisonics"):
    """
    Overlay an azimuthal Audio Energy Map (AEM, after Guo's ambiviz) on the
    equirectangular video or on the visual anglegram, so where the *sound*
    energy comes from can be read against where the *pixels* move. The audio
    side enters through a file only (see `load_aem` for the expected CSV/TSV
    format, typically exported from ambiscape) — ambiscape is not imported.

    With `on='video'`, a translucent heat strip is rendered along the bottom
    of every frame: horizontal position is azimuth (aligned with the
    equirectangular longitude axis under the chosen convention), color is the
    audio energy at that azimuth around that time. With `on='anglegram'`, the
    visual anglegram is drawn and the binned AEM is overlaid on the same
    time/azimuth axes as translucent filled contours.

    Args:
        aem_file (str): Path to the AEM CSV/TSV file (see `load_aem`).
        on (str, optional): 'video' or 'anglegram'. Defaults to 'video'.
        n_bins (int, optional): Azimuth bins for the AEM grid. Defaults to 72
            (5-degree bins — ambisonic localisation is far coarser than pixels).
        strip_height (float, optional): Height of the heat strip as a fraction
            of the frame height (only for `on='video'`). Defaults to 0.15.
        cmap (str, optional): Matplotlib colormap for the audio energy.
            Defaults to 'magma'.
        alpha (float, optional): Maximum opacity of the overlay in [0, 1].
            Defaults to 0.6.
        time_bin (float, optional): Width of the AEM time bins in seconds.
            Defaults to 1.0 (ambiscape's native rate).
        title (str, optional): Figure title (only for `on='anglegram'`).
            Defaults to None.
        target_name (str, optional): Target output name. Defaults to None
            (input filename + "_aem.mp4" or "_anglegram_aem.png").
        overwrite (bool, optional): Whether to allow overwriting existing files
            or to automatically increment target filenames. Defaults to True.
        azimuth_convention (str, optional): "ambisonics" (default) or "image";
            must match how the anglegram/video is read. See module docstring.

    Returns:
        MgVideo: For `on='video'`, a new MgVideo of the overlaid video
            (original audio is muxed back in when present).
        MgFigure: For `on='anglegram'`, the combined figure
            (`figure_type='video.anglegram_aem'`).
    """
    aem = load_aem(aem_file)
    az_edges = np.linspace(-180.0, 180.0, n_bins + 1)
    duration = get_length(self.filename)
    n_t = max(1, int(np.ceil(duration / time_bin)))
    t_edges = np.arange(n_t + 1) * time_bin
    H = _bin_aem(aem, t_edges, az_edges)               # (n_az, n_t), ambisonic az
    H = H / (H.max() + 1e-12)

    if on == 'anglegram':
        mgf = self.anglegram(azimuth_convention=azimuth_convention,
                             overwrite=overwrite)
        fig, ax = plt.subplots(figsize=(12, 4), dpi=300)
        fig.patch.set_facecolor('white')
        fig.patch.set_alpha(1)
        gram, az, times = (mgf.data['anglegram'], mgf.data['azimuth'],
                          mgf.data['times'])
        ax.imshow(gram, extent=[0, times[-1], az[0], az[-1]], origin='lower',
                  norm=colors.PowerNorm(gamma=1.0 / 2.0), aspect='auto',
                  cmap='gray')
        az_plot = az_edges if azimuth_convention == "ambisonics" else -az_edges[::-1]
        Hp = H if azimuth_convention == "ambisonics" else H[::-1]
        tc = (t_edges[:-1] + t_edges[1:]) / 2
        ac = (az_plot[:-1] + az_plot[1:]) / 2
        ax.contourf(tc, ac, Hp, levels=np.linspace(0.05, 1.0, 8),
                    cmap=cmap, alpha=alpha)
        ax.set_yticks([-180, -90, 0, 90, 180])
        ax.set_ylabel('Azimuth [Degrees]')
        ax.set_xlabel('Time [Seconds]')
        fig.suptitle(title or 'Anglegram (visual motion, gray) + AEM (audio, color)',
                     fontsize=16)
        target_name = resolve_filename(self.of, '_anglegram_aem.png',
                                       target_name, overwrite)
        plt.savefig(target_name, format='png', transparent=False)
        plt.close()
        data = dict(mgf.data)
        data.update({"aem": H, "aem_time_edges": t_edges,
                     "aem_azimuth_edges": az_edges})
        return MgFigure(figure=fig, figure_type='video.anglegram_aem',
                        data=data, layers=None, image=target_name)

    elif on == 'video':
        target_name = resolve_filename(self.of, '_aem.mp4', target_name, overwrite)

        vidcap = cv2.VideoCapture(self.filename)
        fps = vidcap.get(cv2.CAP_PROP_FPS) or self.fps
        length = int(vidcap.get(cv2.CAP_PROP_FRAME_COUNT))
        width = int(vidcap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(vidcap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        strip_px = max(2, int(round(height * strip_height)))

        # precompute one BGR strip row + alpha row per time bin:
        # image x = azimuth; ambisonic azimuth decreases with x (flip), image
        # convention increases with x
        colormap = plt.get_cmap(cmap)
        xs = (np.arange(width) + 0.5) / width * 360.0 - 180.0   # image az
        az_of_x = -xs if azimuth_convention == "ambisonics" else xs
        xi = np.clip(np.searchsorted(az_edges, az_of_x, side='right') - 1,
                     0, n_bins - 1)
        strip_rgba = colormap(H[xi, :].T)                       # (n_t, W, 4)
        strip_bgr = (strip_rgba[:, :, 2::-1] * 255).astype(np.float32)
        strip_alpha = (alpha * H[xi, :].T).astype(np.float32)[:, :, None]

        cmd = ['ffmpeg', '-y', '-s', f'{width}x{height}', '-r', str(fps),
               '-f', 'rawvideo', '-pix_fmt', 'bgr24', '-vcodec', 'rawvideo',
               '-i', '-', '-i', self.filename, '-map', '0:v']
        if has_audio(self.filename):
            cmd += ['-map', '1:a:0', '-c:a', 'aac']
        cmd += ['-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-shortest',
                target_name]
        process = ffmpeg_cmd(cmd, total_time=length, pipe='write')

        pb = MgProgressbar(total=length, prefix='Rendering AEM overlay:')
        i = 0
        while vidcap.isOpened():
            ret, frame = vidcap.read()
            if not ret:
                pb.progress(length)
                break
            ti = min(int(i / fps / time_bin), n_t - 1)
            band = frame[height - strip_px:, :, :].astype(np.float32)
            a = strip_alpha[ti]
            band = band * (1 - a) + strip_bgr[ti] * a
            frame[height - strip_px:, :, :] = band.astype(np.uint8)
            process.stdin.write(frame.astype(np.uint8).tobytes())
            pb.progress(i)
            i += 1
        vidcap.release()
        process.stdin.close()
        process.wait()

        from musicalgestures._video import MgVideo
        return MgVideo(target_name, returned_by_process=True)

    else:
        raise ValueError("on must be 'video' or 'anglegram'")

view

view(yaw=0, pitch=0, roll=0, h_fov=90, v_fov=60, width=None, height=None, target_name=None, print_cmd=False)

Extract a flat (rectilinear/perspective) view in a chosen direction from the 360 video, via ffmpeg's v360 filter, and return it as a regular MgVideo — a non-destructive alternative to convert_projection for running any standard MGT analysis (motiongrams, optical flow, pose...) on one direction of the scene. Args: yaw (float): Viewing direction, degrees, as ffmpeg v360's yaw rotation (0 = the equirectangular center). Note: v360's sign convention is not the ambisonic azimuth convention used by anglegram; verify direction on your own footage. pitch (float): Vertical viewing direction in degrees. roll (float): In-plane rotation in degrees. h_fov, v_fov (float): Horizontal/vertical field of view of the extracted view in degrees. Defaults to 90 x 60. width, height (int): Output size. Defaults to source height * (h_fov/90) by source height * (v_fov/90), rounded to even. target_name (str): Output path. Defaults to <input>_view_y<yaw>_p<pitch>.mp4. print_cmd (bool): Print the ffmpeg command. Defaults to False. Returns: MgVideo: The extracted view.

Source code in musicalgestures/_360video.py
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
def view(self, yaw: float = 0, pitch: float = 0, roll: float = 0,
         h_fov: float = 90, v_fov: float = 60, width: int = None,
         height: int = None, target_name: str = None,
         print_cmd: bool = False) -> "MgVideo":
    """
    Extract a flat (rectilinear/perspective) view in a chosen direction
    from the 360 video, via ffmpeg's v360 filter, and return it as a
    regular MgVideo — a non-destructive alternative to
    `convert_projection` for running any standard MGT analysis
    (motiongrams, optical flow, pose...) on one direction of the scene.
    Args:
        yaw (float): Viewing direction, degrees, as ffmpeg v360's `yaw`
            rotation (0 = the equirectangular center). Note: v360's sign
            convention is not the ambisonic azimuth convention used by
            `anglegram`; verify direction on your own footage.
        pitch (float): Vertical viewing direction in degrees.
        roll (float): In-plane rotation in degrees.
        h_fov, v_fov (float): Horizontal/vertical field of view of the
            extracted view in degrees. Defaults to 90 x 60.
        width, height (int): Output size. Defaults to source height *
            (h_fov/90) by source height * (v_fov/90), rounded to even.
        target_name (str): Output path. Defaults to
            `<input>_view_y<yaw>_p<pitch>.mp4`.
        print_cmd (bool): Print the ffmpeg command. Defaults to False.
    Returns:
        MgVideo: The extracted view.
    """
    _, src_h = get_widthheight(self.filename)
    if width is None:
        width = 2 * round(src_h * h_fov / 180)
    if height is None:
        height = 2 * round(src_h * v_fov / 180)
    if target_name is None:
        target_name = (f"{os.path.splitext(self.filename)[0]}"
                       f"_view_y{yaw:g}_p{pitch:g}.mp4")
    target_name = generate_outfilename(target_name)
    vf = (f"v360={self.projection}:flat:yaw={yaw}:pitch={pitch}:"
          f"roll={roll}:h_fov={h_fov}:v_fov={v_fov}:w={width}:h={height}")
    cmds = ["ffmpeg", "-y", "-i", self.filename, "-vf", vf, target_name]
    ffmpeg_cmd(cmds, get_length(self.filename),
               pb_prefix=f"Extracting view (yaw {yaw}, pitch {pitch}):",
               print_cmd=print_cmd)
    return MgVideo(target_name, returned_by_process=True)

from_dual_fisheye classmethod

from_dual_fisheye(front_file, back_file, camera=None, **stitch_kwargs)

Stitch a dual-fisheye pair (e.g. the _00_/_10_ .insv files of an Insta360 camera) into an equirectangular video and open it as an Mg360Video. See stitch_dual_fisheye for the stitching options (fov=None auto-calibrates the lens FOV on a probe frame).

Source code in musicalgestures/_360video.py
383
384
385
386
387
388
389
390
391
392
393
@classmethod
def from_dual_fisheye(cls, front_file, back_file, camera: str = None,
                      **stitch_kwargs):
    """
    Stitch a dual-fisheye pair (e.g. the `_00_`/`_10_` .insv files of an
    Insta360 camera) into an equirectangular video and open it as an
    Mg360Video. See `stitch_dual_fisheye` for the stitching options
    (`fov=None` auto-calibrates the lens FOV on a probe frame).
    """
    stitched = stitch_dual_fisheye(front_file, back_file, **stitch_kwargs)
    return cls(stitched, Projection.equirect, camera=camera)

convert_projection

convert_projection(target_projection, options=None, print_cmd=False, test=False)

Convert the video to a different projection. Args: target_projection (Projection): Target projection. options (Dict[str, str], optional): Options for the conversion. Defaults to None. print_cmd (bool, optional): Print the ffmpeg command. Defaults to False.

Source code in musicalgestures/_360video.py
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
def convert_projection(
    self,
    target_projection: Union[Projection, str],
    options: Dict[str, str] = None,
    print_cmd: bool = False,
    test: bool = False,
):
    """
    Convert the video to a different projection.
    Args:
        target_projection (Projection): Target projection.
        options (Dict[str, str], optional): Options for the conversion. Defaults to None.
        print_cmd (bool, optional): Print the ffmpeg command. Defaults to False.
    """
    target_projection = self._parse_projection(target_projection)

    if target_projection == self.projection:
        print(f"{self} is already in target projection {target_projection}.")
        return
    elif self.projection == Projection.gopro_360:
        if test:
            print(
                f"=> Test mode: would convert {self.filename} to {target_projection} with options {options}."
            )

        assert target_projection in [
            Projection.equirect,
            Projection.equirectangular,
        ], (
            f"Invalid target projection from gopro_360: {target_projection}, only equirect and equirectangular are supported."
        )

        from musicalgestures._remap360 import flatten_gopro360
        output_name = flatten_gopro360(self.filename)
        self.filename = output_name
        self.projection = target_projection

    else:
        output_name = generate_outfilename(
            f"{self.filename.split('.')[0]}_{target_projection}.mp4"
        )

        # parse options
        if options:
            options = "".join([f"{k}={options[k]}:" for k in options])[:-1]
            cmds = [
                "ffmpeg",
                "-i",
                self.filename,
                "-vf",
                f"v360={self.projection}:{target_projection}:{options}",
                output_name,
            ]
        else:
            cmds = [
                "ffmpeg",
                "-i",
                self.filename,
                "-vf",
                f"v360={self.projection}:{target_projection}",
                output_name,
            ]

        # execute conversion
        ffmpeg_cmd(
            cmds,
            get_length(self.filename),
            pb_prefix=f"Converting projection to {target_projection}:",
            print_cmd=print_cmd,
        )
        self.filename = output_name
        self.projection = target_projection

_parse_projection

_parse_projection(projection)

Parse projection type. Args: projection (str): Projection type.

Source code in musicalgestures/_360video.py
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
def _parse_projection(self, projection: Union[str, Projection]):
    """
    Parse projection type.
    Args:
        projection (str): Projection type.
    """
    if isinstance(projection, str):
        try:
            return Projection[projection.lower()]
        except KeyError:
            raise ValueError(
                f"Projection type '{projection}' not recognized. See `Projection` for available options."
            )
    elif isinstance(projection, Projection):
        return projection
    else:
        raise TypeError(f"Unsupported projection type: '{type(projection)}'.")