trace-digitiser 2.1.0__tar.gz → 2.3.0__tar.gz
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.
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/CHANGELOG.md +150 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/MANIFEST.in +2 -0
- {trace_digitiser-2.1.0/src/trace_digitiser.egg-info → trace_digitiser-2.3.0}/PKG-INFO +38 -2
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/README.md +37 -1
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/RELEASING.md +31 -2
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/pyproject.toml +1 -1
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/__init__.py +18 -1
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/_figure.py +70 -1
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/axis_labels.py +86 -2
- trace_digitiser-2.3.0/src/trace_digitiser/bars.py +591 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/frames.py +40 -1
- trace_digitiser-2.3.0/src/trace_digitiser/legend.py +331 -0
- trace_digitiser-2.3.0/src/trace_digitiser/markers.py +1073 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/panel_detection.py +83 -3
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/points.py +19 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/polar.py +10 -3
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/trace_detection.py +65 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0/src/trace_digitiser.egg-info}/PKG-INFO +38 -2
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/SOURCES.txt +3 -0
- trace_digitiser-2.3.0/tests/test_figure_styles.py +138 -0
- trace_digitiser-2.3.0/tests/test_legend.py +65 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_readers.py +17 -1
- trace_digitiser-2.1.0/src/trace_digitiser/bars.py +0 -324
- trace_digitiser-2.1.0/src/trace_digitiser/markers.py +0 -399
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/LICENSE +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/examples/README.md +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/examples/digitise_published_figure.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/examples/lopatina2014_fig4.jpg +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/examples/validate_with_known_data.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/examples/walkthrough.ipynb +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/setup.cfg +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/_results.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/calibration.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/cli.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/diagnostics.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/digitise.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/explore.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/geometry.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/heatmap.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/io.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/line_detection.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/models.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/ocr.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/provenance.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/resampling.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/rows.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/straighten.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/summarise.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/synthetic.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/validation.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser/x_calibration.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/dependency_links.txt +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/entry_points.txt +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/requires.txt +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/top_level.txt +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/__init__.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/conftest.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/data/lopatina2014_fig4_reference.csv +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_axis_labels.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_calibration.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_cli.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_digitise.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_frames.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_geometry.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_integration.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_io.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_panel_detection.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_published_figure.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_straighten.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_trace_detection.py +0 -0
- {trace_digitiser-2.1.0 → trace_digitiser-2.3.0}/tests/test_validation.py +0 -0
|
@@ -4,6 +4,153 @@ All notable changes to trace-digitiser are recorded here.
|
|
|
4
4
|
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
5
5
|
and the project uses [semantic versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [2.3.0] - 2026-11-15
|
|
10
|
+
|
|
11
|
+
Read again with 2.2.0, some of the figures in the validation sets still came
|
|
12
|
+
out wrong, or not at all: dot plots of translucent dots that overlap, legends
|
|
13
|
+
and labels drawn inside plots, plots stacked with no gap between them, tick
|
|
14
|
+
labels set close together, a line inside a pale band of its own colour. This
|
|
15
|
+
release reads most of them: of the values in the figures of published papers
|
|
16
|
+
(`validation/published-figures`), 1,184 of 1,601 are read (2.2.0: 614). The
|
|
17
|
+
changes were made while looking at all of those figures, including the ones
|
|
18
|
+
of Andreani et al. (2022) that were held out until 2.2.0 was made, so their
|
|
19
|
+
results are no longer those of figures held out. Activity traces read as well
|
|
20
|
+
as before or better (median correlation with the authors' series 0.95; 2.2.0:
|
|
21
|
+
0.94), and dense traces give the same results as with 2.2.0.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- **Legends inside a plot are found and left out**
|
|
26
|
+
(`trace_digitiser.legend`; `digitise(..., legend="auto")`, the default, or
|
|
27
|
+
`legend="keep"`): two or more entries, each a symbol with its words to the
|
|
28
|
+
right, one above another or side by side in a row. Their symbols and words
|
|
29
|
+
are left out as user-given `exclude` boxes are, and a warning says where.
|
|
30
|
+
- **Words written inside a plot are taken out of its traces**: a panel's
|
|
31
|
+
label in the trace's colour (such as "18:6") is found as a row of glyphs
|
|
32
|
+
standing on a baseline and left out of the trace.
|
|
33
|
+
- **Stacked plots with no gap between them are separated**: lines as dark and
|
|
34
|
+
as thick as the frame's sides that run right across it are noted, and where
|
|
35
|
+
the frame's tick labels give no one scale and the plots between such lines
|
|
36
|
+
each read as an axis of their own, they are taken as plots of their own.
|
|
37
|
+
Leypunskiy et al. (2017) 2B with nothing given: all three plots, 0.2-0.6 %
|
|
38
|
+
(2.2.0: no axis read).
|
|
39
|
+
- `validation/published-figures/run.py --digitised` keeps what each case read
|
|
40
|
+
(`validation/published-figures/digitised`), and `paper/figures.py` draws
|
|
41
|
+
the paper's supporting figures (S1-S6 Fig) from it and from the other
|
|
42
|
+
validation sets.
|
|
43
|
+
- **A validation set of activity traces**
|
|
44
|
+
([`validation/activity-traces`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/activity-traces)):
|
|
45
|
+
57 traces of mouse and rat activity over days, from the figures of 18
|
|
46
|
+
open-access papers found by a search of Europe PMC, read with 2.2.0 as a
|
|
47
|
+
workbench user would read them. For the 8 papers that published the numbers
|
|
48
|
+
behind their figures, the same rhythm analysis is run on the trace as read
|
|
49
|
+
and on the authors' series: the Lomb-Scargle periodogram and the 24-h
|
|
50
|
+
cosinor reach the same verdict for all 29 series, the share of activity in
|
|
51
|
+
the dark phase is within 0.05 for 27 of 29. For all 18, what the papers say
|
|
52
|
+
about their traces is checked against them. Not part of the package.
|
|
53
|
+
- **A test of dense traces**
|
|
54
|
+
([`validation/dense-traces`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/dense-traces)):
|
|
55
|
+
nine real activity records, kept every minute or every 5 minutes, drawn as
|
|
56
|
+
a week across a few hundred pixels (so that each pixel column is a stroke
|
|
57
|
+
from the lowest to the highest sample within it) and read back. Averaged
|
|
58
|
+
within each hour, `center_estimate` (the midpoint of each column) puts the
|
|
59
|
+
acrophase within 0.36 h of the record's (median) and reads the level 1.19
|
|
60
|
+
times too high; `upper_envelope` times the rhythm as well but reads the
|
|
61
|
+
level about twice too high. Every column statistic but the lower envelope
|
|
62
|
+
understates the share of activity in the dark phase. Not part of the
|
|
63
|
+
package.
|
|
64
|
+
|
|
65
|
+
### Fixed
|
|
66
|
+
|
|
67
|
+
- **Overlapping markers** are found by the edges of each that can be seen: a
|
|
68
|
+
Hough transform for circles of one size, in which each pixel on an edge -
|
|
69
|
+
of the blob, or, where the markers are translucent, between one layer and
|
|
70
|
+
two - votes for a centre a marker's radius inwards. Markers mostly hidden
|
|
71
|
+
are added where the blob is otherwise not covered; a marker partly hidden
|
|
72
|
+
under dark ink (a mean's bar, an error bar) is taken to go on under it; the
|
|
73
|
+
size of one marker is taken from the round blobs where most blobs are two
|
|
74
|
+
or three markers. Dot plots of Andreani et al. (2022) 3A-D: 386 of the 497
|
|
75
|
+
dots compared, with their colours given (2.2.0: 143), 348 with nothing
|
|
76
|
+
given (117); the polar plot of Hearn & Whitmore (2026): 55 of 60 years (50).
|
|
77
|
+
- **Tick labels set close together, or run into an axis title**, are each
|
|
78
|
+
looked for under (or beside) their own tick, where the ticks are at a
|
|
79
|
+
regular step: Leypunskiy et al. 5A with nothing given, 9 of 9 points
|
|
80
|
+
(2.2.0: x axis not read); Andreani et al. 1E with its plot area, 24 of 24
|
|
81
|
+
bars (y axis not read).
|
|
82
|
+
- **Ticks are found on plots shaded with pale bands**, by their contrast with
|
|
83
|
+
what they are drawn on.
|
|
84
|
+
- **A line drawn inside a pale band of its own colour** (a mean within its
|
|
85
|
+
error, shaded) is followed by its strong colour, not by the band's middle:
|
|
86
|
+
Andreani et al. 1C, FD 1.0 % (4.2 %).
|
|
87
|
+
- **Error bars** are judged against what lies round them, so that a pale band
|
|
88
|
+
is not taken for a bar; a line through a marker that runs on, uncapped, to
|
|
89
|
+
the edge of the plot, or aslant past where a bar would end, or on beyond a
|
|
90
|
+
gap (a broken gridline), is not a bar.
|
|
91
|
+
- A label read as "04" counts as evidence that an axis's decimal points are
|
|
92
|
+
being lost in small print, or in larger print only if every reading of it
|
|
93
|
+
agrees.
|
|
94
|
+
- A lump of more than fifty markers' area is a region of colour, not markers
|
|
95
|
+
(a figure with shaded bands took four minutes).
|
|
96
|
+
|
|
97
|
+
## [2.2.0] - 2026-11-15
|
|
98
|
+
|
|
99
|
+
Tested on the figures of published papers whose underlying numbers are
|
|
100
|
+
published too (Leypunskiy et al. 2017, eLife 6:e23539: markers with error
|
|
101
|
+
bars, a line over shaded bands; Foley et al. 2019, eLife 8:e50063: bar charts;
|
|
102
|
+
Pearl and Reed 1920, PNAS 6:275, a microfilm scan), 2.1.0 read what it found
|
|
103
|
+
accurately but often found nothing. This release finds much more of it, there
|
|
104
|
+
and on the figures of a paper held out until the changes were made (Andreani
|
|
105
|
+
et al. 2022, eLife 11:e74327). Line plots digitise as before: the package's
|
|
106
|
+
validation suite and the Hearn and Whitmore (2026) examples give the same
|
|
107
|
+
results.
|
|
108
|
+
|
|
109
|
+
### Added
|
|
110
|
+
|
|
111
|
+
- **A test set of published figures**
|
|
112
|
+
([`validation/published-figures`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/published-figures)):
|
|
113
|
+
seven figures from those four papers, the numbers behind them from their
|
|
114
|
+
authors' data, and a script that reads them with nothing given and with the
|
|
115
|
+
help a user can give, and compares. Its README has the results of 2.1.0 and
|
|
116
|
+
2.2.0, case by case, including the few that 2.2.0 reads worse. It is run
|
|
117
|
+
before each release and is not part of the package.
|
|
118
|
+
|
|
119
|
+
### Fixed
|
|
120
|
+
|
|
121
|
+
- **Bar charts in the style of GraphPad Prism**: black, grey, hatched and
|
|
122
|
+
outlined bars are found, on an axis that does not start at 0 (2.1.0 found 3
|
|
123
|
+
of the 35 bars of Foley et al.'s figure 2, and read those upside down; all
|
|
124
|
+
35 are now read, within 0.3 % of the axis). Bars are the columns filled from
|
|
125
|
+
the baseline, so an error bar or a significance bracket joined to a bar no
|
|
126
|
+
longer spoils it; a border of another colour (a black outline round a grey
|
|
127
|
+
bar) is part of the bar; a bar cut by the edge of the plot area is kept.
|
|
128
|
+
- **Error bars drawn in the colour of their bar or marker** (the default in
|
|
129
|
+
Prism, MATLAB and matplotlib) are followed beyond the bar's end, or from the
|
|
130
|
+
marker, to their caps; one running across a fitted line goes on beyond it.
|
|
131
|
+
Where only the half beyond a bar's end shows, a note says so.
|
|
132
|
+
- **Markers sitting on a line of their own colour** (a fitted line, their
|
|
133
|
+
error bars) are found: other sizes are tried where the lines' ridges
|
|
134
|
+
outnumber the markers, and such a colour counts as a colour of markers if
|
|
135
|
+
its markers are joined to its lines and much thicker than them. Round
|
|
136
|
+
letters of words written in the plot, and a legend's markers beside their
|
|
137
|
+
words, are left out; a marker found in two series of close colours is kept
|
|
138
|
+
in the nearer one.
|
|
139
|
+
- **A plot area given by hand** (a box drawn in the workbench, or numbers read
|
|
140
|
+
off a picture) moves to the middle of the axis lines within a few pixels of
|
|
141
|
+
it, and their thickness is measured: with a thick axis line the tick labels
|
|
142
|
+
were looked for inside it, and the axis title was read instead.
|
|
143
|
+
- Long tick marks of a small plot in a large picture are found, so that its
|
|
144
|
+
labels are anchored to them.
|
|
145
|
+
- A colour that fills areas (a shaded band behind a plot) is not taken for a
|
|
146
|
+
trace of a line plot.
|
|
147
|
+
- Tick labels that Tesseract misreads as a line of text (75 as 15, in large
|
|
148
|
+
Helvetica) are read once more as a single word when an axis is not settled.
|
|
149
|
+
- Looking at a whole page of a scanned paper (`describe_figure`, the
|
|
150
|
+
workbench's first look at a page of a PDF) took a minute and a half, nearly
|
|
151
|
+
all of it in the search for polar axes; the circles are now proposed on a
|
|
152
|
+
smaller copy of the page (16 seconds in all).
|
|
153
|
+
|
|
7
154
|
## [2.1.0] - 2026-11-14
|
|
8
155
|
|
|
9
156
|
Everything in 2.0.0 works as before. What is new is readers for other kinds of
|
|
@@ -165,6 +312,9 @@ First version tracked in this repository; identical to the 1.2.0 files on PyPI.
|
|
|
165
312
|
Releases 0.1.0 to 1.0.0 (6-7 June 2026) were uploaded to PyPI directly and are
|
|
166
313
|
not recorded here.
|
|
167
314
|
|
|
315
|
+
[Unreleased]: https://github.com/comparativechrono/trace-digitiser/compare/v2.3.0...HEAD
|
|
316
|
+
[2.3.0]: https://github.com/comparativechrono/trace-digitiser/compare/v2.2.0...v2.3.0
|
|
317
|
+
[2.2.0]: https://github.com/comparativechrono/trace-digitiser/compare/v2.1.0...v2.2.0
|
|
168
318
|
[2.1.0]: https://github.com/comparativechrono/trace-digitiser/compare/v2.0.0...v2.1.0
|
|
169
319
|
[2.0.0]: https://github.com/comparativechrono/trace-digitiser/compare/1d34f75...v2.0.0
|
|
170
320
|
[1.2.0]: https://pypi.org/project/trace-digitiser/1.2.0/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: trace-digitiser
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Recover the data from figure images - lines, points, bars, actograms, heat maps - on your own computer: no uploads, no AI model.
|
|
5
5
|
Author: Trace Digitiser Contributors
|
|
6
6
|
License-Expression: MIT
|
|
@@ -255,7 +255,7 @@ three typefaces; JPEG compression and blur).
|
|
|
255
255
|
| Height of the labels | axes | read correctly | declined | read wrongly, with a warning | read wrongly, no warning |
|
|
256
256
|
|---|---|---|---|---|---|
|
|
257
257
|
| 8 pixels or more | 477 | 464 | 13 | 0 | 0 |
|
|
258
|
-
| under 8 pixels | 273 |
|
|
258
|
+
| under 8 pixels | 273 | 260 (all with a warning to check them) | 12 | 1 | 0 |
|
|
259
259
|
|
|
260
260
|
*Declined* means that no scale was offered. All of those at 8 pixels or more are logarithmic axes
|
|
261
261
|
whose raised exponents are too small to read, though the rest of the label is not. The outcome for small
|
|
@@ -267,6 +267,39 @@ Labels under 8 pixels high are never given high confidence, however well they ag
|
|
|
267
267
|
that small is misread in consistent ways — the decimal point dropped from every label, say. If
|
|
268
268
|
your figure has such labels, enlarging the image does not help; typing in two numbers does.
|
|
269
269
|
|
|
270
|
+
**Published figures.** The tests above draw their own figures.
|
|
271
|
+
[`validation/published-figures`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/published-figures)
|
|
272
|
+
holds seven figures from four papers whose authors published the numbers behind them — markers with error bars, a
|
|
273
|
+
line over shaded bands, ten bar charts, dot plots, and a scanned page from 1920 — and reads them with nothing given
|
|
274
|
+
and with the help a user can give. With nothing given, 2.3.0 reads all 35 bars of the bar charts to within 0.3 % of
|
|
275
|
+
the axis, the 29 points of a scatter plot of four series to within 0.5 %, the 9 points of a phase-response curve whose
|
|
276
|
+
tick labels are set close together, and each of three line plots stacked with no gap between them to within 0.6 %;
|
|
277
|
+
with two ticks clicked on each axis and the dots' colour, it reads the census dots of the 1920 figure to within
|
|
278
|
+
0.9 %. In four dot plots of translucent dots that overlap, it finds 386 of the 497 dots compared once their colours
|
|
279
|
+
are clicked (2.2.0: 143). The figures of one of the papers were held out until 2.2.0 was made; 2.3.0 was made while
|
|
280
|
+
looking at them too, so they are a held-out test of 2.2.0 only. Tick labels that are slanted or hand-lettered, and
|
|
281
|
+
series of one colour told apart only by the shape of their markers, still need help; that folder's README has the
|
|
282
|
+
details, case by case.
|
|
283
|
+
|
|
284
|
+
**Activity traces.**
|
|
285
|
+
[`validation/activity-traces`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/activity-traces)
|
|
286
|
+
tests the use the package was written for: rhythm analysis of activity traces digitised from papers. It holds 57
|
|
287
|
+
traces of mouse and rat activity over days from 18 open-access papers found by a literature search. For the 29
|
|
288
|
+
series whose authors published the numbers behind them, the same rhythm analysis is run on the trace as read and on
|
|
289
|
+
the authors' series: the Lomb–Scargle periodogram and the 24-h cosinor reach the same verdict on every series, the
|
|
290
|
+
share of activity in the dark phase agrees to within 0.05 for 27, and the acrophase to within 1 h for 27. For all 18
|
|
291
|
+
papers, what the authors say about their traces is checked against them: 46 of 53 statements are borne out, and
|
|
292
|
+
where the authors' numbers can decide, the trace and the numbers agree on 21 of 22.
|
|
293
|
+
|
|
294
|
+
**Dense traces.**
|
|
295
|
+
[`validation/dense-traces`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/dense-traces)
|
|
296
|
+
draws real activity records, kept every minute or every 5 minutes, as dense traces (a week across a few hundred
|
|
297
|
+
pixels, so that each pixel column is a stroke from the lowest to the highest sample within it) and reads them back.
|
|
298
|
+
Averaged within each hour, the midpoint of each column (`center_estimate`) puts the acrophase within 0.36 h of the
|
|
299
|
+
record's (median) and reads the level 1.19 times too high; the top of each column (`upper_envelope`) times the rhythm
|
|
300
|
+
as well but reads the level about twice too high. Every reading of a dense trace understates how much of the
|
|
301
|
+
activity falls in the dark phase, by 0.03–0.04 for the means of three mice and more for single animals.
|
|
302
|
+
|
|
270
303
|
### Test it on data you know
|
|
271
304
|
|
|
272
305
|
The best test of a digitisation is one on data like yours. `round_trip` draws a known series as a
|
|
@@ -312,6 +345,9 @@ cd trace-digitiser
|
|
|
312
345
|
pip install -e ".[dev]"
|
|
313
346
|
pytest # the tests that need Tesseract are skipped without it
|
|
314
347
|
ruff check src tests
|
|
348
|
+
python validation/published-figures/run.py # figures from papers against their authors' data (minutes)
|
|
349
|
+
python validation/activity-traces/run.py # rhythm analysis of digitised activity traces (a minute)
|
|
350
|
+
python validation/dense-traces/run.py # real records drawn as dense traces and read back (seconds)
|
|
315
351
|
```
|
|
316
352
|
|
|
317
353
|
`src/trace_digitiser/` in the order the work is done:
|
|
@@ -215,7 +215,7 @@ three typefaces; JPEG compression and blur).
|
|
|
215
215
|
| Height of the labels | axes | read correctly | declined | read wrongly, with a warning | read wrongly, no warning |
|
|
216
216
|
|---|---|---|---|---|---|
|
|
217
217
|
| 8 pixels or more | 477 | 464 | 13 | 0 | 0 |
|
|
218
|
-
| under 8 pixels | 273 |
|
|
218
|
+
| under 8 pixels | 273 | 260 (all with a warning to check them) | 12 | 1 | 0 |
|
|
219
219
|
|
|
220
220
|
*Declined* means that no scale was offered. All of those at 8 pixels or more are logarithmic axes
|
|
221
221
|
whose raised exponents are too small to read, though the rest of the label is not. The outcome for small
|
|
@@ -227,6 +227,39 @@ Labels under 8 pixels high are never given high confidence, however well they ag
|
|
|
227
227
|
that small is misread in consistent ways — the decimal point dropped from every label, say. If
|
|
228
228
|
your figure has such labels, enlarging the image does not help; typing in two numbers does.
|
|
229
229
|
|
|
230
|
+
**Published figures.** The tests above draw their own figures.
|
|
231
|
+
[`validation/published-figures`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/published-figures)
|
|
232
|
+
holds seven figures from four papers whose authors published the numbers behind them — markers with error bars, a
|
|
233
|
+
line over shaded bands, ten bar charts, dot plots, and a scanned page from 1920 — and reads them with nothing given
|
|
234
|
+
and with the help a user can give. With nothing given, 2.3.0 reads all 35 bars of the bar charts to within 0.3 % of
|
|
235
|
+
the axis, the 29 points of a scatter plot of four series to within 0.5 %, the 9 points of a phase-response curve whose
|
|
236
|
+
tick labels are set close together, and each of three line plots stacked with no gap between them to within 0.6 %;
|
|
237
|
+
with two ticks clicked on each axis and the dots' colour, it reads the census dots of the 1920 figure to within
|
|
238
|
+
0.9 %. In four dot plots of translucent dots that overlap, it finds 386 of the 497 dots compared once their colours
|
|
239
|
+
are clicked (2.2.0: 143). The figures of one of the papers were held out until 2.2.0 was made; 2.3.0 was made while
|
|
240
|
+
looking at them too, so they are a held-out test of 2.2.0 only. Tick labels that are slanted or hand-lettered, and
|
|
241
|
+
series of one colour told apart only by the shape of their markers, still need help; that folder's README has the
|
|
242
|
+
details, case by case.
|
|
243
|
+
|
|
244
|
+
**Activity traces.**
|
|
245
|
+
[`validation/activity-traces`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/activity-traces)
|
|
246
|
+
tests the use the package was written for: rhythm analysis of activity traces digitised from papers. It holds 57
|
|
247
|
+
traces of mouse and rat activity over days from 18 open-access papers found by a literature search. For the 29
|
|
248
|
+
series whose authors published the numbers behind them, the same rhythm analysis is run on the trace as read and on
|
|
249
|
+
the authors' series: the Lomb–Scargle periodogram and the 24-h cosinor reach the same verdict on every series, the
|
|
250
|
+
share of activity in the dark phase agrees to within 0.05 for 27, and the acrophase to within 1 h for 27. For all 18
|
|
251
|
+
papers, what the authors say about their traces is checked against them: 46 of 53 statements are borne out, and
|
|
252
|
+
where the authors' numbers can decide, the trace and the numbers agree on 21 of 22.
|
|
253
|
+
|
|
254
|
+
**Dense traces.**
|
|
255
|
+
[`validation/dense-traces`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/dense-traces)
|
|
256
|
+
draws real activity records, kept every minute or every 5 minutes, as dense traces (a week across a few hundred
|
|
257
|
+
pixels, so that each pixel column is a stroke from the lowest to the highest sample within it) and reads them back.
|
|
258
|
+
Averaged within each hour, the midpoint of each column (`center_estimate`) puts the acrophase within 0.36 h of the
|
|
259
|
+
record's (median) and reads the level 1.19 times too high; the top of each column (`upper_envelope`) times the rhythm
|
|
260
|
+
as well but reads the level about twice too high. Every reading of a dense trace understates how much of the
|
|
261
|
+
activity falls in the dark phase, by 0.03–0.04 for the means of three mice and more for single animals.
|
|
262
|
+
|
|
230
263
|
### Test it on data you know
|
|
231
264
|
|
|
232
265
|
The best test of a digitisation is one on data like yours. `round_trip` draws a known series as a
|
|
@@ -272,6 +305,9 @@ cd trace-digitiser
|
|
|
272
305
|
pip install -e ".[dev]"
|
|
273
306
|
pytest # the tests that need Tesseract are skipped without it
|
|
274
307
|
ruff check src tests
|
|
308
|
+
python validation/published-figures/run.py # figures from papers against their authors' data (minutes)
|
|
309
|
+
python validation/activity-traces/run.py # rhythm analysis of digitised activity traces (a minute)
|
|
310
|
+
python validation/dense-traces/run.py # real records drawn as dense traces and read back (seconds)
|
|
275
311
|
```
|
|
276
312
|
|
|
277
313
|
`src/trace_digitiser/` in the order the work is done:
|
|
@@ -45,8 +45,37 @@ plans). Now that the repository is public, one can be added:
|
|
|
45
45
|
2. In `CHANGELOG.md`, give the section for the version its date in place of
|
|
46
46
|
"unreleased", and end its link at the new tag (`...vX.Y.Z` in place of
|
|
47
47
|
`...HEAD`).
|
|
48
|
-
3.
|
|
49
|
-
|
|
48
|
+
3. Run the published-figure test set
|
|
49
|
+
([`validation/published-figures`](validation/published-figures/README.md)),
|
|
50
|
+
which reads figures from papers whose data are published, and compare the
|
|
51
|
+
results with the last release's:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
python validation/published-figures/run.py --digitised \
|
|
55
|
+
--json validation/published-figures/results-X.Y.Z.json \
|
|
56
|
+
--compare validation/published-figures/results-<last release>.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Look into any case marked WORSE before going on. Commit the new results
|
|
60
|
+
and what each case read (`digitised/`), and update the table in that
|
|
61
|
+
folder's README (`--markdown` prints it).
|
|
62
|
+
|
|
63
|
+
Then run the activity-trace set
|
|
64
|
+
([`validation/activity-traces`](validation/activity-traces/README.md)),
|
|
65
|
+
which needs pandas, openpyxl, SciPy and matplotlib besides:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
python validation/activity-traces/run.py
|
|
69
|
+
git diff --stat validation/activity-traces
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Its readings and results are kept in git (the script records the
|
|
73
|
+
release in `digitised/VERSION`), so the diff shows what the release
|
|
74
|
+
changed. Look into any change in `results/agreement_*.csv` or in the
|
|
75
|
+
verdicts of `results/claims_checked.csv`, and update the numbers in that
|
|
76
|
+
folder's README (`tables.py` prints its tables).
|
|
77
|
+
4. Merge to `main` and wait for the *Tests* workflow to pass.
|
|
78
|
+
5. On GitHub: *Releases -> Draft a new release*, create the tag `vX.Y.Z`
|
|
50
79
|
(matching the version exactly, with a leading `v`) on `main`, and publish.
|
|
51
80
|
|
|
52
81
|
Publishing the release starts the workflow. It checks that the tag matches the
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "trace-digitiser"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Recover the data from figure images - lines, points, bars, actograms, heat maps - on your own computer: no uploads, no AI model."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -61,6 +61,7 @@ from .diagnostics import (
|
|
|
61
61
|
)
|
|
62
62
|
from .digitise import digitise_trace_mask
|
|
63
63
|
from .io import build_panel_metadata, load_image, save_outputs # noqa: F401 (load_image: as before)
|
|
64
|
+
from .legend import drop_text, find_text_rows
|
|
64
65
|
from .models import Calibration, DigitiserResult, Panel, ResampledResult, Trace, XLabel
|
|
65
66
|
from .ocr import detect_x_labels
|
|
66
67
|
from .provenance import build_provenance, package_version, write_provenance
|
|
@@ -119,6 +120,7 @@ def digitise(
|
|
|
119
120
|
write_outputs: bool = True,
|
|
120
121
|
straighten: Union[str, bool, float] = "auto",
|
|
121
122
|
y_axis: str = "left",
|
|
123
|
+
legend: Union[str, bool] = "auto",
|
|
122
124
|
) -> DigitiserResult:
|
|
123
125
|
"""Digitise the line traces in a figure image.
|
|
124
126
|
|
|
@@ -182,6 +184,11 @@ def digitise(
|
|
|
182
184
|
exclude : (x_left, y_top, x_right, y_bottom), or a list of them, optional
|
|
183
185
|
Regions to ignore when looking for traces, e.g. a legend or an
|
|
184
186
|
annotation drawn inside the plot.
|
|
187
|
+
legend : {"auto", "keep"}
|
|
188
|
+
``"auto"`` (default) finds a legend drawn inside a plot area - two
|
|
189
|
+
or more entries, each a symbol with its label to the right - and
|
|
190
|
+
leaves it out, as if it had been given in *exclude*; a warning
|
|
191
|
+
says where. ``"keep"`` does not look for one.
|
|
185
192
|
achromatic : {"auto", "always", "never"}
|
|
186
193
|
Whether to look for a black or grey trace.
|
|
187
194
|
ocr : bool
|
|
@@ -214,7 +221,7 @@ def digitise(
|
|
|
214
221
|
expected_panels=expected_panels, y_min=y_min, y_max=y_max, x_min=x_min, x_max=x_max, total_days=total_days,
|
|
215
222
|
output_dir=output_dir, output_prefix=output_prefix, show_debug=show_debug, save_diagnostics=save_diagnostics,
|
|
216
223
|
plot_area=plot_area, y_ref=y_ref, x_ref=x_ref, y_scale=y_scale, x_scale=x_scale, exclude=exclude, ocr=ocr,
|
|
217
|
-
write_outputs=write_outputs, straighten=straighten, y_axis=y_axis,
|
|
224
|
+
write_outputs=write_outputs, straighten=straighten, y_axis=y_axis, legend=legend,
|
|
218
225
|
extra_parameters={"trace_colours": list(trace_colours) if trace_colours else None, "achromatic": achromatic},
|
|
219
226
|
)
|
|
220
227
|
rgb, dark, panels, notes, note = fig.rgb, fig.dark, fig.panels, fig.notes, fig.note
|
|
@@ -231,7 +238,17 @@ def digitise(
|
|
|
231
238
|
for p in panels:
|
|
232
239
|
masks = detect_trace_masks(
|
|
233
240
|
rgb, p, colours=trace_colours, exclude=exclude_boxes, achromatic=achromatic, darkness=dark,
|
|
241
|
+
lines_only=True,
|
|
234
242
|
)
|
|
243
|
+
# words written in the plot in a trace's colour (a panel's label, say) are not trace; words are found in
|
|
244
|
+
# black or grey print, so only a black or grey trace can hold them
|
|
245
|
+
grey = [tr for tr in masks if np.ptp(np.asarray(tr.get("median_rgb", (0, 0, 0)), float)) <= 60]
|
|
246
|
+
rows = find_text_rows(rgb, (p["x0"], p["y_top"], p["x1"], p["y_bottom"])) if grey else []
|
|
247
|
+
if rows:
|
|
248
|
+
for tr in grey:
|
|
249
|
+
tr["mask"] = drop_text(tr["mask"], (p["x0"], p["y_top"]), rows)
|
|
250
|
+
tr["pixel_count"] = int(tr["mask"].sum())
|
|
251
|
+
masks = [tr for tr in masks if tr["pixel_count"] > 0]
|
|
235
252
|
if show_debug:
|
|
236
253
|
print(f"Panel {p['panel_id']}: detected {len(masks)} trace(s)")
|
|
237
254
|
if not masks:
|
|
@@ -26,8 +26,9 @@ from .calibration import (
|
|
|
26
26
|
from .diagnostics import draw_panel_overlay, print_panel_summary
|
|
27
27
|
from .frames import darkness_map
|
|
28
28
|
from .io import load_image
|
|
29
|
+
from .legend import find_legend
|
|
29
30
|
from .models import Calibration
|
|
30
|
-
from .panel_detection import find_plot_panels
|
|
31
|
+
from .panel_detection import find_plot_panels, split_stack
|
|
31
32
|
from .straighten import MIN_TILT_DEGREES, estimate_tilt
|
|
32
33
|
from .straighten import straighten as _turn_upright
|
|
33
34
|
from .x_calibration import calibrate_x, x_calibration_from_points, x_calibration_from_range
|
|
@@ -209,6 +210,7 @@ def prepare_figure(
|
|
|
209
210
|
write_outputs: bool = True,
|
|
210
211
|
straighten: Union[str, bool, float] = "auto",
|
|
211
212
|
y_axis: str = "left",
|
|
213
|
+
legend: Union[str, bool] = "auto",
|
|
212
214
|
extra_parameters: Optional[dict] = None,
|
|
213
215
|
stacklevel: int = 4,
|
|
214
216
|
) -> PreparedFigure:
|
|
@@ -227,6 +229,9 @@ def prepare_figure(
|
|
|
227
229
|
raise ValueError(f"{name} must be 'auto', 'linear' or 'log'")
|
|
228
230
|
if y_axis not in ("left", "right"):
|
|
229
231
|
raise ValueError("y_axis must be 'left' or 'right'")
|
|
232
|
+
if legend not in ("auto", "keep", True, False):
|
|
233
|
+
raise ValueError("legend must be 'auto' (find legends drawn inside plots and leave them out) or 'keep'")
|
|
234
|
+
find_legends = legend in ("auto", True)
|
|
230
235
|
if (y_min is None) != (y_max is None):
|
|
231
236
|
raise ValueError("give both y_min and y_max, or neither")
|
|
232
237
|
if (x_min is None) != (x_max is None):
|
|
@@ -276,6 +281,8 @@ def prepare_figure(
|
|
|
276
281
|
}
|
|
277
282
|
if y_axis != "left":
|
|
278
283
|
parameters["y_axis"] = y_axis
|
|
284
|
+
if not find_legends:
|
|
285
|
+
parameters["legend"] = "keep"
|
|
279
286
|
parameters.update(extra_parameters or {})
|
|
280
287
|
|
|
281
288
|
notes: list[str] = []
|
|
@@ -389,6 +396,41 @@ def prepare_figure(
|
|
|
389
396
|
cal = normalised_calibration(p)
|
|
390
397
|
p["y_calibration"] = cal.to_dict()
|
|
391
398
|
|
|
399
|
+
# Plots stacked with no gap between them make one frame, whose tick labels then do not agree on a scale (they
|
|
400
|
+
# repeat, one set to a plot). Where lines as dark as the frame's sides run across it, and the plots between them
|
|
401
|
+
# each read as an axis of their own, they are taken as plots of their own.
|
|
402
|
+
if y_ref is None and y_min is None and ocr and plot_boxes is None:
|
|
403
|
+
split_any = False
|
|
404
|
+
result: list[dict] = []
|
|
405
|
+
for p in panels:
|
|
406
|
+
cal = p["y_calibration"]
|
|
407
|
+
weak = cal["scale_type"] == "normalised_0_to_1" or cal.get("confidence") == "low"
|
|
408
|
+
if p.get("stack_cuts") and weak:
|
|
409
|
+
subs = split_stack(rgb, p)
|
|
410
|
+
for k, sub in enumerate(subs):
|
|
411
|
+
sub["panel_id"] = k + 1
|
|
412
|
+
sub["y_calibration"] = calibrate_y(
|
|
413
|
+
rgb, dark, sub, limit=limits[p["panel_id"]][0], scale=y_scale, verbose=show_debug,
|
|
414
|
+
axis="y2" if y_axis == "right" else "y",
|
|
415
|
+
).to_dict()
|
|
416
|
+
if all(sub["y_calibration"]["scale_type"] != "normalised_0_to_1" for sub in subs):
|
|
417
|
+
result.extend(subs)
|
|
418
|
+
split_any = True
|
|
419
|
+
note(
|
|
420
|
+
f"Panel {p['panel_id']} is {len(subs)} plots stacked one above another, with no gap "
|
|
421
|
+
"between them; each is read as a plot of its own."
|
|
422
|
+
)
|
|
423
|
+
continue
|
|
424
|
+
result.append(p)
|
|
425
|
+
if split_any:
|
|
426
|
+
result.sort(key=lambda d: (round(d["y_top"]), d["x0"]))
|
|
427
|
+
for k, sub in enumerate(result, start=1):
|
|
428
|
+
sub["panel_id"] = k
|
|
429
|
+
sub.setdefault("layout_mode", layout_mode)
|
|
430
|
+
panels = result
|
|
431
|
+
limits = _neighbour_limits(panels, right=(y_axis == "right"))
|
|
432
|
+
n_panels = len(panels)
|
|
433
|
+
|
|
392
434
|
if y_ref is None and y_min is None:
|
|
393
435
|
panels = propagate_y_calibration_across_rows(panels, verbose=show_debug)
|
|
394
436
|
|
|
@@ -464,6 +506,33 @@ def prepare_figure(
|
|
|
464
506
|
"output has x_pixel and x_norm_0_to_1 but no x_value. Give x_min and x_max, or x_ref."
|
|
465
507
|
)
|
|
466
508
|
|
|
509
|
+
# ------------------------------------------------------------------
|
|
510
|
+
# 4. A legend drawn inside a plot is left out, as if it had been excluded
|
|
511
|
+
# ------------------------------------------------------------------
|
|
512
|
+
if find_legends:
|
|
513
|
+
found_legends = []
|
|
514
|
+
for p in panels:
|
|
515
|
+
e = p["edges"]
|
|
516
|
+
half = int(p.get("line_thickness", 1)) // 2 + 1
|
|
517
|
+
box = (
|
|
518
|
+
int(np.ceil(e["left"])) + half,
|
|
519
|
+
int(np.ceil(e["top"])) + half,
|
|
520
|
+
int(np.floor(e["right"])) - half,
|
|
521
|
+
int(np.floor(e["bottom"])) - half,
|
|
522
|
+
)
|
|
523
|
+
boxes = [tuple(float(v) for v in b) for b in find_legend(rgb, box)]
|
|
524
|
+
if boxes:
|
|
525
|
+
found_legends.extend(boxes)
|
|
526
|
+
x0, y0 = min(b[0] for b in boxes), min(b[1] for b in boxes)
|
|
527
|
+
x1, y1 = max(b[2] for b in boxes), max(b[3] for b in boxes)
|
|
528
|
+
note(
|
|
529
|
+
f"Panel {p['panel_id']}: a legend was found inside the plot area (x {x0:.0f}-{x1:.0f}, y "
|
|
530
|
+
f"{y0:.0f}-{y1:.0f}) and left out. If that is not a legend, pass legend='keep'."
|
|
531
|
+
)
|
|
532
|
+
if found_legends:
|
|
533
|
+
exclude_boxes = list(exclude_boxes or []) + found_legends
|
|
534
|
+
parameters["legends_found"] = found_legends
|
|
535
|
+
|
|
467
536
|
return PreparedFigure(
|
|
468
537
|
rgb=rgb,
|
|
469
538
|
dark=dark,
|
|
@@ -176,13 +176,16 @@ class Preparation:
|
|
|
176
176
|
# settled by the first (fast) attempt are therefore read again in other ways
|
|
177
177
|
# and the readings pooled; the axis fit then keeps the readings that agree.
|
|
178
178
|
# The settings and their order were chosen on labels 5 to 7 pixels high in
|
|
179
|
-
# three typefaces: each one added reads labels that the ones before it miss.
|
|
179
|
+
# three typefaces: each one added reads labels that the ones before it miss. The last was added for large print.
|
|
180
180
|
PREPARATIONS = (
|
|
181
181
|
Preparation(44, True, 2.0, True, True, 6),
|
|
182
182
|
Preparation(64, False, 2.0, True, False, 7),
|
|
183
183
|
Preparation(64, True, 0.0, False, False, 7, gamma=0.6),
|
|
184
184
|
Preparation(32, False, 2.0, True, False, 7),
|
|
185
185
|
Preparation(64, False, 0.0, False, False, 7, gamma=0.6),
|
|
186
|
+
# Large, clean print in some typefaces (MATLAB's Helvetica at 600 dpi) is misread as a line of text (75 as 15)
|
|
187
|
+
# and read right as a single word.
|
|
188
|
+
Preparation(64, False, 0.0, True, False, 8),
|
|
186
189
|
)
|
|
187
190
|
|
|
188
191
|
|
|
@@ -658,6 +661,84 @@ def find_tick_labels(
|
|
|
658
661
|
best = found
|
|
659
662
|
if len(found) >= 2:
|
|
660
663
|
break
|
|
664
|
+
|
|
665
|
+
# Labels set close together (or an axis title just below them, run into them) come out as one long piece. Where
|
|
666
|
+
# the axis has tick marks at a regular step, each label is looked for under its own tick instead: the first
|
|
667
|
+
# piece of print out from the axis in the stretch nearer that tick than any other.
|
|
668
|
+
origin = x_lo if axis == "x" else y_lo
|
|
669
|
+
key = {"x": "tick_cols", "y": "tick_rows", "y2": "tick_rows_right"}[axis]
|
|
670
|
+
ticks = sorted(t - origin for t in panel.get(key, []) if lo - 2 <= t - origin <= hi + 2)
|
|
671
|
+
lattice = _regular(ticks)
|
|
672
|
+
if len(lattice) >= 3:
|
|
673
|
+
step = float(np.median(np.diff(lattice)))
|
|
674
|
+
# (labels run together: one piece much longer than the rest, or one piece where there should be several -
|
|
675
|
+
# not merely labels longer than the step between minor ticks)
|
|
676
|
+
lengths = [((lab.box[2] - lab.box[0]) if not vertical else (lab.box[3] - lab.box[1])) + 1 for lab in best]
|
|
677
|
+
too_wide = bool(lengths) and (
|
|
678
|
+
(len(lengths) >= 2 and max(lengths) > 2.0 * float(np.median(lengths)) and max(lengths) > 1.1 * step)
|
|
679
|
+
or (len(lengths) == 1 and lengths[0] > 2.5 * step)
|
|
680
|
+
)
|
|
681
|
+
# (pieces of an axis title run in among the labels split them up: more pieces than ticks)
|
|
682
|
+
many = len(best) > 1.5 * (len(lattice) if len(lattice) >= 0.7 * len(ticks) else len(ticks))
|
|
683
|
+
if too_wide or len(best) < 0.6 * len(lattice) or many:
|
|
684
|
+
at_ticks = []
|
|
685
|
+
# under an x axis, the print of a label has no blank row across it, and a title below does; beside a
|
|
686
|
+
# y axis, the characters of a label stand a little apart
|
|
687
|
+
gap = max(1.0, 0.5 * glyph_h) if vertical else 1.0
|
|
688
|
+
for t in lattice:
|
|
689
|
+
a, b = int(math.ceil(t - 0.48 * step)), int(math.floor(t + 0.48 * step))
|
|
690
|
+
a, b = max(0, a), min(ink.shape[1] - 1, b)
|
|
691
|
+
if b - a < 2:
|
|
692
|
+
continue
|
|
693
|
+
window = ink[:, a : b + 1]
|
|
694
|
+
rows = _merge_runs(_runs(window.any(axis=1)), max_gap=gap)
|
|
695
|
+
rows = [r for r in rows if window[r[0] : r[1] + 1].sum() >= 4]
|
|
696
|
+
if not rows or rows[0][0] > 4.0 * glyph_h + 0.12 * span:
|
|
697
|
+
continue
|
|
698
|
+
da, db = rows[0]
|
|
699
|
+
cols = np.flatnonzero(window[da : db + 1].any(axis=0))
|
|
700
|
+
if cols[0] == 0 or cols[-1] == window.shape[1] - 1:
|
|
701
|
+
continue # it runs on past the stretch: a label of a tick further on (minor ticks between)
|
|
702
|
+
ca, cb = a + int(cols[0]), a + int(cols[-1])
|
|
703
|
+
if abs(0.5 * (ca + cb) - t) > 0.25 * step:
|
|
704
|
+
continue # not centred on this tick
|
|
705
|
+
if not 0.5 * glyph_h <= db - da + 1 <= 3.0 * glyph_h:
|
|
706
|
+
continue
|
|
707
|
+
box = to_image_box(da, db, ca, cb)
|
|
708
|
+
at_ticks.append(
|
|
709
|
+
TickLabel(
|
|
710
|
+
pos=0.5 * (box[1] + box[3]) if vertical else 0.5 * (box[0] + box[2]),
|
|
711
|
+
box=box,
|
|
712
|
+
anchor=float(t + origin),
|
|
713
|
+
)
|
|
714
|
+
)
|
|
715
|
+
if (
|
|
716
|
+
len(at_ticks) > len(best)
|
|
717
|
+
or (too_wide and len(at_ticks) >= 3)
|
|
718
|
+
or (many and len(at_ticks) >= max(3, 0.6 * len(lattice)))
|
|
719
|
+
):
|
|
720
|
+
best = at_ticks
|
|
721
|
+
return best
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _regular(positions: list[float]) -> list[float]:
|
|
725
|
+
"""The longest run of *positions* at one regular step (to 4 %, or a pixel and a half): the tick marks along an
|
|
726
|
+
axis, without the ends of the axis or a gridline out of step."""
|
|
727
|
+
pos = sorted(positions)
|
|
728
|
+
best: list[float] = []
|
|
729
|
+
for i in range(len(pos)):
|
|
730
|
+
for j in range(i + 1, len(pos)):
|
|
731
|
+
step = pos[j] - pos[i]
|
|
732
|
+
if step < 4:
|
|
733
|
+
continue
|
|
734
|
+
run = [pos[i], pos[j]]
|
|
735
|
+
nxt = pos[j] + step
|
|
736
|
+
for q in pos[j + 1 :]:
|
|
737
|
+
if abs(q - nxt) <= max(1.5, 0.04 * step):
|
|
738
|
+
run.append(q)
|
|
739
|
+
nxt = q + step
|
|
740
|
+
if len(run) > len(best):
|
|
741
|
+
best = run
|
|
661
742
|
return best
|
|
662
743
|
|
|
663
744
|
|
|
@@ -837,7 +918,10 @@ def _restore_lost_points(labels: list[TickLabel]) -> None:
|
|
|
837
918
|
decimals = 0
|
|
838
919
|
for lab in labels:
|
|
839
920
|
whole = _whole_number(lab.text) if lab.text else None
|
|
840
|
-
|
|
921
|
+
# (in print large enough to read, every reading of the label: "54" read once as "54" and twice as "04" is a
|
|
922
|
+
# misread 5, not a lost point; it is small print that loses its points)
|
|
923
|
+
small = lab.box[3] - lab.box[1] + 1 < 2 * SMALL_PRINT_PX
|
|
924
|
+
if whole and len(whole[1]) > 1 and whole[1][0] == "0" and (small or len(set(lab.readings)) == 1):
|
|
841
925
|
decimals = max(decimals, len(whole[1]) - 1)
|
|
842
926
|
if decimals == 0:
|
|
843
927
|
return
|