Skip to content

Instantly share code, notes, and snippets.

@me-suzy
Created August 8, 2026 10:09
Show Gist options
  • Select an option

  • Save me-suzy/485a63a4eeae867b9317da5e5d302e96 to your computer and use it in GitHub Desktop.

Select an option

Save me-suzy/485a63a4eeae867b9317da5e5d302e96 to your computer and use it in GitHub Desktop.
Nou V4.py
"""Script standalone "Nou V4" - text negru clar pe alb, din fotografii de pagini
neclare (defocalizate, hartie sepia, umbra puternica) SAU din alb-negru prost facut.
Ce aduce V4 peste V3
====================
A. DECONVOLUTIE RICHARDSON-LUCY <-- castigul principal
Pozele nu sunt doar intunecate, sunt DEFOCALIZATE: fiecare punct de cerneala
e imprastiat pe un disc de cativa pixeli. De aceea barele lui "=" se lipesc
intre ele si capetele de cuvant se pierd. Deconvolutia inverseaza matematic
imprastierea: se presupune o pata gaussiana si se reface, iterativ, imaginea
care ar fi produs-o. Nu e "sharpen" (care doar ridica contrastul pe muchii si
amplifica zgomotul) - e reconstructie.
Efect masurat pe paginile de test: "(m - 1)" si "(2 ξ' - η)" din formulele
sterse, pe care V3 le pierdea, ies intregi. Semnele "=" se separa in doua bare.
B. SUPERSAMPLING LA BINARIZARE (2x)
Trasaturile subtiri au sub 2 px latime; un prag aplicat direct pe ele le rupe
in bucati. Se mareste imaginea la 2x inainte de prag, deci fiecare trasatura
primeste ~4 px si pragul poate decide pe jumatati de pixel. Rezultatul se
poate lasa la 2x (fisier dublu, exact claritatea pe care o vedeti cand dati
zoom) sau se micsoreaza inapoi - si atunci micsorarea produce tonuri
intermediare, adica margini netede in loc de zimti.
C. OGLINDA DE TEXT DOAR PE ORIZONTALA
In V3 blocul tiparit se calcula si pe verticala si taia ultimul rand de jos
al paginii 319 (rand scurt, in umbra). Marginea de sus/jos nu producea oricum
murdarie, deci acum se restrange doar pe latime - acolo unde e utila (umbra
de cotor in stanga, muchia paginii in dreapta).
Restul motorului e cel din V3 si ramane valabil:
- normalizare pe canale separate, nu pe gri (hartia maro are alt raport
cerneala/hartie pe fiecare canal; pe albastru contrastul e de 2.5 ori mai bun);
- fundal prin inchidere morfologica, nu prin blur (blur-ul lasa fantome);
- toti parametrii in inaltimi de litera, deduse din imagine;
- binarizare cu histereza (prag strict pentru nuclee + prag permisiv pentru
trasee palide, pastrate doar unde un nucleu le confirma);
- curatare STRUCTURALA: pe aceste pagini petele de murdarie sunt mai intunecate
decat textul sters, deci nici un prag de intunecime nu le poate separa. Le
separa pozitia - coridoarele randurilor de text.
ATENTIE: dintr-un alb-negru prost (fisier pe 1 bit) nu se mai poate recupera ce
s-a pierdut deja la binarizare - deconvolutia nu are pe ce lucra. Pentru rezultat
maxim dati la intrare fotografia color, nu alb-negrul.
"""
import sys
import cv2
import numpy as np
from pathlib import Path
# ----------------------------------------------------------------- configurare
INPUT_DIR = Path(r"g:\Colectia EMINESCIANA")
OUTPUT_DIR = Path(r"g:\Colectia EMINESCIANA\Output")
JPEG_QUALITY = 92
PRESET = "clar"
PRESETS = {
# implicit: deconvolutie + binarizare la 2x, iesire la marimea originalului
# (marimea e aceeasi ca la intrare, dar literele sunt intregi si cu contur neted)
"clar": dict(scale=2.0, out_scale=1.0),
# la fel, dar fisierul iese la dublu (4896x6528 pentru o poza de 2448x3264).
# Asta e claritatea pe care o vedeti cand dati zoom in vizualizator, salvata
# ca atare. Recomandat daca urmeaza OCR sau tiparire.
"clar2x": dict(scale=2.0, out_scale=2.0),
# recupereaza si ultimele urme de text; lasa vizibil mai multa murdarie
"maxim": dict(scale=2.0, out_scale=1.0, k_hi=0.22, k_lo=0.045, min_contrast=10),
# litere ingrosate, pentru tiparire sau ochi obositi
"gros": dict(scale=2.0, out_scale=1.0, thicken=1),
# fara deconvolutie si fara supersampling = comportamentul V3, de ~4 ori mai rapid
"rapid": dict(scale=1.0, out_scale=1.0, deconv_iters=0),
}
# -------------------------------------------------------------------- utilitare
def _odd(n):
n = int(round(n))
return n + 1 if n % 2 == 0 else max(3, n)
def imread_unicode(path):
"""cv2.imread nu suporta diacritice in cale pe Windows."""
data = np.fromfile(str(path), dtype=np.uint8)
return cv2.imdecode(data, cv2.IMREAD_COLOR)
def imwrite_unicode(path, img, quality=JPEG_QUALITY):
ok, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, quality])
if ok:
buf.tofile(str(path))
return ok
def is_bilevel(gray):
"""Fisierul e deja alb-negru pe 1 bit? (JPEG-ul mai adauga cenusiu pe muchii)"""
h = cv2.calcHist([gray], [0], None, [256], [0, 256]).ravel()
return (h[:24].sum() + h[232:].sum()) / h.sum() > 0.97
# ------------------------------------------------------------ estimarea scarii
def _weighted_median(values, weights):
order = np.argsort(values)
cum = np.cumsum(weights[order].astype(np.float64))
return float(values[order][np.searchsorted(cum, cum[-1] / 2.0)])
def estimate_text_height(binary):
"""Inaltimea corpului de litera, in pixeli.
Mediana ponderata cu aria: praful are arie mica si nu trage rezultatul in jos,
cum se intampla la o mediana simpla.
"""
fallback = max(10.0, binary.shape[0] / 90.0)
n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=8)
if n < 20:
return fallback
w, h, area = stats[1:, 2], stats[1:, 3], stats[1:, 4]
ok = (area >= 8) & (h >= 4) & (w <= 6 * h) & (h <= binary.shape[0] * 0.05)
if ok.sum() < 20:
return fallback
return max(6.0, _weighted_median(h[ok], area[ok]))
# ------------------------------------------------------- iluminare si cerneala
def background(channel, radius):
"""Suprafata de iluminare: inchidere morfologica (sterge textul) + netezire."""
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (_odd(radius), _odd(radius)))
bg = cv2.morphologyEx(channel, cv2.MORPH_CLOSE, k)
bg = cv2.medianBlur(bg, _odd(radius // 2))
return cv2.GaussianBlur(bg, (0, 0), radius / 3.0)
def ink_and_contrast(img, radius):
"""Returneaza (harta de cerneala 0..255, contrast absolut fata de fundal).
Harta de cerneala = minimul canalelor normalizate cu propriul fundal.
Contrastul e luat ca maxim pe canale: da cerneala reala un scor mare chiar si
in zonele de umbra, unde valorile brute sunt toate mici.
"""
channels = [img] if img.ndim == 2 else [img[:, :, i] for i in range(3)]
ink, contrast = None, None
for ch in channels:
bg = background(ch, radius)
norm = np.clip(ch.astype(np.float32) / np.maximum(bg, 1) * 255.0, 0, 255)
con = bg.astype(np.float32) - ch.astype(np.float32)
ink = norm if ink is None else np.minimum(ink, norm)
contrast = con if contrast is None else np.maximum(contrast, con)
return ink, contrast
# ------------------------------------------------------------- deconvolutie
def richardson_lucy(img, sigma, iters=20, damp=1e-3):
"""Deconvolutie Richardson-Lucy cu pata gaussiana.
Porneste de la imaginea observata ca prima estimare si o corecteaza iterativ:
la fiecare pas reimprastie estimarea curenta si compara cu ce s-a masurat,
apoi ajusteaza. Converge la imaginea care, imprastiata, ar da poza reala.
sigma prea mare sau prea multe iteratii = inele in jurul literelor si
textura de hartie amplificata. sigma ~ inaltimea literei / 16 e sigur.
"""
size = int(2 * round(3 * sigma) + 1)
k = cv2.getGaussianKernel(size, sigma)
psf = (k @ k.T).astype(np.float32)
flip = psf[::-1, ::-1].copy()
observed = np.maximum(img.astype(np.float32), 0)
est = observed.copy()
for _ in range(iters):
blurred = cv2.filter2D(est, -1, psf, borderType=cv2.BORDER_REFLECT)
est *= cv2.filter2D(observed / np.maximum(blurred, damp), -1, flip,
borderType=cv2.BORDER_REFLECT)
np.clip(est, 0, 255, out=est)
return est
def sauvola_threshold(gray, window, k, R=128.0):
"""Pragul Sauvola: T = m * (1 + k*(s/R - 1)). k mai mare = prag mai strict."""
g = gray.astype(np.float32)
mean = cv2.boxFilter(g, -1, (window, window), normalize=True,
borderType=cv2.BORDER_REFLECT)
sq = cv2.boxFilter(g * g, -1, (window, window), normalize=True,
borderType=cv2.BORDER_REFLECT)
std = np.sqrt(np.maximum(sq - mean * mean, 0))
return mean * (1.0 + k * (std / R - 1.0))
# --------------------------------------------------------- curatarea murdariei
def text_block(anchors, th, gap_mult=2.0, min_run_mult=6.0, margin_mult=2.5,
quant=0.004, axes="x"):
"""Latimea oglinzii de text, dedusa din randurile lungi.
Se unesc pe orizontala literele confirmate; doar sirurile mai lungi de 6
inaltimi de litera trec drept rand de text. Umbra de cotor si murdaria de pe
margini nu formeaza randuri, deci raman in afara.
Implicit se restrange doar pe orizontala (axes="x"): pe verticala, un rand
scurt de la baza paginii ar fi taiat pe nedrept.
"""
k = cv2.getStructuringElement(cv2.MORPH_RECT, (_odd(th * gap_mult), 1))
n, labels, stats, _ = cv2.connectedComponentsWithStats(
cv2.morphologyEx(anchors, cv2.MORPH_CLOSE, k), connectivity=8)
if n <= 1:
return None
lut = np.zeros(n, np.uint8)
lut[1:][stats[1:, cv2.CC_STAT_WIDTH] >= th * min_run_mult] = 1
rows = lut[labels]
if rows.sum() < 50 * th:
return None
def span(profile):
c = np.cumsum(profile) / profile.sum()
return int(np.searchsorted(c, quant)), int(np.searchsorted(c, 1 - quant))
m = int(th * margin_mult)
h, w = anchors.shape
y0, y1 = span(rows.sum(axis=1).astype(np.float64)) if "y" in axes else (0, h - 1)
x0, x1 = span(rows.sum(axis=0).astype(np.float64)) if "x" in axes else (0, w - 1)
box = np.zeros((h, w), np.uint8)
box[max(0, y0 - m):min(h, y1 + m + 1), max(0, x0 - m):min(w, x1 + m + 1)] = 255
return box
def select_text(weak, strong, th, min_area_frac=0.025, glyph_h_frac=0.40,
glyph_a_frac=0.10, reach_mult=7.0, rise_mult=0.9, near_mult=1.6,
use_block=True, block_axes="x"):
"""Alege ce componente sunt text si sterge restul.
Ancore = litere de marime normala confirmate de pragul strict, plus liniile
lungi (bare de fractie, filetul de la antet).
Coridor = banda orizontala din jurul ancorelor, adica randul de text. Formulele
au spatii mari intre simboluri, de aceea raza e de 7 inaltimi.
"""
n, labels, stats, _ = cv2.connectedComponentsWithStats(weak, connectivity=8)
if n <= 1:
return weak
x, y, w, h, area = (stats[1:, i] for i in range(5))
seeded = np.bincount(labels[strong > 0].ravel(), minlength=n)[1:] > 0
is_line = (w >= 4 * th) | (h >= 4 * th)
is_glyph = (h >= glyph_h_frac * th) & (area >= glyph_a_frac * th * th)
anchor = (seeded & is_glyph) | is_line
lut = np.zeros(n, np.uint8)
lut[1:][anchor] = 255
anchor_mask = lut[labels]
cy = np.clip(y + h // 2, 0, weak.shape[0] - 1)
cx = np.clip(x + w // 2, 0, weak.shape[1] - 1)
# 1. taie tot ce e in afara oglinzii de text
if use_block:
box = text_block(anchor_mask, th, axes=block_axes)
if box is not None:
anchor &= box[cy, cx] > 0
lut[:] = 0
lut[1:][anchor] = 255
anchor_mask = lut[labels]
# 2. coridoarele randurilor
far = cv2.dilate(anchor_mask, cv2.getStructuringElement(
cv2.MORPH_RECT, (_odd(th * reach_mult), _odd(th * rise_mult))))
close = cv2.dilate(anchor_mask, cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (_odd(th * near_mult), _odd(th * near_mult))))
in_far = far[cy, cx] > 0
in_close = close[cy, cx] > 0
min_area = max(3.0, min_area_frac * th * th)
# "tiny" = punct de i, virgula, fir de praf. Barele lui "=" si "-" sunt late,
# deci nu intra aici si nu sunt condamnate sa stea lipite de o litera.
tiny = (w <= 0.6 * th) & (h <= 0.6 * th)
keep = anchor
keep |= in_far & ~tiny & (seeded | is_glyph | (area >= min_area))
keep |= in_close & tiny & (area >= min_area)
out = np.zeros(n, np.uint8)
out[1:][keep] = 255
return out[labels]
def render(binary, ratio, thicken=0, smooth=0.6):
"""`binary` e la scara de lucru; `ratio` = marimea ceruta / scara de lucru."""
out = binary
if thicken:
out = cv2.dilate(out, cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (_odd(2 * thicken + 1),) * 2))
if abs(ratio - 1.0) > 1e-6:
# INTER_AREA produce tonuri intermediare = antialiasing pe gratis
out = cv2.resize(out, None, fx=ratio, fy=ratio, interpolation=cv2.INTER_AREA)
elif smooth:
out = cv2.GaussianBlur(out, (0, 0), smooth)
return np.clip(255 - out, 0, 255).astype(np.uint8)
# ------------------------------------------------------------------- pipeline
def proc_nou_v4(img, scale=2.0, out_scale=1.0, deconv_sigma_frac=1 / 16.0,
deconv_iters=20, k_hi=0.25, k_lo=0.06, win_mult=1.6,
radius_mult=1.2, seed_area_frac=0.05, min_contrast=15,
denoise=True, thicken=0, smooth=0.6, min_area_frac=0.025,
glyph_h_frac=0.40, glyph_a_frac=0.10, reach_mult=7.0,
rise_mult=0.9, near_mult=1.6, use_block=True, block_axes="x",
close_gaps=0):
"""Returneaza grayscale: text negru pe fundal alb.
scale - rezolutia de lucru la binarizare (2.0 = supersampling 2x)
out_scale - marimea fisierului de iesire fata de original (1.0 sau 2.0)
"""
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img
if is_bilevel(gray):
# deja binarizat: nu mai e nimic de recuperat din imagine (deconvolutia nu
# are pe ce lucra), dar 2x + micsorare tot netezeste conturul zimtat
base = ((255 - gray) > 127).astype(np.uint8) * 255
th_native = estimate_text_height(base)
if scale != 1.0:
base = cv2.resize(base, None, fx=scale, fy=scale,
interpolation=cv2.INTER_CUBIC)
base = (base > 110).astype(np.uint8) * 255
weak = strong_raw = base
th = th_native * scale
else:
# --- pas 1: scara textului, la rezolutia nativa
rough = max(15, gray.shape[0] // 80)
g0 = ink_and_contrast(img, rough)[0].astype(np.uint8)
otsu, _ = cv2.threshold(g0, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
th_native = estimate_text_height((g0 < otsu).astype(np.uint8) * 255)
# --- pas 2: harta de cerneala corectata la iluminare
ink, contrast = ink_and_contrast(img, max(15, th_native * radius_mult))
if denoise:
ink = cv2.medianBlur(ink.astype(np.uint8), 3).astype(np.float32)
# --- pas 3: deconvolutie, la rezolutia la care s-a produs neclaritatea
sigma = th_native * deconv_sigma_frac
if deconv_iters > 0 and sigma > 0.4:
ink = richardson_lucy(ink, sigma, deconv_iters)
# --- pas 4: supersampling, ca pragul sa poata decide sub-pixel
th = th_native * scale
if scale != 1.0:
ink = cv2.resize(ink, None, fx=scale, fy=scale,
interpolation=cv2.INTER_LANCZOS4)
contrast = cv2.resize(contrast, None, fx=scale, fy=scale,
interpolation=cv2.INTER_LINEAR)
# --- pas 5: binarizare cu histereza
g = np.clip(ink, 0, 255).astype(np.uint8)
gf = g.astype(np.float32)
real = contrast >= min_contrast # taie fluctuatiile de fibra de hartie
window = _odd(th * win_mult)
weak = ((gf < sauvola_threshold(g, window, k_lo)) & real).astype(np.uint8) * 255
strong_raw = ((gf < sauvola_threshold(g, window, k_hi)) & real).astype(np.uint8) * 255
# nucleele: pragul strict, minus firele de praf care nu au voie sa germineze
n, labels, stats, _ = cv2.connectedComponentsWithStats(strong_raw, connectivity=8)
lut = np.zeros(n, np.uint8)
lut[1:][stats[1:, cv2.CC_STAT_AREA] >= seed_area_frac * th * th] = 255
strong = lut[labels]
if close_gaps:
weak = cv2.morphologyEx(weak, cv2.MORPH_CLOSE, cv2.getStructuringElement(
cv2.MORPH_ELLIPSE, (_odd(close_gaps * scale),) * 2))
binary = select_text(weak, strong, th, min_area_frac, glyph_h_frac,
glyph_a_frac, reach_mult, rise_mult, near_mult,
use_block, block_axes)
return render(binary, out_scale / scale, thicken=thicken, smooth=smooth)
# ----------------------------------------------------------------------- main
def main():
in_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else INPUT_DIR
out_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else OUTPUT_DIR
preset = sys.argv[3] if len(sys.argv) > 3 else PRESET
if preset not in PRESETS:
print(f"Preset necunoscut: {preset}. Alege din: {', '.join(PRESETS)}")
return 1
if not in_dir.is_dir():
print(f"Nu exista folderul de intrare: {in_dir}")
return 1
out_dir.mkdir(parents=True, exist_ok=True)
extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tif", ".tiff", ".webp"}
files = sorted(f for f in in_dir.iterdir()
if f.is_file() and f.suffix.lower() in extensions)
if not files:
print(f"Nu am gasit imagini in {in_dir}")
return 1
print(f"Procesez {len(files)} imagini din {in_dir} [preset: {preset}]")
params = PRESETS[preset]
for i, path in enumerate(files, 1):
img = imread_unicode(path)
if img is None:
print(f" [{i}/{len(files)}] {path.name} - SKIP, nu pot citi")
continue
out = proc_nou_v4(img, **params)
imwrite_unicode(out_dir / (path.stem + ".jpg"), out)
print(f" [{i}/{len(files)}] {path.name} -> {out.shape[1]}x{out.shape[0]}, "
f"cerneala {100*(out<128).mean():.2f}%")
print(f"Gata! Iesire: {out_dir}")
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment