Initial commit: Ross 1988 re-typeset edition
A searchable, re-typeset edition of Fiona Ross, "The Evolution of the Printed Bengali Character from 1778 to 1978" (Ph.D., SOAS, 1988), transcribed from the 431-leaf ProQuest scan. All 431 pages done; 178 plates and 410 inline type specimens cut from the scan; 51 errata. Tracked: the transcription (src/pages), the preamble and its typographic decisions, the cut images (plates/ — not reliably regenerable, the crop specs for the inline cuts were never scripted), tools, and the four working documents. Not tracked: the built PDF, which `make` remakes from src/ and plates/; the ProQuest scan under source/, which is third-party and needed only by `make prep` and `make plate`; scans/ and work/, both regenerable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Executable
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract a plate image from the original scan, dropping the printed page
|
||||
number (top) and the typeset caption (bottom), which are re-set in LaTeX.
|
||||
|
||||
usage: crop_plate.py PDF_PAGE [--top FRAC --bottom FRAC] [--keep-caption]
|
||||
Writes plates/pNNNN.png (1-bit PNG when the source is bitonal).
|
||||
Heuristic defaults: ignore the outer 4% (scanner edge), the top 7.5%
|
||||
(page number) and the bottom 9% (caption). Override per page via manifest.
|
||||
"""
|
||||
import sys, argparse, subprocess, io, math
|
||||
from PIL import Image, ImageOps
|
||||
import numpy as np
|
||||
|
||||
import os
|
||||
SRC = os.environ.get("ROSS_SRC", "source/10731406.pdf")
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("page", type=int)
|
||||
ap.add_argument("--top", type=float, default=0.075)
|
||||
ap.add_argument("--bottom", type=float, default=0.09)
|
||||
ap.add_argument("--edge", type=float, default=0.04)
|
||||
ap.add_argument("--dpi", type=int, default=300)
|
||||
ap.add_argument("--box", default=None,
|
||||
help="explicit crop as LEFT,TOP,RIGHT,BOTTOM fractions of the raw page; skips the scanner-bar and page-number heuristics. Use for dark plates (a photographed manuscript), where \"dark = scanner bar\" does not hold")
|
||||
ap.add_argument("--trim", default=None,
|
||||
help="post-rotation trim as TOP,RIGHT,BOTTOM,LEFT fractions, e.g. 0,0,0.06,0")
|
||||
ap.add_argument("--rotate", type=int, default=0, choices=[0,90,180,270],
|
||||
help="rotate the crop counter-clockwise (lossless); plates the original prints sideways")
|
||||
ap.add_argument("--raw", action="store_true",
|
||||
help="with --box: take the box exactly — no shadow sweep, deskew, bar sweep or autocrop. Use for a framed plate, whose own border rules the edge heuristics mistake for scanner bars")
|
||||
ap.add_argument("--rule", type=float, default=0.012,
|
||||
help="dark runs thinner than this fraction are plate rules, not scanner bars")
|
||||
a = ap.parse_args()
|
||||
|
||||
png = subprocess.run(["pdftoppm", "-png", "-r", str(a.dpi), "-f", str(a.page),
|
||||
"-l", str(a.page), SRC], capture_output=True).stdout
|
||||
im = Image.open(io.BytesIO(png)).convert("L")
|
||||
W, H = im.size
|
||||
# strip scanner black bars: any column/row that is >40% dark is an edge;
|
||||
# keep only the widest run of "paper" columns/rows.
|
||||
dark = np.array(im) < 100
|
||||
def paper_run(profile, rule=None):
|
||||
ok = profile < 0.40
|
||||
# A thin dark run is a ruled line belonging to the plate (engraved column
|
||||
# rules, table borders), not a scanner bar: close it so it cannot split the
|
||||
# paper run. Scanner bars are an order of magnitude thicker.
|
||||
rule = int((rule if rule is not None else a.rule) * len(ok))
|
||||
i = 0
|
||||
while i < len(ok):
|
||||
if not ok[i]:
|
||||
j = i
|
||||
while j < len(ok) and not ok[j]: j += 1
|
||||
interior = i > 0.05*len(ok) and j < 0.95*len(ok)
|
||||
if j - i <= rule and interior: ok[i:j] = True
|
||||
i = j
|
||||
else: i += 1
|
||||
best=(0,0); i=0
|
||||
while i < len(ok):
|
||||
if ok[i]:
|
||||
j=i
|
||||
while j < len(ok) and ok[j]: j+=1
|
||||
if j-i > best[1]-best[0]: best=(i,j)
|
||||
i=j
|
||||
else: i+=1
|
||||
return best
|
||||
if a.box:
|
||||
l, t, r, b = (float(x) for x in a.box.split(","))
|
||||
im = im.crop((int(W*l), int(H*t), int(W*r), int(H*b)))
|
||||
else:
|
||||
c0,c1 = paper_run(dark.mean(0)); r0,r1 = paper_run(dark.mean(1))
|
||||
im = im.crop((c0,r0,c1,r1)); W,H = im.size
|
||||
box = (int(W*a.edge), int(H*a.top), int(W*(1-a.edge)), int(H*(1-a.bottom)))
|
||||
im = im.crop(box)
|
||||
# A thin, very dark run just inside an edge is a scanner bar the paper-run
|
||||
# heuristic kept (it is thinner than --rule, so it was read as a plate rule).
|
||||
# Sweep it off before autocropping.
|
||||
def strip_edge_bars(im, frac=0.06, maxthick=30):
|
||||
for _ in range(2):
|
||||
a = np.array(im) < 100
|
||||
W, H = im.size
|
||||
l, r, t, b = 0, W, 0, H
|
||||
cols = a.mean(0); rows = a.mean(1)
|
||||
for i in range(int(W*frac)):
|
||||
if cols[i] > 0.5: l = i + 1
|
||||
for i in range(W - 1, W - int(W*frac) - 1, -1):
|
||||
if cols[i] > 0.5: r = i
|
||||
for i in range(int(H*frac)):
|
||||
if rows[i] > 0.5: t = i + 1
|
||||
for i in range(H - 1, H - int(H*frac) - 1, -1):
|
||||
if rows[i] > 0.5: b = i
|
||||
if (l, r, t, b) == (0, W, 0, H): break
|
||||
if l > maxthick + int(W*frac) or W - r > maxthick + int(W*frac): break
|
||||
im = im.crop((l, t, r, b))
|
||||
return im
|
||||
# ---- deskew -----------------------------------------------------------
|
||||
# Scans are a degree or two out of square: the page edge and any ruled lines
|
||||
# lean. Estimate the angle by rotating a downsampled copy through a small range
|
||||
# and taking the angle whose row/column ink profiles are sharpest (a straight
|
||||
# page concentrates ink into rows and columns, maximising their variance).
|
||||
# NB rotation resamples — the only step in this tool that does. It is applied
|
||||
# only when the page is measurably out of square.
|
||||
def skew_angle(im, limit=2.0, step=0.05):
|
||||
# measure on the interior: edge bands and the page frame would otherwise
|
||||
# dominate the profile variance and pin the estimate at zero
|
||||
W, H = im.size
|
||||
g = im.crop((int(W*0.08), int(H*0.06), int(W*0.92), int(H*0.94)))
|
||||
g = g.resize((max(g.width//4, 1), max(g.height//4, 1)), Image.BILINEAR)
|
||||
best, best_score = 0.0, -1.0
|
||||
n = int(limit/step)
|
||||
for i in range(-n, n+1):
|
||||
deg = round(i*step, 2)
|
||||
a = np.array(g.rotate(deg, resample=Image.BILINEAR, fillcolor=255)) < 128
|
||||
score = float(a.mean(1).var() + a.mean(0).var())
|
||||
if score > best_score: best, best_score = deg, score
|
||||
return best
|
||||
|
||||
def rule_angle(im):
|
||||
"""Angle from long near-vertical or near-horizontal rules, when the plate
|
||||
has any: compare where a rule sits near one end against the other. More
|
||||
sensitive than the profile method for engravings, which have few text
|
||||
lines but strong ruled columns."""
|
||||
a = np.array(im) < 128
|
||||
H, W = a.shape
|
||||
def drift(arr, long_axis):
|
||||
n = arr.shape[long_axis]
|
||||
lo = arr.take(range(int(n*0.10), int(n*0.35)), axis=long_axis)
|
||||
hi = arr.take(range(int(n*0.65), int(n*0.90)), axis=long_axis)
|
||||
pl, ph = lo.mean(long_axis), hi.mean(long_axis)
|
||||
# A rule is several pixels wide, so group contiguous strong lines and
|
||||
# compare their CENTRES — matching column to column would pair edge with
|
||||
# edge and under-measure the drift.
|
||||
def centres(prof):
|
||||
idx = [i for i in range(len(prof)) if prof[i] > 0.55]
|
||||
if not idx: return []
|
||||
out, cur = [], [idx[0]]
|
||||
for i in idx[1:]:
|
||||
if i - cur[-1] <= 3: cur.append(i)
|
||||
else: out.append(sum(cur)/len(cur)); cur = [i]
|
||||
out.append(sum(cur)/len(cur))
|
||||
return out
|
||||
cl, ch = centres(pl), centres(ph)
|
||||
if not cl or not ch: return None
|
||||
ds = []
|
||||
for i in cl:
|
||||
j = min(ch, key=lambda k: abs(k-i))
|
||||
if abs(j-i) <= 20: ds.append(j-i)
|
||||
if not ds: return None
|
||||
span = n*0.55
|
||||
return math.degrees(math.atan(float(np.median(ds))/span))
|
||||
v = drift(a, 0) # vertical rules: drift measured down the page
|
||||
h = drift(a.T, 0) # horizontal rules
|
||||
cands = [x for x in (v, h) if x is not None]
|
||||
if not cands: return None
|
||||
return -cands[0] if abs(cands[0]) >= 0.05 else 0.0
|
||||
|
||||
def deskew(im):
|
||||
ang = rule_angle(im)
|
||||
if ang is None: ang = skew_angle(im)
|
||||
if abs(ang) < 0.05: return im, 0.0
|
||||
return im.rotate(ang, resample=Image.BICUBIC, expand=True, fillcolor=255), ang
|
||||
|
||||
# ---- scanner shadow ---------------------------------------------------------
|
||||
# A soft grey band down an edge (the gutter shadow) is not dark enough for the
|
||||
# bar sweep but still prints. Trim edge rows/columns that are mostly dark.
|
||||
def strip_edge_shadow(im, frac=0.05, dark=0.30):
|
||||
# A gutter shadow is often a wedge — dark over part of the edge only — so a
|
||||
# whole-column mean misses it. Score each edge column (row) by the darkest
|
||||
# tenth of its length instead.
|
||||
a = np.array(im) < 150
|
||||
W, H = im.size
|
||||
l, r, t, b = 0, W, 0, H
|
||||
def worst(v, n): # darkest window of length n along v
|
||||
if len(v) < n: return v.mean() if len(v) else 0.0
|
||||
c = np.cumsum(np.insert(v, 0, 0.0))
|
||||
return float(((c[n:] - c[:-n])/n).max())
|
||||
hw, vw = max(H//10, 1), max(W//10, 1)
|
||||
# stop at the first light line: only a band touching the edge is shadow,
|
||||
# anything past it is the plate's own content
|
||||
for i in range(int(W*frac)):
|
||||
if worst(a[:, i], hw) > dark: l = i + 1
|
||||
else: break
|
||||
for i in range(W-1, W-int(W*frac)-1, -1):
|
||||
if worst(a[:, i], hw) > dark: r = i
|
||||
else: break
|
||||
for i in range(int(H*frac)):
|
||||
if worst(a[i, :], vw) > dark: t = i + 1
|
||||
else: break
|
||||
for i in range(H-1, H-int(H*frac)-1, -1):
|
||||
if worst(a[i, :], vw) > dark: b = i
|
||||
else: break
|
||||
return im.crop((l, t, r, b)) if (l, r, t, b) != (0, W, 0, H) else im
|
||||
|
||||
if a.raw:
|
||||
_skew = 0.0
|
||||
else:
|
||||
im = strip_edge_shadow(im) # drop the gutter shadow first…
|
||||
im, _skew = deskew(im) # …so it cannot bias the angle estimate
|
||||
im = strip_edge_shadow(im) # …then clear what the rotation brought in
|
||||
|
||||
# autocrop to dark content with a small margin
|
||||
def autocrop(im):
|
||||
arr = np.array(im) < 128
|
||||
rows = np.where(arr.mean(1) > 0.002)[0]; cols = np.where(arr.mean(0) > 0.002)[0]
|
||||
if not (len(rows) and len(cols)): return im
|
||||
m = int(0.01*im.width)
|
||||
return im.crop((max(cols[0]-m,0), max(rows[0]-m,0),
|
||||
min(cols[-1]+m, im.width), min(rows[-1]+m, im.height)))
|
||||
if not a.raw:
|
||||
im = autocrop(im) # bring the edges to the ink…
|
||||
im = autocrop(strip_edge_bars(im)) # …then sweep off any scanner bar now at an edge
|
||||
im = autocrop(strip_edge_shadow(im)) # …and the gutter shadow the autocrop just exposed
|
||||
if a.rotate:
|
||||
im = im.transpose({90: Image.ROTATE_90, 180: Image.ROTATE_180,
|
||||
270: Image.ROTATE_270}[a.rotate])
|
||||
# --trim T,R,B,L (fractions, after rotation): for skewed scanner edges that are
|
||||
# too thin for the bar heuristic and too dark for the autocrop to ignore.
|
||||
if a.trim:
|
||||
t, r, b, l = (float(x) for x in a.trim.split(","))
|
||||
W, H = im.size
|
||||
im = autocrop(im.crop((int(W*l), int(H*t), int(W*(1-r)), int(H*(1-b)))))
|
||||
uniq = np.unique(np.array(im))
|
||||
out = f"plates/p{a.page:04d}.png"
|
||||
if len(uniq) <= 2: # bitonal source: keep it crisp and small
|
||||
im = im.point(lambda v: 255 if v > 128 else 0).convert("1")
|
||||
im.save(out, optimize=True)
|
||||
print(out, im.size, im.mode, f"deskew {_skew:+.1f}deg" if _skew else "")
|
||||
Reference in New Issue
Block a user