Skip to content

Commit 18c7ab8

Browse files
authored
[scene_manager] Add crop functionality (#449)
* [scene_manager] Add ability to crop input * [scene_manager] Validate crop config params and improve error messaging Make sure exceptions are always thrown in debug mode from the source location.
1 parent 95091f8 commit 18c7ab8

File tree

11 files changed

+245
-39
lines changed

11 files changed

+245
-39
lines changed

docs/cli.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ Options
5757

5858
Path to config file. See :ref:`config file reference <scenedetect_cli-config_file>` for details.
5959

60+
.. option:: --crop X0 Y0 X1 Y1
61+
62+
Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).
63+
6064
.. option:: -s CSV, --stats CSV
6165

6266
Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.

scenedetect.cfg

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,19 @@
2727
# Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist
2828
#default-detector = detect-adaptive
2929

30-
# Video backend interface, must be one of: opencv, pyav, moviepy.
31-
#backend = opencv
30+
# Output directory for written files. Defaults to working directory.
31+
#output = /usr/tmp/scenedetect/
3232

3333
# Verbosity of console output (debug, info, warning, error, or none).
3434
# Set to none for the same behavior as specifying -q/--quiet.
3535
#verbosity = debug
3636

37-
# Output directory for written files. Defaults to working directory.
38-
#output = /usr/tmp/scenedetect/
37+
# Crop input video to area. Specified as two points in the form X0 Y0 X1 Y1 or
38+
# as (X0 Y0), (X1 Y1). Coordinate (0, 0) is the top-left corner.
39+
#crop = 100 100 200 250
40+
41+
# Video backend interface, must be one of: opencv, pyav, moviepy.
42+
#backend = opencv
3943

4044
# Minimum length of a given scene.
4145
#min-scene-len = 0.6s

scenedetect/_cli/__init__.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,14 @@ def print_command_help(ctx: click.Context, command: click.Command):
256256
help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s"
257257
% (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")),
258258
)
259+
@click.option(
260+
"--crop",
261+
metavar="X0 Y0 X1 Y1",
262+
type=(int, int, int, int),
263+
default=None,
264+
help="Crop input video. Specified as two points representing top left and bottom right corner of crop region. 0 0 is top-left of the video frame. Bounds are inclusive (e.g. for a 100x100 video, the region covering the whole frame is 0 0 99 99).%s"
265+
% (USER_CONFIG.get_help_string("global", "crop", show_default=False)),
266+
)
259267
@click.option(
260268
"--downscale",
261269
"-d",
@@ -312,6 +320,7 @@ def scenedetect(
312320
drop_short_scenes: ty.Optional[bool],
313321
merge_last_scene: ty.Optional[bool],
314322
backend: ty.Optional[str],
323+
crop: ty.Optional[ty.Tuple[int, int, int, int]],
315324
downscale: ty.Optional[int],
316325
frame_skip: ty.Optional[int],
317326
verbosity: ty.Optional[str],
@@ -326,12 +335,13 @@ def scenedetect(
326335
output=output,
327336
framerate=framerate,
328337
stats_file=stats,
329-
downscale=downscale,
330338
frame_skip=frame_skip,
331339
min_scene_len=min_scene_len,
332340
drop_short_scenes=drop_short_scenes,
333341
merge_last_scene=merge_last_scene,
334342
backend=backend,
343+
crop=crop,
344+
downscale=downscale,
335345
quiet=quiet,
336346
logfile=logfile,
337347
config=config,

scenedetect/_cli/config.py

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,47 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue":
135135
) from ex
136136

137137

138+
class CropValue(ValidatedValue):
139+
"""Validator for crop region defined as X0 Y0 X1 Y1."""
140+
141+
_IGNORE_CHARS = [",", "/", "(", ")"]
142+
"""Characters to ignore."""
143+
144+
def __init__(self, value: Optional[Union[str, Tuple[int, int, int, int]]] = None):
145+
if isinstance(value, CropValue) or value is None:
146+
self._crop = value
147+
else:
148+
crop = ()
149+
if isinstance(value, str):
150+
translation_table = str.maketrans(
151+
{char: " " for char in ScoreWeightsValue._IGNORE_CHARS}
152+
)
153+
values = value.translate(translation_table).split()
154+
crop = tuple(int(val) for val in values)
155+
elif isinstance(value, tuple):
156+
crop = value
157+
if not len(crop) == 4:
158+
raise ValueError("Crop region must be four numbers of the form X0 Y0 X1 Y1!")
159+
if any(coordinate < 0 for coordinate in crop):
160+
raise ValueError("Crop coordinates must be >= 0")
161+
(x0, y0, x1, y1) = crop
162+
self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))
163+
164+
@property
165+
def value(self) -> Tuple[int, int, int, int]:
166+
return self._crop
167+
168+
def __str__(self) -> str:
169+
return "[%d, %d], [%d, %d]" % self.value
170+
171+
@staticmethod
172+
def from_config(config_value: str, default: "CropValue") -> "CropValue":
173+
try:
174+
return CropValue(config_value)
175+
except ValueError as ex:
176+
raise OptionParseFailure(f"{ex}") from ex
177+
178+
138179
class ScoreWeightsValue(ValidatedValue):
139180
"""Validator for score weight values (currently a tuple of four numbers)."""
140181

