
####################################################################################################
# neuropythy/vision/retinotopy.py
# Tools for registering the cortical surface to a particular potential function
# By Noah C. Benson

import numpy                        as np
import numpy.linalg                 as npla
import nibabel.freesurfer.mghformat as fsmgh
import nibabel.freesurfer.io        as fsio
import pyrsistent                   as pyr
import os, sys, gzip, six, types, pimms

from .. import geometry       as geo
from .. import freesurfer     as nyfs
from .. import mri            as mri
from .. import io             as nyio
from ..util               import (zinv, library_path, is_tuple, is_list, nangt, nanlt)
from ..registration       import (mesh_register, java_potential_term)
from ..java               import (to_java_doubles, to_java_ints)
from functools            import reduce

from .models import (RetinotopyModel, SchiraModel, RetinotopyMeshModel, RegisteredRetinotopyModel,
                     load_fmm_model, visual_area_names, visual_area_numbers)
# due to parameter name clash we import this one special:
from .models import visual_area_field_signs as global_visual_area_field_signs

# Tools for extracting retinotopy data from a subject:
_empirical_retinotopy_names = {
    'polar_angle':  ['prf_polar_angle',       'empirical_polar_angle',  'measured_polar_angle',
                     'training_polar_angle',  'polar_angle'],
    'eccentricity': ['prf_eccentricity',      'empirical_eccentricity', 'measured_eccentricity',
                     'training_eccentricity', 'eccentricity'],
    'radius':       ['pRF_size', 'pRF_radius', 'empirical_size', 'empirical_radius',
                     'measured_size', 'measured_radius', 'size', 'radius',
                     'pRF_sigma', 'empirical_sigma', 'measured_sigma', 'sigma'],
    'weight':       ['prf_weight',       'prf_variance_explained',       'prf_vexpl',
                     'empirical_weight', 'empirical_variance_explained', 'empirical_vexpl',
                     'measured_weight',  'measured_variance_explained',  'measured_vexpl',
                     'training_weight',  'training_variance_explained',  'training_vexpl',
                     'weight',           'variance_explained',           'vexpl']}

# handy function for picking out properties automatically...
def empirical_retinotopy_data(hemi, retino_type):
    '''
    empirical_retinotopy_data(hemi, t) yields a numpy array of data for the given cortex object hemi
    and retinotopy type t; it does this by looking at the properties in hemi and picking out any
    combination that is commonly used to denote empirical retinotopy data. These common names are
    stored in _empirical_retintopy_names, in order of preference, which may be modified.
    The argument t should be one of 'polar_angle', 'eccentricity', 'weight'.
    '''
    dat = _empirical_retinotopy_names[retino_type.lower()]
    hdat = {s.lower(): s for s in six.iterkeys(hemi.properties)}
    return next((hemi.prop(hdat[s.lower()]) for s in dat if s.lower() in hdat), None)

_predicted_retinotopy_names = {
    'polar_angle':  ['predicted_polar_angle',   'model_polar_angle',
                     'registered_polar_angle',  'template_polar_angle'],
    'eccentricity': ['predicted_eccentricity',  'model_eccentricity',
                     'registered_eccentricity', 'template_eccentricity'],
    'visual_area':  ['predicted_visual_area',   'model_visual_area',
                     'registered_visual_area',  'template_visual_area']}

def predicted_retinotopy_data(hemi, retino_type):
    '''
    predicted_retinotopy_data(hemi, t) yields a numpy array of data for the given cortex object hemi
    and retinotopy type t; it does this by looking at the properties in hemi and picking out any
    combination that is commonly used to denote empirical retinotopy data. These common names are
    stored in _predicted_retintopy_names, in order of preference, which may be modified.
    The argument t should be one of 'polar_angle', 'eccentricity', 'visual_area'.
    '''
    dat = _predicted_retinotopy_names[retino_type.lower()]
    hdat = {s.lower(): s for s in six.iterkeys(hemi.properties)}
    return next((hemi.prop(hdat[s]) for s in dat if s.lower() in hdat), None)

_retinotopy_names = {
    'polar_angle':  set(['polar_angle']),
    'eccentricity': set(['eccentricity']),
    'visual_area':  set(['visual_area', 'visual_roi', 'visual_region', 'visual_label']),
    'weight':       set(['weight', 'variance_explained'])}

def basic_retinotopy_data(hemi, retino_type):
    '''
    basic_retinotopy_data(hemi, t) yields a numpy array of data for the given cortex object hemi
    and retinotopy type t; it does this by looking at the properties in hemi and picking out any
    combination that is commonly used to denote empirical retinotopy data. These common names are
    stored in _predicted_retintopy_names, in order of preference, which may be modified.
    The argument t should be one of 'polar_angle', 'eccentricity', 'visual_area', or 'weight'.
    Unlike the related functions empirical_retinotopy_data and predicted_retinotopy_data, this
    function calls both of these (predicted first then empirical) in the case that it does not
    find a valid property.
    '''
    dat = _retinotopy_names[retino_type.lower()]
    val = next((hemi.prop(s) for s in six.iterkeys(hemi.properties) if s.lower() in dat), None)
    if val is None and retino_type.lower() != 'weight':
        val = predicted_retinotopy_data(hemi, retino_type)
    if val is None and retino_type.lower() != 'visual_area':
        val = empirical_retinotopy_data(hemi, retino_type)
    return val

def extract_retinotopy_argument(obj, retino_type, arg, default='any'):
    '''
    extract_retinotopy_argument(o, retino_type, argument) yields retinotopy data of the given
    retinotopy type (e.g., 'polar_angle', 'eccentricity', 'variance_explained', 'visual_area',
    'weight') from the given hemisphere or cortical mesh object o, according to the given
    argument. If the argument is a string, then it is considered a property name and that is
    returned regardless of its value. If the argument is an iterable, then it is returned. If
    the argument is None, then retinotopy will automatically be extracted, if found, by calling
    the retinotopy_data function.
    The option default (which, by default, is 'any') specifies which function should be used to
    extract retinotopy in the case that the argument is None. The value 'any' indicates that the
    function retinotopy_data should be used, while the values 'empirical' and 'predicted' specify
    that the empirical_retinotopy_data and predicted_retinotopy_data functions should be used,
    respectively.
    '''
    if   pimms.is_str(arg):        values = obj.prop(arg)
    elif hasattr(arg, '__iter__'): values = arg
    elif arg is not None:          raise ValueError('cannot interpret retinotopy arg: %s' % arg)
    elif default == 'predicted':   values = predicted_retinotopy_data(obj, retino_type)
    elif default == 'empirical':   values = empirical_retinotopy_data(obj, retino_type)
    elif default == 'any':         values = basic_retinotopy_data(obj, retino_type)
    else:                          raise ValueError('bad default retinotopy: %s' % default)
    if values is None:
        raise RuntimeError('No %s retinotopy data found given argument: %s' % (retino_type, arg))
    n = obj.vertex_count
    values = np.asarray(values)
    if len(values) != n:
        found = False
        # could be that we were given a mesh data-field for a map
        try:              values = values[obj.labels]
        except Exception: values = None
        if values is None:
            raise RuntimeError('%s data: length %s should be %s' % (retino_type, len(values), n))
    return values

_default_polar_angle_units = {
    'polar_angle': 'deg',
    'polarangle':  'deg',
    'polang':      'deg',
    'angle':       'deg',
    'ang':         'deg',
    'polar_theta': 'rad',
    'polartheta':  'rad',
    'poltht':      'rad',
    'theta':       'rad',
    'tht':         'rad'}
_default_polar_angle_axis = {
    'polar_angle': 'UVM',
    'polarangle':  'UVM',
    'polang':      'UVM',
    'angle':       'RHM',
    'ang':         'RHM',
    'polar_theta': 'UVM',
    'polartheta':  'UVM',
    'poltht':      'UVM',
    'theta':       'RHM',
    'tht':         'RHM'}
_default_polar_angle_dir = {
    'polar_angle': 'cw',
    'polarangle':  'cw',
    'polang':      'cw',
    'angle':       'ccw',
    'ang':         'ccw',
    'polar_theta': 'cw',
    'polartheta':  'cw',
    'poltht':      'cw',
    'theta':       'ccw',
    'tht':         'ccw'}
_default_eccentricity_units = {
    'eccentricity': 'deg',
    'eccen':        'deg',
    'ecc':          'deg',
    'rho':          'rad'}
_default_x_units = {
    'x':            'deg',
    'longitude':    'deg',
    'lon':          'deg'}
_default_y_units = {
    'y':            'deg',
    'latitude':     'deg',
    'lat':          'deg'}
_default_z_units = {
    'z':            'rad',
    'complex':      'deg',
    'complex-rad':  'rad',
    'coordinate':   'deg'}
def _clean_angle_deg(polang):
    polang = np.asarray(polang)
    clean = np.mod(polang + 180, 360) - 180
    is180 = np.isclose(polang, -180)
    clean[is180] = np.abs(clean[is180]) * np.sign(polang[is180])
    return clean
def _clean_angle_rad(polang):
    polang = np.asarray(polang)
    clean = np.mod(polang + np.pi, np.pi*2) - np.pi
    return clean
_retinotopy_style_fns = {
    'visual':       lambda t,e: (_clean_angle_deg(90.0 - 180.0/np.pi * t), e),
    'visual-rad':   lambda t,e: (_clean_angle_rad(np.pi/2 - t), e * np.pi/180.0),
    'spherical':    lambda t,e: (_clean_angle_rad(t), e*np.pi/180.0),
    'spherical-deg':lambda t,e: (_clean_angle_deg(180.0/np.pi*t), e),
    'standard':     lambda t,e: (_clean_angle_rad(t), e),
    'cartesian':    lambda t,e: (e * np.cos(t), e * np.sin(t)),
    'cartesian-rad':lambda t,e: (np.pi/180.0 * e * np.cos(t), np.pi/180.0 * e * np.sin(t)),
    'geographical': lambda t,e: (e * np.cos(t), e * np.sin(t)),
    'complex':      lambda t,e: e * np.exp(t * 1j),
    'complex-rad':  lambda t,e: np.pi/180.0 * e * np.exp(t * 1j),
    'z':            lambda t,e: np.pi/180.0 * e * np.exp(t * 1j)}

def as_retinotopy(data, output_style='visual', units=Ellipsis, prefix=None, suffix=None):
    '''
    as_retinotopy(data) converts the given data, if possible, into a 2-tuple, (polar_angle, eccen),
      both in degrees, with 0 degrees of polar angle corresponding to the upper vertical meridian
      and negative values corresponding to the left visual hemifield.
    as_retinotopy(data, output_style) yields the given retinotopy data in the given output_style;
      as_retinotopy(data) is equivalent to as_retinotopy(data, 'visual').

    This function is intended as a general conversion routine between various sources of retinotopy
    data. All lookups are done in a case insensitive manner. Data may be specified in any of the
    following ways:
      * A cortical mesh containing recognized properties (such as 'polar_angle' and 'eccentricity'
        or 'latitude' and 'longitude'.
      * A dict with recognized fields.
      * A tuple of (polar_angle, eccentricity) (assumed to be in 'visual' style).
      * A numpy vector of complex numbers (assumed in 'complex' style).
      * An n x 2 or 2 x n matrix whose rows/columns are (polar_angle, eccentricity) values (assumed
        in 'visual' style).

    The following output_styles are accepted:
      * 'visual':       polar-axis:         upper vertical meridian
                        positive-direction: clockwise
                        fields:             ['polar_angle' (degrees), 'eccentricity' (degrees)]
      * 'spherical':    polar-axis:         right horizontal meridian
                        positive-direction: counter-clockwise
                        fields:             ['theta' (radians), 'rho' (radians)]
      * 'standard':     polar-axis:         right horizontal meridian
                        positive-direction: counter-clockwise
                        fields:             ['angle' (radians), 'eccentricity' (degrees)]
      * 'cartesian':    axes:               x/y correspond to RHM/UVM
                        positive-direction: left/up
                        fields:             ('x' (degrees), 'y' (degrees))
      * 'geographical': axes:               x/y correspond to RHM/UVM
                        positive-direction: left/up
                        fields:             ('longitude' (degrees), 'latitude' (degrees))
      * 'complex':      axes:               x/y correspond to RHM/UVM
                        positive-direction: left/up
                        fields:             longitude (degrees) + I*latitude (degrees)
      * 'complex-rad':  axes:               x/y correspond to RHM/UVM
                        positive-direction: left/up
                        fields:             longitude (radians) + I*latitude (radians)
      * 'visual-rad':   polar-axis:         upper vertical meridian
                        positive-direction: clockwise
                        fields:             ['angle' (radians), 'eccentricity' (radians)]

    The following options may be given:
      * units (Ellipsis) specifies the unit that should be assumed (degrees or radians);
        if Ellipsis is given, then auto-detect the unit if possible. This may be a map whose keys
        are 'polar_angle' and 'eccentricity' (or the equivalent titles in data) and whose keys are
        the individual units.
      * prefix (None) specifies a prefix that is required for any keys or property names.
      * suffix (None) specifies a suffix that is required for any keys or property names.
    '''
    # simple sanity check:
    output_style = output_style.lower()
    if output_style not in _retinotopy_style_fns:
        raise ValueError('Unrecognized output style: %s' % output_style)
    # First step: get the retinotopy into a format we can deal with easily
    if is_tuple(data) and len(data) == 2:
        data = {'polar_angle': data[0], 'eccentricity': data[1]}
    if isinstance(data, list):
        data = np.asarray(data)
    if pimms.is_nparray(data):
        if pimms.is_vector(data, np.complexfloating):
            data = {'complex': data}
        else:
            if data.shape[0] != 2: data = data.T
            data = {'polar_angle': data[0], 'eccentricity': data[1]}
    # We now assume that data is a dict type; or is a mesh;
    # figure out the data we have and make it into theta/rho
    if isinstance(data, geo.VertexSet):
        pnames = {k.lower():k for k in six.iterkeys(data.properties)}
        mem_dat = lambda k: k in pnames
        get_dat = lambda k: data.prop(pnames[k])
    else:
        def _make_lambda(data,k): return lambda:data[k]
        data = pimms.lazy_map({k.lower():_make_lambda(data,k) for k in six.iterkeys(data)})
        mem_dat = lambda k: k in data
        get_dat = lambda k: data[k]
    # Check in a particular order:
    suffix = '' if suffix is None else suffix.lower()
    prefix = '' if prefix is None else prefix.lower()
    (angle_key, eccen_key, x_key, y_key, z_key) = [
        next((k for k in aliases if mem_dat(prefix + k + suffix)), None)
        for aliases in [['polar_angle', 'polar angle', 'angle', 'ang', 'polang', 'theta'],
                        ['eccentricity', 'eccen', 'ecc', 'rho'],
                        ['x', 'longitude', 'lon'], ['y', 'latitude', 'lat'],
                        ['z', 'complex', 'complex-rad', 'complex_rad', 'coordinate']]]
    rad2deg = 180.0 / np.pi
    deg2rad = np.pi / 180.0
    (hpi, dpi) = (np.pi / 2.0, np.pi * 2.0)
    if angle_key and eccen_key:
        akey = prefix + angle_key + suffix
        ekey = prefix + eccen_key + suffix
        theta = np.asarray(get_dat(akey))
        rho   = np.asarray(get_dat(ekey))
        theta = theta * (deg2rad if _default_polar_angle_units[angle_key]  == 'deg' else 1)
        rho   = rho   * (rad2deg if _default_eccentricity_units[eccen_key] == 'rad' else 1)
        if _default_polar_angle_axis[angle_key] == 'UVM': theta = theta - hpi
        if _default_polar_angle_dir[angle_key] == 'cw':   theta = -theta
        ok = np.where(np.isfinite(theta))[0]
        theta[ok[theta[ok] < -np.pi]] += dpi
        theta[ok[theta[ok] >  np.pi]] -= dpi
    elif x_key and y_key:
        (x,y) = [np.asarray(get_dat(prefix + k + suffix)) for k in [x_key, y_key]]
        if _default_x_units[x_key] == 'rad': x *= rad2deg
        if _default_y_units[y_key] == 'rad': y *= rad2deg
        theta = np.arctan2(y, x)
        rho   = np.sqrt(x*x + y*y)
    elif z_key:
        z = get_dat(prefix + z_key + suffix)
        theta = np.angle(z)
        rho   = np.abs(z)
        if _default_z_units[z_key] == 'rad': rho *= rad2deg
    else:
        raise ValueError('could not identify a valid retinotopic representation in data')
    # Now, we just have to convert to the requested output style
    f = _retinotopy_style_fns[output_style]
    return f(theta, rho)

retinotopic_property_aliases = {
    'radius': [
        (set(['radius', 'size', 'sigma', 'rad', 'sz', 'sig',
              'prf_radius', 'prf_size', 'prf_sigma', 'prf_rad', 'prf_sz', 'prf_sig',
              'prfradius', 'prfsize', 'prfsigma', 'prfrad', 'prfsz', 'prfsig']),
         lambda r: r),
        (set(['fwhm']), lambda fwhm: fwhm / (2.0 * np.sqrt(2.0 * np.log(2.0))))],
    'variance_explained': [
        (set(['variance_explained', 'varexp', 'varexpl', 'vexpl', 'weight',
              'coefficient_of_determination', 'cod', 
              'r2', 'rsquared', 'r_squared', 'rsqr', 'rsq']),
         lambda x: x)],
    'visual_area': [
        (set(['visual_area', 'visual_roi', 'visual_label',
              'varea', 'vroi', 'vlabel', 'visarea', 'visroi', 'vislabel', 'vislbl']),
         lambda x: x)]}
