Download source/src/deg/ground.py from gcoderw/DEG-5B-E2B-1019: direct link, hf CLI and curl.
- Browser
- Download file 15.4 kB
-
https://huggingface.co/gcoderw/DEG-5B-E2B-1019/resolve/main/source/src/deg/ground.py
- Command line
-
hf download hf://gcoderw/DEG-5B-E2B-1019/source/src/deg/ground.py
-
curl -L -o ground.py https://huggingface.co/gcoderw/DEG-5B-E2B-1019/resolve/main/source/src/deg/ground.py
15.4 kB
| """Grounded extraction on multimodal contexts: an extract field whose value is in a media | |
| item says where, as text extraction says where with ``meta.span``. No schema annotation: | |
| a field's mode follows from its type (SPEC.md §3); whether an extract field points into | |
| text or into media follows from where its pointer lands in the context. | |
| The span head (:class:`deg.model.SpanHead`) already scores every context token, the media | |
| **soft tokens** included: | |
| * image: the ~260 pooled soft tokens are a ``gw x gh`` grid in raster order (Gemma 4 pools | |
| 3x3 patches of 16 px of the aspect-preserving resize, :func:`image_grid`). ``start`` is | |
| the box's **top-left** cell and ``end`` its **bottom-right** cell, so ``start <= end`` | |
| in raster order (the span head's own order) and two pointers give a rectangle; | |
| :func:`cells_to_box` maps them back to pixels of the source image. | |
| * audio: one soft token per :data:`AUDIO_MS` ms; ``start``/``end`` are the first and last | |
| token of the window, :func:`tokens_to_time` gives seconds. | |
| :func:`landing` decides text vs media vs null from the start pointer's probability mass. | |
| A media landing has no text to copy, so the value is **read from the region**: the | |
| region (padded, :func:`crop_box`) is cut from the source and encoded again by the frozen | |
| towers as the only media item of a one-field x-generate fill (:func:`read_schema`), and | |
| the text is parsed by the field's mode like a copied span. At the image budget of a whole | |
| receipt one soft token covers ~48x48 resized px (a printed word is often smaller); a crop | |
| at the same budget is read at several times the resolution, which a region-masked | |
| attention bias cannot add, and it needs no change to the fork kernels or decode graphs. | |
| Crop-read records come from gold boxes (:mod:`deg.datagen.mm_ground`). | |
| """ | |
| from __future__ import annotations | |
| import hashlib | |
| import math | |
| from dataclasses import dataclass | |
| from typing import Any | |
| import torch | |
| from torch import Tensor | |
| PATCH = 16 | |
| POOL = 3 | |
| SOFT_BUDGETS = (70, 140, 280, 560, 1120) # Gemma 4 image processor's supported budgets | |
| AUDIO_MS = 40 # processor_config audio_ms_per_token | |
| CROP_PAD = 0.15 # crop margin: share of the box side, each side | |
| CROP_MIN_PX = 48 # crops are at least this wide/high in source pixels | |
| AUDIO_PAD_S = 0.5 | |
| # --------------------------------------------------------------------------- geometry | |
| def resized_size(width: int, height: int, max_soft_tokens: int, patch: int = PATCH, | |
| pool: int = POOL) -> tuple[int, int]: | |
| """(target width, target height) of Gemma 4's aspect-preserving resize (HF | |
| ``get_aspect_ratio_preserving_size``): the largest multiple of ``pool * patch`` per side | |
| with at most ``max_soft_tokens * pool**2`` patches.""" | |
| max_patches = max_soft_tokens * pool * pool | |
| factor = math.sqrt(max_patches * patch * patch / (height * width)) | |
| side = pool * patch | |
| th = int(math.floor(factor * height / side)) * side | |
| tw = int(math.floor(factor * width / side)) * side | |
| if th == 0 and tw == 0: | |
| raise ValueError(f"image {width}x{height} resizes to 0x0") | |
| longest = (max_patches // (pool * pool)) * side | |
| if th == 0: | |
| th, tw = side, min(int(math.floor(width / height)) * side, longest) | |
| elif tw == 0: | |
| tw, th = side, min(int(math.floor(height / width)) * side, longest) | |
| return tw, th | |
| class Grid: | |
| """Pooled soft-token grid of one image: ``gw x gh`` cells in raster order, each | |
| ``cell_w x cell_h`` source pixels.""" | |
| gw: int | |
| gh: int | |
| width: int # source image | |
| height: int | |
| def n(self) -> int: | |
| return self.gw * self.gh | |
| def cell_w(self) -> float: | |
| return self.width / self.gw | |
| def cell_h(self) -> float: | |
| return self.height / self.gh | |
| def image_grid(width: int, height: int, max_soft_tokens: int) -> Grid: | |
| tw, th = resized_size(width, height, max_soft_tokens) | |
| return Grid(tw // (PATCH * POOL), th // (PATCH * POOL), int(width), int(height)) | |
| def grid_for(width: int, height: int, n: int, prefer: int | None = None) -> Grid: | |
| """The grid of an image whose embedding has ``n`` soft tokens (the budget that produced | |
| it: ``prefer`` first, then every supported one).""" | |
| for budget in ((prefer,) if prefer else ()) + SOFT_BUDGETS: | |
| g = image_grid(width, height, budget) | |
| if g.n == n: | |
| return g | |
| raise ValueError(f"no soft-token budget gives {n} tokens for a {width}x{height} image") | |
| def cells_to_box(i: int, j: int, g: Grid) -> list[float]: | |
| """Top-left cell ``i`` and bottom-right cell ``j`` (raster indices) -> pixel box | |
| ``[x0, y0, x1, y1]`` in the source image.""" | |
| (y0, x0), (y1, x1) = divmod(i, g.gw), divmod(j, g.gw) | |
| x0, x1 = min(x0, x1), max(x0, x1) | |
| y0, y1 = min(y0, y1), max(y0, y1) | |
| return [round(x0 * g.cell_w, 1), round(y0 * g.cell_h, 1), | |
| round(min(g.width, (x1 + 1) * g.cell_w), 1), | |
| round(min(g.height, (y1 + 1) * g.cell_h), 1)] | |
| def box_to_cells(box: Any, g: Grid) -> tuple[int, int]: | |
| """Pixel box -> (top-left cell, bottom-right cell): the cells its corners fall in.""" | |
| x0, y0, x1, y1 = (float(v) for v in box) | |
| eps = 1e-6 | |
| def col(x: float) -> int: | |
| return min(g.gw - 1, max(0, int(x / g.cell_w))) | |
| def row(y: float) -> int: | |
| return min(g.gh - 1, max(0, int(y / g.cell_h))) | |
| c0, r0 = col(x0), row(y0) | |
| c1, r1 = col(max(x0, x1 - eps)), row(max(y0, y1 - eps)) | |
| return r0 * g.gw + c0, r1 * g.gw + c1 | |
| def tokens_to_time(i: int, j: int, seconds: float | None = None, ms: int = AUDIO_MS | |
| ) -> list[float]: | |
| s, e = i * ms / 1000.0, (j + 1) * ms / 1000.0 | |
| if seconds is not None: | |
| s, e = min(s, seconds), min(e, seconds) | |
| return [round(s, 3), round(e, 3)] | |
| def time_to_tokens(window: Any, n: int, ms: int = AUDIO_MS) -> tuple[int, int]: | |
| s, e = float(window[0]), float(window[1]) | |
| i = min(n - 1, max(0, int(s * 1000 // ms))) | |
| j = min(n - 1, max(i, int(math.ceil(e * 1000 / ms)) - 1)) | |
| return i, j | |
| # --------------------------------------------------------------------------- media in context | |
| class MediaSpan: | |
| """One media item of an encoded context: its soft tokens are context tokens | |
| ``[lo, lo + n)``; ``grid`` (image) or ``seconds`` (audio) map pointers to the source.""" | |
| id: str | |
| kind: str | |
| lo: int | |
| n: int | |
| grid: Grid | None = None | |
| seconds: float | None = None | |
| def media_spans(ectx: Any, parts: list[Any], max_soft_tokens: int | None = None | |
| ) -> list[MediaSpan]: | |
| """The media of ``ectx`` (rendered from ``parts``) with their geometry. An image part | |
| without a ``size`` cannot be mapped to pixels and is left out.""" | |
| from deg.model import Media | |
| media = [p for p in parts if isinstance(p, Media)] | |
| out = [] | |
| for k, ((start, emb), part) in enumerate(zip(ectx.media, media)): | |
| n, lo = int(emb.shape[0]), start - ectx.ctx_start | |
| mid = part.id or f"m{k}" | |
| if part.kind == "image": | |
| if part.size is None: | |
| continue | |
| out.append(MediaSpan(mid, "image", lo, n, grid_for(*part.size, n, max_soft_tokens))) | |
| else: | |
| out.append(MediaSpan(mid, "audio", lo, n, seconds=part.seconds)) | |
| return out | |
| def located(span: MediaSpan, i: int, j: int) -> dict[str, Any]: | |
| """Pointer cells (item-relative) -> the response's ground entry.""" | |
| out: dict[str, Any] = {"media": span.id, "kind": span.kind, "tokens": [i, j]} | |
| if span.kind == "image": | |
| assert span.grid is not None | |
| out["box"] = cells_to_box(i, j, span.grid) | |
| else: | |
| out["time"] = tokens_to_time(i, j, span.seconds) | |
| return out | |
| def ground_target(span: MediaSpan, gold: dict[str, Any]) -> tuple[int, int] | None: | |
| """A gold ``{box}`` / ``{time}`` -> context-token (start, end) of ``span``.""" | |
| if span.kind == "image" and gold.get("box") is not None and span.grid is not None: | |
| i, j = box_to_cells(gold["box"], span.grid) | |
| elif span.kind == "audio" and gold.get("time") is not None: | |
| i, j = time_to_tokens(gold["time"], span.n) | |
| else: | |
| return None | |
| return span.lo + i, span.lo + j | |
| def soft_mask(spans: list[MediaSpan], T: int, device: Any = None) -> Tensor: | |
| """[T + 1] bool: the soft tokens of ``spans`` and the null slot.""" | |
| m = torch.zeros(T + 1, dtype=torch.bool, device=device) | |
| for s in spans: | |
| m[s.lo:s.lo + s.n] = True | |
| m[T] = True | |
| return m | |
| def landing(start: Tensor, spans: list[MediaSpan], temperature: Any = 1.0) -> str: | |
| """Where an extract pointer lands: ``"media"`` when the start pointer's probability | |
| mass on the media soft tokens beats both the text tokens' and null's, else | |
| ``"text"`` (the text path, null included, decides as without media).""" | |
| T = start.shape[0] - 1 | |
| ls = torch.log_softmax(start.float() / temperature, -1) | |
| soft = soft_mask(spans, T, start.device) | |
| soft[T] = False | |
| text = ~soft | |
| text[T] = False | |
| m = torch.logsumexp(ls[soft], 0) | |
| t = torch.logsumexp(ls[text], 0) if bool(text.any()) else torch.tensor(float("-inf")) | |
| return "media" if float(m) > max(float(t), float(ls[T])) else "text" | |
| def text_only(logits: Tensor, spans: list[MediaSpan]) -> Tensor: | |
| """Span logits with the media soft tokens masked (the text path of a media context).""" | |
| soft = soft_mask(spans, logits.shape[0] - 1, logits.device) | |
| soft[-1] = False | |
| return logits.masked_fill(soft, float("-inf")) | |
| def decode_ground(start: Tensor, end: Tensor, spans: list[MediaSpan], temperature: Any = 1.0 | |
| ) -> tuple[dict[str, Any] | None, float]: | |
| """The best (top-left, bottom-right) pair inside one media item, or None when null | |
| wins; returns (entry, its probability). Logits are renormalized over the soft tokens | |
| of ``spans`` plus null. Image pairs need ``col(end) >= col(start)`` and ``row(end) >= | |
| row(start)``; audio pairs ``end >= start``.""" | |
| T = start.shape[0] - 1 | |
| keep = soft_mask(spans, T, start.device) | |
| ls = torch.log_softmax((start.float() / temperature).masked_fill(~keep, float("-inf")), -1) | |
| le = torch.log_softmax((end.float() / temperature).masked_fill(~keep, float("-inf")), -1) | |
| best, best_score = None, float(ls[T] + le[T]) | |
| for s in spans: | |
| a, b = ls[s.lo:s.lo + s.n], le[s.lo:s.lo + s.n] | |
| score = a[:, None] + b[None, :] | |
| idx = torch.arange(s.n, device=score.device) | |
| if s.kind == "image": | |
| assert s.grid is not None | |
| col, row = idx % s.grid.gw, idx // s.grid.gw | |
| ok = (col[None, :] >= col[:, None]) & (row[None, :] >= row[:, None]) | |
| else: | |
| ok = idx[None, :] >= idx[:, None] | |
| score = score.masked_fill(~ok, float("-inf")) | |
| v, f = score.flatten().max(0) | |
| if float(v) > best_score: | |
| best_score = float(v) | |
| best = located(s, int(f) // s.n, int(f) % s.n) | |
| return best, min(1.0, math.exp(best_score)) | |
| # --------------------------------------------------------------------------- crop re-read | |
| def crop_box(box: Any, width: int, height: int, pad: float = CROP_PAD, | |
| min_px: int = CROP_MIN_PX) -> tuple[int, int, int, int]: | |
| """Integer crop ``(x0, y0, x1, y1)`` around ``box``: ``pad`` of each side added, at | |
| least ``min_px`` per side, clipped to the image.""" | |
| x0, y0, x1, y1 = (float(v) for v in box) | |
| w, h = max(x1 - x0, 1.0), max(y1 - y0, 1.0) | |
| px, py = max(pad * w, (min_px - w) / 2), max(pad * h, (min_px - h) / 2) | |
| return (max(0, int(math.floor(x0 - px))), max(0, int(math.floor(y0 - py))), | |
| min(int(width), int(math.ceil(x1 + px))), min(int(height), int(math.ceil(y1 + py)))) | |
| def crop_window(window: Any, seconds: float | None, pad: float = AUDIO_PAD_S | |
| ) -> tuple[float, float]: | |
| s, e = float(window[0]) - pad, float(window[1]) + pad | |
| return max(0.0, s), (e if seconds is None else min(seconds, e)) | |
| def crop_sha(sha: str, region: Any) -> str: | |
| """Content key of a crop: its source's sha256 and the integer/ms region (train crops | |
| from gold boxes and inference crops from predicted boxes share the media cache).""" | |
| return hashlib.sha256(f"{sha}:{list(region)}".encode()).hexdigest() | |
| def crop_item(item: dict[str, Any], entry: dict[str, Any]) -> dict[str, Any]: | |
| """An in-memory media item (``data``: PIL image / float32 waveform) of ``entry``'s | |
| region of ``item`` (a record media item with ``path``).""" | |
| from deg.media import SAMPLE_RATE, item_sha, load_audio, load_image | |
| sha = item_sha(item) | |
| if item["kind"] == "image": | |
| im = load_image(item["path"]) | |
| region = crop_box(entry["box"], im.width, im.height) | |
| return {"id": "crop", "kind": "image", "data": im.crop(region), | |
| "sha256": crop_sha(sha, region), "width": region[2] - region[0], | |
| "height": region[3] - region[1]} | |
| x = load_audio(item["path"]) | |
| s, e = crop_window(entry["time"], len(x) / SAMPLE_RATE) | |
| region = (int(round(s * 1000)), int(round(e * 1000))) | |
| clip = x[int(s * SAMPLE_RATE):int(e * SAMPLE_RATE)] | |
| return {"id": "crop", "kind": "audio", "data": clip, "sha256": crop_sha(sha, region), | |
| "duration_s": len(clip) / SAMPLE_RATE} | |
| READ_MAX_LENGTH = 200 | |
| def read_schema(fld: Any) -> tuple[dict[str, Any], str]: | |
| """The one-field schema a region is read with (inference and crop-read training | |
| records alike): the extract leaf's key as an x-generate string with the leaf's own | |
| title/description; the read text is then parsed by the leaf's mode.""" | |
| key = next((p for p in reversed(fld.path) if isinstance(p, str)), "value") | |
| leaf: dict[str, Any] = {"type": "string", "x-generate": True, | |
| "maxLength": int(fld.constraints.get("maxLength", READ_MAX_LENGTH))} | |
| for k in ("title", "description"): | |
| if getattr(fld, k): | |
| leaf[k] = getattr(fld, k) | |
| return {"type": "object", "properties": {key: leaf}, "required": [key], | |
| "additionalProperties": False}, key | |
| # --------------------------------------------------------------------------- metrics | |
| def box_iou(a: Any, b: Any) -> float: | |
| ix = max(0.0, min(a[2], b[2]) - max(a[0], b[0])) | |
| iy = max(0.0, min(a[3], b[3]) - max(a[1], b[1])) | |
| inter = ix * iy | |
| area = (a[2] - a[0]) * (a[3] - a[1]) + (b[2] - b[0]) * (b[3] - b[1]) - inter | |
| return inter / area if area > 0 else 0.0 | |
| def box_cover(pred: Any, gold: Any) -> float: | |
| """Share of the gold box inside the predicted one (containment).""" | |
| ix = max(0.0, min(pred[2], gold[2]) - max(pred[0], gold[0])) | |
| iy = max(0.0, min(pred[3], gold[3]) - max(pred[1], gold[1])) | |
| g = (gold[2] - gold[0]) * (gold[3] - gold[1]) | |
| return ix * iy / g if g > 0 else 0.0 | |
| def center_in(pred: Any, gold: Any) -> bool: | |
| cx, cy = (gold[0] + gold[2]) / 2, (gold[1] + gold[3]) / 2 | |
| return pred[0] <= cx <= pred[2] and pred[1] <= cy <= pred[3] | |
| def time_iou(a: Any, b: Any) -> float: | |
| inter = max(0.0, min(a[1], b[1]) - max(a[0], b[0])) | |
| union = max(a[1], b[1]) - min(a[0], b[0]) | |
| return inter / union if union > 0 else 0.0 | |
| def time_cover(pred: Any, gold: Any) -> float: | |
| inter = max(0.0, min(pred[1], gold[1]) - max(pred[0], gold[0])) | |
| g = gold[1] - gold[0] | |
| return inter / g if g > 0 else 0.0 | |