@@ -154,7 +195,7 @@ def __init__(self, value: Union[str, ContentDetector.Components]):
154195
self._value = ContentDetector.Components(*(float(val) for val in values))
155196

156197
@property
157-
def value(self) -> Tuple[float, float, float, float]:
198+
def value(self) -> ContentDetector.Components:
158199
return self._value
159200

160201
def __str__(self) -> str:
@@ -340,6 +381,7 @@ def format(self, timecode: FrameTimecode) -> str:
340381
},
341382
"global": {
342383
"backend": "opencv",
384+
"crop": CropValue(),
343385
"default-detector": "detect-adaptive",
344386
"downscale": 0,
345387
"downscale-method": Interpolation.LINEAR,
@@ -484,7 +526,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
484526
out_map[command][option] = parsed
485527
except TypeError:
486528
errors.append(
487-
"Invalid [%s] value for %s: %s. Must be one of: %s."
529+
"Invalid value for [%s] option %s': %s. Must be one of: %s."
488530
% (
489531
command,
490532
option,
@@ -498,7 +540,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
498540

499541
except ValueError as _:
500542
errors.append(
501-
"Invalid [%s] value for %s: %s is not a valid %s."
543+
"Invalid value for [%s] option '%s': %s is not a valid %s."
502544
% (command, option, config.get(command, option), value_type)
503545
)
504546
continue
@@ -514,7 +556,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
514556
)
515557
except OptionParseFailure as ex:
516558
errors.append(
517-
"Invalid [%s] value for %s:\n %s\n%s"
559+
"Invalid value for [%s] option '%s': %s\nError: %s"
518560
% (command, option, config_value, ex.error)
519561
)
520562
continue
@@ -526,7 +568,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
526568
if command in CHOICE_MAP and option in CHOICE_MAP[command]:
527569
if config_value.lower() not in CHOICE_MAP[command][option]:
528570
errors.append(
529-
"Invalid [%s] value for %s: %s. Must be one of: %s."
571+
"Invalid value for [%s] option '%s': %s. Must be one of: %s."
530572
% (
531573
command,
532574
option,
@@ -612,8 +654,12 @@ def _load_from_disk(self, path=None):
612654
config_file_contents = config_file.read()
613655
config.read_string(config_file_contents, source=path)
614656
except ParsingError as ex:
657+
if __debug__:
658+
raise
615659
raise ConfigLoadFailure(self._init_log, reason=ex) from None
616660
except OSError as ex:
661+
if __debug__:
662+
raise
617663
raise ConfigLoadFailure(self._init_log, reason=ex) from None
618664
# At this point the config file syntax is correct, but we need to still validate
619665
# the parsed options (i.e. that the options have valid values).
@@ -638,8 +684,8 @@ def get_value(
638684
"""Get the current setting or default value of the specified command option."""
639685
assert command in CONFIG_MAP and option in CONFIG_MAP[command]
640686
if override is not None:
641-
return override
642-
if command in self._config and option in self._config[command]:
687+
value = override
688+
elif command in self._config and option in self._config[command]:
643689
value = self._config[command][option]
644690
else:
645691
value = CONFIG_MAP[command][option]

scenedetect/_cli/context.py

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
CHOICE_MAP,
2323
ConfigLoadFailure,
2424
ConfigRegistry,
25+
CropValue,
2526
)
2627
from scenedetect.detectors import (
2728
AdaptiveDetector,
@@ -157,12 +158,13 @@ def handle_options(
157158
output: ty.Optional[ty.AnyStr],
158159
framerate: float,
159160
stats_file: ty.Optional[ty.AnyStr],
160-
downscale: ty.Optional[int],
161161
frame_skip: int,
162162
min_scene_len: str,
163163
drop_short_scenes: ty.Optional[bool],
164164
merge_last_scene: ty.Optional[bool],
165165
backend: ty.Optional[str],
166+
crop: ty.Optional[ty.Tuple[int, int, int, int]],
167+
downscale: ty.Optional[int],
166168
quiet: bool,
167169
logfile: ty.Optional[ty.AnyStr],
168170
config: ty.Optional[ty.AnyStr],
@@ -212,7 +214,7 @@ def handle_options(
212214
logger.log(log_level, log_str)
213215
if init_failure:
214216
logger.critical("Error processing configuration file.")
215-
raise click.Abort()
217+
raise SystemExit(1)
216218

217219
if self.config.config_dict:
218220
logger.debug("Current configuration:\n%s", str(self.config.config_dict).encode("utf-8"))
@@ -285,9 +287,23 @@ def handle_options(
285287
scene_manager.downscale = downscale
286288
except ValueError as ex:
287289
logger.debug(str(ex))
288-
raise click.BadParameter(str(ex), param_hint="downscale factor") from None
290+
raise click.BadParameter(str(ex), param_hint="downscale factor") from ex
289291
scene_manager.interpolation = self.config.get_value("global", "downscale-method")
290292

293+
# If crop was set, make sure it's valid (e.g. it should cover at least a single pixel).
294+
try:
295+
crop = self.config.get_value("global", "crop", CropValue(crop))
296+
if crop is not None:
297+
(min_x, min_y) = crop[0:2]
298+
frame_size = self.video_stream.frame_size
299+
if min_x >= frame_size[0] or min_y >= frame_size[1]:
300+
region = CropValue(crop)
301+
raise ValueError(f"{region} is outside of video boundary of {frame_size}")
302+
scene_manager.crop = crop
303+
except ValueError as ex:
304+
logger.debug(str(ex))
305+
raise click.BadParameter(str(ex), param_hint="--crop") from ex
306+
291307
self.scene_manager = scene_manager
292308

293309
#
@@ -318,6 +334,8 @@ def get_detect_content_params(
318334
try:
319335
weights = ContentDetector.Components(*weights)
320336
except ValueError as ex:
337+
if __debug__:
338+
raise
321339
logger.debug(str(ex))
322340
raise click.BadParameter(str(ex), param_hint="weights") from None
323341

@@ -373,6 +391,8 @@ def get_detect_adaptive_params(
373391
try:
374392
weights = ContentDetector.Components(*weights)
375393
except ValueError as ex:
394+
if __debug__:
395+
raise
376396
logger.debug(str(ex))
377397
raise click.BadParameter(str(ex), param_hint="weights") from None
378398
return {
@@ -545,20 +565,31 @@ def _open_video_stream(
545565
framerate=framerate,
546566
backend=backend,
547567
)
548-
logger.debug("Video opened using backend %s", type(self.video_stream).__name__)
568+
logger.debug(f"""Video information:
569+
Backend: {type(self.video_stream).__name__}
570+
Resolution: {self.video_stream.frame_size}
571+
Framerate: {self.video_stream.frame_rate}
572+
Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""")
573+
549574
except FrameRateUnavailable as ex:
575+
if __debug__:
576+
raise
550577
raise click.BadParameter(
551578
"Failed to obtain framerate for input video. Manually specify framerate with the"
552579
" -f/--framerate option, or try re-encoding the file.",
553580
param_hint="-i/--input",
554581
) from ex
555582
except VideoOpenFailure as ex:
583+
if __debug__:
584+
raise
556585
raise click.BadParameter(
557586
"Failed to open input video%s: %s"
558587
% (" using %s backend" % backend if backend else "", str(ex)),
559588
param_hint="-i/--input",
560589
) from ex
561590
except OSError as ex:
591+
if __debug__:
592+
raise
562593
raise click.BadParameter(
563594
"Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input"
564595
) from None

scenedetect/detectors/content_detector.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,6 @@ def __init__(
133133
self._weights = ContentDetector.LUMA_ONLY_WEIGHTS
134134
self._kernel: Optional[numpy.ndarray] = None
135135
if kernel_size is not None:
136-
print(kernel_size)
137136
if kernel_size < 3 or kernel_size % 2 == 0:
138137
raise ValueError("kernel_size must be odd integer >= 3")
139138
self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8)

scenedetect/platform.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -330,7 +330,10 @@ def get_system_version_info() -> str:
330330
for module_name in third_party_packages:
331331
try:
332332
module = importlib.import_module(module_name)
333-
out_lines.append(output_template.format(module_name, module.__version__))
333+
if hasattr(module, "__version__"):
334+
out_lines.append(output_template.format(module_name, module.__version__))
335+
else:
336+
out_lines.append(output_template.format(module_name, not_found_str))
334337
except ModuleNotFoundError:
335338
out_lines.append(output_template.format(module_name, not_found_str))
336339

0 commit comments

Comments
 (0)