Skip to content

Commit b7bb299

Browse files
ENH: add Brain.save_movie()
1 parent 21c6901 commit b7bb299

File tree

2 files changed

+217
-7
lines changed

2 files changed

+217
-7
lines changed

surfer/utils.py

Lines changed: 94 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from os import path as op
66
import inspect
77
from functools import wraps
8+
import subprocess
89

910
import numpy as np
1011
import nibabel as nib
@@ -241,7 +242,6 @@ def set_log_level(verbose=None, return_old_level=False):
241242
if verbose not in logging_types:
242243
raise ValueError('verbose must be of a valid type')
243244
verbose = logging_types[verbose]
244-
logger = logging.getLogger('surfer')
245245
old_verbose = logger.level
246246
logger.setLevel(verbose)
247247
return (old_verbose if return_old_level else None)
@@ -273,7 +273,6 @@ def set_log_file(fname=None, output_format='%(message)s', overwrite=None):
273273
but additionally raises a warning to notify the user that log
274274
entries will be appended.
275275
"""
276-
logger = logging.getLogger('surfer')
277276
handlers = logger.handlers
278277
for h in handlers:
279278
if isinstance(h, logging.FileHandler):
@@ -634,3 +633,96 @@ def has_fsaverage(subjects_dir=None):
634633

635634
requires_fsaverage = np.testing.dec.skipif(not has_fsaverage(),
636635
'Requires fsaverage subject data')
636+
637+
def has_ffmpeg():
638+
"""Test whether the FFmpeg is available in a subprocess
639+
640+
Returns
641+
-------
642+
ffmpeg_exists : bool
643+
True if FFmpeg can be successfully called, False otherwise.
644+
"""
645+
rcode = subprocess.call(["type", "ffmpeg"], stdout=subprocess.PIPE,
646+
stderr=subprocess.PIPE)
647+
ffmpeg_exists = rcode == 0
648+
return ffmpeg_exists
649+
650+
651+
def ffmpeg(dst, frame_path, framerate=10, codec='mpeg4', opt="", inopt="",
652+
outopt=""):
653+
"""Run FFmpeg in a subprocess to convert an image sequence into a movie
654+
655+
Parameters
656+
----------
657+
dst : str
658+
Destination path. If the extension is not ".mov" or ".avi", ".mov" is
659+
added. If the file already exists it is overwritten.
660+
frame_path : str
661+
Path to the source frames (with a frame number field like '%04d').
662+
framerate : int
663+
Framerate of the movie (frames per second).
664+
codec : str
665+
Codec to use (default 'mpeg4').
666+
opt, inopt, outopt : str
667+
FFmpeg options, infile options and outfile options (e.g., "-o1 value
668+
-o2 value", see FFmpeg help for possible options).
669+
670+
Notes
671+
-----
672+
Requires FFmpeg to be in the path. FFmpeg can be downlaoded from `here
673+
<http://ffmpeg.org/download.html>`_. Stdout and stderr are written to the
674+
logger. If the movie file is not created, a RuntimeError is raised.
675+
"""
676+
# make sure FFmpeg is available
677+
if not has_ffmpeg():
678+
err = ("FFmpeg is not in the path and is needed for saving "
679+
"movies. Install FFmpeg and try again. It can be "
680+
"downlaoded from http://ffmpeg.org/download.html.")
681+
raise RuntimeError(err)
682+
683+
# find target path
684+
dst = os.path.expanduser(dst)
685+
dst = os.path.abspath(dst)
686+
root, ext = os.path.splitext(dst)
687+
dirname = os.path.dirname(dst)
688+
if ext not in ['.mov', '.avi']:
689+
dst += '.mov'
690+
691+
if os.path.exists(dst):
692+
os.remove(dst)
693+
elif not os.path.exists(dirname):
694+
os.mkdir(dirname)
695+
696+
frame_dir, frame_fmt = os.path.split(frame_path)
697+
698+
# make the movie
699+
cmd = ['ffmpeg']
700+
if opt:
701+
cmd.extend(opt.split())
702+
if inopt:
703+
cmd.extend(inopt.split())
704+
cmd.extend(('-i', frame_fmt))
705+
if outopt:
706+
cmd.extend(outopt.split())
707+
if '-r ' not in outopt:
708+
cmd.extend(('-r', str(framerate)))
709+
if not ('-c' in cmd or '-codec' in cmd):
710+
cmd.extend(('-c', codec))
711+
cmd.append(dst)
712+
713+
logger.info("Running FFmpeg with command: %s", ' '.join(cmd))
714+
sp = subprocess.Popen(cmd, cwd=frame_dir, stdout=subprocess.PIPE,
715+
stderr=subprocess.PIPE)
716+
717+
# log stdout and stderr
718+
stdout, stderr = sp.communicate()
719+
std_info = os.linesep.join(("FFmpeg stdout", '=' * 25, stdout))
720+
logger.info(std_info)
721+
if stderr.strip():
722+
err_info = os.linesep.join(("FFmpeg stderr", '=' * 27, stderr))
723+
logger.error(err_info)
724+
725+
# check that movie file is created
726+
if not os.path.exists(dst):
727+
err = ("FFmpeg failed, no file created; see log for more more information")
728+
raise RuntimeError(err)

surfer/viz.py

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import os
22
from os.path import join as pjoin
3+
from tempfile import mkdtemp
34
from warnings import warn
45

56
import numpy as np
@@ -16,7 +17,7 @@
1617
from . import utils, io
1718
from .config import config
1819
from .utils import (Surface, verbose, create_color_lut, _get_subjects_dir,
19-
string_types)
20+
string_types, has_ffmpeg, ffmpeg)
2021

2122

2223
import logging
@@ -1623,13 +1624,20 @@ def set_data_smoothing_steps(self, smoothing_steps, verbose=None):
16231624
data["smoothing_steps"] = smoothing_steps
16241625
self._toggle_render(True, views)
16251626

1626-
def set_time(self, time):
1627-
"""Set the data time index to the time point closest to time
1627+
def index_for_time(self, time, rounding='closest'):
1628+
"""Find the data time index closest to a specific time point
16281629
16291630
Parameters
16301631
----------
16311632
time : scalar
16321633
Time.
1634+
rounding : 'closest' | 'up' | 'down
1635+
How to round if the exact time point is not an index.
1636+
1637+
Returns
1638+
-------
1639+
index : int
1640+
Data time index closest to time.
16331641
"""
16341642
if self.n_times is None:
16351643
raise RuntimeError("Brain has no time axis")
@@ -1644,7 +1652,27 @@ def set_time(self, time):
16441652
"[%s, %s]" % (time, tmin, tmax))
16451653
raise ValueError(err)
16461654

