if Image.evision_configured?() do
defmodule Image.Video do
@moduledoc """
Implements functions to extract frames froma video file
as images using [eVision](https://hex.pm/packages/evision). The
implementation is based upon
[OpenCV Video Capture](https://docs.opencv.org/3.4/d0/da7/videoio_overview.html).
Images can be extracted by frame number of number of milliseconds with
`Image.Video.image_from_video/2`.
In order to extract images the video file must first be
opened with `Image.Video.open/1`. At the end of processing the video
file should be closed with `Image.Video.close/1`.
This process can be wrapped by `Image.Video.with_video/2` which will
open a video file, execute a function (passing it the video reference) and
closing the video file at the end of the function.
### Note
This module is only available if the optional dependency
[eVision](https://hex.pm/packages/evision) is configured in
`mix.exs`.
"""
alias Vix.Vips.Image, as: Vimage
alias Evision.VideoCapture
alias Evision.Constant
alias Image.Options
@typedoc "The valid options for Image.Video.seek/2, Image.Video.image_from_video/2"
@type seek_options :: [frame: non_neg_integer()] | [millisecond: non_neg_integer()]
@typedoc "The representation of a video stream"
@type stream_id :: non_neg_integer() | :default_camera
@doc subject: "Guard"
@doc "Guards that a frame offset is valid for a video"
defguard is_frame(frame, frame_count)
when (is_integer(frame) and frame in 0..(trunc(frame_count) - 1)) or
(is_integer(frame) and frame_count == 0.0)
@doc subject: "Guard"
@doc "Guards that a millisecond count is valid for a video"
defguard is_valid_millis(millis, frames, fps)
when is_integer(millis) and millis in 0..(trunc(fps * frames * 1000) - 1)
@doc "Guards that a stream id is valid for a video stream"
@doc subject: "Guard"
defguard is_stream(stream_id)
when (is_integer(stream_id) and stream_id >= 0) or stream_id == :default_camera
@doc """
Opens a video file, calls the given function with the video
reference and closes the video after the function returns.
### Arguments
* `filename` is the filename of a video file
### Returns
* `{:ok, video}` or
* `{:error, reason}`
### Example
iex> Image.Video.with_video "./test/support/video/video_sample.mp4", fn video ->
...> Image.Video.image_from_video(video, 1)
...> end
"""
@spec with_video(filename :: Path.t(), (VideoCapture.t() -> any())) ::
{:ok, VideoCapture.t()} | {:error, Image.error_message()}
def with_video(filename, fun) when is_binary(filename) and is_function(fun, 1) do
filename
|> open()
|> do_with_video(fun)
end
defp do_with_video({:ok, video}, fun) do
fun.(video)
after
close(video)
end
defp do_with_video({:error, reason}, _fun) do
{:error, reason}
end
@doc """
Opens a video file or video stream for
frame extraction.
### Arguments
* `filename_or_stream` is the filename of a video file
or the OpenCV representation of a video stream as
an integer. It may also be `:default_camera` to open
the default camera if there is one.
* `options` is a keyword list of options. The default
is `[]`.
### Options
* `:backend` specifies the backend video processing
system to be used. The default is `:any` which means
that the first available backend in the current OpenCV
configuration will be used. The available backends
can be returned by `Image.Video.available_backends/0`.
### Returns
* `{:ok, video}` or
* `{:error, reason}`.
### Note
* The video `t:VideoCapture.t/0` struct that is returned
includes metadata fields for frame rate (:fps), frame width
(:frame_width), frame height (:frame_height) and frame count
(:frame_count). *Note that frame count is an approximation due to
issues in the underlying OpenCV*.
### Example
iex> Image.Video.open "./test/support/video/video_sample.mp4"
iex> {:ok, camera_video} = Image.Video.open(:default_camera)
iex> Image.Video.close(camera_video)
"""
@spec open(filename_or_stream :: Path.t() | stream_id(), Options.Video.open_options()) ::
{:ok, VideoCapture.t()} | {:error, Image.error_message()}
def open(filename, options \\ [])
def open(filename, options) when is_binary(filename) and is_list(options) do
with {:ok, backend} <- Options.Video.validate_open_options(options) do
case VideoCapture.videoCapture(filename, apiPreference: backend) do
%VideoCapture{} = video ->
{:ok, video}
error ->
{:error, "Could not open video #{inspect(filename)}. Error #{inspect(error)}"}
end
end
end
def open(camera, options) when is_integer(camera) and camera >= 0 do
with {:ok, backend} <- Options.Video.validate_open_options(options) do
case VideoCapture.videoCapture(camera, apiPreference: backend) do
%VideoCapture{} = video ->
{:ok, video}
error ->
{:error, "Could not open the camera. Error #{inspect(error)}"}
end
end
end
@default_camera_id 0
def open(:default_camera, options) do
open(@default_camera_id, options)
end
@doc """
Opens a video file for frame extraction or
raises an exception.
### Arguments
* `filename` is the filename of a video file.
* `options` is a keyword list of options. The default
is `[]`.
### Options
* `:backend` specifies the backend video processing
system to be used. The default is `:any` which means
that the first available backend in the current OpenCV
configuration will be used. The available backends
can be returned by `Image.Video.available_backends/0`.
### Returns
* `video` or
* raises an exception.
### Example
iex> Image.Video.open! "./test/support/video/video_sample.mp4"
"""
@spec open!(filename_or_stream :: Path.t() | stream_id()) ::
VideoCapture.t() | no_return()
def open!(filename_or_stream) do
case open(filename_or_stream) do
{:ok, video} -> video
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Closes a video.
### Arguments
* `video` is any `t:VideoCapture.t/0`.
### Returns
* `{:ok, closed_video}` or
* `{:error, reason}`.
### Example
iex> {:ok, video} = Image.Video.open "./test/support/video/video_sample.mp4"
iex> Image.Video.close(video)
"""
@spec close(VideoCapture.t()) ::
{:ok, VideoCapture.t()} | {:error, Image.error_message()}
def close(%VideoCapture{} = video) do
case VideoCapture.release(video) do
%VideoCapture{} = video ->
{:ok, video}
error ->
{:error, "Could not close video. Error #{inspect(error)}"}
end
end
@doc """
Closes a video or raises an exception.
### Arguments
* `video` is any `t:VideoCapture.t/0`.
### Returns
* `closed_video` or
* raises an exception.
### Example
iex> {:ok, video} = Image.Video.open "./test/support/video/video_sample.mp4"
iex> Image.Video.close!(video)
"""
@spec close!(VideoCapture.t()) :: VideoCapture.t() | no_return()
def close!(video) do
case close(video) do
{:ok, video} -> video
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Returns video file or live video as a `t:Enumerable.t/0`
stream.
This allows a video file or live video to be streamed
for processing like any other enumerable.
### Arguments
* `filename_or_stream` is either a pathname on the
current system, a non-negative integer representing a
video stream or `:default_camera` representing the
stream for the default system camera. It can also
be a `t:VideoCapture.t/0` representing a
video file or stream that is already opened (this is the
preferred approach).
* `options` is a keyword list of options.
### Options
Only one of the following options can be provided. No
options means the entire video will be streamed frame
by frame.
* `:frame` is a `t:Range.t/0` representing the range
of frames to be extracted. `:frames` can only be specified
for video files, not for video streams. For example,
`frames: 10..100/2` will produce a stream of images that
are every second image between the frame offsets `10` and `100`.
* `:millisecond` is a `t:Range.t/0` representing the range
of milliseconds to be extracted. `:millisecond` can only
be specified for video files, not for video streams. For example,
`millisecond: 1000..100000/2` will produce a stream of images
that are every second image between the millisecond offsets of `1_000`
and `100_000`.
### Returns
* A `t:Enumerable.t/0` that can be used with functions in
the `Stream` and `Enum` modules to lazily enumerate images
extracted from a video stream.
### Example
# Extract every second frame starting at the
# first frame and ending at the last frame.
iex> "./test/support/video/video_sample.mp4"
...> |> Image.Video.stream!(frame: 0..-1//2)
...> |> Enum.to_list()
...> |> Enum.count()
86
"""
@spec stream!(
filename_or_stream :: Path.t() | stream_id() | VideoCapture.t(),
options :: Keyword.t()
) :: Enumerable.t()
def stream!(video, options \\ [])
def stream!(filename_or_stream, options)
when is_binary(filename_or_stream) or is_stream(filename_or_stream) do
filename_or_stream
|> open!()
|> stream!(options)
end
def stream!(%VideoCapture{} = video, options) do
options = Options.Video.validate_stream_options!(video, options)
Stream.resource(
fn ->
seek_to_video_first(video, options)
end,
fn
{video, _unit, first, last, _step} = stream when first <= last ->
case Image.Video.image_from_video(video) do
{:ok, image} ->
{[image], advance_stream(stream)}
_other ->
{:halt, video}
end
{video, _unit, _first, _last, _step} ->
{:halt, video}
end,
fn video -> Image.Video.close(video) end
)
end
defp seek_to_video_first(video, {nil = unit, first, last, step}) do
{video, unit, first, last, step}
end
defp seek_to_video_first(video, {unit, 0 = first, last, step}) do
{video, unit, first, last, step}
end
defp seek_to_video_first(video, {unit, first, last, step}) do
{:ok, video} = seek(video, [{unit, first}])
{video, unit, first, last, step}
end
defp advance_stream({video, nil = unit, first, last, step}) do
{video, unit, first, last, step}
end
defp advance_stream({video, unit, first, last, 1 = step}) do
next = first + step
{video, unit, next, last, step}
end
defp advance_stream({video, unit, first, last, step}) do
next = first + step
Enum.each(1..(step - 1), fn _x -> VideoCapture.grab(video) end)
{video, unit, next, last, step}
end
@doc """
Seeks the video head to a specified frame offset
or millisecond offset.
Note that seeking a video format is supported,
seeking a live video stream (such as from a
webcam) is not supported and will return an
error.
### Arguments
* `video` is any `t:VideoCapture.t/0`.
* `options` is a keyword list of options.
### Options
* `unit` is either `:frame` or `:millisecond` with a
non-negative integer offset. For example `frame: 3`.
### Returns
* `{:ok, video}` or
* `{:error, reason}`
### Notes
Seeking cannot be performed on image streams such as
webcams. Therefore no options may be provided when
extracting images from an image stream.
### Warning
Seeking is not [frame accurate](https://github.com/opencv/opencv/issues/9053)!
### Examples
iex> {:ok, video} = Image.Video.open("./test/support/video/video_sample.mp4")
iex> {:ok, _image} = Image.Video.seek(video, frame: 0)
iex> {:ok, _image} = Image.Video.seek(video, millisecond: 1_000)
iex> Image.Video.seek(video, frame: -1)
{:error, "Offset for :frame must be a non-negative integer. Found -1"}
"""
@spec seek(VideoCapture.t(), seek_options()) ::
{:ok, VideoCapture.t()} | {:error, Image.error_message()}
def seek(%VideoCapture{isOpened: true, frame_count: frame_count} = video, [{:frame, frame}])
when is_frame(frame, frame_count) do
case VideoCapture.set(video, Constant.cv_CAP_PROP_POS_FRAMES(), frame) do
true -> {:ok, video}
false -> {:error, "Could not seek to the frame offset #{inspect(frame)}."}
end
end
def seek(%VideoCapture{isOpened: true, fps: fps, frame_count: frame_count} = video, [
{:millisecond, millis}
])
when is_valid_millis(millis, frame_count, fps) do
case VideoCapture.set(video, Constant.cv_CAP_PROP_POS_MSEC(), millis) do
true -> {:ok, video}
false -> {:error, "Could not seek to the millisecond offset #{inspect(millis)}."}
end
end
def seek(%VideoCapture{isOpened: true}, [{unit, offset}])
when unit in [:frame, :millisecond] and offset < 0 do
{:error,
"Offset for #{inspect(unit)} must be a non-negative integer. Found #{inspect(offset)}"}
end
def seek(%VideoCapture{isOpened: true}, [{unit, offset}])
when unit in [:frame, :millisecond] and is_integer(offset) do
{:error, "Offset for #{inspect(unit)} is too large"}
end
def seek(%VideoCapture{isOpened: true}, options) do
{:error,
"Options must be either `frame: frame_offet` or `millisecond: millisecond_offset`. Found #{inspect(options)}"}
end
def seek(%VideoCapture{isOpened: false}, _options) do
{:error, video_closed_error()}
end
@doc """
Seeks the video head to a specified frame offset
or millisecond offset.
Note that seeking a video format is supported,
seeking a live video stream (such as from a
webcam) is not supported and will return an
error.
### Arguments
* `video` is any `t:VideoCapture.t/0`.
* `options` is a keyword list of options.
### Options
* `unit` is either `:frame` or `:millisecond` with a
non-negative integer offset. For example `frame: 3`.
### Returns
* `{:ok, video}` or
* `{:error, reason}`.
### Notes
Seeking cannot be performed on image streams such as
webcams. Therefore no options may be provided when
extracting images from an image stream.
"""
@spec seek!(VideoCapture.t(), seek_options()) ::
VideoCapture.t() | no_return()
def seek!(video, options \\ []) do
case seek(video, options) do
{:ok, video} -> video
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Scrubs a video forward by a number of frames.
In OpenCV (the underlying video library used by
`Image.Video`), seeking to a specified frame is not
frame accurate. This function moves the video
play head forward frame by frame and is therefore
a frame accurate way of moving the the video head
forward.
### Arguements
* `video` is any `t:VideoCapture.t/0`.
* `frames` is a positive integer number of frames
to scrub forward.
### Returns
* `{:ok, frames_scrubbed}`. `frames_scrubbed` may
be less than the number of requested frames. This may
happen of the end of the video stream is reached, or
* `{:error, reason}`.
### Examples
iex> {:ok, video} = Image.Video.open "./test/support/video/video_sample.mp4"
iex> {:ok, 10} = Image.Video.scrub(video, 10)
iex> Image.Video.scrub(video, 100_000_000)
{:ok, 161}
"""
@spec scrub(VideoCapture.t(), frames :: pos_integer) ::
{:ok, pos_integer()} | {:error, Image.error_message()}
def scrub(%VideoCapture{isOpened: true} = video, frames)
when is_integer(frames) and frames > 0 do
Enum.reduce_while(1..frames, {:ok, 0}, fn _frame, {:ok, count} ->
case VideoCapture.grab(video) do
true -> {:cont, {:ok, count + 1}}
false -> {:halt, {:ok, count}}
{:error, reason} -> {:halt, {:error, reason}}
end
end)
end
def scrub(%VideoCapture{isOpened: false}, _frames) do
{:error, video_closed_error()}
end
@doc """
Extracts a frame from a video and returns
an image.
After the image is extracted the play head
in the video file is advanced one frame. That is,
successive calls to `Image.Video.image_from_video/2`
will return successive frames - not the same frame.
### Arguments
* `video` is any `t:VideoCapture.t/0`
* `options` is a keyword list of options. The defalt
### Options
* `unit` is either `:frame` or `:millisecond` with a
non-negative integer offset. For example `frame: 3`.
The default is `[]` which means that no seek is performed
and the extracted image is taken from the current
position in the file or video stream. Note that seeking
is not guaranteed to be accurate. If frame accuracy is
required the recommended process is:
* Open the video file with `Image.Video.open/1`
* Scrub forward to the required freame with `Image.Video.scrub/2`
* Then capture the frame with `Image.Video.image_from_video/1`
### Returns
* `{:ok, image}` or
* `{:error, reason}`.
### Notes
Seeking cannot be performed on image streams such as
webcams. Therefore no options may be provided when
extracting images from an image stream.
### Examples
iex> {:ok, video} = Image.Video.open("./test/support/video/video_sample.mp4")
iex> {:ok, _image} = Image.Video.image_from_video(video)
iex> {:ok, _image} = Image.Video.image_from_video(video, frame: 0)
iex> {:ok, _image} = Image.Video.image_from_video(video, millisecond: 1_000)
iex> Image.Video.image_from_video(video, frame: -1)
{:error, "Offset for :frame must be a non-negative integer. Found -1"}
iex> Image.Video.image_from_video(video, frame: 500)
{:error, "Offset for :frame is too large"}
"""
@spec image_from_video(VideoCapture.t(), seek_options()) ::
{:ok, Vimage.t()} | {:error, Image.error_message()}
def image_from_video(video, options \\ [])
def image_from_video(%VideoCapture{isOpened: true} = video, []) do
with %Evision.Mat{} = cv_image <- VideoCapture.read(video) do
Image.from_evision(cv_image)
else
error -> {:error, "Could not extract the frame. Error #{inspect(error)}."}
end
end
def image_from_video(%VideoCapture{isOpened: true} = video, options) do
with {:ok, video} <- seek(video, options) do
image_from_video(video)
end
end
def image_from_video(%VideoCapture{isOpened: false}, _options) do
{:error, video_closed_error()}
end
@doc """
Extracts a frame from a video and returns
an image or raises an exception.
After the image is extracted the play head
in the video file is advanced one frame. That is,
successive calls to `Image.Video.image_from_video/2`
will return successive frames - not the same frame.
### Arguments
* `video` is any `t:VideoCapture.t/0`.
* `options` is a keyword list of options.
### Options
* `unit` is either `:frame` or `:millisecond` with a
non-negative integer offset. For example `frame: 3`.
The default is `[]` which means that no seek is performed
and the extracted image is taken from the current
position in the file or video stream. Note that seeking
is not guaranteed to be accurate. If frame accuracy is
required the recommended process is:
* Open the video file with `Image.Video.open/1`
* Scrub forward to the required freame with `Image.Video.scrub/2`
* Then capture the frame with `Image.Video.image_from_video/1`
### Returns
* `image` or
* raises an exception.
### Notes
Seeking cannot be performed on image streams such as
webcams. Therefore no options may be provided when
extracting images from an image stream.
"""
@spec image_from_video!(VideoCapture.t(), seek_options()) :: Vimage.t() | no_return()
def image_from_video!(%VideoCapture{} = video, options \\ []) do
case image_from_video(video, options) do
{:ok, image} -> image
{:error, reason} -> raise Image.Error, reason
end
end
@doc """
Returns a list of known (valid but not necessarily
available for use in the current OpenCV configuration)
backend video processors.
See the [OpenCV documentation](https://docs.opencv.org/4.x/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d)
for more information on video processor backends.
### Example
iex> Image.Video.known_backends() |> Enum.sort()
[:android, :any, :aravis, :avfoundation, :cmu1394, :dc1394, :dshow, :ffmpeg,
:fireware, :firewire, :giganetix, :gphoto2, :gstreamer, :ieee1394, :images,
:intel_mfx, :intelperc, :msmf, :obsensor, :opencv_mjpeg, :openni, :openni2,
:openni2_astra, :openni2_asus, :openni_asus, :pvapi, :qt, :realsense, :ueye,
:unicap, :v4l, :v4l2, :vfw, :winrt, :xiapi, :xine]
"""
@spec known_backends :: list(Options.Video.backend())
def known_backends do
Map.keys(Options.Video.known_backends())
end
@doc false
def known_backend_values do
Map.keys(Options.Video.inverted_known_backends())
end
@doc """
Returns a boolean indicating if the specified
backend is known (valid but not necessarily
available for use in the current OpenCV configuration).
### Examples
iex> Image.Video.known_backend?(:avfoundation)
true
iex> Image.Video.known_backend?(:invalid)
false
iex> Image.Video.known_backend?(1200)
true
iex> Image.Video.known_backend?(-1)
false
"""
@spec known_backend?(Options.Video.backend()) :: boolean()
def known_backend?(backend) when is_atom(backend) do
backend in known_backends()
end
def known_backend?(backend) when is_integer(backend) do
backend in known_backend_values()
end
@doc """
Returns a list of available (configured and
available for use) backend video processors.
See the [OpenCV documentation](https://docs.opencv.org/4.x/d4/d15/group__videoio__flags__base.html#ga023786be1ee68a9105bf2e48c700294d)
for more information on video processor backends.
"""
@spec available_backends :: list(Options.Video.backend())
def available_backends do
Options.Video.known_backends()
|> Enum.filter(fn {_backend, value} -> Evision.VideoIORegistry.hasBackend(value) end)
|> Keyword.keys()
end
@doc """
Returns a boolean indicating if the specified
backend is available (configured and
available for use).
"""
@spec available_backend?(any) :: boolean()
def available_backend?(backend) do
backend in available_backends()
end
### Helpers
defp video_closed_error do
"Video is not open"
end
end
end