def retinotopy_data(m, source='any'):
    '''
    retinotopy_data(m) yields a dict containing a retinotopy dataset with the keys 'polar_angle',
      'eccentricity', and any other related fields for the given retinotopy type; for example,
      'pRF_size' and 'variance_explained' may be included for measured retinotopy datasets and
      'visual_area' may be included for atlas or model datasets. The coordinates are always in the
      'visual' retinotopy style, but can be reinterpreted with as_retinotopy.
    retinotopy_data(m, source) may be used to specify a particular source for the data; this may be
      either 'empirical', 'model', or 'any'; or it may be a prefix/suffix beginning/ending with
      an _ character.
    '''
    if pimms.is_map(source):
        if all(k in source for k in ['polar_angle', 'eccentricity']): return source
    if geo.is_vset(m): return retinotopy_data(m.properties, source=source)
    source = source.lower()
    model_rets = ['predicted', 'model', 'template', 'atlas', 'inferred']
    empir_rets = ['empirical', 'measured', 'prf', 'data']
    wild = False
    extra_fields = {'empirical':('radius','variance_explained'), 'model':('radius','visual_area')}
    check_fields = []
    if source in empir_rets:
        fixes = empir_rets
        check_fields = extra_fields['empirical']
    elif source in model_rets:
        fixes = model_rets
        check_fields = extra_fields['model']
    elif source in ['any', '*', 'all']:
        fixes = model_rets + empir_rets
        check_fields = extra_fields['model'] + extra_fields['empirical']
        wild = True
    elif source in ['none', 'basic']:
        fixes = []
        check_fields = extra_fields['model'] + extra_fields['empirical']
        wild = True
    else: fixes = []
    # first, try all the fixes as prefixes then suffixes
    (z, prefix, suffix) = (None, None, None)
    if wild:
        try: z = as_retinotopy(m, 'visual')
        except Exception: pass
    for fix in fixes:
        if z: break
        try:
            z = as_retinotopy(m, 'visual', prefix=(fix + '_'))
            prefix = fix + '_'
        except Exception: pass
    for fix in fixes:
        if z: break
        try:
            z = as_retinotopy(m, 'visual', suffix=('_' + fix))
            suffix = fix + '_'
        except Exception: pass
    # if none of those worked, try with no prefix/suffix
    if not z:
        try:
            pref = source if source.endswith('_') else (source + '_')
            z = as_retinotopy(m, 'visual', prefix=pref)
            prefix = pref
            check_fields = extra_fields['model'] + extra_fields['empirical']
        except Exception:
            raise
            try:
                suff = source if source.startswith('_') else ('_' + source)
                z = as_retinotopy(m, 'visual', suffix=suff)
                suffix = suff
                check_fields = extra_fields['model'] + extra_fields['empirical']
            except Exception: pass
    # if still not z... we couldn't figure it out
    if not z: raise ValueError('Could not find an interpretation for source %s' % source)
    # okay, we found it; make it into a dict
    res = {'polar_angle': z[0], 'eccentricity': z[1]}
    # check for extra fields if relevant
    pnames = {k.lower():k for k in m} if check_fields else {}
    for fname in set(check_fields):
        for (aliases, trfn) in retinotopic_property_aliases.get(fname, []):
            if trfn is None: trfn = lambda x:x
            for f in aliases:
                if prefix: f = prefix + f
                if suffix: f = f + suffix
                f = f.lower()
                if f in pnames:
                    res[fname] = trfn(m[pnames[f]])
                    trfn = None
                    break
            if trfn is None: break
    # That's it
    return res

def to_logeccen(ecc, vmin=0, vmax=90, offset=0.75):
    '''
    to_logeccen(ecc) yields a rescaled log-space version of the eccentricity value (or values) ecc,
      which are extracted in degrees.
    to_logeccen(xy_matrix) rescales all the (x,y) points in the given matrix to have lox-spaced
      eccentricity values.

    to_logeccen is the inverse of from_logeccen.
    '''
    if pimms.is_matrix(ecc):
        xy = np.asarray(pimms.mag(ecc, 'deg'))
        trq = xy.shape[0] != 2
        xy = np.transpose(xy) if trq else np.asarray(xy)
        ecc = np.sqrt(np.sum(xy**2, axis=0))
        esc = to_logeccen(ecc, vmin=vmin, vmax=vmax, offset=offset)
        ecc = zinv(ecc)
        xy = xy * [ecc,ecc] * [esc,esc]
        return xy.T if trq else xy
    else:
        (ecc,vmin,vmax,offset) = [np.asarray(pimms.mag(u, 'deg')) for u in (ecc,vmin,vmax,offset)]
        log_ecc = np.log(ecc + offset)
        (vmin, vmax) = [np.log(u + offset) for u in (vmin, vmax)]
        return (log_ecc - vmin) / (vmax - vmin)
def from_logeccen(logecc, vmin=0, vmax=90, offset=0.75):
    '''
    from_logeccen(logecc) yields a rescaled linear-space version of the log-eccentricity value (or
      values) logecc.
    from_logeccen(logxy_matrix) rescales all the (x,y) points in the given matrix to have
      linearly-spaced eccentricity values.

    from_logeccen is the inverse of to_logeccen.
    '''
    if pimms.is_matrix(logecc):
        xy = np.asarray(logecc)
        trq = xy.shape[0] != 2
        xy = np.transpose(xy) if trq else np.asarray(xy)
        r = np.sqrt(np.sum(xy**2, axis=0))
        esc = from_logeccen(r, vmin=vmin, vmax=vmax, offset=offset)
        ecc = zinv(r)
        xy = xy * [ecc,ecc] * [esc,esc]
        return xy.T if trq else xy
    else:
        logecc = np.asarray(logecc)
        (vmin,vmax,offset) = [np.asarray(u) for u in (vmin,vmax,offset)]
        (vmin, vmax) = [np.log(u + offset) for u in (vmin, vmax)]
        logecc = logecc*(vmax - vmin) + vmin
        return np.exp(logecc) - offset

pRF_data_Wandell2015 = pyr.pmap(
    {k.lower():pyr.pmap(v)
     for (k,v) in six.iteritems(
             {"V1":  {'m':0.16883, 'b':0.02179}, "V2":  {'m':0.16912, 'b':0.14739},
              "V3":  {'m':0.26397, 'b':0.34221}, "hV4": {'m':0.52963, 'b':0.44501},
              "V3a": {'m':0.35722, 'b':1.00189}, "V3b": {'m':0.35722, 'b':1.00189},
              "VO1": {'m':0.68505, 'b':0.47988}, "VO2": {'m':0.93893, 'b':0.26177},
              "LO1": {'m':0.85645, 'b':0.36149}, "LO2": {'m':0.74762, 'b':0.45887},
              "TO1": {'m':1.37441, 'b':0.17240}, "TO2": {'m':1.65694, 'b':0.00000}})})
pRF_data_Kay2013 = pyr.pmap(
    {k.lower():pyr.pmap({'m':v, 'b':0.5})
     for (k,v) in six.iteritems({'V1':0.16, 'V2':0.18, 'V3':0.25, 'hV4':0.36})})
pRF_data = pyr.pmap({'wandell2015':pRF_data_Wandell2015, 'kay2013':pRF_data_Kay2013})
def predict_pRF_radius(eccentricity, visual_area='V1', source='Wandell2015'):
    '''
    predict_pRF_radius(eccentricity) yields an estimate of the pRF size for a patch of cortex at the
      given eccentricity in V1.
    predict_pRF_radius(eccentricity, area) yields an estimate in the given visual area (may be given
      by the keyword visual_area).
    predict_pRF_radius(eccentricity, area, source) uses the given source to estimate the pRF size
      (may be given by the keyword source).

    The following visual areas can be specified:
      * 'V1' (default), 'V2', 'V3'
      * 'hV4'
      * 'V3a', 'V3b'
      * 'VO1', 'VO2'
      * 'LO1', 'LO2'
      * 'TO1', 'TO2'

    The following sources may be given:
      * 'Wandell2015': Wandell BA, Winawer J (2015) Computational neuroimaging and population
                       receptive fields. Trends Cogn Sci. 19(6):349-57.
                       doi:10.1016/j.tics.2015.03.009.
      * 'Kay2013: Kay KN, Winawer J, Mezer A, Wandell BA (2013) Compressive spatial summation in
                  human visual cortex. J Neurophysiol. 110(2):481-94.
    The default source is 'Wandell2015'.
    '''
    visual_area = visual_area.lower()
    if pimms.is_str(source):
        source = source.lower()
        if source not in pRF_data:
            raise ValueError('Given source (%s) not found in pRF-size database' % source)
        dat = pRF_data[source]
        dat = dat[visual_area]
    else:
        dat = {'m':source[0], 'b':source[1]}
    return dat['m']*eccentricity + dat['b']

def fit_pRF_radius(ctx, retinotopy=Ellipsis, mask=None, weight=Ellipsis, slope_only=False):
    '''
    fit_pRF_radius(ctx) fits a line, m*eccen + b, to the pRF radius and yields the tuple (m, b).

    The following options may be given:
      * retinotopy (default: Ellipsis) specifies the prefix for the retinotopy (passed to
        retinotopy_data() to find the retinotopic dataset).
      * mask (default: None) specifies the mask over which to perform the calculation. This is
        passed to the to_mask() function. In the case that mask is a set or frozenset, then it is
        treated as a conjunction (intersection) of masks.
      * weight (default: None) specifies that a weight should be used; if this is True or Ellipsis,
        will use the variance_explained if it is part of the retinotopy dataset; if this is False or
        None, uses no weight; otherwise, this must be a weight property or property name.
      * slope_only (default: False) may be set to True to instead fit radius = m*eccen and return
        only m.
    '''
    rdat = retinotopy_data(ctx, retinotopy)
    if 'radius' not in rdat: raise ValueError('No pRF radius found in dataset %s' % retinotopy)
    rad = rdat['radius']
    (ang,ecc) = as_retinotopy(rdat, 'visual')
    if isinstance(mask, (set, frozenset)):
        mask = reduce(np.intersect1d, [ctx.mask(m, indices=True) for m in mask])
    else: mask = ctx.mask(mask, indices=True)
    # get a weight if provided:
    if weight in [False, None]: wgt = np.ones(rad.shape)
    elif weight in [True, Ellipsis]:
        if 'variance_explained' in rdat: wgt = rdat['variance_explained']
        else: wgt = np.ones(rad.shape)
    else: wgt = ctx.property(weight)
    # get the relevant eccen and radius values
    (ecc,rad,wgt) = [x[mask] for x in (ecc,rad,wgt)]
    # fit a line...
    if slope_only:
        ecc = np.reshape(ecc * wgt, (len(ecc), 1))
        rad = np.reshape(rad * wgt, (len(rad), 1))
        return np.linalg.lstsq(ecc, rad)[0]
    else:
        return tuple(np.polyfit(ecc, rad, 1, w=wgt))

def _retinotopic_field_sign_triangles(m, retinotopy):
    t = m.tess if isinstance(m, geo.Mesh) or isinstance(m, geo.Topology) else m
    # get the polar angle and eccen data as a complex number in degrees
    if pimms.is_str(retinotopy):
        (x,y) = as_retinotopy(retinotopy_data(m, retinotopy), 'geographical')
    elif retinotopy is Ellipsis:
        (x,y) = as_retinotopy(retinotopy_data(m, 'any'),      'geographical')
    else:
        (x,y) = as_retinotopy(retinotopy,                     'geographical')
    # Okay, now we want to make some coordinates...
    coords = np.asarray([x, y])
    us = coords[:, t.indexed_faces[1]] - coords[:, t.indexed_faces[0]]
    vs = coords[:, t.indexed_faces[2]] - coords[:, t.indexed_faces[0]]
    (us,vs) = [np.concatenate((xs, np.full((1, t.face_count), 0.0))) for xs in [us,vs]]
    xs = np.cross(us, vs, axis=0)[2]
    xs[np.isclose(xs, 0)] = 0
    return np.sign(xs)

def retinotopic_field_sign(m, element='vertices', retinotopy=Ellipsis, invert_field=False):
    '''
    retinotopic_field_sign(mesh) yields a property array of the field sign of every vertex in the 
    mesh m; this value may not be exactly 1 (same as VF) or -1 (mirror-image) but some value
    in-between; this is because the field sign is calculated exactly (1, 0, or -1) for each triangle
    in the mesh then is average onto the vertices. To get only the triangle field signs, use
    retinotopic_field_sign(m, 'triangles').

    The following options are accepted:
      * element ('vertices') may be 'vertices' to specify that the vertex signs should be returned
        or 'triangles' (or 'faces') to specify that the triangle field signs should be returned.
      * retinotopy (Ellipsis) specifies the retinotopic dataset to be used. If se to 'empirical' or
        'predicted', the retinotopy data is auto-detected from the given categories; if set to
        Ellipsis, a property pair like 'polar_angle' and 'eccentricity' or 'lat' and 'lon' are
        searched for using the as_retinotopy function; otherwise, this may be a retinotopy dataset
        recognizable by as_retinotopy.
      * invert_field (False) specifies that the inverse of the field sign should be returned.
    '''
    tsign = _retinotopic_field_sign_triangles(m, retinotopy)
    t = m.tess if isinstance(m, geo.Mesh) or isinstance(m, geo.Topology) else m
    if invert_field: tsign = -tsign
    element = element.lower()
    if element == 'triangles' or element == 'faces': return tsign
    vfs = t.vertex_faces
    vfs = np.asarray([np.mean(tsign[list(ii)]) if len(ii) > 0 else 0 for ii in vfs])
    return vfs

visual_area_field_signs = pyr.pmap({'V1' :-1, 'V2' :1, 'V3' :-1, 'hV4':1,
                                    'VO1':-1, 'VO2':1, 'LO1':1,  'LO2':-1,
                                    'V3b':-1, 'V3a':1, 'TO1':-1, 'TO2':1})
'''
visual_area_field_signs is a persistent map of field signs as observed empirically for visual areas
V1, V2, V3, hV4, VO1, LO1, V3a, and V3b.
'''

# Tools for retinotopy model loading:
_default_schira_model = None
def get_default_schira_model():
    global _default_schira_model
    if _default_schira_model is None:
        #try:
            _default_schira_model = RegisteredRetinotopyModel(
                SchiraModel(),
                geo.MapProjection(
                    registration='fsaverage_sym',
                    chirality='lh',
                    center=[-7.03000, -82.59000, -55.94000],
                    center_right=[58.58000, -61.84000, -52.39000],
                    radius=np.pi/2.5,
                    method='orthographic'))
        #except Exception: raise
    return _default_schira_model

_retinotopy_model_paths = [os.path.join(library_path(), 'models')]
def retinotopy_model(name='benson17', hemi=None,
                     radius=np.pi/2.5, sphere_radius=100.0,
                     search_paths=None, update=False):
    '''
    retinotopy_model() yields a standard retinotopy model of V1, V2, and V3 as well as other areas
    (depending on the options). The model itself is represented as a RegisteredRetinotopyModel
    object, which may internally store a set of meshes with values at the vertices that define the
    polar angle and eccentricity, or as another object (such as with the SchiraModel). The mesh
    models are loaded from files in the neuropythy lib directory. Because the model's class is
    RegisteredRetinotopyModel, so the details of the model's 2D projection onto the cortical surface
    are included in the model.
    
    The following options may be given:
      * name (default: 'benson17') indicates the name of the model to load; the Benson17 model is
        included with the neuropythy library along with various others. If name is a filename, this
        file is loaded (must be a valid fmm or fmm.gz file). Currently, models that are included
        with neuropythy are: Benson17, Benson17-uncorrected, Schira10, and Benson14 (which is
        identical to Schira10, as Schira10 was used by Benson14).
      * hemi (default: None) specifies that the model should go with a particular hemisphere, either
        'lh' or 'rh'. Generally, model files are names lh.<model>.fmm.gz or rh.<model>.fmm.gz, but
        models intended for the fsaverage_sym don't necessarily get a prefix. Note that you can
        leave this as none and just specify that the model name is 'lh.model' instead.
      * radius, sphere_radius (defaults: pi/2.5 and 100.0, respectively) specify the radius of the
        projection (on the surface of the sphere) and the radius of the sphere (100 is the radius
        for Freesurfer spheres). See neuropythy.registration.load_fmm_model for mode details.
      * search_paths (default: None) specifies directories in which to look for fmm model files. No
        matter what is included in these files, the neuropythy library's folders are searched last.
    '''
    origname = name
    tup = (name,hemi,radius,sphere_radius)
    if tup in retinotopy_model.cache:
        return retinotopy_model.cache[tup]
    if os.path.isfile(name):
        fname = name
        name = None
    elif name.lower() in ['schira', 'schira10', 'schira2010', 'benson14', 'benson2014']:
        tmp = get_default_schira_model()
        retinotopy_model.cache[tup] = tmp
        return tmp
    else:
        name = name if hemi is None else ('%s.%s' % (hemi.lower(), name))
        if len(name) > 4 and name[-4:] == '.fmm':
            fname = name
            name = name[:-4]
        elif len(name) > 7 and name[-7:] == '.fmm.gz':
            fname = name
            name = name[:-7]
        else:
            fname = name + '.fmm'
            # Find it in the search paths...
            spaths = ([] if search_paths is None else search_paths) + _retinotopy_model_paths
            fname = next(
                (os.path.join(path, nm0)
                 for path in spaths
                 for nm0 in os.listdir(path)
                 for nm in [nm0[:-4] if len(nm0) > 4 and nm0[-4:] == '.fmm'    else \
                            nm0[:-7] if len(nm0) > 7 and nm0[-7:] == '.fmm.gz' else \
                            None]
                 if nm is not None and nm == name),
                None)
    if fname is None: raise ValueError('Cannot find an FFM file with the name %s' % origname)
    # Okay, load the model...
    mdl = load_fmm_model(fname).persist()
    retinotopy_model.cache[tup] = mdl
    return mdl
retinotopy_model.cache = {}

def occipital_flatmap(cortex, radius=None):
    '''
    occipital_flatmap(cortex) yields a flattened mesh of the occipital cortex of the given cortex
      object.
      
    Note that if the cortex is not registrered to fsaverage, this will fail.

    The option radius may be given to specify the fraction of the cortical sphere (in radians) to
    include in the map.
    '''
    mdl = retinotopy_model('benson17', hemi=cortex.chirality)
    mp = mdl.map_projection
    if radius is not None: mp = mp.copy(radius=radius)
    return mp(cortex)

# Tools for retinotopy registration:
def _retinotopy_vectors_to_float(ang, ecc, wgt, weight_min=0):
    ok = np.isfinite(wgt) & np.isfinite(ecc) & np.isfinite(ang)
    ok[ok] &= wgt[ok] > weight_min
    bad = np.logical_not(ok)
    if np.sum(bad) > 0:
        wgt = np.array(wgt)
        wgt[bad] = 0
    return (ang, ecc, wgt)

