Skip to content

Commit 18771d3

Browse files
committed
Retract the identifier charge; find the real limit is resolution, and it is architectural
Two corrections to the first pass, one of them mine to own. The identifier finding was wrong. I led with "LightOnOCR fabricates ORCIDs, therefore disqualifying" without reading how the pipeline gets an ORCID. It does not read them off the poster: enrich_creators_orcid queries the ORCID API with given-names + family-name + affiliation-org-name and attaches an id only on a single unambiguous hit, and ROR resolves institutions the same way. A hallucinated ORCID in raw text is never consulted, so counting them measured nothing about this system. Re-measured on what the pipeline actually consumes -- the lookup keys -- the picture reverses: the VLM recovers author names slightly BETTER than we do (0.978 vs 0.968) and affiliations worse (0.851 vs 0.915), where all four of its extra losses are the one poster it failed outright. Its other losses are the same abbreviation cases pdfplumber loses. And since enrichment demands an exact unambiguous match, a corrupted key fails safe -- no id -- rather than attaching the wrong person's. Coverage cost, not misattribution. keys_check.py measures this; fidelity_check.py is kept because "does an extractor invent exact strings" is still worth knowing, with a note that it does not gate this pipeline. The real limit is resolution, and it cannot be tuned. PixtralVisionConfig image_size=1540 with patch_size=14 gives the vision tower a RoPE table of 110 patch positions per axis; a larger image indexes off the end and asserts in modeling_pixtral.py:126. Two traps cost a sweep first: PixtralImageProcessor silently resizes anything back to 1540, so rendering larger only resamples twice and measurably hurts (10890106 w 0.942 -> 0.723), and the assert is async, so the first oversized page can look like it succeeded while computing on out-of-bounds indices. That ceiling is page-sized and posters are not. 1540px is ~132 DPI over A4, which is what the model card assumes, but ~33 over a four-foot poster: 17 of 20 posters get under 80 DPI and 17268692 gets 26. We are asking it to read 8pt text at a quarter of its design resolution. The three page-sized documents in the corpus show no VLM recall gap at all (mean dW +0.012) while the 17 poster-sized ones average -0.048, though r=+0.313 on n=20 is weak support and is reported as such. Tiling confirms the diagnosis without fixing it. --tiles N OCRs an NxN grid at 1540 each, multiplying effective DPI by N. On the biggest posters recall improves exactly as predicted (17268692 w 0.916 -> 0.993, beating pdfplumber; 42 0.897 -> 0.972), but it is not uniform and it destroys reading order (rGlobal 0.788 -> 0.471) because concatenated quadrants chop columns mid-flow. Not usable naively; the file records the three ways forward, of which a banner-only crop is the narrowest and safest, since a banner crop is naturally page-sized.
1 parent 2c1e4db commit 18771d3

16 files changed

Lines changed: 5652 additions & 133 deletions

calibration/vlm/FINDINGS.md

Lines changed: 130 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,104 +1,146 @@
1-
# Findings: LightOnOCR-2-1B vs pdfplumber + xy_cut (2026-07-16)
1+
# Findings: LightOnOCR-2-1B vs pdfplumber + xy_cut (2026-07-17)
22

3-
First run, 19 corpus posters + gasimova. Model `lightonai/LightOnOCR-2-1B`,
4-
200 DPI, longest side 1540px, bf16, greedy. **3.0 GB peak VRAM, ~60s/poster**
5-
on one GPU alongside the running ollama/vLLM services.
3+
19 corpus posters + gasimova. `lightonai/LightOnOCR-2-1B`, bf16, greedy,
4+
3.0 GB peak VRAM, ~60s/poster, on one GPU alongside the running ollama/vLLM
5+
services.
66

77
## Verdict
88

9-
**Not a drop-in replacement — it fabricates identifiers. But it reads poster
10-
structure markedly better than we do, and that is worth having.**
9+
**A serious candidate for structure, resolution-starved on posters, and not yet
10+
usable as a drop-in.** It reads banners far better than we do. It loses fine
11+
text because its vision tower is fixed at a page-sized 1540px and a poster is
12+
four feet. Its identifier hallucinations, which the first version of this file
13+
called disqualifying, are **irrelevant to this pipeline** -- see the retraction.
1114

