Automatic page splitting
This commit is contained in:
1 parent
d4404e4c27
commit
78f3e4b2f0
13 files changed
+1959
-40
No files matched your search
+192
-5
@@ -45,12 +45,15 @@ def _paper_blur(gray: np.ndarray, sigma: float) -> np.ndarray:
|
||||
return blurred.astype(np.uint8)
|
||||
|
||||
|
||||
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float, *,
|
||||
content_only: bool = False):
|
||||
"""Refine confirmed ruling, only accepting substantial extra blank margins.
|
||||
|
||||
Short directional openings tolerate locally bent/broken paper lines. A
|
||||
physical component-size threshold rejects their remaining tiny fragments.
|
||||
This is deliberately limited to already-confirmed ruled paper.
|
||||
Crop refinement is limited to already-confirmed ruled paper. Content-only
|
||||
analysis also uses this stroke filter on seedless plain pages and can return
|
||||
an empty mask; the normal crop path deliberately preserves its input then.
|
||||
"""
|
||||
px = dpi / 25.4
|
||||
height, width = gray.shape
|
||||
@@ -120,6 +123,14 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
overlap = np.bincount(lab[strong_ruling > 0], minlength=count)
|
||||
bars &= overlap < st[:, 4]*.5
|
||||
refined |= bars[lab]
|
||||
if content_only:
|
||||
# A crop must preserve its input when there is no reliable content.
|
||||
# A blank-page classifier needs that empty result instead. Recover the
|
||||
# same nearby original strokes without restoring remote paper artifacts.
|
||||
radius = max(1, round(2*px))
|
||||
nearby = cv2.dilate(refined.astype(np.uint8),
|
||||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(2*radius+1,2*radius+1))) > 0
|
||||
return refined | ((clean > 0) & nearby), len(holes), True
|
||||
ys = np.flatnonzero(np.any(refined, axis=1))
|
||||
original = np.flatnonzero(np.any(clean, axis=1))
|
||||
if not len(ys) or not len(original):
|
||||
@@ -138,8 +149,171 @@ def _large_blank_ink(gray: np.ndarray, clean: np.ndarray, dpi: float):
|
||||
return result, len(holes), changed
|
||||
|
||||
|
||||
def _blank_content_support(content: np.ndarray, dpi: float) -> tuple[np.ndarray, dict]:
|
||||
"""Require a glyph, neighbouring strokes, or a substantial diagram.
|
||||
|
||||
A few remote dots and a short loose curve do not establish a written page.
|
||||
Grouping protects fragmented pencil letters without a page-wide ink quota.
|
||||
This evidence filter is for blank classification, never margin cropping.
|
||||
"""
|
||||
px = dpi/25.4
|
||||
n,labels,stats,_ = cv2.connectedComponentsWithStats(content.astype(np.uint8),8)
|
||||
if n == 1:
|
||||
return content,dict(content_supported_groups=0,content_unsupported_pixels=0)
|
||||
radius = max(1,round(1.5*px))
|
||||
joined = cv2.dilate(content.astype(np.uint8),
|
||||
cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(2*radius+1,2*radius+1)))
|
||||
_,groups = cv2.connectedComponents(joined,8)
|
||||
ys,xs = np.nonzero(content)
|
||||
component_groups = np.zeros(n,np.int32)
|
||||
component_groups[labels[ys,xs]] = groups[ys,xs]
|
||||
area = stats[:,4]
|
||||
bw,bh = stats[:,2],stats[:,3]
|
||||
density = area/np.maximum(1,bw*bh)
|
||||
letter = ((area >= .8*px*px) & (np.minimum(bw,bh) >= .6*px)
|
||||
& (density >= .18))
|
||||
# A dense sub-2 mm ink spot is not a glyph on its own. Keep it eligible
|
||||
# for a supported group (decimal points, dots over letters, punctuation).
|
||||
compact_spot = np.zeros(n,bool)
|
||||
spot_candidates = np.flatnonzero((density > .55) & (np.maximum(bw,bh) < 2*px))
|
||||
for i in spot_candidates:
|
||||
if i == 0:
|
||||
continue
|
||||
x,y,ww,hh,_ = stats[i]
|
||||
yy,xx = np.nonzero(labels[y:y+hh,x:x+ww] == i)
|
||||
hull_area = cv2.contourArea(cv2.convexHull(np.c_[xx,yy].astype(np.int32)))
|
||||
# Small x's have empty wedges inside their convex hull; solid spots
|
||||
# fill it. Pixel-centre hull area can be smaller than the pixel count.
|
||||
compact_spot[i] = area[i] >= .9*max(1.,hull_area)
|
||||
letter &= ~compact_spot
|
||||
substantial = (area >= 4*px*px) & (np.maximum(bw,bh) >= 5*px)
|
||||
# A lone short smear at the physical top/bottom edge can pass both area
|
||||
# tests. Require its pixels to be almost collinear before discounting it;
|
||||
# normal digits, letters and closed diagrams have broader stroke geometry.
|
||||
# Neighbouring meaningful strokes still support it through the group rule.
|
||||
near_edge = (stats[:,1] < 15*px) | (stats[:,1]+bh > content.shape[0]-15*px)
|
||||
edge_smear = np.zeros(n,bool)
|
||||
candidates = np.flatnonzero(near_edge & (area < 6*px*px)
|
||||
& (np.maximum(bw,bh) < 8*px)
|
||||
& (np.minimum(bw,bh) > 2*px) & (density < .35))
|
||||
for i in candidates:
|
||||
if i == 0:
|
||||
continue
|
||||
x,y,ww,hh,_ = stats[i]
|
||||
yy,xx = np.nonzero(labels[y:y+hh,x:x+ww] == i)
|
||||
values = np.linalg.eigvalsh(np.cov(np.c_[xx,yy].T))
|
||||
edge_smear[i] = values[0] < .05*values[1]
|
||||
letter &= ~edge_smear
|
||||
substantial &= ~edge_smear
|
||||
credible = area >= .2*px*px
|
||||
credible[0] = False
|
||||
counts = np.bincount(component_groups[credible],minlength=int(groups.max())+1)
|
||||
totals = np.bincount(component_groups,weights=area,minlength=len(counts))
|
||||
supported = (counts >= 2) & (totals >= 1.2*px*px)
|
||||
supported[component_groups[letter | substantial]] = True
|
||||
supported[0] = False
|
||||
retained = supported[groups] & content
|
||||
return retained,dict(content_supported_groups=int(supported.sum()),
|
||||
content_unsupported_pixels=int(content.sum()-retained.sum()),
|
||||
content_compact_spots=int(compact_spot[1:].sum()),
|
||||
content_edge_smears=int(edge_smear.sum()))
|
||||
|
||||
|
||||
def detect_page_content(rgb: np.ndarray, dpi: float = 200) -> tuple[np.ndarray, dict]:
|
||||
"""Expose rognage's content evidence on a single logical page.
|
||||
|
||||
Normal crop behaviour stays unchanged. On confirmed ruled paper, use its
|
||||
large-blank refinement even when that refinement finds no content, instead of
|
||||
the crop-specific fallback that keeps the original page. No separate weak-mark
|
||||
detector is used. The returned mask is in the input image coordinates.
|
||||
"""
|
||||
content_masks = {}
|
||||
paper_geometry = {}
|
||||
result = detect_bounds(rgb,dpi,content_masks=content_masks,_paper_geometry=paper_geometry)
|
||||
content = content_masks['foreground']
|
||||
gray = cv2.cvtColor(rgb,cv2.COLOR_RGB2GRAY)
|
||||
# Use the same deskew and paper-family confirmation as the crop pipeline.
|
||||
h,w = gray.shape
|
||||
if paper_geometry:
|
||||
matrix,size,deskewed = (paper_geometry[key] for key in ('matrix','size','deskewed'))
|
||||
else:
|
||||
angle = _skew(gray,angle_step=.5)
|
||||
matrix = cv2.getRotationMatrix2D((w/2,h/2),angle,1)
|
||||
corners = cv2.transform(np.array([[[0,0],[w,0],[0,h],[w,h]]],float),matrix)[0]
|
||||
origin = np.floor(corners.min(axis=0))
|
||||
size = np.ceil(corners.max(axis=0)-origin).astype(int)
|
||||
matrix[:,2] -= origin
|
||||
deskewed = cv2.warpAffine(gray,matrix,tuple(size),borderValue=255)
|
||||
binary = cv2.adaptiveThreshold(deskewed,255,cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
||||
cv2.THRESH_BINARY_INV,max(15,int(dpi/5)|1),9)
|
||||
horizontal,nh = _ruling(binary,True)
|
||||
vertical,nv = _ruling(binary,False)
|
||||
confirmed = ((nh and _edge_connected_family(binary,horizontal,True)) or
|
||||
(nv and _edge_connected_family(binary,vertical,False)))
|
||||
if not confirmed:
|
||||
# Slight local warp breaks one-pixel pale grid lines. Confirm the same
|
||||
# periodic, edge-reaching family with a small physical-width tolerance.
|
||||
tolerance = max(3,round(.3*dpi/25.4)|1)
|
||||
tolerant = cv2.dilate(binary,np.ones((tolerance,tolerance),np.uint8))
|
||||
th,tnh = _ruling(tolerant,True)
|
||||
tv,tnv = _ruling(tolerant,False)
|
||||
confirmed = ((tnh and _edge_connected_family(tolerant,th,True)) or
|
||||
(tnv and _edge_connected_family(tolerant,tv,False)))
|
||||
result['content_tolerant_ruling'] = bool(confirmed)
|
||||
# With no seeds, the same coherent-stroke filter can recover pencil-only
|
||||
# writing on plain paper. Arbitrary weak pixels alone are not a veto.
|
||||
refine = bool(confirmed) or not content.any()
|
||||
result['content_refinement'] = refine
|
||||
result['content_ruling_confirmed'] = bool(confirmed)
|
||||
if refine:
|
||||
clean = cv2.warpAffine(content.astype(np.uint8),matrix,tuple(size),
|
||||
flags=cv2.INTER_NEAREST,borderValue=0)
|
||||
refined,holes,_ = _large_blank_ink(deskewed,clean,dpi,content_only=True)
|
||||
content = cv2.warpAffine(refined.astype(np.uint8),cv2.invertAffineTransform(matrix),
|
||||
(w,h),flags=cv2.INTER_NEAREST,borderValue=0)>0
|
||||
result['content_holes'] = holes
|
||||
result['content_pixels'] = int(content.sum())
|
||||
if not confirmed and not content.any():
|
||||
# The directional refinement can erase very thin pencil letters on
|
||||
# plain paper. Reuse the crop pipeline's adaptive foreground instead,
|
||||
# requiring coherent letter-sized components rather than arbitrary
|
||||
# weak pixels. This fallback is confined to seedless/plain pages.
|
||||
weak = _foreground(gray,dpi,9,np.zeros_like(gray))
|
||||
weak,_ = _content_mask(weak,dpi)
|
||||
count,labels,stats,_ = cv2.connectedComponentsWithStats(weak,8)
|
||||
keep = np.zeros(count,bool)
|
||||
px = dpi/25.4
|
||||
keep[1:] = ((stats[1:,4] >= .5*px*px)
|
||||
& (stats[1:,2] >= px) & (stats[1:,3] >= px))
|
||||
# Faint scanner borders can be one huge connected component. They are
|
||||
# not coherent writing merely because their bounding box is large.
|
||||
x,y,bw,bh,area = stats[1:].T
|
||||
sparse = area / np.maximum(1,bw*bh) < .1
|
||||
edge_frame = (((bw > .9*w) & ((y < px) | (y+bh > h-px)))
|
||||
| ((bh > .9*h) & ((x < px) | (x+bw > w-px))))
|
||||
edge_frame |= ((bw > .8*w) & ((x < 2*px) | (x+bw > w-2*px)))
|
||||
keep[1:] &= ~(sparse & edge_frame)
|
||||
# A faint narrow scanner-edge band is not a pencil letter. Require
|
||||
# both edge placement and pale neutral appearance, not position alone.
|
||||
for i in np.flatnonzero(keep[1:])+1:
|
||||
xx,yy,ww,hh,_ = stats[i]
|
||||
if ww < 3*px and (xx < 2*px or xx+ww > w-2*px) and hh > ww:
|
||||
values = rgb[yy:yy+hh,xx:xx+ww][labels[yy:yy+hh,xx:xx+ww]==i]
|
||||
if np.median(values.min(axis=1)) > 205 and np.median(np.ptp(values.astype(np.int16),axis=1)) < 30:
|
||||
keep[i] = False
|
||||
content = keep[labels]
|
||||
result['content_pixels'] = int(content.sum())
|
||||
result['content_pencil_recovery'] = bool(content.any())
|
||||
content,support = _blank_content_support(content,dpi)
|
||||
result.update(support)
|
||||
result['content_pixels'] = int(content.sum())
|
||||
result['content_state'] = 'content' if content.any() else 'blank'
|
||||
return content,result
|
||||
|
||||
|
||||
def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
|
||||
*, diagnostics: dict | None = None):
|
||||
*, diagnostics: dict | None = None,
|
||||
geometry: dict | None = None):
|
||||
"""Clean confirmed dark ruling before it can seed whole pages.
|
||||
|
||||
Returns None for ordinary ink components or unconfirmed paper geometry.
|
||||
@@ -163,6 +337,8 @@ def _neutral_paper_foreground(gray: np.ndarray, chroma: np.ndarray, dpi: float,
|
||||
size = np.ceil(corners.max(axis=0)-origin).astype(int)
|
||||
matrix[:, 2] -= origin
|
||||
deskewed = cv2.warpAffine(gray, matrix, tuple(size), borderValue=255)
|
||||
if geometry is not None:
|
||||
geometry.update(matrix=matrix,size=size,deskewed=deskewed)
|
||||
background = _paper_blur(deskewed, dpi/8)
|
||||
normalized = cv2.divide(deskewed, np.maximum(background,1), scale=255)
|
||||
block = max(15, int(dpi/5) | 1)
|
||||
@@ -220,7 +396,9 @@ def _seed_mask(mask: np.ndarray, px: float, *, paper_nearby=None,
|
||||
|
||||
def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
min_crop_mm: float = 5, *, diagnostics: dict | None = None,
|
||||
colour_cleanup: bool = False, edge_mark_cleanup: bool = True) -> dict:
|
||||
colour_cleanup: bool = False, edge_mark_cleanup: bool = True,
|
||||
content_masks: dict | None = None,
|
||||
_paper_geometry: dict | None = None) -> dict:
|
||||
"""Locate strong ink, recover adjacent faint strokes, and retain padding.
|
||||
|
||||
Neutral punched-hole shadows usually have neither sufficient chroma nor
|
||||
@@ -237,6 +415,9 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
Optional diagnostics are written to a separate caller-owned dictionary.
|
||||
Its masks use the original image coordinates and never influence decisions
|
||||
or enter the JSON-serializable return value.
|
||||
|
||||
content_masks requests only foreground and weak-candidate masks, avoiding
|
||||
diagnostic component explanations and intermediate paper-mask exports.
|
||||
"""
|
||||
px = dpi/25.4
|
||||
h, w = rgb.shape[:2]
|
||||
@@ -259,7 +440,8 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
diagnostics['masks'] = {}
|
||||
diagnostics['thresholds'] = []
|
||||
masks = diagnostics['masks'] if diagnostics is not None else None
|
||||
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi, diagnostics=masks)
|
||||
cleaned, paper_info = _neutral_paper_foreground(gray, chroma, dpi, diagnostics=masks,
|
||||
geometry=_paper_geometry)
|
||||
if cleaned is not None:
|
||||
# Adaptive neutral cleanup includes faint paper fragments. Such pixels
|
||||
# need local contrast or substantial darkness before they seed a crop.
|
||||
@@ -279,6 +461,9 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
masks['isolated_edge_marks'] = np.zeros((h,w), bool)
|
||||
weak = ((chroma > 60) | ((gray < 175) & (residual > 25))).astype(np.uint8)
|
||||
weak[suppressed] = 0
|
||||
if content_masks is not None:
|
||||
content_masks.clear()
|
||||
content_masks.update(foreground=np.zeros((h,w),bool),weak_candidates=weak>0)
|
||||
if masks is not None:
|
||||
masks['strong_seeds'] = np.zeros((h,w), bool)
|
||||
masks['foreground'] = np.zeros((h,w), bool)
|
||||
@@ -329,6 +514,8 @@ def detect_bounds(rgb: np.ndarray, dpi: float = 200, padding_mm: float = 6,
|
||||
masks['isolated_edge_marks'] |= discarded[labels]
|
||||
seeded_labels = remaining
|
||||
retained = seeded_labels[labels]
|
||||
if content_masks is not None:
|
||||
content_masks['foreground'] |= retained
|
||||
if masks is not None:
|
||||
masks['strong_seeds'] |= seeds & retained
|
||||
ys = np.flatnonzero(np.any(retained, axis=1))
|
||||
|
||||
Reference in new issue
Block a user