def retinotopy_mesh_field(mesh, mdl,
                          polar_angle=None, eccentricity=None, weight=None,
                          weight_min=0, scale=1, sigma=None, shape=2, suffix=None,
                          max_eccentricity=Ellipsis,
                          max_polar_angle=180,
                          angle_type='both',
                          exclusion_threshold=None):
    '''
    retinotopy_mesh_field(mesh, model) yields a list that can be used with mesh_register as a
      potential term. This should generally be used in a similar fashion to retinotopy_anchors.

    Options:
      * polar_angle (default None) specifies that the given data should be used in place of the
        'polar_angle' or 'PRF_polar_angle'  property values. The given argument must be numeric and
        the same length as the the number of vertices in the mesh. If None is given, then the
        property value of the mesh is used; if a list is given and any element is None, then the
        weight for that vertex is treated as a zero. If the option is a string, then the property
        value with the same name isused as the polar_angle data.
      * eccentricity (default None) specifies that the given data should be used in places of the
        'eccentricity' or 'PRF_eccentricity' property values. The eccentricity option is handled 
        virtually identically to the polar_angle option.
      * weight (default None) specifies that the weight or scale of the data; this is handled
        generally like the polar_angle and eccentricity options, but may also be 1, indicating that
        all vertices with polar_angle and eccentricity values defined will be given a weight of 1.
        If weight is left as None, then the function will check for 'weight',
        'variance_explained', 'PRF_variance_explained', and 'retinotopy_weight' values and will use
        the first found (in that order). If none of these is found, then a value of 1 is assumed.
      * weight_min (default 0) specifies that the weight must be higher than the given value inn
        order to be included in the fit; vertices with weights below this value have their weights
        truncated to 0.
      * scale (default 1) specifies a constant by which to multiply all weights for all anchors; the
        value None is interpreted as 1.
      * shape (default 2.0) specifies the exponent in the harmonic function.
      * sigma (default None) specifies, if given as a number, the sigma parameter of the Gaussian
        potential function; if sigma is None, however, the potential function is harmonic.
      * suffix (default None) specifies any additional arguments that should be appended to the 
        potential function description list that is produced by this function; i.e., the 
        retinotopy_anchors function produces a list, and the contents of suffix, if given and not
        None, are appended to that list (see mesh_register).
      * max_eccentricity (default Ellipsis) specifies how the eccentricity portion of the potential
        field should be normalized. Specifically, in order to ensure that polar angle and
        eccentricity contribute roughly equally to the potential, this should be approximately the
        max eccentricity appearing in the data on the mesh. If the argument is the default then the
        actual max eccentricity will be used.
      * max_polar_angle (default: 180) is used the same way as the max_eccentricity function, but if
        Ellipsis is given, the value 180 is always assumed regardless of measured data.
      * exclusion_threshold (default None) specifies that if the initial norm of a vertex's gradient
        is greater than exclusion_threshold * std + median (where std and median are calculated over
        the vertices with non-zero gradients) then its weight is set to 0 and it is not kept as part
        of the potential field.
      * angle_type (default: None) specifies that only one type of angle should be included in the
        mesh; this may be one of 'polar', 'eccen', 'eccentricity', 'angle', or 'polar_angle'. If
        None, then both polar angle and eccentricity are included.

    Example:
     # The retinotopy_anchors function is intended for use with mesh_register, as follows:
     # Define our Schira Model:
     model = neuropythy.retinotopy_model()
     # Make sure our mesh has polar angle, eccentricity, and weight data:
     mesh.prop('polar_angle',  polar_angle_vertex_data);
     mesh.prop('eccentricity', eccentricity_vertex_data);
     mesh.prop('weight',       variance_explained_vertex_data);
     # register the mesh using the retinotopy and model:
     registered_mesh = neuropythy.registration.mesh_register(
        mesh,
        ['mesh', retinotopy_mesh_field(mesh, model)],
        max_step_size=0.05,
        max_steps=2000)
    '''
    #TODO: given a 3D mesh and a registered model, we should be able to return a 3D version of the
    # anchors by unprojecting them
    if pimms.is_str(mdl):
        mdl = retinotopy_model(mdl)
    if not isinstance(mdl, RetinotopyMeshModel):
        if isinstance(mdl, RegisteredRetinotopyModel):
            mdl = mdl.model
        if not isinstance(mdl, RetinotopyMeshModel):
            raise RuntimeError('given model is not a RetinotopyMeshModel instance!')
    if not hasattr(mdl, 'data') or 'polar_angle' not in mdl.data or 'eccentricity' not in mdl.data:
        raise ValueError('Retinotopy model does not have polar angle and eccentricity data')
    if not isinstance(mesh, CorticalMesh):
        raise RuntimeError('given mesh is not a CorticalMesh object!')
    n = mesh.vertex_count
    X = mesh.coordinates.T
    if weight_min is None: weight_min = 0
    # make sure we have our polar angle/eccen/weight values:
    # (weight is odd because it might be a single number, so handle that first)
    (polar_angle, eccentricity, weight) = [
        extract_retinotopy_argument(mesh, name, arg, default='empirical')
        for (name, arg) in [
                ('polar_angle', polar_angle),
                ('eccentricity', eccentricity),
                ('weight', [weight for i in range(n)] \
                           if isinstance(weight, Number) or np.issubdtype(type(weight), np.float) \
                           else weight)]]
    # Make sure they contain no None/invalid values
    (polar_angle, eccentricity, weight) = _retinotopy_vectors_to_float(
        polar_angle, eccentricity, weight,
        weight_min=weight_min)
    if np.sum(weight > 0) == 0:
        raise ValueError('No positive weights found')
    idcs = [i for (i,w) in enumerate(weight) if w > 0]
    # Okay, let's get the model data ready
    mdl_1s = np.ones(mdl.forward.coordinates.shape[0])
    mdl_coords = np.dot(mdl.transform, np.vstack((mdl.forward.coordinates.T, mdl_1s)))[:2].T
    mdl_faces  = mdl.forward.triangles
    mdl_data   = np.asarray([mdl.data['polar_angle'], mdl.data['eccentricity']])
    # Get just the relevant weights and the scale
    wgts = weight[idcs] * (1 if scale is None else scale)
    # and the relevant polar angle/eccen data
    msh_data = np.asarray([polar_angle, eccentricity])[:,idcs]
    # format shape correctly
    shape = np.full((len(idcs)), float(shape), dtype=np.float32)
    # Last thing before constructing the field description: normalize both polar angle and eccen to
    # cover a range of 0-1:
    if max_eccentricity is Ellipsis: max_eccentricity = np.max(msh_data[1])
    if max_polar_angle  is Ellipsis: max_polar_angle  = 180
    if max_polar_angle is not None:
        msh_data[0] /= max_polar_angle
        mdl_data[0] /= max_polar_angle
    if max_eccentricity is not None:
        msh_data[1] /= max_eccentricity
        mdl_data[1] /= max_eccentricity
    # Check if we are making an eccentricity-only or a polar-angle-only field:
    if angle_type is not None:
        angle_type = angle_type.lower()
        if angle_type != 'both' and angle_type != 'all':
            convert = {'eccen':'eccen', 'eccentricity':'eccen', 'radius':'eccen',
                       'angle':'angle', 'polar_angle': 'angle', 'polar': 'angle'}
            angle_type = convert[angle_type]
            mdl_data = [mdl_data[0 if angle_type == 'angle' else 1]]
            msh_data = [msh_data[0 if angle_type == 'angle' else 1]]
    # okay, we've partially parsed the data that was given; now we can construct the final list of
    # instructions:
    if sigma is None:
        field_desc = ['mesh-field', 'harmonic', mdl_coords, mdl_faces, mdl_data, idcs, msh_data,
                      'scale', wgts, 'order', shape]
    else:
        if not hasattr(sigma, '__iter__'): sigma = [sigma for _ in wgts]
        field_desc = ['mesh-field', 'gaussian', mdl_coords, mdl_faces, mdl_data, idcs, msh_data,
                      'scale', wgts, 'order', shape, 'sigma', sigma]
    if suffix is not None: field_desc += suffix
    # now, if we want to exclude outliers, we do so here:
    if exclusion_threshold is not None:
        jpe = java_potential_term(mesh, field_desc)
        jcrds = to_java_doubles(mesh.coordinates)
        jgrad = to_java_doubles(np.zeros(mesh.coordinates.shape))
        jpe.calculate(jcrds,jgrad)
        gnorms = np.sum((np.asarray([[x for x in row] for row in jgrad])[:, idcs])**2, axis=0)
        gnorms_pos = gnorms[gnorms > 0]
        mdn = np.median(gnorms_pos)
        std = np.std(gnorms_pos)
        gn_idcs = np.where(gnorms > mdn + std*3.5)[0]
        for i in gn_idcs: wgts[i] = 0;
    return field_desc

        
def retinotopy_anchors(mesh, mdl,
                       polar_angle=None, eccentricity=None,
                       weight=None, weight_min=0.1,
                       field_sign_weight=0, field_sign=None, invert_field_sign=False,
                       radius_weight=0, radius_weight_source='Wandell2015', radius=None,
                       model_field_sign=None,
                       model_hemi=Ellipsis,
                       scale=1,
                       shape='Gaussian', suffix=None,
                       sigma=[0.1, 2.0, 8.0],
                       select='close'):
    '''
    retinotopy_anchors(mesh, model) is intended for use with the mesh_register function and the
    retinotopy_model() function and/or the RetinotopyModel class; it yields a description of the
    anchor points that tie relevant vertices the given mesh to points predicted by the given model
    object. Any instance of the RetinotopyModel class should work as a model argument; this includes
    SchiraModel objects as well as RetinotopyMeshModel objects such as those returned by the
    retinotopy_model() function. If the model given is a string, then it is passed to the
    retinotopy_model() function first.

    Options:
      * polar_angle (default None) specifies that the given data should be used in place of the
        'polar_angle' or 'PRF_polar_angle'  property values. The given argument must be numeric and
        the same length as the the number of vertices in the mesh. If None is given, then the
        property value of the mesh is used; if a list is given and any element is None, then the
        weight for that vertex is treated as a zero. If the option is a string, then the property
        value with the same name isused as the polar_angle data.
      * eccentricity (default None) specifies that the given data should be used in places of the
        'eccentricity' or 'PRF_eccentricity' property values. The eccentricity option is handled 
        virtually identically to the polar_angle option.
      * weight (default None) specifies that the weight or scale of the data; this is handled
        generally like the polar_angle and eccentricity options, but may also be 1, indicating that
        all vertices with polar_angle and eccentricity values defined will be given a weight of 1.
        If weight is left as None, then the function will check for 'weight',
        'variance_explained', 'PRF_variance_explained', and 'retinotopy_weight' values and will use
        the first found (in that order). If none of these is found, then a value of 1 is assumed.
      * weight_min (default 0) specifies that the weight must be higher than the given value inn
        order to be included in the fit; vertices with weights below this value have their weights
        truncated to 0.
      * scale (default 1) specifies a constant by which to multiply all weights for all anchors; the
        value None is interpreted as 1.
      * shape (default 'Gaussian') specifies the shape of the potential function (see mesh_register)
      * model_hemi (default: None) specifies the hemisphere of the model to load; if None, then
        looks for a non-specific model.
      * suffix (default None) specifies any additional arguments that should be appended to the 
        potential function description list that is produced by this function; i.e., the 
        retinotopy_anchors function produces a list, and the contents of suffix, if given and not
        None, are appended to that list (see mesh_register).
      * select (default 'close') specifies a function that will be called with two arguments for
        every vertex given an anchor; the arguments are the vertex label and the matrix of anchors.
        The function should return a list of anchors to use for the label (None is equivalent to
        lambda id,anc: anc). The parameter may alternately be specified using the string 'close':
        select=['close', [k]] indicates that any anchor more than k times the average edge-length in
        the mesh should be excluded; a value of just ['close', k] on the other hand indicates that
        any anchor more than k distance from the vertex should be exlcuded. The default value,
        'close', is equivalent to ['close', [40]].
      * sigma (default [0.1, 2.0, 4.0]) specifies how the sigma parameter should be handled; if
        None, then no sigma value is specified; if a single number, then all sigma values are
        assigned that value; if a list of three numbers, then the first is the minimum sigma value,
        the second is the fraction of the minimum distance between paired anchor points, and the 
        last is the maximum sigma --- the idea with this form of the argument is that the ideal
        sigma value in many cases is approximately 0.25 to 0.5 times the distance between anchors
        to which a single vertex is attracted; for any anchor a to which a vertex u is attracted,
        the sigma of a is the middle sigma-argument value times the minimum distance from a to all
        other anchors to which u is attracted (clipped by the min and max sigma).
      * field_sign_weight (default: 0) specifies the amount of weight that should be put on the
        retinotopic field of the model as a method of attenuating the weights on those anchors whose
        empirical retinotopic values and predicted model locations do not match. The weight that
        results is calculated from the difference in empirical field-sign for each vertex and the
        visual area field sign based on the labels in the model. The higher the field-sign weight,
        (approaching 1) the more the resulting value is a geometric mean of the field-sign-based
        weight and the original weights. As this value approaches 0, the resulting weights are more
        like the original weights.
      * radius_weight (default: 0) specifies the amount of weight that should be put on the
        receptive field radius of the model as a method of attenuating the weights on those anchors
        whose empirical retinotopic values and predicted model locations do not match. The weight
        that results is calculated from the difference in empirical RF radius for each vertex and
        the predicted RF radius based on the labels in the model. The higher the radius weight,
        (approaching 1) the more the resulting value is a geometric mean of the field-sign-based
        weight and the original weights. As this value approaches 0, the resulting weights are more
        like the original weights.
      * radius_weight_source (default: 'Wandell2015') specifies the source for predicting RF radius;
        based on eccentricity and visual area label.

    Example:
     # The retinotopy_anchors function is intended for use with mesh_register, as follows:
     # Define our Schira Model:
     model = neuropythy.registration.SchiraModel()
     # Make sure our mesh has polar angle, eccentricity, and weight data:
     mesh.prop('polar_angle',  polar_angle_vertex_data);
     mesh.prop('eccentricity', eccentricity_vertex_data);
     mesh.prop('weight',       variance_explained_vertex_data);
     # register the mesh using the retinotopy and model:
     registered_mesh = neuropythy.registration.mesh_register(
        mesh,
        ['mesh', retinotopy_anchors(mesh, model)],
        max_step_size=0.05,
        max_steps=2000)
    '''
    if pimms.is_str(mdl):
        hemi = None
        if pimms.is_str(model_hemi):
            model_hemi = model_hemi.upper()
            hemnames = {k:h
                        for (h,als) in [('LH', ['LH','L','LEFT','RHX','RX']),
                                        ('RH', ['RH','R','RIGHT','LHX','LX'])]
                        for k in als}
            if model_hemi in hemnames: hemi = hemnames[model_hemi]
            else: raise ValueError('Unrecognized hemisphere name: %s' % model_hemi)
        elif model_hemi is not None:
            raise ValueError('model_hemi must be a string, Ellipsis, or None')
        mdl = retinotopy_model(mdl, hemi=hemi)
    if not isinstance(mdl, RetinotopyModel):
        raise RuntimeError('given model is not a RetinotopyModel instance!')
    if not isinstance(mesh, geo.Mesh):
        raise RuntimeError('given mesh is not a Mesh object!')
    n = mesh.vertex_count
    X = mesh.coordinates.T
    if weight_min is None: weight_min = 0
    # make sure we have our polar angle/eccen/weight values:
    # (weight is odd because it might be a single number, so handle that first)
    (polar_angle, eccentricity, weight) = [
        extract_retinotopy_argument(mesh, name, arg, default='empirical')
        for (name, arg) in [
                ('polar_angle', polar_angle),
                ('eccentricity', eccentricity),
                ('weight', np.full(n, weight) if pimms.is_number(weight) else weight)]]
    # Make sure they contain no None/invalid values
    (polar_angle, eccentricity, weight) = _retinotopy_vectors_to_float(
        polar_angle, eccentricity, weight,
        weight_min=weight_min)
    if np.sum(weight > 0) == 0:
        raise ValueError('No positive weights found')
    idcs = np.where(weight > 0)[0]
    # Interpret the select arg if necessary (but don't apply it yet)
    select = ['close', [40]] if select == 'close'   else \
             ['close', [40]] if select == ['close'] else \
             select
    if select is None:
        select = lambda a,b: b
    elif ((pimms.is_vector(select) or is_list(select) or is_tuple(select))
          and len(select) == 2 and select[0] == 'close'):
        if pimms.is_vector(select[1]): d = np.mean(mesh.edge_lengths) * select[1][0]
        else:                          d = select[1]
        select = lambda idx,ancs: [a for a in ancs if a[0] is not None if npla.norm(X[idx] - a) < d]
    # Okay, apply the model:
    res = mdl.angle_to_cortex(polar_angle[idcs], eccentricity[idcs])
    oks = np.isfinite(np.sum(np.reshape(res, (res.shape[0], -1)), axis=1))
    # Organize the data; trim out those not selected
    data = [[[i for _ in r], r, [ksidx[tuple(a)] for a in r]]
            for (i,r0,ok) in zip(idcs, res, oks) if ok
            for ksidx in [{tuple(a):(k+1) for (k,a) in enumerate(r0)}]
            for r in [select(i, r0)]
            if len(r) > 0]
    # Flatten out the data into arguments for Java
    idcs = [int(i) for d in data for i in d[0]]
    ancs = np.asarray([pt for d in data for pt in d[1]]).T
    labs = np.asarray([ii for d in data for ii in d[2]]).T
    # Get just the relevant weights and the scale
    wgts = np.asarray(weight[idcs] * (1 if scale is None else scale))
    # add in the field-sign weights and radius weights if requested here;
    if not np.isclose(field_sign_weight, 0) and mdl.area_name_to_id is not None:
        id2n = mdl.area_id_to_name
        if field_sign is True or field_sign is Ellipsis or field_sign is None:
            from .cmag import cmag
            r = {'polar_angle': polar_angle,  'eccentricity': eccentricity}
            #field_sign = retinotopic_field_sign(mesh, retinotopy=r)
            field_sign = cmag(mesh, r)['field_sign']
        elif pimms.is_str(field_sign): field_sign = mesh.prop(field_sign)
        field_sign = np.asarray(field_sign)
        if invert_field_sign: field_sign = -field_sign
        fswgts = 1.0 - 0.5*np.abs([(fs - visual_area_field_signs[id2n[l]]) if l in id2n else 0
                                   for (l,fs) in zip(labs, field_sign[idcs])])
        #print(np.min(fswgts), np.mean(fswgts), np.max(fswgts))
        # average the weights at some fraction with the original weights
        fswgts = field_sign_weight*fswgts + (1 - field_sign_weight)*wgts
    else: fswgts = None
    # add in radius weights if requested as well
    if not np.isclose(radius_weight, 0) and mdl.area_name_to_id is not None:
        id2n = mdl.area_id_to_name
        emprad = extract_retinotopy_argument(mesh, 'radius', radius, default='empirical')
        emprad = emprad[idcs]
        emprad = np.argsort(np.argsort(emprad)) * (1.0 / len(emprad)) - 0.5
        eccs = eccentricity[idcs]
        prerad = np.asarray([predict_pRF_radius(ecc, id2n[lbl], source=radius_weight_source)
                             for (ecc,lbl) in zip(eccs,labs)])
        prerad = np.argsort(np.argsort(prerad)) * (1.0 / len(prerad)) - 0.5
        rdwgts = 1.0 - (emprad - prerad)**2
        # average the weights at some fraction with the original weights
        rdwgts = radius_weight*rdwgts + (1-radius_weight)*wgts
    else: rdwgts = None
    # apply the weights
    if fswgts is not None:
        if rdwgts is not None: wgts = np.power(fswgts*rdwgts*wgts, 1.0/3.0)
        else:                  wgts = np.sqrt(fswgts*wgts)
    elif rdwgts is not None:   wgts = np.sqrt(rdwgts*wgts)
    # Figure out the sigma parameter:
    if sigma is None: sigs = None
    elif pimms.is_number(sigma): sigs = sigma
    elif pimms.is_vector(sigma) and len(sigma) == 3:
        [minsig, mult, maxsig] = sigma
        sigs = np.clip(
            [mult*min([npla.norm(a0 - a) for a in anchs if a is not a0]) if len(iii) > 1 else maxsig
             for (iii,anchs,_) in data
             for a0 in anchs],
            minsig, maxsig)
    else:
        raise ValueError('sigma must be a number or a list of 3 numbers')
    # okay, we've partially parsed the data that was given; now we can construct the final list of
    # instructions:
    tmp =  (['anchor', shape,
             np.asarray(idcs, dtype=np.int),
             np.asarray(ancs, dtype=np.float64),
             'scale', np.asarray(wgts, dtype=np.float64)]
            + ([] if sigs is None else ['sigma', sigs])
            + ([] if suffix is None else suffix))
    return tmp

####################################################################################################
# Registration Calculations
# The registration system is a set of pimms calculation functions all wrapped into a pimms plan;
# this way it is modular and easy to modify.

