Skip to content

Remap360

Remap-table flattening for legacy 360 formats.

GoPro MAX/MAX2 .360 files store the sphere as two strips of a custom equi-angular cubemap (EAC) that stock ffmpeg cannot unwrap; legacy Ricoh Theta S files store two 90-degree-rotated fisheye circles in one 16:9 frame. Both become plain equirectangular through the same machinery: numpy-generated remap tables (16-bit PGM) driving ffmpeg's remap filter, with a feathered maskedmerge blend across the unstitched seams — the same two-pass pattern as stitch_dual_fisheye in _360video.

The GoPro mapping is a port of Paul Bourke's max2sphere reference (paulbourke.net/panorama/gopromax2sphere/). MAX2-resolution files are handled by proportional template scaling and are experimental until validated against a real recording.

probe_gopro360

probe_gopro360(path)

Stream inventory + strip geometry of a GoPro two-strip container.

Works on original .360 files and on chunk-merged .mkv copies. Returns {"video": [{index,width,height} x2], "audio": [{index,codec,channels}], "centerwidth", "sidewidth", "blendwidth", "experimental"}. Raises ValueError naming what was found when the file does not match the two-strip pattern.

Source code in musicalgestures/_remap360.py
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
def probe_gopro360(path):
    """Stream inventory + strip geometry of a GoPro two-strip container.

    Works on original .360 files and on chunk-merged .mkv copies. Returns
    {"video": [{index,width,height} x2], "audio": [{index,codec,channels}],
    "centerwidth", "sidewidth", "blendwidth", "experimental"}. Raises
    ValueError naming what was found when the file does not match the
    two-strip pattern.
    """
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-show_entries",
         "stream=index,codec_type,codec_name,width,height,channels",
         "-of", "json", str(path)],
        capture_output=True, text=True, check=True)
    streams = json.loads(out.stdout).get("streams", [])
    video = [{"index": s["index"], "width": s.get("width"),
              "height": s.get("height")}
             for s in streams if s.get("codec_type") == "video"]
    audio = [{"index": s["index"], "codec": s.get("codec_name", "none"),
              "channels": s.get("channels", 0)}
             for s in streams if s.get("codec_type") == "audio"]
    inventory = (f"{len(video)} video "
                 f"{[(v['width'], v['height']) for v in video]}, "
                 f"{len(audio)} audio "
                 f"{[(a['codec'], a['channels']) for a in audio]}")
    if len(video) != 2 or video[0]["width"] != video[1]["width"] \
            or video[0]["height"] != video[1]["height"]:
        raise ValueError(f"not a GoPro two-strip .360: found {inventory}")
    w, h = video[0]["width"], video[0]["height"]
    if 3 * h > w:
        raise ValueError(f"not a GoPro two-strip .360: found {inventory} "
                         f"(strip narrower than three faces)")
    if (w, h) in GOPRO_TEMPLATES:
        cw, sw, bw = GOPRO_TEMPLATES[(w, h)]
        experimental = False
    else:                       # MAX2 & friends: scale the 5.6K template
        cw = round(w * 1376 / 4096)
        sw = round(w * 1344 / 4096)
        bw = max(2, round(w * 32 / 4096))
        experimental = True
    return {"video": video, "audio": audio, "centerwidth": cw,
            "sidewidth": sw, "blendwidth": bw, "experimental": experimental}

write_remap_pgm

write_remap_pgm(xmap, ymap, tmpdir)

Write x/y remap tables as 16-bit binary PGMs for ffmpeg's remap.

Values are integer source-pixel coordinates; 16-bit PGM payloads are big-endian per the Netpbm spec.

Source code in musicalgestures/_remap360.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def write_remap_pgm(xmap, ymap, tmpdir):
    """Write x/y remap tables as 16-bit binary PGMs for ffmpeg's remap.

    Values are integer source-pixel coordinates; 16-bit PGM payloads are
    big-endian per the Netpbm spec.
    """
    paths = []
    for name, arr in (("xmap", xmap), ("ymap", ymap)):
        a = np.ascontiguousarray(arr.astype(">u2"))
        p = os.path.join(tmpdir, f"{name}.pgm")
        with open(p, "wb") as f:
            f.write(f"P5\n{a.shape[1]} {a.shape[0]}\n65535\n".encode())
            f.write(a.tobytes())
        paths.append(p)
    return tuple(paths)

gopro_maps

