cosmo map2fmri

function hdr=cosmo_map2fmri(dataset, fn, varargin)
% maps a dataset structure to a NIFTI, AFNI, or BV structure or file
%
% Usage 1: hdr=cosmo_map2fmri(dataset, '-{FMT}) returns a header structure
% Usage 2: cosmo_map2fmri(dataset, fn) saves dataset to a volumetric file.
%
% In Usage 1, {FMT} can be one of 'nii','bv_vmp',bv_vmr','bv_msk','afni'.
% In Usage 2, fn should end with '.nii.gz', '.nii', '.hdr', '.img', '.vmp',
%             '.vmr', '.msk', '+orig','+orig.HEAD','+orig.BRIK',
%             '+orig.BRIK.gz','+tlrc','+tlrc.HEAD','+tlrc.BRIK', or
%             '+tlrc.BRIK.gz'.
%
% Use cosmo_map2fmri(...,'deoblique',true) to de-oblique the dataset. For
% information on this option, see cosmo_fmri_deoblique
%
% - for NIFTI files, it requires the following toolbox:
%   http://www.mathworks.com/matlabcentral/fileexchange/8797-tools-for-nifti-and-analyze-image
%   (note that his toolbox is included in CoSMoMVPA in /externals)
% - for Brainvoyager files (.vmp and .vtc), it requires the NeuroElf
%   toolbox, available from: http://neuroelf.net
% - for AFNI files (+{orig,tlrc}.{HEAD,BRIK[.gz]}) it requires the AFNI
%   Matlab toolbox, available from: http://afni.nimh.nih.gov/afni/matlab/
%
% See also: cosmo_fmri_deoblique
%
% #   For CoSMoMVPA's copyright information and license terms,   #
% #   see the COPYING file distributed with CoSMoMVPA.           #

    defaults=struct();
    defaults.deoblique=false;
    defaults.bv_force_fit=[];
    opt=cosmo_structjoin(defaults,varargin{:});

    cosmo_check_dataset(dataset, 'fmri');

    img_formats=get_img_formats();
    sp=cosmo_strsplit(fn,'-');
    save_to_file=~isempty(sp{1});

    if save_to_file
        fmt=get_format(img_formats, fn);
    else
        if numel(sp)~=2
            error('expected -{FORMAT}');
        end
        fmt=sp{2};
    end

    if ~isfield(img_formats,fmt)
        error('Unsupported format %s', fmt);
    end

    methods=img_formats.(fmt);
    externals=methods.externals;
    cosmo_check_external(externals);

    creator=methods.creator;

    % preprocess the data (de-oblique if necessary)
    dataset=preprocess(dataset,opt);

    % build header structure
    hdr=creator(dataset);

    if nargout==0
        cleaner=onCleanup(get_cleaner(methods,hdr));
    end

    if save_to_file
        writer=methods.writer;
        writer(fn, hdr);
    end



%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% general helper functions
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
function f=get_cleaner(methods,hdr)
    if isfield(methods,'cleaner')
        f=@()methods.cleaner(hdr);
    else
        f=@do_nothing;
    end

function do_nothing
    % do nothing

function ds=preprocess(ds,opt)
    if opt.deoblique
        ds=cosmo_fmri_deoblique(ds);
    end

    if opt.bv_force_fit
        ds=bv_force_fit(ds);
    end

function ds=bv_force_fit(ds)

    mat=ds.a.vol.mat;

    dim=[ds.a.vol.dim(:);1];
    rot=mat(1:3,1:3);
    vox_size=sqrt(sum(rot.^2,1)); %.*sign(sum(rot,1));
    resolution=prod(vox_size).^(1/3);

    % put the center defined by the original matrix close to the center
    % defined by the new matrix
    old_center=mat*(dim+1)/2;
    mat(1:3,1:3)=bsxfun(@times,mat(1:3,1:3),resolution./vox_size);

    new_center=mat*(dim+1)/2;
    mat(:,4)=round(mat(:,4)+old_center-new_center)+.5;

    ds.a.vol.mat=mat;



function check_plump_orientation(ds)
    rot=ds.a.vol.mat(1:3,1:3);
    nonzero=rot~=0;
    is_oblique=any(sum(nonzero,1)~=1) || any(sum(nonzero,2)~=1);
    if is_oblique
        error(['Dataset has oblique orientation, and the specified '...
                'output format does not support this. Options are:\n\n'...
                '(1) use %s(...,''deoblique'',true) to deoblique the '...
                'dataset automatically when writing it\n'...
                '(2) use cosmo_fmri_deoblique to deoblique the dataset '...
                'manually\n'...
                '(3) store the dataset in another file format (such as '...
                'NIFTI)\n\n'...
                'For options (1) and (2), the spatial location of the '...
                'voxels will change. It is recommended to inspect the '...
                'results visually when using one of these options.'],...
                mfilename());
    end

function check_isotropic_voxels(ds)
    rot=ds.a.vol.mat(1:3,1:3);
    vox_size=sqrt(sum(rot.^2,1));

    max_delta=1e-5;
    delta=max(vox_size)-min(vox_size);

    if delta>max_delta
        error(['Dataset has non-isotropic voxels, and the specified '...
                'output format does not support this. Options are:\n\n'...
                '(1) use %s(...,''bv_force_fit'',true) to set the '...
                'voxel size to be isotropic\n'...
                '(2) store the dataset in another file format (such as '...
                'NIFTI)\n\n'...
                'For option (1), the spatial location of the '...
                'voxels will change. It is recommended to inspect the '...
                'results visually when using one of these options.'],...
                mfilename());
    end




function unfl_ds_timelast=unflatten(ds)
    % puts the time dimension last, instead of first
    unfl_ds=cosmo_unflatten(ds);

    % make time dimension last instead of first
    unfl_ds_timelast=shiftdim(unfl_ds,1);

    % deal with singleton dimension
    sz=size(unfl_ds);
    dim_size=ones(1,4);
    dim_size(4)=sz(1);
    dim_size(1:(numel(sz)-1))=sz(2:end);

    nsamples=size(ds.samples,1);
    assert(isequal([ds.a.vol.dim nsamples],dim_size));

    if ~isequal(size(unfl_ds_timelast),dim_size)
        unfl_ds_timelast=reshape(unfl_ds_timelast,dim_size);
    end



function b=ends_with(end_str, str)
    if iscell(end_str)
        b=any(cellfun(@(x) ends_with(x,str),end_str));
    else
        b=isempty(cosmo_strsplit(str, end_str,-1));
    end

function fmt=get_format(img_formats, file_name)
    fns=fieldnames(img_formats);
    fmt=[];
    for k=1:numel(fns)
        fn=fns{k};
        exts=img_formats.(fn).exts;
        if ends_with(exts, file_name)
            fmt=fn;
        end
    end
    if isempty(fmt)
        error('Not found: format for %s', file_name);
    end

function img_formats=get_img_formats()
    img_formats=struct();

    img_formats.nii.creator=@new_nii;
    img_formats.nii.writer=@write_nii;
    img_formats.nii.externals={'nifti'};
    img_formats.nii.exts={'.nii','.nii.gz','.hdr','.img'};

    img_formats.bv_vmp.creator=@new_bv_vmp;
    img_formats.bv_vmp.writer=@write_bv;
    img_formats.bv_vmp.cleaner=@clean_bv;
    img_formats.bv_vmp.externals={'neuroelf'};
    img_formats.bv_vmp.exts={'.vmp'};

    img_formats.bv_vmr.creator=@new_bv_vmr;
    img_formats.bv_vmr.writer=@write_bv;
    img_formats.bv_vmr.cleaner=@clean_bv;
    img_formats.bv_vmr.externals={'neuroelf'};
    img_formats.bv_vmr.exts={'.vmr'};

    img_formats.bv_msk.creator=@new_bv_msk;
    img_formats.bv_msk.writer=@write_bv;
    img_formats.bv_msk.cleaner=@clean_bv;
    img_formats.bv_msk.externals={'neuroelf'};
    img_formats.bv_msk.exts={'.msk'};

    img_formats.afni.creator=@new_afni;
    img_formats.afni.writer=@write_afni;
    img_formats.afni.externals={'afni'};
    img_formats.afni.exts={'+orig','+orig.HEAD','+orig.BRIK',...
                           '+orig.BRIK.gz','+tlrc','+tlrc.HEAD',...
                           '+tlrc.BRIK','+tlrc.BRIK.gz'};


%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%
% format-specific helper functions
%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%

%% Nifti

function ni=new_nii(ds)
    a=ds.a;
    vol=a.vol;
    vol_data=unflatten(ds);
    dim_all=[4 ones(1,7)];
    vol_dim=size(vol_data);
    nvol_dim=numel(vol_dim);
    dim_all(1+(1:nvol_dim))=vol_dim;

    mat=vol.mat;
    mat(1:3,4)=mat(1:3,4)+mat(1:3,1:3)*[1 1 1]';
    pix_dim=sqrt(sum(mat(1:3,1:3).^2,1));
    hdr=struct();

    dime=struct();
    dime.datatype=16; %single
    dime.dim=dim_all;
    dime.pixdim=[0 abs(pix_dim(:))' 0 0 0 0]; % ensure positive values

    fns={'intent_p1','intent_p2','intent_p3','intent_code',...
        'slice_start','slice_duration','slice_end',...
        'scl_slope','scl_inter','slice_code','cal_max',...
        'cal_min','toffset'};

    dime=set_all(dime,fns);
    dime=cosmo_structjoin(dime, cosmo_statcode(ds,'nifti'));
    dime.xyzt_units=10;
    hdr.dime=dime;

    hk=struct();
    hk.sizeof_hdr=348;
    hk.data_type='';
    hk.db_name='';
    hk.extents=0;
    hk.session_error=0;
    hk.regular='r';
    hk.dim_info=0;
    hdr.hk=hk;

    hist=struct();

    if isfield(ds.a.vol,'xform')
        xform=ds.a.vol.xform;
    else
        xform=''; % unknown
    end
    xform_code=cosmo_fmri_convert_xform('nii',xform);

    hist.sform_code=xform_code;
    % hist.originator=[1 1 1 1 0];
    hist=set_all(hist,{'descrip','aux_file','intent_name'},'');
    hist=set_all(hist,{'qform_code','quatern_b',...
                        'quatern_d',...
                        'qoffset_x','qoffset_y','qoffset_z'});
    hist=set_all(hist,{'intent_name'},'');
    hist.srow_x=mat(1,:);
    hist.srow_y=mat(2,:);
    hist.srow_z=mat(3,:);
    hist.quatern_c=0;

    % required to avoid complaints with hdr/img
    hist.originator=round(dim_all(2:5)/2);
    hdr.hist=hist;

    ni.img=single(vol_data);
    ni.hdr=hdr;

function write_nii(fn, hdr)
    save_nii(hdr, fn);


    %% BrainVoyager helper
function mat=neuroelf_bvcoordconv_wrapper(varargin)
    % converts BV bounding box to affine transformation matrix
    % helper functions that deals with both new neuroelf (version 1.0)
    % and older versions.
    % the old version provides a 'bvcoordconv' .m file
    % the new version privides this function in the neuroelf class
    has_bvcoordconv=~isempty(which('bvcoordconv'));

    % set function handle
    if has_bvcoordconv
        f=@bvcoordconv;
    else
        n=neuroelf();
        f=@n.bvcoordconv;
    end

    mat=f(varargin{:});

function result=neuroelf_bless_wrapper(arg)
    % deals with recent neuroelf (>v1.1), where bless is deprecated
    s=warning('off','neuroelf:xff:deprecated');
    resetter=onCleanup(@()warning(s));

    result=bless(arg);

    %% Brainvoyager VMP
function [hdr,ds]=new_bv_mat_hdr(ds,bv_type)
    % ensure dataset is plump
    check_plump_orientation(ds);

    if ~strcmp(bv_type,'vmr')
        % require voxels to be isotropic
        check_isotropic_voxels(ds);
    end

    % automatically set orientation to ASR
    if ~strcmp(cosmo_fmri_orientation(ds),'ASR')
        ds=cosmo_fmri_reorient(ds,'ASR');
    end

    % helper to set matrix in header
    mat=ds.a.vol.mat;

    % ensure proper VMP/VMR orientation
    rot_asr=mat(1:3,1:3)*[0 0 -1; -1 0 0; 0 -1 0]';

    if ~isequal(rot_asr,diag(diag(rot_asr)))
        % should never get here because of the ASR orientation
        % forced earlier
        error('Unsupported orientation: need ASR');
    end

    hdr=xff(['new:' bv_type]);
    hdr=neuroelf_bless_wrapper(hdr);

    % Set {X,Y,Z}{Start,End} values based on the transformation matrix
    tal_coords=mat*[1 1 1 1; ds.a.vol.dim+1, 1]';
    bv_coords=neuroelf_bvcoordconv_wrapper(tal_coords(1:3,:), ...
                                        'tal2bvs',hdr.BoundingBox);

    switch bv_type
        case {'vmp','msk'}
            dg=diag(rot_asr);
            resolution=prod(dg)^(1/3);

            labels={'ZStart','ZEnd','XStart','XEnd','YStart','YEnd'};
            for k=1:numel(labels)
                label=labels{k};
                hdr.(label)=bv_coords(k);
            end

            hdr.Resolution=resolution;

        case 'vmr'
            % this *should* be a 256^3 volume
            % XXX check for this?
            labels={'X','Y','Z'};
            dg=diag(rot_asr);
            for k=1:3
                label=labels{k};
                hdr.(['VoXRes' label])=dg(k);
                hdr.(['Dim' label])=round(bv_coords(2,k));
            end

        otherwise
            error('Unsupported type %s', bv_type);
    end

function hdr=new_bv_vmp(ds)
    [hdr,ds]=new_bv_mat_hdr(ds,'vmp');

    % Get data
    nsamples=size(ds.samples,1);
    maps=cell(1,nsamples);

    stats=cosmo_statcode(ds,'bv');

    for k=1:nsamples
        empty_hdr=xff('new:vmp');
        map=empty_hdr.Map;
        ds_k=cosmo_slice(ds,k);
        map.VMPData=unflatten(ds_k);
        if cosmo_isfield(ds_k,'sa.labels')
            map.Name=[ds_k.sa.labels{:}];
        end
        if ~isempty(stats)
            stats_k=stats{k};

            stats_k_fieldnames=fieldnames(stats_k);
            for j=1:numel(stats_k_fieldnames)
                key=stats_k_fieldnames{j};
                map.(key)=stats_k.(key);
            end
        end
        maps{k}=map;
        empty_hdr.ClearObject();
    end

    hdr.Map=cat(2,maps{:});
    hdr.NrOfMaps=nsamples;

    neuroelf_bless_wrapper(hdr);

function write_bv(fn, hdr)
    % general storage function
    hdr.SaveAs(fn);

function clean_bv(hdr)
    hdr.ClearObject();

    %% Brainvoyager VMR
function hdr=new_bv_vmr(ds)
    nsamples=size(ds.samples,1);
    if nsamples~=1,
        error('Unsupported: more than 1 sample');
    end

    [hdr,ds]=new_bv_mat_hdr(ds,'vmr');

    % scale to 0..255
    vol_data=unflatten(ds);
    hdr.VMRData=scale_uint8(vol_data(:,:,:,1));

    %% Brainvoyager mask
function hdr=new_bv_msk(ds)
    nsamples=size(ds.samples,1);
    if nsamples~=1,
        error('Unsupported: more than 1 sample');
    end

    [hdr,ds]=new_bv_mat_hdr(ds,'msk');

    vol_data=unflatten(ds);
    hdr.Mask=scale_uint8(vol_data);

function scaled_data=scale_uint8(data)
    mn=min(data(:));
    mx=max(data(:));
    if mn<0 || mx>255 || ~isequal(round(data),data)
        cosmo_warning(['.samples does not fit integer range 0..255 ',...
                            'data will be rescaled']);
        data=255*(data-mn)/(mx-mn);
    end

    scaled_data=uint8(data);



%% AFNI
function afni_info=new_afni(ds)
    check_plump_orientation(ds);

    a=ds.a;
    mat=a.vol.mat;

    % deal with orientation
    idxs=zeros(1,3);
    m=zeros(1,3);

    % this part should be necessary
    for k=1:3
        % for each spatial dimension, find which row transforms it
        idx=find(mat(1:3,k));
        assert(numel(idx)==1); % ds is plump

        % store index and transformation matrix
        idxs(k)=idx;
        m(k)=mat(idx,k);
    end

    % set voxel size and origin
    % as the header was stored in LPI but AFNI likes RAI,
    % convert coordinates to RAI
    lpi2rai=[-1 -1 1];

    delta=m.*lpi2rai(idxs);
    origin=mat(idxs,:)*[1 1 1 1]'.*lpi2rai(idxs)';

    % set orientation code.
    % thse are neither RAI or LPI, but RPI (when ordered logically)
    % No idea why Bob Cox made that decision.
    lpi2orient=[-1 1 1];
    offset=(1-sign(m).*lpi2orient(idxs))/2;
    orient=(idxs-1)*2+offset;

    [nsamples,nfeatures]=size(ds.samples);

    vol_data=unflatten(ds);
    dim=ds.a.vol.dim(:)';
    vol_dim=[size(vol_data) 1]; % in case of singleton in last dimension
    assert(isequal(vol_dim(1:3),dim));
    assert(prod(dim)>=nfeatures);


    brik_type=1; %functional head
    brik_typestring='3DIM_HEAD_FUNC';
    brik_func=11; % FUNC_BUCK_TYPE

    if isfield(ds.a.vol,'xform')
        xform=ds.a.vol.xform;
    else
        xform=''; % unknown
    end
    xform_code=cosmo_fmri_convert_xform('afni',xform);

    brik_view=xform_code; % default to +orig, but overriden by writer

    afni_info=struct();
    afni_info.SCENE_DATA=[brik_view, brik_func, brik_type];
    afni_info.TYPESTRING=brik_typestring;
    afni_info.BRICK_TYPES=3*ones(1,nsamples); % store in float format
    afni_info.BRICK_STATS=[];                 % ... and thus need no stats
    afni_info.BRICK_FLOAT_FACS=[];            % ... or multipliers
    afni_info.DATASET_RANK=[3 nsamples];      % number of volumes
    afni_info.DATASET_DIMENSIONS=dim(1:3);
    afni_info.ORIENT_SPECIFIC=orient;
    afni_info.DELTA=delta(:)';
    afni_info.ORIGIN=origin(:)';
    afni_info.SCALE=0;
    afni_info.NOTES_COUNT=0;
    afni_info.WARP_TYPE=[0 0];
    afni_info.FileFormat='BRIK';

    [unused, unused, endian_ness] = computer();
    afni_info.BYTEORDER_STRING = sprintf('%sSB_FIRST', endian_ness);

    set_empty={'BRICK_LABS','BRICK_KEYWORDS',...
                'BRICK_STATS','BRICK_FLOAT_FACS',...
                'BRICK_STATAUX','STAT_AUX'};
    for k=1:numel(set_empty)
        fn=set_empty{k};
        afni_info.(fn)=[];
    end

    % if labels for the samples, store them in the header
    if isfield(ds.sa,'labels') && ~isempty(ds.sa.labels)
        afni_info.BRICK_LABS=cosmo_strjoin(ds.sa.labels,'~');
    end

    if isfield(ds.sa,'stats') && ~isempty(ds.sa.stats)
        afni_info=cosmo_structjoin(afni_info,cosmo_statcode(ds,'afni'));
    end

    % store data in non-afni field 'img'
    afni_info.img=vol_data;

function write_afni(fn, hdr)

    hdr.RootName=fn;
    data=hdr.img; % get the data
    hdr=rmfield(hdr,'img'); % remove the non-afni field 'img'

    afniopt=struct();
    afniopt.Prefix=fn; %the second input argument
    afniopt.OverWrite='y';
    afniopt.NoCheck=0;
    afniopt.AppendHistory=false;
    afniopt.verbose=0;
    afniopt.Scale=0;
    afniopt.AdjustHeader='no';

    if ends_with({'+orig','+orig.HEAD','+orig.BRIK',...
                           '+orig.BRIK.gz'},fn)
        hdr.SCENE_DATA(1)=0;
        afniopt.View='+orig';
    elseif ends_with({'+tlrc','+tlrc.HEAD',...
                           '+tlrc.BRIK','+tlrc.BRIK.gz'},fn)
        hdr.SCENE_DATA(1)=2;
        afniopt.View='+tlrc';
    else
        error('Unsupported scene data for %s', fn);
    end

    [err, ErrMessage]=WriteBrik(data, hdr, afniopt);
    if err
        error(ErrMessage);
    end


 function s=set_all(s, fns, v)
    % sets all fields in fns in struct s to v
    % if v is omitted it is set to 0.
    if nargin<3, v=0; end
    n=numel(fns);
    for k=1:n
        fn=fns{k};
        s.(fn)=v;
    end