@pimms.calc('empirical_retinotopy')
def calc_empirical_retinotopy(cortex,
                              polar_angle=None, eccentricity=None, pRF_radius=None, weight=None,
                              eccentricity_range=None, weight_min=0,
                              invert_rh_angle=False, clip_angles=True,
                              partial_voluming_correction=False):
    '''
    calc_empirical_retinotopy computes the value empirical_retinotopy, which is an itable object
      storing the retinotopy data for the registration.

    Required afferent parameters:
      @ cortex Must be the cortex object that is to be registered to the model of retinotopy.
 
    Optional afferent parameters:
      @ polar_angle May be an array of polar angle values or a polar angle property name; if None
        (the default), attempts to auto-detect an empirical polar angle property.
      @ eccentricity May be an array of eccentricity values or an eccentricity property name; if
        None (the default), attempts to auto-detect an empirical eccentricity property.
      @ pRF_radius May be an array of receptive field radius values or the property name for such an
        array; if None (the default), attempts to auto-detect an empirical radius property.
      @ weight May be an array of weight values or a weight property name; if None (the default),
        attempts to auto-detect an empirical weight property, such as variance_explained.
      @ eccentricity_range May be a maximum eccentricity value or a (min, max) eccentricity range
        to be used in the registration; if None, then no clipping is done.
      @ weight_min May be given to indicate that weight values below this value should not be
        included in the registration; the default is 0.
      @ partial_voluming_correction May be set to True (default is False) to indicate that partial
        voluming correction should be used to adjust the weights.
      @ invert_rh_angle May be set to True (default is False) to indicate that the right hemisphere
        has its polar angle stored with opposite sign to the model polar angle.
      @ clip_angles May be set to False to indicate that the angles should not be clipped such that
        angles below 0 are set to 0 and angles above 180 are set to 180. This is done to prevent
        issues with the model not allowing values outside the contralateral hemifield.

    Efferent values:
      @ empirical_retinotopy Will be a pimms itable of the empirical retinotopy data to be used in
        the registration; the table's keys will be 'polar_angle', 'eccentricity', and 'weight';
        values that should be excluded for any reason will have 0 weight and undefined angles.
    '''
    data = {}  # the map we build up in this function
    n = cortex.vertex_count
    (emin,emax) = (-np.inf,np.inf)       if eccentricity_range is None          else \
                  (0,eccentricity_range) if pimms.is_number(eccentricity_range) else \
                  eccentricity_range
    # Step 1: get our properties straight ##########################################################
    (ang, ecc, rad, wgt) = [
        np.array(extract_retinotopy_argument(cortex, name, arg, default='empirical'))
        for (name, arg) in [
                ('polar_angle', polar_angle),
                ('eccentricity', eccentricity),
                ('radius', pRF_radius),
                ('weight', np.full(n, weight) if pimms.is_number(weight) else weight)]]
    if wgt is None: wgt = np.ones(len(ecc))
    bad = np.logical_not(np.isfinite(np.prod([ang, ecc, wgt], axis=0)))
    ecc[bad] = 0
    wgt[bad] = 0
    if rad is not None: rad[bad] = 0
    # See if we need to invert polar angle.
    if invert_rh_angle and cortex.chirality == 'rh': ang = -ang
    # do partial voluming correction if requested
    if partial_voluming_correction: wgt = wgt * (1 - cortex.partial_voluming_factor)
    # now trim and finalize
    bad = bad | (wgt <= weight_min) | (ecc < emin) | (ecc > emax)
    wgt[bad] = 0
    ang[~np.isfinite(ang)] = 0
    ecc[~np.isfinite(ecc)] = 0
    # clip angles if that is needed
    if clip_angles:
        ang[ang > 180] = 180
        ang[ang < 0] = 0
    # persist the vectors
    for x in [ang, ecc, wgt, rad]:
        if x is not None:
            x.setflags(write=False)
    # that's it!
    dat = dict(polar_angle=ang, eccentricity=ecc, weight=wgt)
    if rad is not None: dat['radius'] = rad
    return (pimms.itable(dat),)
@pimms.calc('model')
def calc_model(cortex, model_argument, model_hemi=Ellipsis, radius=np.pi/3):
    '''
    calc_model loads the appropriate model object given the model argument, which may given the name
    of the model or a model object itself.

    Required afferent parameters:
      @ model_argument Must be either a RegisteredRetinotopyModel object or the name of a model that
        can be loaded.

    Optional afferent parameters:
      @ model_hemi May be used to specify the hemisphere of the model; this is usually only used
        when the fsaverage_sym hemisphere is desired, in which case this should be set to None; if
        left at the default value (Ellipsis), then it will use the hemisphere of the cortex param.

    Provided efferent values:
      @ model Will be the RegisteredRetinotopyModel object to which the mesh should be registered.
    '''
    if pimms.is_str(model_argument):
        h = cortex.chirality if model_hemi is Ellipsis else \
            None             if model_hemi is None     else \
            model_hemi
        model = retinotopy_model(model_argument, hemi=h, radius=radius)
    else:
        model = model_argument
    if not isinstance(model, RegisteredRetinotopyModel):
        raise ValueError('model must be a RegisteredRetinotopyModel')
    return model
@pimms.calc('native_mesh', 'preregistration_mesh', 'preregistration_map',
            'preregistration_field_sign')
def calc_initial_state(cortex, model, empirical_retinotopy, resample=Ellipsis, prior=None,
                       field_sign=None):
    '''
    calc_initial_state is a calculator that prepares the initial state of the registration process.
    The initial state consists of a flattened 2D mesh ('native_map') that has been made from the
    initial cortex, and a 'registration_map', on which registration is to be performed. The former
    of these two meshes will always share vertex labels with the cortex argument, and the latter
    mesh this mesh may be identical to the native mesh or may be a resampled version of it.

    Optional afferent parameters:
      @ resample May specify that the registration_map should be resampled to the 'fsaverage' or the
        'fsaverage_sym' map; the advantage of this is that the resampling prevents angles already
        distorted by inflation, registration, and flattening from being sufficiently small to
        dominate the registration initially. The default value is Ellipsis, which specifies that the
        'fsaverage' or 'fsaverage_sym' resampling should be applied if the model is registered to
        either of those, and otherwise no resampling should be applied.
      @ prior May specify an alternate registration to which the native mesh should be projected
        prior to flattening and registration. The default value, None, indicates that the model's
        default registration should just be used. Generally models will be registered to either the 
        fsaverage or fsaverage_sym atlases; if your fsaverage subject has a geometry file matching
        the pattern ?h.*.sphere.reg, such as lh.retinotopy.sphere.reg, you can use that file as a
        registration (with registration/prior name 'retinotopy') in place of the fsaverage's native
        lh.sphere from which to start the overall retinotopy registration.

    Provided efferent values:
      @ native_mesh Will be the 3D mesh registered to the model's required registration space.
      @ preregistration_mesh Will be the 3D mesh that is ready to be used in registration; this may
        be identical to native_mesh if there is no resampling.
      @ preregistration_map Will be the 2D flattened mesh for use in registration; this mesh is
        a flattened version of preregistration_mesh.
    '''
    model_reg = model.map_projection.registration
    model_reg = 'native' if model_reg is None else model_reg
    model_chirality = None if model_reg == 'fsaverage_sym' else model.map_projection.chirality
    ch = 'lh' if model_chirality is None else model_chirality
    if model_chirality is not None and cortex.chirality != model_chirality:
        raise ValueError('Inverse-chirality hemisphere cannot be registered to model')
    # make sure we are registered to the model space
    if model_reg not in cortex.registrations:
        raise ValueError('given Cortex is not registered to the model registration: %s' % model_reg)
    # give this registration the correct data
    native_mesh = cortex.registrations[model_reg].with_prop(empirical_retinotopy)
    preregmesh = native_mesh # will become the preregistration mesh below
    preregfs = field_sign
    # see about the prior
    if prior is not None:
        try:
            mdl_ctx = getattr(nyfs.subject(model_reg), ch)
            nativ = mdl_ctx.registrations['native']
            prior = mdl_ctx.registrations[prior]
        except Exception: raise ValueError('Could not find given prior %s' % prior)
        addr = nativ.address(native_mesh)
        preregmesh = native_mesh.copy(coordinates=prior.unaddress(addr))
        # and now, resampling...
    if resample is Ellipsis:
        resample = model_reg if model_reg == 'fsaverage' or model_reg == 'fsaverage_sym' else None
    if resample is not None and resample is not False:
        # make a map from the appropriate hemisphere...
        preregmesh = nyfs.subject(resample).hemis[ch].registrations['native']
        # resample properties over...
        tmp = native_mesh.interpolate(preregmesh.coordinates, 'all')
        preregmesh = preregmesh.with_prop(tmp)
        if pimms.is_vector(preregfs, 'number'):
            preregfs = native_mesh.interpolate(preregmesh.coordinates, preregfs)
    # make the map projection now...
    preregmap = model.map_projection(preregmesh)
    if pimms.is_vector(preregfs, 'number'):
        preregfs = preregfs[preregmap.labels]
    return {'native_mesh':                native_mesh,
            'preregistration_mesh':       preregmesh,
            'preregistration_map':        preregmap,
            'preregistration_field_sign': preregfs}
@pimms.calc('anchors')
def calc_anchors(preregistration_map, model, model_hemi, preregistration_field_sign,
                 scale=1, sigma=Ellipsis, radius_weight=0, field_sign_weight=0,
                 invert_rh_field_sign=False):
    '''
    calc_anchors is a calculator that creates a set of anchor instructions for a registration.

    Required afferent parameters:
      @ invert_rh_field_sign May be set to True (default is False) to indicate that the right
        hemisphere's field signs will be incorrect relative to the model; this generally should be
        used whenever invert_rh_angle is also set to True.

    '''
    wgts = preregistration_map.prop('weight')
    rads = preregistration_map.prop('radius')
    if np.isclose(radius_weight, 0): radius_weight = 0
    ancs = retinotopy_anchors(preregistration_map, model,
                              polar_angle='polar_angle',
                              eccentricity='eccentricity',
                              radius='radius',
                              weight=wgts, weight_min=0, # taken care of already
                              radius_weight=radius_weight, field_sign_weight=field_sign_weight,
                              scale=scale, field_sign=preregistration_field_sign,
                              invert_field_sign=(model_hemi == 'rh' and invert_rh_field_sign),
                              **({} if sigma is Ellipsis else {'sigma':sigma}))
    return ancs

@pimms.calc('registered_map')
def calc_registration(preregistration_map, anchors,
                      max_steps=2000, max_step_size=0.05, method='random'):
    '''
    calc_registration is a calculator that creates the registration coordinates.
    '''
    # if max steps is a tuple (max, stride) then a trajectory is saved into
    # the registered_map meta-data
    pmap = preregistration_map
    if is_tuple(max_steps) or is_list(max_steps):
        (max_steps, stride) = max_steps
        traj = [preregistration_map.coordinates]
        x = preregistration_map.coordinates
        for s in np.arange(0, max_steps, stride):
            x = mesh_register(
                preregistration_map,
                [['edge',      'harmonic',      'scale', 1.0],
                 ['angle',     'infinite-well', 'scale', 1.0],
                 ['perimeter', 'harmonic'],
                 anchors],
                initial_coordinates=x,
                method=method,
                max_steps=stride,
                max_step_size=max_step_size)
            traj.append(x)
        pmap = pmap.with_meta(trajectory=np.asarray(traj))
    else:
        x = mesh_register(
            preregistration_map,
            [['edge',      'harmonic',      'scale', 1.0],
             ['angle',     'infinite-well', 'scale', 1.0],
             ['perimeter', 'harmonic'],
             anchors],
            method=method,
            max_steps=max_steps,
            max_step_size=max_step_size)
    return pmap.copy(coordinates=x)
@pimms.calc('registered_mesh', 'registration_prediction', 'prediction', 'predicted_mesh')
def calc_prediction(registered_map, preregistration_mesh, native_mesh, model):
    '''
    calc_registration_prediction is a pimms calculator that creates the both the prediction and the
    registration_prediction, both of which are pimms itables including the fields 'polar_angle',
    'eccentricity', and 'visual_area'. The registration_prediction data describe the vertices for
    the registered_map, not necessarily of the native_mesh, while the prediction describes the
    native mesh.

    Provided efferent values:
      @ registered_mesh Will be a mesh object that is equivalent to the preregistration_mesh but
        with the coordinates and predicted fields (from the registration) filled in. Note that this
        mesh is still in the resampled configuration is resampling was performed.
      @ registration_prediction Will be a pimms ITable object with columns 'polar_angle', 
        'eccentricity', and 'visual_area'. For values outside of the model region, visual_area will
        be 0 and other values will be undefined (but are typically 0). The registration_prediction
        describes the values on the registrered_mesh.
      @ prediction will be a pimms ITable object with columns 'polar_angle', 'eccentricity', and
        'visual_area'. For values outside of the model region, visual_area will be 0 and other
        values will be undefined (but are typically 0). The prediction describes the values on the
        native_mesh and the predicted_mesh.
    '''
    # invert the map projection to make the registration map into a mesh
    coords3d = np.array(preregistration_mesh.coordinates)
    idcs = registered_map.labels
    coords3d[:,idcs] = registered_map.meta('projection').inverse(registered_map.coordinates)
    rmesh = preregistration_mesh.copy(coordinates=coords3d)
    # go ahead and get the model predictions...
    d = model.cortex_to_angle(registered_map.coordinates)
    id2n = model.area_id_to_name
    (ang, ecc) = d[0:2]
    lbl = np.asarray(d[2], dtype=np.int)
    rad = np.asarray([predict_pRF_radius(e, id2n[l]) if l > 0 else 0 for (e,l) in zip(ecc,lbl)])
    d = {'polar_angle':ang, 'eccentricity':ecc, 'visual_area':lbl, 'radius':rad}
    # okay, put these on the mesh
    rpred = {}
    for (k,v) in six.iteritems(d):
        v.setflags(write=False)
        tmp = np.zeros(rmesh.vertex_count, dtype=v.dtype)
        tmp[registered_map.labels] = v
        tmp.setflags(write=False)
        rpred[k] = tmp
    rpred = pyr.pmap(rpred)
    rmesh = rmesh.with_prop(rpred)
    # next, do all of this for the native mesh..
    if native_mesh is preregistration_mesh:
        pred = rpred
        pmesh = rmesh
    else:
        # we need to address the native coordinates in the prereg coordinates then unaddress them
        # in the registered coordinates; this will let us make a native-registered-map and repeat
        # the exercise above
        addr = preregistration_mesh.address(native_mesh.coordinates)
        natreg_mesh = native_mesh.copy(coordinates=rmesh.unaddress(addr))
        d = model.cortex_to_angle(natreg_mesh)
        (ang,ecc) = d[0:2]
        lbl = np.asarray(d[2], dtype=np.int)
        rad = np.asarray([predict_pRF_radius(e, id2n[l]) if l > 0 else 0 for (e,l) in zip(ecc,lbl)])
        pred = pyr.m(polar_angle=ang, eccentricity=ecc, radius=rad, visual_area=lbl)
        pmesh = natreg_mesh.with_prop(pred)
    return {'registered_mesh'        : rmesh,
            'registration_prediction': rpred,
            'prediction'             : pred,
            'predicted_mesh'         : pmesh}

#: retinotopy_registration is the pimms calculation plan executed by register_retinotopy()
retinotopy_registration = pimms.plan(
    retinotopy = calc_empirical_retinotopy,
    model      = calc_model,
    initialize = calc_initial_state,
    anchors    = calc_anchors,
    register   = calc_registration,
    predict    = calc_prediction)

