Skip to content

Commit 2ad46b0

Browse files
authored
Add the "atldld dataset preview" command (#66)
Also: * Add the "constants" module * Add the "plot" module
1 parent fe77afa commit 2ad46b0

File tree

8 files changed

+512
-59
lines changed

8 files changed

+512
-59
lines changed

src/atldld/cli.py

Lines changed: 104 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
# You should have received a copy of the GNU Lesser General Public License
1616
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
"""The command line interface (CLI) for Atlas-Download-Tools."""
18+
from typing import Any, Dict, Optional, Sequence
19+
1820
import click
1921

2022
import atldld
@@ -60,47 +62,73 @@ def cache_folder():
6062

6163

6264
# ============================= Dataset subcommand =============================
63-
64-
65-
@click.group(help="Commands related to atlas datasets")
66-
def dataset():
67-
"""Run dataset subcommands."""
68-
69-
70-
@click.command("info", help="Get information for a given dataset ID")
71-
@click.argument("dataset_id", type=int)
72-
def dataset_info(dataset_id):
73-
"""Get information for a given dataset ID."""
74-
import textwrap
75-
76-
import atldld.dataset
65+
def get_dataset_meta_or_abort(
66+
dataset_id: int, include: Optional[Sequence[str]] = None
67+
) -> Dict[str, Any]:
68+
"""Download the dataset metadata.
69+
70+
Parameters
71+
----------
72+
dataset_id
73+
The dataset ID.
74+
include
75+
The include keys to use in the RMA query.
76+
77+
Returns
78+
-------
79+
meta : dict
80+
The dataset metadata.
81+
82+
Raises
83+
------
84+
click.Abort
85+
Whenever the metadata download fails or yields unexpected results.
86+
"""
7787
from atldld import requests
7888

7989
# Send request
8090
rma_parameters = requests.RMAParameters(
8191
"SectionDataSet",
8292
criteria={"id": dataset_id},
83-
include=("genes", "section_images"),
93+
include=include,
8494
)
8595
try:
8696
msg = requests.rma_all(rma_parameters)
8797
except requests.RMAError as exc:
88-
click.secho(
89-
f"An error occurred while querying the AIBS servers: {str(exc)}",
90-
fg="red",
98+
raise click.ClickException(
99+
f"An error occurred while querying the AIBS servers: {str(exc)}"
91100
)
92-
raise click.Abort
93101

94102
# Check response
95103
if len(msg) == 0:
96-
click.secho(f"Dataset with ID {dataset_id} does not exist", fg="red")
97-
raise click.Abort
104+
raise click.ClickException(f"Dataset with ID {dataset_id} does not exist")
98105
elif len(msg) > 1:
99-
click.secho("Something went wrong: got more than one dataset", fg="red")
100-
raise click.Abort
106+
raise click.ClickException("Something went wrong: got more than one dataset")
101107

102-
# Print response
103108
meta = msg[0]
109+
if not isinstance(meta, dict) or not all(isinstance(key, str) for key in meta):
110+
raise click.ClickException("Got an unexpected dataset information format")
111+
112+
return meta
113+
114+
115+
@click.group(help="Commands related to atlas datasets")
116+
def dataset():
117+
"""Run dataset subcommands."""
118+
119+
120+
@click.command("info", help="Get information for a given dataset ID")
121+
@click.argument("dataset_id", type=int)
122+
def dataset_info(dataset_id):
123+
"""Get information for a given dataset ID."""
124+
import textwrap
125+
126+
import atldld.dataset
127+
128+
# Download the dataset metadata
129+
meta = get_dataset_meta_or_abort(dataset_id, include=["genes", "section_images"])
130+
131+
# Print response
104132
section_images = meta.pop("section_images")
105133
r_str = meta["red_channel"] or "-"
106134
g_str = meta["green_channel"] or "-"
@@ -124,5 +152,57 @@ def dataset_info(dataset_id):
124152
click.secho(textwrap.dedent(output).strip(), fg="green")
125153

126154

155+
@click.command("preview", help="Plot a preview of dataset slices")
156+
@click.argument("dataset_id", type=int)
157+
@click.option(
158+
"-o",
159+
"--output-dir",
160+
type=click.Path(file_okay=False, writable=True, resolve_path=True),
161+
help="""
162+
The output directory for the plot figure. If not provided the current
163+
working directory will be used.
164+
""",
165+
)
166+
def dataset_preview(dataset_id, output_dir):
167+
"""Plot a sketch of section images mapped into the reference space."""
168+
import pathlib
169+
170+
import atldld.dataset
171+
import atldld.utils
172+
from atldld import plot
173+
174+
# Download the dataset metadata
175+
meta = get_dataset_meta_or_abort(dataset_id, include=["section_images"])
176+
plane_of_section = atldld.dataset.PlaneOfSection(meta["plane_of_section_id"])
177+
section_image_metas = meta.pop("section_images")
178+
section_image_metas.sort(key=lambda image_meta_: image_meta_["section_number"])
179+
180+
click.secho("Fetching the corner coordinates of the section images...", fg="green")
181+
all_corners = []
182+
with click.progressbar(section_image_metas) as progress:
183+
for image_meta in progress:
184+
corners = atldld.utils.get_corners_in_ref_space(
185+
image_meta["id"],
186+
image_meta["image_width"],
187+
image_meta["image_height"],
188+
)
189+
all_corners.append(corners)
190+
191+
click.secho("Plotting...", fg="green")
192+
img_file_name = f"dataset-id-{dataset_id}-preview.png"
193+
if output_dir is None:
194+
img_path = pathlib.Path.cwd() / img_file_name
195+
else:
196+
img_path = pathlib.Path(output_dir) / img_file_name
197+
img_path.parent.mkdir(exist_ok=True, parents=True)
198+
fig = plot.dataset_preview(all_corners, plane_of_section)
199+
fig.suptitle(f"Dataset ID {dataset_id}", fontsize=32)
200+
fig.set_dpi(200)
201+
fig.savefig(img_path)
202+
click.secho("Figure was saved in ", fg="green", nl=False)
203+
click.secho(f"{img_path.resolve().as_uri()}", fg="yellow", bold=True)
204+
205+
127206
root.add_command(dataset)
128207
dataset.add_command(dataset_info)
208+
dataset.add_command(dataset_preview)

src/atldld/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,7 @@
1919

2020
GLOBAL_CACHE_FOLDER = pathlib.Path.home() / ".atldld"
2121
GLOBAL_CACHE_FOLDER.mkdir(exist_ok=True)
22+
23+
# Volume dimensions
24+
REF_DIM_1UM = (13200, 8000, 11400)
25+
REF_DIM_25UM = (528, 320, 456)

src/atldld/plot.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# The package atldld is a tool to download atlas data.
2+
#
3+
# Copyright (C) 2021 EPFL/Blue Brain Project
4+
#
5+
# This program is free software: you can redistribute it and/or modify
6+
# it under the terms of the GNU Lesser General Public License as published by
7+
# the Free Software Foundation, either version 3 of the License, or
8+
# (at your option) any later version.
9+
#
10+
# This program is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
# GNU Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public License
16+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
"""Different plotting routines."""
18+
from typing import Iterable
19+
20+
import numpy as np
21+
from matplotlib.figure import Figure
22+
from matplotlib.lines import Line2D
23+
24+
from atldld.constants import REF_DIM_1UM
25+
from atldld.dataset import PlaneOfSection
26+
27+
28+
def dataset_preview(
29+
all_corners: Iterable[np.ndarray],
30+
plane_of_section: PlaneOfSection,
31+
) -> Figure:
32+
"""Plot a preview of how section images fit into the reference space.
33+
34+
Parameters
35+
----------
36+
all_corners
37+
The corners of all section images. Each element in this iterable should
38+
be a NumPy array of shape (4, 3). The format of this array corresponds
39+
to that returned by the `atldld.utils.get_corners_in_ref_space`
40+
function.
41+
42+
The first axis refers to the four corners of a section image in the
43+
following order:
44+
45+
1. Lower left (0, 0)
46+
2. Lower right (0, 1)
47+
3. Upper right (1, 1)
48+
4. Upper left (1, 0)
49+
50+
This corresponds to following the corners counterclockwise starting with
51+
the corner in the axes origin. The second array axis contains the 3D
52+
coordinates of the corners in the standard PIR references space.
53+
54+
plane_of_section
55+
The plane of section of the dataset. Can be either coronal or sagittal.
56+
57+
Returns
58+
-------
59+
fig
60+
The figure with the plot.
61+
"""
62+
# A semi-arbitrary choice of the reference space scale. This choice only
63+
# changes the ticks on the axes, but not the overall plot. The 25µm scale
64+
# is one of the common scales used for volumes.
65+
ref_space_scale = 25
66+
ref_space_size = np.array(REF_DIM_1UM) / ref_space_scale
67+
p, i, r = 0, 1, 2
68+
labels = {
69+
p: "p (coronal)",
70+
i: "i (transverse)",
71+
r: "r (sagittal)",
72+
}
73+
# We'll plot the views of all four edges in counterclockwise order starting
74+
# with the bottom edge. The last two x-axes are inverted so that the edge
75+
# vertices on the right of a plot appear on the left of the following plot.
76+
edges = [[0, 1], [1, 2], [2, 3], [3, 0]]
77+
titles = ["Bottom Edges", "Right Edges", "Top Edges", "Left Edges"]
78+
inverts = [False, False, True, True]
79+
80+
# Depending on the plane of section the views are different
81+
if plane_of_section == PlaneOfSection.CORONAL:
82+
y_axis = p
83+
x_axes = [r, i, r, i]
84+
elif plane_of_section == PlaneOfSection.SAGITTAL:
85+
y_axis = r
86+
x_axes = [p, i, p, i]
87+
else: # pragma: no cover
88+
raise NotImplementedError(f"Unknown plane of section: {plane_of_section}")
89+
90+
# The figure width is fixed and arbitrary. (Is there a more clever choice?)
91+
# The figure height is computed to match the ratio between the total width
92+
# and height of all subplots. This way the ratios of the x and y axes are
93+
# roughly the same (but not quite since the in-between spaces, titles, etc.
94+
# are not taken into account...)
95+
plot_width = sum(ref_space_size[x_axis] for x_axis in x_axes)
96+
plot_height = ref_space_size[y_axis]
97+
fig_width_inches = 14
98+
fig_height_inches = fig_width_inches * plot_height / plot_width
99+
100+
fig = Figure(figsize=(fig_width_inches, fig_height_inches))
101+
fig.set_tight_layout(True)
102+
# The width ratios of subplots are based on the reference volume dimensions,
103+
# this way the scales of the x-axes roughly match.
104+
axs = fig.subplots(
105+
ncols=4,
106+
sharey=True,
107+
gridspec_kw={"width_ratios": [ref_space_size[x_axis] for x_axis in x_axes]},
108+
)
109+
# Y-label only on the left-most plot because it's the same for all plots
110+
axs[0].set_ylabel(labels[y_axis], fontsize=16)
111+
112+
# Add the legend for the reference space lines
113+
ref_space_line_style = {"color": "blue", "linestyle": ":"}
114+
line = Line2D([], [], **ref_space_line_style)
115+
axs[0].legend(
116+
[line],
117+
["Reference space boundary"],
118+
loc="upper left",
119+
bbox_to_anchor=(0, -0.2),
120+
borderaxespad=0,
121+
frameon=False,
122+
)
123+
124+
# The actual plotting
125+
for ax, edge, x_axis, invert, title in zip(axs, edges, x_axes, inverts, titles):
126+
# Axes setup
127+
ax.grid(True, linestyle=":", color="gray")
128+
ax.set_title(title)
129+
ax.set_xlabel(labels[x_axis], fontsize=16)
130+
131+
# Reference space boundary lines
132+
ax.axvline(0, **ref_space_line_style)
133+
ax.axvline(ref_space_size[x_axis], **ref_space_line_style)
134+
ax.axhline(0, **ref_space_line_style)
135+
ax.axhline(ref_space_size[y_axis], **ref_space_line_style)
136+
137+
# Plot the section image edges
138+
for corners in all_corners:
139+
points = corners[np.ix_(edge, [x_axis, y_axis])]
140+
coords = points.T / ref_space_scale
141+
ax.plot(*coords, color="green")
142+
ax.scatter(*coords, color="red")
143+
if invert:
144+
ax.invert_xaxis()
145+
146+
return fig

src/atldld/utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,67 @@ def get_image(
142142
return img
143143

144144

145+
def get_corners_in_ref_space(
146+
image_id: int,
147+
image_width: int,
148+
image_height: int,
149+
) -> np.ndarray:
150+
"""Get the corner coordinates of a section image in the reference space.
151+
152+
Parameters
153+
----------
154+
image_id
155+
The section image ID.
156+
image_width
157+
The width of the section image.
158+
image_height
159+
The height of the section image.
160+
161+
Returns
162+
-------
163+
ref_corners : np.ndarray
164+
165+
Notes
166+
-----
167+
The x and y coordinates in the API requests refer to the mathematical
168+
axes with the origin in the lower left corner of the plotted image. This
169+
is not the same as the array indices of ``image`` since the element
170+
``image[0, 0]`` is mapped to the upper left corner, ``image[i_max, 0]`` to
171+
the lower left corner, etc.
172+
173+
Mathematical coordinates:
174+
175+
.. code-block:: text
176+
177+
^ (0, 1) (1, 1)
178+
|
179+
|
180+
+------------------------->
181+
(0, 0) (1, 0)
182+
183+
Corresponding elements of the image array:
184+
^ image[0, 0] image[0, j_max]
185+
|
186+
|
187+
+------------------------->
188+
image[i_max, 0] image[i_max, j_max]
189+
190+
"""
191+
# Can we do this with affine transforms without sending a separate query
192+
# for each corner???
193+
ref_corners = []
194+
for x, y in (
195+
(0, 0),
196+
(image_width, 0),
197+
(image_width, image_height),
198+
(0, image_height),
199+
):
200+
pir = xy_to_pir_API_single(x, y, image_id)
201+
ref_corners.append(pir)
202+
203+
return np.array(ref_corners)
204+
205+
145206
def get_experiment_list_from_gene(gene_name, axis="sagittal"):
146207
"""Get Allen's experiments IDs for a given gene expression name.
147208

0 commit comments

Comments
 (0)