{"id":5610,"date":"2025-11-08T12:50:56","date_gmt":"2025-11-08T17:50:56","guid":{"rendered":"https:\/\/labrigger.com\/blog\/?p=5610"},"modified":"2025-11-08T12:50:56","modified_gmt":"2025-11-08T17:50:56","slug":"snappy-looking-calcium-imaging-videos","status":"publish","type":"post","link":"http:\/\/labrigger.com\/blog\/2025\/11\/08\/snappy-looking-calcium-imaging-videos\/","title":{"rendered":"Snappy looking calcium imaging videos"},"content":{"rendered":"\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"893\" src=\"https:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image-1024x893.png\" alt=\"\" class=\"wp-image-5618\" srcset=\"http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image-1024x893.png 1024w, http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image-300x262.png 300w, http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image-768x670.png 768w, http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image.png 1394w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p>Want to make your calcium imaging videos look better for presentations? Read on. Or just skip to the recipe section below. First, I&#8217;ll discuss the motivation a bit.<\/p>\n\n\n\n<p>One of my refrains when I talk about making slides for scientific presentations is: &#8220;Never tell your audience, <em>&#8216;this is hard to see, but it looks okay on my laptop&#8217;<\/em>.&#8221; We&#8217;ve all heard some version of that many times. It&#8217;s natural and understandable, but it&#8217;s also avoidable. Show respect to your audience by preparing in advance.<\/p>\n\n\n\n<p>Unless you&#8217;re giving a talk on something excellent like an 8k high dynamic range OLED monitor, all of your slides are definitely going to look poor compared to your laptop display. Very low contrast. Count on it. Expect it. Plan for it.<\/p>\n\n\n\n<p>All of your images need to have very high contrast. Test them out on the worst projector you can find. Like a cheap VGA mini projector you buy on the internet for fifty bucks. Make sure that they look good on that. THEN go give your talk.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><a href=\"https:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/10\/image.png\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"929\" src=\"https:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/10\/image-1024x929.png\" alt=\"\" class=\"wp-image-5613\" srcset=\"http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/10\/image-1024x929.png 1024w, http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/10\/image-300x272.png 300w, http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/10\/image-768x696.png 768w, http:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/10\/image.png 1394w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/a><\/figure>\n\n\n\n<p>I show a lot of two-photon calcium imaging videos in my talks. It&#8217;s a big part of how I study neural circuitry. <strong>These videos are beautiful to me. Perception and behavior, all of our experiences and interactions with the world, are built from countless tiny millisecond-long millivolt-level excursions. These vast conversations, spanning the entire brain every second, are an insanely beautiful choreography. And large field-of-view two-photon calcium imaging distills this computational choreography into visible light.<\/strong><\/p>\n\n\n\n<p>The type of calcium imaging I do prioritizes the field-of-view and temporal resolution. I want as many neurons as I can sample, at around 10 frames per second, with each neuron having enough samples to provide a high fidelity measurement of the fluorescence dynamics of a calcium indicator. The spatial sampling is typically well below the Nyquist criterion, because I don&#8217;t care about the shape of cells. I just need enough pixels per neuron to get a high fidelity signal (often this is 10-20 pixels). We are often operating in the regime where shot noise is a visible factor, making due with as few photons per pixel as we can, so that we can distribute them broadly in space and time, and obtain sufficient temporal resolution for as many neurons as possible.<\/p>\n\n\n\n<p><strong>So the individual frames can be a bit ugly. <\/strong>That&#8217;s okay. They are a bit noisy. We&#8217;re averaging over all of the pixels for individual cells before we do our analysis. The individual frames can look pretty rough, and we still get high fidelity measurements of indicator dynamics from them. So while the aficionados can see the beauty in raw calcium imaging videos, a general audience might be less impressed. They might wonder what we&#8217;re so excited about. It can be helpful to process the videos to give a better impression of the data. <\/p>\n\n\n\n<p>Sometimes simply adjusting the look-up-table parameters (min and max values, and gamma) can help a lot. Perhaps a snappy color look-up-table can help. I typically prefer to stick with black-and-white for this type of data. Colored look up tables can lead to unpredictable results with color reproduction on projectors, low contrast, and\/or colorblind audience members. Black-and-white is a bit more general purpose, and can be sufficient in many cases. <\/p>\n\n\n\n<p class=\"has-x-large-font-size\"><strong>Recipe<\/strong><\/p>\n\n\n\n<p>Let&#8217;s think about this problem another way. Like I said above, the individual frames can be sort of ugly and that&#8217;s okay because we average signals over the pixels assigned to individual cells. This leads to high fidelity signals that we use for analysis. Instead of asking the audience to have the eyes of an aficionado and recognize the quality of the raw data, let&#8217;s go ahead and do the analysis and then map that back into the video.<\/p>\n\n\n\n<p><strong>First, let&#8217;s analyze the data.<\/strong> Apply motion correction, segment the image, extract the raw traces, do the neuropil subtraction, and infer spikes. Hold on to that for a moment.<\/p>\n\n\n\n<p><strong>Second, let&#8217;s take a projection of the video stack.<\/strong> Either a max, average, or std dev projection. Whatever looks good. Sometimes it&#8217;s better to run the projection on a subsection of the stack, not the full stack.<\/p>\n\n\n\n<p><strong>Third, let&#8217;s work backwards. <\/strong>Take the inferred spike trains and convolve them with a calcium indicator kernel (near-instantaneous onset and an exponential decay). Then use those convolved signals to modulate the brightness of the ROIs mapped back onto the projected image.<\/p>\n\n\n\n<p>Filip Tomaska worked on this idea and wrote the code to work things out. At first, we used binary ROI masks, and that was okay, but looked a bit crude and pixelated. Using a max projection of the ROI gave us some shading that looked a bit more pleasing. Another thing we did is make the projection a bit dim, so that bright cells can pop out better when they increase in brightness over time. <\/p>\n\n\n\n<p>Last of all, we adjusted the function for mapping the signal trace to ROI brightness. A straight linear mapping is a problem because single spike signals are hard to see by eye, and the bursts are relatively rare. So it gives the impression that there is less activity than we can actually detect. Projectors and displays often only have 8 bits of <a href=\"https:\/\/www.projectorcentral.com\/All-About-Bit-Depth.htm\">dynamic range<\/a> for brightness and can be effectively worse depending on ambient lighting. The actual signals we use for analysis can have 12+ bits of dynamic range. So for display I wanted a function that would be linear at low values, to make single spikes visible, and then quickly saturate at high values. The hyperbolic tangent (tanh) function worked well for that. Here&#8217;s the end result:<\/p>\n\n\n\n<figure class=\"wp-block-video\"><video height=\"1024\" style=\"aspect-ratio: 1024 \/ 1024;\" width=\"1024\" controls src=\"https:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/allcells_roiMax_tanh_2x.mp4\"><\/video><\/figure>\n\n\n\n<p>And here&#8217;s the code by Filip Tomaska (<em>below<\/em>). We use <a href=\"https:\/\/github.com\/MouseLand\/suite2p\">Suite2p<\/a> and <a href=\"https:\/\/github.com\/epnev\/continuous_time_ca_sampler\">MCMC<\/a> for spike inference. This code takes in a <strong>mean image<\/strong>,\u00a0<strong>stat<\/strong>,\u00a0and\u00a0<strong>iscell<\/strong>\u00a0from Suite2p +\u00a0<strong>spiketimes<\/strong>\u00a0from MCMC.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>function overlayBlinkingROIs_onMean(meanIm, out, stat, iscellFlags, varargin)\n% Overlay blinking ROIs (alpha ~ convolved spikes) on a mean projection.\n%\n% Inputs\n%   meanIm        : &#91;Ly x Lx] mean projection (double\/single\/uint)\n%   out.path1_phys.spiketimes :\n%                    - cell array: {k} seconds\n%                    - struct array: (k).spks seconds  (your case)\n%                    Count must equal sum(iscellFlags(:,1)==1)\n%   stat          : Suite2p ROI struct(s): stat{1,i}.xpix\/ypix or stat(i)\n%   iscellFlags   : &#91;Nroi x 2], first col 1 = cell, 0 = not cell\n%\n% Name\/Value options\n%   'Params'       : struct('tau_rise',0.07,'tau_decay',0.7)  (default)\n%   'FPS'          : native fps of original acquisition (default 15)\n%   'DurationSec'  : total duration to render (default: from last spike + margin)\n%   'NFrames'      : override number of frames (else computed from DurationSec)\n%   'Output'       : output MP4 filename (default 'roi_on_mean_2x.mp4')\n%   'BaseContrast' : &#91;low high] percentiles for base image (default &#91;1 99])\n%   'ClipPercent'  : soft-clip traces percentile (default 99)\n%   'GlobalScale'  : normalize traces by global max (default true)\n%   'OverlayColor' : &#91;R G B] single color (default &#91;0 1 0])\n%   'ColorByROI'   : true = distinct colors per ROI (default false)\n%   'AlphaMax'     : max overlay alpha (default 0.85)\n%\n% Example\n%   params = struct('tau_rise',0.07,'tau_decay',0.7);\n%   overlayBlinkingROIs_onMean(mean(M1,3), out, stat, iscell, ...\n%       'Params', params, 'FPS', 15, 'Output','blink_on_mean.mp4');\n\n%^by chatGPT for better dissemination\n\n%  parse options\np = inputParser;\np.addParameter('Params', struct('tau_rise',0.07,'tau_decay',0.7));\np.addParameter('FPS', 15);\np.addParameter('DurationSec', &#91;]);\np.addParameter('NFrames', &#91;]);\np.addParameter('Output', 'roi_on_mean_2x.mp4', @ischar);\np.addParameter('BaseContrast', &#91;1 99], @(v)isnumeric(v)&amp;&amp;numel(v)==2);\np.addParameter('ClipPercent', 99, @(x)isnumeric(x)&amp;&amp;isscalar(x)&amp;&amp;x>0&amp;&amp;x&lt;=100);\np.addParameter('GlobalScale', true, @islogical);\np.addParameter('OverlayColor', &#91;0 1 0], @(v)isnumeric(v)&amp;&amp;numel(v)==3);\np.addParameter('ColorByROI', false, @islogical);\np.addParameter('AlphaMax', 0.85, @(x)isnumeric(x)&amp;&amp;isscalar(x)&amp;&amp;x>=0&amp;&amp;x&lt;=1);\np.parse(varargin{:});\nopt = p.Results;\n\n% kernel params (support params.'6s' style too)\nparams = opt.Params;\nif isfield(params,'tau_rise') &amp;&amp; isfield(params,'tau_decay')\n    tau_rise  = params.tau_rise;  tau_decay = params.tau_decay;\nelseif isfield(params,'6s')\n    tau_rise  = params.('6s').tau_rise;  tau_decay = params.('6s').tau_decay;\nelse\n    error('Params must have tau_rise\/tau_decay or a key like params.''6s''.');\nend\nfps_native = opt.FPS;\n\n% ?? select ROIs (only iscell==1) and grab spikes container ?????????????????\nroiIsCell = find(iscellFlags(:,1)==1);\nnKeep     = numel(roiIsCell);\n\nif ~isfield(out,'path1_phys') || ~isfield(out.path1_phys,'spiketimes')\n    error('out.path1_phys.spiketimes not found.');\nend\nspkBag = out.path1_phys.spiketimes; % may be cell array or struct array\nisSpkCell   = builtin('iscell', spkBag);\nisSpkStruct = isstruct(spkBag);\nif numel(spkBag) ~= nKeep\n    error('spiketimes count (%d) != number of iscell==1 (%d).', numel(spkBag), nKeep);\nend\n\n% helper for spike times (seconds) per kept index k\nget_spikes = @(k) local_get_spikes(spkBag, k, isSpkCell, isSpkStruct);\n\n% get image size, roi etc...\n&#91;Ly,Lx,zeroBased] = local_infer_image_size(stat, iscellFlags);\nmaskIdx = cell(nKeep,1);\nfor k = 1:nKeep\n    roi = roiIsCell(k);\n    S = local_stat_at(stat, roi);\n    y = S.ypix(:); x = S.xpix(:);\n    if zeroBased, y = y+1; x = x+1; end\n    y = max(1, min(Ly, round(y)));\n    x = max(1, min(Lx, round(x)));\n    maskIdx{k} = sub2ind(&#91;Ly Lx], y, x);\nend\n\n% process mean projection\n% \nmeanIm = im2double(imresize(meanIm, &#91;Ly Lx]));\nlo = prctile(meanIm(:), opt.BaseContrast(1));\nhi = prctile(meanIm(:), opt.BaseContrast(2));\nbaseIm = (meanIm - lo) \/ max(hi - lo, eps);\nbaseIm = min(max(baseIm,0),1);\n\n% infer timeline from last spike time\nif ~isempty(opt.NFrames)\n    nFrames = opt.NFrames;\nelse\n    if ~isempty(opt.DurationSec)\n        Tsec = opt.DurationSec;\n    else\n        lastSpike = 0;\n        for k = 1:nKeep\n            st = get_spikes(k);\n            if ~isempty(st), lastSpike = max(lastSpike, max(st)); end\n        end\n        Tsec = lastSpike + 5*max(tau_decay,tau_rise); % tail margin\n        if ~isfinite(Tsec) || Tsec&lt;=0, Tsec = 10; end % fallback 10s\n    end\n    nFrames = max(1, round(Tsec * fps_native));\nend\ndt = 1 \/ fps_native;\nt_edges = 0:dt:(nFrames*dt);\n\n% GECI kernel\ntmax = 5*max(tau_decay, tau_rise);\nkt   = 0:dt:tmax;\nkern = exp(-kt.\/tau_decay) - exp(-kt.\/tau_rise);\nkern = max(kern, 0);\nif max(kern)>0, kern = kern.\/max(kern); else, warning('Kernel is zero.'); end\n\n% convolve spikes to Ca\ntraces = zeros(nKeep, nFrames, 'single');\nglobalMax = 0;\nfor k = 1:nKeep\n    st = get_spikes(k);\n    if isempty(st), continue; end\n    bcounts     = histcounts(st, t_edges);          % length nFrames\n    convsig     = conv(single(bcounts), single(kern), 'same');\n    traces(k,:) = convsig;\n    if opt.GlobalScale, globalMax = max(globalMax, max(convsig)); end\nend\nif opt.GlobalScale\n    traces = traces \/ max(eps, globalMax);\nelse\n    for k = 1:nKeep\n        m = max(traces(k,:)); if m>0, traces(k,:) = traces(k,:)\/m; end\n    end\nend\ntraces = min(max(traces,0),1);\nif opt.ClipPercent &lt; 100\n    v = traces(:); v = v(v>0);\n    if ~isempty(v)\n        vmax = prctile(v, opt.ClipPercent);\n        if isfinite(vmax) &amp;&amp; vmax>0, traces = min(traces \/ vmax, 1); end\n    end\nend\n\n% overlay color def\nif opt.ColorByROI\n    cmap = hsv(nKeep);\nelse\n    cmap = repmat(opt.OverlayColor(:).', nKeep, 1);\nend\n\n% write as mp4 because boss hates avi.\nplayback_coeff = 2: % how many times faster is the playback\nfps_out = playback_coeff * fps_native;\nvw = VideoWriter(opt.Output, 'MPEG-4'); vw.FrameRate = fps_out; vw.Quality = 95; open(vw);\nfprintf('Writing %s (%dx%d), %d frames @ %.1f fps (2\u00d7)...\\n', opt.Output, Lx, Ly, nFrames, fps_out);\n\nfor f = 1:nFrames\n    % start with static base\n    frameRGB = repmat(baseIm, &#91;1 1 3]);\n    % alpha-blend each ROI using trace as alpha driver\n    for k = 1:nKeep\n        a = min(1, opt.AlphaMax * double(traces(k,f)));\n        if a&lt;=0, continue; end\n        idx = maskIdx{k};\n        for c = 1:3\n            col = cmap(k,c);\n            tmp = frameRGB(:,:,c);\n            tmp(idx) = (1 - a).*tmp(idx) + a.*col;\n            frameRGB(:,:,c) = tmp;\n        end\n    end\n    writeVideo(vw, im2uint8(frameRGB));\nend\nclose(vw);\nfprintf('Done.\\n');\n\nend\n\n%helpers\nfunction S = local_stat_at(stat, ii)\n    if builtin('iscell', stat), S = stat{1,ii}; else, S = stat(ii); end\nend\n\nfunction &#91;Ly,Lx,zeroBased] = local_infer_image_size(stat, iscellFlags)\n    nROIs = size(iscellFlags,1);\n    allY = &#91;]; allX = &#91;];\n    for ii = 1:nROIs\n        S = &#91;];\n        if builtin('iscell', stat)\n            if ii&lt;=numel(stat) &amp;&amp; ~isempty(stat{1,ii}), S = stat{1,ii}; end\n        else\n            if ii&lt;=numel(stat) &amp;&amp; ~isempty(stat(ii)),   S = stat(ii);    end\n        end\n        if ~isempty(S) &amp;&amp; isfield(S,'ypix') &amp;&amp; isfield(S,'xpix') ...\n                       &amp;&amp; ~isempty(S.ypix) &amp;&amp; ~isempty(S.xpix)\n            allY = &#91;allY; S.ypix(:)];\n            allX = &#91;allX; S.xpix(:)];\n        end\n    end\n    if isempty(allY) || isempty(allX)\n        error('Could not infer image size: stat entries missing ypix\/xpix.');\n    end\n    zeroBased = (min(allY) == 0) || (min(allX) == 0);\n    Ly = max(allY) + double(zeroBased);\n    Lx = max(allX) + double(zeroBased);\nend\n\nfunction st = local_get_spikes(spkBag, k, isSpkCell, isSpkStruct)\n    if isSpkCell\n        st = spkBag{k};\n    elseif isSpkStruct\n        if isfield(spkBag, 'spks')\n            st = spkBag(k).spks;\n        else\n            cand = {'t','times','spike_times','spikeTimes'};\n            got = &#91;];\n            for ii=1:numel(cand)\n                if isfield(spkBag, cand{ii}), got = spkBag(k).(cand{ii}); break; end\n            end\n            if isempty(got), error('spiketimes struct lacks .spks (or known aliases).'); end\n            st = got;\n        end\n    else\n        error('out.path1_phys.spiketimes must be cell or struct array.');\n    end\n    if isempty(st), st = &#91;]; else, st = st(:)'; end\nend\n<\/code><\/pre>\n","protected":false},"excerpt":{"rendered":"<p><a href=\"https:\/\/labrigger.com\/blog\/wp-content\/uploads\/2025\/11\/image.png\"><\/a><\/p>\n<p>Want to make your calcium imaging videos look better for presentations? Read on. Or just skip to the recipe section below. First, I&#8217;ll discuss the motivation a bit.<\/p>\n<p>One of my refrains&#8230;<\/p>\n<div class=\"read-more\"><a href=\"http:\/\/labrigger.com\/blog\/2025\/11\/08\/snappy-looking-calcium-imaging-videos\/\">Read More<\/a><\/div><\/p>\n","protected":false},"author":1,"featured_media":5618,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[7,3],"tags":[],"class_list":["post-5610","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-software","category-tips"],"_links":{"self":[{"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/posts\/5610","targetHints":{"allow":["GET"]}}],"collection":[{"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/comments?post=5610"}],"version-history":[{"count":5,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/posts\/5610\/revisions"}],"predecessor-version":[{"id":5619,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/posts\/5610\/revisions\/5619"}],"wp:featuredmedia":[{"embeddable":true,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/media\/5618"}],"wp:attachment":[{"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/media?parent=5610"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/categories?post=5610"},{"taxonomy":"post_tag","embeddable":true,"href":"http:\/\/labrigger.com\/blog\/wp-json\/wp\/v2\/tags?post=5610"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}