def register_retinotopy(hemi,
                        model='benson17', model_hemi=Ellipsis,
                        polar_angle=None, eccentricity=None, weight=None, pRF_radius=None,
                        weight_min=0.1,
                        eccentricity_range=None,
                        partial_voluming_correction=False,
                        radius_weight=1, field_sign_weight=1, invert_rh_field_sign=False,
                        invert_rh_angle=False,
                        field_sign=None,
                        scale=20.0,
                        sigma=Ellipsis,
                        select='close',
                        prior=None,
                        resample=Ellipsis,
                        radius=np.pi/3,
                        max_steps=2000, max_step_size=0.05, method='random',
                        yield_imap=False):
    '''
    register_retinotopy(hemi) registers the given hemisphere object, hemi, to a model of V1, V2,
      and V3 retinotopy, and yields a copy of hemi that is identical but additionally contains
      the registration 'retinotopy', whose coordinates are aligned with the model.

    Registration attempts to align the vertex positions of the hemisphere's spherical surface with a
    model of polar angle and eccentricity. This alignment proceeds through several steps and can
    be modified by several options. A description of these steps and options are provided here. For
    most cases, the default options should work relatively well.

    Method:
      (1) Prepare for registration by several intitialization substeps:
            a. Extract the polar angle, eccentricity and weight data from the hemisphere. These
               data are usually properties on the mesh and can be modifies by the options
               polar_angle, eccentricity, and weight, which can be either property names or list
               of property values. By default (None), a property is chosen using the functions
               neuropythy.vision.extract_retinotopy_argument with the default option set to
               'empirical'.
            b. If partial voluming correction is enabled (via the option
               partial_voluming_correction), multiply the weight by (1 - p) where p is 
               hemi.partial_volume_factor.
            c. If there is a prior that is specified as a belief about the retinotopy, then a
               Registration is created for the hemisphere such that its vertices are arranged
               according to that prior (see also the prior option). Note that because hemi's
               coordinates must always be projected into the registration specified by the model,
               the prior must be the name of a registration to which the model's specified subject
               is also registered. This is clear in the case of an example. The default value for
               this is 'retinotopy'; assuming that our model is specified on the fsaverage_sym, 
               surface, the initial positions of the coordinates for the registration process would
               be the result of starting with hemi's fsaverage_sym-aligned coordinates then warping
               these coordinates in a way that is equivalent to the warping from fsaverage_sym's 
               native spherical coordinates to fsaverage_sym's retinotopy registration coordinates.
               Note that the retinotopy registration would usually be specified in a file in the
               fsaverage_sym subject's surf directory: surf/lh.retinotopy.sphere.reg.
               If no prior is specified (option value None), then the vertices that are used are
               those aligned with the registration of the model, which will usually be 'fsaverage'
               or 'fsaverage_sym'.
            d. If the option resample is not None, then the vertex coordinates are resampled onto
               either the fsaverage or fsaverage_sym's native sphere surface. (The value of resample
               should be either 'fsaverage' or 'fsaverage_sym'.) Resampling can prevent vertices
               that have been rearranged by alignment with the model's specified registration or by
               application of a prior from beginning the alignment with very high initial gradients
               and is recommended for subject alignments.
               If resample is None then no changes are made.
            e. A 2D projection of the (possibly aligned, prior-warped, and resampled) cortical
               surface is made according to the projection parameters of the model. This map is the
               mesh that is warped to eventually fit the model.
      (2) Perform the registration by running neuropythy.registration.mesh_register. This step
          consists of two major components.
            a. Create the potential function, which we will minimize. The potential function is a
               complex function whose inputs are the coordinates of all of the vertices and whose
               output is a potential value that increases both as the mesh is warped and as the
               vertices with retinotopy predictions get farther away from the positions in the model
               that their retinotopy values would predict they should lie. The balance of these
               two forces is best controlled by the option functional_scale. The potential function
               fundamentally consists of four terms; the first three describe mesh deformations and
               the last describes the model fit.
                - The edge deformation term is described for any vertices u and v that are connected
                  by an edge in the mesh; it's value is c/p (r(u,v) - r0(u,v))^2 where c is the
                  edge_scale, p is the number of edges in the mesh, r(a,b) is the distance between
                  vertices a and b, and r0(a,b) is the distance between a and b in the initial mesh.
                - The angle deformation term is described for any three vertices (u,v,w) that form
                  an angle in the mesh; its value is c/m h(t(u,v,w), t0(u,v,w)) where c is the
                  angle_scale argument, m is the number of angles in the mesh, t is the value of the
                  angle (u,v,w), t0 is the value of the angle in the initial mesh, and h(t,t0) is an
                  infinite-well function that asymptotes to positive infinity as t approaches both 0
                  and pi and is minimal when t = t0 (see the nben's 
                  nben.mesh.registration.InfiniteWell documentation for more details).
                - The perimeter term prevents the perimeter vertices form moving significantly;
                  this primarily prevents the mesh from wrapping in on itself during registration.
                  The form of this term is, for any vertex u on the mesh perimeter, 
                  (x(u) - x0(u))^2 where x and x0 are the position and initial position of the
                  vertex.
                - Finally, the functional term is minimized when the vertices best align with the
                  retinotopy model.
            b. Register the mesh vertices to the potential function using the nben Java library. The
               particular parameters of the registration are method, max_steps, and max_step_size.

    Options:
      * model specifies the instance of the retinotopy model to use; this must be an
        instance of the RegisteredRetinotopyModel class or a string that can be passed to the
        retinotopy_model() function (default: 'standard').
      * model_hemi specifies the hemisphere of the model; generally you shouldn't have to set this
        unless you are using an fsaverage_sym model, in which case it should be set to None; in all
        other cases, the default value (Ellipsis) instructs the function to auto-detect the
        hemisphere.
      * polar_angle, eccentricity, pRF_radius, and weight specify the property names for the
        respective quantities; these may alternately be lists or numpy arrays of values. If weight
        is not given or found, then unity weight for all vertices is assumed. By default, each will
        check the  hemisphere's properties for properties with compatible names; it will prefer the
        properties PRF_polar_angle, PRF_ecentricity, and PRF_variance_explained if possible.
      * weight_min (default: 0.1) specifies the minimum value a vertex must have in the weight
        property in order to be considered as retinotopically relevant.
      * eccentricity_range (default: None) specifies that any vertex whose eccentricity is too low
        or too high should be given a weight of 0 in the registration.
      * partial_voluming_correction (default: True), if True, specifies that the value
        (1 - hemi.partial_volume_factor) should be applied to all weight values (i.e., weights
        should be down-weighted when likely to be affected by a partial voluming error).
      * field_sign_weight (default: 1) indicates the relative weight (between 0 and 1) that should
        be given to the field-sign as a method of determining which anchors have the strongest
        springs. A value of 1 indicates that the effective weights of anchors should be the 
        geometric mean of the empirical retinotopic weight and field-sign-based weight; a value of 0
        indicates that no attention should be paid to the field sign weight.
      * radius_weight (default: 1) indicates the relative weight (between 0 and 1) that should
        be given to the pRF radius as a method of determining which anchors have the strongest
        springs. A value of 1 indicates that the effective weights of anchors should be the 
        geometric mean of the empirical retinotopic weight and pRF-radius-based weight; a value of 0
        indicates that no attention should be paid to the radius-based weight.
      * field_sign (default: None) optionally specifies the field-sign values for each vertex. If
        set to or left as None (the default), then the actual measured field-signs are used.
        Otherwise, this must be a vector of the imposed field sign for each vertex.
      * sigma specifies the standard deviation of the Gaussian shape for the Schira model anchors;
        see retinotopy_anchors for more information.
      * scale (default: 1.0) specifies the strength of the functional constraints (i.e. the anchors:
        the part of the minimization responsible for ensuring that retinotopic coordinates are
        aligned); the anatomical constraints (i.e. the edges and angles: the part of the
        minimization responsible for ensuring that the mesh is not overly deformed) are always held
        at a strength of 1.0.
      * select specifies the select option that should be passed to retinotopy_anchors.
      * max_steps (default 2,000) specifies the maximum number of registration steps to run. This
        may be a tuple (max_steps, stride) in which case the registered map that is returned will
        contain a piece of meta-data, 'trajectory' containing the vertex coordinates every stride
        steps of the registration.
      * max_step_size (default 0.05) specifies the maxmim distance a single vertex is allowed to
        move in a single step of the minimization.
      * method (default 'random') is the method argument passed to mesh_register. This should be
        'random', 'pure', or 'nimble'. Generally, 'random' is recommended.
      * yield_imap (default: False) specifies whether the return value should be the new
        Mesh object or a pimms imap (i.e., a persistent mapping of the result of a pimms
        calculation) containing the meta-data that was used during the registration
        calculations. If this is True, then register_retinotopy will return immediately, and
        calculations will only be performed as the relevant data are requested from the returned
        imap. The item 'predicted_mesh' gives the return value when yield_imap is set to False.
      * radius (default: pi/3) specifies the radius, in radians, of the included portion of the map
        projection (projected about the occipital pole).
      * sigma (default Ellipsis) specifies the sigma argument to be passed onto the 
        retinotopy_anchors function (see help(retinotopy_anchors)); the default value, Ellipsis,
        is interpreted as the default value of the retinotopy_anchors function's sigma option.
      * prior (default: None) specifies the prior that should be used, if found, in the 
        topology registrations for the subject associated with the retinotopy_model's registration.
      * resample (default: Ellipsis) specifies that the data should be resampled to one of
        the uniform meshes, 'fsaverage' or 'fsaverage_sym', prior to registration; if None then no
        resampling is performed; if Ellipsis, then auto-detect either fsaverage or fsaverage_sym
        based on the model_hemi option (if it is None, fsaverage_sym, else fsaverage).
    '''
    # create the imap
    m = retinotopy_registration(
        cortex=hemi, model_argument=model, model_hemi=model_hemi,
        polar_angle=polar_angle, eccentricity=eccentricity, weight=weight, pRF_radius=pRF_radius,
        weight_min=weight_min,  eccentricity_range=eccentricity_range,
        partial_voluming_correction=partial_voluming_correction,
        radius_weight=radius_weight, field_sign_weight=field_sign_weight, field_sign=field_sign,
        invert_rh_field_sign=invert_rh_field_sign, invert_rh_angle=invert_rh_angle,
        scale=scale, sigma=sigma, select=select, prior=prior, resample=resample, radius=radius,
        max_steps=max_steps, max_step_size=max_step_size, method=method)
    return m if yield_imap else m['predicted_mesh']

# Tools for registration-free retinotopy prediction:
def predict_retinotopy(sub, template='benson14', registration='fsaverage', sym_angle=True,
                       names='files'):
    '''
    predict_retinotopy(subject) yields a pair of dictionaries each with four keys: angle, eccen,
      sigma, and varea. Each of these keys maps to a numpy array with one entry per vertex.  The
      first element of the yielded pair is the left hemisphere map and the second is the right
      hemisphere map. The values are obtained by resampling the Benson et al. 2014 anatomically
      defined template of retinotopy to the given subject.

    The following optional arguments may be given:
      * template (default: 'benson14') specifies the template to use.
      * registration (default: 'fsaverage') specifies the subject registration to use; generally can
        only be 'fsaverage' or 'fsaverage_sym'.
      * sym_angle (default: True) may specify that the polar angle returned should be symmetric or
        not-symmetric across the vertical meridian. By convention, the predict_retinotopy() function
        yields the symmetric retinotopy, meaning that in LH and RH the polar angles range from 0 
        (UVM) to 180 (LVM). If sym_angle is set to False, then the RH values will be negative and
        the LH values will be positive.
      * names (default: 'files') specifies whether the returned property map should use short keys
        appropriate for inclusion in traditional filenames ('files') or longer keys appropriate for
        use as properties and with other neuropythy functions ('properties'). For historical
        reasons, this is 'files' by default, but at some future version may change to 'properties'.
        "files":      "angle",       "eccen",        "sigma",  "varea"
        "properties": "polar_angle", "eccentricity", "radius", "visual_area"
    '''
    names = names.lower()
    if names not in ['files', 'properties']:
        raise ValueError('bad argument for names: %s' % (names,))
    template = template.lower()
    retino_tmpls = predict_retinotopy.retinotopy_templates[registration]
    hemis = ['lh','rh'] if registration == 'fsaverage' else ['sym']
    if template not in retino_tmpls:
        libdir = os.path.join(library_path(), 'data')
        search_paths = [libdir]
        # just hard-baked-in for now.
        suff = 'v4_0' if registration == 'fsaverage' else 'v3_0'
        filenames = {(hname, fnm): ('%s.%s_%s.%s.mgz' % (hname,template,fnm,suff))
                     for fnm in ['angle','eccen','varea','sigma']
                     for hname in hemis}
        # find an appropriate directory
        tmpl_path = next((os.path.join(path0, registration)
                          for path0 in search_paths
                          if all(os.path.isfile(os.path.join(path0, registration, 'surf', s))
                                 for s in six.itervalues(filenames))),
                         None)
        if tmpl_path is None:
            raise ValueError('No subject found with appropriate surf/*.%s_* files!' % template)
        tmpl_sub = nyfs.subject(registration)
        spath = os.path.join(tmpl_path, 'surf')
        retino_tmpls[template] = pimms.persist(
            {h:{k: pimms.imm_array(dat)
                for k in ['angle', 'eccen', 'varea', 'sigma']
                for dat in [nyio.load(os.path.join(tmpl_path, 'surf', filenames[(h,k)]))]}
             for h in hemis})
    # Okay, we just need to interpolate over to this subject
    tmpl = retino_tmpls[template]
    if not all(s in tmpl for s in hemis):
        raise ValueError('could not find matching template')
    if registration == 'fsaverage_sym':
        sym = nyfs.subject('fsaverage_sym')
        if isinstance(sub, mri.Subject):
            subj_hems = (sub.lh, sub.hemis['rhx'])
            tmpl_hems = (sym.lh, sym.lh)
            chrs_hems = ('lh','rh')
        else:
            subj_hems = (sub,)
            tmpl_hems = (sym.lh,)
            chrs_hems = (sub.chirality,)
    else:
        fsa = nyfs.subject('fsaverage')
        if isinstance(sub, mri.Subject):
            subj_hems = (sub.lh, sub.rh)
            tmpl_hems = (fsa.lh, fsa.rh)
            chrs_hems = ('lh','rh')
        else:
            subj_hems = (sub,)
            tmpl_hems = ((fsa.lh if sub.chirality == 'lh' else fsa.rh),)
            chrs_hems = (sub.chirality,)
    tpl = []
    for (sh,th,h) in zip(subj_hems, tmpl_hems, chrs_hems):
        q = pyr.pmap(tmpl[h if registration == 'fsaverage' else 'sym'])
        (a,e) = (q['angle'],q['eccen'])
        a = np.pi/180*(90 - a)
        q = pimms.assoc(pimms.dissoc(q, 'angle', 'eccen'), x=e*np.cos(a), y=e*np.sin(a))
        dat = th.interpolate(sh, q)
        a = np.mod(90 - 180.0/np.pi * np.arctan2(dat['y'], dat['x']) + 180, 360) - 180
        if not sym_angle and h == 'rh': a *= -1
        e = np.sqrt(dat['x']**2 + dat['y']**2)
        bad = dat['varea'] == 0
        a[bad] = 0
        e[bad] = 0
        tpl.append(pimms.assoc(pimms.dissoc(dat, 'x', 'y'), angle=a, eccen=e))
    if names == 'properties':
        # convert the keys of each map:
        tr = dict(angle='polar_angle', eccen='eccentricity', sigma='radius', varea='visual_area')
        tpl = [{tr[k]:v for (k,v) in six.iteritems(u)} for u in tpl]
    return tpl[0] if len(tpl) == 1 else tuple(tpl)
predict_retinotopy.retinotopy_templates = pyr.m(fsaverage={}, fsaverage_sym={})

def retinotopy_comparison(arg1, arg2, arg3=None,
                          eccentricity_range=None, polar_angle_range=None, visual_area_mask=None,
                          weight=Ellipsis, weight_min=None, visual_area=Ellipsis,
                          method='rmse', distance='scaled', gold=None):
    '''
    retinotopy_comparison(dataset1, dataset2) yields a pimms itable comparing the two retinotopy
      datasets.
    retinotopy_error(obj, dataset1, dataset2) is equivalent to retinotopy_comparison(x, y) where x
      and y are retinotopy(obj, dataset1) and retinotopy_data(obj, dataset2).
    
    The datasets may be specified in a number of ways, some of which may be incompatible with
    certain options. The simplest way to specify a dataset is as a vector of complex numbers, which
    are taken to represent positions in the visual field with (a + bi) corresponding to the
    coordinate (a deg, b deg) in the visual field. Alternately, an n x 2 or 2 x n matrix will be
    interpreted as (polar angle, eccentricity) coordinates, in terms of visual degrees (see the
    as_retinotopy function: as_retinotopy(arg, 'visual') yields this input format). Alternately,
    the datasets may be mappings such as those retuend by the retinotopy_data function; in this case
    as_retinotopy is used to extract the visual coordinates (so they need not be specified in visual
    coordinates specifically in this case). In this last case, additional properties such as the
    variance explained and pRF size can be returned, making it valuable for more sophisticated error
    methods or distance metrics.

    The returned dataset will always have a row for each row in the two datasets (which must have
    the same number of rows). However, many rows may have a weight of 0 even if no weights were 
    specified in the options; this is because other limitations may have been specified (such as
    in the eccentricity_range or visual_areas). The returned dataset will always contain the
    following columns:
      * 'weight' gives the weight assigned to this particular vertex; the weights will always sum to
        1 unless all vertices have 0 weight.
      * 'polar_angle_1' and 'polar_angle_2', 'eccentricity_1', 'eccenticity_2', 'x_1', 'x_2', 'y_1',
        'y_2', 'z_1', and 'z_2' all give the visual field coordinates in degrees; the z values give
        complex numbers equivalent to the x/y values.
      * 'radius_1' and 'radius_2' give the radii (sigma parameters) of the pRF gaussians.
      * 'polar_angle_error', 'eccentricity_error', and 'center_error' all give the difference
        between the visual field points in the two datasets; note that polar_angle_error in
        particular is an error measure of rotations around the visual field and not of visual field
        position. The 'center_error' is the distance between the centers of the visual field, in
        degrees. The 'radius_error' value is also given.
      * 'visual_area_1' and 'visual_area_2' specify the visual areas of the individual datasets; if
        either of the datasets did not have a visual area, it will be omitted. Additionally, the
        property 'visual_area' specifies the visual area suggested for use in analyses; this is
        chosen based on the following: (1) if there is a gold standard dataset specified that has
        a visual area, use it; (2) if only one of the datasets has a visual area, use it; (3) if
        both have a visual area, then use the (varea1 == varea2) * varea1 (the areas that agree are
        kept and all others are set to 0); (4) if neither has a visual area, then this property is
        omitted. In all cases where a 'visual_area' property is included, those vertices that do not
        overlap with the given visual_area_option option will be set to 0 along with the
        corresponding weights.
      * A variety of other lazily-calculated error metrics are included.

    The following options are accepted:
      * eccentricity_range (default: None) specifies the range of eccentricity to include in the
        calculation (in degrees). This may be specified as emax or (emin, emax).
      * polar_angle_range (default: None) specifies the range of polar angles to include in the
        calculation. Like eccentricity range it may be specified as (amin, amax) but amax alone is
        not allowed. Additionally the strings 'lh' and 'rvf' are equivalent to (0,180) and the
        strings 'rh' and 'lvf' are equivalent to (-180,0).
      * weight (default: Ellipsis) specifies the weights to be used in the calculation. This may be
        None to specify that no weights should be used, or a property name or an array of weight
        values. Alternately, it may be a tuple (w1, w2) of the weights for datasets 1 and 2. If the
        argument is Ellipsis, then it will use weights if they are found in the retinotopy dataset;
        both datasets may contain weights in which the product is used.
      * weight_min (default: None) specifies the minimum weight a vertex must have to be included in
        the calculation.
      * visual_area (default: Ellipsis) specifies the visual area labels to be used in the
        calculation. This may be None to specify that no labels should be used, or a property name
        or an array of labels. Alternately, it may be a tuple (l1, l2) of the labels for datasets 1 
        and 2. If the argument is Ellipsis, then it will use labels if they are found in the
        retinotopy dataset; both datasets may contain labels in which the gold standard's labels are
        used if there is a gold standard and the overlapping labels are used otherwise.
      * visual_area_mask (default: None) specifies a list of visual areas included in the
        calculation; this is applied to all datasets with a visual_area key; see the 'visual_area'
        columns above and the visual_area option. If None, then no visual areas are filtered;
        otherwise, arguments should like (1,2,3), which would usually specify that areas V1, V2, and
        V3, be included.
      * gold (default: None) specifies which dataset should be considered the gold standard; this
        should be either 1 or 2. If a gold-standard dataset is specified, then it is used in certain
        calculations; for example, when scaling an error by eccentricity, the gold-standard's
        eccentricity will be used unless there is no gold standard, in which case the mean of the
        two values are used.
    '''
    if arg3 is not None: (obj, dsets) = (arg1, [retinotopy_data(arg1, aa) for aa in (arg2,arg3)])
    else:                (obj, dsets) = (None,    [arg1, arg2])
    (gi,gold) = (None,False) if not gold else (gold-1,True)
    # we'll build up this result as we go...
    result = {}
    # they must have a retinotopy representation:
    vis = [as_retinotopy(ds, 'visual') for ds in dsets]
    ps = (vis[0][0], vis[1][0])
    es = (vis[0][1], vis[1][1])
    rs = [ds['radius'] if 'radius' in ds else None for ds in dsets]
    for ii in (0,1):
        s = '_%d' % (ii + 1)
        (p,e) = (ps[ii],es[ii])
        result['polar_angle'  + s] = p
        result['eccentricity' + s] = e
        if rs[ii] is not None: result['radius' + s] = rs[ii]
        p = np.pi/180.0 * (90.0 - p)
        (x,y) = (e*np.cos(p), e*np.sin(p))
        result['x' + s] = x
        result['y' + s] = y
        result['z' + s] = x + 1j * y
    n = len(ps[0])
    # figure out the weight
    if pimms.is_vector(weight) and len(weight) == 2:
        ws = [(None  if w is None                   else
               ds[w] if pimms.is_str(w) and w in ds else
               geo.to_property(obj, w))
              for (w,ds) in zip(weight, dsets)]
        weight = Ellipsis
    else:
        ws = [next((ds[k] for k in ('weight','variance_explained') if k in ds), None)
              for ds in dsets]
    if pimms.is_vector(weight, 'real'):
        wgt = weight
    elif pimms.is_str(weight):
        if obj is None: raise ValueError('weight property name but no vertex-set given')
        wgt = geo.to_property(obj, weight)
    elif weight is Ellipsis:
        if gold: wgt = ws[gi]
        elif ws[0] is None and ws[1] is None: wgt = None
        elif ws[0] is None: wgt = ws[1]
        elif ws[1] is None: wgt = ws[0]
        else: wgt = ws[0] * ws[1]
    else: raise ValueError('Could not parse weight argument')
    if wgt is None: wgt = np.ones(n)
    if ws[0] is not None: result['weight_1'] = ws[0]
    if ws[1] is not None: result['weight_2'] = ws[1]
    # figure out the visual areas
    if is_tuple(visual_area) and len(visual_area) == 2:
        ls = [(None  if l is None                   else
               ds[l] if pimms.is_str(l) and l in ds else
               geo.to_property(obj, l))
              for (l,ds) in zip(visual_area, dsets)]
        visual_area = Ellipsis
    else:
        ls = [next((ds[k] for k in ('visual_area','label') if k in ds), None)
              for ds in dsets]
    if pimms.is_vector(visual_area):
        lbl = visual_area
    elif pimms.is_str(visual_area):
        if obj is None: raise ValueError('visual_area property name but no vertex-set given')
        lbl = geo.to_property(obj, visual_area)
    elif visual_area is None:
        lbl = None
    elif visual_area is Ellipsis:
        if gold: lbl = ls[gi]
        elif ls[0] is None and ls[1] is None: lbl = None
        elif ls[0] is None: lbl = ls[1]
        elif ls[1] is None: lbl = ls[0]
        else: lbl = l[0] * (l[0] == l[1])
    else: raise ValueError('Could not parse visual_area argument')
    if ls[0] is not None: result['visual_area_1'] = ls[0]
    if ls[1] is not None: result['visual_area_2'] = ls[1]
    # Okay, now let's do some filtering; we clear weights as we go
    wgt = np.array(wgt)
    # Weight must be greater than the min
    if weight_min is not None: wgt[wgt < weight_min] = 0
    # Visual areas must be in the mask
    lbl = None if lbl is None else np.array(lbl)
    if lbl is not None and visual_area_mask is not None:
        if pimms.is_int(visual_area_mask): visual_area_mask = [visual_area_mask]
        oomask = (0 == np.sum([lbl == va for va in visual_area_mask], axis=0))
        wgt[oomask] = 0
        lbl[oomask] = 0
    if lbl is not None: result['visual_area'] = lbl
    # eccen must be in range
    if eccentricity_range is not None:
        er = eccentricity_range
        if pimms.is_real(er): er = (0,er)
        if gold: wgt[(es[gi] < er[0]) | (es[gi] > er[1])] = 0
        else:    wgt[(es[0] < er[0]) | (es[0] > er[1]) | (es[1] < er[0]) | (es[1] > er[1])] = 0
    # angle must be in range
    if polar_angle_range is not None:
        pr = polar_angle_range
        if pimms.is_str(pr):
            pr = pr.lower()
            if   pr in ['lh', 'rvf']: pr = (   0, 180)
            elif pr in ['rh', 'lvf']: pr = (-180,   0)
            else: raise ValueError('unrecognized polar angle range argument: %s' % pr)
        if gold: wgt[(ps[gi] < pr[0]) | (ps[gi] > pr[1])] = 0
        else:    wgt[(ps[0] < pr[0]) | (ps[0] > pr[1]) | (ps[1] < pr[0]) | (ps[1] > pr[1])] = 0
    # okay! Now we can add the weight into the result
    result['weight'] = wgt * zinv(np.sum(wgt))
    # now we add a bunch of calculations we can perform on the data!
    # first: metrics of distance
    gsecc = es[gi] if gold else np.mean(es, axis=0)
    gsang = ps[gi] if gold else np.mean(ps, axis=0)
    gsrad = rs[gi] if gold else rs[0] if rs[1] is None else rs[1] if rs[0] is None else \
            np.mean(rs, axis=0)
    gsecc_inv = zinv(gsecc)
    gsrad_inv = None if gsrad is None else zinv(gsrad)
    for (tag,resprop) in [('z', 'center'), ('polar_angle', 'polar_angle'),
                          ('eccentricity', 'eccentricity'), ('x', 'x'), ('y', 'y')]:
        serr = result[tag + '_1'] - result[tag + '_2']
        aerr = np.abs(serr)
        result[resprop + '_error'] = serr
        result[resprop + '_abs_error'] = aerr
        result[resprop + '_scaled_error'] = aerr * gsecc_inv
        if gsrad_inv is not None:
            result[resprop + '_radii_error'] = aerr * gsrad_inv
    return pimms.itable(result)

