def test_TimeSeries_append(): max_val = 2.0 max_idx = 156 data_orig = np.random.rand(10, 10, 1000) data_orig[4, 4, max_idx] = max_val ts_orig = utils.TimeSeries(1.0, data_orig) # Make sure adding two TimeSeries objects works: # Must have the right type and size ts = copy.deepcopy(ts_orig) with pytest.raises(TypeError): ts.append(4.0) with pytest.raises(ValueError): ts_wrong_size = utils.TimeSeries(1.0, np.ones((2, 2))) ts.append(ts_wrong_size) # Adding messes only with the last dimension of the array ts = copy.deepcopy(ts_orig) ts.append(ts) npt.assert_equal(ts.shape[:-1], ts_orig.shape[:-1]) npt.assert_equal(ts.shape[-1], ts_orig.shape[-1] * 2) # If necessary, the second pulse train is resampled to the first ts = copy.deepcopy(ts_orig) tsample_new = 2.0 ts_new = ts.resample(tsample_new) ts.append(ts_new) npt.assert_equal(ts.shape[:-1], ts_orig.shape[:-1]) npt.assert_equal(ts.shape[-1], ts_orig.shape[-1] * 2) ts_add = copy.deepcopy(ts_new) ts_add.append(ts_orig) npt.assert_equal(ts_add.shape[:-1], ts_new.shape[:-1]) npt.assert_equal(ts_add.shape[-1], ts_new.shape[-1] * 2)
def test_parse_pulse_trains(): # Specify pulse trains in a number of different ways and make sure they # are all identical after parsing # Create some p2p.implants argus = implants.ArgusI() simple = implants.ElectrodeArray('subretinal', 0, 0, 0, 0) pt_zero = utils.TimeSeries(1, np.zeros(1000)) pt_nonzero = utils.TimeSeries(1, np.random.rand(1000)) # Test 1 # ------ # Specify wrong number of pulse trains with pytest.raises(ValueError): stimuli.parse_pulse_trains(pt_nonzero, argus) with pytest.raises(ValueError): stimuli.parse_pulse_trains([pt_nonzero], argus) with pytest.raises(ValueError): stimuli.parse_pulse_trains([pt_nonzero] * (argus.num_electrodes - 1), argus) with pytest.raises(ValueError): stimuli.parse_pulse_trains([pt_nonzero] * 2, simple) # Test 2 # ------ # Send non-zero pulse train to specific electrode el_name = 'B3' el_idx = argus.get_index(el_name) # Specify a list of 16 pulse trains (one for each electrode) pt0_in = [pt_zero] * argus.num_electrodes pt0_in[el_idx] = pt_nonzero pt0_out = stimuli.parse_pulse_trains(pt0_in, argus) # Specify a dict with non-zero pulse trains pt1_in = {el_name: pt_nonzero} pt1_out = stimuli.parse_pulse_trains(pt1_in, argus) # Make sure the two give the same result for p0, p1 in zip(pt0_out, pt1_out): npt.assert_equal(p0.data, p1.data) # Test 3 # ------ # Smoke testing stimuli.parse_pulse_trains([pt_zero] * argus.num_electrodes, argus) stimuli.parse_pulse_trains(pt_zero, simple) stimuli.parse_pulse_trains([pt_zero], simple)
def parse_pulse_trains(stim, implant): """Parse input stimulus and convert to list of pulse trains Parameters ---------- stim : utils.TimeSeries|list|dict There are several ways to specify an input stimulus: - For a single-electrode array, pass a single pulse train; i.e., a single utils.TimeSeries object. - For a multi-electrode array, pass a list of pulse trains, where every pulse train is a utils.TimeSeries object; i.e., one pulse train per electrode. - For a multi-electrode array, specify all electrodes that should receive non-zero pulse trains by name in a dictionary. The key of each element is the electrode name, the value is a pulse train. Example: stim = {'E1': pt, 'stim': pt}, where 'E1' and 'stim' are electrode names, and `pt` is a utils.TimeSeries object. implant : p2p.implants.ElectrodeArray A p2p.implants.ElectrodeArray object that describes the implant. Returns ------- A list of pulse trains; one pulse train per electrode. """ # Parse input stimulus if isinstance(stim, utils.TimeSeries): # `stim` is a single object: This is only allowed if the implant # has only one electrode if implant.num_electrodes > 1: e_s = "More than 1 electrode given, use a list of pulse trains" raise ValueError(e_s) pt = [copy.deepcopy(stim)] elif isinstance(stim, dict): # `stim` is a dictionary: Look up electrode names and assign pulse # trains, fill the rest with zeros # Get right size from first dict element, then generate all zeros idx0 = list(stim.keys())[0] pt_zero = utils.TimeSeries(stim[idx0].tsample, np.zeros_like(stim[idx0].data)) pt = [pt_zero] * implant.num_electrodes # Iterate over dictionary and assign non-zero pulse trains to # corresponding electrodes for key, value in stim.items(): el_idx = implant.get_index(key) if el_idx is not None: pt[el_idx] = copy.deepcopy(value) else: e_s = "Could not find electrode with name '%s'" % key raise ValueError(e_s) else: # Else, `stim` must be a list of pulse trains, one for each electrode if len(stim) != implant.num_electrodes: e_s = "Number of pulse trains must match number of electrodes" raise ValueError(e_s) pt = copy.deepcopy(stim) return pt
def test_TimeSeries_resample(): max_val = 2.0 max_idx = 156 data_orig = np.random.rand(10, 10, 1000) data_orig[4, 4, max_idx] = max_val ts = utils.TimeSeries(1.0, data_orig) tmax, vmax = ts.max() # Resampling with same sampling step shouldn't change anything ts_new = ts.resample(ts.tsample) npt.assert_equal(ts_new.shape, ts.shape) npt.assert_equal(ts_new.duration, ts.duration) # Make sure resampling works tsample_new = 4 ts_new = ts.resample(tsample_new) npt.assert_equal(ts_new.tsample, tsample_new) npt.assert_equal(ts_new.data.shape[-1], ts.data.shape[-1] / tsample_new) npt.assert_equal(ts_new.duration, ts.duration) tmax_new, vmax_new = ts_new.max() npt.assert_equal(tmax_new, tmax) npt.assert_equal(vmax_new, vmax) # Make sure resampling leaves old data unaffected (deep copy) ts_new.data[0, 0, 0] = max_val * 2.0 tmax_new, vmax_new = ts_new.max() npt.assert_equal(tmax_new, 0) npt.assert_equal(vmax_new, max_val * 2.0) tmax, vmax = ts.max() npt.assert_equal(tmax, max_idx) npt.assert_equal(vmax, max_val)
def test_save_video_sidebyside(): reload(files) from skvideo import datasets videoin = files.load_video(datasets.bikes(), as_timeseries=False) fps = files.load_video_framerate(datasets.bikes()) tsample = 1.0 / float(fps) rollaxes = np.roll(range(videoin.ndim), -1) percept = utils.TimeSeries(tsample, np.transpose(videoin, rollaxes)) files.save_video_sidebyside(datasets.bikes(), percept, 'mymovie.mp4', fps=fps) videout = files.load_video('mymovie.mp4', as_timeseries=False) npt.assert_equal(videout.shape[0], videoin.shape[0]) npt.assert_equal(videout.shape[1], videoin.shape[1]) npt.assert_equal(videout.shape[2], videoin.shape[2] * 2) npt.assert_equal(videout.shape[3], videoin.shape[3]) with pytest.raises(TypeError): files.save_video_sidebyside(datasets.bikes(), [2, 3, 4], 'invalid.avi') with mock.patch.dict("sys.modules", {"skvideo": {}}): with pytest.raises(ImportError): reload(files) files.save_video_sidebyside(datasets.bikes(), percept, 'invalid.avi') with mock.patch.dict("sys.modules", {"skimage": {}}): with pytest.raises(ImportError): reload(files) files.save_video_sidebyside(datasets.bikes(), percept, 'invalid.avi')
def test_save_video(): # Load a test example reload(files) from skvideo import datasets # There and back again: ndarray videoin = files.load_video(datasets.bikes(), as_timeseries=False) fpsin = files.load_video_framerate(datasets.bikes()) files.save_video(videoin, 'myvideo.mp4', fps=fpsin) videout = files.load_video('myvideo.mp4', as_timeseries=False) npt.assert_equal(videoin.shape, videout.shape) npt.assert_almost_equal(videout / 255.0, videoin / 255.0, decimal=0) # Write to file with different frame rate, widths, and heights fpsout = 15 files.save_video(videoin, 'myvideo.mp4', width=100, fps=fpsout) npt.assert_equal(files.load_video_framerate('myvideo.mp4'), fpsout) videout = files.load_video('myvideo.mp4', as_timeseries=False) npt.assert_equal(videout.shape[2], 100) files.save_video(videoin, 'myvideo.mp4', height=20, fps=fpsout) videout = files.load_video('myvideo.mp4', as_timeseries=False) npt.assert_equal(videout.shape[1], 20) videout = None # There and back again: TimeSeries tsamplein = 1.0 / float(fpsin) tsampleout = 1.0 / float(fpsout) rollaxes = np.roll(range(videoin.ndim), -1) tsin = utils.TimeSeries(tsamplein, np.transpose(videoin, rollaxes)) files.save_video(tsin, 'myvideo.mp4', fps=fpsout) npt.assert_equal(tsin.tsample, tsamplein) tsout = files.load_video('myvideo.mp4', as_timeseries=True) npt.assert_equal(files.load_video_framerate('myvideo.mp4'), fpsout) npt.assert_equal(isinstance(tsout, utils.TimeSeries), True) npt.assert_almost_equal(tsout.tsample, tsampleout) # Also verify the actual data tsres = tsin.resample(tsampleout) npt.assert_equal(tsout.shape, tsres.shape) npt.assert_almost_equal(tsout.data / 255.0, tsres.data / tsres.data.max(), decimal=0) with pytest.raises(TypeError): files.save_video([2, 3, 4], 'invalid.avi') # Trigger an import error with mock.patch.dict("sys.modules", {"skvideo": {}}): with pytest.raises(ImportError): reload(files) files.save_video(videoin, 'invalid.avi') with mock.patch.dict("sys.modules", {"skimage": {}}): with pytest.raises(ImportError): reload(files) files.save_video(videoin, 'invalid.avi')
def model_cascade(self, in_arr, pt_list, layers, use_jit): """Horsager model cascade Parameters ---------- in_arr: array-like A 2D array specifying the effective current values at a particular spatial location (pixel); one value per retinal layer and electrode. Dimensions: <#layers x #electrodes> pt_list : list List of pulse train 'data' containers. Dimensions: <#electrodes x #time points> layers : list List of retinal layers to simulate. Choose from: - 'OFL': optic fiber layer - 'GCL': ganglion cell layer use_jit : bool If True, applies just-in-time (JIT) compilation to expensive computations for additional speed-up (requires Numba). """ if 'INL' in layers: raise ValueError("The Nanduri2012 model does not support an inner " "nuclear layer.") # Although the paper says to use cathodic-first, the code only # reproduces if we use what we now call anodic-first. So flip the sign # on the stimulus here: stim = -self.calc_layer_current(in_arr, pt_list, layers) # R1 convolved the entire stimulus (with both pos + neg parts) r1 = self.tsample * utils.conv( stim, self.gamma1, mode='full', method='sparse')[:stim.size] # It's possible that charge accumulation was done on the anodic phase. # It might not matter too much (timing is slightly different, but the # data are not accurate enough to warrant using one over the other). # Thus use what makes the most sense: accumulate on cathodic ca = self.tsample * np.cumsum(np.maximum(0, -stim)) ca = self.tsample * utils.conv( ca, self.gamma2, mode='full', method='fft')[:stim.size] r2 = r1 - self.epsilon * ca # Then half-rectify and pass through the power-nonlinearity r3 = np.maximum(0.0, r2)**self.beta # Then convolve with slow gamma r4 = self.tsample * utils.conv( r3, self.gamma3, mode='full', method='fft')[:stim.size] return utils.TimeSeries(self.tsample, r4)
def model_cascade(self, in_arr, pt_list, layers, use_jit): """Nanduri model cascade Parameters ---------- in_arr: array-like A 2D array specifying the effective current values at a particular spatial location (pixel); one value per retinal layer and electrode. Dimensions: <#layers x #electrodes> pt_list : list List of pulse train 'data' containers. Dimensions: <#electrodes x #time points> layers : list List of retinal layers to simulate. Choose from: - 'OFL': optic fiber layer - 'GCL': ganglion cell layer use_jit : bool If True, applies just-in-time (JIT) compilation to expensive computations for additional speed-up (requires Numba). """ if 'INL' in layers: raise ValueError("The Nanduri2012 model does not support an inner " "nuclear layer.") # `b1` contains a scaled PulseTrain per layer for this particular # pixel: Use as input to model cascade b1 = self.calc_layer_current(in_arr, pt_list, layers) # Fast response b2 = self.tsample * utils.conv( b1, self.gamma1, mode='full', method='sparse', use_jit=use_jit)[:b1.size] # Charge accumulation ca = self.tsample * np.cumsum(np.maximum(0, b1)) ca = self.tsample * utils.conv( ca, self.gamma2, mode='full', method='fft')[:b1.size] b3 = np.maximum(0, b2 - self.eps * ca) # Stationary nonlinearity sigmoid = ss.expit((b3.max() - self.shift) / self.slope) b4 = b3 * sigmoid * self.asymptote # Slow response b5 = self.tsample * utils.conv( b4, self.gamma3, mode='full', method='fft')[:b1.size] return utils.TimeSeries(self.tsample, b5)
def test_TimeSeries(): max_val = 2.0 max_idx = 156 data_orig = np.random.rand(10, 10, 1000) data_orig[4, 4, max_idx] = max_val ts = utils.TimeSeries(1.0, data_orig) # Make sure function returns largest element tmax, vmax = ts.max() npt.assert_equal(tmax, max_idx) npt.assert_equal(vmax, max_val) # Make sure function returns largest frame tmax, fmax = ts.max_frame() npt.assert_equal(tmax, max_idx) npt.assert_equal(fmax.data, data_orig[:, :, max_idx]) # Make sure getitem works npt.assert_equal(isinstance(ts[3], utils.TimeSeries), True) npt.assert_equal(ts[3].data, ts.data[3])
def model_cascade(self, ecs_item, pt_list, layers, use_jit): """The Temporal Sensitivity model This function applies the model of temporal sensitivity to a single retinal cell (i.e., a pixel). The model is inspired by Nanduri et al. (2012), with some extended functionality. Parameters ---------- ecs_item: array-like A 2D array specifying the effective current values at a particular spatial location (pixel); one value per retinal layer and electrode. Dimensions: <#layers x #electrodes> pt_list: list A list of PulseTrain `data` containers. Dimensions: <#electrodes x #time points> layers : list List of retinal layers to simulate. Choose from: - 'OFL': optic fiber layer - 'GCL': ganglion cell layer - 'INL': inner nuclear layer use_jit : bool If True, applies just-in-time (JIT) compilation to expensive computations for additional speed-up (requires Numba). Returns ------- Brightness response over time. In Nanduri et al. (2012), the maximum value of this signal was used to represent the perceptual brightness of a particular location in space, B(r). """ # For each layer in the model, scale the pulse train data with the # effective current: ecm = self.calc_layer_current(ecs_item, pt_list, layers) # Calculate charge accumulation on the input ca = self.charge_accumulation(ecm) # Sparse convolution is faster if input is sparse. This is true for # the first convolution in the cascade, but not for subsequent ones. if 'INL' in layers: fr_inl = self.fast_response(ecm[0], self.gamma_inl, use_jit=use_jit, method='sparse') # Cathodic and anodic parts are treated separately: They have the # same charge accumulation, but anodic currents contribute less to # the response fr_inl_cath = np.maximum(0, -fr_inl) fr_inl_anod = self.aweight * np.maximum(0, fr_inl) resp_inl = np.maximum(0, fr_inl_cath + fr_inl_anod - ca[0, :]) else: resp_inl = np.zeros_like(ecm[0]) if ('GCL' or 'OFL') in layers: fr_gcl = self.fast_response(ecm[1], self.gamma_gcl, use_jit=use_jit, method='sparse') # Cathodic and anodic parts are treated separately: They have the # same charge accumulation, but anodic currents contribute less to # the response fr_gcl_cath = np.maximum(0, -fr_gcl) fr_gcl_anod = self.aweight * np.maximum(0, fr_gcl) resp_gcl = np.maximum(0, fr_gcl_cath + fr_gcl_anod - ca[1, :]) else: resp_gcl = np.zeros_like(ecm[1]) resp = resp_gcl + self.lweight * resp_inl resp = self.stationary_nonlinearity(resp) resp = self.slow_response(resp) return utils.TimeSeries(self.tsample, resp)
def pulse2percept(self, stim, t_percept=None, tol=0.05, layers=['OFL', 'GCL', 'INL']): """Transforms an input stimulus to a percept Parameters ---------- stim : utils.TimeSeries|list|dict There are several ways to specify an input stimulus: - For a single-electrode array, pass a single pulse train; i.e., a single utils.TimeSeries object. - For a multi-electrode array, pass a list of pulse trains; i.e., one pulse train per electrode. - For a multi-electrode array, specify all electrodes that should receive non-zero pulse trains by name. t_percept : float, optional, default: inherit from `stim` object The desired time sampling of the output (seconds). tol : float, optional, default: 0.05 Ignore pixels whose effective current is smaller than a fraction `tol` of the max value. layers : list, optional, default: ['OFL', 'GCL', 'INL'] A list of retina layers to simulate (order does not matter): - 'OFL': Includes the optic fiber layer in the simulation. If omitted, the tissue activation map will not account for axon streaks. - 'GCL': Includes the ganglion cell layer in the simulation. - 'INL': Includes the inner nuclear layer in the simulation. If omitted, bipolar cell activity does not contribute to ganglion cell activity. Returns ------- A utils.TimeSeries object whose data container comprises the predicted brightness over time at each retinal location (x, y), with the last dimension of the container representing time (t). Examples -------- Simulate a single-electrode array: >>> import pulse2percept as p2p >>> implant = p2p.implants.ElectrodeArray('subretinal', 0, 0, 0) >>> stim = p2p.stimuli.PulseTrain(tsample=5e-6, freq=50, amp=20) >>> sim = p2p.Simulation(implant) >>> percept = sim.pulse2percept(stim) # doctest: +SKIP Simulate an Argus I array centered on the fovea, where a single electrode is being stimulated ('C3'): >>> import pulse2percept as p2p >>> implant = p2p.implants.ArgusI() >>> stim = {'C3': stimuli.PulseTrain(tsample=5e-6, freq=50, ... amp=20)} >>> sim = p2p.Simulation(implant) >>> resp = sim.pulse2percept(stim, implant) # doctest: +SKIP """ logging.getLogger(__name__).info("Starting pulse2percept...") # Get a flattened, all-uppercase list of layers layers = np.array([layers]).flatten() layers = np.array([l.upper() for l in layers]) # Make sure all specified layers exist not_supported = np.array( [l not in retina.SUPPORTED_LAYERS for l in layers], dtype=bool) if any(not_supported): msg = ', '.join(layers[not_supported]) msg = "Specified layer %s not supported. " % msg msg += "Choose from %s." % ', '.join(retina.SUPPORTED_LAYERS) raise ValueError(msg) # Set up all layers that haven't been set up yet self._set_layers() # Parse `stim` (either single pulse train or a list/dict of pulse # trains), and generate a list of pulse trains, one for each electrode pt_list = stimuli.parse_pulse_trains(stim, self.implant) pt_data = [pt.data for pt in pt_list] if not np.allclose([p.tsample for p in pt_list], self.gcl.tsample): e_s = "For now, all pulse trains must have the same sampling " e_s += "time step as the ganglion cell layer. In the future, " e_s += "this requirement might be relaxed." raise ValueError(e_s) # Tissue activation maps: If OFL is simulated, includes axon streaks. if 'OFL' in layers: ecs, _ = self.ofl.electrode_ecs(self.implant) else: _, ecs = self.ofl.electrode_ecs(self.implant) # Calculate the max of every current spread map lmax = np.zeros((2, ecs.shape[-1])) if 'INL' in layers: lmax[0, :] = ecs[:, :, 0, :].max(axis=(0, 1)) if ('GCL' or 'OFL') in layers: lmax[1, :] = ecs[:, :, 1, :].max(axis=(0, 1)) # `ecs_list` is a pixel by `n` list where `n` is the number of layers # being simulated. Each value in `ecs_list` is the current contributed # by each electrode for that spatial location ecs_list = [] idx_list = [] for xx in range(self.ofl.gridx.shape[1]): for yy in range(self.ofl.gridx.shape[0]): # If any of the used current spread maps at [yy, xx] are above # tolerance, we need to process that pixel process_pixel = False if 'INL' in layers: # For this pixel: Check if the ecs in any layer is large # enough compared to the max across pixels within the layer process_pixel |= np.any( ecs[yy, xx, 0, :] >= tol * lmax[0, :]) if ('GCL' or 'OFL') in layers: process_pixel |= np.any( ecs[yy, xx, 1, :] >= tol * lmax[1, :]) if process_pixel: ecs_list.append(ecs[yy, xx]) idx_list.append([yy, xx]) s_info = "tol=%.1f%%, %d/%d px selected" % (tol * 100, len(ecs_list), np.prod(ecs.shape[:2])) logging.getLogger(__name__).info(s_info) sr_list = utils.parfor(self.gcl.model_cascade, ecs_list, n_jobs=self.n_jobs, engine=self.engine, scheduler=self.scheduler, func_args=[pt_data, layers, self.use_jit]) bm = np.zeros(self.ofl.gridx.shape + (sr_list[0].data.shape[-1], )) idxer = tuple(np.array(idx_list)[:, i] for i in range(2)) bm[idxer] = [sr.data for sr in sr_list] percept = utils.TimeSeries(sr_list[0].tsample, bm) # It is possible to specify an additional sampling rate for the # percept: If different from the input sampling rate, need to resample. if t_percept != percept.tsample: percept = percept.resample(t_percept) logging.getLogger(__name__).info("Done.") return percept
def load_video(filename, as_timeseries=True, as_gray=False, ffmpeg_path=None, libav_path=None): """Loads a video from file. This function loads a video from file with the help of Scikit-Video, and returns the data either as a NumPy array (if `as_timeseries` is False) or as a ``p2p.utils.TimeSeries`` object (if `as_timeseries` is True). Parameters ---------- filename : str Video file name as_timeseries: bool, optional, default: True If True, returns the data as a ``p2p.utils.TimeSeries`` object. as_gray : bool, optional, default: False If True, loads only the luminance channel of the video. ffmpeg_path : str, optional, default: system's default path Path to ffmpeg library. libav_path : str, optional, default: system's default path Path to libav library. Returns ------- video : ndarray | p2p2.utils.TimeSeries If `as_timeseries` is False, returns video data according to the Scikit-Video standard; that is, an ndarray of dimension (T, M, N, C), (T, M, N), (M, N, C), or (M, N), where T is the number of frames, M is the height, N is the width, and C is the number of channels (will be either 1 for grayscale or 3 for RGB). If `as_timeseries` is True, returns video data as a TimeSeries object of dimension (M, N, C), (M, N, T), (M, N, C), or (M, N). The sampling rate corresponds to 1 / frame rate. Examples -------- Load a video as a ``p2p.utils.TimeSeries`` object: >>> from skvideo import datasets >>> video = load_video(datasets.bikes()) >>> video.tsample 0.04 >>> video.shape (272, 640, 3, 250) Load a video as a NumPy ndarray: >>> from skvideo import datasets >>> video = load_video(datasets.bikes(), as_timeseries=False) >>> video.shape (250, 272, 640, 3) Load a video as a NumPy ndarray and convert to grayscale: >>> from skvideo import datasets >>> video = load_video(datasets.bikes(), as_timeseries=False, as_gray=True) >>> video.shape (250, 272, 640, 1) """ if not has_skvideo: raise ImportError("You do not have scikit-video installed. " "You can install it via $ pip install sk-video.") # Set the path if necessary set_skvideo_path(ffmpeg_path, libav_path) if skvideo._HAS_FFMPEG: backend = 'ffmpeg' else: backend = 'libav' video = svio.vread(filename, as_grey=as_gray, backend=backend) logging.getLogger(__name__).info("Loaded video from file '%s'." % filename) d_s = "Loaded video has shape (T, M, N, C) = " + str(video.shape) logging.getLogger(__name__).debug(d_s) if as_timeseries: # TimeSeries has the time as the last dimensions: re-order dimensions, # then squeeze out singleton dimensions axes = np.roll(range(video.ndim), -1) video = np.squeeze(np.transpose(video, axes=axes)) fps = load_video_framerate(filename) d_s = "Reshaped video to shape (M, N, C, T) = " + str(video.shape) logging.getLogger(__name__).debug(d_s) return utils.TimeSeries(1.0 / fps, video) else: # Return as ndarray return video