gopro_maps(track_w, track_h, centerwidth, sidewidth, blendwidth, out_w, out_h)

Equirect -> vstacked GoPro strips: dual sample maps + blend alpha.

Port of max2sphere's FindFaceUV/GetColour (Paul Bourke). Returns (xmapL, ymapL, xmapR, ymapR, alpha): two source-coordinate maps into the double-height stacked frame (strip 1 on top) and the weight of the R sample (nonzero only in the unstitched seam zones of the four side faces).

Source code in musicalgestures/_remap360.py
 98
 99
100
101
102
103
104
105
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
def gopro_maps(track_w, track_h, centerwidth, sidewidth, blendwidth,
               out_w, out_h):
    """Equirect -> vstacked GoPro strips: dual sample maps + blend alpha.

    Port of max2sphere's FindFaceUV/GetColour (Paul Bourke). Returns
    (xmapL, ymapL, xmapR, ymapR, alpha): two source-coordinate maps into
    the double-height stacked frame (strip 1 on top) and the weight of the
    R sample (nonzero only in the unstitched seam zones of the four side
    faces).
    """
    # NOTE (port-check deviation, see task-2-report.md): max2sphere.c samples
    # each output pixel at x0 = i / width, y0 = j / height (max2sphere.c
    # lines ~116-124), i.e. the pixel's un-offset grid position, not a
    # centred (i+0.5)/width sample. Matching that here (rather than the
    # brief's +0.5-centred formula) is required for FRONT/BACK/TOP/DOWN
    # face boundaries to land where the test data expects them.
    #
    # Coordinates never exceed a handful of thousand pixels, far below
    # float32's ~7-significant-digit precision floor, so the geometry
    # below runs in float32 throughout; large intermediates are freed as
    # soon as they are no longer needed to keep peak memory down.
    jj, ii = np.meshgrid(np.arange(out_h, dtype=np.float32),
                         np.arange(out_w, dtype=np.float32), indexing="ij")
    lon = ii / out_w * 2 * np.pi - np.pi             # -pi..pi
    lat = np.pi / 2 - jj / out_h * np.pi             # +pi/2..-pi/2
    del jj, ii
    px = np.cos(lat) * np.sin(lon)
    py = np.cos(lat) * np.cos(lon)
    pz = np.sin(lat)
    del lon, lat

    ax, ay, az = np.abs(px), np.abs(py), np.abs(pz)
    face = np.full((out_h, out_w), _FRONT, dtype=np.int8)
    face = np.where((ax >= ay) & (ax >= az) & (px < 0), _LEFT, face)
    face = np.where((ax >= ay) & (ax >= az) & (px >= 0), _RIGHT, face)
    face = np.where((ay > ax) & (ay >= az) & (py >= 0), _FRONT, face)
    face = np.where((ay > ax) & (ay >= az) & (py < 0), _BACK, face)
    face = np.where((az > ax) & (az > ay) & (pz >= 0), _TOP, face)
    face = np.where((az > ax) & (az > ay) & (pz < 0), _DOWN, face)

    fourdivpi = 4.0 / np.pi
    dom = np.select([face == _LEFT, face == _RIGHT, face == _FRONT,
                     face == _BACK, face == _TOP, face == _DOWN],
                    [ax, ax, ay, ay, az, az])
    dom = np.maximum(dom, 1e-12)
    del ax, ay, az
    qx = np.arctan(px / dom) * fourdivpi
    qy = np.arctan(py / dom) * fourdivpi
    qz = np.arctan(pz / dom) * fourdivpi
    del px, py, pz, dom

    u = np.select(
        [face == _LEFT, face == _RIGHT, face == _FRONT,
         face == _BACK, face == _TOP, face == _DOWN],
        [(qy + 1), (1 - qy), (qx + 1), (1 - qx), (1 - qx), (1 - qx)]) / 2
    v = np.select(
        [face == _LEFT, face == _RIGHT, face == _FRONT,
         face == _BACK, face == _TOP, face == _DOWN],
        [(qz + 1), (qz + 1), (qz + 1), (qz + 1), (qy + 1), (1 - qy)]) / 2
    u = np.clip(u, 0, 1 - 1e-9)
    v = np.clip(v, 0, 1 - 1e-9)
    del qx, qy, qz

    # RotateUV90 for DOWN, BACK, TOP (port-check Step 0 verified this)
    rot = (face == _DOWN) | (face == _BACK) | (face == _TOP)
    u, v = np.where(rot, v, u), np.where(rot, 1 - u, v)
    # real GoPro MAX files store every face with v inverted in the
    # face-local (post-rotation) frame relative to the max2sphere
    # formulas — validated on 2023-12-18 lab footage, where the
    # un-flipped mapping renders the equatorial band upside-down
    v = np.clip(1.0 - v, 0, 1 - 1e-9)

    second = (face == _BACK) | (face == _DOWN) | (face == _TOP)
    y_off = np.where(second, np.float32(track_h), np.float32(0))
    center = (face == _FRONT) | (face == _BACK)
    left_slot = (face == _LEFT) | (face == _DOWN)
    x0 = np.where(center, np.float32(sidewidth),
                  np.where(left_slot, np.float32(0),
                           np.float32(sidewidth + centerwidth)))

    # side faces: split halves + seam blend (GetColour)
    duv = blendwidth / sidewidth
    uL = 2 * (0.5 - duv) * u
    uR = 2 * (0.5 - duv) * (u - 0.5) + 0.5 + duv
    left_only = uL <= 0.5 - 2 * duv
    right_only = uR >= 0.5 + 2 * duv
    blend = ~(left_only | right_only)
    alpha = np.where(blend & ~center,
                     (uL - 0.5 + 2 * duv) / (2 * duv), 0.0)
    alpha = np.clip(alpha, 0.0, 1.0)

    u_l = np.where(right_only, uR, uL)     # L map: left sample unless right-only
    u_r = np.where(left_only, uL, uR)      # R map: right sample unless left-only
    w_face = np.where(center, np.float32(centerwidth), np.float32(sidewidth))
    xL = x0 + np.where(center, u, u_l) * w_face
    xR = x0 + np.where(center, u, u_r) * w_face
    y = y_off + v * track_h
    return xL, y.copy(), xR, y.copy(), alpha

