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>
226 lines
10 KiB
Python
Executable File
226 lines
10 KiB
Python
Executable File
#!/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 "")
|