Download ecglib/scale.py from pashaalex/ecg-rectifier: direct link, hf CLI and curl.
- Browser
- Download file 6.46 kB
-
https://huggingface.co/pashaalex/ecg-rectifier/resolve/main/ecglib/scale.py
- Command line
-
hf download hf://pashaalex/ecg-rectifier/ecglib/scale.py
-
curl -L -o scale.py https://huggingface.co/pashaalex/ecg-rectifier/resolve/main/ecglib/scale.py
6.46 kB
| """Photo scale (px/mm) from detector trials at several assumed scales. | |
| A square crop around a point is resized to 5.5 px/mm under each assumed scale; the detector finds | |
| nodes and they are linked into a lattice. A right guess gives a large lattice with a 27.5 px (5 mm) | |
| step. The "5 mm cell" check (four thin 1 mm lines inside a cell -> 5th harmonic of the brightness | |
| profile) rejects the x5 (1 mm grid taken for 5 mm) and x2 (10 mm) errors. | |
| """ | |
| import cv2 | |
| import numpy as np | |
| from . import lattice, nets | |
| TARGET_PX_PER_MM = 5.5 | |
| STEP_PX = 5.0 * TARGET_PX_PER_MM | |
| CENTER_CROP = 448 | |
| TRIAL_PX_PER_MM = (2.0, 3.3, 5.5, 9.0, 15.0, 25.0, 40.0) | |
| PROFILE_SAMPLES = 100 | |
| MIN_CELLS = 20 | |
| MIN_FIVE_10MM = 0.5 | |
| MIN_CELL_PX = 12.0 | |
| MIN_FIVE = 1.2 | |
| SCALE_TRIES = 3 | |
| MAX_SHEET_MM = 600.0 | |
| def resize(image, factor): | |
| """INTER_AREA for downscaling (no moire on thin grid lines), cubic for upscaling.""" | |
| if abs(factor - 1.0) < 1e-3: | |
| return image | |
| return cv2.resize(image, None, fx=factor, fy=factor, | |
| interpolation=cv2.INTER_AREA if factor < 1 else cv2.INTER_CUBIC) | |
| def five_score(image, quads): | |
| """5th harmonic of the mean brightness profile across lattice cells / mean of the 4th and 6th.""" | |
| green = image[..., 1].astype(np.float32) | |
| t = np.arange(PROFILE_SAMPLES) / PROFILE_SAMPLES | |
| profiles = [] | |
| for a, b, c, d in ((0, 1, 3, 2), (0, 3, 1, 2)): | |
| for offset in (0.35, 0.5, 0.65): | |
| start = quads[:, a] + offset * (quads[:, c] - quads[:, a]) | |
| end = quads[:, b] + offset * (quads[:, d] - quads[:, b]) | |
| line = start[:, None] + t[None, :, None] * (end - start)[:, None] | |
| profiles.append(cv2.remap(green, line[..., 0].astype(np.float32), line[..., 1].astype(np.float32), | |
| cv2.INTER_LINEAR)) | |
| mean = np.concatenate(profiles).mean(0) | |
| amplitude = np.abs(np.fft.rfft(mean - mean.mean())) | |
| return float(amplitude[5] / max(1e-6, 0.5 * (amplitude[4] + amplitude[6]))) | |
| def center_trial(models, image, px_per_mm, cx, cy, min_px_per_mm=0.0): | |
| """One trial around (cx, cy) assuming px_per_mm. score 0 means unusable.""" | |
| h, w = image.shape[:2] | |
| factor = TARGET_PX_PER_MM / px_per_mm | |
| half = CENTER_CROP / factor / 2 | |
| x0, x1 = int(max(0, cx - half)), int(min(w, cx + half)) | |
| y0, y1 = int(max(0, cy - half)), int(min(h, cy + half)) | |
| crop = resize(image[y0:y1, x0:x1], factor) | |
| found = nets.detector_net(models, crop) | |
| if len(found) < 8: | |
| return {"score": 0} | |
| xy = found[:, :2] | |
| index, component, right, down = lattice.index_mutual(xy, STEP_PX) | |
| members = np.nonzero(component == np.argmax(np.bincount(component)))[0] | |
| step_x, step_y, nr, nd = lattice.lattice_stats(xy, right, down, members) | |
| step = np.nanmean([step_x, step_y]) | |
| closeness = np.exp(-0.5 * (np.log(step / STEP_PX) / 0.2) ** 2) if np.isfinite(step) else 0.0 | |
| quads = lattice.cell_quads(xy, index, members) | |
| five = five_score(image, (quads + 0.5) / factor - 0.5 + np.array([x0, y0])) if len(quads) >= 4 else 0.0 | |
| if np.sqrt(abs(px_per_mm * step_x / STEP_PX * px_per_mm * step_y / STEP_PX)) < min_px_per_mm: | |
| closeness = 0.0 | |
| return {"score": float(min(nr, nd) * closeness), "cells": int(len(quads)), "five": five, | |
| "px_per_mm_x": px_per_mm * step_x / STEP_PX, "px_per_mm_y": px_per_mm * step_y / STEP_PX} | |
| def find_scale_at(models, image, cx, cy, min_px_per_mm=0.0): | |
| """Best trial around (cx, cy) with a "verified" flag, or None. | |
| Usable trials are grouped (+-15 %); 10 mm lattices are dropped; a group is verified when its | |
| cells look like 5 mm cells. The winner is refined by one more trial at the found scale.""" | |
| trial = lambda p: center_trial(models, image, p, cx, cy, min_px_per_mm) | |
| trials = [trial(p) for p in TRIAL_PX_PER_MM] | |
| estimate = lambda t: float(np.sqrt(t["px_per_mm_x"] * t["px_per_mm_y"])) | |
| usable = [t for t in trials if t["score"] > 0 and t.get("cells", 0) >= MIN_CELLS | |
| and abs(np.log(t["px_per_mm_x"] / t["px_per_mm_y"])) < np.log(1.25)] | |
| if not usable: | |
| return None | |
| groups = [] | |
| for t in sorted(usable, key=lambda t: -t["cells"]): | |
| for g in groups: | |
| if abs(np.log(estimate(t) / g["px_per_mm"])) < np.log(1.15): | |
| g["trials"].append(t) | |
| g["weight"] += t["cells"] | |
| g["five"] = max(g["five"], t["five"]) | |
| break | |
| else: | |
| groups.append({"px_per_mm": estimate(t), "trials": [t], "weight": t["cells"], "five": t["five"]}) | |
| groups = [g for g in groups if g["five"] >= MIN_FIVE_10MM] or groups | |
| groups.sort(key=lambda g: -g["weight"]) | |
| top = groups[0]["weight"] | |
| verified = [g for g in groups if g["five"] >= MIN_FIVE and 5 * g["px_per_mm"] >= MIN_CELL_PX | |
| and g["weight"] >= 0.25 * top] | |
| if verified: | |
| best = max(verified, key=lambda g: g["weight"] * min(g["five"], 10.0)) | |
| else: | |
| best = groups[0] | |
| for g in groups[1:]: | |
| ratio = best["px_per_mm"] / g["px_per_mm"] | |
| if 1.7 < ratio < 2.4 and g["weight"] > 0.5 * best["weight"]: | |
| best = g | |
| seed = max(best["trials"], key=lambda t: t["cells"]) | |
| refined = trial(estimate(seed)) | |
| agrees = refined["score"] > 0 and refined.get("cells", 0) >= MIN_CELLS and \ | |
| abs(np.log(estimate(refined) / estimate(seed))) < np.log(1.15) | |
| return dict(refined if agrees else seed, verified=bool(verified)) | |
| def find_scale_in_mask(models, image, mask): | |
| """Scale at the deepest mask point, then at up to SCALE_TRIES - 1 other deep points. | |
| -> (px/mm or None, (cx, cy) or None, tried points, verified).""" | |
| dist = cv2.distanceTransform(mask.astype(np.uint8), cv2.DIST_L2, 5) | |
| min_ppm = max(image.shape[:2]) / MAX_SHEET_MM | |
| tried = [] | |
| for _ in range(SCALE_TRIES): | |
| cy, cx = np.unravel_index(int(np.argmax(dist)), dist.shape) | |
| if dist[cy, cx] <= 0: | |
| break | |
| centre = find_scale_at(models, image, cx, cy, min_ppm) | |
| ppm = None if centre is None else float(np.sqrt(centre["px_per_mm_x"] * centre["px_per_mm_y"])) | |
| tried.append([int(cx), int(cy), None if ppm is None else round(ppm, 3)]) | |
| if centre is not None: | |
| return ppm, (int(cx), int(cy)), tried, bool(centre["verified"]) | |
| r = int(max(20, 0.15 * max(image.shape[:2]))) | |
| cv2.circle(dist, (int(cx), int(cy)), r, 0, -1) | |
| return None, None, tried, False | |