| from __future__ import annotations |
|
|
| from collections.abc import Sequence |
|
|
| import torch |
| import torch.nn.functional as F |
|
|
| Point = Sequence[float] |
|
|
| RS = "\x1e" |
|
|
| |
| |
| |
| PATTERN_CLASSES = ["whorl", "loop", "arch", "other"] |
|
|
|
|
| def pattern_to_index(code: str) -> int: |
| """Map an EFS 9.307 pattern code to a PATTERN_CLASSES index (whorl/loop/arch/other).""" |
| c = (code or "").strip().upper().split("\x1f")[0].split("\x1e")[0][:2] |
| if c.startswith("W"): |
| return 0 |
| if c in ("LS", "RS") or c.startswith("L") or c.startswith("R"): |
| return 1 |
| if c.startswith("A") or c in ("TA", "PA"): |
| return 2 |
| return 3 |
|
|
|
|
| def pattern_onehot(code: str) -> list[float]: |
| v = [0.0] * len(PATTERN_CLASSES) |
| v[pattern_to_index(code)] = 1.0 |
| return v |
|
|
|
|
| def compute_orientation_field( |
| image: torch.Tensor, block: int = 8, sigma_smooth: int = 9 |
| ) -> torch.Tensor: |
| """Ridge orientation field via the gradient structure tensor. |
| |
| Returns ``(B, 2, H, W)`` = ``[cos(2θ), sin(2θ)] * coherence``. Using the |
| double-angle (2θ) encoding avoids the orientation wrap-around at 0/π, and |
| weighting by coherence down-weights noisy/blank regions (low coherence → ~0), |
| so it is a clean dense conditioning/loss signal that is registered to the image |
| (computed from the image itself, no annotation needed). Identity-bearing: it |
| captures ridge flow, the basis of pattern class and singular points. |
| """ |
| if image.ndim != 4: |
| raise ValueError(f"expected (B,C,H,W), got {tuple(image.shape)}") |
| g = image if image.shape[1] == 1 else image.mean(1, keepdim=True) |
| kx = torch.tensor([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=g.dtype, device=g.device).view(1, 1, 3, 3) |
| ky = kx.transpose(-1, -2) |
| |
| |
| |
| g = F.pad(g, (1, 1, 1, 1), mode="replicate") |
| gx = F.conv2d(g, kx) |
| gy = F.conv2d(g, ky) |
| gxx, gyy, gxy = gx * gx, gy * gy, gx * gy |
| pad = sigma_smooth // 2 |
| sm = lambda t: F.avg_pool2d(t, sigma_smooth, stride=1, padding=pad) |
| gxx, gyy, gxy = sm(gxx), sm(gyy), sm(gxy) |
| cos2 = gxx - gyy |
| sin2 = 2 * gxy |
| |
| |
| |
| |
| |
| mag = torch.sqrt(cos2 * cos2 + sin2 * sin2 + 1e-12) + 1e-6 |
| coh = mag / (gxx + gyy + 1e-6) |
| return torch.cat([cos2 / mag * coh, sin2 / mag * coh], dim=1) |
|
|
|
|
| def parse_quality_grid(value: str) -> list[list[int]]: |
| """Parse a Type-9 ``9.308`` ridge-quality grid (RS-separated digit rows). |
| |
| Returns a rectangular int grid (rows of equal length); ragged input is |
| right-padded with zeros. Values are quality levels (0=background .. 5). |
| """ |
| rows = [row for row in value.split(RS) if row] |
| grid = [[int(c) for c in row if c.isdigit()] for row in rows] |
| grid = [r for r in grid if r] |
| if not grid: |
| return [] |
| width = max(len(r) for r in grid) |
| return [r + [0] * (width - len(r)) for r in grid] |
|
|
|
|
| def render_quality_grid( |
| grid: str | list[list[int]], |
| out_size: int, |
| max_level: int = 5, |
| binarize: bool = False, |
| device: torch.device | str = "cpu", |
| dtype: torch.dtype = torch.float32, |
| ) -> torch.Tensor: |
| """Render a ``9.308`` quality grid as a dense ``(1, out, out)`` channel. |
| |
| The grid spans the full annotation image frame, so it is spatially registered |
| to the (uniformly-scaled) latent image and is upsampled to ``out_size``. With |
| ``binarize=True`` it returns a foreground mask (level>0), suitable as the |
| ``M_fg`` for ``L_seg``; otherwise quality is normalised to ``[0,1]``. |
| Empty grid -> all-ones (no annotation = treat everything as valid, the safe |
| default for a mask) when binarized, else zeros. |
| """ |
| if isinstance(grid, str): |
| grid = parse_quality_grid(grid) |
| if not grid: |
| fill = 1.0 if binarize else 0.0 |
| return torch.full((1, out_size, out_size), fill, device=device, dtype=dtype) |
| t = torch.tensor(grid, device=device, dtype=dtype).unsqueeze(0).unsqueeze(0) |
| if binarize: |
| t = (t > 0).to(dtype) |
| else: |
| t = t.clamp(0, max_level) / float(max_level) |
| |
| |
| |
| hg, wg = t.shape[-2], t.shape[-1] |
| s = out_size / max(hg, wg) |
| nh, nw = max(1, round(hg * s)), max(1, round(wg * s)) |
| t = F.interpolate(t, size=(nh, nw), mode="bilinear", align_corners=False) |
| canvas = torch.zeros(1, 1, out_size, out_size, device=device, dtype=dtype) |
| oy, ox = (out_size - nh) // 2, (out_size - nw) // 2 |
| canvas[:, :, oy:oy + nh, ox:ox + nw] = t |
| return canvas[0] |
|
|
|
|
| def structural_channel_names(orientation: bool = False) -> list[str]: |
| """Channel order produced by :func:`build_structural_conditioning`.""" |
| names = ["minutiae", "core", "delta"] |
| if orientation: |
| names += ["minutiae_sin", "minutiae_cos"] |
| return names |
|
|
|
|
| def render_gaussian_points( |
| points: Sequence[Point], |
| out_size: int, |
| src_size: tuple[float, float] | None = None, |
| sigma: float = 3.0, |
| weights: Sequence[float] | None = None, |
| device: torch.device | str = "cpu", |
| dtype: torch.dtype = torch.float32, |
| ) -> torch.Tensor: |
| """Render points as a single-channel Gaussian heatmap ``(1, out, out)``. |
| |
| Coordinates are in the pixel system of the source annotation image. Pass |
| ``src_size=(W, H)`` (the EFS record's image size, e.g. 9.300) to rescale onto |
| the ``out_size`` training grid; omit it if points are already in ``out_size`` |
| pixels. Peaks are 1.0 at each point (or ``weights[i]`` if given). An empty |
| point list yields an all-zero map, which is the correct conditioning for a |
| latent that genuinely has no annotated core/delta. |
| """ |
| hm = torch.zeros(1, out_size, out_size, device=device, dtype=dtype) |
| if not points: |
| return hm |
|
|
| |
| |
| |
| |
| |
| if src_size: |
| s = out_size / max(float(src_size[0]), float(src_size[1])) |
| sx = sy = s |
| ox = (out_size - float(src_size[0]) * s) / 2.0 |
| oy = (out_size - float(src_size[1]) * s) / 2.0 |
| else: |
| sx = sy = 1.0 |
| ox = oy = 0.0 |
|
|
| yy, xx = torch.meshgrid( |
| torch.arange(out_size, device=device, dtype=dtype), |
| torch.arange(out_size, device=device, dtype=dtype), |
| indexing="ij", |
| ) |
| two_s2 = 2.0 * float(sigma) * float(sigma) |
| for i, point in enumerate(points): |
| x = float(point[0]) * sx + ox |
| y = float(point[1]) * sy + oy |
| if not (0 <= x < out_size and 0 <= y < out_size): |
| continue |
| w = float(weights[i]) if weights is not None else 1.0 |
| hm[0] += w * torch.exp(-((xx - x) ** 2 + (yy - y) ** 2) / two_s2) |
| return hm.clamp(0.0, 1.0) |
|
|
|
|
| def conditioning_from_sidecar( |
| sidecar: dict, |
| out_size: int, |
| channels: Sequence[str] = ("quality", "minutiae"), |
| prefix: str = "latent", |
| sigma_minutiae: float = 3.0, |
| sigma_singular: float = 6.0, |
| image: torch.Tensor | None = None, |
| device: torch.device | str = "cpu", |
| dtype: torch.dtype = torch.float32, |
| ) -> torch.Tensor: |
| """Build a registered conditioning tensor ``(len(channels), out, out)`` from a |
| pair-annotation sidecar (see scripts/extract_pair_annotations.py). |
| |
| Channels are registered to the image of the chosen side via its frame: |
| ``prefix="latent"`` (default) uses ``latent_frame`` / ``latent_minutiae`` ...; |
| ``prefix="mate"`` uses ``mate_frame`` / ``mate_minutiae`` ... so the mate |
| conditioning is registered to the *mate* image. ``channels`` may include |
| ``"quality"`` (dense 9.308 grid, latent-only -> zeros for mate), ``"minutiae"``, |
| ``"core"``, ``"delta"``. |
| """ |
| frame = sidecar.get(f"{prefix}_frame") |
| src = (float(frame[0]), float(frame[1])) if frame else None |
| kw = dict(out_size=out_size, device=device, dtype=dtype) |
| field = { |
| "minutiae": f"{prefix}_minutiae", |
| "core": f"{prefix}_cores", |
| "delta": f"{prefix}_deltas", |
| } |
| maps = [] |
| for ch in channels: |
| if ch == "quality": |
| maps.append(render_quality_grid(sidecar.get(f"{prefix}_quality_grid", ""), **kw)) |
| elif ch == "orientation": |
| |
| |
| if image is None: |
| maps.append(torch.zeros(2, out_size, out_size, device=device, dtype=dtype)) |
| else: |
| of = compute_orientation_field(image.to(device=device, dtype=dtype)) |
| of = F.interpolate(of, size=(out_size, out_size), mode="bilinear", align_corners=False) |
| maps.append(of[0]) |
| elif ch in field: |
| sigma = sigma_minutiae if ch == "minutiae" else sigma_singular |
| maps.append(render_gaussian_points( |
| sidecar.get(field[ch], []), src_size=src, sigma=sigma, **kw)) |
| else: |
| raise ValueError(f"Unknown conditioning channel: {ch}") |
| return torch.cat(maps, dim=0) |
|
|
|
|
| def cond_channel_count(channels: Sequence[str]) -> int: |
| """Number of tensor channels produced for a channel list (orientation = 2).""" |
| return sum(2 if c == "orientation" else 1 for c in channels) |
|
|
|
|
| def build_structural_conditioning( |
| minutiae: Sequence[Point], |
| cores: Sequence[Point], |
| deltas: Sequence[Point], |
| out_size: int, |
| src_size: tuple[float, float] | None = None, |
| sigma_minutiae: float = 3.0, |
| sigma_singular: float = 6.0, |
| orientation: bool = False, |
| device: torch.device | str = "cpu", |
| dtype: torch.dtype = torch.float32, |
| ) -> torch.Tensor: |
| """Stack annotation channels into a conditioning tensor ``(C, out, out)``. |
| |
| Base channels: ``[minutiae, core, delta]``. Singular points use a larger |
| sigma because they are coarse, low-count topological landmarks. With |
| ``orientation=True`` two extra channels encode minutiae direction as |
| ``sin``/``cos`` weighted heatmaps (theta in degrees from the 3rd column), |
| giving the critic ridge-flow context. Channel order matches |
| :func:`structural_channel_names`. |
| """ |
| kw = dict(out_size=out_size, src_size=src_size, device=device, dtype=dtype) |
| channels = [ |
| render_gaussian_points(minutiae, sigma=sigma_minutiae, **kw), |
| render_gaussian_points(cores, sigma=sigma_singular, **kw), |
| render_gaussian_points(deltas, sigma=sigma_singular, **kw), |
| ] |
| if orientation: |
| import math |
|
|
| sin_w = [math.sin(math.radians(float(p[2]))) if len(p) > 2 else 0.0 for p in minutiae] |
| cos_w = [math.cos(math.radians(float(p[2]))) if len(p) > 2 else 0.0 for p in minutiae] |
| channels.append( |
| render_gaussian_points(minutiae, sigma=sigma_minutiae, weights=sin_w, **kw) |
| ) |
| channels.append( |
| render_gaussian_points(minutiae, sigma=sigma_minutiae, weights=cos_w, **kw) |
| ) |
| return torch.cat(channels, dim=0) |
|
|