def visual_isolines(hemi, retinotopy='any', visual_area=Ellipsis, mask=None, surface='midgray',
                    weights=Ellipsis, min_weight=0.05,
                    eccentricity_range=None, polar_angle_range=None,
                    eccentricity_lines=8, polar_angle_lines=8,
                    max_step_scale=1.0/16.0):
    '''
    visual_isolines(hemi) yields a dictionary of the iso-angle and iso-eccentricity lines for the
      given hemisphere hemi. The returned structure depends on the optional arguments and is
      documented below.
    visual_isolines(hemi, retino) uses the retinotopy found by retinotopy_data(hemi, retino).
    
    The following optional arguments may be given:
      * visual_area (default: Ellipsis) specifies the property that should be used as the visual
        area labels; if Ellipsis, then uses any visual area property found in the retinotopy data
        and otherwise uses none; if set to None, then the visual area is always ignored. Note that
        visual area values of 0 are ignored as well.
      * mask (default: None) specifies an additional mask that should be applied; when visual_area
        is None or not found, then this is the only masking that is performed.
      * weights (default: Ellipsis) specifies the property that should be used as the weights on
        the polar angle and eccentricity data; if Ellipsis, indicates that the variance explained or
        weight property found in the retinotopy data should be used if any.
      * min_weight (default: 0.05) specifies the minimum weight that should be included.
      * polar_angle_range (default: None) may speficy the (min,max) polar angle to include.
      * eccentricity_range (default: None) may specify the max or (min,max) eccentricity to include.
      * eccentricity_lines (default: 8) specifies the eccentricity values at which to draw
        iso-eccentricity lines. If this is an integer, it specifies that these should be chosen
        using even spacing along the inverse CDF of the eccentricity values above the weight
        threshold.
      * polar_angle_lines (default: 8) specifies the polar angle values at which to draw the
        iso-angular lines. If this is an integer, it specifies that these should be chosen using
        even spacing along the inverse CDF of the eccentricity values above the weight threshold.
      * surface (default: 'midgray') specifies the surface to use for calculating surface
        coordinates if hemi is a topology and not a mesh.

    Return value:
      The return value of this function is a nested dictionary structure. If a visual area property
      is used, then the first level of keys are the visual areas (excluding 0) that are found.
      After this level, the next level's keys are 'polar_angle' and 'eccentricity' with the
      following level's keys indicating the polar angle or eccentricity value that was used. These
      internal dicts of iso-angle keys map to values that are lists of lines (there may be many
      contiguous lines at one angle); each line is given by a map of addresses and other meta-data
      about the line.
    '''
    from neuropythy import (to_mask, retinotopy_data, isolines, is_cortex, to_mesh)
    from neuropythy.util import curry
    # first off, apply the mask if specified:
    if mask is not None: mask = to_mask(hemi, mask)
    else: mask = hemi.tess.indices
    # now, get the retinotopy
    retino = retinotopy_data(hemi, retinotopy)
    if   visual_area is Ellipsis: visual_area = retino.get('visual_area', None)
    elif visual_area is not None: visual_area = hemi.property(visual_area, mask=mask, null=0)
    # if there is a visual area, we just recurse setting these values as a mask
    if visual_area is not None:
        vas = np.unique(visual_area)
        kw = dict(weights=weights, min_weight=min_weight, surface=surface, visual_area=None,
                  eccentricity_lines=eccentricity_lines, polar_angle_lines=polar_angle_lines,
                  eccentricity_range=eccentricity_range, polar_angle_range=polar_angle_range,
                  max_step_scale=max_step_scale)
        def recurse(va):
            vamask = to_mask(hemi, (visual_area, va))
            return visual_isolines(hemi, retinotopy, mask=np.intersect1d(mask, vamask), **kw)
        return pimms.lazy_map({va:curry(recurse, va) for va in vas if va != 0})
    # If there are weights, figure them out and apply them to the mask
    ve = 'variance_explained'
    weights = (retino[ve] if weights is Ellipsis and ve in retino   else
               None       if weights is Ellipsis or weights is None else
               hemi.property(weights))
    if weights is not None and min_weight is not None:
        mask = np.intersect1d(mask, np.where(weights >= min_weight)[0])
    # make sure polar angle and eccen are in retino:
    (ang,ecc) = as_retinotopy(retino, 'visual')
    retino['polar_angle'] = ang
    retino['eccentricity'] = ecc
    # if there's a surface to get...
    mesh = to_mesh((hemi, surface))
    # when we calculate the isolines we use this function which also adds in the polar angles and
    # eccentricities of the addressed lines
    def calc_isolines(hemi, dat, ln, mask=None):
        addrs = isolines(hemi, dat, ln, mask=mask, yield_addresses=True)
        (angs,eccs) = [[hemi.interpolate(addr, retino[nm]) for addr in addrs]
                       for nm in ('polar_angle', 'eccentricity')]
        vxys = [np.asarray([e*np.cos(t), e*np.sin(t)])
                for (a,e) in zip(angs,eccs) for t in [np.pi/180.0*(90.0 - a)]]
        sxys = [mesh.unaddress(addr) for addr in addrs]
        # trim/cut where needed
        if max_step_scale is not None: 
            iiss = []
            for (vxy,ecc) in zip(vxys, eccs):
                dists = np.sqrt(np.sum((vxy[:,:-1] - vxy[:,1:])**2, 0))
                es = np.mean([ecc[:-1], ecc[1:]], 0)
                mx = max_step_scale * es
                bd = dists > mx
                ok = np.where(~bd)[0]
                bd = np.where(bd)[0]
                (oi,bi) = (0,0)
                iis = []
                while True:
                    if   oi >= len(ok): break
                    elif bi >= len(bd):
                        iis.append(ok[oi:])
                        break
                    elif ok[oi] < bd[bi]:
                        n = bd[bi] - ok[oi]
                        iis.append(ok[oi:(oi+n)])
                        oi += n
                    else: bi += ok[oi] - bd[bi]
                iiss.append(iis)
            # okay, fix all the lists...
            (angs,eccs) = [[u[ii]   for (u,iis) in zip(q,iiss) for ii in iis] for q in (angs,eccs)]
            (vxys,sxys) = [[u[:,ii] for (u,iis) in zip(q,iiss) for ii in iis] for q in (vxys,sxys)]
            addrs = [{k:addr[k][:,ii] for k in ('faces','coordinates')}
                     for (addr,iis) in zip(addrs,iiss) for ii in iis]
        return pimms.persist({'addresses': addrs, 'polar_angles': angs, 'eccentricities':eccs,
                              'visual_coordinates': vxys, 'surface_coordinates': sxys})
    # okay, we are operating over just the mask we have plus polar angle/eccentricity ranges
    r = {}
    (ang,ecc) = as_retinotopy(retino, 'visual')
    if eccentricity_range is not None:
        rng     = eccentricity_range
        (mn,mx) = rng if pimms.is_vector(rng) else (np.min(ecc), rng)
        mask    = np.setdiff1d(mask, np.where((ecc < mn) | (ecc > mx))[0])
    if polar_angle_range is not None:
        rng     = polar_angle_range
        (mn,mx) = rng if pimms.is_vector(rng) else (np.min(ang), rng)
        # we need to center the angle on this range here
        aa = np.mean(rng)
        aa = np.mod(ang + aa, 360) - aa
        mask = np.setdiff1d(mask, np.where((aa < mn) | (aa > mx))[0])
    for (p,dat,lns,rng) in zip(['polar_angle','eccentricity'], [ang,ecc],
                               [polar_angle_lines, eccentricity_lines],
                               [polar_angle_range, eccentricity_range]):
        # first, figure out the lines themselves
        if pimms.is_int(lns): lns = np.nanpercentile(dat[mask], np.linspace(0, 100, 2*lns + 1)[1::2])
        # now grab them from the hemisphere...
        r[p] = pimms.lazy_map({ln:curry(calc_isolines, hemi, dat, ln, mask=mask) for ln in lns})
    return pyr.pmap(r)