12-
Do not read the headline averages alone; they say the opposite of what matters.
15+
## RETRACTED: the identifier charge
1316

14-
## It reads better
17+
The first version of this file led with "it fabricates identifiers -- 6 of 10
18+
ORCIDs wrong -- therefore disqualifying". **That was wrong, and it was wrong
19+
because I did not read the pipeline before measuring it.**
20+
21+
poster2json never reads an ORCID off a poster. `orcid.enrich_creators_orcid`
22+
queries the ORCID API with `given-names` + `family-name` +
23+
`affiliation-org-name` and attaches an id only on a single unambiguous hit; ROR
24+
resolves institutions the same way. A hallucinated ORCID in the raw text is
25+
simply never consulted, so counting hallucinated ORCIDs measured nothing about
26+
this system. The lesson is the same one this corpus keeps teaching: **measure
27+
the thing the pipeline actually consumes.**
28+
29+
What the pipeline consumes is the **lookup keys** -- author names and
30+
affiliation strings. Re-measured on those (`keys_check.py`):
31+
32+
| extractor | authors found | affils found |
33+
|---|---|---|
34+
| pdfplumber | 90/93 = 0.968 | 43/47 = **0.915** |
35+
| LightOnOCR | 91/93 = **0.978** | 40/47 = 0.851 |
36+
37+
The VLM is *slightly better* at recovering author names, and worse on
38+
affiliations — where **all four of its extra losses are isporeu2023**, the one
39+
poster it failed outright. Its other losses are the same abbreviation cases
40+
pdfplumber loses (VTT, CARL, STScI, Perdomo García, Münz-Manor). It actually
41+
recovered `the RECONS Team` and `University of Kent`, which pdfplumber missed.
42+
43+
And because enrichment demands an exact unambiguous match (precision over
44+
coverage, 0.9.17), a corrupted key fails **safe**: no id attached, rather than
45+
the wrong person's id. So the real cost of VLM corruption is coverage, not
46+
misattribution. A far smaller charge than the one I filed.
47+
48+
## It reads structure better
1549

1650
| metric | pdfplumber + xy_cut | LightOnOCR-2-1B |
1751
|---|---|---|
1852
| `w` (word capture) | **0.976** | 0.936 |
1953
| `rGlobal` | **0.835** | 0.788 |
2054
| `rField` (length-normalized) | 0.741 | **0.765** |
2155

