File size: 12,353 Bytes
dfc2650
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
from __future__ import annotations

from collections.abc import Sequence

import torch
import torch.nn.functional as F

Point = Sequence[float]  # (x, y) or (x, y, theta_degrees)

RS = "\x1e"

# Pattern-class vocabulary (EFS 9.307): whorl / loops / arches / unclassifiable.
# Mapped to a fixed index for global one-hot conditioning. Sub-codes (e.g. "LS",
# "RS", "WU", "AU", "TA", "PA") collapse to their family by first letter group.
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  # whorl (WU, etc.)
    if c in ("LS", "RS") or c.startswith("L") or c.startswith("R"):
        return 1  # loop (left/right slant)
    if c.startswith("A") or c in ("TA", "PA"):
        return 2  # arch (tented/plain)
    return 3  # UC / unclassifiable / other


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)
    # Replicate-pad before Sobel: zero padding fabricates strong gradients along
    # the image border (a flat/blank image otherwise gets a high-coherence ring,
    # which leaks through the foreground gate and pollutes orientation losses).
    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
    # Epsilon INSIDE the sqrt: d/dx sqrt(x) -> inf at x=0, so sqrt(0)+eps still
    # backprops NaN on exactly-flat regions. This function runs on generator
    # OUTPUTS (regen orientation loss, equalized critic cond), and the foreground
    # gate makes outputs exactly equal flat inputs there — sqrt(0) killed a full
    # training run (every weight NaN within epoch 0) before this guard.
    mag = torch.sqrt(cos2 * cos2 + sin2 * sin2 + 1e-12) + 1e-6
    coh = mag / (gxx + gyy + 1e-6)  # coherence in [0,1]
    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)
    # pad_square-aware (match resize_pad_square): the grid spans the frame, so its aspect
    # = frame aspect -> uniform-resize the longer side then center-pad, NOT anisotropic
    # stretch (which misregistered the quality/ROI mask vs the padded latent image).
    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

    # pad_square-aware mapping: MATCH FingerprintTransform.resize_pad_square (uniform
    # scale of the longer side + centered pad), NOT an anisotropic stretch. The old
    # anisotropic (out/W, out/H) misregistered minutiae vs the padded image by mean ~8px
    # (up to 38px, 14% of pairs >15px tol) on non-square latents -> the minutiae anchor
    # rewarded ridge energy at the WRONG pixels.
    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":
            # 2-channel dense ridge-flow field, computed from the image itself
            # (registered). Requires `image` (B=1,1,H,W) in [0,1].
            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)