diff --git a/musicalgestures/_videograms.py b/musicalgestures/_videograms.py index 1cd172b9..8b0c9658 100644 --- a/musicalgestures/_videograms.py +++ b/musicalgestures/_videograms.py @@ -7,17 +7,31 @@ 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. @@ -25,6 +39,25 @@ def videograms_ffmpeg(self, target_name_x: str | None = None, target_name_y: str 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): """ @@ -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...') @@ -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 @@ -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 diff --git a/musicalgestures/cli.py b/musicalgestures/cli.py index 3e431531..ab8dea1b 100644 --- a/musicalgestures/cli.py +++ b/musicalgestures/cli.py @@ -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) diff --git a/tests/test_videograms.py b/tests/test_videograms.py index 7425a8ec..7efc96a9 100644 --- a/tests/test_videograms.py +++ b/tests/test_videograms.py @@ -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)