1647-
idx = np.argmin(np.abs(times - time))
1655+
if rounding == 'closest':
1656+
idx = np.argmin(np.abs(times - time))
1657+
elif rounding == 'up':
1658+
idx = np.nonzero(times >= time)[0][0]
1659+
elif rounding == 'down':
1660+
idx = np.nonzero(times <= time)[0][-1]
1661+
else:
1662+
err = "Invalid rounding parameter: %s" % repr(rounding)
1663+
raise ValueError(err)
1664+
1665+
return idx
1666+
1667+
def set_time(self, time):
1668+
"""Set the data time index to the time point closest to time
1669+
1670+
Parameters
1671+
----------
1672+
time : scalar
1673+
Time.
1674+
"""
1675+
idx = self.index_for_time(time)
16481676
self.set_data_time_index(idx)
16491677

16501678
def _get_colorbars(self, row, col):
@@ -1845,7 +1873,7 @@ def screenshot_single(self, mode='rgb', antialiased=False, row=-1, col=-1):
18451873
brain = self.brain_matrix[row, col]
18461874
return mlab.screenshot(brain._f, mode, antialiased)
18471875

1848-
def save_imageset(self, prefix, views, filetype='png', colorbar='auto',
1876+
def save_imageset(self, prefix, views, filetype='png', colorbar='auto',
18491877
row=-1, col=-1):
18501878
"""Convenience wrapper for save_image
18511879
@@ -2034,6 +2062,96 @@ def save_montage(self, filename, order=['lat', 'ven', 'med'],
20342062
cb.visible = colorbars_visibility[cb]
20352063
return out
20362064

2065+
def save_movie(self, dst, tstart=None, tstop=None, step=None,
2066+
time_idx=None, montage='current', orientation='h',
2067+
border_size=15, colorbar='auto', framerate=10,
2068+
codec='mpeg4', row=-1, col=-1, movie_tool='ffmpeg'):
2069+
"""Save a movie (for data with a time axis)
2070+
2071+
Parameters
2072+
----------
2073+
dst : str
2074+
Path at which to sae the movie.
2075+
tstart : None | float
2076+
First time point to include (default: all data).
2077+
tstop : None | float
2078+
Time point at which to stop the movie (exclusive; default: all
2079+
data).
2080+
step : None | int
2081+
Number of data frames to step forward between movie frames
2082+
(default 1).
2083+
time_idx : None | array
2084+
Index that selects time points form the time axis from which to
2085+
make the movie. If time_idx is specified, neither of tstart, tstop
2086+
or tstep should be specified.
2087+
montage: 'current' | 'single' | list
2088+
Views to include in the images: 'current' (default) uses the
2089+
currently displayed image; 'single' uses a single view, specified
2090+
by the ``row`` and ``col`` parameters; a list can be used to
2091+
specify a complete montage (see :meth:`save_montage`).
2092+
orientation: {'h' | 'v'}
2093+
montage image orientation (horizontal of vertical alignment; only
2094+
applies if ``montage`` is a flat list)
2095+
border_size: int
2096+
Size of image border (more or less space between images)
2097+
colorbar: None | 'auto' | [int], optional
2098+
if None no colorbar is visible. If 'auto' is given the colorbar
2099+
is only shown in the middle view. Otherwise on the listed
2100+
views when a list of int is passed.
2101+
framerate : int
2102+
Framerate of the movie (frames per second).
2103+
codec : str
2104+
Codec to use (default 'mpeg4').
2105+
row : int
2106+
row index of the brain to use
2107+
col : int
2108+
column index of the brain to use
2109+
movie_tool : 'ffmpeg'
2110+
Tool to use to convert image sequence into a movie (default:
2111+
'ffmpeg').
2112+
"""
2113+
if movie_tool.lower() == 'ffmpeg':
2114+
if not has_ffmpeg():
2115+
err = ("FFmpeg is not in the path and is needed for saving "
2116+
"movies. Install FFmpeg and try again. It can be "
2117+
"downlaoded from http://ffmpeg.org/download.html.")
2118+
raise RuntimeError(err)
2119+
else:
2120+
err = "Currently the only possible movie tool is FFmpeg"
2121+
raise ValueError(err)
2122+
2123+
if tstart is not None:
2124+
start = self.index_for_time(tstart, rounding='up')
2125+
else:
2126+
start = 0
2127+
2128+
if tstop is not None:
2129+
stop = self.index_for_time(tstop, rounding='up')
2130+
else:
2131+
stop = self.n_times
2132+
2133+
if all(x is None for x in (tstart, tstop, step)):
2134+
if time_idx is None:
2135+
time_idx = np.arange(self.n_times)
2136+
elif time_idx is None:
2137+
time_idx = np.arange(start, stop, step)
2138+
else:
2139+
err = ("Both slice parameters (tstart, tstop, step) and "
2140+
"time_idx can not be specified at the same time.")
2141+
raise TypeError(err)
2142+
2143+
n_times = len(time_idx)
2144+
if n_times == 0:
2145+
raise ValueError("No time points selected")
2146+
2147+
tempdir = mkdtemp()
2148+
frame_pattern = 'frame%%0%id.png' % (np.floor(np.log10(n_times)) + 1)
2149+
fname_pattern = os.path.join(tempdir, frame_pattern)
2150+
self.save_image_sequence(time_idx, fname_pattern, False, row,
2151+
col, montage, orientation, border_size,
2152+
colorbar)
2153+
ffmpeg(dst, fname_pattern, framerate, codec)
2154+
20372155
def animate(self, views, n_steps=180., fname=None, use_cache=False,
20382156
row=-1, col=-1):
20392157
"""Animate a rotation.

0 commit comments

Comments
 (0)