Skip to content

Commit

Permalink
✨ [v0.2.1] Implement support for live input devices/sources. (Fixes #16)
Browse files Browse the repository at this point in the history
### ✨ New Features
- Sourcer API:
  * Implemented support for extracting metadata from live input devices/sources.
  * Added new `source_demuxer` and `forced_validate` parameters to `validate_source` internal method.
  * Implemented logic to validate `source_demuxer` value against FFmpeg supported demuxers.
  * Rearranged metadata dict.
  * Updated Code comments.
- FFdecoder API: 
  * Implemented functionality to supported live devices by allowing device path and respective demuxer into pipeline.
  * Included `-f` FFmpeg parameter into pipeline to specify source device demuxer.
  * Added special case for discarding `-framerate` value with Nonetype.
- CI:
  * Added new unittest `test_camera_capture()` to test support for live Virtual Camera devices.
  * Added new `v4l2loopback-dkms`, `v4l2loopback-utils` and kernel related APT dependencies. 
- Bash Script:
  * Added new FFmpeg command to extract image datasets from given video on Linux envs.
  * Created live Virtual Camera devices through `v4l2loopback` library on Github Actions Linux envs. 
    * Added `v4l2loopback` modprobe command to setup Virtual Camera named `VCamera` dynamically at `/dev/video2`.
    * Added `v4l2-ctl --list-devices` command for debugging.
    * Implemented FFmpeg command through `nohup`(no hangup) to feed video loop input to Virtual Camera in the background.

### ⚡️ Updates/Improvements
- Sourcer API:
  * Only either `source_demuxer` or `source_extension` attribute can be present in metadata.
  * Enforced `forced_validate` for live input devices/sources in `validate_source` internal method.
- FFdecoder API:
  * Rearranged FFmpeg parameters in pipeline.
  * Removed redundant code.
  * Updated Code comments.
- FFhelper API:
  * Logged error message on metadata extraction failure.
- CI:
  * Restricted `test_camera_capture()` unittest to Linux envs only.
  * Removed `return_generated_frames_path()` method support for Linux envs. 
  * Pinned jinja2 `3.1.0` or above breaking mkdocs. 
    * `jinja2>=3.1.0` breaks mkdocs (mkdocs/mkdocs#2799), therefore pinned jinja2 version to `<3.1.0`.
- Bash Script:
  * Updated to latest FFmpeg Static Binaries links. 
    * Updated download links to abhiTronix/ffmpeg-static-builds * hosting latest available versions.
    * Updated date/version tag to `12-07-2022`.
    * Removed depreciated binaries download links and code.
- Setup:
  * Bumped version to 0.2.1.
- Docs:
  * Updated Roadmap in README.md

### 💥 Updates/Changes
- Implement support for live input devices/sources.
  * `source` parameter now accepts device name or path.
  * Added `source_demuxer` parameter to specify demuxer for live input devices/sources.
  * Implemented Automated inserting of `-f` FFmpeg parameter whenever `source_demuxer` is specified by the user.

### 🐛 Bug-fixes
- Sourcer API:
  * Fixed Nonetype value bug in `source_demuxer` assertion logic.
  * Fixed typos in parameter names.
  * Added missing import.
- FFhelper API:
  * Logged error message on metadata extraction failure.
  * Fixed bug with `get_supported_demuxers` not detecting name patterns with commas.
  * Removed redundant logging.
- CI:
  * Fixed critical permission bug causing  `v4l2loopback` to fail on Github Actions Linux envs. 
    * Elevated privileges to `root` by adding `sudo` to all commands(including bash scripts and python commands).
    * Updated vidgear dependency to pip install from its git `testing` branch with recent bug fixes.
    * Replaced relative paths with absolute paths in unit tests.
  * Fixed WriteGear API unable to write frames due to permission errors.
  * Fixed `test_source_playback()` test failing on Darwin envs with OLD FFmpeg binaries.
    * Removed `custom_ffmpeg` value for Darwin envs.
  * Fixed various naming typos.
  * Fixed missing APT dependencies.
  • Loading branch information
abhiTronix authored Jul 14, 2022
2 parents f3a9e74 + a22465f commit df7568e
Show file tree
Hide file tree
Showing 12 changed files with 307 additions and 124 deletions.
18 changes: 10 additions & 8 deletions .github/workflows/CIlinux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,28 +42,30 @@ jobs:
- uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install API Dependencies
- name: Install APT Dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq unzip curl -y
sudo apt-get install -qq dos2unix ffmpeg -y
sudo apt-get install -qq dos2unix git -y
sudo apt-get install ffmpeg v4l2loopback-dkms v4l2loopback-utils linux-modules-extra-$(uname -r) -y
- name: Prepare Bash scripts
run: |
dos2unix scripts/bash/prepare_dataset.sh
chmod +x scripts/bash/prepare_dataset.sh
sudo chmod +x scripts/bash/prepare_dataset.sh
- name: Install Pip Dependencies
run: |
pip install -U pip wheel numpy
pip install -U .
pip install -U vidgear[core] opencv-python-headless
pip install -U flake8 six codecov pytest pytest-cov
sudo pip install -U pip wheel numpy
sudo pip install -U .
sudo pip install git+https://github.com/abhiTronix/vidgear@testing#egg=vidgear[core]
sudo pip install -U opencv-python-headless
sudo pip install -U flake8 six codecov pytest pytest-cov
if: success()
- name: Run prepare_dataset Bash script
run: bash scripts/bash/prepare_dataset.sh
shell: bash
- name: Run pytest and flake8
run: |
timeout 1200 pytest --verbose --cov=deffcode --cov-report=xml --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "EXIT_CODE=$code" >>$GITHUB_ENV; fi
timeout 1200 sudo python -m pytest --verbose --cov=deffcode --cov-report=xml --cov-report term-missing tests/ || code=$?; if [[ $code -ne 124 && $code -ne 0 ]]; then exit $code; else echo "EXIT_CODE=$code" >>$GITHUB_ENV; fi
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
if: success()
- name: Upload coverage to Codecov
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/docs_deployer.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
run: |
pip install -U mkdocs mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin mkdocs-exclude mike
pip install mkdocstrings==0.17.0
pip install jinja2==3.0.*
if: success()
- name: git configure
run: |
Expand Down Expand Up @@ -96,6 +97,7 @@ jobs:
run: |
pip install -U mkdocs mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin mkdocs-exclude mike
pip install mkdocstrings==0.17.0
pip install jinja2==3.0.*
if: success()
- name: git configure
run: |
Expand Down Expand Up @@ -145,6 +147,7 @@ jobs:
run: |
pip install -U mkdocs mkdocs-material mkdocs-git-revision-date-localized-plugin mkdocs-minify-plugin mkdocs-exclude mike
pip install mkdocstrings==0.17.0
pip install jinja2==3.0.*
if: success()
- name: git configure
run: |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,8 +144,8 @@ For more examples and in-depth usage guide, kindly refer our **[Basic Recipes
- [x] Add project Issue and PR templates.
- [x] Add related unit tests with `pytests`.
- [x] Automate stuff with Continuous Integration.
- [x] Add Devices and Screen Capture support.
- [ ] Add Multiple Source Inputs support.
- [ ] Add Devices and Screen Capture support.
- [ ] Resolve High CPU usage issue with WriteGear API.
- [ ] Add more parameters to Sourcer API's metadata.
- [ ] Implement Buffer and Audio pass-through modes.
Expand Down
47 changes: 30 additions & 17 deletions deffcode/ffdecoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,13 @@ class FFdecoder:
""" """

def __init__(
self, source, frame_format=None, custom_ffmpeg="", verbose=False, **extraparams
self,
source,
source_demuxer=None,
frame_format=None,
custom_ffmpeg="",
verbose=False,
**extraparams
):
"""
This constructor method initializes the object state and attributes of the FFdecoder.
Expand Down Expand Up @@ -74,9 +80,6 @@ def __init__(
# handle process to be frames written
self.__process = None

# handle valid FFmpeg assets location
self.__ffmpeg = ""

# handle exclusive metadata
self.__ff_pixfmt_metadata = None # metadata
self.__raw_frame_num = None # raw-frame number
Expand All @@ -101,7 +104,7 @@ def __init__(
# cleans and reformat user-defined parameters
self.__extra_params = {
str(k).strip(): str(v).strip()
if not isinstance(v, (dict, list, int, float))
if not (v is None) and not isinstance(v, (dict, list, int, float))
else v
for k, v in extraparams.items()
}
Expand Down Expand Up @@ -132,15 +135,16 @@ def __init__(
self.__source_metadata = (
Sourcer(
source=source,
source_demuxer=source_demuxer,
verbose=verbose,
ffmpeg_path=self.__ffmpeg,
custom_ffmpeg=custom_ffmpeg if isinstance(custom_ffmpeg, str) else "",
**sourcer_params
)
.probe_stream(default_stream_indexes=default_stream_indexes)
.retrieve_metadata()
)

# get valid ffmpeg path
# handle valid FFmpeg assets location
self.__ffmpeg = self.__source_metadata["ffmpeg_binary_path"]

# handle pass-through audio mode works in conjunction with WriteGear [WIP]
Expand Down Expand Up @@ -182,15 +186,20 @@ def __init__(

# handle user-defined framerate
self.__inputframerate = self.__extra_params.pop("-framerate", 0.0)
if (
isinstance(self.__inputframerate, (float, int))
and self.__inputframerate > 0.0
):
# must be float
self.__inputframerate = float(self.__inputframerate)
if isinstance(self.__inputframerate, (float, int)):
self.__inputframerate = (
float(self.__inputframerate) if self.__inputframerate > 0 else 0.0
)
elif self.__inputframerate is None:
# special case for discarding framerate value
pass
else:
# reset improper values
self.__inputframerate = 0.0
# warn if wrong type
logger.warning(
"Discarding `-framerate` value of wrong type `{}`!".format(
type(self.__inputframerate)
)
)

# FFmpeg parameter `-s` is unsupported
if not (self.__extra_params.pop("-s", None) is None):
Expand Down Expand Up @@ -533,8 +542,12 @@ def __launch_FFdecoderline(self, input_params, output_params):
+ self.__ffmpeg_prefixes
+ input_parameters
+ self.__ffmpeg_postfixes
+ ["-i"]
+ [self.__source_metadata["source"]]
+ (
["-f", self.__source_metadata["source_demuxer"]]
if ("source_demuxer" in self.__source_metadata.keys())
else []
)
+ ["-i", self.__source_metadata["source"]]
+ output_parameters
+ ["-f", "rawvideo", "-"]
)
Expand Down
12 changes: 7 additions & 5 deletions deffcode/ffhelper.py
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ def get_supported_demuxers(path):
# find all outputs
outputs = finder.findall("\n".join(supported_demuxers))
# return output findings
return [o.strip() for o in outputs]
return [o.strip() if not ("," in o) else o.split(",")[-1].strip() for o in outputs]


def validate_imgseqdir(source, extension="jpg", verbose=False):
Expand Down Expand Up @@ -474,20 +474,18 @@ def check_sp_output(*args, **kwargs):
if platform.system() == "Windows":
# see comment https://bugs.python.org/msg370334
sp._cleanup = lambda: None

# handle additional params
retrieve_stderr = kwargs.pop("force_retrieve_stderr", False)

# execute command in subprocess
process = sp.Popen(
stdout=sp.PIPE,
stderr=sp.DEVNULL if not (retrieve_stderr) else sp.PIPE,
*args,
**kwargs,
)
# communicate and poll process
output, stderr = process.communicate()
retcode = process.poll()

# handle return code
if retcode and not (retrieve_stderr):
cmd = kwargs.get("args")
Expand All @@ -496,5 +494,9 @@ def check_sp_output(*args, **kwargs):
error = sp.CalledProcessError(retcode, cmd)
error.output = output
raise error

# raise error if no output
bool(output) or bool(stderr) or logger.error(
"FFmpeg Pipline failed to exact any metadata!"
)
# return output otherwise
return output if not (retrieve_stderr) else stderr
102 changes: 70 additions & 32 deletions deffcode/sourcer.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@
"""

# import required libraries
import re, logging, os
import re, logging, os, platform
import numpy as np

# import utils packages
from .utils import logger_handler
from .ffhelper import (
check_sp_output,
get_supported_demuxers,
is_valid_url,
is_valid_image_seq,
get_valid_ffmpeg_path,
Expand All @@ -41,7 +42,14 @@
class Sourcer:
""" """

def __init__(self, source, custom_ffmpeg="", verbose=False, **sourcer_params):
def __init__(
self,
source,
source_demuxer=None,
custom_ffmpeg="",
verbose=False,
**sourcer_params
):
"""
This constructor method initializes the object state and attributes of the Sourcer.
Expand Down Expand Up @@ -101,9 +109,12 @@ def __init__(self, source, custom_ffmpeg="", verbose=False, **sourcer_params):

# define externally accessible parameters
self.__source = source # handles source stream
self.__source_extension = os.path.splitext(source)[
-1
] # handles source stream extension
# handles source demuxer
self.__source_demuxer = (
source_demuxer.strip().lower() if isinstance(source_demuxer, str) else None
)
# handles source stream extension
self.__source_extension = os.path.splitext(source)[-1]
self.__default_video_resolution = "" # handle stream resolution
self.__default_video_framerate = "" # handle stream framerate
self.__default_video_bitrate = "" # handle stream's video bitrate
Expand Down Expand Up @@ -137,7 +148,13 @@ def probe_stream(self, default_stream_indexes=(0, 0)):
and all(isinstance(x, int) for x in default_stream_indexes)
), "Invalid default_stream_indexes value!"
# validate source and extract metadata
self.__ffsp_output = self.__validate_source(self.__source)
self.__ffsp_output = self.__validate_source(
self.__source,
source_demuxer=self.__source_demuxer,
forced_validate=(
self.__forcevalidatesource if self.__source_demuxer is None else True
),
)
# parse resolution and framerate
video_rfparams = self.__extract_resolution_framerate(
default_stream=default_stream_indexes[0]
Expand Down Expand Up @@ -209,50 +226,71 @@ def retrieve_metadata(self):
metadata = {
"ffmpeg_binary_path": self.__ffmpeg,
"source": self.__source,
"source_extension": self.__source_extension,
"source_video_resolution": self.__default_video_resolution,
"source_video_framerate": self.__default_video_framerate,
"source_video_pixfmt": self.__default_video_pixfmt,
"source_video_decoder": self.__default_video_decoder,
"source_duration_sec": self.__default_source_duration,
"approx_video_nframes": int(self.__approx_video_nframes)
if self.__approx_video_nframes
else None,
"source_video_bitrate": self.__default_video_bitrate,
"source_audio_bitrate": self.__default_audio_bitrate,
"source_audio_samplerate": self.__default_audio_samplerate,
"source_has_video": self.__contains_video,
"source_has_audio": self.__contains_audio,
"source_has_image_sequence": self.__contains_images,
}
metadata.update(
{"source_extension": self.__source_extension}
if self.__source_demuxer is None
else {"source_demuxer": self.__source_demuxer}
)
metadata.update(
{
"source_video_resolution": self.__default_video_resolution,
"source_video_framerate": self.__default_video_framerate,
"source_video_pixfmt": self.__default_video_pixfmt,
"source_video_decoder": self.__default_video_decoder,
"source_duration_sec": self.__default_source_duration,
"approx_video_nframes": (
int(self.__approx_video_nframes)
if self.__approx_video_nframes
else None
),
"source_video_bitrate": self.__default_video_bitrate,
"source_audio_bitrate": self.__default_audio_bitrate,
"source_audio_samplerate": self.__default_audio_samplerate,
"source_has_video": self.__contains_video,
"source_has_audio": self.__contains_audio,
"source_has_image_sequence": self.__contains_images,
}
)
return metadata

def __validate_source(self, source):
def __validate_source(self, source, source_demuxer=None, forced_validate=False):
"""
Internal method for validating source and extract its FFmpeg metadata.
"""
if source is None or not source or not isinstance(source, str):
raise ValueError("Input source is empty!")
# assert if valid source
assert source and isinstance(source, str), "Input source is empty!"
# assert if valid source demuxer
assert source_demuxer is None or source_demuxer in get_supported_demuxers(
self.__ffmpeg
), "Installed FFmpeg failed to recognise `{}` demuxer. Check ``source_demuxer`` parameter value again!".format(
source_demuxer
)
# Differentiate input
if os.path.isfile(source):
self.__video_source = os.path.abspath(source)
if forced_validate:
source_demuxer is None and logger.critical(
"Forcefully passing validation test for given source!"
)
self.__source = source
elif os.path.isfile(source):
self.__source = os.path.abspath(source)
elif is_valid_image_seq(
self.__ffmpeg, source=source, verbose=self.__verbose_logs
):
self.__video_source = source
self.__source = source
self.__contains_images = True
elif is_valid_url(self.__ffmpeg, url=source, verbose=self.__verbose_logs):
self.__video_source = source
elif self.__forcevalidatesource:
logger.critical("Forcefully passing validation test for given source!")
self.__video_source = source
self.__source = source
else:
logger.error("`source` value is unusable or unsupported!")
# discard the value otherwise
raise ValueError("Input source is invalid. Aborting!")
# extract metadata
metadata = check_sp_output(
[self.__ffmpeg, "-hide_banner", "-i", source], force_retrieve_stderr=True
[self.__ffmpeg, "-hide_banner"]
+ (["-f", source_demuxer] if source_demuxer else [])
+ ["-i", source],
force_retrieve_stderr=True,
)
# filter and return
return metadata.decode("utf-8").strip()
Expand Down
2 changes: 1 addition & 1 deletion deffcode/version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.2.0"
__version__ = "0.2.1"
Loading

0 comments on commit df7568e

Please sign in to comment.