ambiviz 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ambiviz-0.0.1/PKG-INFO +32 -0
- ambiviz-0.0.1/README.md +12 -0
- ambiviz-0.0.1/ambiviz/__init__.py +10 -0
- ambiviz-0.0.1/ambiviz/aem.py +195 -0
- ambiviz-0.0.1/ambiviz/anglegram.py +275 -0
- ambiviz-0.0.1/ambiviz/directogram.py +98 -0
- ambiviz-0.0.1/ambiviz/utils.py +24 -0
- ambiviz-0.0.1/ambiviz.egg-info/PKG-INFO +32 -0
- ambiviz-0.0.1/ambiviz.egg-info/SOURCES.txt +16 -0
- ambiviz-0.0.1/ambiviz.egg-info/dependency_links.txt +1 -0
- ambiviz-0.0.1/ambiviz.egg-info/requires.txt +13 -0
- ambiviz-0.0.1/ambiviz.egg-info/top_level.txt +1 -0
- ambiviz-0.0.1/pyproject.toml +42 -0
- ambiviz-0.0.1/setup.cfg +4 -0
- ambiviz-0.0.1/tests/test_aem.py +38 -0
- ambiviz-0.0.1/tests/test_anglegram.py +131 -0
- ambiviz-0.0.1/tests/test_directogram.py +33 -0
- ambiviz-0.0.1/tests/test_plot.py +117 -0
ambiviz-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ambiviz
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Visualization tools for ambisonic audio and 360 video
|
|
5
|
+
Author-email: Arthur Jinyue Guo <jinyue.guo@imv.uio.no>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: numpy>=2.0.2
|
|
9
|
+
Requires-Dist: matplotlib>=3.7
|
|
10
|
+
Requires-Dist: librosa>=0.10
|
|
11
|
+
Requires-Dist: torchaudio>=2.0
|
|
12
|
+
Requires-Dist: opencv-python>=4.8
|
|
13
|
+
Requires-Dist: Pillow>=10.0
|
|
14
|
+
Requires-Dist: tqdm>=4.0
|
|
15
|
+
Requires-Dist: scipy>=1.10
|
|
16
|
+
Requires-Dist: scienceplots>=2.2.1
|
|
17
|
+
Requires-Dist: musicalgestures>=1.3.3
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# ambiviz
|
|
22
|
+
|
|
23
|
+
[](https://github.com/astral-sh/uv)
|
|
24
|
+
[](https://github.com/fisheggg/ambiviz/actions)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Visualization tools for ambisonic audio (and 360 video).
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## References
|
|
31
|
+
|
|
32
|
+
- The core ambisonics decoder is modified from [https://github.com/pedro-morgado/spatialaudiogen](https://github.com/pedro-morgado/spatialaudiogen).
|
ambiviz-0.0.1/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# ambiviz
|
|
2
|
+
|
|
3
|
+
[](https://github.com/astral-sh/uv)
|
|
4
|
+
[](https://github.com/fisheggg/ambiviz/actions)
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
Visualization tools for ambisonic audio (and 360 video).
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## References
|
|
11
|
+
|
|
12
|
+
- The core ambisonics decoder is modified from [https://github.com/pedro-morgado/spatialaudiogen](https://github.com/pedro-morgado/spatialaudiogen).
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import glob
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import cv2
|
|
11
|
+
import librosa
|
|
12
|
+
import musicalgestures
|
|
13
|
+
import numpy as np
|
|
14
|
+
import PIL
|
|
15
|
+
import tqdm
|
|
16
|
+
|
|
17
|
+
from .ambisonics.audio_to_aem import compute_aem
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def aem(
|
|
23
|
+
audio_path: str,
|
|
24
|
+
video_paths: List[str],
|
|
25
|
+
save_path: str | Path,
|
|
26
|
+
duration: float,
|
|
27
|
+
audio_offset: float = 0.0,
|
|
28
|
+
video_offset: float = 0.0,
|
|
29
|
+
normalization_mode: str = "global",
|
|
30
|
+
use_gpu: bool = False,
|
|
31
|
+
aem_mode: str = "aem",
|
|
32
|
+
aem_mask_alpha: float = 0.5,
|
|
33
|
+
verbose: bool = False,
|
|
34
|
+
to_db: bool = False,
|
|
35
|
+
**kwargs,
|
|
36
|
+
):
|
|
37
|
+
"""
|
|
38
|
+
Takes an FOA audio and a 360 video, plots the audio energy map on the video.
|
|
39
|
+
|
|
40
|
+
Parameters
|
|
41
|
+
----------
|
|
42
|
+
normalization_mode: "global" or "local"
|
|
43
|
+
if "global", normalize the AEM according to the min/max over the entire video.
|
|
44
|
+
if "local", normalize the AEM of each frame by itself
|
|
45
|
+
"""
|
|
46
|
+
assert normalization_mode in ["global", "local"]
|
|
47
|
+
assert aem_mode in ["aem"]
|
|
48
|
+
|
|
49
|
+
if verbose:
|
|
50
|
+
logging.basicConfig(level=logging.INFO)
|
|
51
|
+
|
|
52
|
+
# Load video
|
|
53
|
+
logger.info("Loading video...")
|
|
54
|
+
video, fps = musicalgestures.Mg360Video(
|
|
55
|
+
video_paths,
|
|
56
|
+
projection="equirectangular",
|
|
57
|
+
starttime=video_offset,
|
|
58
|
+
endtime=video_offset + duration,
|
|
59
|
+
).numpy()
|
|
60
|
+
video = np.copy(video) # make writable
|
|
61
|
+
logger.info(f"Video shape: {video.shape}")
|
|
62
|
+
logger.info(f"Video FPS: {fps}")
|
|
63
|
+
logger.info(
|
|
64
|
+
f"Video stats: max={np.max(video)}, min={np.min(video)}, mean={np.mean(video)}"
|
|
65
|
+
)
|
|
66
|
+
logger.info(f"Video dtype: {video.dtype}")
|
|
67
|
+
|
|
68
|
+
video_frame_shape = video.shape[1:3]
|
|
69
|
+
video = video * 0.95 # reduce brightness
|
|
70
|
+
|
|
71
|
+
audio_sr = librosa.get_samplerate(audio_path)
|
|
72
|
+
aem, timestamp, phi_mesh, nu_mesh = compute_aem(
|
|
73
|
+
audio_path,
|
|
74
|
+
mode=aem_mode,
|
|
75
|
+
fps=fps,
|
|
76
|
+
audio_frame_length=int(audio_sr * 4 / fps),
|
|
77
|
+
audio_hop_length=int(audio_sr * 2 / fps),
|
|
78
|
+
gpu=use_gpu,
|
|
79
|
+
verbose=verbose,
|
|
80
|
+
duration=duration,
|
|
81
|
+
offset=audio_offset,
|
|
82
|
+
to_db=to_db,
|
|
83
|
+
**kwargs,
|
|
84
|
+
)
|
|
85
|
+
# cv2 counts video frame from top left,
|
|
86
|
+
# aem counts from bottom left, so flip the aem vertically
|
|
87
|
+
aem = np.flip(aem, axis=1)
|
|
88
|
+
aem = np.repeat(
|
|
89
|
+
aem, 2, axis=0
|
|
90
|
+
) # when hoplength reduced to half fps, repeat each AEM frame once to match the length
|
|
91
|
+
|
|
92
|
+
if normalization_mode == "global":
|
|
93
|
+
# global normalize
|
|
94
|
+
aem = (aem - np.min(aem)) / (np.max(aem) - np.min(aem))
|
|
95
|
+
# aem_frame_gain = np.mean(aem, axis=(1, 2))
|
|
96
|
+
# aem_frame_gain = (aem_frame_gain - np.min(aem_frame_gain)) / (
|
|
97
|
+
# np.max(aem_frame_gain) - np.min(aem_frame_gain)
|
|
98
|
+
# )
|
|
99
|
+
logger.info(f"AEM shape: {aem.shape}")
|
|
100
|
+
logger.info(f"AEM stats: max={np.max(aem)}, min={np.min(aem)}, mean={np.mean(aem)}")
|
|
101
|
+
|
|
102
|
+
# add aem colormask to video
|
|
103
|
+
frames = range(min(video.shape[0], aem.shape[0]))
|
|
104
|
+
if verbose:
|
|
105
|
+
frames = tqdm.tqdm(frames, desc="=> rendering AEM mask to video")
|
|
106
|
+
for i in frames:
|
|
107
|
+
# aem_frame = aem[i]
|
|
108
|
+
# aem_frame = aem_frame_gain[i] * (aem[i] - np.min(aem[i])) / (np.max(aem[i]) - np.min(aem[i])) # gain+local normalization
|
|
109
|
+
if normalization_mode == "local":
|
|
110
|
+
# local normalize
|
|
111
|
+
aem_frame = (aem[i] - np.min(aem[i])) / (np.max(aem[i]) - np.min(aem[i]))
|
|
112
|
+
elif normalization_mode == "global":
|
|
113
|
+
# make local changes more drastic, but keeps the local max of the frame
|
|
114
|
+
aem_frame = aem[i]
|
|
115
|
+
aem_frame_max = np.max(aem_frame)
|
|
116
|
+
aem_frame = aem_frame**8
|
|
117
|
+
aem_frame = aem_frame / np.max(aem_frame) * aem_frame_max
|
|
118
|
+
|
|
119
|
+
aem_frame = (aem_mask_alpha * aem_frame * 255).astype(video.dtype)
|
|
120
|
+
# aem_frame = (aem_mask_alpha * aem[i] * 255).astype(video.dtype)
|
|
121
|
+
aem_frame_resized = PIL.Image.fromarray(aem_frame).resize(
|
|
122
|
+
(video_frame_shape[1], video_frame_shape[0])
|
|
123
|
+
)
|
|
124
|
+
# video[i, :, :, 2] = (video[i, :, :, 2] * (1 - aem_mask_alpha) + np.asarray(aem_frame_resized)).astype(video.dtype)
|
|
125
|
+
video[i, :, :, 2] = (video[i, :, :, 2] + np.asarray(aem_frame_resized)).astype(
|
|
126
|
+
video.dtype
|
|
127
|
+
)
|
|
128
|
+
video = np.clip(video, 0, 255)
|
|
129
|
+
|
|
130
|
+
# save video
|
|
131
|
+
save_path = Path(save_path).resolve().absolute()
|
|
132
|
+
audio_path = Path(audio_path).resolve().absolute()
|
|
133
|
+
if not save_path.suffix: # add a filename if a dir is given
|
|
134
|
+
save_path = save_path / f"{Path(audio_path).stem}.mp4"
|
|
135
|
+
save_path.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
else:
|
|
137
|
+
save_path.parent.mkdir(parents=True, exist_ok=True)
|
|
138
|
+
save_path_tmp = save_path.with_stem(save_path.stem + "_tmp")
|
|
139
|
+
video = video.astype(np.uint8)
|
|
140
|
+
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
|
141
|
+
out = cv2.VideoWriter(str(save_path_tmp), fourcc, fps, video_frame_shape[::-1])
|
|
142
|
+
range_ = range(video.shape[0])
|
|
143
|
+
if verbose:
|
|
144
|
+
range_ = tqdm.tqdm(range_, desc="=> writing video...")
|
|
145
|
+
for frame_idx in range_:
|
|
146
|
+
out.write(video[frame_idx])
|
|
147
|
+
out.release()
|
|
148
|
+
|
|
149
|
+
# merge audio and video, only take the first channel from ambisonics
|
|
150
|
+
cmd = f"ffmpeg -hide_banner -y -i '{save_path_tmp}' -ss {audio_offset} -i '{audio_path}' -filter_complex \"[1:a:0]pan=mono|c0=c0[a_out]\" -map 0:v:0 -map \"[a_out]\" -c:v copy -c:a aac -shortest '{save_path}'"
|
|
151
|
+
# cmd = [
|
|
152
|
+
# "ffmpeg",
|
|
153
|
+
# "-hide_banner",
|
|
154
|
+
# "-y",
|
|
155
|
+
# "-i",
|
|
156
|
+
# f"{save_path_tmp}",
|
|
157
|
+
# "-ss",
|
|
158
|
+
# f"{audio_offset}",
|
|
159
|
+
# "-i",
|
|
160
|
+
# f"{audio_path}",
|
|
161
|
+
# "-filter_complex",
|
|
162
|
+
# "[1:a:0]pan=mono|c0=c0[a_out]",
|
|
163
|
+
# "-map",
|
|
164
|
+
# "[a_out]",
|
|
165
|
+
# "-c:v",
|
|
166
|
+
# "copy",
|
|
167
|
+
# "-c:a",
|
|
168
|
+
# "aac",
|
|
169
|
+
# "-shortest",
|
|
170
|
+
# f"{save_path}",
|
|
171
|
+
# ]
|
|
172
|
+
subprocess.call(cmd, shell=True)
|
|
173
|
+
# subprocess.check_call(cmd)
|
|
174
|
+
os.remove(save_path_tmp)
|
|
175
|
+
|
|
176
|
+
if verbose:
|
|
177
|
+
print(f"=> Video saved to {save_path}")
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
if __name__ == "__main__":
|
|
181
|
+
audio_path = "../test_files/test_aem_3oa.wav"
|
|
182
|
+
video_path = "../test_files/test_aem_video.mp4"
|
|
183
|
+
|
|
184
|
+
aem(
|
|
185
|
+
audio_path,
|
|
186
|
+
[video_path],
|
|
187
|
+
"../test_output",
|
|
188
|
+
duration=30,
|
|
189
|
+
audio_offset=23.3 + 270,
|
|
190
|
+
video_offset=32.5 + 270,
|
|
191
|
+
verbose=True,
|
|
192
|
+
aem_mask_alpha=0.5,
|
|
193
|
+
ambi_order=3,
|
|
194
|
+
to_db=True,
|
|
195
|
+
)
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional, Tuple, Union
|
|
6
|
+
|
|
7
|
+
import librosa
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import numpy as np
|
|
10
|
+
import scienceplots
|
|
11
|
+
|
|
12
|
+
from .ambisonics.audio_to_aem import compute_aem
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compute_anglegram_from_aem(
|
|
18
|
+
aem_path: Optional[Union[str, os.PathLike, Path]] = None,
|
|
19
|
+
time_stamp=None,
|
|
20
|
+
phi_mesh=None,
|
|
21
|
+
nu_mesh=None,
|
|
22
|
+
aem=None,
|
|
23
|
+
) -> Tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
|
|
24
|
+
if aem_path is not None:
|
|
25
|
+
assert (
|
|
26
|
+
time_stamp is None and phi_mesh is None and nu_mesh is None and aem is None
|
|
27
|
+
), "If aem_path is provided, other parameters should be None."
|
|
28
|
+
else:
|
|
29
|
+
assert (
|
|
30
|
+
time_stamp is not None
|
|
31
|
+
and phi_mesh is not None
|
|
32
|
+
and nu_mesh is not None
|
|
33
|
+
and aem is not None
|
|
34
|
+
), "If aem_path is not provided, other parameters should not be None."
|
|
35
|
+
if aem_path is not None:
|
|
36
|
+
data = np.load(aem_path)
|
|
37
|
+
time_stamp, phi_mesh, nu_mesh, aem = (
|
|
38
|
+
data["time_stamp"],
|
|
39
|
+
data["phi_mesh"],
|
|
40
|
+
data["nu_mesh"],
|
|
41
|
+
data["aem"],
|
|
42
|
+
)
|
|
43
|
+
phi_record, nu_record, energy_record = [], [], []
|
|
44
|
+
|
|
45
|
+
aem_argmax = [np.unravel_index(np.argmax(r), r.shape) for r in aem]
|
|
46
|
+
for i, point in enumerate(aem_argmax):
|
|
47
|
+
phi_record.append(phi_mesh[point[0], point[1]])
|
|
48
|
+
nu_record.append(nu_mesh[point[0], point[1]])
|
|
49
|
+
energy_record.append(aem[i, point[0], point[1]])
|
|
50
|
+
phi_record = np.array(phi_record)
|
|
51
|
+
nu_record = np.array(nu_record)
|
|
52
|
+
energy_record = np.array(energy_record)
|
|
53
|
+
energy_record = librosa.amplitude_to_db(energy_record, ref=np.max)
|
|
54
|
+
|
|
55
|
+
return time_stamp, phi_record, nu_record, energy_record
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# def get_anglegram_mean_rms(audio_path) -> Tuple[float, np.ndarray]:
|
|
59
|
+
# """
|
|
60
|
+
# Calculate average rms of the audio file, made for noise estimation..
|
|
61
|
+
# """
|
|
62
|
+
# time_stamp, phi_mesh, nu_mesh, aem = compute_aem(
|
|
63
|
+
# audio_path=audio_path,
|
|
64
|
+
# fps=20,
|
|
65
|
+
# audio_frame_length=4800,
|
|
66
|
+
# aem_width=360, # angular_res = 1
|
|
67
|
+
# aem_height=180,
|
|
68
|
+
# gpu=True,
|
|
69
|
+
# batch_size=5,
|
|
70
|
+
# )
|
|
71
|
+
# phi_record, nu_record, energy_record = [], [], []
|
|
72
|
+
|
|
73
|
+
# aem_argmax = [np.unravel_index(np.argmax(r), r.shape) for r in aem]
|
|
74
|
+
# for i, point in enumerate(aem_argmax):
|
|
75
|
+
# phi_record.append(phi_mesh[point[0], point[1]])
|
|
76
|
+
# nu_record.append(nu_mesh[point[0], point[1]])
|
|
77
|
+
# energy_record.append(aem[i, point[0], point[1]])
|
|
78
|
+
# phi_record = np.array(phi_record)
|
|
79
|
+
# nu_record = np.array(nu_record)
|
|
80
|
+
# energy_record = np.array(energy_record)
|
|
81
|
+
|
|
82
|
+
# return np.mean(energy_record), energy_record
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def plot_anglegram(
|
|
86
|
+
time_record,
|
|
87
|
+
phi_record,
|
|
88
|
+
nu_record,
|
|
89
|
+
rms_record,
|
|
90
|
+
db_threshold=-60,
|
|
91
|
+
db_max=-20,
|
|
92
|
+
title: Optional[str] = None,
|
|
93
|
+
save_path: Optional[Union[str, os.PathLike[str], Path]] = None,
|
|
94
|
+
):
|
|
95
|
+
with plt.style.context(["science", "no-latex"]):
|
|
96
|
+
fig, ax = plt.subplots(2, 1, figsize=(12, 6))
|
|
97
|
+
if title:
|
|
98
|
+
fig.suptitle(title, fontsize=16)
|
|
99
|
+
|
|
100
|
+
sc = ax[0].scatter(
|
|
101
|
+
time_record[rms_record > db_threshold],
|
|
102
|
+
phi_record[rms_record > db_threshold],
|
|
103
|
+
c=rms_record[rms_record > db_threshold],
|
|
104
|
+
label="Azimuth",
|
|
105
|
+
alpha=0.9,
|
|
106
|
+
vmin=db_threshold,
|
|
107
|
+
vmax=db_max,
|
|
108
|
+
)
|
|
109
|
+
# find the closest multiple of 2pi to azimuth_min
|
|
110
|
+
azimuth_min_pi = np.floor(np.min(phi_record) / np.pi) * np.pi
|
|
111
|
+
azimuth_max_pi = np.ceil(np.max(phi_record) / np.pi) * np.pi
|
|
112
|
+
ax[0].set_ylim(azimuth_min_pi - 0.1, azimuth_max_pi + 0.1)
|
|
113
|
+
yticks = np.arange(azimuth_min_pi, azimuth_max_pi + 0.1, np.pi)
|
|
114
|
+
ax[0].set_yticks(yticks)
|
|
115
|
+
ax[0].set_yticklabels([f"{int(ytick / np.pi)}$\pi$" for ytick in yticks])
|
|
116
|
+
|
|
117
|
+
# plt.colorbar(sc, ax=ax[0], label="RMS (dB)")
|
|
118
|
+
ax[0].set(xlabel="Time (s)", ylabel="Azimuth (rad)")
|
|
119
|
+
# horizontal lines on pi and 2pi
|
|
120
|
+
for ytick in yticks:
|
|
121
|
+
if np.abs(ytick % (2 * np.pi)) < 1e-1:
|
|
122
|
+
ax[0].axhline(y=ytick, linestyle="-", color="black", alpha=0.5)
|
|
123
|
+
else:
|
|
124
|
+
ax[0].axhline(y=ytick, linestyle="--", alpha=0.3)
|
|
125
|
+
|
|
126
|
+
ax[0].grid(axis="x")
|
|
127
|
+
|
|
128
|
+
sc = ax[1].scatter(
|
|
129
|
+
time_record[rms_record > db_threshold],
|
|
130
|
+
nu_record[rms_record > db_threshold],
|
|
131
|
+
c=rms_record[rms_record > db_threshold],
|
|
132
|
+
label="Elevation",
|
|
133
|
+
alpha=0.9,
|
|
134
|
+
vmin=db_threshold,
|
|
135
|
+
vmax=db_max,
|
|
136
|
+
)
|
|
137
|
+
# ax[1].plot(time_record, nu_record, label='Elevation')
|
|
138
|
+
ax[1].set(
|
|
139
|
+
xlabel="Time (s)",
|
|
140
|
+
ylabel="Elevation (rad)",
|
|
141
|
+
ylim=(-0.5 * np.pi - 0.1, 0.5 * np.pi + 0.1),
|
|
142
|
+
)
|
|
143
|
+
ax[1].grid(axis="x")
|
|
144
|
+
yticks = np.array([-0.5 * np.pi, -0.25 * np.pi, 0, 0.25 * np.pi, 0.5 * np.pi])
|
|
145
|
+
ax[1].set_yticks(yticks)
|
|
146
|
+
ax[1].set_yticklabels(
|
|
147
|
+
[r"$-\pi/2$", r"$-\pi/4$", r"$0$", r"$\pi/4$", r"$\pi/2$"]
|
|
148
|
+
)
|
|
149
|
+
for ytick in yticks:
|
|
150
|
+
if ytick == 0:
|
|
151
|
+
ax[1].axhline(y=ytick, linestyle="-", color="black", alpha=0.5)
|
|
152
|
+
else:
|
|
153
|
+
ax[1].axhline(y=ytick, linestyle="--", alpha=0.3)
|
|
154
|
+
# plt.colorbar(sc, ax=ax[1], label="RMS (dB)")
|
|
155
|
+
fig.colorbar(sc, ax=ax.ravel().tolist(), label="RMS (dB)")
|
|
156
|
+
|
|
157
|
+
if save_path is not None:
|
|
158
|
+
plt.savefig(save_path)
|
|
159
|
+
logger.info(f"Figure saved to {save_path}")
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
ax,
|
|
163
|
+
time_record[rms_record > db_threshold],
|
|
164
|
+
phi_record[rms_record > db_threshold],
|
|
165
|
+
nu_record[rms_record > db_threshold],
|
|
166
|
+
rms_record[rms_record > db_threshold],
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def plot_anglemap(aem_path: str, save_path: Optional[str] = None, **kwargs):
|
|
171
|
+
data = np.load(aem_path)
|
|
172
|
+
time_stamp, _, _, aem = (
|
|
173
|
+
data["time_stamp"],
|
|
174
|
+
data["phi_mesh"],
|
|
175
|
+
data["nu_mesh"],
|
|
176
|
+
data["aem"],
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
logger.debug(f"aem shape: {aem.shape}") # (1093, 180, 360)
|
|
180
|
+
|
|
181
|
+
phi_img = np.mean(aem, axis=1) # (1093, 360)
|
|
182
|
+
phi_img = np.flip(phi_img, axis=1).T # (360, 1093), top left pi
|
|
183
|
+
logger.debug(f"phi_img shape: {phi_img.shape}")
|
|
184
|
+
|
|
185
|
+
nu_img = np.mean(aem, axis=2) # (1093, 180)
|
|
186
|
+
nu_img = np.flip(nu_img, axis=1).T # (180, 1093), top left pi/2
|
|
187
|
+
logger.debug(f"nu_img shape: {nu_img.shape}")
|
|
188
|
+
|
|
189
|
+
fig, ax = plt.subplots(2, 1, figsize=(12, 6), sharex=True)
|
|
190
|
+
fig.suptitle(
|
|
191
|
+
f"{os.path.basename(aem_path)}\nmin: {aem.mean():.2f}, max: {aem.max():.2f}",
|
|
192
|
+
fontsize=16,
|
|
193
|
+
)
|
|
194
|
+
ax[0].imshow(phi_img, cmap="hot", aspect="auto", **kwargs)
|
|
195
|
+
ax[0].set_yticks([0, 89, 179, 269, 359])
|
|
196
|
+
ax[0].set_yticklabels([r"$\pi$", r"$\pi/2$", r"$0$", r"$-\pi/2$", r"$-\pi$"])
|
|
197
|
+
ax[0].set_xticks(np.arange(0, len(time_stamp), 200))
|
|
198
|
+
ax[0].set_xticklabels(np.arange(0, len(time_stamp), 200) / 20)
|
|
199
|
+
|
|
200
|
+
ax[0].set_ylabel("Azimuth (rad)")
|
|
201
|
+
|
|
202
|
+
ax[1].imshow(nu_img, cmap="hot", aspect="auto", **kwargs)
|
|
203
|
+
ax[1].set_xlabel("Time (s)")
|
|
204
|
+
ax[1].set_ylabel("Elevation (rad)")
|
|
205
|
+
ax[1].set_yticks([0, 44, 89, 134, 179])
|
|
206
|
+
ax[1].set_yticklabels([r"$\pi/2$", r"$\pi/4$", r"$0$", r"$-\pi/4$", r"$-\pi/2$"])
|
|
207
|
+
|
|
208
|
+
if save_path is not None:
|
|
209
|
+
plt.savefig(save_path)
|
|
210
|
+
logger.info(f"=> Figure saved to {save_path}")
|
|
211
|
+
else:
|
|
212
|
+
plt.show()
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def frame_to_seconds(frame_idx, fps=30):
|
|
216
|
+
return frame_idx / fps
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def seconds_to_timestring(seconds):
|
|
220
|
+
return str(datetime.timedelta(seconds=seconds))
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def frame_to_timestring(frame_idx, fps=30):
|
|
224
|
+
return seconds_to_timestring(frame_to_seconds(frame_idx, fps))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def anglegram(
|
|
228
|
+
audio_path: Union[str, os.PathLike[str], Path],
|
|
229
|
+
save_path: Optional[Union[str, os.PathLike[str], Path]] = None,
|
|
230
|
+
fps: int = 20,
|
|
231
|
+
audio_frame_length: int = 4800,
|
|
232
|
+
aem_width: int = 90,
|
|
233
|
+
aem_height: int = 45,
|
|
234
|
+
gpu: bool = False,
|
|
235
|
+
db_threshold: int = -60,
|
|
236
|
+
db_max: int = -20,
|
|
237
|
+
title: Optional[str] = None,
|
|
238
|
+
batch_size: int = 5,
|
|
239
|
+
):
|
|
240
|
+
logger.debug(f"Computing AEM. Audio path: {audio_path}")
|
|
241
|
+
aem, time_stamp, phi_mesh, nu_mesh = compute_aem(
|
|
242
|
+
audio_path=audio_path,
|
|
243
|
+
fps=fps,
|
|
244
|
+
audio_frame_length=audio_frame_length,
|
|
245
|
+
aem_width=aem_width,
|
|
246
|
+
aem_height=aem_height,
|
|
247
|
+
gpu=gpu,
|
|
248
|
+
batch_size=batch_size,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
logger.debug(f"Computing anglegram. AEM shape: {aem.shape}")
|
|
252
|
+
timestamp, phi_record, nu_record, rms_record = compute_anglegram_from_aem(
|
|
253
|
+
time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
|
|
254
|
+
)
|
|
255
|
+
logger.debug(
|
|
256
|
+
f"rms record max: {rms_record.max()}, min: {rms_record.min()}, mean: {rms_record.mean()}"
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
logger.debug(f"Plotting anglegram. Save path: {save_path}")
|
|
260
|
+
if title is None:
|
|
261
|
+
title = os.path.basename(audio_path)
|
|
262
|
+
|
|
263
|
+
ax, *_ = plot_anglegram(
|
|
264
|
+
timestamp,
|
|
265
|
+
phi_record,
|
|
266
|
+
nu_record,
|
|
267
|
+
rms_record,
|
|
268
|
+
db_threshold=db_threshold,
|
|
269
|
+
db_max=db_max,
|
|
270
|
+
title=title,
|
|
271
|
+
save_path=save_path,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
if not save_path:
|
|
275
|
+
return ax
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Optional, Union
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import numpy as np
|
|
8
|
+
import scienceplots
|
|
9
|
+
|
|
10
|
+
from .ambisonics.audio_to_aem import compute_aem
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def plot_directogram(
|
|
16
|
+
angles,
|
|
17
|
+
energy,
|
|
18
|
+
title=None,
|
|
19
|
+
figsize=(8, 8),
|
|
20
|
+
dpi=300,
|
|
21
|
+
ax=None,
|
|
22
|
+
save_path=None,
|
|
23
|
+
):
|
|
24
|
+
with plt.style.context(["science", "no-latex"]):
|
|
25
|
+
if ax is None:
|
|
26
|
+
fig = plt.figure(figsize=figsize, dpi=dpi)
|
|
27
|
+
ax = fig.add_subplot(111, projection="polar")
|
|
28
|
+
ax.set_title(title if title is not None else "Directogram")
|
|
29
|
+
ax.bar(
|
|
30
|
+
x=angles / 180 * np.pi,
|
|
31
|
+
height=energy,
|
|
32
|
+
width=2 * np.pi / len(angles),
|
|
33
|
+
color="tab:blue",
|
|
34
|
+
alpha=0.7,
|
|
35
|
+
label="Summed directional energy",
|
|
36
|
+
)
|
|
37
|
+
ax.set_theta_zero_location("N")
|
|
38
|
+
|
|
39
|
+
if save_path is not None:
|
|
40
|
+
plt.savefig(save_path)
|
|
41
|
+
logger.info(f"Figure saved to {save_path}")
|
|
42
|
+
|
|
43
|
+
return ax
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def compute_directogram_from_aem(aem, time_stamp, phi_mesh, nu_mesh):
|
|
47
|
+
"""
|
|
48
|
+
Calculate the directogram from the AEM.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
aem: np.ndarray, shape (n_frames, n_nu, n_phi)
|
|
52
|
+
time_stamp: np.ndarray, shape (n_frames,)
|
|
53
|
+
phi_mesh: np.ndarray, azimuth. shape: (n_nu, n_phi)
|
|
54
|
+
nu_mesh: np.ndarray, elevation. shape: (n_nu, n_phi)
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
angles: np.ndarray, in degrees, shape (n_phi,)
|
|
58
|
+
energy: np.ndarray, shape (n_phi,)
|
|
59
|
+
"""
|
|
60
|
+
horizontal_nu_idx = nu_mesh.shape[0] // 2
|
|
61
|
+
horizontal_aem = aem[:, horizontal_nu_idx, :]
|
|
62
|
+
angles = phi_mesh[0, :] / np.pi * 180
|
|
63
|
+
energy = np.zeros_like(angles)
|
|
64
|
+
for i in range(horizontal_aem.shape[0]):
|
|
65
|
+
argmax_idx = np.argmax(horizontal_aem[i])
|
|
66
|
+
energy[argmax_idx] += horizontal_aem[i, argmax_idx]
|
|
67
|
+
|
|
68
|
+
return angles, energy
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def directogram(
|
|
72
|
+
audio_path: Union[str, os.PathLike, Path],
|
|
73
|
+
save_path: Optional[Union[str, os.PathLike, Path]] = None,
|
|
74
|
+
ax=None,
|
|
75
|
+
fps: int = 20,
|
|
76
|
+
audio_frame_length: int = 4800,
|
|
77
|
+
aem_width: int = 90,
|
|
78
|
+
aem_height: int = 45,
|
|
79
|
+
batch_size: int = 5,
|
|
80
|
+
gpu: bool = False,
|
|
81
|
+
):
|
|
82
|
+
aem, time_stamp, phi_mesh, nu_mesh = compute_aem(
|
|
83
|
+
audio_path=audio_path,
|
|
84
|
+
fps=fps,
|
|
85
|
+
audio_frame_length=audio_frame_length,
|
|
86
|
+
aem_width=aem_width,
|
|
87
|
+
aem_height=aem_height,
|
|
88
|
+
gpu=gpu,
|
|
89
|
+
batch_size=batch_size,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
logger.debug(f"Computing anglegram. AEM shape: {aem.shape}")
|
|
93
|
+
angles, energy = compute_directogram_from_aem(aem, time_stamp, phi_mesh, nu_mesh)
|
|
94
|
+
|
|
95
|
+
logger.debug(f"Plotting directogram. Save path: {save_path}")
|
|
96
|
+
ax = plot_directogram(angles, energy, ax=ax, save_path=save_path)
|
|
97
|
+
|
|
98
|
+
return ax, angles, energy
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def check_ambisonics_order(audio: np.ndarray) -> int:
|
|
5
|
+
"""
|
|
6
|
+
Check the order of ambisonics.
|
|
7
|
+
Raises error if the number of channels does not correspond to a valid ambisonics order.
|
|
8
|
+
|
|
9
|
+
Parameters:
|
|
10
|
+
audio: (n_channels, n_samples)
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
order: int, the order of ambisonics
|
|
14
|
+
"""
|
|
15
|
+
assert len(audio.shape) == 2, "Audio should be 2D (n_channels, n_samples)"
|
|
16
|
+
n_channels = audio.shape[0]
|
|
17
|
+
|
|
18
|
+
order = int(np.sqrt(n_channels) - 1)
|
|
19
|
+
if (order + 1) ** 2 != n_channels:
|
|
20
|
+
raise ValueError(
|
|
21
|
+
f"Number of channels {n_channels} does not correspond to a valid ambisonics order."
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
return order
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ambiviz
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Visualization tools for ambisonic audio and 360 video
|
|
5
|
+
Author-email: Arthur Jinyue Guo <jinyue.guo@imv.uio.no>
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: numpy>=2.0.2
|
|
9
|
+
Requires-Dist: matplotlib>=3.7
|
|
10
|
+
Requires-Dist: librosa>=0.10
|
|
11
|
+
Requires-Dist: torchaudio>=2.0
|
|
12
|
+
Requires-Dist: opencv-python>=4.8
|
|
13
|
+
Requires-Dist: Pillow>=10.0
|
|
14
|
+
Requires-Dist: tqdm>=4.0
|
|
15
|
+
Requires-Dist: scipy>=1.10
|
|
16
|
+
Requires-Dist: scienceplots>=2.2.1
|
|
17
|
+
Requires-Dist: musicalgestures>=1.3.3
|
|
18
|
+
Provides-Extra: dev
|
|
19
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
20
|
+
|
|
21
|
+
# ambiviz
|
|
22
|
+
|
|
23
|
+
[](https://github.com/astral-sh/uv)
|
|
24
|
+
[](https://github.com/fisheggg/ambiviz/actions)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
Visualization tools for ambisonic audio (and 360 video).
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
## References
|
|
31
|
+
|
|
32
|
+
- The core ambisonics decoder is modified from [https://github.com/pedro-morgado/spatialaudiogen](https://github.com/pedro-morgado/spatialaudiogen).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
ambiviz/__init__.py
|
|
4
|
+
ambiviz/aem.py
|
|
5
|
+
ambiviz/anglegram.py
|
|
6
|
+
ambiviz/directogram.py
|
|
7
|
+
ambiviz/utils.py
|
|
8
|
+
ambiviz.egg-info/PKG-INFO
|
|
9
|
+
ambiviz.egg-info/SOURCES.txt
|
|
10
|
+
ambiviz.egg-info/dependency_links.txt
|
|
11
|
+
ambiviz.egg-info/requires.txt
|
|
12
|
+
ambiviz.egg-info/top_level.txt
|
|
13
|
+
tests/test_aem.py
|
|
14
|
+
tests/test_anglegram.py
|
|
15
|
+
tests/test_directogram.py
|
|
16
|
+
tests/test_plot.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ambiviz
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "ambiviz"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Visualization tools for ambisonic audio and 360 video"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
authors = [
|
|
8
|
+
{name = "Arthur Jinyue Guo", email = "jinyue.guo@imv.uio.no"},
|
|
9
|
+
]
|
|
10
|
+
dependencies = [
|
|
11
|
+
"numpy>=2.0.2",
|
|
12
|
+
"matplotlib>=3.7",
|
|
13
|
+
"librosa>=0.10",
|
|
14
|
+
"torchaudio>=2.0",
|
|
15
|
+
"opencv-python>=4.8",
|
|
16
|
+
"Pillow>=10.0",
|
|
17
|
+
"tqdm>=4.0",
|
|
18
|
+
"scipy>=1.10",
|
|
19
|
+
"scienceplots>=2.2.1",
|
|
20
|
+
"musicalgestures>=1.3.3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
dev = ["pytest>=7.0"]
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
markers = [
|
|
28
|
+
"integration: tests that require real audio/video files and ffmpeg (deselect with '-m not integration')",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
[tool.ruff]
|
|
32
|
+
ignore = ["F401"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools]
|
|
35
|
+
packages = ["ambiviz"]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = [
|
|
40
|
+
"ipykernel>=6.31.0",
|
|
41
|
+
"pytest>=8.4.2",
|
|
42
|
+
]
|
ambiviz-0.0.1/setup.cfg
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Unit and integration tests for ambiviz.aem."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from ambiviz.aem import aem
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# Real test files (used by integration tests)
|
|
13
|
+
TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
|
|
14
|
+
REAL_AUDIO_PATH = TEST_FILES_DIR / "test_aem_3oa.wav"
|
|
15
|
+
REAL_VIDEO_PATH = TEST_FILES_DIR / "test_aem_video.mp4"
|
|
16
|
+
TEST_OUTPUT_DIR = Path(__file__).parent.parent / "test_output"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.integration
|
|
20
|
+
def test_anglegram_integration():
|
|
21
|
+
"""Integration test for aem"""
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
aem(
|
|
25
|
+
audio_path=REAL_AUDIO_PATH,
|
|
26
|
+
video_paths=[str(REAL_VIDEO_PATH)],
|
|
27
|
+
save_path=str(TEST_OUTPUT_DIR / "test_aem_output.mp4"),
|
|
28
|
+
duration=30,
|
|
29
|
+
audio_offset=23.3 + 270,
|
|
30
|
+
video_offset=32.5 + 270,
|
|
31
|
+
aem_mask_alpha=0.5,
|
|
32
|
+
ambi_order=3,
|
|
33
|
+
to_db=True,
|
|
34
|
+
normalization_mode="local",
|
|
35
|
+
verbose=True,
|
|
36
|
+
)
|
|
37
|
+
except Exception as e:
|
|
38
|
+
pytest.fail(f"Integration test for aem failed with exception: {e}")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Unit and integration tests for ambiviz.anglegram."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import MagicMock
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from ambiviz.anglegram import anglegram, compute_anglegram_from_aem
|
|
10
|
+
|
|
11
|
+
# Real test files (used by integration tests)
|
|
12
|
+
TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
|
|
13
|
+
REAL_AUDIO_PATH = TEST_FILES_DIR / "test_anglegram.wav"
|
|
14
|
+
TEST_OUTPUT_DIR = Path(__file__).parent.parent / "test_output"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Helpers / fixtures
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _make_aem_data(n_frames=10, height=4, width=8):
|
|
23
|
+
"""Return fake (aem, time_stamp, phi_mesh, nu_mesh) as compute_aem would."""
|
|
24
|
+
rng = np.random.default_rng(0)
|
|
25
|
+
aem = rng.random((n_frames, height, width)).astype(np.float32)
|
|
26
|
+
time_stamp = np.linspace(0, 1, n_frames)
|
|
27
|
+
phi_mesh, nu_mesh = np.meshgrid(
|
|
28
|
+
np.linspace(-np.pi, np.pi, width),
|
|
29
|
+
np.linspace(-np.pi / 2, np.pi / 2, height),
|
|
30
|
+
)
|
|
31
|
+
return aem, time_stamp, phi_mesh, nu_mesh
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_plot_return():
|
|
35
|
+
n = 5
|
|
36
|
+
return (MagicMock(), np.zeros(n), np.zeros(n), np.zeros(n), np.zeros(n))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ---------------------------------------------------------------------------
|
|
40
|
+
# compute_anglegram_from_aem unit tests
|
|
41
|
+
# ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class TestComputeAnglegramFromAem:
|
|
45
|
+
def test_output_shapes(self):
|
|
46
|
+
aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data(n_frames=10)
|
|
47
|
+
ts, phi, nu, energy = compute_anglegram_from_aem(
|
|
48
|
+
time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
|
|
49
|
+
)
|
|
50
|
+
assert ts.shape == (10,)
|
|
51
|
+
assert phi.shape == (10,)
|
|
52
|
+
assert nu.shape == (10,)
|
|
53
|
+
assert energy.shape == (10,)
|
|
54
|
+
|
|
55
|
+
def test_energy_is_db(self):
|
|
56
|
+
"""Energy record should be in dB: max value must be 0.0."""
|
|
57
|
+
aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data(n_frames=8)
|
|
58
|
+
_, _, _, energy = compute_anglegram_from_aem(
|
|
59
|
+
time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
|
|
60
|
+
)
|
|
61
|
+
assert np.max(energy) == pytest.approx(0.0, abs=1e-5)
|
|
62
|
+
|
|
63
|
+
def test_energy_nonpositive(self):
|
|
64
|
+
"""All dB values should be <= 0."""
|
|
65
|
+
aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data(n_frames=8)
|
|
66
|
+
_, _, _, energy = compute_anglegram_from_aem(
|
|
67
|
+
time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
|
|
68
|
+
)
|
|
69
|
+
assert np.all(energy <= 0.0)
|
|
70
|
+
|
|
71
|
+
def test_picks_argmax_coordinates(self):
|
|
72
|
+
"""phi/nu should come from the grid point with the highest AEM value per frame."""
|
|
73
|
+
n_frames, height, width = 3, 4, 8
|
|
74
|
+
aem = np.zeros((n_frames, height, width), dtype=np.float32)
|
|
75
|
+
phi_mesh, nu_mesh = np.meshgrid(
|
|
76
|
+
np.linspace(-np.pi, np.pi, width),
|
|
77
|
+
np.linspace(-np.pi / 2, np.pi / 2, height),
|
|
78
|
+
)
|
|
79
|
+
time_stamp = np.arange(n_frames, dtype=float)
|
|
80
|
+
|
|
81
|
+
# Place the maximum at known positions
|
|
82
|
+
expected_phi = []
|
|
83
|
+
expected_nu = []
|
|
84
|
+
for i in range(n_frames):
|
|
85
|
+
row, col = i % height, (i * 2) % width
|
|
86
|
+
aem[i, row, col] = 1.0
|
|
87
|
+
expected_phi.append(phi_mesh[row, col])
|
|
88
|
+
expected_nu.append(nu_mesh[row, col])
|
|
89
|
+
|
|
90
|
+
_, phi, nu, _ = compute_anglegram_from_aem(
|
|
91
|
+
time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh, aem=aem
|
|
92
|
+
)
|
|
93
|
+
np.testing.assert_allclose(phi, expected_phi)
|
|
94
|
+
np.testing.assert_allclose(nu, expected_nu)
|
|
95
|
+
|
|
96
|
+
def test_requires_consistent_args(self):
|
|
97
|
+
"""Passing aem_path together with other args should raise AssertionError."""
|
|
98
|
+
aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data()
|
|
99
|
+
with pytest.raises(AssertionError):
|
|
100
|
+
compute_anglegram_from_aem(
|
|
101
|
+
aem_path="dummy.npz",
|
|
102
|
+
time_stamp=time_stamp,
|
|
103
|
+
phi_mesh=phi_mesh,
|
|
104
|
+
nu_mesh=nu_mesh,
|
|
105
|
+
aem=aem,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
def test_requires_all_args_when_no_path(self):
|
|
109
|
+
"""Omitting any required arg when aem_path is None should raise AssertionError."""
|
|
110
|
+
aem, time_stamp, phi_mesh, nu_mesh = _make_aem_data()
|
|
111
|
+
with pytest.raises(AssertionError):
|
|
112
|
+
compute_anglegram_from_aem(
|
|
113
|
+
time_stamp=time_stamp, phi_mesh=phi_mesh, nu_mesh=nu_mesh
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ---------------------------------------------------------------------------
|
|
118
|
+
# Integration test
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.mark.integration
|
|
123
|
+
def test_anglegram_integration():
|
|
124
|
+
"""Integration test for anglegram function."""
|
|
125
|
+
try:
|
|
126
|
+
anglegram(
|
|
127
|
+
audio_path=REAL_AUDIO_PATH,
|
|
128
|
+
save_path=TEST_OUTPUT_DIR / "test_anglegram.png",
|
|
129
|
+
)
|
|
130
|
+
except Exception as e:
|
|
131
|
+
pytest.fail(f"Integration test failed with exception: {e}")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Unit and integration tests for ambiviz.directogram."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from ambiviz.directogram import directogram
|
|
9
|
+
|
|
10
|
+
# Real test files (used by integration tests)
|
|
11
|
+
TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
|
|
12
|
+
REAL_AUDIO_PATH = TEST_FILES_DIR / "test_directogram.wav"
|
|
13
|
+
TEST_OUTPUT_DIR = Path(__file__).parent.parent / "test_output"
|
|
14
|
+
|
|
15
|
+
logging.basicConfig(level=logging.WARNING)
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
logger.setLevel(logging.DEBUG)
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# Integration test
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.mark.integration
|
|
25
|
+
def test_directogram_integration():
|
|
26
|
+
"""Integration test for directogram function."""
|
|
27
|
+
try:
|
|
28
|
+
directogram(
|
|
29
|
+
audio_path=REAL_AUDIO_PATH,
|
|
30
|
+
save_path=TEST_OUTPUT_DIR / "test_directogram.png",
|
|
31
|
+
)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
pytest.fail(f"Integration test failed with exception: {e}")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# """Unit and integration tests for ambiviz.plot.visualize_aem."""
|
|
2
|
+
# from pathlib import Path
|
|
3
|
+
|
|
4
|
+
# import numpy as np
|
|
5
|
+
# import pytest
|
|
6
|
+
|
|
7
|
+
# from ambiviz.aem import visualize_aem
|
|
8
|
+
|
|
9
|
+
# AUDIO_PATH = "fake/audio.wav"
|
|
10
|
+
# VIDEO_PATH = "fake/video.mp4"
|
|
11
|
+
|
|
12
|
+
# # Real test files (used by integration tests)
|
|
13
|
+
# TEST_FILES_DIR = Path(__file__).parent.parent / "test_files"
|
|
14
|
+
# REAL_AUDIO_PATH = TEST_FILES_DIR / "test_audio_foa.wav"
|
|
15
|
+
# REAL_VIDEO_PATH = TEST_FILES_DIR / "test_video.mp4"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# def _call(tmp_path, normalization_mode=None, **kwargs):
|
|
19
|
+
# """Helper: invoke visualize_aem with a concrete save path."""
|
|
20
|
+
# visualize_aem(
|
|
21
|
+
# AUDIO_PATH,
|
|
22
|
+
# VIDEO_PATH,
|
|
23
|
+
# str(tmp_path / "out.mp4"),
|
|
24
|
+
# duration=2.0,
|
|
25
|
+
# audio_offset=0.0,
|
|
26
|
+
# video_offset=0.0,
|
|
27
|
+
# normalization_mode=normalization_mode,
|
|
28
|
+
# **kwargs,
|
|
29
|
+
# )
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# # ---------------------------------------------------------------------------
|
|
33
|
+
# # Validation
|
|
34
|
+
# # ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
# def test_invalid_normalization_mode_raises(tmp_path):
|
|
37
|
+
# with pytest.raises(AssertionError):
|
|
38
|
+
# _call(tmp_path, normalization_mode="bad_mode")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# def test_invalid_aem_mode_raises(tmp_path):
|
|
42
|
+
# with pytest.raises(AssertionError):
|
|
43
|
+
# _call(tmp_path, aem_mode="bad_mode")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# # ---------------------------------------------------------------------------
|
|
47
|
+
# # Smoke tests — one per normalization mode
|
|
48
|
+
# # ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
# @pytest.mark.parametrize("normalization_mode", [None, "global", "local"])
|
|
51
|
+
# def test_smoke(patched_env, normalization_mode):
|
|
52
|
+
# """Function completes without error for every normalization mode."""
|
|
53
|
+
# _call(patched_env["tmp_path"], normalization_mode=normalization_mode)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# # ---------------------------------------------------------------------------
|
|
57
|
+
# # Side-effect verification
|
|
58
|
+
# # ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
# def test_ffmpeg_called_once(patched_env):
|
|
61
|
+
# _call(patched_env["tmp_path"])
|
|
62
|
+
# patched_env["p_subprocess"].assert_called_once()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# def test_temp_file_removed(patched_env):
|
|
66
|
+
# _call(patched_env["tmp_path"])
|
|
67
|
+
# patched_env["p_remove"].assert_called_once()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# def test_writer_called_per_frame(patched_env):
|
|
71
|
+
# """cv2.VideoWriter.write() must be called once per video frame."""
|
|
72
|
+
# n_video_frames = patched_env["video_array"].shape[0]
|
|
73
|
+
# _call(patched_env["tmp_path"])
|
|
74
|
+
# assert patched_env["mock_writer"].write.call_count == n_video_frames
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# # ---------------------------------------------------------------------------
|
|
78
|
+
# # AEM overlay logic
|
|
79
|
+
# # ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
# def test_channel2_increased_by_aem(patched_env):
|
|
82
|
+
# """Channel index 2 of every written frame must be >= the original (AEM adds energy)."""
|
|
83
|
+
# original_ch2 = patched_env["video_array"][:, :, :, 2].astype(np.float64)
|
|
84
|
+
|
|
85
|
+
# _call(patched_env["tmp_path"])
|
|
86
|
+
|
|
87
|
+
# written_frames = [
|
|
88
|
+
# call_args[0][0] # first positional arg to write()
|
|
89
|
+
# for call_args in patched_env["mock_writer"].write.call_args_list
|
|
90
|
+
# ]
|
|
91
|
+
# for i, frame in enumerate(written_frames):
|
|
92
|
+
# assert np.all(frame[:, :, 2].astype(np.float64) >= original_ch2[i] * 0.94), (
|
|
93
|
+
# f"Frame {i}: channel 2 should not decrease below original × 0.95 brightness factor"
|
|
94
|
+
# )
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# # ---------------------------------------------------------------------------
|
|
98
|
+
# # Integration tests — require real files and ffmpeg
|
|
99
|
+
# # Run with: pytest -m integration
|
|
100
|
+
# # ---------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
# # @pytest.mark.integration
|
|
103
|
+
# # @pytest.mark.parametrize("normalization_mode", [None, "global", "local"])
|
|
104
|
+
# # def test_integration_smoke(tmp_path, normalization_mode):
|
|
105
|
+
# # """End-to-end run with real audio/video files; verifies output .mp4 is created."""
|
|
106
|
+
# # out = tmp_path / "out.mp4"
|
|
107
|
+
# # visualize_aem(
|
|
108
|
+
# # str(REAL_AUDIO_PATH),
|
|
109
|
+
# # str(REAL_VIDEO_PATH),
|
|
110
|
+
# # str(out),
|
|
111
|
+
# # duration=1.0,
|
|
112
|
+
# # audio_offset=0.0,
|
|
113
|
+
# # video_offset=0.0,
|
|
114
|
+
# # normalization_mode=normalization_mode,
|
|
115
|
+
# # )
|
|
116
|
+
# # assert out.exists(), "Output video file was not created"
|
|
117
|
+
# # assert out.stat().st_size > 0, "Output video file is empty"
|