Stop an edge artefact and a speck filter from destroying a song
Olukainen juomukainen came out unusable, from two separate faults. The scanner left a dark line down the sheet edge, running the full height of every page but the first. Being taller than any bracket it won every overlap in anchor selection and swallowed the page into one system, so five pages of six proposed no cuts at all. A page's brackets and barlines are all about one system tall, so a stroke far taller than the typical one is not notation — relative to the page's own strokes, since a page holding one big system is legitimate. The trim then removed each system's bottom line of lyrics. It judged ink blobs by area, and a letter is nowhere near the threshold; a whole line of them is dozens of blobs, none of which qualifies. Measure ink per row and per column instead — a line of text carries plenty in total, and a fleck's row carries almost none, which is the case the filter was for. Detection now finds three systems on every page of that score, and every slice keeps all four voices' words.
This commit is contained in:
@@ -26,6 +26,7 @@ _SKEW_WORK_SCALE = 0.25
|
|||||||
_INK = 128 # below this is ink, above is paper
|
_INK = 128 # below this is ink, above is paper
|
||||||
_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height
|
_ANCHOR_KERNEL = 0.03 # vertical open kernel, as a fraction of page height
|
||||||
_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
|
_ANCHOR_MIN = 0.04 # a bracket is at least this tall, as a fraction of page
|
||||||
|
_ANCHOR_MAX_RATIO = 2.0 # a stroke this much taller than the typical one is an artefact
|
||||||
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak
|
_PROFILE_FLOOR = 0.02 # ink-run threshold, as a fraction of the profile peak
|
||||||
_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
|
_EXPAND_REACH = 1.5 # how far past the bracket a system's ink reaches, in staff heights
|
||||||
_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
|
_STAFF_KERNEL = 0.05 # horizontal open kernel, as a fraction of page width
|
||||||
@@ -123,6 +124,17 @@ def system_anchors(gray: np.ndarray) -> list[Anchor]:
|
|||||||
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
|
if stats[i, cv2.CC_STAT_HEIGHT] > h * _ANCHOR_MIN
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# A scanner leaves a dark line down the sheet edge — the binder shadow, the
|
||||||
|
# glass, the page next to it — and it runs the whole height of the scan.
|
||||||
|
# Being the tallest stroke on the page it wins every overlap below and
|
||||||
|
# swallows every system into one. A page's brackets and barlines are all
|
||||||
|
# about one system tall, so anything wildly taller than the typical stroke
|
||||||
|
# is not notation. Relative, not an absolute fraction of the page: a page
|
||||||
|
# holding one big system is legitimate and must survive.
|
||||||
|
if len(tall) > 1:
|
||||||
|
limit = float(np.median([a.bottom - a.top for a in tall])) * _ANCHOR_MAX_RATIO
|
||||||
|
tall = [a for a in tall if a.bottom - a.top <= limit] or tall
|
||||||
|
|
||||||
# Tallest first, keeping only strokes that don't overlap one already kept:
|
# Tallest first, keeping only strokes that don't overlap one already kept:
|
||||||
# a system's barlines all overlap its bracket, so each system yields one.
|
# a system's barlines all overlap its bracket, so each system yields one.
|
||||||
# The kept stroke is the tallest, which is the bracket rather than a barline.
|
# The kept stroke is the tallest, which is the bracket rather than a barline.
|
||||||
|
|||||||
+19
-18
@@ -26,7 +26,10 @@ from .project import Cut, Project
|
|||||||
|
|
||||||
MAX_WIDTH = 1920
|
MAX_WIDTH = 1920
|
||||||
ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes
|
ALPHA_LEVELS = 16 # quantising alpha costs nothing visible and ~32% of the bytes
|
||||||
_SPECK_AREA = 300 # ink blobs smaller than this don't anchor a trim
|
# A row or column carrying less ink than this is a fleck, not content: at least
|
||||||
|
# this many pixels, and at least this share of the slice's own size.
|
||||||
|
_SPECK_INK = 8
|
||||||
|
_SPECK_SHARE = 0.005
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -105,26 +108,24 @@ def _ink_bbox(gray: np.ndarray) -> tuple[int, int, int, int] | None:
|
|||||||
|
|
||||||
One scan fleck at the far left would otherwise anchor the trim and shift
|
One scan fleck at the far left would otherwise anchor the trim and shift
|
||||||
that slice relative to every other one.
|
that slice relative to every other one.
|
||||||
|
|
||||||
|
Measured per row and per column rather than per blob. Judging each blob on
|
||||||
|
its own area throws away a whole line of lyrics — every letter is its own
|
||||||
|
small component, and no single one is big enough to keep — which is how a
|
||||||
|
slice loses its bottom voice's words. A row carrying a line of text carries
|
||||||
|
plenty of ink *in total*, and a fleck's row carries almost none.
|
||||||
"""
|
"""
|
||||||
ink = (gray < 200).astype(np.uint8)
|
ink = gray < 200
|
||||||
count, _, stats, _ = cv2.connectedComponentsWithStats(ink, 8)
|
rows, cols = ink.sum(axis=1), ink.sum(axis=0)
|
||||||
boxes = [
|
kept_rows = np.where(rows >= max(_SPECK_INK, ink.shape[1] * _SPECK_SHARE))[0]
|
||||||
(
|
kept_cols = np.where(cols >= max(_SPECK_INK, ink.shape[0] * _SPECK_SHARE))[0]
|
||||||
stats[i, cv2.CC_STAT_LEFT],
|
if not kept_rows.size or not kept_cols.size:
|
||||||
stats[i, cv2.CC_STAT_TOP],
|
|
||||||
stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH],
|
|
||||||
stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT],
|
|
||||||
)
|
|
||||||
for i in range(1, count)
|
|
||||||
if stats[i, cv2.CC_STAT_AREA] >= _SPECK_AREA
|
|
||||||
]
|
|
||||||
if not boxes:
|
|
||||||
return None
|
return None
|
||||||
return (
|
return (
|
||||||
min(b[0] for b in boxes),
|
int(kept_cols[0]),
|
||||||
min(b[1] for b in boxes),
|
int(kept_rows[0]),
|
||||||
max(b[2] for b in boxes),
|
int(kept_cols[-1]) + 1,
|
||||||
max(b[3] for b in boxes),
|
int(kept_rows[-1]) + 1,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,14 @@ def main() -> int:
|
|||||||
found = deskew_angle(deskew(page, angle))
|
found = deskew_angle(deskew(page, angle))
|
||||||
assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}"
|
assert abs(found + angle) <= 0.15, f"skew {angle}: got {found}"
|
||||||
|
|
||||||
|
# A scanner's edge line runs the whole height of the sheet. Being taller
|
||||||
|
# than every bracket it used to win each overlap and swallow the page into
|
||||||
|
# one system — Olukainen juomukainen, where five pages of six came out as a
|
||||||
|
# single slice each.
|
||||||
|
scanned = _page()
|
||||||
|
scanned[10 : H - 10, W - 8 : W - 4] = 0
|
||||||
|
assert len(detect_page(scanned).systems) == 2, "an edge artefact is not a bracket"
|
||||||
|
|
||||||
# No brackets: every ink run is its own system.
|
# No brackets: every ink run is its own system.
|
||||||
bare = np.full((H, W), 255, np.uint8)
|
bare = np.full((H, W), 255, np.uint8)
|
||||||
for y in (200, 500, 800):
|
for y in (200, 500, 800):
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from noteman_slicer.pdf import open_source, page_raster # noqa: E402
|
|||||||
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
|
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
|
||||||
from noteman_slicer.render import ( # noqa: E402
|
from noteman_slicer.render import ( # noqa: E402
|
||||||
ALPHA_LEVELS,
|
ALPHA_LEVELS,
|
||||||
|
_ink_bbox,
|
||||||
apply_levels,
|
apply_levels,
|
||||||
encode,
|
encode,
|
||||||
pad_right,
|
pad_right,
|
||||||
@@ -87,6 +88,19 @@ def main() -> int:
|
|||||||
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
|
assert rgba[:, :, 3].min() == 0, "paper must be fully transparent"
|
||||||
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
|
assert len(np.unique(rgba[:, :, 3])) <= ALPHA_LEVELS
|
||||||
|
|
||||||
|
# Trim keeps a line of lyrics and drops a fleck. Each letter is its own
|
||||||
|
# small blob, so judging blobs by area threw the whole line away and the
|
||||||
|
# bottom voice lost its words; a fleck's row carries almost no ink at all.
|
||||||
|
art = np.full((300, 800), 255, np.uint8)
|
||||||
|
art[100:150, 50:750] = 0 # a staff
|
||||||
|
for x in range(60, 700, 30): # lyrics: many small glyphs, one row
|
||||||
|
art[200:220, x : x + 14] = 0
|
||||||
|
art[5:9, 10:14] = 0 # a fleck in the far corner
|
||||||
|
x0, y0, x1, y1 = _ink_bbox(art)
|
||||||
|
assert (y0, y1) == (100, 220), f"lyrics kept, fleck dropped: {(y0, y1)}"
|
||||||
|
assert (x0, x1) == (50, 750), (x0, x1)
|
||||||
|
assert _ink_bbox(np.full((50, 50), 255, np.uint8)) is None, "blank slice has no box"
|
||||||
|
|
||||||
# Levels: a white point below the paper value wipes the paper out entirely.
|
# Levels: a white point below the paper value wipes the paper out entirely.
|
||||||
faint = np.full((10, 10), 200, np.uint8)
|
faint = np.full((10, 10), 200, np.uint8)
|
||||||
assert apply_levels(faint, 0, 180).max() == 255
|
assert apply_levels(faint, 0, 180).max() == 255
|
||||||
|
|||||||
Reference in New Issue
Block a user