Commit 8370ebaf authored by Sean Fitzgibbon's avatar Sean Fitzgibbon
Browse files

Merge branch 'new_chart_reg' into 'master'

Improved chart-to-slide registration

See merge request !2
parents 3f1720fd b896788f
...@@ -132,18 +132,24 @@ You can register a Neurolucida chart file to a 2D-slide using the `CHART` subcom ...@@ -132,18 +132,24 @@ You can register a Neurolucida chart file to a 2D-slide using the `CHART` subcom
```shell ```shell
>> slider_app.py CHART -h >> slider_app.py CHART -h
usage: slider_app.py CHART [-h] [--out <dir>] <chart> <slide> <slide-resolution> usage: slider_app.py CHART [-h] [--outdir <dir>] [--boundary_key <key>] [--justify <left-right>]
[--config <config.yaml>]
<chart> <slide> <slide-resolution>
Register charting to 2D slide Register charting to 2D slide
positional arguments: positional arguments:
<chart> Neurolucida chart file <chart> Neurolucida chart file
<slide> Fixed slide <slide> Fixed slide
<slide-resolution> Slide image resolution (mm) <slide-resolution> Slide image resolution (mm)
optional arguments: optional arguments:
-h, --help show this help message and exit -h, --help show this help message and exit
--out <dir> Output directory --outdir <dir> Output directory
--boundary_key <key> Name of boundary contour in chart
--justify <left-right> Justify chart bounding-box to the left/right of the image bounding box.
Useful when only one hemisphere is charted.
--config <config.yaml> configuration file
``` ```
For example: For example:
......
This diff is collapsed.
...@@ -41,16 +41,25 @@ import numpy as np ...@@ -41,16 +41,25 @@ import numpy as np
# WANTED_SECTIONS = { # WANTED_SECTIONS = {
# 'Asterix': 0, # 'Asterix': 0,
# } # }
# UNWANTED_SECTION_NAMES = [
# 'Color', 'Closed','Stereology','StereologyPropertyExtension','MBFObjectType','FillDensity', 'GUID', 'ImageCoords', 'MBFObjectType',
# 'Marker', 'Name', 'Resolution', 'Set', 'Description',
# 'Cross', 'Dot', 'DoubleCircle', 'FilledCircle', 'FilledDownTriangle',
# 'FilledSquare', 'FilledStar', 'FilledUpTriangle', 'FilledUpTriangle', 'Flower',
# 'Flower2', 'OpenCircle', 'OpenDiamond', 'OpenDownTriangle', 'OpenSquare', 'OpenStar',
# 'OpenUpTriangle', 'Plus', 'ShadedStar', 'Splat', 'TriStar', 'Sections', 'SSM', 'SSM2',
# ]
UNWANTED_SECTION_NAMES = [ UNWANTED_SECTION_NAMES = [
'Color', 'Closed','Stereology','StereologyPropertyExtension','MBFObjectType','FillDensity', 'GUID', 'ImageCoords', 'MBFObjectType', 'Stereology','StereologyPropertyExtension','MBFObjectType','FillDensity', 'ImageCoords', 'MBFObjectType',
'Marker', 'Name', 'Resolution', 'Set', 'Description', 'Marker', 'Name', 'Set', 'Description',
'Cross', 'Dot', 'DoubleCircle', 'FilledCircle', 'FilledDownTriangle', 'Cross', 'Dot', 'DoubleCircle', 'FilledCircle', 'FilledDownTriangle',
'FilledSquare', 'FilledStar', 'FilledUpTriangle', 'FilledUpTriangle', 'Flower', 'FilledSquare', 'FilledStar', 'FilledUpTriangle', 'FilledUpTriangle', 'Flower',
'Flower2', 'OpenCircle', 'OpenDiamond', 'OpenDownTriangle', 'OpenSquare', 'OpenStar', 'Flower2', 'OpenCircle', 'OpenDiamond', 'OpenDownTriangle', 'OpenSquare', 'OpenStar',
'OpenUpTriangle', 'Plus', 'ShadedStar', 'Splat', 'TriStar', 'Sections', 'SSM', 'SSM2', 'OpenUpTriangle', 'Plus', 'ShadedStar', 'Splat', 'TriStar', 'Sections', 'SSM', 'SSM2', 'Site'
] ]
UNWANTED_SECTIONS = {name: True for name in UNWANTED_SECTION_NAMES} UNWANTED_SECTIONS = {name: True for name in UNWANTED_SECTION_NAMES}
# --- section parsing ---
def _match_section(section, match): def _match_section(section, match):
'''checks whether the `type` of section is in the `match` dictionary '''checks whether the `type` of section is in the `match` dictionary
...@@ -177,20 +186,89 @@ def _flatten_subsection(subsection, _type, offset, parent): ...@@ -177,20 +186,89 @@ def _flatten_subsection(subsection, _type, offset, parent):
offset += 1 offset += 1
yield _row yield _row
# --- data parsing ---
def _to_data(sections): def is_number(s):
cells = [] '''Test if string is a number'''
contour = [] try:
float(s)
return True
except ValueError:
return False
def is_point(p):
'''Test if section looks like a point section'''
result = True
if len(p) not in [4, 5]: result = False
if not all([is_number(n) for n in p[:4]]): result = False
return result
def parse_point(p):
'''Parse a point section'''
return [float(p0) for p0 in p[:-1]] + [p[-1]]
def parse_contour(c0):
'''Convert contour section into a dictionary'''
c0_dict = {
'name': c0[0].replace('"',''),
'closed': False,
}
points = []
for p in c0[1:]:
if p[0] == 'Closed':
c0_dict['closed'] = True
elif p[0] == 'Color':
c0_dict['color'] = p[1]
elif p[0] == 'GUID':
c0_dict['guid'] = p[1].replace('"','')
elif p[0] == 'Resolution':
c0_dict['resolution'] = float(p[1])
elif is_point(p):
points.append(parse_point(p)[:4])
else:
raise ValueError(f'Unknown property: ({p})')
c0_dict['points'] = np.array(points, dtype=np.float64)
return c0_dict
def parse_asterisk(cell):
'''Convert cell (asterisk) section into a dictionary'''
cell_dict = {}
for prop in cell[1:]:
if prop[0] == 'Color':
cell_dict['color'] = prop[1]
elif is_point(prop):
cell_dict['point'] = np.array(parse_point(prop)[:-1])
else:
raise ValueError(f'Unknown property: ({prop})')
return cell_dict
def to_data(sections):
# convert sections to dicts
asterisks = []
contours = []
for section in sections: for section in sections:
if section[0] == 'Asterisk': # Cell if section[0] == 'Asterisk': # Cell
cells.append( np.asarray(section[2][:4],dtype=np.float64) ) asterisks.append(parse_asterisk(section))
else: else:
contour.append((section[0].replace('"',''), np.asarray([s[:4] for s in section[1:]],dtype=np.float64))) contours.append(parse_contour(section))
cells = np.asarray(cells) if len(asterisks)>0: asterisks = np.asarray(asterisks)
return contour, cells return contours, asterisks
def read(file): def read(file):
...@@ -199,5 +277,7 @@ def read(file): ...@@ -199,5 +277,7 @@ def read(file):
with open(file, encoding='utf-8', errors='replace') as fd: with open(file, encoding='utf-8', errors='replace') as fd:
sections = _parse_sections(fd) sections = _parse_sections(fd)
contour, cells = _to_data(sections) contour, asterisk = to_data(sections)
return contour, cells return contour, asterisk
\ No newline at end of file
...@@ -5,6 +5,6 @@ chart: ...@@ -5,6 +5,6 @@ chart:
# key for the contour to use for aligning with image # key for the contour to use for aligning with image
boundary_key: outline boundary_key: outline
slide: slide:
# set the resolution at which registration will be performed # set the resolution at which alignment will be performed
# resolution = native-image-resolution * (2^resolution_level) # resolution = native-image-resolution * (2^resolution_level)
resolution_level: 4 resolution_level: 4
\ No newline at end of file
...@@ -16,6 +16,11 @@ ...@@ -16,6 +16,11 @@
# #
import os.path as op import os.path as op
from dataclasses import dataclass
from typing import Optional, List
import numpy as np
from slider.external import neurolucida
import glymur
def get_slider_dir() -> str: def get_slider_dir() -> str:
rpath = op.realpath(op.dirname(op.abspath(__file__))) rpath = op.realpath(op.dirname(op.abspath(__file__)))
...@@ -30,3 +35,79 @@ def get_resource(name) -> str: ...@@ -30,3 +35,79 @@ def get_resource(name) -> str:
r = op.join(get_resource_path(), name) r = op.join(get_resource_path(), name)
# asrt.assert_file_exists(r) # asrt.assert_file_exists(r)
return r return r
@dataclass
class ChartContour:
'''Container for chart contour'''
name: str
closed: bool
color: str
resolution: float
points: np.array
guid: Optional[str] = None
def __repr__(self):
r, c = self.points.shape
return f"ChartContour(name='{self.name}', guid={self.guid}, closed={self.closed}, color='{self.color}', resolution={self.resolution}, points=[{r}x{c} np.array])"
@dataclass
class ChartCell:
'''Container for chart cell'''
color: str
point: np.array
@dataclass
class Chart:
'''Container for chart'''
contours: List[ChartContour] = None
cells: List[ChartCell] = None
@property
def n_contours(self):
return len(self.contours) if self.contours is not None else 0
@property
def n_cells(self):
return len(self.cells) if self.cells is not None else 0
def get_contours(self, name=None, closed=None):
cnt = self.contours
if name is not None: cnt = [c for c in cnt if c.name==name]
if closed is not None: cnt = [c for c in cnt if c.closed==closed]
return cnt
def get_contour_names(self):
return [contour.name for contour in c.contours] if self.n_contours > 0 else None
@classmethod
def from_neurolucida_ascii(cls, fname):
contours, cells = neurolucida.read(fname)
return cls(
[ChartContour(**cnt) for cnt in contours],
[ChartCell(**cell) for cell in cells]
)
def __repr__(self):
return f"Chart(contours=[{self.n_contours}x ChartContour], cells=[{self.n_cells}x ChartCell])"
@dataclass
class Slide:
data: np.array
resolution: float
mask: Optional[np.array] = None
@property
def shape(self):
return self.data.shape
@classmethod
def from_jp2(cls, fname, resolution):
im = glymur.Jp2k(fname)
return cls(im, resolution)
...@@ -94,13 +94,30 @@ def add_chart_cli(subparsers): ...@@ -94,13 +94,30 @@ def add_chart_cli(subparsers):
type=float, type=float,
) )
parser.add_argument( parser.add_argument(
"--out", "--outdir",
metavar="<dir>", metavar="<dir>",
help="Output directory", help="Output directory",
default="./chart-to-slide.reg", default="./chart-to-slide.reg",
type=str, type=str,
required=False, required=False,
) )
parser.add_argument(
"--boundary_key",
metavar="<key>",
help="Name of boundary contour in chart",
default=None,
type=str,
required=False,
)
parser.add_argument(
"--justify",
metavar="<left-right>",
help="Justify chart bounding-box to the left/right of the image bounding box. Useful when only one hemisphere is charted.",
default=None,
choices=['left', 'right', None],
type=str,
required=False,
)
parser.add_argument( parser.add_argument(
"--config", "--config",
metavar="<config.yaml>", metavar="<config.yaml>",
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment