99
1010from .data .image import Image
1111from .data .image import LabelMap
12+ from .data .image import ScalarImage
1213from .data .subject import Subject
1314from .external .imports import get_ffmpeg
1415from .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
1519from .transforms .preprocessing .spatial .to_canonical import ToCanonical
20+ from .transforms .preprocessing .spatial .to_orientation import ToOrientation
1621from .types import TypePath
1722
1823if TYPE_CHECKING :
@@ -287,33 +292,94 @@ def make_gif(
287292
288293
289294def 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