def clean_retinotopy_potential(hemi, retinotopy=Ellipsis, mask=Ellipsis, weight=Ellipsis,
                               surface='midgray', min_weight=Ellipsis, min_eccentricity=0.75,
                               visual_area=None, map_visual_areas=Ellipsis,
                               visual_area_field_signs=Ellipsis,
                               measurement_uncertainty=0.4, measurement_knob=1,
                               magnification_knob=2, fieldsign_knob=8, edge_knob=0, rt_knob=1):
    '''
    clean_retinotopy_potential(hemi) yields a retinotopic potential function for the given
      hemisphere that, when minimized, should yeild a cleaned/smoothed version of the retinotopic
      maps.

    The potential function f returned by clean_retinotopy_potential() is a PotentialFunction object,
    as defined in neuropythy.optimize. The potential function consists of four terms that are summed
    with weights derived from the four '*_knob' options (see below). The function f as well as the
    three terms that it comprises require as input a matrix X of the pRF centers of mesh or the
    masked part of the mesh (X0 is the initial measurement matrix). These four potential terms are:
      * The measurement potential. The retinotopic map that is being minimized is referred to as the
        measured map, and the measurement potential function, fm(X), increases as X becomes farther
        from X0. Explicitly, fm(X) is the sum over all pRF centers (x,y) in X (with initial position
        (x0,y0) in X0) of exp(-0.5 * ((x - x0)**2 + (y - y0)**2) / s**2). The parameter s is the
        initial eccentricity (sqrt(x0**2 + y0**2)) times the measurement_uncertainty option.
      * The magnification potential. The retinotopy cleaning is performed in part by attempting to
        smooth the visual magnification (the inverse of cortical magnification: deg**2 / mm**2)
        across the cortical surface; the magnification potential fg(X) specifies how the visual
        magnification contributes to the overall potential: it decreases as the magnification
        becomes smoother and increases as it becomes less smooth. Explicitly, fg(X) is equal to the
        sum over all pairs of faces (s,t) sharing one edge e of
        (vmag(s) - sgn(vmag(t))*vmag(e))**2 + (vmag(t) - sgn(vmag(s))*vmag(e))**2. Note that the
        function vmag(s) yields the areal visual magnification (deg**2 / mm**2) of any face s and
        vmag(e) is the square of the linear magnification of any edge e; additionally, the sign of
        vmag(s) for a face s is always equal to the fieldsign of the face (while for edges vmag(e)
        is always positive).
      * The fieldsign potential. The next way in which the potential function attempts to clean the
        retinotopy is via the use of fieldsign: adjacent faces should have the same fieldsign under
        most circumstanced. This is modeled by the function fs(X), which is 0 for any pair of faces
        that have the same fieldsign and non-zero for faces that have different fieldsigns. The
        form of fs(X) is the sum over all pairs of adjacent triangles (s,t) of -vmag(s)*vmag(t) if
        vmag(s) and vmag(t) have different fieldsigns, otherwise 0.
      * The edge potential. Additionally, the potential function attempts to force edges to be
        smooth by penalizing edges whose endpoints are far apart in the visual field. The edge
        potential function fe(X) is equal to the sum for all edges (u,v) of
        (x[u] - x[v])**2 + (y[u] - y[v])**2 / mean(eccen(u), eccen(v)).
      * The radial/tangential magnification potential. Finally, the smoothness of the cortical
        magnification, not just in terms of area, but also interms of its geometric orientation,
        is forced to be smooth across the visual field. This term of the potential field models
        the neighborhood vertices of each vertex as an ellipse in the visual field and minimizes
        the difference between the radial and tangential components of neighboring vertices (i.e.,
        makes sure that the radial and tangential components are each smooth across the cortical
        surface).

    Note additionally that all four potential functions are normalized by a factor intended to keep
    them on similar scales (this factor is not mentioned above or below, but it is automatically
    applied to all potential terms). For the magnification, fieldsign, and edge potential terms, the
    normalization factor is 1/m where m is the number of non-perimeter edges (or, alternately, the
    number of adjacent face pairs) in the mesh. For the measurement potential term, the
    normalization factor is 1/W where W is the sum of the weights on the measurement vertices (if
    no weights are given, they are considered to be 1 for each vertex).

    The following options may be given:
      * retinotopy (default: Ellipsis) specifies the retinotopy data to use for the hemisphere;
        the argument may be a map from retinotopy_data or a valid argument to it. The default
        indicates that the result of calling retinotopy_data(hemi) is used.
      * mask (default: Ellipsis) specifies that the specific mask should be used; by default, the
        mask is made using the vertices kept in to_flatmap('occipital_pole', hemi, radius=pi/2.75).
      * weight (default: Ellipsis) specifies the weight to use; the default indicates that the
        weight found in the retinotopy data, if any, should be used. If None, then all values
        in the mask are equally weighted.
      * visual_area (default: Ellipsis) specifies the visual area labels to use; the default
        indicates that the visual area property found in the retinotopy data should be used, if any.
        If None then no visual area splitting is done. This property is only important if 
        map_visual_areas is not False or None; otherwise it is ignored.
      * map_visual_areas (default: Ellipsis) specifies whether the return value should be a lazy map
        whose keys are visual area labels and whose values are recursed calls to this function for
        only the subset of the mesh with the associated label. May be False or None to specify that
        a single potential should be yielded. May be a list of labels to specify that only those
        visual areas should be mapped. Alternately, if specified as a tuple, then the visual areas
        listed in the tuple will be stitched together such that each visual area's overall potential
        is independent except at the edges that connect visual areas, which are still required to be
        smooth across boundaries. The default value (Ellipsis) uses all labels in visual_areas
        except for 0 and treats them as if they were listed in a tuple.
      * min_weight (default: Ellipsis) specifies the minimum weight to include, after the
        weights have been normalized such that sum(weights) == 1. If the value is a list or
        tuple containing a single item [p] then p is taken to be a percentile below which
        vertices should be excluded. The default, Ellipsis, is equivalent to [5].
      * min_eccentricity (default: 0.75) specifies the eccentricity below which no measurement-based
        potential is applied; i.e., by default, vertices with eccentricity below 0.75 degrees will
        be considered as having 0 weight.
      * surface (default: 'midgray') specifies which surface should be used to establish cortical
        magnification; may be 'pial', 'midgray', or 'white'.
      * measurement_uncertainty (default: 0.3) is used to determine the standard deviation of the 
        Gaussian potential well used to prevent individual vertices with valid retinotopic
        measurements from straying too far from their initial measured positions. In other words, if
        a vertex has a weight that is above threshold and a pRF center of (x0,y0), then the 
        measurement-potential for that vertex is exp(-0.5 * ((x - x0)**2 + (y - y0)**2)/s**2) where
        (x,y) is the center of the pRF during minimization and s is equal to
        measurement_uncertainty * sqrt(x0**2 + y0**2).
      * measurement_knob, magnification_knob, fieldsign_knob, edge_knob, and rt_knob (defaults:
        1, 2, 8, 0, and 1, respectively) specify the relative weights of the terms of the potential
        function on a log2 scale. In other words, if the measurement, magnification, fieldsign,
        edge, and rt potential terms are fm, fg, fs, fe, and fr while the knobs are km, kg, ks, ke,
        and kr, then the overall potential function f is equal to:
        f(X) = (2**km * fm(X) + 2**kg * fg(X) + 2**ks * fs(X) + 2**ke * fe(X) + 2**kr * fr(X)) / q
        where w = (2**km + 2**kg + 2**ks + 2**ke + 2**kr)
        If any knob is set to None, then its value is 0 instead of 2**k.
    '''
    from neuropythy.util import curry
    import neuropythy.optimize as op
    # first, get the mesh and the retinotopy data
    mesh = geo.to_mesh((hemi, surface))
    rdat = (retinotopy_data(mesh) if retinotopy is Ellipsis   else
            retinotopy            if pimms.is_map(retinotopy) else
            retinotopy_data(mesh, retinotopy))
    lbls = (rdat.get('visual_area') if visual_area is Ellipsis else
            None                    if visual_area is None     else
            hemi.property(visual_area))
    wght = (rdat.get('variance_explained') if weight is Ellipsis                      else
            weight                         if pimms.is_vector(weight)                 else
            rdat.get(weight)               if pimms.is_str(weight) and weight in rdat else
            hemi.property(weight)          if weight is not None                      else
            None)
    # figure out the mask
    if mask is Ellipsis:
        if mesh.coordinates.shape[0] == 2: mask = mesh.indices
        else: mask = geo.to_flatmap('occipital_pole', hemi, radius=np.pi/2.75).labels
    else: mask = hemi.mask(mask, indices=True)
    global_field_sign = None
    # if we are splitting on visual area, we should do that here:
    if pimms.is_vector(map_visual_areas) and len(map_visual_areas) == 0: map_visual_areas = None
    if map_visual_areas not in (False, None) and lbls is not None:
        # get the visual areas
        vas = (np.unique(lbls)                    if map_visual_areas == 'all'           else
               np.setdiff1d(np.unique(lbls), [0]) if map_visual_areas in [True,Ellipsis] else
               np.unique(map_visual_areas))
        if map_visual_areas is True or map_visual_areas in [Ellipsis,'all']:
            map_visual_areas = tuple(vas)
        # we also want to have the field-sign map handy if provided
        if visual_area_field_signs is None:
            visual_area_field_signs = {}
        elif visual_area_field_signs is Ellipsis:
            visual_area_field_signs = global_visual_area_field_signs
        # Three possibilities:
        #  (1) map_visual_areas is a string or integer; in this case we proceed with the calculation
        #  (2) map_visual_areas is a list, in which case we return a lazy-map of potentials
        #  (3) map_visual_areas is a tuple, in which case we stitch together potentials
        if pimms.is_int(map_visual_areas) or pimms.is_str(map_visual_areas):
            mask = np.intersect1d(mask, np.where(lbls == map_visual_areas)[0])
            global_field_sign = visual_area_field_signs.get(map_visual_areas, None)
        else:
            listq = pimms.is_list(map_visual_areas) or pimms.is_npvector(map_visual_areas)
            # we will need a lazy map of these whether map_visual_areas is a list or tuple:
            kw = dict(retinotopy=rdat, mask=mask, weight=wght,
                      surface=surface, min_weight=min_weight, min_eccentricity=min_eccentricity,
                      visual_area=lbls, measurement_uncertainty=measurement_uncertainty,
                      measurement_knob=measurement_knob, edge_knob=edge_knob, rt_knob=rt_knob,
                      magnification_knob=magnification_knob, fieldsign_knob=fieldsign_knob,
                      visual_area_field_signs=visual_area_field_signs)
            # if we got a tuple, we don't want to setup any edge fields here
            if not listq: kw['edge_knob'] = None
            pemap = pimms.lazy_map(
                {lbl: curry(clean_retinotopy_potential, hemi, map_visual_areas=lbl, **kw)
                 for lbl in vas})
            if listq: return pemap
            # it's a tuple, so we need to stitch these together with an additional edge potential
            for k in six.iterkeys(kw):
                if k.endswith('_knob'): kw[k] = None
            kw['edge_knob'] = 0 if edge_knob is None else edge_knob
            kw['visual_area'] = np.isin(lbls, vas).astype('int')
            f_edge = clean_retinotopy_potential(hemi, map_visual_areas=1, **kw)
            X0fe = f_edge.meta_data['X0']
            # If there are nans in this, we should make them 0's; we assume these just indicate
            # low confidence of a value
            X0fe[~np.isfinite(X0fe)] = 0
            # okay, now add up all of these individual potential fields with the edge field
            submesh = f_edge.meta_data['mesh']
            nparams = 2 * submesh.vertex_count
            xyii = np.reshape(np.arange(nparams), (-1,2))
            f = 0 if edge_knob is None else f_edge
            partmap = {}
            for (k,v) in six.iteritems(pemap):
                if v == 0: continue
                mm = np.isin(submesh.labels, v.meta_data['mesh'].labels)
                # Check for nans here also
                X0v = v.meta_data['X0']
                X0v[~np.isfinite(X0v)] = 0
                assert np.isclose(v.meta_data['X0'], f_edge.meta_data['X0'][mm,:]).all()
                mm = xyii[mm, :].flatten()
                partmap[k] = v.compose(op.part(mm, input_len=nparams))
                f = partmap[k] + f
            # make the meta-data for the function and return it!
            md = {'X0': f_edge.meta_data['X0'], 'mesh': submesh, 'mask': submesh.indices,
                  'f_edge': f_edge, 'fs_orig': pemap, 'fs_map': pyr.pmap(partmap)}
            object.__setattr__(f, 'meta_data', pyr.pmap(md))
            return f
    # fix rdat, weight, and mesh to match the mask
    (supermesh, orig_mask) = (mesh, mask)
    rdat = {k:(v[mask] if len(v) > len(mask) else v) for (k,v) in six.iteritems(rdat)}
    mesh = supermesh.submesh(mask)
    # possible that the mask got further downsampled:
    mask = supermesh.tess.index(mesh.labels)
    if len(mask) == len(orig_mask): smsk = np.arange(len(mask))
    else:
        tmp = set(mask)
        smsk = np.asarray([k for (k,u) in enumerate(orig_mask) if u in tmp])
    n = mesh.vertex_count # number vertices
    N = 2*n # number parameters
    if   wght is None:                wght = np.ones(n)
    elif len(wght) == len(orig_mask): wght = np.array(wght)[smsk]
    elif len(wght) > n:               wght = np.array(wght)[mask]
    else:                             wght = np.array(wght)
    wght[~np.isfinite(wght)] = 0
    if min_eccentricity < 0 or np.isclose(min_eccentricity, 0):
        raise ValueError('min_eccentricity should be small but must be > 0')
    # we'll need a potential function...
    # The input to the potential function will be a 2 x N matrix of (x,y) visual field coordinates:
    xy      = op.identity
    (x,y)   = (xy[np.arange(0,N,2)],xy[np.arange(1,N,2)])
    # We have a few components to the potential function
    # [1] Deviation from measurements:
    # These are the initial measurements we will use
    xy0     = np.array(as_retinotopy(rdat, 'geographical')).T
    if len(xy0) == len(orig_mask): xy0 = xy0[smsk]
    elif len(xy0) > n:             xy0 = xy0[mask]
    (x0,y0) = xy0.T
    xy0     = xy0.flatten()
    ecc0    = np.sqrt(x0**2 + y0**2)
    ii      = np.where(nangt(ecc0, min_eccentricity))[0]
    minw    = (0                             if min_weight is None          else
               min_weight                    if pimms.is_number(min_weight) else
               0                             if np.std(wght[ii]) < 0.00001  else
               np.percentile(wght[ii], 5)    if min_weight is Ellipsis      else
               np.percentile(wght[ii], min_weight[0]))
    ii      = np.intersect1d(ii, np.where(wght > minw)[0])
    wsum    = np.sum(wght[ii])
    if wsum < 0 or np.isclose(wsum, 0): raise ValueError('all-zero weights given')
    wght    = wght / wsum
    s2_meas = (measurement_uncertainty * ecc0[ii])**2
    d2_meas = (x[ii] - x0[ii])**2 + (y[ii] - y0[ii])**2
    f_meas  = (1 - op.exp(-0.5*d2_meas/s2_meas)) * wght[ii]
    f_meas  = op.sum(f_meas)
    # [2] For adjacent triangles, how different are the cortical magnifications?
    sarea   = mesh.face_areas
    faces   = mesh.tess.indexed_faces.T
    selen   = mesh.edge_lengths
    # we want to ensure that vmag is locally smooth across triangles, but we need
    # to make sure there aren't any edges or faces with 0 surface-areas (so that
    # we don't divide by zero)
    mnden   = 0.0001
    (e,s,t) = np.transpose([(i,e[0],e[1]) for (i,e) in enumerate(mesh.tess.edge_faces)
                            if len(e) == 2 and selen[i] > mnden
                            if sarea[e[0]] > mnden and sarea[e[1]] > mnden])
    m       = len(e)
    (fis,q) = np.unique(np.concatenate([s,t]), return_inverse=True)
    (s,t)   = np.reshape(q, (2,-1))
    faces   = faces[fis]
    sarea   = sarea[fis]
    selen   = selen[e]
    selen2  = selen**2
    (u,v)   = mesh.tess.indexed_edges[:,e]
    # we will use visual mag instead of cortical mag: this way we aren't worried about
    # varea going to 0 and creating a singularity, and it should have a linear 
    # relationship with eccentricity
    velen2  = (x[u] - x[v])**2 + (y[u] - y[v])**2
    vme     = velen2 / selen2 # visual magnification: edges
    varea   = op.signed_face_areas(faces)
    vmf     = varea / sarea # visual magnification: faces
    vms     = vmf[s]
    vmt     = vmf[t]
    vsgns   = op.sign(vmf)
    #f_magn  = (1.0 / m) * op.sum((vms - vme*vsgns[t])**2 + (vmt - vme*vsgns[s])**2)
    #vmfmu   = op.sqrt(op.abs(vms*vmt))
    f_magn  = (1.0 / m) * (op.sum((vms - vmt)**2)) # + op.sum((vmfmu - velen2)**2))
    # [3] we want a special function for faces whose vmags are different signs
    if global_field_sign is None:
        f_sign = op.compose(op.piecewise(0, ((-np.inf, 0), -op.identity)), vms*vmt)
        f_sign = (1.0 / m) * op.sum(f_sign)
    else:
        vmfsgn = vmf * global_field_sign
        f_sign = op.compose(op.piecewise(0, ((-np.inf, 0), -op.identity)), vmfsgn)
        f_sign = (1.0 / m) * op.sum(f_sign)
    # the edge potential...
    ex      = 0.5*(x[u] + x[v])
    ey      = 0.5*(y[u] + y[v])
    eecc2   = (ex**2 + ey**2)
    f_edge  = (1.0 / m) * op.sum(((x[u] - x[v])**2 + (y[u] - y[v])**2) / (eecc2 + 0.05))
    # and the rt potential...
    if rt_knob is None: f_rt = 0
    else:
        from neuropythy.vision.cmag import rtmag_potential
        f_rt = rtmag_potential(mesh, xy, fieldsign=global_field_sign)
    # This is the potential function:
    (measurement_knob, magnification_knob, fieldsign_knob, edge_knob, rt_knob) = [

        None if knob is None else (knob + clret_knob_bases[name + '_knob'])
        for (knob,name) in zip(
                [measurement_knob, magnification_knob, fieldsign_knob, edge_knob, rt_knob],
                ['measurement', 'magnification', 'fieldsign', 'edge', 'rt'])]
    (k_meas, k_magn, k_sign, k_edge, k_rt) = [
        0 if knob is None else (2.0**knob)
        for knob in (measurement_knob, magnification_knob, fieldsign_knob, edge_knob, rt_knob)]
    fs = (k_meas*f_meas, k_magn*f_magn, k_sign*f_sign, k_edge*f_edge, k_rt*f_rt)
    if all(k == 0 for k in (k_meas, k_magn, k_sign, k_edge, k_rt)):
        f = op.const_potential(0)
    else:
        f = ((0 if k_meas == 0 else fs[0]) + 
             (0 if k_magn == 0 else fs[1]) +
             (0 if k_sign == 0 else fs[2]) +
             (0 if k_edge == 0 else fs[3]) +
             (0 if k_rt   == 0 else fs[4])) / (k_meas + k_magn + k_sign + k_edge + k_rt)
    xy0 = np.reshape(xy0, (-1,2))
    object.__setattr__(f, 'meta_data',
                       pyr.m(f_meas=f_meas, f_magn=f_magn, f_sign=f_sign, f_edge=f_edge, f_rt=f_rt,
                             mesh=mesh, X0=xy0, mask=mask))
    return f

clret_default_steps   = [  50,    50,   25,    50,    25,   100,   100,   100,   100]
clret_default_average = [True, False, True, False, False, False, False, False, False]
clret_default_msknob  = [   0,    -3,   -2,    -2,    -2,    -1,    -1,     0,     0]
clret_knob_bases = {'measurement_knob': 1, 'magnification_knob': 2,
                    'fieldsign_knob': 8,   'edge_knob': 0,  'rt_knob': 1}
def clean_retinotopy(hemi,
                     retinotopy=Ellipsis,
                     mask=Ellipsis,
                     weight=Ellipsis,
                     surface='midgray',
                     min_weight=Ellipsis,
                     min_eccentricity=0.5,
                     visual_area=Ellipsis,
                     map_visual_areas=Ellipsis,
                     visual_area_field_signs=Ellipsis,
                     method=['L-BFGS-B', 'TNC'],
                     measurement_uncertainty=0.4,
                     measurement_knob=clret_default_msknob,
                     magnification_knob=0,
                     fieldsign_knob=0,
                     edge_knob=0,
                     rt_knob=0,
                     yield_report=False,
                     steps=clret_default_steps,
                     jitter=None,
                     average=clret_default_average,
                     initial_retinotopy=None,
                     output_style='visual',
                     round_fn=None):

    '''
    clean_retinotopy(hemi) attempts to cleanup the retinotopic maps on the given cortical mesh by
      minimizing an objective function that tracks the smoothness of the fields, the orthogonality
      of polar angle to eccentricity, and the deviation of the values from the measured values; the
      yielded result is the smoothed retinotopy, as would be returned by
      as_retinotopy(..., 'visual').

    The argument hemi may be a Cortex object or a Mesh. See also clean_retinotopy_potential for
    information on the various parameters related to the potential function that is minimized by
    clean_retinotopy(). The following additional options are also accepted:
      * output_style (default: 'visual') specifies the style of the output data that should be
        returned; this should be a string understood by as_retinotopy.
      * steps (default: below) specifies the max number of minimization steps to run. A list of step
        numbers may be given, in which case, that many minimization rounds are used with a different
        number of steps in each.
      * method (default: ['L-BFGS-B', 'TNC']) specifies the method to be used in each of the
        minimization rounds (as determined by the number of elements in the steps argument). If
        fewer methods than rounds are specified, then the method argument is repeated as needed.
      * jitter (default: None) specifies whether and how to jitter the vertices during
        minimization. Jittering adds a customizable amount of exponentially-distributed random noise
        to the vertex position at the start of certain minimization rounds. The jitter should be
        specified as a list of jitter scales for each round (rounds are determined by the number of
        integers in the steps option). For a vertex with an eccentricity of e, the jitter is added
        in a uniformly-distributed direction with a length drawn from an exponential distribution
        with a mean of (scale * e). If jitter is set to True, Ellipsis, or 'auto', then [0, 0.05, 0]
        is used. If the number of rounds is greater than the number of jitter entries, then the
        jitter list is considered to be repeated indefinitely.
      * average (default: below) specifies whether and how to average the vertices during
        minimization. Averaging is similar to jittering in that it is run prior to minimization in
        customizable steps. However, unlike jittering, averaging has no scale and so is specified
        by a list of True or False values. In a round during which averaging is being done, all
        vetices are moved to the mean position of their neighboring vertices, prior to that round's
        minimization. If True, Ellipsis, or 'auto' are given, then [False,False,True]. Like with 
        jittering, a list that is shorter than the number of rounds is repeated as needed.
      * yield_report (False) may be set to True, in which case a tuple (retino, report) is returned,
        where the report is the return value of the scipy.optimization.minimize function.
      * initial_retinotopy (default: None) specifies an alternate set of retinotopy to use as the
        starting positions for the vetices. If this is not a valid retinotopy_data dictionary, then
        it must be either None or it must be a matrix in the geographical retinotopy style (i.e.,
        a 2 x n (x,y) matrix with x and y in degrees).
      * round_fn (default: None) may optionally be a function of two arguments (round_number,
        xy_matrix) that is called at the beginning of each minimization round as well as once
        at the conclusion of minimization. When called at the end of the function, the round_number
        argument is len(steps).

    Note that the number of rounds is customizable via the steps option. In addition, various
    options may be declared in a round-specific manner, as with jitter and average (see above).
    These options are: jitter, average, measurement_uncertainty, and all the knob options (e.g.,
    measurement_knob).

    The default behavior of clean_retinotopy is to follow a cleaning plan that unfolds over several
    rounds; the initial rounds are fast but include averaging in order to escape local minima and to
    jump-start the trajectory. Additionally, the measurement_knob is gradually turned up over the
    course of the minimization. The default arguments are as follows (msknob is the measurement
    knob):
    steps   = [  50,    50,   25,    50,    25,   100,   100,   100,   100]
    average = [True, False, True, False, False, False, False, False, False]
    msknob  = [   0,    -3,   -2,    -2,    -2,    -1,    -1,     0,     0]

    Note that the default value for all knobs is 0; the weights placed in front of each term of the
    potential field are calibrated to be reasonable for these values.
    '''
    # Parse our args.
    if jitter in [True, Ellipsis, 'auto', 'automatic']: jitter = [0, 0.05, 0]
    elif pimms.is_number(jitter): jitter = [jitter]
    elif jitter is None or jitter is False: jitter = [0]
    if average in [True, Ellipsis, 'auto', 'automatic']: average = [False, False, True]
    elif average is False or average is None: average = [False]
    if visual_area_field_signs is None: visual_area_field_signs = {}
    if round_fn is None: round_fn = lambda a,b: None
    # We have to handle the possibility that steps and various other arguments are lists; get them
    # all turned into lists here:
    oneround = pimms.is_int(steps) # only one round requested
    (steps, method, measurement_uncertainty,
     measurement_knob, magnification_knob, fieldsign_knob, edge_knob, rt_knob) = [
        np.asarray(u if pimms.is_vector(u) else [u])
        for u in (steps, method, measurement_uncertainty,
                  measurement_knob, magnification_knob, fieldsign_knob, edge_knob, rt_knob)]
    pe_param_names = [
        'measurement_uncertainty', 'measurement_knob', 'magnification_knob',
        'fieldsign_knob', 'edge_knob', 'rt_knob']
    pe_params = [
        measurement_uncertainty, measurement_knob, magnification_knob,
        fieldsign_knob, edge_knob, rt_knob]
    rounds = len(steps)
    tess = hemi if geo.is_tess(hemi) else hemi.tess
    # we want to avoid making the same potential function twice, so we're going to cache them
    # as we go; we setup this function to make a potential function:
    pe_cache = {}
    pe_opts = dict(retinotopy=retinotopy, mask=mask, weight=weight, surface=surface,
                   min_weight=min_weight, min_eccentricity=min_eccentricity,
                   visual_area=visual_area, map_visual_areas=map_visual_areas,
                   visual_area_field_signs=visual_area_field_signs)
    def make_pe(rno):
        knobs = tuple([u[rno % len(u)] for u in pe_params])
        if knobs in pe_cache: return pe_cache[knobs]
        # we need to make a new potential function
        kw = dict(pe_opts)
        for (k,v) in zip(pe_param_names, knobs): kw[k] = v
        f = clean_retinotopy_potential(hemi, **kw)
        pe_cache[knobs] = f
        return f
    # figure out initial coordinates
    iret = initial_retinotopy
    if   pimms.is_map(iret):    xy = np.transpose(as_retinotopy(iret, 'geographical'))
    elif pimms.is_matrix(iret): xy = np.array(iret)
    elif iret is None:          xy = np.full((hemi.vertex_count, 2), np.nan)
    else: raise ValueError('could not interpret initial_retinotopy argument')
    # okay, now we can start doing rounds:
    reports = None
    xys = []
    for rno in range(rounds):
        st = steps[rno % len(steps)]
        pe = make_pe(rno)
        # at this point, it's possible that we got a lazy map back; if so we're going to want to
        # iterate through each of the masks/potential functions
        m = pe if pimms.is_map(pe) else {None: pe}
        if reports is None: reports = {k:[] for k in six.iterkeys(m)}
        if rno == 0 and initial_retinotopy is None:
            # at this point if initial_retinotopy was not provided, we want to get the xy values
            # setup so that we can operate on them
            for (k,f) in six.iteritems(m):
                submesh = f.meta_data['mesh']
                xy[submesh.labels] = f.meta_data['X0']
        mtd = method[rno % len(method)]
        jit = jitter[rno % len(jitter)]
        avg = average[rno % len(average)]
        round_fn(rno, xy)
        # if we have to do any jitter, do it how; averaging we do per potential function in m.
        if jit is not None and jit > 0:
            ec = np.sqrt(np.sum(xy**2, axis=1))
            th = (np.random.rand(len(ec)) - 0.5)*2*np.pi
            r  = np.random.exponential(ec * jit)
            xy = xy + np.transpose([r*np.cos(th), r*np.sin(th)])
        for (k,f) in six.iteritems(m):
            submesh = f.meta_data['mesh']
            ii = submesh.labels
            # if we have averaging to do, do it here where we have a submesh
            if avg:
                xy[ii] = [xy[ii[k]] if len(nn) == 0 else np.mean(xy[list(nn)], axis=0)
                          for (k,nn) in enumerate(submesh.tess.neighborhoods)]
            # run the minimization
            rr = f.minimize(xy[ii], method=mtd, options=dict(maxiter=st, disp=False))
            reports[k].append(rr)
            xy[ii] = rr.x
        # that's all we need to do this round
        xys.append(np.transpose(xy))
    round_fn(rounds, xy)
    # Done with the minimization! Go ahead and return
    if yield_report:
        xys = [as_retinotopy({'x':x, 'y':y}, output_style) for (x,y) in xys]
        if oneround:
            reports = {k: v[0] for (k,v) in six.iteritems(reports)}
            xys = xys[0]
        if len(reports) == 1 and next(six.iterkeys(reports)) is None: reports = reports[None]
        return (xys, reports)
    (x,y) = xy.T
    return as_retinotopy({'x':x, 'y':y}, output_style)

