Skip to content

Commit

Permalink
[scene_manager] Add crop functionality (#449)
Browse files Browse the repository at this point in the history
* [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.
  • Loading branch information
Breakthrough authored Nov 25, 2024
1 parent 95091f8 commit 18c7ab8
Show file tree
Hide file tree
Showing 11 changed files with 245 additions and 39 deletions.
4 changes: 4 additions & 0 deletions docs/cli.rst
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Options

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

.. option:: --crop X0 Y0 X1 Y1

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).

.. option:: -s CSV, --stats CSV

Stats file (.csv) to write frame metrics. Existing files will be overwritten. Used for tuning detection parameters and data analysis.
Expand Down
12 changes: 8 additions & 4 deletions scenedetect.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,19 @@
# Must be one of: detect-adaptive, detect-content, detect-threshold, detect-hist
#default-detector = detect-adaptive

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

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

# Output directory for written files. Defaults to working directory.
#output = /usr/tmp/scenedetect/
# Crop input video to area. Specified as two points in the form X0 Y0 X1 Y1 or
# as (X0 Y0), (X1 Y1). Coordinate (0, 0) is the top-left corner.
#crop = 100 100 200 250

# Video backend interface, must be one of: opencv, pyav, moviepy.
#backend = opencv

# Minimum length of a given scene.
#min-scene-len = 0.6s
Expand Down
12 changes: 11 additions & 1 deletion scenedetect/_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,14 @@ def print_command_help(ctx: click.Context, command: click.Command):
help="Backend to use for video input. Backend options can be set using a config file (-c/--config). [available: %s]%s"
% (", ".join(AVAILABLE_BACKENDS.keys()), USER_CONFIG.get_help_string("global", "backend")),
)
@click.option(
"--crop",
metavar="X0 Y0 X1 Y1",
type=(int, int, int, int),
default=None,
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"
% (USER_CONFIG.get_help_string("global", "crop", show_default=False)),
)
@click.option(
"--downscale",
"-d",
Expand Down Expand Up @@ -312,6 +320,7 @@ def scenedetect(
drop_short_scenes: ty.Optional[bool],
merge_last_scene: ty.Optional[bool],
backend: ty.Optional[str],
crop: ty.Optional[ty.Tuple[int, int, int, int]],
downscale: ty.Optional[int],
frame_skip: ty.Optional[int],
verbosity: ty.Optional[str],
Expand All @@ -326,12 +335,13 @@ def scenedetect(
output=output,
framerate=framerate,
stats_file=stats,
downscale=downscale,
frame_skip=frame_skip,
min_scene_len=min_scene_len,
drop_short_scenes=drop_short_scenes,
merge_last_scene=merge_last_scene,
backend=backend,
crop=crop,
downscale=downscale,
quiet=quiet,
logfile=logfile,
config=config,
Expand Down
60 changes: 53 additions & 7 deletions scenedetect/_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,47 @@ def from_config(config_value: str, default: "RangeValue") -> "RangeValue":
) from ex


class CropValue(ValidatedValue):
"""Validator for crop region defined as X0 Y0 X1 Y1."""

_IGNORE_CHARS = [",", "/", "(", ")"]
"""Characters to ignore."""

def __init__(self, value: Optional[Union[str, Tuple[int, int, int, int]]] = None):
if isinstance(value, CropValue) or value is None:
self._crop = value
else:
crop = ()
if isinstance(value, str):
translation_table = str.maketrans(
{char: " " for char in ScoreWeightsValue._IGNORE_CHARS}
)
values = value.translate(translation_table).split()
crop = tuple(int(val) for val in values)
elif isinstance(value, tuple):
crop = value
if not len(crop) == 4:
raise ValueError("Crop region must be four numbers of the form X0 Y0 X1 Y1!")
if any(coordinate < 0 for coordinate in crop):
raise ValueError("Crop coordinates must be >= 0")
(x0, y0, x1, y1) = crop
self._crop = (min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1))

@property
def value(self) -> Tuple[int, int, int, int]:
return self._crop

def __str__(self) -> str:
return "[%d, %d], [%d, %d]" % self.value

@staticmethod
def from_config(config_value: str, default: "CropValue") -> "CropValue":
try:
return CropValue(config_value)
except ValueError as ex:
raise OptionParseFailure(f"{ex}") from ex


class ScoreWeightsValue(ValidatedValue):
"""Validator for score weight values (currently a tuple of four numbers)."""

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

@property
def value(self) -> Tuple[float, float, float, float]:
def value(self) -> ContentDetector.Components:
return self._value

def __str__(self) -> str:
Expand Down Expand Up @@ -340,6 +381,7 @@ def format(self, timecode: FrameTimecode) -> str:
},
"global": {
"backend": "opencv",
"crop": CropValue(),
"default-detector": "detect-adaptive",
"downscale": 0,
"downscale-method": Interpolation.LINEAR,
Expand Down Expand Up @@ -484,7 +526,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
out_map[command][option] = parsed
except TypeError:
errors.append(
"Invalid [%s] value for %s: %s. Must be one of: %s."
"Invalid value for [%s] option %s': %s. Must be one of: %s."
% (
command,
option,
Expand All @@ -498,7 +540,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:

except ValueError as _:
errors.append(
"Invalid [%s] value for %s: %s is not a valid %s."
"Invalid value for [%s] option '%s': %s is not a valid %s."
% (command, option, config.get(command, option), value_type)
)
continue
Expand All @@ -514,7 +556,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
)
except OptionParseFailure as ex:
errors.append(
"Invalid [%s] value for %s:\n %s\n%s"
"Invalid value for [%s] option '%s': %s\nError: %s"
% (command, option, config_value, ex.error)
)
continue
Expand All @@ -526,7 +568,7 @@ def _parse_config(config: ConfigParser) -> Tuple[ConfigDict, List[str]]:
if command in CHOICE_MAP and option in CHOICE_MAP[command]:
if config_value.lower() not in CHOICE_MAP[command][option]:
errors.append(
"Invalid [%s] value for %s: %s. Must be one of: %s."
"Invalid value for [%s] option '%s': %s. Must be one of: %s."
% (
command,
option,
Expand Down Expand Up @@ -612,8 +654,12 @@ def _load_from_disk(self, path=None):
config_file_contents = config_file.read()
config.read_string(config_file_contents, source=path)
except ParsingError as ex:
if __debug__:
raise
raise ConfigLoadFailure(self._init_log, reason=ex) from None
except OSError as ex:
if __debug__:
raise
raise ConfigLoadFailure(self._init_log, reason=ex) from None
# At this point the config file syntax is correct, but we need to still validate
# the parsed options (i.e. that the options have valid values).
Expand All @@ -638,8 +684,8 @@ def get_value(
"""Get the current setting or default value of the specified command option."""
assert command in CONFIG_MAP and option in CONFIG_MAP[command]
if override is not None:
return override
if command in self._config and option in self._config[command]:
value = override
elif command in self._config and option in self._config[command]:
value = self._config[command][option]
else:
value = CONFIG_MAP[command][option]
Expand Down
39 changes: 35 additions & 4 deletions scenedetect/_cli/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
CHOICE_MAP,
ConfigLoadFailure,
ConfigRegistry,
CropValue,
)
from scenedetect.detectors import (
AdaptiveDetector,
Expand Down Expand Up @@ -157,12 +158,13 @@ def handle_options(
output: ty.Optional[ty.AnyStr],
framerate: float,
stats_file: ty.Optional[ty.AnyStr],
downscale: ty.Optional[int],
frame_skip: int,
min_scene_len: str,
drop_short_scenes: ty.Optional[bool],
merge_last_scene: ty.Optional[bool],
backend: ty.Optional[str],
crop: ty.Optional[ty.Tuple[int, int, int, int]],
downscale: ty.Optional[int],
quiet: bool,
logfile: ty.Optional[ty.AnyStr],
config: ty.Optional[ty.AnyStr],
Expand Down Expand Up @@ -212,7 +214,7 @@ def handle_options(
logger.log(log_level, log_str)
if init_failure:
logger.critical("Error processing configuration file.")
raise click.Abort()
raise SystemExit(1)

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

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

self.scene_manager = scene_manager

#
Expand Down Expand Up @@ -318,6 +334,8 @@ def get_detect_content_params(
try:
weights = ContentDetector.Components(*weights)
except ValueError as ex:
if __debug__:
raise
logger.debug(str(ex))
raise click.BadParameter(str(ex), param_hint="weights") from None

Expand Down Expand Up @@ -373,6 +391,8 @@ def get_detect_adaptive_params(
try:
weights = ContentDetector.Components(*weights)
except ValueError as ex:
if __debug__:
raise
logger.debug(str(ex))
raise click.BadParameter(str(ex), param_hint="weights") from None
return {
Expand Down Expand Up @@ -545,20 +565,31 @@ def _open_video_stream(
framerate=framerate,
backend=backend,
)
logger.debug("Video opened using backend %s", type(self.video_stream).__name__)
logger.debug(f"""Video information:
Backend: {type(self.video_stream).__name__}
Resolution: {self.video_stream.frame_size}
Framerate: {self.video_stream.frame_rate}
Duration: {self.video_stream.duration} ({self.video_stream.duration.frame_num} frames)""")

except FrameRateUnavailable as ex:
if __debug__:
raise
raise click.BadParameter(
"Failed to obtain framerate for input video. Manually specify framerate with the"
" -f/--framerate option, or try re-encoding the file.",
param_hint="-i/--input",
) from ex
except VideoOpenFailure as ex:
if __debug__:
raise
raise click.BadParameter(
"Failed to open input video%s: %s"
% (" using %s backend" % backend if backend else "", str(ex)),
param_hint="-i/--input",
) from ex
except OSError as ex:
if __debug__:
raise
raise click.BadParameter(
"Input error:\n\n\t%s\n" % str(ex), param_hint="-i/--input"
) from None
1 change: 0 additions & 1 deletion scenedetect/detectors/content_detector.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,6 @@ def __init__(
self._weights = ContentDetector.LUMA_ONLY_WEIGHTS
self._kernel: Optional[numpy.ndarray] = None
if kernel_size is not None:
print(kernel_size)
if kernel_size < 3 or kernel_size % 2 == 0:
raise ValueError("kernel_size must be odd integer >= 3")
self._kernel = numpy.ones((kernel_size, kernel_size), numpy.uint8)
Expand Down
5 changes: 4 additions & 1 deletion scenedetect/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,10 @@ def get_system_version_info() -> str:
for module_name in third_party_packages:
try:
module = importlib.import_module(module_name)
out_lines.append(output_template.format(module_name, module.__version__))
if hasattr(module, "__version__"):
out_lines.append(output_template.format(module_name, module.__version__))
else:
out_lines.append(output_template.format(module_name, not_found_str))
except ModuleNotFoundError:
out_lines.append(output_template.format(module_name, not_found_str))

Expand Down
Loading

0 comments on commit 18c7ab8

Please sign in to comment.