gopro360_dual_fisheye_average

gopro360_dual_fisheye_average(path, target_name=None, fov=180.0, size=704, fps=2.0, transparent=True, print_cmd=False)

The time-average of a .360 as one dual-fisheye image, without writing a video first.

For a recording of somebody standing still this is the useful still: whatever held position resolves, whatever moved smears, and a single frame cannot show either. Returns the path to a PNG, RGBA with the area outside each circle transparent when transparent is set.

fps decimates before averaging. The mean of a stationary scene converges long before every frame is used -- a few hundred samples is plenty -- and decoding 4K equi-angular cubemap frames is the whole cost of this operation, so sampling at 2 Hz rather than 30 does the same job for a fifteenth of the work. Pass fps=None to average every frame.

Frames are accumulated in float64 from a raw pipe rather than written out and re-read. An 8-bit running mean over a few hundred frames loses roughly a bit of precision at the point where the averaging is meant to be revealing motion smaller than a pixel.

path may be several files. GoPro splits a recording into chapters, and averaging each chapter separately and combining the means weighted by frame count is arithmetically identical to averaging their concatenation -- while skipping the concatenation, which for a full session is an 8 GB lossless copy written and read back before any useful work starts.

See gopro360_to_dual_fisheye for what fov means and why it has to be recorded.

