Skip to content

Filter

filter_frame

filter_frame(motion_frame, filtertype, threshold, kernel_size)

Applies a threshold filter and then a median filter (of kernel_sizexkernel_size) to an image or videoframe.

Parameters:

Name Type Description Default
motion_frame array(uint8)

Input motion image.

required
filtertype str

'Regular' turns all values below threshold to 0. 'Binary' turns all values below threshold to 0, above threshold to 1. 'Blob' removes individual pixels with erosion method.

required
threshold float

A number in the range of 0 to 1. Eliminates pixel values less than given threshold.

required
kernel_size int

Size of structuring element.

required

Returns:

Type Description

np.array(uint8): The filtered frame.

Source code in musicalgestures/_filter.py
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
def filter_frame(motion_frame, filtertype, threshold, kernel_size):
    """
    Applies a threshold filter and then a median filter (of `kernel_size`x`kernel_size`) to an image or videoframe.

    Args:
        motion_frame (np.array(uint8)): Input motion image.
        filtertype (str): 'Regular' turns all values below `threshold` to 0. 'Binary' turns all values below `threshold` to 0, above `threshold` to 1. 'Blob' removes individual pixels with erosion method.
        threshold (float): A number in the range of 0 to 1. Eliminates pixel values less than given threshold.
        kernel_size (int): Size of structuring element.

    Returns:
        np.array(uint8): The filtered frame.
    """

    from scipy.signal import medfilt2d   # lazy import: keeps scipy.signal out of startup
    if filtertype.lower() == 'regular':
        motion_frame = (motion_frame > threshold*255)*motion_frame
        motion_frame = medfilt2d(motion_frame, kernel_size)
    elif filtertype.lower() == 'binary':
        motion_frame = (motion_frame > threshold*255)*255
        motion_frame = medfilt2d(motion_frame.astype(np.uint8), kernel_size)
    elif filtertype.lower() == 'blob':
        motion_frame = cv2.erode(motion_frame, np.ones([kernel_size, kernel_size]), iterations=1)
    return motion_frame

filter_frame_ffmpeg

filter_frame_ffmpeg(filename, cmd, color, blur, filtertype, threshold, kernel_size, use_median, invert=False)

Builds an FFmpeg filter-complex string for frame differencing, thresholding and optional median filtering.

Parameters:

Name Type Description Default
filename str

Path to the input video file (used to derive frame dimensions).

required
cmd list

Base FFmpeg command list to which extra inputs are appended in-place.

required
color bool

If True, use gbrp pixel format; otherwise gray.

required
blur str

'Average' applies a 10×10 box blur before differencing; 'None' skips it.

required
filtertype str

'Regular' thresholds frame differences; 'Binary' binarises them; 'Blob' erodes.

required
threshold float

Pixel-value threshold in the range 0–1.

required
kernel_size int

Radius for the median or erosion filter.

required
use_median bool

If True, apply a median filter after thresholding.

required
invert bool

If True, negate the output. Defaults to False.

False

Returns:

Type Description

tuple[list, str]: Updated cmd list and the assembled filter-complex string.

Source code in musicalgestures/_filter.py
 32
 33
 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
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
def filter_frame_ffmpeg(filename, cmd, color, blur, filtertype, threshold, kernel_size, use_median, invert=False):
    """
    Builds an FFmpeg filter-complex string for frame differencing, thresholding and optional median filtering.

    Args:
        filename (str): Path to the input video file (used to derive frame dimensions).
        cmd (list): Base FFmpeg command list to which extra inputs are appended in-place.
        color (bool): If True, use gbrp pixel format; otherwise gray.
        blur (str): 'Average' applies a 10×10 box blur before differencing; 'None' skips it.
        filtertype (str): 'Regular' thresholds frame differences; 'Binary' binarises them; 'Blob' erodes.
        threshold (float): Pixel-value threshold in the range 0–1.
        kernel_size (int): Radius for the median or erosion filter.
        use_median (bool): If True, apply a median filter after thresholding.
        invert (bool, optional): If True, negate the output. Defaults to False.

    Returns:
        tuple[list, str]: Updated `cmd` list and the assembled filter-complex string.
    """
    cmd_filter = ''

    # set color mode
    if color == True:
        pixformat = 'gbrp'
    else:
        pixformat = 'gray'
    cmd_filter += f'format={pixformat},'

    # set blur
    if blur.lower() == 'average':
        cmd_filter += 'avgblur=sizeX=10:sizeY=10,'

    # set frame difference
    if filtertype.lower() == 'regular':
        #: Split, because the difference has to be BOTH the value compared against the
        #: threshold and the value kept when it passes, and a filtergraph label can only
        #: be consumed once.
        cmd_filter += 'tblend=all_mode=difference,split[d1][d2];'
    else:
        cmd_filter += 'tblend=all_mode=difference,'

    width, height = get_widthheight(filename)

    threshold_color = matplotlib.colors.to_hex([threshold, threshold, threshold])
    threshold_color = '0x' + threshold_color[1:]

    # set threshold
    if filtertype.lower() == 'regular':
        #: ffmpeg's threshold filter emits `min` where its FIRST input is below the
        #: second, and `max` elsewhere. That first input must be the difference.
        #:
        #: It used to be `[0:v]`, the original video, which meant the parameter masked
        #: motion in DARK PARTS OF THE PICTURE and passed every difference through
        #: untouched wherever the picture was bright --- the opposite of what this
        #: argument is documented to do, and different from what the numpy `filter_frame`
        #: above does with the same number. Sensor noise was never removed. Fixed
        #: 2026-08-27; the account is in plans/2026-08-27-threshold-does-not-threshold-
        #: the-difference.md, and it changes every quantity-of-motion number this package
        #: produced before that date.
        cmd += ['-f', 'lavfi', '-i', f'color={threshold_color},scale={width}:{height}',
                '-f', 'lavfi', '-i', f'color=black,scale={width}:{height}']
        cmd_filter += '[d1][1][2][d2]threshold,'
    elif filtertype.lower() == 'binary':
        cmd += ['-f', 'lavfi', '-i', f'color={threshold_color},scale={width}:{height}', '-f', 'lavfi', '-i',
                f'color=black,scale={width}:{height}', '-f', 'lavfi', '-i', f'color=white,scale={width}:{height}']
        cmd_filter += 'threshold,'
    elif filtertype.lower() == 'blob':
        # cmd_filter += 'erosion,' # erosion is always 3x3 so we will hack it with a median filter with percentile=0 which will pick minimum values
        cmd_filter += f'median=radius={kernel_size}:percentile=0,'

    # set median
    if use_median and filtertype.lower() != 'blob':  # makes no sense to median-filter the eroded video
        cmd_filter += f'median=radius={kernel_size},'

    if invert:
        cmd_filter += 'negate,'

    return cmd, cmd_filter