def visual_field_mesh(max_eccentricity=12, hemi='lr', resolution=0.18):
    '''
    visual_field_mesh() yields a 2D mesh object that tiles the visual field up to 12 degrees of
      eccentricity.
    visual_field_mesh(max_ecc) tiles the visual field out to max_ecc degrees of eccentricity.
    visual_field_mesh(max_ecc, hemi) tiles only the right ('lh') or left ('rh') visual field out to
      max_ecc degrees of eccentricity; 'lr' can be given for both hemispheres.
      
    The optional argument resolution (default: 1) may be modified to adjust the resolution of the
    points returned up or down.
    '''
    from neuropythy.util import to_hemi_str
    from neuropythy import mesh
    from scipy.spatial import Delaunay
    h = to_hemi_str(hemi)
    if max_eccentricity is None: max_eccentricity = 90
    # parse resolution: we want an odd number of 
    def to_oddeven(k, mod=1):
        kk = np.max([int(np.round(k)), 1])
        if   kk % 2 == mod: return kk
        elif kk < k:        return kk + 1
        else:               return kk - 1
    # okay, decide what polar angle and eccentricity values go into the mesh:
    if   h == 'lh': angle = np.linspace(0, 180, to_oddeven(180*resolution))
    elif h == 'rh': angle = np.linspace(-180, 0, to_oddeven(180*resolution))
    else:           angle = np.linspace(-180, 180, to_oddeven(360*resolution, 0), endpoint=False)
    theta = np.pi/180.0 * (90 - angle)
    nang = len(angle)
    # for eccentricity, we want to logspace it:
    eccen = np.logspace(-3, 1, to_oddeven(nang/np.pi, 0), base=2)
    #eccen = np.arange(to_oddeven(nang/np.pi, 0))
    eccen -= np.min(eccen)
    eccen = eccen / np.max(eccen) * (max_eccentricity - 0.15) + 0.15
    necc = len(eccen)
    (t, r) = np.meshgrid(theta, eccen)
    # here's the tricky part: we want to slightly adjust the values of r for every other row...
    r[1:, 1::2] = np.mean([r[:-1, 1::2], r[1:, 1::2]], axis=0)
    r[0, 1::2] -= 0.075
    (t, r) = [u.flatten() for u in (t, r)]
    (x, y) = (r*np.cos(t), r*np.sin(t))
    # okay, make the mesh:
    xy = np.asarray([x,y])
    tt = Delaunay(xy.T)
    return mesh(tt.simplices.T, xy)

def sectors_to_labels(sectors, vertex_count):
    '''
    sectors_to_labels(sectors, vertex_count) yields a 2-tuple containing, first, a dictionary that
      maps sector labels onto sector boundaries (i.e., keys in the sectors argument), and, second,
      a property vector that contains the relevant sector label for each vertex.
    '''    
    p = np.zeros(vertex_count, dtype=np.int)
    key = {}
    for (lblm1,(k,ii)) in enumerate(sectors.items()):
        lbl = lblm1 + 1
        key[lbl] = k
        p[ii] = lbl
    return (key, p)
def labels_to_sectors(sector_key, labels):
    '''
    labels_to_sectors(sector_key, labels) yields a dictionary that whose keys are the values of
      the given sector_key dictionary and whose values are the vertices that are in each
      associated sector according the the labels argument.
    '''
    res = {}
    for (lbl,k) in sector_key.items():
        res[k] = np.where(labels == lbl)[0]
    return res
def sector_bounds(hemi, sectors):
    '''
    sector_bounds(hemi, sectors, output_bounds) yields a nested dictionary whose top-level keys
      are the same as in the input dictioinary sectors but whose values are dictionaries detailing
      the boundaries of the sector. These dictionaries have as keys either (angle,None) or 
      (None,eccen), indicating a sector boundary, and whose values are indices of the vertices that
      lie along that particular bounndary.

    The argument sectors should be a dictionary whose keys have the form of a 4-tuple
    (min_polar_angle, max_polar_angle, min_eccentricity, max_eccentricity) and whose values are the
    vertex indices of the associated sector.
    '''
    # Make a sector index property.
    (skey, lbls) = sectors_to_labels(sectors, hemi.vertex_count)
    (u,v) = hemi.tess.indexed_edges
    # Now, walk through each sector and build up the results.
    res = {}
    for (lbl,k) in skey.items():
        # For this label we will put results in rr.
        (mnang,mxang,mnecc,mxecc) = k
        ii = sectors[k]
        # Figure out what labels go with mnang, mxang, mnecc, mxecc...
        mnang_lbl = next((lbl for (lbl,(mnang2,mxang2,mnecc2,mxecc2)) in six.iteritems(skey)
                          if mnang == mxang2 and mnecc == mnecc2 and mxecc == mxecc2),
                         0) # 0 on no match because 0 is for unlabelled vertices.
        mxang_lbl = next((lbl for (lbl,(mnang2,mxang2,mnecc2,mxecc2)) in six.iteritems(skey)
                          if mxang == mnang2 and mnecc == mnecc2 and mxecc == mxecc2),
                         0)
        mnecc_lbl = next((lbl for (lbl,(mnang2,mxang2,mnecc2,mxecc2)) in six.iteritems(skey)
                          if mnecc == mxecc2 and mnang == mnang2 and mxang == mxang2),
                         0) # 0 on no match because 0 is for unlabelled vertices.
        mxecc_lbl = next((lbl for (lbl,(mnang2,mxang2,mnecc2,mxecc2)) in six.iteritems(skey)
                          if mxecc == mnecc2 and mnang == mnang2 and mxang == mxang2),
                         0)
        # For each of these we build of the indices along the edge.
        rr = {}
        l1 = lbls == lbl
        for (lbl2,kk) in zip([mnang_lbl, mxang_lbl, mnecc_lbl, mxecc_lbl],
                             [(mnang,None), (mxang,None), (None,mnecc), (None,mxecc)]):
            l2 = lbls == lbl2
            ee_u = np.where(l1[u] & l2[v])[0]
            ee_v = np.where(l1[v] & l2[u])[0]
            rr[kk] = np.intersect1d(np.union1d(u[ee_u], v[ee_v]), ii)
        res[k] = rr
    return res
def _sector_boundary_distances(sctmesh, bounds):
    from neuropythy.geometry import Path
    res = {}
    for (ae,ii) in bounds.items():
        # We estimate the distances using Path.estimate_distances; it requires addresses
        # for the initial path points, but they needn't be in order, and they needn't be
        # made of real faces.
        ii = np.intersect1d(ii, sctmesh.labels)
        if len(ii) == 0:
            res[ae] = np.zeros(0)
        else:
            faces = np.array([ii,ii,ii], dtype=np.int)
            coords = np.zeros((2,len(ii)))
            coords[0,:] = 1.0
            addrs = {'faces':faces, 'coordinates':coords}
            dists = Path.estimate_distances(addrs, sctmesh)
            res[ae] = dists
    return res
def _estimate_crossings(dist, val, refs):
    '''
    Estimates the distances at which val equals the various refs.
    '''
    from sklearn.isotonic import IsotonicRegression
    # Sort the distances and vals by dist.
    ii_sort = np.argsort(dist)
    dist = dist[ii_sort]
    val  = val[ii_sort]
    ok = np.where(np.isfinite(dist) & np.isfinite(val))[0]
    if len(ok) < 4: raise ValueError('fewer than 4 valid values given to _estimate_crossings')
    # Also sort the refs.
    refs = np.sort(refs)
    # Now, make an isotonic regression of the data so that we can split things easily
    ir = IsotonicRegression(out_of_bounds="clip")
    y = ir.fit_transform(dist[ok], val[ok])
    # The y value is the isotonic estimate of the values; we split based on this.
    binnos = np.digitize(y, refs)
    dist_stops = np.full(len(refs), -np.inf)
    for ii in range(len(refs)):
        jj = np.where(binnos == ii)[0]
        if len(jj) == 0:
            if ii != 0: dist_stops[ii] = dist_stops[ii-1]
            continue
        jj = ok[jj[-1]]
        if jj == len(dist) - 1:
            dist_stops[ii] = np.inf
        else:
            dist_stops[ii] = np.mean([dist[jj], dist[jj+1]])
    return dist_stops
def refit_sectors(mesh, sectors, outangs, outeccs,
                  retinotopy=Ellipsis, mask=None, invert_angle=False):
    '''
    refit_sectors(hemi, sectors, output_angles, output_eccens) yields a dictionary of sectors in
      which keys take the form of a 4-tuple (min_polar_angle, max_polar_angle, min_eccentricity,
      max_eccentricity) and values are the vertex indices of the associated sector.
      
    The elements in the keys must be given in neuropythy's visual format, i.e., polar angle is
    measured in clockwise degrees of rotation with 0 at the upper vertical meridian, and
    eccentricity is measured in degrees of visual angle.

    The argument sectors must be an input dictionary identical in format to the output dictionary
    but with an alternative set of sectors. These sectors are refit and returned. The argument
    outbounds must be a list of the keys that should be returned.
    
    The following options can be given:
      * retinotopy (default: Ellipsis) specifies the parameter that will be passed to the
        neuropythy.retinotopy_data() function to obtain the retinotopy fom the mesh. If retinotopy
        is a 2-tuple (angle,eccen) then the values are not processed via as_retinotopy.
      * mask (default: None) specifies the mask that should be used when estimating the distance
        cutoffs for each ROI.
      * invert_angle (default: False) specifies whether to invert the polar angle values
        or not. For a left hemisphere, this is not needed; for a right hemisphere it generally
        is if you have specified your sector angles using positive values instead of negative
        polar angle values.

    Notes:
      * scikit-learn is required to use refit_sectors(), although it is not required by
        neuropythy generally.
      * Make sure that your polar angle values are centered around the sector values you provide;
        if a sector contains values that wrap around from 180 to -180, the isotonic interpolater
        that is used can get very confused.
    '''
    try: import sklearn
    except ImportError:
        raise ImportError(
            'could not import sklearn; scikit-learn must be intalled to use refit_sectors')
    import neuropythy as ny
    # Get the retinotopy data.
    if pimms.is_tuple(retinotopy):
        (ang,ecc) = retinotopy
    else:
        rdat = ny.retinotopy_data(mesh, retinotopy)
        (ang,ecc) = ny.as_retinotopy(rdat, 'visual')
    if invert_angle: ang = -ang
    # Prepare the mask if there is one.
    if mask is not None:
        tmp = mesh.mask(mask, indices=True)
        mask = np.zeros(mesh.vertex_count, dtype=np.bool)
        mask[tmp] = True
    # Sort the output angles and eccens
    (outangs, outeccs) = (np.sort(outangs), np.sort(outeccs))
    # We need to know which vertices in each sector correspond to which boundary.
    bounds = sector_bounds(mesh, sectors)
    # We will collect results in this dictionary:
    res = {(mnang,mxang,mnecc,mxecc): np.zeros(0, dtype=np.int)
           for (mnang,mxang) in zip(outangs[1:], outangs[:-1])
           for (mnecc,mxecc) in zip(outeccs[1:], outeccs[:-1])}
    # Basically we just apply the same algorithm to each existing sector, joining up the
    # pieces we find along the way.
    res = {(out_mn_ang,out_mx_ang,out_mn_ecc,out_mx_ecc): np.zeros(0, dtype=np.int)
           for (out_mn_ang,out_mx_ang) in zip(outangs[:-1], outangs[1:])
           for (out_mn_ecc,out_mx_ecc) in zip(outeccs[:-1], outeccs[1:])}
    for (k, ii) in sectors.items():
        (mn_ang,mx_ang,mn_ecc,mx_ecc) = k
        if mn_ang >= outangs[-1] or mx_ang <= outangs[0]: continue
        if mn_ecc >= outeccs[-1] or mx_ecc <= outeccs[0]: continue
        # First, we can go ahead and make a subtess.
        submesh = mesh.submesh(ii)
        submask = submesh.indices if mask is None else mask[submesh.labels]
        # Now, we want to calculate distances.
        dists = _sector_boundary_distances(submesh, bounds[k])
        # We use a combination of the two that varies linearly between the two extrema; this
        # version (mn^2 - mx^2) will increase from min to max angle/eccen value.
        (d_ang_mn, d_ang_mx) = (dists[(mn_ang,None)], dists[(mx_ang,None)])
        for d_ang_mnmx in [d_ang_mn, d_ang_mx]:
            fin = np.isfinite(d_ang_mnmx)
            d_ang_mnmx[~fin] = np.max(d_ang_mnmx[fin])
            bad = ~np.isfinite(d_ang_mnmx)
        d_ang = (-d_ang_mx if len(d_ang_mn) == 0 else
                 d_ang_mn  if len(d_ang_mx) == 0 else
                 (d_ang_mn - d_ang_mx) * ny.math.zinv(d_ang_mn + d_ang_mx))
        (d_ecc_mn, d_ecc_mx) = (dists[(None,mn_ecc)], dists[(None,mx_ecc)])
        for d_ecc_mnmx in [d_ecc_mn, d_ecc_mx]:
            fin = np.isfinite(d_ecc_mnmx)
            d_ecc_mnmx[~fin] = np.max(d_ecc_mnmx[fin])
        d_ecc = (-d_ecc_mx if len(d_ecc_mn) == 0 else
                 d_ecc_mn  if len(d_ecc_mx) == 0 else
                 (d_ecc_mn - d_ecc_mx) * ny.math.zinv(d_ecc_mn + d_ecc_mx))
        # Now we need to know what boundaries to find inside of this sector; we do them in
        # sorted order from min to max angle/eccen.
        crossing_angs = [ang for ang in outangs if ang >= mn_ang and ang <= mx_ang]
        crossing_eccs = [ecc for ecc in outeccs if ecc >= mn_ecc and ecc <= mx_ecc]
        # If there are output sector boundaries that correspond exactly with the input sector
        # boundaries we don't want to pass them to the _estimate_crossings function.
        incross_angs = [ang for ang in crossing_angs if ang > mn_ang and ang < mx_ang]
        incross_eccs = [ecc for ecc in crossing_eccs if ecc > mn_ecc and ecc < mx_ecc]
        # For each of these, we need to find the best estimate of the crossings distances.
        mesh_submask = submesh.labels[submask]
        dcross_ang = _estimate_crossings(d_ang[submask], ang[mesh_submask], incross_angs)
        dcross_ecc = _estimate_crossings(d_ecc[submask], ecc[mesh_submask], incross_eccs)
        # For each sub-sector, we need to add the indices into the appropriate macro-sector;
        # keep in mind that the macro-sectors may end at the given sector boundaries, in which
        # case we just trust those boundaries.
        # To do this, we loop over the actual bins we just calculated.
        angzip = list(zip([-np.inf] + list(dcross_ang), list(dcross_ang) + [np.inf],
                          [mn_ang] + incross_angs, incross_angs + [mx_ang]))
        ecczip = list(zip([-np.inf] + list(dcross_ecc), list(dcross_ecc) + [np.inf],
                          [mn_ecc] + incross_eccs, incross_eccs + [mx_ecc]))
        for (dc0_ang, dc1_ang, out_mn_ang, out_mx_ang) in angzip:
            # Find the indices that are in this box based on the distances.
            ii_ang = np.where((d_ang >= dc0_ang) & (d_ang < dc1_ang))[0]
            d_ecc_ii = d_ecc[ii_ang]
            for (dc0_ecc, dc1_ecc, out_mn_ecc, out_mx_ecc) in ecczip:
                ii_bin = ii_ang[(d_ecc_ii >= dc0_ecc) & (d_ecc_ii < dc1_ecc)]
                # The ii_ang and ii_bin are submesh indices.
                ii_bin = submesh.labels[ii_bin]
                if len(ii_bin) == 0: continue
                # We have the indices in this sub-bin of the submesh/sector; we need to figure out
                # what out-bin we need to put it in.
                for (out_k,rr) in res.items():
                    if out_k[0] > out_mn_ang or out_k[1] < out_mx_ang: continue
                    if out_k[2] > out_mn_ecc or out_k[3] < out_mx_ecc: continue
                    res[out_k] = np.union1d(rr, ii_bin)
                    break
    # That's everything.
    return res