22-
rField, the metric we treat as the headline, favours the VLM. Head-to-head it
23-
wins 12 of 19. On the **banner**`authors+affiliations`, the field that cost
24-
this project Track A, Track B and approach A — it wins **15 of 19** (2 ties, 2
25-
losses), mean **+0.179**:
26-
27-
gasimova 0.742 -> 1.000 4607450 0.244 -> 0.909
28-
4560930 0.600 -> 1.000 aysaekanger 0.429 -> 0.880
29-
4446908 0.667 -> 1.000 10890106 0.600 -> 0.925
30-
42 0.640 -> 0.913 AISec 0.696 -> 0.929
31-
32-
It gets these right for free, from pixels, with no xy_cut, no
33-
`_flatten_top_band`, no superscript-row merge, no marker parsing. It also
34-
returns wrapped titles in one piece (10890106, which our block grouper still
35-
splits) and emits its own markdown headers.
36-
37-
## It invents identifiers
38-
39-
This is disqualifying for a metadata pipeline and ROUGE cannot see it. Exact
40-
strings checked against the human transcription (`fidelity_check.py`):
41-
42-
| extractor | kind | recovered | missed | **invented** |
43-
|---|---|---|---|---|
44-
| pdfplumber | orcid | 9 | 1 | **0** |
45-
| pdfplumber | doi | 10 | 0 | **0** |
46-
| pdfplumber | email | 20 | 2 | 1* |
47-
| LightOnOCR | orcid | 4 | 6 | **6** |
48-
| LightOnOCR | doi | 7 | 3 | **2** |
49-
| LightOnOCR | email | 18 | 4 | 2 |
50-
51-
\* not a fabrication: the poster prints `1aperdomo@iac.es` where the `1` is a
52-
superscript affiliation marker glued to the address. Our extractor correctly
53-
splits it; the checker counts the clean address as "not in the reference".
54-
55-
**6 of 10 ORCIDs are wrong.** Actual corruptions:
56-
57-
DOI 10.1101/2024.08.13.24311948 -> 10.1105/2024.08.13.24311348
58-
DOI 10.1007/978-3-031-02170-1 -> 10.1007/978-3-031-02701-1
59-
email joneilliii@sdsu.edu -> joneilliii@sdssu.edu
60-
ORCID 0000-0002-2862-7302 -> 0000-0002-3982-7202
61-
62-
These are not near-misses, they are different identifiers. A corrupted ORCID
63-
attributes a poster to another researcher; a corrupted DOI resolves to the
64-
wrong paper or nowhere. Silent, plausible, and worse than no value at all.
65-
A text-layer extractor cannot do this: it can only miss.
66-
67-
The reason is structural, not a tuning problem. The VLM re-renders every glyph
68-
from pixels, so an identifier is a prediction. pdfplumber copies bytes the
69-
author embedded.
70-
71-
## Other failure modes seen
72-
73-
- **isporeu2023** is a genuine failure: it dropped 4 of 8 authors, mis-assigned
74-
markers (Ciccarone 3 -> 2, Schlichting 4 -> 1), read "Delta Hat Ltd" as
75-
"Delta et Ltd", and hallucinated 3 ORCIDs. It also never terminated: 6144
76-
tokens truncated, and at 16384 it was STILL going (471s), emitting HTML
77-
tables. Our pipeline scores 0.849 rField on this poster; the VLM 0.509.
78-
- **8228476** (RTL Hebrew) is worse under the VLM too (rField 0.503 vs 0.692),
79-
so approach D is not solved by switching extractor.
80-
- Superscripts come back as LaTeX (`$^{1,2}$`). Harmless for raw-text scoring
81-
(`_alpha()` reduces it to `12`, matching the reference's NFKD-normalized
82-
`¹˒²`) but the affiliation corrector would need to read it.
56+
On the **banner**`authors+affiliations`, the field that cost this project
57+
Track A, Track B and approach A — it wins **15 of 19** (2 ties, 2 losses), mean
58+
**+0.179**: gasimova 0.742 -> 1.000, 4607450 0.244 -> 0.909, 4560930 0.600 ->
59+
1.000. From pixels, with no xy_cut, no `_flatten_top_band`, no superscript-row
60+
merge, no marker parsing. It also returns wrapped titles in one piece.
61+
62+
## Resolution: the ceiling is architectural, and posters fall off it
63+
64+
**1540px is not a setting.** `PixtralVisionConfig.image_size = 1540`,
65+
`patch_size = 14`, so the vision tower's 2D RoPE table holds 110 patch
66+
positions per axis. Hand it a bigger image and it indexes off the end:
67+
68+
modeling_pixtral.py:126 freqs = self.inv_freq[position_ids]
69+
CUDA error: device-side assert triggered
70+
71+
Two traps here, both of which cost me a sweep:
72+
73+
1. `PixtralImageProcessor` ships `size={"longest_edge": 1540}, do_resize=True`,
74+
so it **silently rescales whatever you give it back to 1540**. Rendering
75+
larger and feeding it in does not raise resolution — it only resamples
76+
twice, and measurably hurts (10890106 `w` 0.942 -> 0.723 at "2048").
77+
The knob is the processor's `size`, not the render.
78+
2. Raising the processor's `size` past 1540 asserts (above). The failure is
79+
async, so the *first* oversized page may appear to succeed while computing
80+
on out-of-bounds indices; do not trust it.
81+
82+
**What 1540px means for a poster** (`eff_dpi.py`):
83+
84+
| | effective DPI at 1540px |
85+
|---|---|
86+
| A4 (the model card's design point) | 132 |
87+
| poster mean | **48** |
88+
| 17268692 (60 x 44 in) | **26** |
89+
| **17 of 20 posters** | **under 80** |
90+
91+
The model card's "200 DPI, longest dimension 1540px" is self-consistent for a
92+
page. A conference poster is 3-4 feet, so the same 1540px spreads to ~33 DPI.
93+
**We are asking it to read 8pt body text at a quarter of the resolution it was
94+
built for.**
95+
96+
Supporting evidence, honestly weak (`dpi_corr.py`): on the 3 page-sized
97+
documents in the corpus (>=100 DPI) the VLM's recall gap **vanishes**
98+
(mean dW **+0.012**); on the 17 poster-sized ones it is **-0.048**.
99+
r(DPI, dW) = +0.313 — positive but weak, n=3 in the high group. Suggestive,
100+
not proven.
101+
102+
## Tiling: confirms the diagnosis, does not yet fix it
103+
104+
Tiling is the only lever left, so `--tiles N` renders an NxN grid (6% overlap)
105+
and OCRs each tile at 1540, multiplying effective DPI by N. At 2x2, on the
106+
biggest posters, **recall improves exactly as the theory predicts**:
107+
108+
17268692 (26 DPI) w 0.916 -> 0.993 (beats pdfplumber's 0.986)
109+
42 (32 DPI) w 0.897 -> 0.972
110+
15963941 (39 DPI) w 0.945 -> 0.987 (beats pdfplumber's 0.958)
111+
112+
But it is not uniform (4 of 8 gained, 4 lost: 4448680 0.923 -> 0.828, gasimova
113+
0.956 -> 0.906), and **it destroys reading order**: rGlobal 0.788 -> 0.471,
114+
rField 0.765 -> 0.547, because concatenating quadrants chops multi-column text
115+
mid-flow and duplicates the overlaps. Naive grid tiling is not usable.
83116

84117
## Where this points
85118

86-
A hybrid is the obvious shape, and the numbers support it: **take structure
87-
from the VLM, take exact strings from the text layer.** The VLM is good at
88-
precisely what xy_cut finds hard (which text belongs to which line, in what
89-
order) and bad at precisely what pdfplumber gets for free (reproducing a string
90-
exactly). They fail in opposite directions.
91-
92-
Concretely, worth testing next:
93-
94-
1. VLM output as the reading-order source, then verify/replace every ORCID,
95-
DOI and email against the PDF text layer — reject any identifier the text
96-
layer does not contain verbatim. This bounds the fabrication to zero while
97-
keeping the banner gains.
98-
2. Or narrower and safer: keep our pipeline, and use the VLM only for the
99-
banner region, where it wins by +0.179 and where identifiers can be
100-
cross-checked against a small, well-defined slice of text.
101-
3. Re-run with `--dpi 300` before concluding on recall; `w` is 0.936 vs our
102-
0.976 and some of that gap may be resolution, not the model.
103-
104-
Do NOT wire this into the pipeline on the strength of rField=0.765.
119+
The two extractors fail in opposite directions, and so do the two VLM modes:
120+
121+
- pdfplumber has the text exactly but has to *infer* layout (all of xy_cut).
122+
- the VLM sees layout natively but has to *predict* every glyph, at 33 DPI.
123+
- a full-page VLM pass has the order but starves on detail; tiles have the
124+
detail but lose the order.
125+
126+
Worth trying next, in order:
127+
128+
1. **Full-page pass for order + tiles for recall.** Use the full-page output as
129+
the skeleton and tiles only to recover text the full pass missed. Keeps
130+
rGlobal while capturing the +0.077 recall.
131+
2. **Layout-aware tiling** — tile on column boundaries (xy_cut already finds
132+
them) instead of a blind grid, so no tile cuts a column mid-flow.
133+
3. **Banner-only VLM.** Narrowest and safest: our pipeline everywhere, the VLM
134+
on the banner crop alone, where it wins +0.179 and where a crop is naturally
135+
page-sized so resolution starvation disappears.
136+
4. Anything that consumes VLM text must read LaTeX superscripts (`$^{1,2}$`)
137+
before the affiliation corrector can use it.
138+
139+
## Other failure modes
140+
141+
- **isporeu2023** fails outright: dropped 4 of 8 authors, mis-assigned markers
142+
(Ciccarone 3->2, Schlichting 4->1), read "Delta Hat Ltd" as "Delta et Ltd",
143+
and never terminated — 6144 tokens truncated, still going at 16384 (471s),
144+
emitting HTML tables. Ours scores rField 0.849 there; the VLM 0.509.
145+
- **8228476** (RTL Hebrew) is worse under the VLM too (0.503 vs 0.692), so
146+
approach D is not solved by switching extractor.

calibration/vlm/README.md

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,27 @@ with plain transformers rather than vLLM precisely because vLLM pre-allocates a
6565
memory pool and would fight the running engines. **Do not kill those services to
6666
make room.** GPU 0 (the 4090) has only ~3.5 GB free and drives the display.
6767

68-
## Rendering
69-
70-
Per the model card: 200 DPI, longest side 1540px, aspect preserved. Both are
71-
flags on the runner (`--dpi`, `--longest`) since posters are unusually large
72-
(gasimova is 3312x3312pt) and resolution is the obvious thing to sweep if
73-
recall disappoints.
68+
## Rendering and the 1540px ceiling
69+
70+
**Read this before trying to raise resolution.** `--longest` sets the render
71+
size AND the processor's `longest_edge` together, and 1540 is a hard ceiling:
72+
73+
- `PixtralImageProcessor` ships `size={"longest_edge": 1540}, do_resize=True`,
74+
so it silently rescales whatever you hand it back to 1540. Rendering bigger
75+
without changing the processor buys nothing and costs a second resampling
76+
pass — measurably (10890106 `w` 0.942 -> 0.723). The knob is the processor.
77+
- Raising the processor past 1540 asserts: `PixtralVisionConfig.image_size` is
78+
1540 with `patch_size` 14, so the vision RoPE table holds 110 patch positions
79+
per axis and a larger image indexes off the end
80+
(`modeling_pixtral.py:126 freqs = self.inv_freq[position_ids]`). The CUDA
81+
assert is **async**, so the first oversized page can look like it worked while
82+
computing on out-of-bounds indices. Do not trust it.
83+
84+
1540px is ~132 DPI over A4 — self-consistent with the model card — but a
85+
conference poster is 3-4 feet, so it lands at ~33 DPI (17 of our 20 posters get
86+
under 80; see `eff_dpi.py`). `--tiles N` is the only lever: it OCRs an NxN grid
87+
at 1540 each, multiplying effective DPI by N. It recovers recall on the largest
88+
posters but destroys reading order; see FINDINGS.md.
7489

7590
`--max-new-tokens` defaults to 6144. A generation that stops exactly at the cap
7691
was truncated and has silently lost recall; the runner flags those in `run.json`
@@ -79,8 +94,13 @@ and in its output rather than letting them be scored as if complete.
7994
## What to watch for
8095

8196
- **Hallucination.** A VLM can produce fluent text that is not on the poster.
82-
ROUGE against `_raw.md` rewards recall and will not punish invention hard
83-
enough on its own. Read some outputs before believing a headline number.
97+
ROUGE rewards recall and will not punish invention. Read some outputs before
98+
believing a headline number. But measure it against what the pipeline
99+
CONSUMES: poster2json looks ORCIDs up from name + affiliation and never reads
100+
them off the poster, so hallucinated ORCIDs are moot and the lookup keys are
101+
what matter (`keys_check.py`). `fidelity_check.py` still reports invented
102+
exact strings, since that is worth knowing, but it does not gate this
103+
pipeline. FINDINGS.md has the retraction.
84104
- **LaTeX.** LightOnOCR emits `$^{1,2}$` for superscript markers. Harmless for
85105
raw-text scoring (`_alpha()` strips it to `12`, and the reference's `¹˒²`
86106
NFKD-normalizes to the same), but it would need handling before the

calibration/vlm/compare_vlm.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,19 +72,28 @@ def main():
7272
ap = argparse.ArgumentParser()
7373
ap.add_argument("--save", default=None)
7474
ap.add_argument("--details", action="store_true")
75+
ap.add_argument("--vlm-dir", default=None,
76+
help="dir of VLM .md files under calibration/vlm "
77+
"(default: out); use to compare a resolution sweep")
78+
ap.add_argument("--only-vlm", action="store_true",
79+
help="skip the control (faster when sweeping the VLM)")
7580
args = ap.parse_args()
7681

82+
vlm_dir = (os.path.join(os.path.dirname(os.path.abspath(__file__)), args.vlm_dir)
83+
if args.vlm_dir else OUT)
7784
rows = []
7885
for pid, pdf, rawp in items():
7986
with open(rawp, encoding="utf-8") as fh:
8087
ref = fh.read()
8188
row = {"id": pid}
82-
vlm_path = os.path.join(OUT, f"{pid}.md")
89+
vlm_path = os.path.join(vlm_dir, f"{pid}.md")
8390
if os.path.exists(vlm_path):
8491
with open(vlm_path, encoding="utf-8") as fh:
8592
row["vlm"] = score(fh.read(), ref)
86-
if pdf:
93+
if pdf and not args.only_vlm:
8794
row["ctl"] = score(E.extract_text_with_pdfplumber(pdf) or "", ref)
95+
if args.vlm_dir and "vlm" not in row:
96+
continue
8897
rows.append(row)
8998

9099
print(f" {'poster':40s} {'--- pdfplumber + xy_cut ---':>28} "

calibration/vlm/dpi_corr.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
#!/usr/bin/env python3
2+
"""Does the VLM's recall gap track effective DPI?
3+
4+
Hypothesis: LightOnOCR-2 is not worse at reading than pdfplumber; it is
5+
resolution-starved on posters. Its vision tower is fixed at 1540px longest edge
6+
(image_size=1540 -- raising it indexes off the Pixtral RoPE table and asserts),
7+
which is ~132 DPI over A4 but ~33 DPI over a four-foot poster. If that is the
8+
cause, then (vlm_w - ctl_w) should rise with a poster's effective DPI, and the
9+
few page-sized posters should show no gap at all.
10+
"""
11+
import glob
12+
import json
13+
import os
14+
import sys
15+
16+
import pypdfium2 as pdfium
17+
18+
CMP = "/tmp/vlm_cmp.json"
19+
CORPUS = ("/home/joneill/Nextcloud/vaults/jmind/calmi2/poster_science/"
20+
"json_schema/manual_poster_annotation")
21+
EXTRA = {"gasimova(oos)": "/storage/poster-work/gasimova.pdf"}
22+
LONGEST = 1540
23+
24+
paths = {}
25+
for d in sorted(glob.glob(os.path.join(CORPUS, "*"))):
26+
pdf = glob.glob(os.path.join(d, "*.pdf"))
27+
if os.path.isdir(d) and pdf:
28+
paths[os.path.basename(d)] = pdf[0]
29+
paths.update(EXTRA)
30+
31+
rows = []
32+
for r in json.load(open(CMP, encoding="utf-8")):
33+
pid = r["id"]
34+
if pid not in paths or not (r.get("ctl") and r.get("vlm")):
35+
continue
36+
page = pdfium.PdfDocument(paths[pid])[0]
37+
dpi = LONGEST / (max(page.get_width(), page.get_height()) / 72.0)
38+
rows.append((dpi, pid, r["ctl"]["w"], r["vlm"]["w"],
39+
r["vlm"]["w"] - r["ctl"]["w"],
40+
r["vlm"]["r_field"] - r["ctl"]["r_field"]))
41+
42+
rows.sort()
43+
print(f"{'poster':40s} {'DPI':>5} {'ctl w':>6} {'vlm w':>6} {'dW':>7} {'dField':>7}")
44+
for dpi, pid, cw, vw, dw, df in rows:
45+
print(f"{pid:40s} {dpi:5.0f} {cw:6.3f} {vw:6.3f} {dw:+7.3f} {df:+7.3f}")
46+
47+
48+
def pearson(xs, ys):
49+
n = len(xs)
50+
mx, my = sum(xs) / n, sum(ys) / n
51+
num = sum((x - mx) * (y - my) for x, y in zip(xs, ys))
52+
dx = sum((x - mx) ** 2 for x in xs) ** 0.5
53+
dy = sum((y - my) ** 2 for y in ys) ** 0.5
54+
return num / (dx * dy) if dx and dy else float("nan")
55+
56+
57+
dpis = [r[0] for r in rows]
58+
dws = [r[4] for r in rows]
59+
print(f"\npearson r(effective DPI, vlm_w - ctl_w) = {pearson(dpis, dws):+.3f} "
60+
f"(n={len(rows)})")
61+
62+
hi = [r for r in rows if r[0] >= 100]
63+
lo = [r for r in rows if r[0] < 60]
64+
for label, grp in (("page-sized (>=100 DPI)", hi), ("poster-sized (<60 DPI)", lo)):
65+
if grp:
66+
print(f" {label:24s} n={len(grp)} "
67+
f"mean dW={sum(r[4] for r in grp) / len(grp):+.3f} "
68+
f"mean dField={sum(r[5] for r in grp) / len(grp):+.3f}")

0 commit comments

Comments
 (0)