Browse code

Add implementation

Robert Cranston authored on 02/06/2020 01:27:51
Showing 2 changed files

... ...
@@ -2,7 +2,78 @@
2 2
 
3 3
 Zoom in on video motion.
4 4
 
5
+-   [Usage](#usage)
6
+-   [Install](#install)
7
+    -   [With `pipx`, for users](#with-pipx-for-users)
8
+    -   [Into `venv`, for developers](#into-venv-for-developers)
9
+-   [License](#license)
10
+
11
+`zoommotion` is implemented in [Python][], uses [OpenCV][] and [SciPy][] for
12
+processing, and external [FFmpeg][] processes for broad file read and write
13
+support (via [`ffmpeg-python`][] and [`pyffstream`][]).
14
+
15
+Simple algorithms are used to achieve speed with acceptable results ([Gaussian
16
+mixture background/foreground segmentation][] and [Gaussian blur][] paired with
17
+a [Butterworth filter][]). Algorithm parameters and input/output file
18
+parameters can be supplied with command line options, see [Usage](#usage).
19
+
5 20
 [`zoommotion`]: https://git.rcrnstn.net/rcrnstn/zoommotion
21
+[Python]: https://www.python.org
22
+[OpenCV]: https://opencv.org/about/
23
+[SciPy]: https://www.scipy.org
24
+[FFmpeg]: https://ffmpeg.org
25
+[`ffmpeg-python`]: https://github.com/kkroening/ffmpeg-python
26
+[`pyffstream`]: https://git.rcrnstn.net/rcrnstn/pyffstream
27
+[Gaussian mixture background/foreground segmentation]: https://docs.opencv.org/3.4/d7/d7b/classcv_1_1BackgroundSubtractorMOG2.html
28
+[Gaussian blur]: https://docs.opencv.org/3.4/d4/d86/group__imgproc__filter.html#gaabe8c836e97159a9193fb0b11ac52cf1
29
+[Butterworth filter]: https://docs.scipy.org/doc/scipy/reference/generated/scipy.signal.butter.html
30
+
31
+## Usage
32
+
33
+`zoommotion --help`:
34
+
35
+```
36
+usage: zoommotion [-h] [--margins L R T B] [--blur-factor F]
37
+                  [--blur-threshold T] [--lowpass-factor F]
38
+                  [--start-frame FRAME] [--end-frame FRAME] [--decimate COUNT]
39
+                  [--width WIDTH] [--height HEIGHT] [--codec CODEC]
40
+                  [--pix-fmt PIX_FMT] [--extra-args ARGS] [--no-preview]
41
+                  [--no-write] [--no-audio] [--overwrite] [--debug DEBUG_FILE]
42
+                  [--output OUTPUT_FILE]
43
+                  INPUT_FILE
44
+
45
+Zoom in on video motion.
46
+
47
+positional arguments:
48
+  INPUT_FILE            input file
49
+
50
+optional arguments:
51
+  -h, --help            show this help message and exit
52
+  --margins L R T B     margins (left, right, top, bottom, in percent) of
53
+                        output video (default: [0, 0, 0, 0])
54
+  --blur-factor F       blur size factor (default: 0.05)
55
+  --blur-threshold T    blur threshold (default: 32)
56
+  --lowpass-factor F    low-pass filter cutoff frequency factor (default:
57
+                        0.00015)
58
+  --start-frame FRAME   starting input frame to process (inclusive)
59
+  --end-frame FRAME     ending input frame to process (exclusive)
60
+  --decimate COUNT      only use every COUNT input frame
61
+  --width WIDTH         width of output video
62
+  --height HEIGHT       height of output video
63
+  --codec CODEC         codec of output video
64
+  --pix-fmt PIX_FMT     pixel format of output video
65
+  --extra-args ARGS     extra arguments to pass to FFmpeg, as a JSON object
66
+                        (do not specify the leading dash for keys, use a null
67
+                        value for arguments that do not take a parameter)
68
+  --no-preview          do not show any previews
69
+  --no-write            do not write any files
70
+  --no-audio            do not include audio in output files
71
+  --overwrite           overwrite output files
72
+  --debug DEBUG_FILE    produce debug output (leave empty to base filename on
73
+                        input)
74
+  --output OUTPUT_FILE  produce final output (leave empty to base filename on
75
+                        input)
76
+```
6 77
 
7 78
 ## Install
8 79
 
9 80
new file mode 100644
... ...
@@ -0,0 +1,158 @@
1
+#!/usr/bin/env python3
2
+
3
+"""
4
+Zoom in on video motion.
5
+"""
6
+
7
+import pyffstream
8
+import numpy as np
9
+import PIL.Image
10
+import cv2
11
+import scipy.signal
12
+
13
+
14
+def args_pre(parser):
15
+    # Add arguments.
16
+    parser.add_argument(
17
+        '--margins', metavar=('L', 'R', 'T', 'B'), type=float, nargs=4,
18
+        default=[0, 0, 0, 0],
19
+        help="""
20
+        margins (left, right, top, bottom, in percent) of output video
21
+        (default: %(default)s)
22
+        """)
23
+    parser.add_argument(
24
+        '--blur-factor', metavar='F', type=float, default=0.05,
25
+        help="blur size factor (default: %(default)s)")
26
+    parser.add_argument(
27
+        '--blur-threshold', metavar='T', type=int, default=32,
28
+        help="blur threshold (default: %(default)s)")
29
+    parser.add_argument(
30
+        '--lowpass-factor', metavar='F', type=float, default=0.00015,
31
+        help="low-pass filter cutoff frequency factor (default: %(default)s)")
32
+
33
+
34
+def init(args):
35
+    # Set arguments.
36
+    args.history = int(30 * args.output_fps)
37
+    args.blur_size = (
38
+        int(np.ceil(args.working_width * args.blur_factor)) // 2 * 2 + 1
39
+    )
40
+    args.lost_size = args.working_width * args.working_height * 0.001
41
+    args.b, args.a = scipy.signal.butter(
42
+        1, args.working_width / args.output_fps * args.lowpass_factor
43
+    )
44
+    args.history_track_ratio = 0.025
45
+    args.resample_ratios = [
46
+        # (1.0, PIL.Image.NEAREST),
47
+        (0.5, PIL.Image.BILINEAR),
48
+        (0.2, PIL.Image.BICUBIC),
49
+        (0.0, PIL.Image.LANCZOS),
50
+    ]
51
+
52
+    # Set state.
53
+    class State:
54
+        pass
55
+    state = State()
56
+    state.background_subtractor = cv2.createBackgroundSubtractorMOG2(
57
+        history=args.history
58
+    )
59
+    state.filter_state = []
60
+    return state
61
+
62
+
63
+def process(args, state, frame, frame_num):
64
+    # Create debug frame.
65
+    if args.debug:
66
+        debug_frame = frame.copy()
67
+    else:
68
+        debug_frame = None
69
+
70
+    # Subtract background, blur and threshold.
71
+    foreground = state.background_subtractor.apply(frame)
72
+    mask = cv2.compare(
73
+        cv2.GaussianBlur(foreground, (args.blur_size, args.blur_size), 0),
74
+        args.blur_threshold,
75
+        cv2.CMP_GE,
76
+    )
77
+    if args.debug:
78
+        debug_frame[mask > 0] = (0, 255, 0)
79
+        debug_frame[foreground > 0] = (255, 0, 0)
80
+
81
+    # Nothing interesting?
82
+    if np.count_nonzero(mask) < args.lost_size:
83
+        # Reset rectangle.
84
+        x, y, w, h = 0, 0, args.working_width, args.working_height
85
+    else:
86
+        # Find bounding rectangle.
87
+        x, y, w, h = cv2.boundingRect(mask)
88
+    if args.debug:
89
+        cv2.rectangle(
90
+            debug_frame, (x, y), (x+w, y+h), (0, 255, 0), 2 * args.thickness
91
+        )
92
+
93
+    # Add rectangle margins.
94
+    ml, mr, mt, mb = args.margins
95
+    m = max(w, h)
96
+    x = max(x - int(m * ml / 100), 0)
97
+    y = max(y - int(m * mt / 100), 0)
98
+    w = min(w + int(m * (ml+mr) / 100), args.working_width - x)
99
+    h = min(h + int(m * (mt+mb) / 100), args.working_height - y)
100
+    if args.debug:
101
+        cv2.rectangle(
102
+            debug_frame, (x, y), (x+w, y+h), (0, 0, 255), 2 * args.thickness
103
+        )
104
+
105
+    # Filter rectangle.
106
+    x1, y1, x2, y2 = x, y, x+w, y+h
107
+    if frame_num == args.start_frame:
108
+        state.filter_state = [
109
+            coord * scipy.signal.lfilter_zi(args.b, args.a)
110
+            for coord in (x1, y1, x2, y2)
111
+        ]
112
+    (x1, y1, x2, y2), filter_state_next = zip(*(
113
+        scipy.signal.lfilter(args.b, args.a, [coord], zi=zi)
114
+        for coord, zi in
115
+        zip((x1, y1, x2, y2), state.filter_state)
116
+    ))
117
+    if frame_num >= args.start_frame + args.history * args.history_track_ratio:
118
+        state.filter_state = filter_state_next
119
+    x1, y1, x2, y2 = [int(coord[0]) for coord in (x1, y1, x2, y2)]
120
+    x, y, w, h = x1, y1, x2-x1, y2-y1
121
+    if args.debug:
122
+        cv2.rectangle(
123
+            debug_frame, (x, y), (x+w, y+h), (255, 0, 255), 2 * args.thickness
124
+        )
125
+
126
+    # Fix rectangle.
127
+    x, y, w, h = pyffstream.fix_rect(args, x, y, w, h)
128
+
129
+    # Determine resampling method.
130
+    for i, (ratio, resample) in enumerate(args.resample_ratios):
131
+        if min(w / args.output_width, h / args.output_height) >= ratio:
132
+            break
133
+    if args.debug:
134
+        color_coeff = i / max(1, len(args.resample_ratios) - 1)
135
+        color = (
136
+            255 * (0 + color_coeff),
137
+            255 * (1 - color_coeff),
138
+            0,
139
+        )
140
+        cv2.rectangle(
141
+            debug_frame, (x, y), (x+w, y+h), color, 2 * args.thickness,
142
+        )
143
+
144
+    # Cut and resize.
145
+    output_frame = pyffstream.resize(
146
+        frame[y:y+h, x:x+w], args.output_width, args.output_height, resample
147
+    )
148
+
149
+    # Return.
150
+    return output_frame, debug_frame
151
+
152
+
153
+def main():
154
+    pyffstream.run(__doc__, process, init, args_pre)
155
+
156
+
157
+if __name__ == '__main__':
158
+    main()