Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add video models + functions #814

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,14 @@ hf = [
"numba>=0.60.0",
"datasets[audio,vision]>=2.21.0"
]
video = [
# Use 'av<14' because of incompatibility with imageio
# See https://github.com/PyAV-Org/PyAV/discussions/1700
"av<14",
dreadatour marked this conversation as resolved.
Show resolved Hide resolved
"imageio[ffmpeg]",
"moviepy",
"opencv-python"
]
tests = [
"datachain[torch,remote,vector,hf]",
"pytest>=8,<9",
Expand Down
59 changes: 56 additions & 3 deletions src/datachain/lib/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from urllib.request import url2pathname

from fsspec.callbacks import DEFAULT_CALLBACK, Callback
from PIL import Image
from PIL import Image as PilImage
from pydantic import Field, field_validator

from datachain.client.fileslice import FileSlice
Expand All @@ -39,7 +39,7 @@
# how to create file path when exporting
ExportPlacement = Literal["filename", "etag", "fullpath", "checksum"]

FileType = Literal["binary", "text", "image"]
FileType = Literal["binary", "text", "image", "video"]


class VFileError(DataChainError):
Expand Down Expand Up @@ -231,6 +231,10 @@
with self.open(mode="r") as stream:
return stream.read()

def stream(self) -> BytesIO:
"""Returns file contents as BytesIO stream."""
return BytesIO(self.read())

Check warning on line 236 in src/datachain/lib/file.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/file.py#L236

Added line #L236 was not covered by tests

def save(self, destination: str):
"""Writes it's content to destination"""
with open(destination, mode="wb") as f:
Expand Down Expand Up @@ -447,13 +451,60 @@
def read(self):
"""Returns `PIL.Image.Image` object."""
fobj = super().read()
return Image.open(BytesIO(fobj))
return PilImage.open(BytesIO(fobj))

def save(self, destination: str):
"""Writes it's content to destination"""
self.read().save(destination)


class Image(DataModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need this separate model?

"""`DataModel` for image file meta information."""

width: int = Field(default=0)
height: int = Field(default=0)
format: str = Field(default="")


class VideoFile(File):
shcheklein marked this conversation as resolved.
Show resolved Hide resolved
"""`DataModel` for reading video files."""


class VideoClip(VideoFile):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so, how are these all modes connected with the helpers? how do I instantiate them? do I have to write my own UDFs to do that (just instantiate these classes?)

"""`DataModel` for reading video clips."""

start: float = Field(default=0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd use some impossible value like -1.0

end: float = Field(default=0)


class VideoFrame(VideoFile):
"""`DataModel` for reading video frames."""

frame: int = Field(default=0)
timestamp: float = Field(default=0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

-1 and -1.0 as defaults?



class Video(DataModel):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should it be a subclass of VideoFile?

"""`DataModel` for video file meta information."""

width: int = Field(default=0)
height: int = Field(default=0)
fps: float = Field(default=0)
duration: float = Field(default=0)
frames: int = Field(default=0)
codec: str = Field(default="")


class Frame(DataModel):
"""`DataModel` for video frame image meta information."""

frame: int = Field(default=0)
timestamp: float = Field(default=0)
width: int = Field(default=0)
height: int = Field(default=0)
format: str = Field(default="")


class ArrowRow(DataModel):
"""`DataModel` for reading row from Arrow-supported file."""

Expand Down Expand Up @@ -489,5 +540,7 @@
file = TextFile
elif type_ == "image":
file = ImageFile # type: ignore[assignment]
elif type_ == "video":
file = VideoFile

Check warning on line 544 in src/datachain/lib/file.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/file.py#L544

Added line #L544 was not covered by tests

return file
Empty file removed src/datachain/lib/vfile.py
Empty file.
273 changes: 273 additions & 0 deletions src/datachain/lib/video.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import os.path
import pathlib
from typing import TYPE_CHECKING, Optional, Union

Check warning on line 3 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L1-L3

Added lines #L1 - L3 were not covered by tests

from datachain.lib.file import Video

Check warning on line 5 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L5

Added line #L5 was not covered by tests

if TYPE_CHECKING:
from collections.abc import Iterator

from numpy import ndarray

from datachain.lib.file import VideoFile

try:
import imageio.v3 as iio
from moviepy.video.io.VideoFileClip import VideoFileClip
except ImportError as exc:
raise ImportError(

Check warning on line 18 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L14-L18

Added lines #L14 - L18 were not covered by tests
"Missing dependencies for processing video:\n"
dreadatour marked this conversation as resolved.
Show resolved Hide resolved
"To install run:\n\n"
" pip install 'datachain[video]'\n"
) from exc


def video_meta(file: "VideoFile") -> Video:

Check warning on line 25 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L25

Added line #L25 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please avoid using erm meta? How about file_to_video(file: File)?
Btw... not just File as input type?

"""
Returns video file meta information.

Args:
file (VideoFile): VideoFile object.

Returns:
Video: Video file meta information.
"""
props = iio.improps(file.stream(), plugin="pyav")
frames_count, width, height, _ = props.shape

Check warning on line 36 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L35-L36

Added lines #L35 - L36 were not covered by tests

meta = iio.immeta(file.stream(), plugin="pyav")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like this part, it looks like we are reading video file twice here. Need to check the other way to get video meta information.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, also are we reading the whole file to get meta?

fps = meta["fps"]
codec = meta["codec"]
duration = meta["duration"]

Check warning on line 41 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L38-L41

Added lines #L38 - L41 were not covered by tests

return Video(

Check warning on line 43 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L43

Added line #L43 was not covered by tests
width=width,
height=height,
fps=fps,
duration=duration,
frames=frames_count,
codec=codec,
)


def video_frame_np(file: "VideoFile", frame: int) -> "ndarray":

Check warning on line 53 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L53

Added line #L53 was not covered by tests
"""
Reads video frame from a file.

Args:
file (VideoFile): VideoFile object.
frame (int): Frame number to read.

Returns:
ndarray: Video frame.
"""
if frame < 0:
raise ValueError("frame must be a non-negative integer.")

Check warning on line 65 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L65

Added line #L65 was not covered by tests

return iio.imread(file.stream(), index=frame, plugin="pyav")

Check warning on line 67 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L67

Added line #L67 was not covered by tests


def video_frame(file: "VideoFile", frame: int, format: str = "jpeg") -> bytes:

Check warning on line 70 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L70

Added line #L70 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we usually use jpg in the codebase, not jpeg

"""
Reads video frame from a file and returns as image bytes.

Args:
file (VideoFile): VideoFile object.
frame (int): Frame number to read.
format (str): Image format (default: 'jpeg').

Returns:
bytes: Video frame image as bytes.
"""
img = video_frame_np(file, frame)
return iio.imwrite("<bytes>", img, extension=f".{format}")

Check warning on line 83 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L82-L83

Added lines #L82 - L83 were not covered by tests


def save_video_frame(

Check warning on line 86 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L86

Added line #L86 was not covered by tests
file: "VideoFile",
frame: int,
output_file: Union[str, pathlib.Path],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we really support Path?

format: str = "jpeg",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jpg

) -> None:
"""
Saves video frame as an image file.

Args:
file (VideoFile): VideoFile object.
frame (int): Frame number to read.
output_file (Union[str, pathlib.Path]): Output file path.
format (str): Image format (default: 'jpeg').
"""
img = video_frame_np(file, frame)
iio.imwrite(output_file, img, extension=f".{format}")

Check warning on line 102 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L101-L102

Added lines #L101 - L102 were not covered by tests


def video_frames_np(

Check warning on line 105 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L105

Added line #L105 was not covered by tests
file: "VideoFile",
start_frame: int = 0,
end_frame: Optional[int] = None,
step: int = 1,
) -> "Iterator[ndarray]":
"""
Reads video frames from a file.

Args:
file (VideoFile): VideoFile object.
start_frame (int): Frame number to start reading from (default: 0).
end_frame (int): Frame number to stop reading at (default: None).
step (int): Step size for reading frames (default: 1).

Returns:
Iterator[ndarray]: Iterator of video frames.
"""
if start_frame < 0:
raise ValueError("start_frame must be a non-negative integer.")

Check warning on line 124 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L124

Added line #L124 was not covered by tests
if end_frame is not None:
if end_frame < 0:
raise ValueError("end_frame must be a non-negative integer.")

Check warning on line 127 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L127

Added line #L127 was not covered by tests
if start_frame > end_frame:
raise ValueError("start_frame must be less than or equal to end_frame.")

Check warning on line 129 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L129

Added line #L129 was not covered by tests
if step < 1:
raise ValueError("step must be a positive integer.")

Check warning on line 131 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L131

Added line #L131 was not covered by tests

# Compute the frame shift to determine the number of frames to skip,
# considering the start frame and step size
frame_shift = start_frame % step

Check warning on line 135 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L135

Added line #L135 was not covered by tests

# Iterate over video frames and yield only those within the specified range and step
for frame, img in enumerate(iio.imiter(file.stream(), plugin="pyav")):
if frame < start_frame:
continue

Check warning on line 140 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L140

Added line #L140 was not covered by tests
if (frame - frame_shift) % step != 0:
continue

Check warning on line 142 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L142

Added line #L142 was not covered by tests
if end_frame is not None and frame > end_frame:
break
yield img

Check warning on line 145 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L144-L145

Added lines #L144 - L145 were not covered by tests


def video_frames(

Check warning on line 148 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L148

Added line #L148 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can a lot of these helpers become part of the Video* classes?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question 👍 I was thinking about this and tried to implement it this way, but in the end I've checked other types and files in lib module (images, hf) and make it the same way.

I was also thinking and trying to move all the models to the datachain.model module, but it turns out it needs more work and may be not backward compatible with File model. In is a subject for a separate PR.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, we need all of theses to become methods of Video class. Should it be a followup or in this PR?

I'd appreciate more insights on the issues with this approach.

file: "VideoFile",
start_frame: int = 0,
end_frame: Optional[int] = None,
step: int = 1,
format: str = "jpeg",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

jpg

) -> "Iterator[bytes]":
"""
Reads video frames from a file and returns as bytes.

Args:
file (VideoFile): VideoFile object.
start_frame (int): Frame number to start reading from (default: 0).
end_frame (int): Frame number to stop reading at (default: None).
step (int): Step size for reading frames (default: 1).
format (str): Image format (default: 'jpeg').

Returns:
Iterator[bytes]: Iterator of video frames.
"""
for img in video_frames_np(file, start_frame, end_frame, step):
yield iio.imwrite("<bytes>", img, extension=f".{format}")

Check warning on line 169 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L169

Added line #L169 was not covered by tests


def save_video_frames(

Check warning on line 172 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L172

Added line #L172 was not covered by tests
file: "VideoFile",
output_dir: Union[str, pathlib.Path],
start_frame: int = 0,
end_frame: Optional[int] = None,
step: int = 1,
format: str = "jpeg",
) -> "Iterator[str]":
"""
Saves video frames as image files.

Args:
file (VideoFile): VideoFile object.
output_dir (Union[str, pathlib.Path]): Output directory path.
start_frame (int): Frame number to start reading from (default: 0).
end_frame (int): Frame number to stop reading at (default: None).
step (int): Step size for reading frames (default: 1).
format (str): Image format (default: 'jpeg').

Returns:
Iterator[str]: List of output file paths.
"""
file_stem = file.get_file_stem()

Check warning on line 194 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L194

Added line #L194 was not covered by tests

for i, img in enumerate(video_frames_np(file, start_frame, end_frame, step)):
frame = start_frame + i * step
output_file = os.path.join(output_dir, f"{file_stem}_{frame:06d}.{format}")
iio.imwrite(output_file, img, extension=f".{format}")
yield output_file

Check warning on line 200 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L197-L200

Added lines #L197 - L200 were not covered by tests


def save_video_clip(

Check warning on line 203 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L203

Added line #L203 was not covered by tests
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like it needs to be renamed to save_subvideo()
In the class names, we use term Clip for virtual videos (start-end) while in this case you are creating just another Video, not clip.

So, it needs to be renamed or we need to avoid this Clip-as-virtual-reference terminology.

file: "VideoFile",
start_time: float,
end_time: float,
output_file: Union[str, pathlib.Path],
codec: str = "libx264",
audio_codec: str = "aac",
) -> None:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be great to generalize the single and plural methods. We just need to come up with output format like output="{name}{:06d}.{ext}") and provide a string in case of a single file.

Also, this method will require generalization for writing to cloud like output={source}/tmp/{name}{:06d}.{ext}

"""
Saves video interval as a new video file.

Args:
file (VideoFile): VideoFile object.
start_time (float): Start time in seconds.
end_time (float): End time in seconds.
output_file (Union[str, pathlib.Path]): Output file path.
codec (str): Video codec for encoding (default: 'libx264').
audio_codec (str): Audio codec for encoding (default: 'aac').
"""
video = VideoFileClip(file.stream())

Check warning on line 222 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L222

Added line #L222 was not covered by tests

if start_time < 0 or end_time > video.duration or start_time >= end_time:
raise ValueError(f"Invalid time range: ({start_time}, {end_time}).")

Check warning on line 225 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L225

Added line #L225 was not covered by tests

clip = video.subclip(start_time, end_time)
clip.write_videofile(output_file, codec=codec, audio_codec=audio_codec)
video.close()

Check warning on line 229 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L227-L229

Added lines #L227 - L229 were not covered by tests


def save_video_clips(

Check warning on line 232 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L232

Added line #L232 was not covered by tests
file: "VideoFile",
intervals: list[tuple[float, float]],
output_dir: Union[str, pathlib.Path],
codec: str = "libx264",
audio_codec: str = "aac",
) -> "Iterator[str]":
"""
Saves video interval as a new video file.

Args:
file (VideoFile): VideoFile object.
intervals (list[tuple[float, float]]): List of start and end times in seconds.
output_dir (Union[str, pathlib.Path]): Output directory path.
codec (str): Video codec for encoding (default: 'libx264').
audio_codec (str): Audio codec for encoding (default: 'aac').

Returns:
Iterator[str]: List of output file paths.
"""
file_stem = file.get_file_stem()
file_ext = file.get_file_ext()

Check warning on line 253 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L252-L253

Added lines #L252 - L253 were not covered by tests

video = VideoFileClip(file.stream())

Check warning on line 255 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L255

Added line #L255 was not covered by tests

for i, (start, end) in enumerate(intervals):
if start < 0 or end > video.duration or start >= end:
print(f"Invalid time range: ({start}, {end}). Skipping this segment.")
continue

Check warning on line 260 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L259-L260

Added lines #L259 - L260 were not covered by tests

# Extract the segment
clip = video.subclip(start, end)

Check warning on line 263 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L263

Added line #L263 was not covered by tests

# Define the output file name
output_file = os.path.join(output_dir, f"{file_stem}_{i + 1}.{file_ext}")

Check warning on line 266 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L266

Added line #L266 was not covered by tests

# Write the video segment to file
clip.write_videofile(output_file, codec=codec, audio_codec=audio_codec)

Check warning on line 269 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L269

Added line #L269 was not covered by tests

yield output_file

Check warning on line 271 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L271

Added line #L271 was not covered by tests

video.close()

Check warning on line 273 in src/datachain/lib/video.py

View check run for this annotation

Codecov / codecov/patch

src/datachain/lib/video.py#L273

Added line #L273 was not covered by tests
Loading