Skip to content

Commit 712fc70

Browse files
committed
Add many features
1 parent 77f8c15 commit 712fc70

2 files changed

Lines changed: 104 additions & 24 deletions

File tree

src/torchio/data/image.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -742,29 +742,41 @@ 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 to_video(
746753
self,
747754
output_path: TypePath,
748-
duration: float | None = None,
749-
frame_rate: float | None = None,
755+
frame_rate: float | None = 15,
756+
seconds: float | None = None,
757+
direction: str = 'I',
758+
verbosity: str = 'error',
750759
) -> None:
751-
"""Save a video of the image.
752-
753-
TODO: add usage example, with recommended transforms (rescale, LPS, etc.)
760+
"""Create a video showing all image slices along a specified direction.
754761
755762
Args:
756-
axis: Spatial axis (0, 1 or 2).
757-
duration: Duration of the full video in seconds.
758-
frame_rate: Number of frames per second.
759763
output_path: Path to the output video file.
764+
frame_rate: Number of frames per second (FPS).
765+
seconds: Target duration of the full video.
766+
direction:
767+
verbosity:
768+
769+
.. note:: Only ``frame_rate`` or ``seconds`` may (and must) be specified.
760770
"""
761771
from ..visualization import make_video # avoid circular import
762772

763773
make_video(
764-
self.data,
774+
self.to_ras(),
765775
output_path,
766-
duration=duration,
767776
frame_rate=frame_rate,
777+
seconds=seconds,
778+
direction=direction,
779+
verbosity=verbosity,
768780
)
769781

770782
def get_center(self, lps: bool = False) -> TypeTripletFloat:

src/torchio/visualization.py

Lines changed: 82 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@
99

1010
from .data.image import Image
1111
from .data.image import LabelMap
12+
from .data.image import ScalarImage
1213
from .data.subject import Subject
1314
from .external.imports import get_ffmpeg
1415
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
1519
from .transforms.preprocessing.spatial.to_canonical import ToCanonical
20+
from .transforms.preprocessing.spatial.to_orientation import ToOrientation
1621
from .types import TypePath
1722

1823
if TYPE_CHECKING:
@@ -287,33 +292,94 @@ def make_gif(
287292

288293

289294
def make_video(
290-
tensor: torch.Tensor,
295+
image: ScalarImage,
291296
output_path: TypePath,
292-
duration: float | None = None,
297+
seconds: float | None = None,
293298
frame_rate: float | None = None,
299+
direction: str = 'I',
300+
verbosity: str = 'error',
294301
) -> None:
295-
"""Encode a 3D array into an MP4 video."""
296302
ffmpeg = get_ffmpeg()
297303

298-
if duration is None and frame_rate is None:
299-
message = 'Either duration or frame_rate must be provided.'
304+
if seconds is None and frame_rate is None:
305+
message = 'Either seconds or frame_rate must be provided.'
300306
raise ValueError(message)
301-
if duration is not None and frame_rate is not None:
302-
message = 'Provide either duration or frame_rate, not both.'
307+
if seconds is not None and frame_rate is not None:
308+
message = 'Provide either seconds or frame_rate, not both.'
303309
raise ValueError(message)
304-
if len(tensor) > 1:
310+
if image.num_channels > 1:
305311
message = 'Only single-channel tensors are supported for video output for now.'
306312
raise ValueError(message)
307-
frames = tensor.numpy()[0].T
308-
num_frames = len(frames)
309-
if duration is not None:
310-
frame_rate = num_frames / duration
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_f, spacing_iso, spacing_iso
362+
image = Resample(target)(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
311376

312377
output_path = Path(output_path)
313378
if output_path.suffix.lower() != '.mp4':
314379
message = 'Only .mp4 files are supported for video output.'
315-
raise ValueError(message)
380+
raise NotImplementedError(message)
316381

382+
frames = image.numpy()[0]
317383
first = frames[0]
318384
height, width = first.shape
319385

@@ -327,8 +393,10 @@ def make_video(
327393
)
328394
.output(
329395
str(output_path),
330-
vcodec='libx264',
396+
vcodec='libx265',
331397
pix_fmt='yuv420p',
398+
loglevel=verbosity,
399+
**{'x265-params': f'log-level={verbosity}'},
332400
)
333401
.overwrite_output()
334402
.run_async(pipe_stdin=True)

0 commit comments

Comments
 (0)