Source code in musicalgestures/_remap360.py
283
284
285
286
287
288
289
290
291
292
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
def gopro360_dual_fisheye_average(path, target_name=None, fov=180.0, size=704,
                                  fps=2.0, transparent=True, print_cmd=False):
    """The time-average of a .360 as one dual-fisheye image, without writing a video first.

    For a recording of somebody standing still this is the useful still: whatever held position
    resolves, whatever moved smears, and a single frame cannot show either. Returns the path to a
    PNG, RGBA with the area outside each circle transparent when `transparent` is set.

    `fps` decimates before averaging. The mean of a stationary scene converges long before every
    frame is used -- a few hundred samples is plenty -- and decoding 4K equi-angular cubemap frames
    is the whole cost of this operation, so sampling at 2 Hz rather than 30 does the same job for a
    fifteenth of the work. Pass `fps=None` to average every frame.

    Frames are accumulated in float64 from a raw pipe rather than written out and re-read. An 8-bit
    running mean over a few hundred frames loses roughly a bit of precision at the point where the
    averaging is meant to be revealing motion smaller than a pixel.

    `path` may be several files. GoPro splits a recording into chapters, and averaging each chapter
    separately and combining the means weighted by frame count is arithmetically identical to
    averaging their concatenation -- while skipping the concatenation, which for a full session is
    an 8 GB lossless copy written and read back before any useful work starts.

    See `gopro360_to_dual_fisheye` for what `fov` means and why it has to be recorded.
    """
    from musicalgestures._utils import generate_outfilename

    paths = [str(path)] if isinstance(path, (str, os.PathLike)) else [str(p) for p in path]
    path = paths[0]
    info = probe_gopro360(path)
    h = info["video"][0]["height"]
    eq_w = (3 * h) // 2 * 2
    if target_name is None:
        target_name = os.path.splitext(path)[0] + "_dualfisheye_average.png"
    target_name = generate_outfilename(target_name)

    with _gopro_remap_stage(path, info, eq_w, eq_w // 2, fps=fps) as (extra, graph, _tmp):
        graph += (f";[eq]format=gbrp,split[e1][e2];"
                  f"[e1]v360=input=e:output=fisheye:h_fov={fov}:v_fov={fov}:"
                  f"w={size}:h={size}[front];"
                  f"[e2]v360=input=e:output=fisheye:h_fov={fov}:v_fov={fov}:yaw=180:"
                  f"w={size}:h={size}[back];"
                  f"[front][back]hstack=inputs=2,format=rgb24[out]")
        nbytes = size * 2 * size * 3
        acc = np.zeros((size, size * 2, 3), np.float64)
        n = 0
        err = ""
        for src in paths:
            cmds = (["ffmpeg", "-v", "error", "-i", src] + extra
                    + ["-filter_complex", graph, "-map", "[out]", "-an",
                       "-f", "rawvideo", "-pix_fmt", "rgb24", "-"])
            if print_cmd:
                print(" ".join(cmds))
            proc = subprocess.Popen(cmds, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
                                    bufsize=nbytes)
            try:
                while True:
                    buf = proc.stdout.read(nbytes)
                    if len(buf) < nbytes:
                        break
                    acc += np.frombuffer(buf, np.uint8).reshape(size, size * 2, 3)
                    n += 1
            finally:
                proc.stdout.close()
                err = proc.stderr.read().decode(errors="replace")
                proc.wait()
        if n == 0:
            raise RuntimeError(f"no frames decoded from {paths}\n{err.strip()[:500]}")

    img = np.rint(acc / n).astype(np.uint8)[:, :, ::-1]          # RGB -> BGR for cv2
    if transparent:
        yy, xx = np.mgrid[0:size, 0:size]
        r = np.hypot(yy - (size - 1) / 2, xx - (size - 1) / 2)
        disc = (r <= size / 2).astype(np.uint8) * 255
        alpha = np.hstack([disc, disc])
        img = np.dstack([img, alpha])
    _imwrite(target_name, img)
    return target_name

gopro360_to_dual_fisheye

gopro360_to_dual_fisheye(path, target_name=None, fov=180.0, size=704, circular=True, crf=21, preset='fast', print_cmd=False)

Convert a GoPro MAX .360 to side-by-side fisheye circles, front then back.

The output is 2*size by size: two inscribed circles of size pixels, the layout GoPro's own LRV proxies use and what most dual-fisheye viewers expect.

fov is the angular width each circle covers, and it is a parameter to set deliberately rather than leave at a default. At 180 degrees a circle holds exactly a hemisphere and the two together hold the sphere with nothing to spare. Above 180 each holds more than a hemisphere, the pair overlap, and a given real-world direction lands closer to the centre of the circle --- at 195 degrees by a factor of 180/195, about eight per cent at the rim. Two renders at different fov have identical pixel dimensions and are not comparable as measurements, so anything measuring direction or angular size in the result must record which was used.

Why this is not v360=input=eac on the strips. GoPro's .360 is a custom equi-angular cubemap that stock ffmpeg cannot unwrap: pointing v360 at one 4096x1344 strip, or at the two stacked, yields a plausible-looking frame with scrambled corners rather than an error. The sphere is recovered here with the same remap tables flatten_gopro360 uses, and only then projected.

circular masks everything outside the inscribed circle to black, which is the convention for dual-fisheye files and what GoPro's own proxies look like. Without it v360 fills the square out to the corners, and those corners hold real image content at an angle wider than fov -- harmless to look at, wrong to measure, and enough to make two otherwise identical renders disagree about where the image ends.

Geometry is validated against synthetic fixtures and the max2sphere reference; strip order/orientation against real camera files is still unverified, as for flatten_gopro360.

Source code in musicalgestures/_remap360.py
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
436
def gopro360_to_dual_fisheye(path, target_name=None, fov=180.0, size=704,
                             circular=True, crf=21, preset="fast",
                             print_cmd=False):
    """Convert a GoPro MAX .360 to side-by-side fisheye circles, front then back.

    The output is `2*size` by `size`: two inscribed circles of `size` pixels, the layout GoPro's
    own LRV proxies use and what most dual-fisheye viewers expect.

    `fov` is the angular width each circle covers, and it is a parameter to set deliberately rather
    than leave at a default. At 180 degrees a circle holds exactly a hemisphere and the two together
    hold the sphere with nothing to spare. Above 180 each holds more than a hemisphere, the pair
    overlap, and a given real-world direction lands closer to the centre of the circle --- at 195
    degrees by a factor of 180/195, about eight per cent at the rim. Two renders at different `fov`
    have identical pixel dimensions and are not comparable as measurements, so anything measuring
    direction or angular size in the result must record which was used.

    Why this is not `v360=input=eac` on the strips. GoPro's `.360` is a custom equi-angular cubemap
    that stock ffmpeg cannot unwrap: pointing `v360` at one 4096x1344 strip, or at the two stacked,
    yields a plausible-looking frame with scrambled corners rather than an error. The sphere is
    recovered here with the same remap tables `flatten_gopro360` uses, and only then projected.

    `circular` masks everything outside the inscribed circle to black, which is the convention for
    dual-fisheye files and what GoPro's own proxies look like. Without it `v360` fills the square
    out to the corners, and those corners hold real image content at an angle wider than `fov` --
    harmless to look at, wrong to measure, and enough to make two otherwise identical renders
    disagree about where the image ends.

    Geometry is validated against synthetic fixtures and the max2sphere reference; strip
    order/orientation against real camera files is still unverified, as for `flatten_gopro360`.
    """
    from musicalgestures._utils import (ffmpeg_cmd, generate_outfilename,
                                        get_length)

    path = str(path)
    info = probe_gopro360(path)
    h = info["video"][0]["height"]
    eq_w = (3 * h) // 2 * 2
    eq_h = eq_w // 2
    if target_name is None:
        target_name = os.path.splitext(path)[0] + "_dualfisheye.mp4"
    target_name = generate_outfilename(target_name)

    with _gopro_remap_stage(path, info, eq_w, eq_h) as (extra, graph, tmpdir):
        # one equirect frame, projected twice: forward, and the same rotated half a turn
        graph += (f";[eq]format=gbrp,split[e1][e2];"
                  f"[e1]v360=input=e:output=fisheye:h_fov={fov}:v_fov={fov}:"
                  f"w={size}:h={size}[front];"
                  f"[e2]v360=input=e:output=fisheye:h_fov={fov}:v_fov={fov}:yaw=180:"
                  f"w={size}:h={size}[back]")
        if circular:
            mask = _circle_mask_png(size, tmpdir)
            n = len(extra) // 2 + 1                     # next free input index
            extra = extra + ["-i", mask]
            graph += (f";[{n}:v]format=gbrp,split[mk1][mk2];"
                      f"[front][mk1]blend=all_mode=multiply[fc];"
                      f"[back][mk2]blend=all_mode=multiply[bc];"
                      f"[fc][bc]hstack=inputs=2,format=yuv420p[out]")
        else:
            graph += ";[front][back]hstack=inputs=2,format=yuv420p[out]"
        cmds = (["ffmpeg", "-y", "-i", path] + extra
                + ["-filter_complex", graph, "-map", "[out]"]
                + _gopro_audio_args(info)
                + ["-shortest", "-c:v", "libx264", "-crf", str(crf),
                   "-preset", preset, target_name])
        ffmpeg_cmd(cmds, get_length(path),
                   pb_prefix=f"GoPro 360 to dual fisheye ({fov:g} deg):",
                   print_cmd=print_cmd)
    return target_name

flatten_gopro360

flatten_gopro360(path, target_name=None, width=None, height=None, crf=21, preset='fast', print_cmd=False)

Flatten a GoPro MAX/MAX2 .360 (or chunk-merged .mkv) to equirect.

vstacks the two EAC strips, runs two remap passes (left/right seam samples) and blends the unstitched zones with maskedmerge. The best audio stream (most channels — the ambisonic PCM track on a MAX) is carried over as AAC. Files that are not exact GoPro templates (e.g. MAX2) use proportionally scaled geometry and are experimental.

Geometry is validated against synthetic fixtures and the max2sphere reference; strip order/orientation against real camera files is still unverified.

Source code in musicalgestures/_remap360.py
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
467
468
469
470
471
472
473
474
475
476
def flatten_gopro360(path, target_name=None, width=None, height=None,
                     crf=21, preset="fast", print_cmd=False):
    """Flatten a GoPro MAX/MAX2 .360 (or chunk-merged .mkv) to equirect.

    vstacks the two EAC strips, runs two `remap` passes (left/right seam
    samples) and blends the unstitched zones with `maskedmerge`. The best
    audio stream (most channels — the ambisonic PCM track on a MAX) is
    carried over as AAC. Files that are not exact GoPro templates (e.g.
    MAX2) use proportionally scaled geometry and are experimental.

    Geometry is validated against synthetic fixtures and the max2sphere
    reference; strip order/orientation against real camera files is still
    unverified.
    """
    from musicalgestures._utils import (ffmpeg_cmd, generate_outfilename,
                                        get_length)

    path = str(path)
    info = probe_gopro360(path)
    h = info["video"][0]["height"]
    if width is None:
        width = (3 * h) // 2 * 2
    if height is None:
        height = width // 2
    if target_name is None:
        target_name = os.path.splitext(path)[0] + "_equirect.mp4"
    target_name = generate_outfilename(target_name)

    with _gopro_remap_stage(path, info, width, height) as (extra, graph, _tmp):
        graph += ";[eq]format=yuv420p[out]"
        cmds = (["ffmpeg", "-y", "-i", path] + extra
                + ["-filter_complex", graph, "-map", "[out]"]
                + _gopro_audio_args(info)
                + ["-shortest", "-c:v", "libx264", "-crf", str(crf),
                   "-preset", preset, target_name])
        ffmpeg_cmd(cmds, get_length(path),
                   pb_prefix="Flattening GoPro 360:", print_cmd=print_cmd)
    return target_name

theta_maps

theta_maps(in_w, in_h, out_w, out_h, fov_deg=191.5, roll_deg=(90.0, -90.0))

Equirect -> Ricoh Theta S rotated dual-fisheye source coordinates.

Legacy Theta S videos hold two fisheye circles side by side, each rotated 90 degrees in plane, in a 16:9 frame whose bottom band is unused. Front lens = left circle (axis +y), back = right (axis -y); equidistant fisheye model. Returns dual maps + seam-blend alpha like gopro_maps. fov_deg and roll_deg are tunable against a real file.

Source code in musicalgestures/_remap360.py
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
def theta_maps(in_w, in_h, out_w, out_h, fov_deg=191.5,
               roll_deg=(90.0, -90.0)):
    """Equirect -> Ricoh Theta S rotated dual-fisheye source coordinates.

    Legacy Theta S videos hold two fisheye circles side by side, each
    rotated 90 degrees in plane, in a 16:9 frame whose bottom band is
    unused. Front lens = left circle (axis +y), back = right (axis -y);
    equidistant fisheye model. Returns dual maps + seam-blend alpha like
    `gopro_maps`. fov_deg and roll_deg are tunable against a real file.
    """
    R = in_w / 4.0
    cy = R
    centers = (in_w / 4.0, 3.0 * in_w / 4.0)
    rolls = tuple(np.radians(r) for r in roll_deg)
    fov = np.radians(fov_deg)

    jj, ii = np.meshgrid(np.arange(out_h), np.arange(out_w), indexing="ij")
    lon = (ii + 0.5) / out_w * 2 * np.pi - np.pi
    # top-down rows: latitude runs south->north (validated against the
    # RICOH THETA app's own equirect export of the same file)
    lat = (jj + 0.5) / out_h * np.pi - np.pi / 2
    sx = np.cos(lat) * np.sin(lon)
    sy = np.cos(lat) * np.cos(lon)
    sz = np.sin(lat)

    maps = []
    for lens in range(2):
        axis = 1 if lens == 0 else -1
        costh = axis * sy
        theta = np.arccos(np.clip(costh, -1, 1))
        phi = np.arctan2(-sz, axis * sx)
        r = np.where(fov > 0, theta / (fov / 2), 0.0)
        x = centers[lens] + r * R * np.cos(phi + rolls[lens])
        y = cy + r * R * np.sin(phi + rolls[lens])
        maps.append((x, y, theta))

    (x0m, y0m, th0), (x1m, y1m, th1) = maps
    use1 = th1 < th0                       # back lens closer to its axis
    xL = np.where(use1, x1m, x0m)
    yL = np.where(use1, y1m, y0m)
    xR = np.where(use1, x0m, x1m)          # the *other* lens
    yR = np.where(use1, y0m, y1m)
    # blend where both lenses see the point (theta near 90 deg on both);
    # ramps 0 -> 0.5 over the last `margin` radians, reaching 0.5 exactly
    # at the geometric seam (theta == pi/2 on both lenses)
    margin = (fov / 2) - np.pi / 2         # half-overlap beyond a hemisphere
    prim = np.minimum(th0, th1)
    alpha = np.where(margin > 0,
                     0.5 * np.clip((prim - (np.pi / 2 - margin)) / margin,
                                   0.0, 1.0), 0.0)
    return xL, yL, xR, yR, alpha

flatten_theta360

flatten_theta360(path, target_name=None, width=1920, height=960, fov_deg=191.5, roll_deg=(90.0, -90.0), crf=21, preset='fast', print_cmd=False)

Flatten a legacy Ricoh Theta S dual-fisheye MP4 to equirectangular.

Explicit invocation only: a 16:9 MP4 is not identifiable as a Theta file by probing. Audio (mono on the Theta S) is passed through as AAC.

The 191.5-degree/±90-degree defaults are validated only against synthetic fixtures; a real Theta S recording may need fov_deg/roll_deg fine-tuning.

Source code in musicalgestures/_remap360.py
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
def flatten_theta360(path, target_name=None, width=1920, height=960,
                     fov_deg=191.5, roll_deg=(90.0, -90.0), crf=21,
                     preset="fast", print_cmd=False):
    """Flatten a legacy Ricoh Theta S dual-fisheye MP4 to equirectangular.

    Explicit invocation only: a 16:9 MP4 is not identifiable as a Theta
    file by probing. Audio (mono on the Theta S) is passed through as AAC.

    The 191.5-degree/±90-degree defaults are validated only against
    synthetic fixtures; a real Theta S recording may need fov_deg/roll_deg
    fine-tuning.
    """
    from musicalgestures._utils import (ffmpeg_cmd, generate_outfilename,
                                        get_length, get_widthheight,
                                        has_audio)

    path = str(path)
    in_w, in_h = get_widthheight(path)
    if target_name is None:
        target_name = os.path.splitext(path)[0] + "_equirect.mp4"
    target_name = generate_outfilename(target_name)

    xL, yL, xR, yR, alpha = theta_maps(in_w, in_h, width, height,
                                       fov_deg=fov_deg, roll_deg=roll_deg)
    tmpdir = tempfile.mkdtemp(prefix="mgt_remap360_")
    try:
        xlp, ylp = write_remap_pgm(np.rint(xL), np.rint(yL), tmpdir)
        os.rename(xlp, os.path.join(tmpdir, "xl.pgm"))
        os.rename(ylp, os.path.join(tmpdir, "yl.pgm"))
        xrp, yrp = write_remap_pgm(np.rint(xR), np.rint(yR), tmpdir)
        mask = os.path.join(tmpdir, "alpha.png")
        _imwrite(mask, (alpha * 255).astype(np.uint8))
        xlp, ylp = os.path.join(tmpdir, "xl.pgm"), os.path.join(tmpdir, "yl.pgm")

        graph = (f"[0:v]format=gbrp,split[s1][s2];"
                 f"[s1][1:v][2:v]remap[l];"
                 f"[s2][3:v][4:v]remap[r];"
                 f"[5:v]format=gray,scale={width}:{height}[m];"
                 f"[l][r][m]maskedmerge,format=yuv420p[out]")
        cmds = ["ffmpeg", "-y", "-i", path,
                "-i", xlp, "-i", ylp, "-i", xrp, "-i", yrp, "-i", mask,
                "-filter_complex", graph, "-map", "[out]"]
        if has_audio(path):
            cmds += ["-map", "0:a:0", "-c:a", "aac", "-b:a", "128k"]
        cmds += ["-shortest", "-c:v", "libx264", "-crf", str(crf),
                 "-preset", preset, target_name]
        ffmpeg_cmd(cmds, get_length(path),
                   pb_prefix="Flattening Theta 360:", print_cmd=print_cmd)
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)
    return target_name