File size: 6,710 Bytes
86454a3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Delay-embedded image rendering for time series.

Each 1-D series is turned into a (H, W) matrix by sliding a fixed-length window across the
series with a step proportional to (length - window) / (W - 1). The window
values are interpolated to fill H rows. The 2-D map is normalised to [0, 1]
using min/max and broadcast across 3 channels.

Output is a **(3, H, W) float32 tensor in [0, 1]** — no uint8 quantisation,
no PIL round-trip. The collators feed it straight to the DINO normaliser
((x - mean) / std) without any /255 step. `delay_tensor_to_pil` is provided
for visualisation paths that still want a PIL image.

Normalisation scope:
  - Default: per-series (uses the current 1-D array's own min/max).
  - With `vmin/vmax` overrides (`channel_stats`, a {field -> {vmin, vmax}}
    JSON given explicitly via `channel_stats_path`): the per-channel min/max
    is fixed across the dataset. Off in every shipped config and evaluation.

For TimeSeriesExam examples that carry multiple series (ts1+ts2), we emit one
delay-embedded tensor per series (paired examples -> 2 tensors).
"""

from __future__ import annotations

import math
import os
from typing import Dict, List, Optional, Tuple

import numpy as np
import torch
from PIL import Image

from src import paths

DEFAULT_HW = 256
DEFAULT_EMBED_RATIO = 0.6
DEFAULT_EMBED_LMIN = 48
DEFAULT_EMBED_LMAX = 192


def _to_1d(ts) -> np.ndarray:
    arr = np.asarray(ts, dtype=np.float32).squeeze()
    if arr.ndim == 0:
        arr = arr[None]
    if arr.ndim != 1:
        raise ValueError(f"expected 1-D series, got shape {arr.shape}")
    return arr


def _delay_embed_2d(
    x: np.ndarray,
    height: int,
    width: int,
    embed_ratio: float,
    embed_lmin: int,
    embed_lmax: int,
) -> np.ndarray:
    if x.size == 0:
        x = np.zeros(height, dtype=np.float32)

    length = int(x.size)
    raw_l = int(math.floor(embed_ratio * length))
    l = min(embed_lmax, max(embed_lmin, raw_l))
    l = max(1, min(l, length))

    max_start = max(0, length - l)
    delay = (max_start / float(width - 1)) if width > 1 else 0.0

    out = np.empty((height, width), dtype=np.float32)
    src = np.linspace(0.0, 1.0, num=l, dtype=np.float32)
    dst = np.linspace(0.0, 1.0, num=height, dtype=np.float32)

    for col in range(width):
        start = int(round(col * delay))
        if start > max_start:
            start = max_start
        window = x[start : start + l]
        if l == height:
            out[:, col] = window
        elif l == 1:
            out[:, col] = window[0]
        else:
            out[:, col] = np.interp(dst, src, window).astype(np.float32)
    return out


def render_single_delay(
    ts,
    base_height: int = DEFAULT_HW,
    base_width: int = DEFAULT_HW,
    embed_ratio: float = DEFAULT_EMBED_RATIO,
    embed_lmin: int = DEFAULT_EMBED_LMIN,
    embed_lmax: int = DEFAULT_EMBED_LMAX,
    vmin: Optional[float] = None,
    vmax: Optional[float] = None,
) -> torch.Tensor:
    """Render one 1-D series to a delay-embedded RGB tensor in float32 [0, 1].

    Returns a (3, H, W) float32 tensor. No uint8 quantisation, no PIL — the
    collators apply DINO's (x - mean) / std directly.

    If `vmin` and `vmax` are provided, use them for the [0,1] rescale (matches
    the original repo, where per-channel min/max are computed once across the
    whole dataset). Otherwise fall back to per-series min/max.
    """
    x = _to_1d(ts)
    if vmin is None or vmax is None:
        vmin = float(np.nanmin(x)) if np.any(np.isfinite(x)) else 0.0
        vmax = float(np.nanmax(x)) if np.any(np.isfinite(x)) else 1.0
    if vmax > vmin:
        xn = (x - vmin) / (vmax - vmin)
    else:
        xn = np.zeros_like(x)
    xn = np.clip(xn, 0.0, 1.0)

    x2d = _delay_embed_2d(xn, base_height, base_width, embed_ratio, embed_lmin, embed_lmax)
    # (H, W) -> (3, H, W) float32 in [0, 1]
    t = torch.from_numpy(x2d).to(torch.float32)
    return t.unsqueeze(0).expand(3, -1, -1).contiguous()


def render_for_example_delay(
    example: dict,
    channel_stats: Optional[Dict[str, Dict[str, float]]] = None,
    **kwargs,
) -> List[torch.Tensor]:
    """Return one (3, H, W) float32 tensor per time-series in the example.

    Inspects standard TimeSeriesExam fields:
      - `ts`         -> [one tensor]
      - `ts1`,`ts2`  -> [t1, t2]
      - fallback: any field whose value looks like a numeric 1-D series.

    `channel_stats`, when given, maps field name -> {"vmin": float, "vmax": float}
    and overrides the per-series min/max so all examples share a per-field scale.
    """
    ts1 = example.get("ts1")
    ts2 = example.get("ts2")
    ts = example.get("ts")

    named_series: List[Tuple[str, object]] = []
    if ts1 is not None and len(ts1):
        named_series.append(("ts1", ts1))
    if ts2 is not None and len(ts2):
        named_series.append(("ts2", ts2))
    if not named_series and ts is not None and len(ts):
        named_series.append(("ts", ts))

    if not named_series:
        for k, v in example.items():
            if isinstance(v, (list, tuple)) and len(v) > 8 and all(
                isinstance(x, (int, float)) for x in v[:5]
            ):
                named_series.append((k, v))

    images: List[torch.Tensor] = []
    for name, s in named_series:
        if channel_stats and name in channel_stats:
            stats = channel_stats[name]
            images.append(render_single_delay(
                s, vmin=stats["vmin"], vmax=stats["vmax"], **kwargs,
            ))
        else:
            images.append(render_single_delay(s, **kwargs))
    return images


def delay_tensor_to_pil(t: torch.Tensor) -> Image.Image:
    """Visualisation helper: (3, H, W) float32 [0, 1] tensor -> PIL.RGB.

    Used by preview / sample-HTML scripts that need a PIL image for `.save()`.
    Loses the same precision the old pipeline did; the training path never
    touches this — it stays in float."""
    if t.ndim == 3 and t.shape[0] == 3:
        arr = t.permute(1, 2, 0).cpu().float().clamp(0.0, 1.0).numpy()
    elif t.ndim == 2:
        arr = t.cpu().float().clamp(0.0, 1.0).numpy()
        arr = np.stack([arr] * 3, axis=-1)
    else:
        raise ValueError(f"unexpected shape: {tuple(t.shape)}")
    return Image.fromarray((arr * 255.0).astype(np.uint8))


if __name__ == "__main__":
    import os
    out = paths.resolve("results/preview_delay")
    os.makedirs(out, exist_ok=True)
    rng = np.random.default_rng(0)
    for i in range(3):
        ts = np.cumsum(rng.standard_normal(256)) + np.sin(np.linspace(0, 6 * np.pi, 256))
        t = render_single_delay(ts)
        delay_tensor_to_pil(t).save(f"{out}/delay_{i}.jpg")
    print(f"Wrote delay-embedding previews to {out}")