Skip to content

Commit b88d442

Browse files
authored
Add support to export images as videos (#1346)
1 parent cf98275 commit b88d442

4 files changed

Lines changed: 191 additions & 8 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dependencies = [
5050
[project.optional-dependencies]
5151
csv = ["pandas>=1"]
5252
plot = ["distinctipy>=1.3.4", "matplotlib>=3.4"]
53+
video = ["ffmpeg-python>=0.2.0"]
5354

5455
[project.scripts]
5556
tiohd = "torchio.cli.print_info:app"

src/torchio/data/image.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -742,6 +742,13 @@ def to_gif(
742742
reverse=reverse,
743743
)
744744

745+
def to_ras(self) -> Image:
746+
if self.orientation != tuple('RAS'):
747+
from ..transforms.preprocessing.spatial.to_canonical import ToCanonical
748+
749+
return ToCanonical()(self)
750+
return self
751+
745752
def get_center(self, lps: bool = False) -> TypeTripletFloat:
746753
"""Get image center in RAS+ or LPS+ coordinates.
747754
@@ -880,6 +887,36 @@ def hist(self, **kwargs) -> None:
880887
x = self.data.flatten().numpy()
881888
plot_histogram(x, **kwargs)
882889

890+
def to_video(
891+
self,
892+
output_path: TypePath,
893+
frame_rate: float | None = 15,
894+
seconds: float | None = None,
895+
direction: str = 'I',
896+
verbosity: str = 'error',
897+
) -> None:
898+
"""Create a video showing all image slices along a specified direction.
899+
900+
Args:
901+
output_path: Path to the output video file.
902+
frame_rate: Number of frames per second (FPS).
903+
seconds: Target duration of the full video.
904+
direction:
905+
verbosity:
906+
907+
.. note:: Only ``frame_rate`` or ``seconds`` may (and must) be specified.
908+
"""
909+
from ..visualization import make_video # avoid circular import
910+
911+
make_video(
912+
self.to_ras(), # type: ignore[arg-type]
913+
output_path,
914+
frame_rate=frame_rate,
915+
seconds=seconds,
916+
direction=direction,
917+
verbosity=verbosity,
918+
)
919+
883920

884921
class LabelMap(Image):
885922
"""Image whose pixel values represent segmentation labels.

src/torchio/external/imports.py

Lines changed: 27 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,45 @@
1+
from __future__ import annotations
2+
13
from importlib import import_module
24
from importlib.util import find_spec
5+
from shutil import which
36
from types import ModuleType
47

58

6-
def _check_package(*, package: str, extra: str) -> None:
7-
if find_spec(package) is None:
9+
def _check_module(*, module: str, extra: str, package: str | None = None) -> None:
10+
if find_spec(module) is None:
11+
name = module if package is None else package
812
message = (
9-
f'The `{package}` package is required for this.'
13+
f'The `{name}` package is required for this.'
1014
f' Install TorchIO with the `{extra}` extra:'
1115
f' `pip install torchio[{extra}]`.'
1216
)
1317
raise ImportError(message)
1418

1519

16-
def _check_and_import(package: str, extra: str) -> ModuleType:
17-
_check_package(package=package, extra=extra)
18-
return import_module(package)
20+
def _check_and_import(module: str, extra: str, **kwargs) -> ModuleType:
21+
_check_module(module=module, extra=extra, **kwargs)
22+
return import_module(module)
1923

2024

2125
def get_pandas() -> ModuleType:
22-
return _check_and_import(package='pandas', extra='csv')
26+
return _check_and_import(module='pandas', extra='csv')
2327

2428

2529
def get_distinctipy() -> ModuleType:
26-
return _check_and_import(package='distinctipy', extra='plot')
30+
return _check_and_import(module='distinctipy', extra='plot')
31+
32+
33+
def get_ffmpeg() -> ModuleType:
34+
ffmpeg = _check_and_import(module='ffmpeg', extra='video', package='ffmpeg-python')
35+
_check_executable('ffmpeg')
36+
return ffmpeg
37+
38+
39+
def _check_executable(executable: str) -> None:
40+
if which(executable) is None:
41+
message = (
42+
f'The `{executable}` executable is required for this. Install it from your'
43+
' package manager or download it from the official website.'
44+
)
45+
raise FileNotFoundError(message)

src/torchio/visualization.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
from __future__ import annotations
22

33
import warnings
4+
from pathlib import Path
45
from typing import TYPE_CHECKING
56

67
import numpy as np
78
import torch
89

910
from .data.image import Image
1011
from .data.image import LabelMap
12+
from .data.image import ScalarImage
1113
from .data.subject import Subject
14+
from .external.imports import get_ffmpeg
1215
from .transforms.preprocessing.intensity.rescale import RescaleIntensity
16+
from .transforms.preprocessing.intensity.to import To
17+
from .transforms.preprocessing.spatial.ensure_shape_multiple import EnsureShapeMultiple
18+
from .transforms.preprocessing.spatial.resample import Resample
1319
from .transforms.preprocessing.spatial.to_canonical import ToCanonical
20+
from .transforms.preprocessing.spatial.to_orientation import ToOrientation
1421
from .types import TypePath
1522

1623
if TYPE_CHECKING:
@@ -282,3 +289,122 @@ def make_gif(
282289
duration=frame_duration_ms,
283290
loop=loop,
284291
)
292+
293+
294+
def make_video(
295+
image: ScalarImage,
296+
output_path: TypePath,
297+
seconds: float | None = None,
298+
frame_rate: float | None = None,
299+
direction: str = 'I',
300+
verbosity: str = 'error',
301+
) -> None:
302+
ffmpeg = get_ffmpeg()
303+
304+
if seconds is None and frame_rate is None:
305+
message = 'Either seconds or frame_rate must be provided.'
306+
raise ValueError(message)
307+
if seconds is not None and frame_rate is not None:
308+
message = 'Provide either seconds or frame_rate, not both.'
309+
raise ValueError(message)
310+
if image.num_channels > 1:
311+
message = 'Only single-channel tensors are supported for video output for now.'
312+
raise ValueError(message)
313+
tmin, tmax = image.data.min(), image.data.max()
314+
if tmin < 0 or tmax > 255:
315+
message = (
316+
'The tensor must be in the range [0, 256) for video output.'
317+
' The image data will be rescaled to this range.'
318+
)
319+
warnings.warn(message, RuntimeWarning, stacklevel=2)
320+
image = RescaleIntensity((0, 255))(image)
321+
if image.data.dtype != torch.uint8:
322+
message = (
323+
'Only uint8 tensors are supported for video output. The image data'
324+
' will be cast to uint8.'
325+
)
326+
warnings.warn(message, RuntimeWarning, stacklevel=2)
327+
image = To(torch.uint8)(image)
328+
329+
# Reorient so the output looks like in typical visualization software
330+
direction = direction.upper()
331+
if direction == 'I': # axial top to bottom
332+
target = 'IPL'
333+
elif direction == 'S': # axial bottom to top
334+
target = 'SPL'
335+
elif direction == 'A': # coronal back to front
336+
target = 'AIL'
337+
elif direction == 'P': # coronal front to back
338+
target = 'PIL'
339+
elif direction == 'R': # sagittal left to right
340+
target = 'RIP'
341+
elif direction == 'L': # sagittal right to left
342+
target = 'LIP'
343+
else:
344+
message = (
345+
'Direction must be one of "I", "S", "P", "A", "R" or "L".'
346+
f' Got {direction!r}.'
347+
)
348+
raise ValueError(message)
349+
image = ToOrientation(target)(image)
350+
351+
# Check isotropy
352+
spacing_f, spacing_h, spacing_w = image.spacing
353+
if spacing_h != spacing_w:
354+
message = (
355+
'The height and width spacings should be the same video output.'
356+
f' Got {spacing_h:.2f} and {spacing_w:.2f}.'
357+
f' Resampling both to {spacing_f:.2f}.'
358+
)
359+
warnings.warn(message, RuntimeWarning, stacklevel=2)
360+
spacing_iso = min(spacing_h, spacing_w)
361+
target_spacing = spacing_f, spacing_iso, spacing_iso
362+
image = Resample(target_spacing)(image) # type: ignore[assignment]
363+
364+
# Check that height and width are multiples of 2 for H.265 encoding
365+
num_frames, height, width = image.spatial_shape
366+
if height % 2 != 0 or width % 2 != 0:
367+
message = (
368+
f'The height ({height}) and width ({width}) must be even.'
369+
' The image will be cropped to the nearest even number.'
370+
)
371+
warnings.warn(message, RuntimeWarning, stacklevel=2)
372+
image = EnsureShapeMultiple((1, 2, 2), method='crop')(image)
373+
374+
if seconds is not None:
375+
frame_rate = num_frames / seconds
376+
377+
output_path = Path(output_path)
378+
if output_path.suffix.lower() != '.mp4':
379+
message = 'Only .mp4 files are supported for video output.'
380+
raise NotImplementedError(message)
381+
382+
frames = image.numpy()[0]
383+
first = frames[0]
384+
height, width = first.shape
385+
386+
process = (
387+
ffmpeg.input(
388+
'pipe:',
389+
format='rawvideo',
390+
pix_fmt='gray',
391+
s=f'{width}x{height}',
392+
framerate=frame_rate,
393+
)
394+
.output(
395+
str(output_path),
396+
vcodec='libx265',
397+
pix_fmt='yuv420p',
398+
loglevel=verbosity,
399+
**{'x265-params': f'log-level={verbosity}'},
400+
)
401+
.overwrite_output()
402+
.run_async(pipe_stdin=True)
403+
)
404+
405+
for array in frames:
406+
buffer = array.tobytes()
407+
process.stdin.write(buffer)
408+
409+
process.stdin.close()
410+
process.wait()

0 commit comments

Comments
 (0)