trace-digitiser 2.2.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.2.0 → trace_digitiser-2.3.0}/CHANGELOG.md +92 -0
- {trace_digitiser-2.2.0/src/trace_digitiser.egg-info → trace_digitiser-2.3.0}/PKG-INFO +32 -9
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/README.md +31 -8
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/RELEASING.md +19 -3
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/pyproject.toml +1 -1
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/__init__.py +17 -1
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/_figure.py +70 -1
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/axis_labels.py +82 -1
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/frames.py +37 -0
- 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.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/panel_detection.py +23 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/trace_detection.py +50 -2
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0/src/trace_digitiser.egg-info}/PKG-INFO +32 -9
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/SOURCES.txt +2 -0
- trace_digitiser-2.3.0/tests/test_legend.py +65 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_readers.py +17 -1
- trace_digitiser-2.2.0/src/trace_digitiser/markers.py +0 -587
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/LICENSE +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/MANIFEST.in +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/README.md +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/digitise_published_figure.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/lopatina2014_fig4.jpg +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/validate_with_known_data.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/walkthrough.ipynb +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/setup.cfg +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/_results.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/bars.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/calibration.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/cli.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/diagnostics.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/digitise.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/explore.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/geometry.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/heatmap.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/io.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/line_detection.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/models.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/ocr.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/points.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/polar.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/provenance.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/resampling.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/rows.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/straighten.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/summarise.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/synthetic.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/validation.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/x_calibration.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/dependency_links.txt +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/entry_points.txt +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/requires.txt +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/top_level.txt +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/__init__.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/conftest.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/data/lopatina2014_fig4_reference.csv +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_axis_labels.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_calibration.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_cli.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_digitise.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_figure_styles.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_frames.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_geometry.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_integration.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_io.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_panel_detection.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_published_figure.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_straighten.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_trace_detection.py +0 -0
- {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_validation.py +0 -0
|
@@ -4,6 +4,96 @@ 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
|
+
|
|
7
97
|
## [2.2.0] - 2026-11-15
|
|
8
98
|
|
|
9
99
|
Tested on the figures of published papers whose underlying numbers are
|
|
@@ -222,6 +312,8 @@ First version tracked in this repository; identical to the 1.2.0 files on PyPI.
|
|
|
222
312
|
Releases 0.1.0 to 1.0.0 (6-7 June 2026) were uploaded to PyPI directly and are
|
|
223
313
|
not recorded here.
|
|
224
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
|
|
225
317
|
[2.2.0]: https://github.com/comparativechrono/trace-digitiser/compare/v2.1.0...v2.2.0
|
|
226
318
|
[2.1.0]: https://github.com/comparativechrono/trace-digitiser/compare/v2.0.0...v2.1.0
|
|
227
319
|
[2.0.0]: https://github.com/comparativechrono/trace-digitiser/compare/1d34f75...v2.0.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
|
|
@@ -271,13 +271,34 @@ your figure has such labels, enlarging the image does not help; typing in two nu
|
|
|
271
271
|
[`validation/published-figures`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/published-figures)
|
|
272
272
|
holds seven figures from four papers whose authors published the numbers behind them — markers with error bars, a
|
|
273
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.
|
|
275
|
-
the axis,
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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.
|
|
281
302
|
|
|
282
303
|
### Test it on data you know
|
|
283
304
|
|
|
@@ -325,6 +346,8 @@ pip install -e ".[dev]"
|
|
|
325
346
|
pytest # the tests that need Tesseract are skipped without it
|
|
326
347
|
ruff check src tests
|
|
327
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)
|
|
328
351
|
```
|
|
329
352
|
|
|
330
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
|
|
@@ -231,13 +231,34 @@ your figure has such labels, enlarging the image does not help; typing in two nu
|
|
|
231
231
|
[`validation/published-figures`](https://github.com/comparativechrono/trace-digitiser/tree/main/validation/published-figures)
|
|
232
232
|
holds seven figures from four papers whose authors published the numbers behind them — markers with error bars, a
|
|
233
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.
|
|
235
|
-
the axis,
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
241
262
|
|
|
242
263
|
### Test it on data you know
|
|
243
264
|
|
|
@@ -285,6 +306,8 @@ pip install -e ".[dev]"
|
|
|
285
306
|
pytest # the tests that need Tesseract are skipped without it
|
|
286
307
|
ruff check src tests
|
|
287
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)
|
|
288
311
|
```
|
|
289
312
|
|
|
290
313
|
`src/trace_digitiser/` in the order the work is done:
|
|
@@ -51,13 +51,29 @@ plans). Now that the repository is public, one can be added:
|
|
|
51
51
|
results with the last release's:
|
|
52
52
|
|
|
53
53
|
```bash
|
|
54
|
-
python validation/published-figures/run.py \
|
|
54
|
+
python validation/published-figures/run.py --digitised \
|
|
55
55
|
--json validation/published-figures/results-X.Y.Z.json \
|
|
56
56
|
--compare validation/published-figures/results-<last release>.json
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
-
Look into any case marked WORSE before going on. Commit the new results
|
|
60
|
-
and update the table in that
|
|
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).
|
|
61
77
|
4. Merge to `main` and wait for the *Tests* workflow to pass.
|
|
62
78
|
5. On GitHub: *Releases -> Draft a new release*, create the tag `vX.Y.Z`
|
|
63
79
|
(matching the version exactly, with a leading `v`) on `main`, and publish.
|
|
@@ -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
|
|
@@ -233,6 +240,15 @@ def digitise(
|
|
|
233
240
|
rgb, p, colours=trace_colours, exclude=exclude_boxes, achromatic=achromatic, darkness=dark,
|
|
234
241
|
lines_only=True,
|
|
235
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]
|
|
236
252
|
if show_debug:
|
|
237
253
|
print(f"Panel {p['panel_id']}: detected {len(masks)} trace(s)")
|
|
238
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,
|
|
@@ -661,6 +661,84 @@ def find_tick_labels(
|
|
|
661
661
|
best = found
|
|
662
662
|
if len(found) >= 2:
|
|
663
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
|
|
664
742
|
return best
|
|
665
743
|
|
|
666
744
|
|
|
@@ -840,7 +918,10 @@ def _restore_lost_points(labels: list[TickLabel]) -> None:
|
|
|
840
918
|
decimals = 0
|
|
841
919
|
for lab in labels:
|
|
842
920
|
whole = _whole_number(lab.text) if lab.text else None
|
|
843
|
-
|
|
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):
|
|
844
925
|
decimals = max(decimals, len(whole[1]) - 1)
|
|
845
926
|
if decimals == 0:
|
|
846
927
|
return
|
|
@@ -71,6 +71,7 @@ class Frame:
|
|
|
71
71
|
ticks_x: list[float] = field(default_factory=list)
|
|
72
72
|
source: str = "axis_lines"
|
|
73
73
|
from_edges: bool = False # its left or bottom side is the edge of a filled area, not a line
|
|
74
|
+
stack_cuts: list[float] = field(default_factory=list) # lines across it that may part plots stacked in it
|
|
74
75
|
|
|
75
76
|
@property
|
|
76
77
|
def width(self) -> float:
|
|
@@ -384,6 +385,37 @@ def _filter_frames(frames: list[Frame], image_w: int, image_h: int) -> list[Fram
|
|
|
384
385
|
return _reading_order(kept)
|
|
385
386
|
|
|
386
387
|
|
|
388
|
+
def _find_stack_cuts(frames: list[Frame], horizontal: list[AxisLine], tol: float) -> None:
|
|
389
|
+
"""Note the lines that may part plots stacked with no gap between them, in each frame's ``stack_cuts``.
|
|
390
|
+
|
|
391
|
+
Stacked plots that share their y-axis line (one line down the side of all of them) make a single frame: its
|
|
392
|
+
corner is the bottom plot's. The lines between them run right across, as dark and as thick as the frame's own
|
|
393
|
+
sides - as a dark gridline may, too: whether the frame is one plot or several is decided by its tick labels
|
|
394
|
+
(see :func:`trace_digitiser.panel_detection.split_stack`).
|
|
395
|
+
"""
|
|
396
|
+
for f in frames:
|
|
397
|
+
if f.kind != "box" or f.height < 60:
|
|
398
|
+
continue
|
|
399
|
+
sides = [ln for ln in horizontal if abs(ln.pos - f.bottom) <= tol or abs(ln.pos - f.top) <= tol]
|
|
400
|
+
if not sides:
|
|
401
|
+
continue
|
|
402
|
+
side_dark = min(ln.darkness for ln in sides)
|
|
403
|
+
side_thick = min(ln.thickness for ln in sides)
|
|
404
|
+
cuts = []
|
|
405
|
+
for ln in horizontal:
|
|
406
|
+
if not (f.top + 0.15 * f.height < ln.pos < f.bottom - 0.15 * f.height):
|
|
407
|
+
continue
|
|
408
|
+
if abs(ln.start - f.left) > tol + ln.thickness or abs(ln.end - f.right) > tol + ln.thickness:
|
|
409
|
+
continue # it does not run from side to side
|
|
410
|
+
if ln.darkness < 0.85 * side_dark or ln.thickness < max(1, side_thick):
|
|
411
|
+
continue # a gridline
|
|
412
|
+
cuts.append(float(ln.pos))
|
|
413
|
+
cuts = sorted(set(round(c, 1) for c in cuts))
|
|
414
|
+
edges = [f.top] + cuts + [f.bottom]
|
|
415
|
+
if cuts and min(b - a for a, b in zip(edges, edges[1:])) >= 0.15 * f.height:
|
|
416
|
+
f.stack_cuts = cuts
|
|
417
|
+
|
|
418
|
+
|
|
387
419
|
def _reading_order(frames: list[Frame]) -> list[Frame]:
|
|
388
420
|
"""Sort frames row by row, left to right, treating nearly equal tops as one row."""
|
|
389
421
|
if not frames:
|
|
@@ -497,6 +529,10 @@ def _add_gridlines_and_ticks(frame: Frame, dark: np.ndarray, sat: np.ndarray) ->
|
|
|
497
529
|
is_stub = (run >= min_run) & (run < strip.shape[1])
|
|
498
530
|
return [0.5 * (b0 + b1) for b0, b1 in _runs(is_stub) if b1 - b0 + 1 <= max_thick]
|
|
499
531
|
|
|
532
|
+
# A tick is darker than what it is drawn on - the paper, or a pale band shading part of the plot (which is
|
|
533
|
+
# neutral ink too, and would make every row inside the axis a run of ink).
|
|
534
|
+
lum = 255.0 - dark.astype(np.float32)
|
|
535
|
+
ink = ink & ((cv2.dilate(lum, np.ones((15, 15), np.uint8)) - lum) > 50.0)
|
|
500
536
|
xl = int(round(frame.left))
|
|
501
537
|
yb = int(round(frame.bottom))
|
|
502
538
|
half = t // 2 + 1
|
|
@@ -534,6 +570,7 @@ def find_frames_and_lines(rgb: np.ndarray) -> tuple[list[Frame], list[AxisLine],
|
|
|
534
570
|
tol = float(max(3, int(round(0.012 * min(h, w)))))
|
|
535
571
|
|
|
536
572
|
frames = _filter_frames(_assemble_frames(horizontal, vertical, tol), w, h)
|
|
573
|
+
_find_stack_cuts(frames, horizontal, tol)
|
|
537
574
|
sat = _saturation(rgb)
|
|
538
575
|
for frame in frames:
|
|
539
576
|
_add_gridlines_and_ticks(frame, dark, sat)
|