Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 64 additions & 13 deletions musicalgestures/_videograms.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,57 @@
import math


def videograms_ffmpeg(self, target_name_x: str | None = None, target_name_y: str | None = None, overwrite: bool = True) -> "MgList":
def videograms_ffmpeg(
self,
target_name_x: str | None = None,
target_name_y: str | None = None,
overwrite: bool = True,
mode: str = "average",
line_x: int | None = None,
line_y: int | None = None,
) -> "MgList":
"""
Renders horizontal and vertical videograms of the source video using ffmpeg. Averages videoframes by axes,
and creates two images of the horizontal-axis and vertical-axis stacks. In these stacks, a single row or
column corresponds to a frame from the source video, and the index of the row or column corresponds to
the index of the source frame.
Renders horizontal and vertical videograms of the source video using ffmpeg.
By default, videoframes are averaged by axes. Alternatively, ``mode='slit'``
samples a single column and row per frame (photo-finish style) and stacks
those over time.

Args:
target_name_x (str, optional): Target output name for the vertical videogram (the x-axis collapse). Defaults to None (which assumes that the input filename with the suffix "_vgv" should be used).
target_name_y (str, optional): Target output name for the horizontal videogram (the y-axis collapse). Defaults to None (which assumes that the input filename with the suffix "_vgh" should be used).
overwrite (bool, optional): Whether to allow overwriting existing files or to automatically increment target filenames to avoid overwriting. Defaults to True.
mode (str, optional): Either ``'average'`` (default) or ``'slit'``.
``'slit'`` uses one column/row line per frame instead of averaging.
line_x (int, optional): Source x-position (column index) used in slit
mode for the vertical videogram. Defaults to the center column.
line_y (int, optional): Source y-position (row index) used in slit mode
for the horizontal videogram. Defaults to the center row.

Returns:
MgList: An MgList with the MgImage objects referring to the vertical and horizontal videograms respectively.
"""

width, height = get_widthheight(self.filename)
framecount = get_framecount(self.filename)
mode = mode.lower()

if mode not in ["average", "slit"]:
raise ValueError("mode must be 'average' or 'slit'.")

def _resolve_line(line: int | None, max_size: int, name: str, default: int) -> int:
if line is None:
return default
if isinstance(line, bool) or not isinstance(line, int):
raise ValueError(f"{name} must be an integer in the range [0, {max_size - 1}].")
if line < 0 or line >= max_size:
raise ValueError(f"{name} must be an integer in the range [0, {max_size - 1}].")
return line

