Created
August 8, 2026 10:08
-
-
Save me-suzy/b695cf03f887b5c16fc5583facfeff60 to your computer and use it in GitHub Desktop.
Nou V3.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 V3" - text negru curat pe alb, din fotografii de pagini | |
| degradate (hartie sepia, umbra puternica, text sters) SAU din alb-negru prost facut. | |
| De ce e mai bun decat "Nou V2": | |
| 1. NORMALIZARE PE CANALE, NU PE GRI | |
| Hartia veche e maro: reflecta mult rosu si putin albastru. Cerneala e neagra | |
| in toate canalele. Raportul cerneala/hartie e deci mult mai bun pe canale | |
| separate decat pe gri. Se normalizeaza fiecare canal cu propriul fundal si | |
| se ia minimul -> contrast maxim. (Pe gri, semnele "=" din formulele sterse | |
| dispar complet; pe canale raman.) | |
| 2. FUNDAL PRIN INCHIDERE MORFOLOGICA, NU PRIN BLUR | |
| Blur-ul gaussian e tras in jos de textul dens si lasa "fantome". Inchiderea | |
| morfologica cu raza mai mare decat o litera sterge textul si pastreaza doar | |
| forma iluminarii. | |
| 3. SCARA DEDUSA DIN IMAGINE | |
| Toti parametrii sunt exprimati in inaltimi de litera, estimata ca mediana | |
| ponderata cu aria a componentelor. V2 folosea ferestre fixe (31 px), gresite | |
| pentru alta rezolutie. Scriptul merge la fel pe 300 dpi si pe 600 dpi. | |
| 4. BINARIZARE CU HISTEREZA, NU CU UN SINGUR PRAG | |
| Un prag strict (Sauvola k=0.25) da nucleele sigure de cerneala; un prag | |
| permisiv (k=0.06) da si trasaturile palide. Se pastreaza traseul palid doar | |
| unde e confirmat de un nucleu. Asa iese text intreg fara sa se ingroase. | |
| 5. CURATARE STRUCTURALA, NU DUPA INTUNECIME | |
| Masurat pe aceste pagini: petele de murdarie sunt MAI INTUNECATE decat textul | |
| sters (pete pana la 109 contrast, text sters pana la 73). Deci nici un prag | |
| de intunecime nu le poate separa. Ce le separa e pozitia: | |
| - se detecteaza oglinda de text (blocul tiparit) din randurile lungi; | |
| - in jurul literelor confirmate se deschid coridoare orizontale = randul; | |
| - ce cade in afara coridoarelor e murdarie si se sterge; | |
| - punctele minuscule trebuie sa fie lipite de o litera (i, diacritice, | |
| virgule) - altfel sunt fire de praf. | |
| Asta curata umbra de cotor, marginile si praful, fara sa atinga textul. | |
| 6. V2 STERGEA CONTINUT DIN GRESEALA | |
| Filtrul lui de componente taia orice mai lat de 700 px - adica exact linia | |
| de la antet si barele de fractie lungi. Aici liniile lungi sunt protejate. | |
| Detecteaza singur daca fisierul e deja alb-negru (1 bit) si atunci sare peste | |
| partea de imagine si doar curata praful si reface conturul. ATENTIE: dintr-un | |
| alb-negru prost nu se mai poate recupera ce s-a pierdut deja la binarizare - | |
| 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 = 90 | |
| # "curat" - implicit; echilibrul cel mai bun text/murdarie | |
| # "maxim" - recupereaza si ultimele urme de text, dar lasa mai multa murdarie | |
| # "gros" - ca "curat", cu literele ingrosate (pentru tiparire / ochi obositi) | |
| PRESET = "curat" | |
| PRESETS = { | |
| "curat": dict(), | |
| "maxim": dict(k_hi=0.22, k_lo=0.045, min_contrast=10), | |
| "gros": dict(thicken=1), | |
| } | |
| # -------------------------------------------------------------------- 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: ii 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 | |
| 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): | |
| """Oglinda de text (blocul tiparit), 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 blocului. | |
| """ | |
| 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)) | |
| y0, y1 = span(rows.sum(axis=1).astype(np.float64)) | |
| x0, x1 = span(rows.sum(axis=0).astype(np.float64)) | |
| m = int(th * margin_mult) | |
| h, w = anchors.shape | |
| 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): | |
| """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) | |
| 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, thicken=0, smooth=0.6): | |
| """Text negru pe alb, cu muchii fine (fara zimti de binarizare).""" | |
| out = binary | |
| if thicken: | |
| out = cv2.dilate(out, cv2.getStructuringElement( | |
| cv2.MORPH_ELLIPSE, (_odd(2 * thicken + 1),) * 2)) | |
| alpha = (cv2.GaussianBlur(out, (0, 0), smooth) if smooth else out) | |
| return np.clip(255 * (1 - alpha.astype(np.float32) / 255.0), 0, 255).astype(np.uint8) | |
| # ------------------------------------------------------------------- pipeline | |
| def proc_nou_v3(img, 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, | |
| close_gaps=0, 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): | |
| """Returneaza grayscale: text negru pe fundal alb.""" | |
| gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if img.ndim == 3 else img | |
| if is_bilevel(gray): | |
| # deja binarizat: nu mai e nimic de castigat din imagine, doar de curatat | |
| weak = ((255 - gray) > 127).astype(np.uint8) * 255 | |
| th = estimate_text_height(weak) | |
| strong_raw = weak | |
| else: | |
| # pas 1: o normalizare grosiera doar ca sa aflam cat de mare e o litera | |
| 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 = estimate_text_height((g0 < otsu).astype(np.uint8) * 255) | |
| # pas 2: normalizare la scara textului + binarizare cu histereza | |
| ink, contrast = ink_and_contrast(img, max(15, th * radius_mult)) | |
| g = ink.astype(np.uint8) | |
| if denoise: | |
| g = cv2.medianBlur(g, 3) | |
| window = _odd(th * win_mult) | |
| gf = g.astype(np.float32) | |
| real = contrast >= min_contrast # taie fluctuatiile de fibra de hartie | |
| 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),) * 2)) | |
| binary = select_text(weak, strong, th, min_area_frac, glyph_h_frac, | |
| glyph_a_frac, reach_mult, rise_mult, near_mult, use_block) | |
| return render(binary, 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_v3(img, **params) | |
| imwrite_unicode(out_dir / (path.stem + ".jpg"), out) | |
| print(f" [{i}/{len(files)}] {path.name} -> 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