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.
Files changed (70) hide show
  1. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/CHANGELOG.md +92 -0
  2. {trace_digitiser-2.2.0/src/trace_digitiser.egg-info → trace_digitiser-2.3.0}/PKG-INFO +32 -9
  3. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/README.md +31 -8
  4. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/RELEASING.md +19 -3
  5. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/pyproject.toml +1 -1
  6. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/__init__.py +17 -1
  7. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/_figure.py +70 -1
  8. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/axis_labels.py +82 -1
  9. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/frames.py +37 -0
  10. trace_digitiser-2.3.0/src/trace_digitiser/legend.py +331 -0
  11. trace_digitiser-2.3.0/src/trace_digitiser/markers.py +1073 -0
  12. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/panel_detection.py +23 -0
  13. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/trace_detection.py +50 -2
  14. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0/src/trace_digitiser.egg-info}/PKG-INFO +32 -9
  15. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/SOURCES.txt +2 -0
  16. trace_digitiser-2.3.0/tests/test_legend.py +65 -0
  17. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_readers.py +17 -1
  18. trace_digitiser-2.2.0/src/trace_digitiser/markers.py +0 -587
  19. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/LICENSE +0 -0
  20. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/MANIFEST.in +0 -0
  21. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/README.md +0 -0
  22. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/digitise_published_figure.py +0 -0
  23. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/lopatina2014_fig4.jpg +0 -0
  24. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/validate_with_known_data.py +0 -0
  25. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/examples/walkthrough.ipynb +0 -0
  26. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/setup.cfg +0 -0
  27. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/_results.py +0 -0
  28. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/bars.py +0 -0
  29. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/calibration.py +0 -0
  30. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/cli.py +0 -0
  31. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/diagnostics.py +0 -0
  32. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/digitise.py +0 -0
  33. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/explore.py +0 -0
  34. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/geometry.py +0 -0
  35. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/heatmap.py +0 -0
  36. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/io.py +0 -0
  37. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/line_detection.py +0 -0
  38. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/models.py +0 -0
  39. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/ocr.py +0 -0
  40. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/points.py +0 -0
  41. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/polar.py +0 -0
  42. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/provenance.py +0 -0
  43. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/resampling.py +0 -0
  44. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/rows.py +0 -0
  45. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/straighten.py +0 -0
  46. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/summarise.py +0 -0
  47. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/synthetic.py +0 -0
  48. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/validation.py +0 -0
  49. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser/x_calibration.py +0 -0
  50. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/dependency_links.txt +0 -0
  51. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/entry_points.txt +0 -0
  52. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/requires.txt +0 -0
  53. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/src/trace_digitiser.egg-info/top_level.txt +0 -0
  54. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/__init__.py +0 -0
  55. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/conftest.py +0 -0
  56. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/data/lopatina2014_fig4_reference.csv +0 -0
  57. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_axis_labels.py +0 -0
  58. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_calibration.py +0 -0
  59. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_cli.py +0 -0
  60. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_digitise.py +0 -0
  61. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_figure_styles.py +0 -0
  62. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_frames.py +0 -0
  63. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_geometry.py +0 -0
  64. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_integration.py +0 -0
  65. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_io.py +0 -0
  66. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_panel_detection.py +0 -0
  67. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_published_figure.py +0 -0
  68. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_straighten.py +0 -0
  69. {trace_digitiser-2.2.0 → trace_digitiser-2.3.0}/tests/test_trace_detection.py +0 -0
  70. {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.2.0
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 | 257 (all with a warning to check them) | 15 | 1 | 0 |
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.2.0 reads all 35 bars of the bar charts to within 0.3 % of
275
- the axis, and the 29 points of a scatter plot of four series to within 0.5 % (2.1.0 read none of either); with two
276
- ticks clicked on each axis and the dots' colour, the census dots of the 1920 figure to within 0.9 %. On the figures
277
- of a paper held out until the changes were made, with the plot area drawn, it reads 24 bars to within 0.4 % once
278
- two ticks are clicked, and 33 of 37 points once the ticks of a slanted axis and the colours are. Stacked plots with no gap between them, dot
279
- plots whose dots overlap, and tick labels that are slanted or hand-lettered still need help or remain hard; that
280
- folder's README has the details.
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 | 257 (all with a warning to check them) | 15 | 1 | 0 |
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.2.0 reads all 35 bars of the bar charts to within 0.3 % of
235
- the axis, and the 29 points of a scatter plot of four series to within 0.5 % (2.1.0 read none of either); with two
236
- ticks clicked on each axis and the dots' colour, the census dots of the 1920 figure to within 0.9 %. On the figures
237
- of a paper held out until the changes were made, with the plot area drawn, it reads 24 bars to within 0.4 % once
238
- two ticks are clicked, and 33 of 37 points once the ticks of a slanted axis and the colours are. Stacked plots with no gap between them, dot
239
- plots whose dots overlap, and tick labels that are slanted or hand-lettered still need help or remain hard; that
240
- folder's README has the details.
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 folder's README (`--markdown` prints it).
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.2.0"
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
- if whole and len(whole[1]) > 1 and whole[1][0] == "0":
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)