slit_x = width // 2
slit_y = height // 2
if mode == "slit":
slit_x = _resolve_line(line_x, width, "line_x", width // 2)
slit_y = _resolve_line(line_y, height, "line_y", height // 2)

def calc_skipfactor(width, height, framecount):
"""
Expand All @@ -48,6 +81,22 @@ def calc_skipfactor(width, height, framecount):

testx, testy = calc_skipfactor(width, height, framecount)

if mode == "average":
suffix_x, suffix_y = "_vgv.png", "_vgh.png"
else:
suffix_x, suffix_y = "_vgv_slit.png", "_vgh_slit.png"

def _filters(framecount: int) -> tuple[str, str]:
if mode == "average":
return (
f"scale=1:{height}:sws_flags=area,normalize,tile={framecount}x1",
f"scale={width}:1:sws_flags=area,normalize,tile=1x{framecount}",
)
return (
f"format=rgb24,crop=1:{height}:{slit_x}:0,normalize,tile={framecount}x1",
f"format=rgb24,crop={width}:1:0:{slit_y},normalize,tile=1x{framecount}",
)

if testx > 1 or testy > 1:
necessary_skipfactor = max([testx, testy])
print(f'{os.path.basename(self.filename)} is too large to process. Applying minimal skipping necessary...')
Expand All @@ -56,16 +105,17 @@ def calc_skipfactor(width, height, framecount):
skip_of = os.path.splitext(shortened_file)[0]
framecount = get_framecount(shortened_file)
length = get_length(shortened_file)
vf_y, vf_x = _filters(framecount)

target_name_x = resolve_filename(skip_of, '_vgv.png', target_name_x, overwrite)
target_name_y = resolve_filename(skip_of, '_vgh.png', target_name_y, overwrite)
target_name_x = resolve_filename(skip_of, suffix_x, target_name_x, overwrite)
target_name_y = resolve_filename(skip_of, suffix_y, target_name_y, overwrite)

cmd = ['ffmpeg', '-y', '-i', shortened_file, '-vf',
f'scale=1:{height}:sws_flags=area,normalize,tile={framecount}x1', '-aspect', f'{framecount}:{height}', '-frames', '1', target_name_y]
vf_y, '-aspect', f'{framecount}:{height}', '-frames', '1', target_name_y]
ffmpeg_cmd(cmd, length, stream=False, pb_prefix="Rendering horizontal videogram:")

cmd = ['ffmpeg', '-y', '-i', shortened_file, '-vf',
f'scale={width}:1:sws_flags=area,normalize,tile=1x{framecount}', '-aspect', f'{width}:{framecount}', '-frames', '1', target_name_x]
vf_x, '-aspect', f'{width}:{framecount}', '-frames', '1', target_name_x]
ffmpeg_cmd(cmd, length, stream=False, pb_prefix="Rendering vertical videogram:")

# save results as MgImages at self.video_gram_x and self.video_gram_y for parent MgObject
Expand All @@ -78,16 +128,17 @@ def calc_skipfactor(width, height, framecount):

else:
length = get_length(self.filename)
vf_y, vf_x = _filters(framecount)

target_name_x = resolve_filename(self.of, '_vgv.png', target_name_x, overwrite)
target_name_y = resolve_filename(self.of, '_vgh.png', target_name_y, overwrite)
target_name_x = resolve_filename(self.of, suffix_x, target_name_x, overwrite)
target_name_y = resolve_filename(self.of, suffix_y, target_name_y, overwrite)

cmd = ['ffmpeg', '-y', '-i', self.filename, '-frames', '1', '-vf',
f'scale=1:{height}:sws_flags=area,normalize,tile={framecount}x1', '-aspect', f'{framecount}:{height}', target_name_y]
vf_y, '-aspect', f'{framecount}:{height}', target_name_y]
ffmpeg_cmd(cmd, length, stream=False, pb_prefix="Rendering horizontal videogram:")

cmd = ['ffmpeg', '-y', '-i', self.filename, '-frames', '1', '-vf',
f'scale={width}:1:sws_flags=area,normalize,tile=1x{framecount}', '-aspect', f'{width}:{framecount}', target_name_x]
vf_x, '-aspect', f'{width}:{framecount}', target_name_x]
ffmpeg_cmd(cmd, length, stream=False, pb_prefix="Rendering vertical videogram:")

# save results as MgImages at self.videogram_x and self.videogram_y for parent MgObject
Expand Down
11 changes: 9 additions & 2 deletions musicalgestures/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,13 +115,20 @@ def cmd_motion(filename, filtertype, threshold, blur, color, overwrite):

@cli.command("videograms")
@click.argument("filename", type=click.Path(exists=True))
@click.option("--mode", default="average", show_default=True,
type=click.Choice(["average", "slit"], case_sensitive=False),
help="Videogram mode: axis average or slit (photo-finish style).")
@click.option("--line-x", type=int,
help="Column index used for slit mode vertical videogram.")
@click.option("--line-y", type=int,
help="Row index used for slit mode horizontal videogram.")
@click.option("--overwrite", is_flag=True, help="Overwrite existing output files.")
def cmd_videograms(filename, overwrite):
def cmd_videograms(filename, mode, line_x, line_y, overwrite):
"""Render horizontal and vertical videograms for FILENAME."""
try:
import musicalgestures as mg
v = mg.MgVideo(filename)
out = v.videograms(overwrite=overwrite)
out = v.videograms(mode=mode, line_x=line_x, line_y=line_y, overwrite=overwrite)
click.echo(f"Videograms saved: {[o.filename for o in out]}")
except Exception as exc:
click.echo(f"Error: {exc}", err=True)
Expand Down
33 changes: 33 additions & 0 deletions tests/test_videograms.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,36 @@ def test_normal_case(self, testvideo_avi):
for videogram in result:
assert type(videogram) == musicalgestures.MgImage
assert os.path.isfile(videogram.filename) == True

def test_slit_mode(self, testvideo_avi):
mg = musicalgestures.MgVideo(testvideo_avi)
width, height = musicalgestures._utils.get_widthheight(testvideo_avi)
framecount = musicalgestures._utils.get_framecount(testvideo_avi)
result = mg.videograms(mode="slit", line_x=0, line_y=0)

assert os.path.isfile(result[0].filename) == True
assert os.path.isfile(result[1].filename) == True
assert result[0].filename.endswith("_vgv_slit.png")
assert result[1].filename.endswith("_vgh_slit.png")

x_width, x_height = musicalgestures._utils.get_widthheight(result[0].filename)
y_width, y_height = musicalgestures._utils.get_widthheight(result[1].filename)
assert x_width == width
assert x_height == framecount
assert y_width == framecount
assert y_height == height

def test_slit_mode_validates_inputs(self, testvideo_avi):
mg = musicalgestures.MgVideo(testvideo_avi)
width, height = musicalgestures._utils.get_widthheight(testvideo_avi)

result = mg.videograms(mode="average", line_x=width, line_y=height)
assert os.path.isfile(result[0].filename) == True
assert os.path.isfile(result[1].filename) == True

with pytest.raises(ValueError):
mg.videograms(mode="unknown")
with pytest.raises(ValueError):
mg.videograms(mode="slit", line_x=width)
with pytest.raises(ValueError):
mg.videograms(mode="slit", line_y=height)