plotastro 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- plotastro/__init__.py +68 -0
- plotastro/_authors.py +302 -0
- plotastro/_colors.py +227 -0
- plotastro/_core.py +256 -0
- plotastro/_extras.py +187 -0
- plotastro/styles/aanda.mplstyle +101 -0
- plotastro/styles/apj.mplstyle +101 -0
- plotastro/styles/jcap.mplstyle +100 -0
- plotastro/styles/mnras.mplstyle +101 -0
- plotastro/styles/natastro.mplstyle +102 -0
- plotastro/styles/oja.mplstyle +102 -0
- plotastro/styles/prd.mplstyle +101 -0
- plotastro/styles/rasti.mplstyle +101 -0
- plotastro-1.0.0.dist-info/METADATA +432 -0
- plotastro-1.0.0.dist-info/RECORD +18 -0
- plotastro-1.0.0.dist-info/WHEEL +4 -0
- plotastro-1.0.0.dist-info/entry_points.txt +2 -0
- plotastro-1.0.0.dist-info/licenses/LICENSE +23 -0
plotastro/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""plotastro — publication-quality matplotlib figures for astronomy journals.
|
|
2
|
+
|
|
3
|
+
One import gives you journal-matched styles, correctly sized figures and a
|
|
4
|
+
colour-blind-friendly palette:
|
|
5
|
+
|
|
6
|
+
import matplotlib.pyplot as plt
|
|
7
|
+
import plotastro as pa
|
|
8
|
+
|
|
9
|
+
pa.set_style("mnras") # or "aanda", "apj", "oja", "prd", ...
|
|
10
|
+
fig, ax = pa.subplots() # one-column, golden-ratio figure
|
|
11
|
+
ax.plot(x, y, label="model")
|
|
12
|
+
ax.set_xlabel("$x$")
|
|
13
|
+
ax.legend()
|
|
14
|
+
pa.savefig("myplot") # -> myplot.pdf
|
|
15
|
+
|
|
16
|
+
Importing plotastro also registers the styles with matplotlib itself, so
|
|
17
|
+
``plt.style.use("mnras")`` works anywhere afterwards.
|
|
18
|
+
|
|
19
|
+
Supported journals: MNRAS, RASTI, A&A, ApJ/ApJL (AASTeX), the Open Journal
|
|
20
|
+
of Astrophysics, PRD/PRL (REVTeX), JCAP and Nature Astronomy — plus
|
|
21
|
+
"thesis" and "beamer" width presets. See the README for the full tutorial.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from importlib.metadata import PackageNotFoundError, version as _version
|
|
25
|
+
|
|
26
|
+
import matplotlib as _mpl
|
|
27
|
+
import matplotlib.style as _mstyle
|
|
28
|
+
|
|
29
|
+
from ._authors import authorlist
|
|
30
|
+
from ._core import (
|
|
31
|
+
GOLDEN, JOURNALS, STYLE_DIR,
|
|
32
|
+
current_journal, figsize, savefig, set_size, set_style, subplots, use,
|
|
33
|
+
)
|
|
34
|
+
from ._colors import (
|
|
35
|
+
COLORS, CYCLE, OKABE_ITO, PAIRED, PETROFF10,
|
|
36
|
+
check_colors, check_figure, darken, lighten, simulate_cvd,
|
|
37
|
+
)
|
|
38
|
+
from ._extras import (
|
|
39
|
+
LINESTYLES, MARKERS,
|
|
40
|
+
label_panels, show_colors, show_linestyles, show_markers, style_cycler,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
try:
|
|
44
|
+
__version__ = _version("plotastro")
|
|
45
|
+
except PackageNotFoundError: # running from a source checkout
|
|
46
|
+
__version__ = "0+unknown"
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"set_style", "use", "figsize", "subplots", "savefig", "current_journal",
|
|
50
|
+
"authorlist",
|
|
51
|
+
"JOURNALS", "GOLDEN", "STYLE_DIR",
|
|
52
|
+
"COLORS", "CYCLE", "OKABE_ITO", "PETROFF10", "PAIRED",
|
|
53
|
+
"lighten", "darken", "simulate_cvd", "check_colors", "check_figure",
|
|
54
|
+
"MARKERS", "LINESTYLES", "style_cycler", "label_panels",
|
|
55
|
+
"show_colors", "show_markers", "show_linestyles",
|
|
56
|
+
"set_size",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _register_styles():
|
|
61
|
+
"""Make the bundled styles available as plt.style.use("mnras") etc."""
|
|
62
|
+
for path in STYLE_DIR.glob("*.mplstyle"):
|
|
63
|
+
_mstyle.library[path.stem] = _mpl.rc_params_from_file(
|
|
64
|
+
path, use_default_template=False)
|
|
65
|
+
_mstyle.available[:] = sorted(_mstyle.library)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
_register_styles()
|
plotastro/_authors.py
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Generate journal-ready LaTeX author/affiliation blocks from a CSV file.
|
|
2
|
+
|
|
3
|
+
Works directly with real collaboration author lists. Recognised columns
|
|
4
|
+
(header names are case-insensitive; **any other column is ignored**, so
|
|
5
|
+
collaboration bookkeeping like ``JoinedAsBuilder`` is fine):
|
|
6
|
+
|
|
7
|
+
Authorname display name, e.g. "Behnood Bandi" (alias: "name");
|
|
8
|
+
if absent, built from "Firstname" + "Lastname"
|
|
9
|
+
Affiliation one or more affiliations separated by ";"
|
|
10
|
+
(aliases: "affiliations", "affil", "affiliation1", ...)
|
|
11
|
+
ORCID optional
|
|
12
|
+
Email optional; authors with an email are marked corresponding
|
|
13
|
+
|
|
14
|
+
Example (a typical collaboration list — see examples/authors_example.csv
|
|
15
|
+
for the full file)::
|
|
16
|
+
|
|
17
|
+
Lastname,Firstname,Authorname,Email,JoinedAsBuilder,Affiliation,ORCID,
|
|
18
|
+
Bandi,Behnood,Behnood Bandi, b.bandi@sussex.ac.uk, False,"Astronomy Centre, University of Sussex, Falmer, Brighton BN1 9QH, UK",0000-0001-5838-3903,
|
|
19
|
+
Rocher,Antoine,Antoine Rocher,antoine.rocher@epfl.ch,False,"EPFL, \\'{E}cole polytechnique f\\'{e}d\\'{e}rale de Lausanne, Chemin des Maillettes, 51, 1290 Versoix, Switzerland",0000-0003-4349-6424,
|
|
20
|
+
|
|
21
|
+
Notes:
|
|
22
|
+
|
|
23
|
+
- LaTeX already present in the CSV (accents like ``\\'{e}``, maths, ...)
|
|
24
|
+
is passed through untouched; only unescaped ``& % # _`` are escaped.
|
|
25
|
+
- Whitespace around values is stripped (`` b.bandi@...`` is fine).
|
|
26
|
+
- An author appearing on **several rows** (one per affiliation, as some
|
|
27
|
+
collaborations do) is merged into one entry with all affiliations.
|
|
28
|
+
- Author order = row order; affiliations are numbered in order of first
|
|
29
|
+
appearance and shared between authors automatically.
|
|
30
|
+
|
|
31
|
+
Usage from Python::
|
|
32
|
+
|
|
33
|
+
import plotastro as pa
|
|
34
|
+
print(pa.authorlist("authors.csv", journal="mnras"))
|
|
35
|
+
|
|
36
|
+
or from the command line (installed with the package)::
|
|
37
|
+
|
|
38
|
+
plotastro-authors authors.csv --journal mnras
|
|
39
|
+
plotastro-authors authors.csv -j apj -o authors.tex
|
|
40
|
+
|
|
41
|
+
The output is a starting point that compiles with the journal's template —
|
|
42
|
+
fine-tune addresses, footnotes etc. in the .tex file.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import argparse
|
|
48
|
+
import csv
|
|
49
|
+
import re
|
|
50
|
+
import string
|
|
51
|
+
from pathlib import Path
|
|
52
|
+
|
|
53
|
+
# Journals sharing an author-block format:
|
|
54
|
+
_FORMATS = {
|
|
55
|
+
"mnras": "mnras", "rasti": "mnras",
|
|
56
|
+
"aanda": "aanda",
|
|
57
|
+
"apj": "aastex", "oja": "aastex",
|
|
58
|
+
"prd": "revtex",
|
|
59
|
+
"jcap": "jcap",
|
|
60
|
+
"generic": "generic", "natastro": "generic",
|
|
61
|
+
"thesis": "generic", "beamer": "generic",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
def _escape(text):
|
|
65
|
+
"""Escape unescaped & % # _ for LaTeX; leave existing LaTeX alone."""
|
|
66
|
+
return re.sub(r"(?<!\\)([&%#_])", r"\\\1", str(text).strip())
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _norm_key(key):
|
|
70
|
+
return str(key).strip().lower().replace(" ", "").replace("_", "")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _read_authors(source):
|
|
74
|
+
"""Return a list of {name, affils, orcid, email} dicts.
|
|
75
|
+
|
|
76
|
+
`source` is a CSV path, or an already-parsed list of dicts (with the
|
|
77
|
+
same keys as the CSV columns) for programmatic use. See the module
|
|
78
|
+
docstring for the recognised columns; rows repeating an author's name
|
|
79
|
+
are merged (extra affiliations appended).
|
|
80
|
+
"""
|
|
81
|
+
if isinstance(source, (str, Path)):
|
|
82
|
+
with open(source, newline="", encoding="utf-8-sig") as f:
|
|
83
|
+
rows = list(csv.DictReader(f))
|
|
84
|
+
else:
|
|
85
|
+
rows = [dict(row) for row in source]
|
|
86
|
+
|
|
87
|
+
authors, seen = [], {}
|
|
88
|
+
for raw in rows:
|
|
89
|
+
row = {_norm_key(k): (v or "").strip() for k, v in raw.items() if k}
|
|
90
|
+
name = (row.get("authorname") or row.get("name") or " ".join(
|
|
91
|
+
part for part in (row.get("firstname"), row.get("lastname")) if part))
|
|
92
|
+
if not name:
|
|
93
|
+
continue
|
|
94
|
+
affils = []
|
|
95
|
+
for key in sorted(row): # affiliation, affiliations, affiliation1, ...
|
|
96
|
+
if key.startswith("affil"):
|
|
97
|
+
affils += [a.strip() for a in row[key].split(";") if a.strip()]
|
|
98
|
+
|
|
99
|
+
author = seen.get(name.lower())
|
|
100
|
+
if author is None:
|
|
101
|
+
author = {"name": _escape(name), "affils": [],
|
|
102
|
+
"orcid": "", "email": ""}
|
|
103
|
+
seen[name.lower()] = author
|
|
104
|
+
authors.append(author)
|
|
105
|
+
for aff in affils:
|
|
106
|
+
escaped = _escape(aff)
|
|
107
|
+
if escaped not in author["affils"]:
|
|
108
|
+
author["affils"].append(escaped)
|
|
109
|
+
author["orcid"] = author["orcid"] or row.get("orcid", "")
|
|
110
|
+
author["email"] = author["email"] or row.get("email", "")
|
|
111
|
+
if not authors:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"No authors found — the CSV needs an 'Authorname' or 'name' "
|
|
114
|
+
"column (or 'Firstname'/'Lastname') with at least one "
|
|
115
|
+
"non-empty row.")
|
|
116
|
+
return authors
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _affiliation_index(authors):
|
|
120
|
+
"""Ordered unique affiliations -> 1-based numbering."""
|
|
121
|
+
index = {}
|
|
122
|
+
for a in authors:
|
|
123
|
+
for aff in a["affils"]:
|
|
124
|
+
index.setdefault(aff, len(index) + 1)
|
|
125
|
+
return index
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _short_authors(authors):
|
|
129
|
+
"""Running-head form: 'B. Bandi et al.', 'Doe & Roe', or 'B. Bandi'."""
|
|
130
|
+
def surname(a):
|
|
131
|
+
return a["name"].split()[-1]
|
|
132
|
+
|
|
133
|
+
first = authors[0]["name"].split()
|
|
134
|
+
initial = f"{first[0][0]}. {first[-1]}" if len(first) > 1 else first[0]
|
|
135
|
+
if len(authors) == 1:
|
|
136
|
+
return initial
|
|
137
|
+
if len(authors) == 2:
|
|
138
|
+
return f"{surname(authors[0])} \\& {surname(authors[1])}"
|
|
139
|
+
return f"{initial} et al."
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _sup(author, index):
|
|
143
|
+
nums = ",".join(str(n) for n in sorted(index[a] for a in author["affils"]))
|
|
144
|
+
return f"$^{{{nums}}}$" if nums else ""
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _corresponding(authors):
|
|
148
|
+
"""First author with an email — the only one footnoted in the
|
|
149
|
+
single-\\thanks formats (MNRAS, A&A)."""
|
|
150
|
+
return next((a for a in authors if a["email"]), None)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _fmt_mnras(authors, index):
|
|
154
|
+
corr = _corresponding(authors)
|
|
155
|
+
lines = [f"\\author[{_short_authors(authors)}]{{"]
|
|
156
|
+
for i, a in enumerate(authors):
|
|
157
|
+
thanks = f"\\thanks{{E-mail: {a['email']}}}" if a is corr else ""
|
|
158
|
+
if len(authors) > 1 and i == len(authors) - 1:
|
|
159
|
+
lines.append(f"and {a['name']}{_sup(a, index)}{thanks}")
|
|
160
|
+
elif i >= len(authors) - 2: # no comma before the final 'and'
|
|
161
|
+
lines.append(f"{a['name']}{_sup(a, index)}{thanks}")
|
|
162
|
+
else:
|
|
163
|
+
lines.append(f"{a['name']},{_sup(a, index)}{thanks}")
|
|
164
|
+
lines += ["\\\\", "% List of institutions"]
|
|
165
|
+
insts = [f"$^{{{n}}}$" + aff for aff, n in index.items()]
|
|
166
|
+
lines.append("\\\\\n".join(insts))
|
|
167
|
+
lines.append("}")
|
|
168
|
+
return "\n".join(lines)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _fmt_aanda(authors, index):
|
|
172
|
+
corr = _corresponding(authors)
|
|
173
|
+
parts = []
|
|
174
|
+
for a in authors:
|
|
175
|
+
nums = ",".join(str(n) for n in sorted(index[x] for x in a["affils"]))
|
|
176
|
+
inst = f"\\inst{{{nums}}}" if nums else ""
|
|
177
|
+
thanks = f"\\thanks{{\\email{{{a['email']}}}}}" if a is corr else ""
|
|
178
|
+
parts.append(f"{a['name']}{inst}{thanks}")
|
|
179
|
+
author_block = "\\author{" + "\n \\and ".join(parts) + "}"
|
|
180
|
+
inst_block = ("\\institute{" +
|
|
181
|
+
"\n \\and ".join(index) + "}")
|
|
182
|
+
return author_block + "\n\n" + inst_block
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _fmt_aastex(authors, index):
|
|
186
|
+
blocks = []
|
|
187
|
+
corresponding = _corresponding(authors)
|
|
188
|
+
if corresponding:
|
|
189
|
+
blocks.append(f"\\correspondingauthor{{{corresponding['name']}}}\n"
|
|
190
|
+
f"\\email{{{corresponding['email']}}}")
|
|
191
|
+
for a in authors:
|
|
192
|
+
opt = f"[{a['orcid']}]" if a["orcid"] else ""
|
|
193
|
+
lines = [f"\\author{opt}{{{a['name']}}}"]
|
|
194
|
+
lines += [f"\\affiliation{{{aff}}}" for aff in a["affils"]]
|
|
195
|
+
blocks.append("\n".join(lines))
|
|
196
|
+
return "\n\n".join(blocks)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _fmt_revtex(authors, index):
|
|
200
|
+
blocks = []
|
|
201
|
+
for a in authors:
|
|
202
|
+
lines = [f"\\author{{{a['name']}}}"]
|
|
203
|
+
if a["email"]:
|
|
204
|
+
lines.append(f"\\email{{{a['email']}}}")
|
|
205
|
+
lines += [f"\\affiliation{{{aff}}}" for aff in a["affils"]]
|
|
206
|
+
blocks.append("\n".join(lines))
|
|
207
|
+
return "\n\n".join(blocks)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _fmt_jcap(authors, index):
|
|
211
|
+
# jcappub labels affiliations with letters
|
|
212
|
+
if len(index) > 26:
|
|
213
|
+
raise ValueError("The JCAP format supports at most 26 affiliations.")
|
|
214
|
+
letters = {aff: string.ascii_lowercase[n - 1] for aff, n in index.items()}
|
|
215
|
+
lines = []
|
|
216
|
+
for a in authors:
|
|
217
|
+
labels = ",".join(sorted(letters[x] for x in a["affils"]))
|
|
218
|
+
opt = f"[{labels}]" if labels else ""
|
|
219
|
+
lines.append(f"\\author{opt}{{{a['name']}}}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
lines += [f"\\affiliation[{letters[aff]}]{{{aff}}}" for aff in index]
|
|
222
|
+
emails = [a["email"] for a in authors if a["email"]]
|
|
223
|
+
if emails:
|
|
224
|
+
lines.append("")
|
|
225
|
+
lines += [f"\\emailAdd{{{e}}}" for e in emails]
|
|
226
|
+
return "\n".join(lines)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _fmt_generic(authors, index):
|
|
230
|
+
names = [f"{a['name']}{_sup(a, index)}" for a in authors]
|
|
231
|
+
if len(names) > 1:
|
|
232
|
+
head = ", ".join(names[:-1]) + " and " + names[-1]
|
|
233
|
+
else:
|
|
234
|
+
head = names[0]
|
|
235
|
+
insts = [f"$^{{{n}}}$" + aff for aff, n in index.items()]
|
|
236
|
+
return head + "\n\n" + "\n".join(insts)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
_RENDERERS = {"mnras": _fmt_mnras, "aanda": _fmt_aanda, "aastex": _fmt_aastex,
|
|
240
|
+
"revtex": _fmt_revtex, "jcap": _fmt_jcap, "generic": _fmt_generic}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def authorlist(source, journal="mnras"):
|
|
244
|
+
"""LaTeX author/affiliation block for a journal, from a CSV file.
|
|
245
|
+
|
|
246
|
+
Parameters
|
|
247
|
+
----------
|
|
248
|
+
source : str, Path, or list of dicts
|
|
249
|
+
Path to a CSV file with columns ``name``, ``affiliations``
|
|
250
|
+
(";"-separated), ``orcid``, ``email`` — or an equivalent list of
|
|
251
|
+
dicts. See the module docstring for the format.
|
|
252
|
+
journal : str
|
|
253
|
+
Any journal key/alias the package knows (``"mnras"``, ``"aanda"``,
|
|
254
|
+
``"apj"``, ``"oja"``, ``"prd"``, ``"jcap"``, ...) or ``"generic"``
|
|
255
|
+
for a plain numbered-superscript block.
|
|
256
|
+
|
|
257
|
+
Returns
|
|
258
|
+
-------
|
|
259
|
+
str : LaTeX source to paste into your manuscript.
|
|
260
|
+
|
|
261
|
+
Examples
|
|
262
|
+
--------
|
|
263
|
+
>>> print(pa.authorlist("authors.csv", journal="aanda"))
|
|
264
|
+
"""
|
|
265
|
+
from ._core import _resolve
|
|
266
|
+
key = "generic" if str(journal).lower() == "generic" else _resolve(journal)
|
|
267
|
+
fmt = _FORMATS[key]
|
|
268
|
+
authors = _read_authors(source)
|
|
269
|
+
index = _affiliation_index(authors)
|
|
270
|
+
header = (f"% Author list generated by plotastro ({key} format)\n"
|
|
271
|
+
f"% Check addresses/footnotes against the journal template.\n")
|
|
272
|
+
return header + _RENDERERS[fmt](authors, index)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv=None):
|
|
276
|
+
"""Command-line entry point: ``plotastro-authors authors.csv -j mnras``."""
|
|
277
|
+
parser = argparse.ArgumentParser(
|
|
278
|
+
prog="plotastro-authors",
|
|
279
|
+
description="Generate a journal-ready LaTeX author/affiliation block "
|
|
280
|
+
"from a CSV file (columns: Authorname or Firstname/"
|
|
281
|
+
"Lastname, Affiliation, ORCID, Email; extra columns are "
|
|
282
|
+
"ignored).")
|
|
283
|
+
parser.add_argument("csv", help="path to the author CSV file")
|
|
284
|
+
parser.add_argument("-j", "--journal", default="mnras",
|
|
285
|
+
help="journal key, e.g. mnras, aanda, apj, oja, prd, "
|
|
286
|
+
"jcap, or 'generic' (default: mnras)")
|
|
287
|
+
parser.add_argument("-o", "--output",
|
|
288
|
+
help="write to this .tex file instead of stdout")
|
|
289
|
+
args = parser.parse_args(argv)
|
|
290
|
+
try:
|
|
291
|
+
tex = authorlist(args.csv, journal=args.journal)
|
|
292
|
+
except (ValueError, OSError) as exc:
|
|
293
|
+
parser.exit(1, f"error: {exc}\n")
|
|
294
|
+
if args.output:
|
|
295
|
+
Path(args.output).write_text(tex + "\n", encoding="utf-8")
|
|
296
|
+
print(f"wrote {args.output}")
|
|
297
|
+
else:
|
|
298
|
+
print(tex)
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
if __name__ == "__main__":
|
|
302
|
+
main()
|
plotastro/_colors.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Palettes, colour utilities and colour-vision-deficiency checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import colorsys
|
|
6
|
+
|
|
7
|
+
import matplotlib.colors as mcolors
|
|
8
|
+
import matplotlib.pyplot as plt
|
|
9
|
+
import numpy as np
|
|
10
|
+
|
|
11
|
+
from ._core import figsize
|
|
12
|
+
|
|
13
|
+
# ----------------------------------------------------------------------
|
|
14
|
+
# Palettes
|
|
15
|
+
# ----------------------------------------------------------------------
|
|
16
|
+
|
|
17
|
+
#: The default colour-blind-friendly cycle, by name (see the README).
|
|
18
|
+
#: First 9: reordered ColorBrewer "Set1" made colour-blind safe;
|
|
19
|
+
#: last 3: light companions from Tableau's Color Blind 10 palette.
|
|
20
|
+
COLORS = {
|
|
21
|
+
"blue": "#377eb8",
|
|
22
|
+
"orange": "#ff7f00",
|
|
23
|
+
"green": "#4daf4a",
|
|
24
|
+
"pink": "#f781bf",
|
|
25
|
+
"brown": "#a65628",
|
|
26
|
+
"purple": "#984ea3",
|
|
27
|
+
"grey": "#999999",
|
|
28
|
+
"red": "#e41a1c",
|
|
29
|
+
"yellow": "#dede00",
|
|
30
|
+
"lightblue": "#a2c8ec",
|
|
31
|
+
"lightorange": "#ffbc79",
|
|
32
|
+
"lightgrey": "#ababab",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#: The colours of the default cycle, in order.
|
|
36
|
+
CYCLE = list(COLORS.values())
|
|
37
|
+
|
|
38
|
+
#: Okabe & Ito (2008) palette — the classic 8-colour scheme designed
|
|
39
|
+
#: for all common types of colour-vision deficiency.
|
|
40
|
+
OKABE_ITO = {
|
|
41
|
+
"black": "#000000",
|
|
42
|
+
"orange": "#e69f00",
|
|
43
|
+
"skyblue": "#56b4e9",
|
|
44
|
+
"green": "#009e73",
|
|
45
|
+
"yellow": "#f0e442",
|
|
46
|
+
"blue": "#0072b2",
|
|
47
|
+
"vermillion": "#d55e00",
|
|
48
|
+
"purple": "#cc79a7",
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
#: Petroff (2021) 10-colour palette — the CVD-optimised cycle adopted as
|
|
52
|
+
#: matplotlib's "petroff10" style and widely used in particle physics.
|
|
53
|
+
PETROFF10 = {
|
|
54
|
+
"blue": "#3f90da",
|
|
55
|
+
"yellow": "#ffa90e",
|
|
56
|
+
"red": "#bd1f01",
|
|
57
|
+
"grey": "#94a4a2",
|
|
58
|
+
"purple": "#832db6",
|
|
59
|
+
"brown": "#a96b59",
|
|
60
|
+
"orange": "#e76300",
|
|
61
|
+
"tan": "#b9ac70",
|
|
62
|
+
"slate": "#717581",
|
|
63
|
+
"cyan": "#92dadd",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
#: Light/dark pairs (ColorBrewer "Paired") — ideal for data/model or
|
|
67
|
+
#: before/after pairs: PAIRED["blue"] -> (light, dark). Note this palette
|
|
68
|
+
#: is *not* fully CVD-safe on its own (it contains red and green); pair
|
|
69
|
+
#: it with distinct line styles or markers.
|
|
70
|
+
PAIRED = {
|
|
71
|
+
"blue": ("#a6cee3", "#1f78b4"),
|
|
72
|
+
"green": ("#b2df8a", "#33a02c"),
|
|
73
|
+
"red": ("#fb9a99", "#e31a1c"),
|
|
74
|
+
"orange": ("#fdbf6f", "#ff7f00"),
|
|
75
|
+
"purple": ("#cab2d6", "#6a3d9a"),
|
|
76
|
+
"brown": ("#ffff99", "#b15928"),
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def lighten(color, amount=0.5):
|
|
81
|
+
"""Lighten a colour by moving it towards white (0 = unchanged, 1 = white).
|
|
82
|
+
|
|
83
|
+
Handy for e.g. filled uncertainty bands under a line of the same hue:
|
|
84
|
+
|
|
85
|
+
>>> ax.plot(x, y, color=pa.COLORS["blue"])
|
|
86
|
+
>>> ax.fill_between(x, lo, hi, color=pa.lighten(pa.COLORS["blue"], 0.7))
|
|
87
|
+
"""
|
|
88
|
+
h, l, s = colorsys.rgb_to_hls(*mcolors.to_rgb(color))
|
|
89
|
+
return colorsys.hls_to_rgb(h, l + (1 - l) * amount, s)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def darken(color, amount=0.5):
|
|
93
|
+
"""Darken a colour by moving it towards black (0 = unchanged, 1 = black)."""
|
|
94
|
+
h, l, s = colorsys.rgb_to_hls(*mcolors.to_rgb(color))
|
|
95
|
+
return colorsys.hls_to_rgb(h, l * (1 - amount), s)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# ----------------------------------------------------------------------
|
|
99
|
+
# Colour-vision-deficiency simulation
|
|
100
|
+
# ----------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
# Machado, Oliveira & Fernandes (2009), IEEE TVCG 15(6) — severity-1.0
|
|
103
|
+
# transformation matrices, applied in linear RGB.
|
|
104
|
+
_CVD_MATRICES = {
|
|
105
|
+
"protanopia": np.array([
|
|
106
|
+
[0.152286, 1.052583, -0.204868],
|
|
107
|
+
[0.114503, 0.786281, 0.099216],
|
|
108
|
+
[-0.003882, -0.048116, 1.051998]]),
|
|
109
|
+
"deuteranopia": np.array([
|
|
110
|
+
[0.367322, 0.860646, -0.227968],
|
|
111
|
+
[0.280085, 0.672501, 0.047413],
|
|
112
|
+
[-0.011820, 0.042940, 0.968881]]),
|
|
113
|
+
"tritanopia": np.array([
|
|
114
|
+
[1.255528, -0.076749, -0.178779],
|
|
115
|
+
[-0.078411, 0.930809, 0.147602],
|
|
116
|
+
[0.004733, 0.691367, 0.303900]]),
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _srgb_to_linear(c):
|
|
121
|
+
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _linear_to_srgb(c):
|
|
125
|
+
return np.where(c <= 0.0031308, c * 12.92, 1.055 * c ** (1 / 2.4) - 0.055)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def simulate_cvd(colors, kind="deuteranopia"):
|
|
129
|
+
"""Simulate how colours appear with a colour-vision deficiency.
|
|
130
|
+
|
|
131
|
+
Parameters
|
|
132
|
+
----------
|
|
133
|
+
colors : colour, sequence of colours, or (..., 3/4) float array
|
|
134
|
+
Anything matplotlib understands (hex strings, names, RGB tuples,
|
|
135
|
+
an image array from a rendered figure, ...).
|
|
136
|
+
kind : {"deuteranopia", "protanopia", "tritanopia", "greyscale"}
|
|
137
|
+
Deficiency to simulate. Deuteranopia and protanopia (red-green)
|
|
138
|
+
together affect ~5% of male readers; greyscale is what a
|
|
139
|
+
black-and-white printout shows.
|
|
140
|
+
|
|
141
|
+
Returns
|
|
142
|
+
-------
|
|
143
|
+
ndarray of RGB(A) values in [0, 1], one row per input colour (or the
|
|
144
|
+
same shape as an input image array).
|
|
145
|
+
|
|
146
|
+
Notes
|
|
147
|
+
-----
|
|
148
|
+
Uses the severity-1.0 matrices of Machado et al. (2009), applied in
|
|
149
|
+
linear RGB — the same model behind most online CVD simulators.
|
|
150
|
+
"""
|
|
151
|
+
arr = np.asarray(colors, dtype=float) if (
|
|
152
|
+
isinstance(colors, np.ndarray) and colors.ndim >= 2
|
|
153
|
+
) else np.atleast_2d(mcolors.to_rgba_array(colors))
|
|
154
|
+
if arr.max() > 1.0: # e.g. uint8 image content passed as float
|
|
155
|
+
arr = arr / 255.0
|
|
156
|
+
rgb, alpha = arr[..., :3], arr[..., 3:]
|
|
157
|
+
lin = _srgb_to_linear(np.clip(rgb, 0, 1))
|
|
158
|
+
if kind == "greyscale":
|
|
159
|
+
lum = lin @ np.array([0.2126, 0.7152, 0.0722])
|
|
160
|
+
lin = np.repeat(lum[..., None], 3, axis=-1)
|
|
161
|
+
elif kind in _CVD_MATRICES:
|
|
162
|
+
lin = lin @ _CVD_MATRICES[kind].T
|
|
163
|
+
else:
|
|
164
|
+
options = ", ".join(list(_CVD_MATRICES) + ["greyscale"])
|
|
165
|
+
raise ValueError(f"kind must be one of: {options}; got {kind!r}")
|
|
166
|
+
out = _linear_to_srgb(np.clip(lin, 0, 1))
|
|
167
|
+
return np.concatenate([out, alpha], axis=-1) if alpha.size else out
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def check_colors(palette=None, kinds=("deuteranopia", "protanopia", "greyscale")):
|
|
171
|
+
"""Show a palette next to CVD simulations of it, to verify that the
|
|
172
|
+
colours stay distinguishable. Returns the figure.
|
|
173
|
+
|
|
174
|
+
Parameters
|
|
175
|
+
----------
|
|
176
|
+
palette : dict, list of colours, or None
|
|
177
|
+
Defaults to the package's colour cycle.
|
|
178
|
+
kinds : sequence of str
|
|
179
|
+
Simulations to include (see :func:`simulate_cvd`).
|
|
180
|
+
"""
|
|
181
|
+
palette = palette if palette is not None else COLORS
|
|
182
|
+
cols = list(palette.values()) if isinstance(palette, dict) else list(palette)
|
|
183
|
+
rows = ["original", *kinds]
|
|
184
|
+
fig, ax = plt.subplots(
|
|
185
|
+
figsize=figsize("full", journal="mnras", fraction=0.9,
|
|
186
|
+
height=0.4 * len(rows) + 0.4))
|
|
187
|
+
ax.set_axis_off()
|
|
188
|
+
for i, row in enumerate(rows):
|
|
189
|
+
y = len(rows) - 1 - i
|
|
190
|
+
shown = cols if row == "original" else simulate_cvd(cols, row)
|
|
191
|
+
for j, c in enumerate(shown):
|
|
192
|
+
ax.add_patch(plt.Rectangle((j, y + 0.12), 0.92, 0.76, color=c))
|
|
193
|
+
ax.text(-0.15, y + 0.5, row, ha="right", va="center", fontsize=8)
|
|
194
|
+
ax.set_xlim(-2.2, len(cols))
|
|
195
|
+
ax.set_ylim(0, len(rows))
|
|
196
|
+
ax.set_title("Colour-vision-deficiency check")
|
|
197
|
+
return fig
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def check_figure(fig=None, kinds=("deuteranopia", "protanopia", "greyscale")):
|
|
201
|
+
"""Render an existing figure and show how it appears under CVD
|
|
202
|
+
simulations — the final accessibility check before submission.
|
|
203
|
+
|
|
204
|
+
Parameters
|
|
205
|
+
----------
|
|
206
|
+
fig : Figure, optional
|
|
207
|
+
Defaults to the current figure.
|
|
208
|
+
kinds : sequence of str
|
|
209
|
+
Simulations to include (see :func:`simulate_cvd`).
|
|
210
|
+
|
|
211
|
+
Returns
|
|
212
|
+
-------
|
|
213
|
+
The new comparison figure.
|
|
214
|
+
"""
|
|
215
|
+
fig = fig if fig is not None else plt.gcf()
|
|
216
|
+
fig.canvas.draw()
|
|
217
|
+
img = np.asarray(fig.canvas.buffer_rgba(), dtype=float) / 255.0
|
|
218
|
+
panels = [("original", img)] + [(k, simulate_cvd(img, k)) for k in kinds]
|
|
219
|
+
n = len(panels)
|
|
220
|
+
ar = img.shape[0] / img.shape[1]
|
|
221
|
+
w = 6.5
|
|
222
|
+
out, axes = plt.subplots(1, n, figsize=(w, ar * w / n * 1.15))
|
|
223
|
+
for ax, (label, im) in zip(np.atleast_1d(axes), panels):
|
|
224
|
+
ax.imshow(im)
|
|
225
|
+
ax.set_axis_off()
|
|
226
|
+
ax.set_title(label, fontsize=8)
|
|
227
|
+
return out
|