Skip to content
Snippets Groups Projects
Commit 432abc93 authored by Paul McCarthy's avatar Paul McCarthy :mountain_bicyclist:
Browse files

Merge branch 'rf/gifti' into 'master'

Rf/gifti

See merge request fsl/fslpy!206
parents 6fefefb6 c92f18ed
No related branches found
No related tags found
No related merge requests found
Pipeline #5107 passed
......@@ -2,6 +2,29 @@ This document contains the ``fslpy`` release history in reverse chronological
order.
2.9.0 (Under development)
-------------------------
Added
^^^^^
* New ``firstDot`` option to the :func:`.path.getExt`,
:func:`.path.removeExt`, and :func:`.path.splitExt`, functions, offering
rudimentary support for double-barrelled filenames.
Changed
^^^^^^^
* The :func:`.gifti.relatedFiles` function now supports files with
BIDS-style naming conventions.
* The :mod:`.bids` module has been updated to support files with any
extension, not just those in the core BIDS specification (``.nii``,
``.nii.gz``, ``.json``, ``.tsv``).
2.8.4 (Monday 2nd March 2020)
-----------------------------
......
......@@ -25,12 +25,14 @@ are available:
import glob
import re
import os.path as op
import numpy as np
import nibabel as nib
import fsl.utils.path as fslpath
import fsl.utils.bids as bids
import fsl.data.constants as constants
import fsl.data.mesh as fslmesh
......@@ -45,6 +47,15 @@ EXTENSION_DESCRIPTIONS = ['GIFTI surface file', 'GIFTI file']
"""A description for each of the :data:`ALLOWED_EXTENSIONS`. """
VERTEX_DATA_EXTENSIONS = ['.func.gii',
'.shape.gii',
'.label.gii',
'.time.gii']
"""File suffixes which are interpreted as GIFTI vertex data files,
containing data values for every vertex in the mesh.
"""
class GiftiMesh(fslmesh.Mesh):
"""Class which represents a GIFTI surface image. This is essentially
just a 3D model made of triangles.
......@@ -102,8 +113,11 @@ class GiftiMesh(fslmesh.Mesh):
# as the specfiied one.
if loadAll:
# Only attempt to auto-load sensibly
# named gifti files (i.e. *.surf.gii,
# rather than *.gii).
surfFiles = relatedFiles(infile, [ALLOWED_EXTENSIONS[0]])
nvertices = vertices[0].shape[0]
surfFiles = relatedFiles(infile, ALLOWED_EXTENSIONS)
for sfile in surfFiles:
......@@ -259,7 +273,7 @@ def prepareGiftiVertexData(darrays, filename=None):
vertices.
"""
intents = set([d.intent for d in darrays])
intents = {d.intent for d in darrays}
if len(intents) != 1:
raise ValueError('{} contains multiple (or no) intents'
......@@ -298,45 +312,109 @@ def relatedFiles(fname, ftypes=None):
directory which appear to be related with the given one. Files which
share the same prefix are assumed to be related to the given file.
This function assumes that the GIFTI files are named according to a
standard convention - the following conventions are supported:
- HCP-style, i.e.: ``<subject>.<hemi>.<type>.<space>.<ftype>.gii``
- BIDS-style, i.e.:
``<source_prefix>_hemi-<hemi>[_space-<space>]*_<suffix>.<ftype>.gii``
If the files are not named according to one of these conventions, this
function will return an empty list.
:arg fname: Name of the file to search for related files for
:arg ftype: If provided, only files with suffixes in this list are
searched for. Defaults to files which contain vertex data.
searched for. Defaults to :attr:`VERTEX_DATA_EXTENSIONS`.
"""
if ftypes is None:
ftypes = ['.func.gii', '.shape.gii', '.label.gii', '.time.gii']
ftypes = VERTEX_DATA_EXTENSIONS
# We want to return all files in the same
# directory which have the following name:
path = op.abspath(fname)
dirname, fname = op.split(path)
# We want to identify all files in the same
# directory which are associated with the
# given file. We assume that the files are
# named according to one of the following
# conventions:
#
# [subj].[hemi].[type].*.[ftype]
# - HCP style:
# <subject>.<hemi>.<type>.<space>.<ftype>.gii
#
# where
# - [subj] is the subject ID, and matches fname
# - BIDS style:
# <source_prefix>_hemi-<hemi>[_space-<space>]*.<ftype>.gii
#
# - [hemi] is the hemisphere, and matches fname
# We cannot assume consistent ordering of
# the entities (key-value pairs) within a
# BIDS style filename, so we cannot simply
# use a regular expression or glob pattern.
# Instead, for each style we define:
#
# - [type] defines the file contents
# - a "matcher" function, which tests
# whether the file matches the style,
# and returns the important elements
# from the file name.
#
# - suffix is func, shape, label, time, or `ftype`
path = op.abspath(fname)
dirname, fname = op.split(path)
# get the [subj].[hemi] prefix
try:
subj, hemi, _ = fname.split('.', 2)
prefix = '.'.join((subj, hemi))
except Exception:
# - a "searcher" function, which takes
# the elements of the input file
# that were extracted by the matcher,
# and searches for other related files
# HCP style - extract "<subject>.<hemi>"
# and "<space>".
def matchhcp(f):
pat = r'^(.*\.[LR])\..*\.(.*)\..*\.gii$'
match = re.match(pat, f)
if match:
return match.groups()
else:
return None
def searchhcp(match, ftype):
prefix, space = match
template = '{}.*.{}{}'.format(prefix, space, ftype)
return glob.glob(op.join(dirname, template))
# BIDS style - extract all entities (kv
# pairs), ignoring specific irrelevant
# ones.
def matchbids(f):
try: match = bids.BIDSFile(f)
except ValueError: return None
match.entities.pop('desc', None)
return match
def searchbids(match, ftype):
allfiles = glob.glob(op.join(dirname, '*{}'.format(ftype)))
for f in allfiles:
try: bf = bids.BIDSFile(f)
except ValueError: continue
if bf.match(match, False):
yield f
# find the first style that matches
matchers = [matchhcp, matchbids]
searchers = [searchhcp, searchbids]
for matcher, searcher in zip(matchers, searchers):
match = matcher(fname)
if match:
break
# Give up if the file does
# not match any known style.
else:
return []
# Build a list of files in the same
# directory and matching the template
related = []
for ftype in ftypes:
hits = glob.glob(op.join(dirname, '{}*{}'.format(prefix, ftype)))
hits = searcher(match, ftype)
# eliminate dupes
related.extend([h for h in hits if h not in related])
# exclude the file itself
return [r for r in related if r != path]
......@@ -9,6 +9,7 @@
.. autosummary::
:nosignatures:
BIDSFile
isBIDSDir
inBIDSDir
isBIDSFile
......@@ -57,20 +58,34 @@ class BIDSFile(object):
self.suffix = suffix
def match(self, other):
def __str__(self):
"""Return a strimg representation of this ``BIDSFile``. """
return 'BIDSFile({})'.format(self.filename)
def __repr__(self):
"""Return a strimg representation of this ``BIDSFile``. """
return str(self)
def match(self, other, suffix=True):
"""Compare this ``BIDSFile`` to ``other``.
:arg other: ``BIDSFile`` to compare
:returns: ``True`` if ``self.suffix == other.suffix`` and if
all of the entities in ``other`` are present in ``self``,
``False`` otherwise.
:arg other: ``BIDSFile`` to compare
:arg suffix: Defaults to ``True``. If ``False``, the comparison
is made solely on the entity values.
:returns: ``True`` if ``self.suffix == other.suffix`` (unless
``suffix`` is ``False``) and if all of the entities in
``other`` are present in ``self``, ``False`` otherwise.
"""
suffix = self.suffix == other.suffix
suffix = (not suffix) or (self.suffix == other.suffix)
entities = True
for key, value in other.entities.items():
entities = entities and self.entities.get(key, None) == value
entities = entities and (self.entities.get(key, None) == value)
return suffix and entities
......@@ -83,7 +98,11 @@ def parseFilename(filename):
sub-01_ses-01_task-stim_bold.nii.gz
has suffix ``bold``, and entities ``sub=01``, ``ses=01`` and ``task=stim``.
has suffix ``bold``, entities ``sub=01``, ``ses=01`` and ``task=stim``, and
extension ``.nii.gz``.
.. note:: This function assumes that no period (``.``) characters occur in
the body of a BIDS filename.
:returns: A tuple containing:
- A dict containing the entities
......@@ -97,7 +116,7 @@ def parseFilename(filename):
suffix = None
entities = []
filename = op.basename(filename)
filename = fslpath.removeExt(filename, ['.nii', '.nii.gz', '.json'])
filename = fslpath.removeExt(filename, firstDot=True)
parts = filename.split('_')
for part in parts[:-1]:
......@@ -148,7 +167,7 @@ def isBIDSFile(filename, strict=True):
"""
name = op.basename(filename)
pattern = r'([a-z0-9]+-[a-z0-9]+_)*([a-z0-9])+\.(nii|nii\.gz|json)'
pattern = r'([a-z0-9]+-[a-z0-9]+_)*([a-z0-9])+\.(.+)'
flags = re.ASCII | re.IGNORECASE
match = re.fullmatch(pattern, name, flags) is not None
......
......@@ -218,37 +218,61 @@ def addExt(prefix,
return allPaths[0]
def removeExt(filename, allowedExts=None):
def removeExt(filename, allowedExts=None, firstDot=False):
"""Returns the base name of the given file name. See :func:`splitExt`. """
return splitExt(filename, allowedExts)[0]
return splitExt(filename, allowedExts, firstDot)[0]
def getExt(filename, allowedExts=None):
def getExt(filename, allowedExts=None, firstDot=False):
"""Returns the extension of the given file name. See :func:`splitExt`. """
return splitExt(filename, allowedExts)[1]
return splitExt(filename, allowedExts, firstDot)[1]
def splitExt(filename, allowedExts=None):
def splitExt(filename, allowedExts=None, firstDot=False):
"""Returns the base name and the extension from the given file name.
If ``allowedExts`` is ``None``, this function is equivalent to using::
If ``allowedExts`` is ``None`` and ``firstDot`` is ``False``, this
function is equivalent to using::
os.path.splitext(filename)
If ``allowedExts`` is provided, but the file does not end with an allowed
extension, a tuple containing ``(filename, '')`` is returned.
If ``allowedExts`` is ``None`` and ``firstDot`` is ``True``, the file
name is split on the first period that is found, rather than the last
period. For example::
splitExt('image.nii.gz') # -> ('image.nii', '.gz')
splitExt('image.nii.gz', firstDot=True) # -> ('image', '.nii.gz')
If ``allowedExts`` is provided, ``firstDot`` is ignored. In this case, if
the file does not end with an allowed extension, a tuple containing
``(filename, '')`` is returned.
:arg filename: The file name to split.
:arg allowedExts: Allowed/recognised file extensions.
:arg firstDot: Split the file name on the first period, rather than the
last period. Ignored if ``allowedExts`` is specified.
"""
# If allowedExts is not specified,
# we just use op.splitext
# If allowedExts is not specified
# we split on a period character
if allowedExts is None:
return op.splitext(filename)
# split on last period - equivalent
# to op.splitext
if not firstDot:
return op.splitext(filename)
# split on first period
else:
idx = filename.find('.')
if idx == -1:
return filename, ''
else:
return filename[:idx], filename[idx:]
# Otherwise, try and find a suffix match
extMatches = [filename.endswith(ext) for ext in allowedExts]
......
......@@ -19,7 +19,7 @@ import fsl.utils.bids as fslbids
def test_parseFilename():
with pytest.raises(ValueError):
fslbids.parseFilename('bad.txt')
fslbids.parseFilename('bad_file.txt')
tests = [
('sub-01_ses-01_t1w.nii.gz',
......@@ -62,12 +62,12 @@ def test_isBIDSFile():
Path('sub-01_ses-01_t1w.nii'),
Path('sub-01_ses-01_t1w.json'),
Path('a-1_b-2_c-3_d-4_e.nii.gz'),
Path('sub-01_ses-01_t1w.txt'),
]
badfiles = [
Path('sub-01_ses-01.nii.gz'),
Path('sub-01_ses-01_t1w'),
Path('sub-01_ses-01_t1w.'),
Path('sub-01_ses-01_t1w.txt'),
Path('sub_ses-01_t1w.nii.gz'),
Path('sub-01_ses_t1w.nii.gz'),
]
......
......@@ -711,6 +711,16 @@ def test_splitExt():
print(filename, '==', (outbase, outext))
assert fslpath.splitExt(filename, allowed) == (outbase, outext)
# firstDot=True
tests = [
('blah', ('blah', '')),
('blah.blah', ('blah', '.blah')),
('blah.one.two', ('blah', '.one.two')),
('blah.one.two.three', ('blah', '.one.two.three')),
]
for f, exp in tests:
assert fslpath.splitExt(f, firstDot=True) == exp
def test_getFileGroup_imageFiles_shouldPass():
......
......@@ -47,9 +47,9 @@ def test_GiftiMesh_create_loadAll():
with tempdir() as td:
vertSets = [op.join(td, 'prefix.L.1.surf.gii'),
op.join(td, 'prefix.L.2.surf.gii'),
op.join(td, 'prefix.L.3.surf.gii')]
vertSets = [op.join(td, 'prefix.L.1.space.surf.gii'),
op.join(td, 'prefix.L.2.space.surf.gii'),
op.join(td, 'prefix.L.3.space.surf.gii')]
for vs in vertSets:
shutil.copy(testfile, vs)
......@@ -154,82 +154,122 @@ def test_loadGiftiVertexData():
assert isinstance(gimg, nib.gifti.GiftiImage)
assert tuple(data.shape) == (642, 10)
def test_relatedFiles():
listing = [
'subject.L.ArealDistortion_FS.32k_fs_LR.shape.gii',
'subject.L.ArealDistortion_MSMSulc.32k_fs_LR.shape.gii',
'subject.L.BA.32k_fs_LR.label.gii',
'subject.L.MyelinMap.32k_fs_LR.func.gii',
'subject.L.MyelinMap_BC.32k_fs_LR.func.gii',
'subject.L.SmoothedMyelinMap.32k_fs_LR.func.gii',
'subject.L.SmoothedMyelinMap_BC.32k_fs_LR.func.gii',
'subject.L.aparc.32k_fs_LR.label.gii',
'subject.L.aparc.a2009s.32k_fs_LR.label.gii',
'subject.L.atlasroi.32k_fs_LR.shape.gii',
'subject.L.corrThickness.32k_fs_LR.shape.gii',
'subject.L.curvature.32k_fs_LR.shape.gii',
'subject.L.flat.32k_fs_LR.surf.gii',
'subject.L.inflated.32k_fs_LR.surf.gii',
'subject.L.midthickness.32k_fs_LR.surf.gii',
'subject.L.pial.32k_fs_LR.surf.gii',
'subject.L.sphere.32k_fs_LR.surf.gii',
'subject.L.sulc.32k_fs_LR.shape.gii',
'subject.L.thickness.32k_fs_LR.shape.gii',
'subject.L.very_inflated.32k_fs_LR.surf.gii',
'subject.L.white.32k_fs_LR.surf.gii',
'subject.R.ArealDistortion_FS.32k_fs_LR.shape.gii',
'subject.R.ArealDistortion_MSMSulc.32k_fs_LR.shape.gii',
'subject.R.BA.32k_fs_LR.label.gii',
'subject.R.MyelinMap.32k_fs_LR.func.gii',
'subject.R.MyelinMap_BC.32k_fs_LR.func.gii',
'subject.R.SmoothedMyelinMap.32k_fs_LR.func.gii',
'subject.R.SmoothedMyelinMap_BC.32k_fs_LR.func.gii',
'subject.R.aparc.32k_fs_LR.label.gii',
'subject.R.aparc.a2009s.32k_fs_LR.label.gii',
'subject.R.atlasroi.32k_fs_LR.shape.gii',
'subject.R.corrThickness.32k_fs_LR.shape.gii',
'subject.R.curvature.32k_fs_LR.shape.gii',
'subject.R.flat.32k_fs_LR.surf.gii',
'subject.R.inflated.32k_fs_LR.surf.gii',
'subject.R.midthickness.32k_fs_LR.surf.gii',
'subject.R.pial.32k_fs_LR.surf.gii',
'subject.R.sphere.32k_fs_LR.surf.gii',
'subject.R.sulc.32k_fs_LR.shape.gii',
'subject.R.thickness.32k_fs_LR.shape.gii',
'subject.R.very_inflated.32k_fs_LR.surf.gii',
'subject.R.white.32k_fs_LR.surf.gii',
'badly-formed-filename.surf.gii'
]
lsurfaces = [l for l in listing if (l.startswith('subject.L') and
l.endswith('surf.gii'))]
lrelated = [l for l in listing if (l.startswith('subject.L') and
not l.endswith('surf.gii'))]
rsurfaces = [l for l in listing if (l.startswith('subject.R') and
l.endswith('surf.gii'))]
rrelated = [l for l in listing if (l.startswith('subject.R') and
not l.endswith('surf.gii'))]
hcp_listing = [
'subject.L.ArealDistortion_FS.32k_fs_LR.shape.gii',
'subject.L.ArealDistortion_MSMSulc.32k_fs_LR.shape.gii',
'subject.L.BA.32k_fs_LR.label.gii',
'subject.L.MyelinMap.32k_fs_LR.func.gii',
'subject.L.MyelinMap_BC.32k_fs_LR.func.gii',
'subject.L.SmoothedMyelinMap.32k_fs_LR.func.gii',
'subject.L.SmoothedMyelinMap_BC.32k_fs_LR.func.gii',
'subject.L.aparc.32k_fs_LR.label.gii',
'subject.L.aparc.a2009s.32k_fs_LR.label.gii',
'subject.L.atlasroi.32k_fs_LR.shape.gii',
'subject.L.corrThickness.32k_fs_LR.shape.gii',
'subject.L.curvature.32k_fs_LR.shape.gii',
'subject.L.flat.32k_fs_LR.surf.gii',
'subject.L.inflated.32k_fs_LR.surf.gii',
'subject.L.midthickness.32k_fs_LR.surf.gii',
'subject.L.pial.32k_fs_LR.surf.gii',
'subject.L.sphere.32k_fs_LR.surf.gii',
'subject.L.sulc.32k_fs_LR.shape.gii',
'subject.L.thickness.32k_fs_LR.shape.gii',
'subject.L.very_inflated.32k_fs_LR.surf.gii',
'subject.L.white.32k_fs_LR.surf.gii',
'subject.R.ArealDistortion_FS.32k_fs_LR.shape.gii',
'subject.R.ArealDistortion_MSMSulc.32k_fs_LR.shape.gii',
'subject.R.BA.32k_fs_LR.label.gii',
'subject.R.MyelinMap.32k_fs_LR.func.gii',
'subject.R.MyelinMap_BC.32k_fs_LR.func.gii',
'subject.R.SmoothedMyelinMap.32k_fs_LR.func.gii',
'subject.R.SmoothedMyelinMap_BC.32k_fs_LR.func.gii',
'subject.R.aparc.32k_fs_LR.label.gii',
'subject.R.aparc.a2009s.32k_fs_LR.label.gii',
'subject.R.atlasroi.32k_fs_LR.shape.gii',
'subject.R.corrThickness.32k_fs_LR.shape.gii',
'subject.R.curvature.32k_fs_LR.shape.gii',
'subject.R.flat.32k_fs_LR.surf.gii',
'subject.R.inflated.32k_fs_LR.surf.gii',
'subject.R.midthickness.32k_fs_LR.surf.gii',
'subject.R.pial.32k_fs_LR.surf.gii',
'subject.R.sphere.32k_fs_LR.surf.gii',
'subject.R.sulc.32k_fs_LR.shape.gii',
'subject.R.thickness.32k_fs_LR.shape.gii',
'subject.R.very_inflated.32k_fs_LR.surf.gii',
'subject.R.white.32k_fs_LR.surf.gii'
]
bids_listing = [
'sub-001_ses-001_hemi-L_desc-corr_space-T2w_thickness.shape.gii',
'sub-001_ses-001_hemi-L_desc-drawem_space-T2w_dparc.label.gii',
'sub-001_ses-001_hemi-L_space-T2w_desc-medialwall_mask.shape.gii',
'sub-001_ses-001_hemi-L_desc-smoothed_space-T2w_myelinmap.shape.gii',
'sub-001_ses-001_hemi-L_space-T2w_curv.shape.gii',
'sub-001_ses-001_hemi-L_space-T2w_inflated.surf.gii',
'sub-001_ses-001_hemi-L_space-T2w_midthickness.surf.gii',
'sub-001_ses-001_hemi-L_space-T2w_myelinmap.shape.gii',
'sub-001_ses-001_hemi-L_space-T2w_pial.surf.gii',
'sub-001_ses-001_hemi-L_space-T2w_sphere.surf.gii',
'sub-001_ses-001_hemi-L_space-T2w_sulc.shape.gii',
'sub-001_ses-001_hemi-L_space-T2w_thickness.shape.gii',
'sub-001_ses-001_hemi-L_space-T2w_veryinflated.surf.gii',
'sub-001_ses-001_hemi-L_space-T2w_wm.surf.gii',
'sub-001_ses-001_hemi-R_desc-corr_space-T2w_thickness.shape.gii',
'sub-001_ses-001_hemi-R_desc-drawem_space-T2w_dparc.label.gii',
'sub-001_ses-001_hemi-R_space-T2w_desc-medialwall_mask.shape.gii',
'sub-001_ses-001_hemi-R_desc-smoothed_space-T2w_myelinmap.shape.gii',
'sub-001_ses-001_hemi-R_space-T2w_curv.shape.gii',
'sub-001_ses-001_hemi-R_space-T2w_inflated.surf.gii',
'sub-001_ses-001_hemi-R_space-T2w_midthickness.surf.gii',
'sub-001_ses-001_hemi-R_space-T2w_myelinmap.shape.gii',
'sub-001_ses-001_hemi-R_space-T2w_pial.surf.gii',
'sub-001_ses-001_hemi-R_space-T2w_sphere.surf.gii',
'sub-001_ses-001_hemi-R_space-T2w_sulc.shape.gii',
'sub-001_ses-001_hemi-R_space-T2w_thickness.shape.gii',
'sub-001_ses-001_hemi-R_space-T2w_veryinflated.surf.gii',
'sub-001_ses-001_hemi-R_space-T2w_wm.surf.gii'
]
def test_relatedFiles_hcp(): _test_relatedFiles(hcp_listing)
def test_relatedFiles_bids(): _test_relatedFiles(bids_listing)
def _test_relatedFiles(listing):
listing = list(listing)
listing.append('badly-formed-filename.surf.gii')
def ishemi(f, hemi):
return ('hemi-{}'.format(hemi) in f) or \
('.{}.' .format(hemi) in f)
def issurf(f):
return f.endswith('surf.gii')
def isrelated(f):
return not issurf(f)
lsurfaces = [l for l in listing if issurf( l) and ishemi(l, 'L')]
lrelated = [l for l in listing if isrelated(l) and ishemi(l, 'L')]
rsurfaces = [l for l in listing if issurf( l) and ishemi(l, 'R')]
rrelated = [l for l in listing if isrelated(l) and ishemi(l, 'R')]
with tempdir() as td:
listing = [op.join(td, f) for f in listing]
lsurfaces = [op.join(td, f) for f in lsurfaces]
rsurfaces = [op.join(td, f) for f in rsurfaces]
lrelated = [op.join(td, f) for f in lrelated]
rrelated = [op.join(td, f) for f in rrelated]
for l in listing:
with open(op.join(td, l), 'wt') as f:
with open(l, 'wt') as f:
f.write(l)
badname = op.join(op.join(td, 'badly-formed-filename'))
badname = op.join(td, 'badly-formed-filename.surf.gii')
assert len(gifti.relatedFiles(badname)) == 0
assert len(gifti.relatedFiles('nonexistent')) == 0
llisting = [op.join(td, f) for f in listing]
lsurfaces = [op.join(td, f) for f in lsurfaces]
rsurfaces = [op.join(td, f) for f in rsurfaces]
lrelated = [op.join(td, f) for f in lrelated]
rrelated = [op.join(td, f) for f in rrelated]
for s in lsurfaces:
result = gifti.relatedFiles(s)
assert sorted(lrelated) == sorted(result)
......@@ -237,12 +277,13 @@ def test_relatedFiles():
result = gifti.relatedFiles(s)
assert sorted(rrelated) == sorted(result)
exp = lsurfaces + lrelated
exp = lsurfaces
exp = [f for f in exp if f != lsurfaces[0]]
result = gifti.relatedFiles(lsurfaces[0],
ftypes=gifti.ALLOWED_EXTENSIONS)
ftypes=[gifti.ALLOWED_EXTENSIONS[0]])
assert sorted(exp) == sorted(result)
TEST_VERTS = np.array([
[0, 0, 0],
[1, 0, 0],
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment