Created
August 8, 2026 10:09
-
-
Save me-suzy/485a63a4eeae867b9317da5e5d302e96 to your computer and use it in GitHub Desktop.
Nou V4.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| """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