diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..a729cbd --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "stackin"] + path = stackin + url = https://github.com/RJ3/stackin.git diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64edf43 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Damon McCullough + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1845b35..90b29fe 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,16 @@ Rhythm is a MATLAB-based graphical user interface to display, condition, and ana * Ability to create movies and export individual signals # Requirements -We recommend the latest release of 64-bit MATLAB on a 64-bit machine. We have tested this package on both windows and mac platforms. This code was originally created for a mac. While functionality is unchanged on windows machines, GUI display may be a bit irregular on MATLAB for windows. +We recommend the latest release of 64-bit MATLAB on a 64-bit machine. We have tested this package on both windows and mac platforms. This code was originally created for a mac. While functionality is unchanged on windows machines, GUI display may be a bit irregular on MATLAB for windows. + +*MATLAB 9.5 +*Signal Processing Toolbox 8.1 +*Image Processing Toolbox 10.3 +*Statistics and Machine Learning Toolbox 11.4 # Contributions If you would like to contribute to this project, please contact Dr. Igor Efimov at igor@wustl.edu. If you find this code helpful, please reference our review paper describing proper analysis of optical mapping data found at http://www.ncbi.nlm.nih.gov/pubmed/22821993 -# Nottice!!! +# Notice!!! Until we can fully perfect the use of Github distribution, we recommend the URL (Google Drive) below for the most up-to-date release of Rhythm https://drive.google.com/folderview?id=0BxA1B_i6ZEvlflptaFlBUlJVNTUtTVUyQzJldnNGc3hneldhVHhVNmhhTjV2M3VmNHY5TlE&usp=sharing diff --git a/aMap.m b/aMap.m index 02cea6e..394bb3e 100755 --- a/aMap.m +++ b/aMap.m @@ -1,4 +1,4 @@ -function [actMap1] = aMap(data,stat,endp,Fs,bg,cmap,filename) +function [actMap1] = aMap(data,startt,endt,Fs,bg,cmap,filename,dir) %% aMap is the central function for creating conduction velocity maps % [actMap1] = aMap(data,stat,endp,Fs,bg) calculates the activation map % for a single action potential upstroke. @@ -19,6 +19,8 @@ % cmap = a colormap input that facilites the potential inversion of the % colormap. cmap is stored in the handles structure as handles.cmap. % +% dir = directory to output csv to +% % % OUTPUT % actMap1 = activation map @@ -48,8 +50,8 @@ %% Code % Create initial variables -stat=round(stat*Fs); -endp=round(endp*Fs); +stat=round(startt*Fs); +endp=round(endt*Fs); actMap = zeros(size(data,1),size(data,2)); mask2 = zeros(size(data,1),size(data,2)); temp = data(:,:,stat:endp); % windowed signal @@ -79,9 +81,10 @@ actMap1 = actMap1/Fs*1000; %% time in ms % Plot Map -zz = figure('Name','Activation Map'); +actMapPeriod = " " + num2str(round(startt, 3)) + " - " + num2str(round(endt, 3)); +zz = figure('Name',strcat('Activation Map: ',actMapPeriod)); contourf(flipud(actMap1),endp-stat,'LineColor','none') -title('Activation Map') +title(strcat('Activation Map: ',actMapPeriod)) axis image axis off colormap(cmap); @@ -90,15 +93,22 @@ % User prompt for input to create csv prompt1 = {'Save activation map for CV analysis?'}; dlg_title1 = 'Save activation map'; -num_lines1 = 1; -direc='/home/lab/Documents/Langendorff-MEHP/ActMaps/'; -def1 = {strcat(direc,'ActMap-',filename,'.csv')}; +num_lines1 = [1 60]; +direc=dir; +file = strtok(filename,'.'); % Get filename without extension +def1 = {strcat(direc,'/ActMaps/ActMap-',file,'.csv')}; answer = inputdlg(prompt1,dlg_title1,num_lines1,def1); % process user inputs if isempty(answer) % cancel save if user clicks "cancel" return end filename = answer{1}; + +% create the ActMaps folder if it doesn't exist already. +newSubFolder = strcat(direc,'/ActMaps/'); +if ~exist(newSubFolder, 'dir') + mkdir(newSubFolder); +end csvwrite(filename,actMap1); diff --git a/apdMap.m b/apdMap.m index 3066859..6ad1c99 100755 --- a/apdMap.m +++ b/apdMap.m @@ -1,4 +1,4 @@ -function [apdMap] = apdMap(data,start,endp,Fs,percent,cmap) +function [apdMap] = apdMap(data,start,endp,Fs,percent,cmap,filename,dir) %% the function apdMap creates a visual representation of the action potential duration % % INPUTS @@ -100,4 +100,27 @@ figure('Name','Histogram of APD') hist(reshape(apdMap,[],1),floor(APD_max-APD_min)) %xlim([APD_min APD_max]) + + + +% User prompt for input to create csv +prompt1 = {'Save APD map?'}; +dlg_title1 = 'Save APD map'; +num_lines1 = [1 60]; +file = strtok(filename,'.'); % Get filename without extension +def1 = {strcat(dir,'/APDMaps/APD-',file,'.csv')}; +answer = inputdlg(prompt1,dlg_title1,num_lines1,def1); +% process user inputs +if isempty(answer) % cancel save if user clicks "cancel" + return +end +filename = answer{1}; + +% create the APDMaps folder if it doesn't exist already. +newSubFolder = strcat(dir,'/APDMaps/'); +if ~exist(newSubFolder, 'dir') + mkdir(newSubFolder); +end +csvwrite(filename,apdMap); + end \ No newline at end of file diff --git a/atsifiomex.mexa64 b/atsifiomex.mexa64 deleted file mode 100755 index a5f95e3..0000000 Binary files a/atsifiomex.mexa64 and /dev/null differ diff --git a/binning.m b/binning.m index cb370dc..25c0e8c 100755 --- a/binning.m +++ b/binning.m @@ -19,8 +19,10 @@ data(data==0) = NaN; avePattern = ones(N,N); -for i = 1:size(data,3) +parfor i = 1:size(data,3) % for each frame of the data + % get the N*N cmos data for the frame temp = data(:,:,i); + % replace temp with temp = 1/N/N*conv2(temp,avePattern,'same'); data(:,:,i) = temp; end \ No newline at end of file diff --git a/filter_data.m b/filter_data.m index ba425f2..2a0c7cc 100755 --- a/filter_data.m +++ b/filter_data.m @@ -32,7 +32,7 @@ %% Apply Filter temp = reshape(data,[],size(data,3)); filt_temp = zeros(size(temp)); - for i = 1:size(temp,1) + parfor i = 1:size(temp,1) if sum(temp(i,:)) ~= 0 filt_temp(i,:) = filtfilt(b,a,temp(i,:)); % needed to create 0 phase offset end diff --git a/normalize_data.m b/normalize_data.m index 3e66574..57dba36 100755 --- a/normalize_data.m +++ b/normalize_data.m @@ -14,9 +14,28 @@ % divides by the difference between the min and max. %% Code -min_data = repmat(min(data,[],3),[1 1 size(data,3)]); -diff_data = repmat(max(data,[],3)-min(data,[],3),[1 1 size(data,3)]); -normData = (data-min_data)./(diff_data); +disp('(normalize_data.m) Starting ') + +disp('(normalize_data.m) Calculation min_data... ') +min_data = repmat(min(data,[],3), [1 1 size(data,3)]); + +disp('(normalize_data.m) Calculation diff_data... ') +diff_data = repmat(max(data,[],3) - min(data,[],3), [1 1 size(data,3)]); +% normData = (data-min_data)./(diff_data); +% data_temp = data - min_data; + +% Subtraction +disp('(normalize_data.m) Subtraction... ') +% data_temp = bsxfun(@minus, data, min_data); +data = bsxfun(@minus, data, min_data); +clear min_data + +% Division +disp('(normalize_data.m) Division... ') +normData = rdivide(data, diff_data); +clear diff_data + +disp('(normalize_data.m) Done ') % % %% NON RECTANGULAR POLYGON MOD % % min_data = repmat(min(data,[],2),[1 size(data,2)]); % % diff_data = repmat(max(data,[],2),[1 size(data,2)])-min_data; diff --git a/remove_BKGRD.m b/remove_BKGRD.m index e00ec5d..a3b959a 100755 --- a/remove_BKGRD.m +++ b/remove_BKGRD.m @@ -99,7 +99,7 @@ BG = mat2gray(bg); level = graythresh(BG); BW = im2bw(BG,level*thresh); -BW2 = bwareaopen(BW, perc_ex*size(BG,1)*size(BG,2)); +BW2 = bwareaopen(BW, floor(perc_ex*size(BG,1)*size(BG,2))); BW3 = imfill(BW2,'holes'); mask = repmat(BW3,[1 1 size(data,3)]); new_data = data.*mask; diff --git a/remove_Drift.m b/remove_Drift.m index 755a133..dca8367 100755 --- a/remove_Drift.m +++ b/remove_Drift.m @@ -34,7 +34,7 @@ tempy = reshape(data,size(data,1)*size(data,2),[]); temp_ord = ord_str{1}; ord = str2num(temp_ord(1)); -for i = 1:size(data,1)*size(data,2) +parfor i = 1:size(data,1)*size(data,2) if sum(tempy(i,:)) ~= 0 [p,s,mu] = polyfit(tempx,tempy(i,:),ord); y_poly = polyval(p,tempx,s,mu); diff --git a/rhythm.m b/rhythm.m index e36ab18..c659c45 100644 --- a/rhythm.m +++ b/rhythm.m @@ -31,7 +31,7 @@ % Feb. 24, 2016 - The GUI has been streamlined to reduce clutter and create % a space for plugins created by future users. % -% +% Jan. 16, 2019 - Several changes to UI. Damon McCullough %% Create GUI structure scrn_size = get(0,'ScreenSize'); @@ -42,9 +42,13 @@ p1 = uipanel('Title','Display Data','FontSize',11,'Position',[.01 .01 .98 .98]); filelist = uicontrol('Parent',p1,'Style','listbox','String','Files','Position',[10 240 150 450],'Callback',{@filelist_callback}); selectdir = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Select Directory','Position',[10 205 150 30],'Callback',{@selectdir_callback}); -loadfile = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Load','Position',[10 175 150 30],'Callback',{@loadfile_callback}); +loadfile = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Load','Position',[85 175 75 30],'Callback',{@loadfile_callback}); refreshdir = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Refresh Directory','Position',[10 145 150 30],'Callback',{@refreshdir_callback}); +togbDataType = uicontrol('Parent',p1,'Style', 'togglebutton','FontSize',11,'String', 'Voltage', 'Position', [10 175 75 30], 'Callback', {@TogB_data}); +set(togbDataType, 'value',1); % Set to On (Voltage) state + + % Movie Screen for Optical Data movie_scrn = axes('Parent',p1,'Units','Pixels','YTick',[],'XTick',[],'Position',[170, 190, 500,500]); @@ -64,11 +68,13 @@ signal_scrn4 = axes('Parent',p1,'Units','Pixels','Color','w','XTick',[],'Position',[710,188,498,120]); signal_scrn5 = axes('Parent',p1,'Units','Pixels','Color','w','Position',[710,60,498,120]); xlabel('Time (sec)'); -expwave_button = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Export OAPs','Position',[1115 1 100 30],'Callback',{@expwave_button_callback}); +expwave_button = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Export OAPs','Position',[1115 1 90 30],'Callback',{@expwave_button_callback}); +expwavecsv_button = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Export OAP CSVs','Position',[720 1 110 30],'Callback',{@expwavecsv_button_callback}); starttimesig_text = uicontrol('Parent',p1,'Style','text','FontSize',11,'String','Start Time','Position',[830 9 55 15]); starttimesig_edit = uicontrol('Parent',p1,'Style','edit','FontSize',11,'Position',[890 5 55 23],'Callback',{@starttimesig_edit_callback}); -endtimesig_text = uicontrol('Parent',p1,'Style','text','FontSize',11,'String','End Time','Position',[965 9 52 15]); -endtimesig_edit = uicontrol('Parent',p1,'Style','edit','FontSize',11,'Position',[1022 5 55 23],'Callback',{@endtimesig_edit_callback}); +endtimesig_text = uicontrol('Parent',p1,'Style','text','FontSize',11,'String','End Time','Position',[945 9 52 15]); +endtimesig_edit = uicontrol('Parent',p1,'Style','edit','FontSize',11,'Position',[1000 5 55 23],'Callback',{@endtimesig_edit_callback}); +resettime_button = uicontrol('Parent',p1,'Style','pushbutton','FontSize',11,'String','Reset','Position',[1055 1 50 30],'Callback',{@resettime_button_callback}); % Sweep Bar Display for Optical Action Potentials sweep_bar = axes ('Parent',p1,'Units','Pixels','Layer','top','Position',[710,55,500,735]); @@ -93,7 +99,7 @@ norm_button = uicontrol('Parent',cond_sig,'Style','checkbox','FontSize',11,'String','Normalize','Position',[5 36 125 15]); apply_button = uicontrol('Parent',cond_sig,'Style','pushbutton','FontSize',11,'String','Apply','Position',[3 2 150 30],'Callback',{@cond_sig_selcbk}); %Pop-up menu options -bin_popup = uicontrol('Parent',cond_sig,'Style','popupmenu','FontSize',11,'String',{'3 x 3', '5 x 5', '15 x 15', '45 x 45'},'Position',[234 88 75 25]); +bin_popup = uicontrol('Parent',cond_sig,'Style','popupmenu','FontSize',11,'String',{'3 x 3', '5 x 5', '7 x 7', '9 x 9', '15 x 15', '45 x 45'},'Position',[234 88 75 25]); filt_popup = uicontrol('Parent',cond_sig,'Style','popupmenu','FontSize',11,'String',{'[0 50]','[0 75]', '[0 100]', '[0 150]'},'Position',[219 61 90 25]); drift_popup = uicontrol('Parent',cond_sig,'Style','popupmenu','FontSize',11,'String',{'1st Order','2nd Order', '3rd Order', '4th Order'},'Position',[210 34 99 25]); export_button = uicontrol('Parent',cond_sig,'Style','pushbutton','FontSize',11,'String','Export Data','Position',[160 2 145 30],'Callback',{@export_callback}); @@ -126,20 +132,20 @@ calc_apd_button = uicontrol('Parent',anal_data,'Style','pushbutton','FontSize',11,'String','Regional APD','Position',[125 2 103 30],'Callback',{@calc_apd_button_callback}); % Allow all GUI structures to be scaled when window is dragged -set([f,p1,filelist,selectdir,refreshdir,loadfile,movie_scrn,movie_slider, signal_scrn1,signal_scrn2,signal_scrn3,... +set([f,p1,filelist,selectdir,refreshdir,loadfile, togbDataType,movie_scrn,movie_slider, signal_scrn1,signal_scrn2,signal_scrn3,... signal_scrn4,signal_scrn5,sweep_bar,play_button,stop_button,dispwave_button,expmov_button,cond_sig,removeBG_button,... bg_thresh_label,perc_ex_label,bg_thresh_edit,perc_ex_edit,bin_button,filt_button,removeDrift_button,norm_button,... apply_button,bin_popup,filt_popup,drift_popup,export_button,anal_data,anal_select,invert_cmap,starttimemap_text,... starttimemap_edit,endtimemap_text,endtimemap_edit,createmap_button,minapd_text,minapd_edit,maxapd_text,maxapd_edit,... - percentapd_text,percentapd_edit,remove_motion_click,remove_motion_click_txt,calc_apd_button,expwave_button,... - starttimesig_text,starttimesig_edit,endtimesig_text,endtimesig_edit],'Units','normalized') + percentapd_text,percentapd_edit,remove_motion_click,remove_motion_click_txt,calc_apd_button,expwave_button,expwavecsv_button,... + starttimesig_text,starttimesig_edit,endtimesig_text,endtimesig_edit,resettime_button],'Units','normalized') % Disable buttons that will not be needed until data is loaded set([removeBG_button,bg_thresh_edit,bg_thresh_label,perc_ex_edit,perc_ex_label,bin_button,filt_button,removeDrift_button,norm_button,... apply_button,bin_popup,filt_popup,drift_popup,anal_select,starttimemap_edit,starttimemap_text,endtimemap_edit,endtimemap_text,... createmap_button,minapd_edit,minapd_text,maxapd_edit,maxapd_text,percentapd_edit,percentapd_text,remove_motion_click,remove_motion_click_txt,... - calc_apd_button,play_button,stop_button,dispwave_button,expmov_button,starttimesig_edit,endtimesig_edit,expwave_button,loadfile,... - refreshdir,invert_cmap,export_button],'Enable','off') + calc_apd_button,play_button,stop_button,dispwave_button,expmov_button,starttimesig_edit,endtimesig_edit,expwave_button,expwavecsv_button,loadfile,... + refreshdir,invert_cmap,export_button,resettime_button],'Enable','off') % Hide all analysis buttons set([invert_cmap,starttimemap_text,starttimemap_edit,endtimemap_text,... @@ -156,6 +162,7 @@ handles.filename = []; handles.cmosData = []; handles.rawData = []; +handles.dataType = 'v'; % v for voltage, c for calcium handles.time = []; handles.wave_window = 1; handles.normflag = 0; @@ -184,11 +191,13 @@ function button_down_function(obj,~) ps = get(gca,'CurrentPoint'); i_temp = round(ps(1,1)); j_temp = round(ps(2,2)); + pad = 3; % if one of the markers on the movie screen is clicked if i_temp<=size(handles.cmosData,1) || j_temp1 || j_temp>1 if size(handles.M,1) > 0 for i=1:size(handles.M,1) - if i_temp == handles.M(i,1) && handles.M(i,2) == j_temp + % if i_temp == handles.M(i,1) && handles.M(i,2) == j_temp + if ((i_temp > handles.M(i,1) - pad) && (i_temp < handles.M(i,1) + pad)) && ((j_temp > handles.M(i,2) - pad) && (j_temp < handles.M(i,2) + pad)) handles.grabbed = i; break end @@ -215,23 +224,29 @@ function button_motion_function(obj,~) j = j_temp; switch handles.grabbed case 1 - plot(handles.time,squeeze(handles.cmosData(j,i,:)),'b','LineWidth',2,'Parent',signal_scrn1) + plot(handles.time,squeeze(handles.cmosData(j,i,:)),'b','LineWidth',2,'Parent',signal_scrn1) + signal_scrn1.YLabel.String = int2str([i j]); handles.M(1,:) = [i j]; case 2 - plot(handles.time,squeeze(handles.cmosData(j,i,:)),'g','LineWidth',2,'Parent',signal_scrn2) + plot(handles.time,squeeze(handles.cmosData(j,i,:)),'g','LineWidth',2,'Parent',signal_scrn2) + signal_scrn2.YLabel.String = int2str([i j]); handles.M(2,:) = [i j]; case 3 - plot(handles.time,squeeze(handles.cmosData(j,i,:)),'m','LineWidth',2,'Parent',signal_scrn3) + plot(handles.time,squeeze(handles.cmosData(j,i,:)),'m','LineWidth',2,'Parent',signal_scrn3) + signal_scrn3.YLabel.String = int2str([i j]); handles.M(3,:) = [i j]; case 4 - plot(handles.time,squeeze(handles.cmosData(j,i,:)),'k','LineWidth',2,'Parent',signal_scrn4) + plot(handles.time,squeeze(handles.cmosData(j,i,:)),'k','LineWidth',2,'Parent',signal_scrn4) + signal_scrn4.YLabel.String = int2str([i j]); handles.M(4,:) = [i j]; case 5 - plot(handles.time,squeeze(handles.cmosData(j,i,:)),'c','LineWidth',2,'Parent',signal_scrn5) + plot(handles.time,squeeze(handles.cmosData(j,i,:)),'c','LineWidth',2,'Parent',signal_scrn5) + signal_scrn5.YLabel.String = int2str([i j]); handles.M(5,:) = [i j]; end cla currentframe = handles.frame; + currentTime = currentframe*1/handles.Fs; drawFrame(currentframe); M = handles.M; colax='bgmkc'; [a,~]=size(M); hold on @@ -255,8 +270,29 @@ function filelist_callback(source,~) handles.filename = file; end + +%% Choose data type: voltage or calcium + function TogB_data(hObject,event) + % hObject handle to togglebutton1 (see GCBO) + % eventdata reserved - to be defined in a future version of MATLAB + % handles structure with handles and user data (see GUIDATA) + + button_state = get(hObject,'Value'); + if button_state == get(hObject,'Max') % Voltage chosen + handles.dataType = 'v' + set(togbDataType, 'String', 'Voltage'); + elseif button_state == get(hObject,'Min') % Calcium chosen + handles.dataType = 'c' + set(togbDataType, 'String', 'Calcium'); + end + end + %% Load selected files in filelist function loadfile_callback(~,~) +% % Create variables for tracking file load progress +% barLoadProg = 3; +% barLoad_i = 0; +% barLoad = waitbar(barLoad_i, 'Loading video'); if isempty(handles.filename) msgbox('Warning: No data selected','Title','warn') else @@ -264,7 +300,7 @@ function loadfile_callback(~,~) cla(movie_scrn); cla(signal_scrn1); cla(signal_scrn2); cla(signal_scrn3) cla(signal_scrn4); cla(signal_scrn5); cla(sweep_bar) % Initialize handles - handles.M = []; % this handle stores the locations of the markers +% handles.M = []; % this handle stores the locations of the markers handles.normflag = 0;% this handle indicate if normalize is clicked handles.wave_window = 1;% this handle indicate the window number of the next wave displayed handles.frame = 1;% this handles indicate the current frame being displayed by the movie screen @@ -272,6 +308,10 @@ function loadfile_callback(~,~) % Check for *.mat file, if none convert filename = [handles.dir,'/',handles.filename]; +% % Update counter % progress bar +% barLoad_i = barLoad_i + 1; +% waitbar(barLoad_i/barLoadProg, barLoad, 'Loading video.'); + % Check for existence of already converted *.mat file if exist([filename(1:end-3),'mat'],'file') Data = load([filename(1:end-3),'mat']); @@ -289,13 +329,31 @@ function loadfile_callback(~,~) handles.Fs = fps; handles.bg = mean(-1.*cmosData(:,:,1:4),3); andor=1; % variable to detect if andor data is being used + elseif exist([filename(1:end-3),'tif'],'file') || exist([filename(1:end-4),'tiff'],'file') || exist([filename(1:end-6),'pcoraw'],'file') + [Data, fps, ~,~]=tifopen(filename); + if(handles.dataType == 'v') + cmosData = -1.*double(Data); % For Voltage + %cmosData = flipdim(cmosData,1); + handles.cmosData = double(cmosData(:,:,2:end)); + handles.Fs = fps; + handles.bg = mean(-1.*cmosData(:,:,1:4),3); % For Voltage + else + cmosData = double(Data); % For Calcium + %cmosData = flipdim(cmosData,1); + handles.cmosData = double(cmosData(:,:,2:end)); + handles.Fs = fps; + handles.bg = mean(cmosData(:,:,1:4),3); % For Calcium + end + andor=1; % variable to detect if andor data is being used else andor=0; CMOSconverter(handles.dir,handles.filename); Data = load([filename(1:end-3),'mat']); end - % Load data from *.mat file - + +% % Update counter % progress bar +% barLoad_i = barLoad_i + 1; +% waitbar(barLoad_i/barLoadProg, barLoad,'Loading video..'); % Check for dual camera data if isfield(Data,'cmosData2') @@ -327,6 +385,10 @@ function loadfile_callback(~,~) handles.Fs = double(Data.frequency); end +% % Update counter % progress bar +% barLoad_i = barLoad_i + 1; +% waitbar(barLoad_i/barLoadProg, barLoad,'Loading video...'); + % Save a variable to preserve the raw cmos data handles.cmosRawData = handles.cmosData; % Convert background to grayscale @@ -369,30 +431,37 @@ function loadfile_callback(~,~) % Initialize movie slider to the first frame set(movie_slider,'Value',0) drawFrame(1); + + % Delete the progress bar +% delete(barLoad) % Enable signal processing and analysis tools set([removeBG_button,bg_thresh_edit,bg_thresh_label,perc_ex_edit,... perc_ex_label,bin_button,filt_button,removeDrift_button,norm_button,... apply_button,bin_popup,filt_popup,drift_popup,play_button,anal_select,... stop_button,dispwave_button,expmov_button,starttimesig_edit,... - endtimesig_edit,expwave_button,export_button],'Enable','on') + endtimesig_edit,resettime_button,expwave_button,expwavecsv_button,export_button],'Enable','on') end end %% Select directory for optical files function selectdir_callback(~,~) -% dir_name = uigetdir; %commented out on 2017-11-29 - dir_name = '/run/media/lab/Posnack-Heart/Mapping/Dual/'; + dir_name = uigetdir; %commented out on 2017-11-29 +% dir_name = '/run/media/lab/Posnack-Heart/Mapping/Dual/'; if dir_name ~= 0 handles.dir = dir_name; search_name = [dir_name,'/*.rsh']; search_nameNew = [dir_name,'/*.gsh']; search_nameAndor = [dir_name,'/*.sif']; %adding Andor SIF support + search_nameTif = [dir_name,'/*.tif']; %adding TIF support + search_namePco = [dir_name,'/*.pcoraw']; %adding PCO support search_nameMAT = [dir_name,'/*.mat']; %adding MATLAB raw data, already converted files = struct2cell(dir(search_name)); filesNew = struct2cell(dir(search_nameNew)); filesAndor = struct2cell(dir(search_nameAndor)); + filesTif = struct2cell(dir(search_nameTif)); + filesPco = struct2cell(dir(search_namePco)); filesMAT = struct2cell(dir(search_nameMAT)); - handles.file_list = [files(1,:)'; filesNew(1,:)';filesAndor(1,:)';filesMAT(1,:)']; + handles.file_list = [files(1,:)'; filesNew(1,:)';filesAndor(1,:)';filesTif(1,:)';filesPco(1,:)';filesMAT(1,:)']; set(filelist,'String',handles.file_list) handles.filename = char(handles.file_list(1)); % enable the refresh directory and load file buttons @@ -411,7 +480,7 @@ function selectdir_callback(~,~) createmap_button,minapd_edit,minapd_text,maxapd_edit,maxapd_text,... percentapd_edit,percentapd_text,remove_motion_click,remove_motion_click_txt,... play_button,stop_button,dispwave_button,expmov_button,starttimesig_edit,... - endtimesig_edit,expwave_button,invert_cmap,export_button],'Enable','off') + endtimesig_edit,resettime_button,expwave_button,expwavecsv_button,invert_cmap,export_button],'Enable','off') end end @@ -477,6 +546,9 @@ function drawFrame(frame) end I = J .* A + G .* (1 - A); image(I,'Parent',movie_scrn); + % Show current frame's timestamp on STOP button + currentTime = frame * 1/handles.Fs; + set(stop_button,'string',num2str(currentTime)) axis('image') end @@ -551,7 +623,7 @@ function dispwave_button_callback(~,~) [c_click,r_click] = myginput(1,'circle'); c = round(c_click); r = round(r_click); % c=X/width/Columns, r=Y/height/Rows - %make sure pixel selected is within movie_scrn + % ensure pixel selected is within movie_scrn if c_click>size(handles.cmosData,2) || r_click>size(handles.cmosData,1) || c_click<=1 || r_click<=1 % tell user to pick new pixel msgbox('Warning: Pixel Selection out of Boundary','Title','help') @@ -565,22 +637,33 @@ function dispwave_button_callback(~,~) handles.wave_window = 1; end wave_window = handles.wave_window; + % Show pixel location on Y axis of each wave window switch wave_window case 1 plot(handles.time,squeeze(handles.cmosData(r,c,:)),'b','LineWidth',2,'Parent',signal_scrn1) handles.M(1,:) = [c r]; + signal_scrn1.YLabel.FontSize = 8; + signal_scrn1.YLabel.String = int2str([c r]); case 2 plot(handles.time,squeeze(handles.cmosData(r,c,:)),'g','LineWidth',2,'Parent',signal_scrn2) handles.M(2,:) = [c r]; + signal_scrn2.YLabel.FontSize = 8; + signal_scrn2.YLabel.String = int2str([c r]); case 3 plot(handles.time,squeeze(handles.cmosData(r,c,:)),'m','LineWidth',2,'Parent',signal_scrn3) handles.M(3,:) = [c r]; + signal_scrn3.YLabel.FontSize = 8; + signal_scrn3.YLabel.String = int2str([c r]); case 4 plot(handles.time,squeeze(handles.cmosData(r,c,:)),'k','LineWidth',2,'Parent',signal_scrn4) handles.M(4,:) = [c r]; + signal_scrn4.YLabel.FontSize = 8; + signal_scrn4.YLabel.String = int2str([c r]); case 5 plot(handles.time,squeeze(handles.cmosData(r,c,:)),'c','LineWidth',2,'Parent',signal_scrn5) handles.M(5,:) = [c r]; + signal_scrn5.YLabel.FontSize = 8; + signal_scrn5.YLabel.String = int2str([c r]); end end handles.wave_window = wave_window + 1; % Dial up the wave window count @@ -597,97 +680,102 @@ function dispwave_button_callback(~,~) hold off end -%% Export movie to .avi file +%% Export movie to .mp4 file %Construct a VideoWriter object and view its properties. Set the frame rate to 60 frames per second: - function expmov_button_callback(~,~) - % Save the movie to the same directory as the cmos data - % Request the directory for saving the file - dir = uigetdir; - % If the cancel button is selected cancel the function - if dir == 0 - return - end - % Request the desired name for the movie file - filename = inputdlg('Enter Filename:'); - filename = char(filename); - % Check to make sure a value was entered - if isempty(filename) - error = 'A filename must be entered! Function cancelled.'; - msgbox(error,'Incorrect Input','Error'); - return - end - filename = char(filename); - % Create path to file - movname = [handles.dir,'/',filename,'.avi']; - % Create the figure to be filmed - fig=figure('Name',filename,'NextPlot','replacechildren','NumberTitle','off',... - 'Visible','off','OuterPosition',[170, 140, 556,715]); - % Start writing the video - vidObj = VideoWriter(movname,'Motion JPEG AVI'); - open(vidObj); - movegui(fig,'center') - set(fig,'Visible','on') - axis tight - set(gca,'nextplot','replacechildren'); - % Designate the step of based on the frequency - - % Creat pop up screen; the start time and end time are determined - % by the windowing of the signals on the Rhythm GUI interface - - % Grab start and stop time times and convert to index values by - % multiplying by frequency, add one to shift from zero - start = str2double(get(starttimesig_edit,'String'))*handles.Fs+1; - fin = str2double(get(endtimesig_edit,'String'))*handles.Fs+1; - % Designate the resolution of the video: ex. 5 = every fifth frame - step = 5; - for i = start:step:fin - % Plot sweep bar on bottom subplot - subplot('Position',[0.05, 0.1, 0.9,0.15]) - a = [handles.time(i) handles.time(i)]; - %b = [min(handles.ecg) max(handles.ecg)]; - squeeze1=squeeze(handles.cmosData(64,64,:)); - b=[min(squeeze1) max(squeeze1)]; - cla - plot(a,b,'r','LineWidth',1.5);hold on - % Plot ecg data on bottom subplot - subplot('Position',[0.05, 0.1, 0.9,0.15]) - % Create a variable for the endtime index - endtime = round(handles.endtime*handles.Fs); - % Plot the desired - plot(handles.time(start:endtime),squeeze1(1:end-1)); - % +function expmov_button_callback(~,~) + % Save the movie to the same directory as the cmos data + % Request the directory for saving the file + dir = uigetdir; + % If the cancel button is selected cancel the function + if dir == 0 + return + end + % Request the desired name for the movie file + filename = inputdlg('Enter Filename:', 'Movie Filename', [1 60]); + filename = char(filename); + % Check to make sure a value was entered + if isempty(filename) + error = 'A filename must be entered! Function cancelled.'; + msgbox(error,'Incorrect Input','Error'); + return + end + filename = char(filename); + % Create path to file + movname = [handles.dir,'/',filename]; + % Get dimensions of video + width = size(handles.cmosData(:,:,1), 1); + height = size(handles.cmosData(:,:,1), 2); + % Create the figure to be filmed + fig=figure('Name',filename,'NextPlot','replacechildren','NumberTitle','off',... + 'Visible','off','OuterPosition',[0, 0, width, height]); + % Start writing the video, format as a .mp4 + vidObj = VideoWriter(movname,'Uncompressed AVI'); + open(vidObj); + movegui(fig,'center') + set(fig,'Visible','on') + axis tight + set(gca,'nextplot','replacechildren'); + % Designate the step of based on the frequency + + % Creat pop up screen; the start time and end time are determined + % by the windowing of the signals on the Rhythm GUI interface + + % Grab start and stop time times and convert to index values by + % multiplying by frequency, add one to shift from zero + start = str2double(get(starttimesig_edit,'String'))*handles.Fs+1; + fin = str2double(get(endtimesig_edit,'String'))*handles.Fs+1; + % Designate the resolution of the video: ex. 5 = every fifth frame + step = 5; + for i = start:step:fin + % Plot sweep bar on bottom subplot +% subplot('Position',[0.05, 0.1, 0.9,0.15]) +% a = [handles.time(i) handles.time(i)]; + %b = [min(handles.ecg) max(handles.ecg)]; +% squeeze1=squeeze(handles.cmosData(64,64,:)); +% b=[min(squeeze1) max(squeeze1)]; + cla +% plot(a,b,'r','LineWidth',1.5);hold on + % Plot ecg data on bottom subplot +% subplot('Position',[0.05, 0.1, 0.9,0.15]) + % Create a variable for the endtime index +% endtime = round(handles.endtime*handles.Fs); + % Plot the desired +% plot(handles.time(start:endtime),squeeze1(1:end-1)); + % % axis([handles.time(start) round(handles.time(fin)) floor(min(squeeze1)) floor(max(squeeze1))]) - % Set the xick mark to start from zero - xlabel('Time (sec)');hold on - % Image movie frames on the top subplot - subplot('Position',[0.05, 0.28, 0.9,0.68]) - % Update image - G = handles.bgRGB; - Mframe = handles.cmosData(:,:,i); - if handles.normflag == 0 - Mmax = handles.matrixMax; - Mmin = handles.minVisible; - numcol = size(jet,1); - J = ind2rgb(round((Mframe - Mmin) ./ (Mmax - Mmin) * (numcol - 1)), jet); - A = real2rgb(Mframe >= handles.minVisible, 'gray'); - else - J = real2rgb(Mframe, 'jet'); - A = real2rgb(Mframe >= handles.normalizeMinVisible, 'gray'); - end - - I = J .* A + G .* (1 - A); - image(I); - axis off; hold off - F = getframe(fig); - writeVideo(vidObj,F);% Write each frame to the file. + % Set the xick mark to start from zero +% xlabel('Time (sec)');hold on + % Image movie frames on the top subplot +% subplot('Position',[0.05, 0.28, 0.9,0.68]) + subplot('Position',[0, 0, 1, 1]) + + % Update image + G = handles.bgRGB; + Mframe = handles.cmosData(:,:,i); + if handles.normflag == 0 + Mmax = handles.matrixMax; + Mmin = handles.minVisible; + numcol = size(jet,1); + J = ind2rgb(round((Mframe - Mmin) ./ (Mmax - Mmin) * (numcol - 1)), jet); + A = real2rgb(Mframe >= handles.minVisible, 'gray'); + else + J = real2rgb(Mframe, 'jet'); + A = real2rgb(Mframe >= handles.normalizeMinVisible, 'gray'); end - close(fig); - close(vidObj); % Close the file. + + I = J .* A + G .* (1 - A); + imshow(I, 'InitialMagnification', 100); + axis off; hold off + F = getframe(fig); + writeVideo(vidObj,F);% Write each frame to the file. end -% -% %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% -% %% SIGNAL SCREENS -% %% Start Time Editable Textbox for Signal Screens + close(fig); + close(vidObj); % Close the file. +end + + +%% SIGNAL SCREENS +% Start Time Editable Textbox for Signal Screens function starttimesig_edit_callback(source,~) %get the val01 (lower limit) and val02 (upper limit) plot values val01 = str2double(get(source,'String')); @@ -728,6 +816,32 @@ function endtimesig_edit_callback(source,~) handles.endtime = val02; end + +%% Reset time range for Signal Screens + function resettime_button_callback(~,~) + timeStep = 1/handles.Fs; + handles.time = 0:timeStep:size(handles.cmosData,3)*timeStep-timeStep; + set(signal_scrn1,'XLim',[min(handles.time) max(handles.time)]) + set(signal_scrn1,'NextPlot','replacechildren') + set(signal_scrn2,'XLim',[min(handles.time) max(handles.time)]) + set(signal_scrn2,'NextPlot','replacechildren') + set(signal_scrn3,'XLim',[min(handles.time) max(handles.time)]) + set(signal_scrn3,'NextPlot','replacechildren') + set(signal_scrn4,'XLim',[min(handles.time) max(handles.time)]) + set(signal_scrn4,'NextPlot','replacechildren') + set(signal_scrn5,'XLim',[min(handles.time) max(handles.time)]) + set(signal_scrn5,'NextPlot','replacechildren') + set(sweep_bar,'XLim',[min(handles.time) max(handles.time)]) + set(sweep_bar,'NextPlot','replacechildren') + % Fill times into activation map editable textboxes + handles.starttime = 0; + handles.endtime = max(handles.time); + set(starttimesig_edit,'String',num2str(handles.starttime)) + set(endtimesig_edit,'String',num2str(handles.endtime)) + set(starttimemap_edit,'String',num2str(handles.starttime)) + set(endtimemap_edit,'String',num2str(handles.endtime)) + end + %% Export signal waves to new screen function expwave_button_callback(~,~) M = handles.M; colax='bgmkc'; [a,~]=size(M); @@ -747,12 +861,62 @@ function expwave_button_callback(~,~) end end xlabel('Time (sec)') + xtick() hold off movegui(w,'center') set(w,'Visible','on') end end +%% Export signal waves to CSV + function expwavecsv_button_callback(~,~) + % Modeled after aMap function + % aMap(handles.cmosData,handles.a_start,handles.a_end,handles.Fs,handles.bg,handles.cmap,handles.filename,handles.dir); + M = handles.M; [a,~]=size(M); + + if isempty(M) + msgbox('No wave to export. Please use "Display Wave" button to select pixels on movie screen.','Icon','help') + else + % User prompt for input to create csv + prompt1 = {'Save current signal waves as CSV?'}; + dlg_title1 = 'Save signal CSV'; + num_lines1 = [1 60]; + % Uses directory chosen for image sources + direc=handles.dir; + file = strtok(handles.filename,'.'); % Get filename without extension + handleXY = strsplit(int2str(handles.M(1,:)), ' '); + file = strcat(file,'x',handleXY(1),'y',handleXY(2)); % Add marker coordinate to filename + def1 = strcat(direc,'/Signals/',file,'.csv'); + answer = inputdlg(prompt1,dlg_title1,num_lines1,def1); + % process user inputs + if isempty(answer) % cancel save if user clicks "cancel" + return + end + filename = answer{1}; + filenameTemp = strsplit(filename,'.'); + % Get time series (in sec) + t = flip(rot90(handles.starttime:1/handles.Fs:handles.endtime)) + % Get frames of interest + startp = max([round(handles.starttime*handles.Fs) 1]); % 1st frame minimum + endp = round(handles.endtime*handles.Fs); + % Add time and signal arrays into a final 2-D array + signalData = squeeze(handles.cmosData(M(1,2),M(1,1),startp:endp-1)); + time = t(1:length(signalData)); + csvData = [time signalData]; + + % create the Signals folder if it doesn't exist already. + newSubFolder = strcat(direc,'/Signals/'); + if ~exist(newSubFolder, 'dir') + mkdir(newSubFolder); + end + csvwrite(filename,csvData); +% for x = 1:a +% % write each plot's data over the time series +% % TODO correct so every signal is writen to a column +% end + + end + end %%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%% %% CONDITION SIGNALS %% Condition Signals Selection Change Callback @@ -788,10 +952,14 @@ function cond_sig_selcbk(~,~) % Update counter % progress bar counter = counter + 1; waitbar(counter/trackProg,g1,'Binning Data'); - if bin_pop_state == 4 + if bin_pop_state == 6 bin_size = 45; - elseif bin_pop_state == 3 + elseif bin_pop_state == 5 bin_size = 15; + elseif bin_pop_state == 4 + bin_size = 9; + elseif bin_pop_state == 3 + bin_size = 7; elseif bin_pop_state == 2 bin_size = 5; else @@ -1016,7 +1184,7 @@ function createmap_button_callback(~,~) if check == 2 gg=msgbox('Building Activation Map...'); % Activation map function - aMap(handles.cmosData,handles.a_start,handles.a_end,handles.Fs,handles.bg,handles.cmap,handles.filename); + aMap(handles.cmosData,handles.a_start,handles.a_end,handles.Fs,handles.bg,handles.cmap,handles.filename,handles.dir); close(gg) % FOR CONDUCTION VELOCITY elseif check == 3 @@ -1028,7 +1196,7 @@ function createmap_button_callback(~,~) elseif check == 4 gg=msgbox('Creating Global APD Map...'); handles.percentAPD = str2double(get(percentapd_edit,'String')); - apdMap(handles.cmosData,handles.a_start,handles.a_end,handles.Fs,handles.percentAPD,handles.cmap); + apdMap(handles.cmosData,handles.a_start,handles.a_end,handles.Fs,handles.percentAPD,handles.cmap, handles.filename,handles.dir); close(gg) % FOR PHASE MAP CALCULATION elseif check == 5 diff --git a/sifopen.m b/sifopen.m deleted file mode 100644 index e420715..0000000 --- a/sifopen.m +++ /dev/null @@ -1,145 +0,0 @@ -function [ret, data3, fps, gain,fname,pname]=sifopen(source) -%{ -[ret, data, fps, gain]=sifopen99(source) where source is a string -containing Andor .sif file name ret will return 0 if error occurs, 1 if ok. -Data is a 3D image array across time. FPS is the 1/KineticCycleTime, and -gain is the pre-amplifier gain. -IMPORTANT DEPENDENCIES: -atsifiomex.mexw32 with ATSIFIO.dll -OR -atsifiomex.mexw64 with ATSIFIO64.dll -IN YOUR SET DIRECTORY FOR 32-BIT AND 64-BIT VERSIONS OF MATLAB RESPECTIVELY - -Rafael Jaimes III 2012-11-11 ver 1.0 -2017-01-02 ver 1.1 -- modified for GUI file selection and implementation -with camat GUI. -%} -addpath(genpath('dependencies')) - -switch nargin - case 0 % source was unspecified - [fname,pname]=uigetfile({'*.sif'; '*.sifx'},'Select an Andor file','/run/media/lab/Posnack_Lab_Lang/Lang/RH237/'); - source=[pname,fname]; - case 1 % file source was specified - fname=[]; % these output vars will be blank, since source was specified - pname=[]; % these output vars will be blank, since source was specified -end - - -rc=atsif_setfileaccessmode(0); -rc=atsif_readfromfile(source); -if (rc == 22002) -% disp('SIF File Found') - [~,loaded] = atsif_isloaded(); - if loaded - %signal=0, ref=1, backgd=2, 3=source, 4=live; - signal=0; - [~,present]=atsif_isdatasourcepresent(signal); - if present - ret=1; - [~,KCT]=atsif_getpropertyvalue(signal,'KineticCycleTime'); - fps=1/str2double(KCT); - [~,noframes]=atsif_getnumberframes(signal); - [~,size]=atsif_getframesize(signal); - [~,data]=atsif_getallframes(signal, size*noframes); - [~,left,bottom,right,top,hBin,vBin]=atsif_getsubimageinfo(signal,0); - [~,sgain]=atsif_getpropertyvalue(signal,'PreAmplifierGain'); - gain=str2double(sgain); - end - data3=reshape(data, (right-left+1)/hBin, (top-bottom+1)/vBin, noframes); - end - atsif_closefile(); -end - -if (rc == 22003) - disp('SIF Format Error') -end - -if (rc == 22004) - disp('SIF Not Loaded') -end - -if (rc == 22005) - disp('SIF File Not Found') -end - -if (rc == 22006) - disp('SIF File Not Found') -end - -if (rc == 22007) - disp('SIF File Empty') -end - -end -%{ -Defining the 16 functions from the Andor SDK here. -Not all of these functions may be used in this version of sifopen, but they -are here for future implementation. -YOU NEED TO HAVE atsifiomex.mexw32 OR atsifiomex.mexw64 IN YOUR DIRECTORY -FOR 32-BIT AND 64-BIT VERSIONS OF MATLAB RESPECTIVELY -%} - -function ret=atsif_closefile() -ret = atsifiomex('ATSIF_CloseFile'); -end - -function [ret,data]=atsif_getallframes(source, size) -[ret,data] = atsifiomex('ATSIF_GetAllFrames', source, size); -end - -function [ret,startPos]=atsif_getdatastartbyteposition(source) -[ret,startPos] = atsifiomex('ATSIF_GetDataStartBytePosition', source); -end - -function [ret,data]=atsif_getframe(source, index, size) -[ret,data] = atsifiomex('ATSIF_GetFrame', source, index, size); -end - -function [ret,size]=atsif_getframesize(source) -[ret,size] = atsifiomex('ATSIF_GetFrameSize', source); -end - -function [ret,noframes]=atsif_getnumberframes(source) -[ret,noframes] = atsifiomex('ATSIF_GetNumberFrames', source); -end - -function [ret,nosubImgs]=atsif_getnumbersubimages(source) -[ret,nosubImgs] = atsifiomex('ATSIF_GetNumberSubImages', source); -end - -function [ret,calVal]=atsif_getpixelcalibration(source, axis, pixel) -[ret,calVal] = atsifiomex('ATSIF_GetPixelCalibration', source, axis, pixel); -end - -function [ret,type]=atsif_getpropertytype(source, propName) -[ret,propType] = atsifiomex('ATSIF_GetPropertyType', source, propName); -end - -function [ret,propVal]=atsif_getpropertyvalue(source, propName) -[ret,propVal] = atsifiomex('ATSIF_GetPropertyValue', source, propName); -end - -function [ret,versH,versL]=atsif_getstructureversion(element) -[ret,versH,versL] = atsifiomex('ATSIF_GetStructureVersion', element); -end - -function [ret,left,bottom,right,top,hBin,vBin]=atsif_getsubimageinfo(source, index) -[ret,left,bottom,right,top,hBin,vBin] = atsifiomex('ATSIF_GetSubImageInfo', source, index); -end - -function [ret,present]=atsif_isdatasourcepresent(source) -[ret,present] = atsifiomex('ATSIF_IsDataSourcePresent', source); -end - -function ret=atsif_readfromfile(filename) -ret = atsifiomex('ATSIF_ReadFromFile',filename); -end - -function ret=atsif_setfileaccessmode(readmode) -ret = atsifiomex('ATSIF_SetFileAccessMode',readmode); -end - -function [ret,loaded] = atsif_isloaded() -[ret,loaded] = atsifiomex('ATSIF_IsLoaded'); -end \ No newline at end of file diff --git a/stackin b/stackin new file mode 160000 index 0000000..4913de3 --- /dev/null +++ b/stackin @@ -0,0 +1 @@ +Subproject commit 4913de3d106b7c377f06a13d471c9d2a84cb69d7