from __future__ import annotations

import argparse
import colorsys
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from typing import Any

import numpy as np
from scipy.ndimage import gaussian_filter

from .io import read_json, save_image, write_json, write_jsonl


@dataclass
class SynthConfig:
    n: int = 5000
    T: int = 220
    X: int = 180
    T_min: int = 0
    T_max: int = 0
    X_min: int = 0
    X_max: int = 0
    seed: int = 123
    preview_every: int = 250
    moving_min: int = 7
    moving_max: int = 16
    static_min: int = 2
    static_max: int = 6
    diffusive_min: int = 2
    diffusive_max: int = 8
    biased_fraction: float = 0.7
    pause_prob_moving: float = 0.65
    pause_prob_diffusive: float = 0.45
    pause_events_min: int = 1
    pause_events_max: int = 3
    pause_dur_min: int = 4
    pause_dur_max: int = 24
    pause_jitter: float = 0.06
    frame_rate_scale: float = 1.1
    fast_track_fraction: float = 0.55
    fast_track_extra: int = 2
    fast_short_fraction: float = 0.88
    fast_short_span_min: int = 5
    fast_short_span_max: int = 12
    crossing_pairs_max: int = 2

    moving_amp_min: float = 0.30
    moving_amp_max: float = 1.15
    static_amp_min: float = 0.20
    static_amp_max: float = 0.90
    diffusive_amp_min: float = 0.18
    diffusive_amp_max: float = 0.95
    track_amp_scale_min: float = 1.00
    track_amp_scale_max: float = 1.45

    sigma_x_min: float = 0.45
    sigma_x_max: float = 0.90
    thickness_min: int = 1
    thickness_max: int = 2

    blink_moving_min: float = 0.0
    blink_moving_max: float = 0.005
    blink_static_min: float = 0.0
    blink_static_max: float = 0.006
    blink_diffusive_min: float = 0.0
    blink_diffusive_max: float = 0.008
    blink_crossing_min: float = 0.0
    blink_crossing_max: float = 0.006

    bleach_tau_mult_min: float = 10.0
    bleach_tau_mult_max: float = 24.0
    gain_osc_amp_min: float = 0.005
    gain_osc_amp_max: float = 0.025
    gain_osc_cycles_min: float = 0.4
    gain_osc_cycles_max: float = 1.3
    gain_noise_amp_min: float = 0.003
    gain_noise_amp_max: float = 0.015
    gain_clip_min: float = 0.93
    gain_clip_max: float = 1.08

    bg_offset_min: float = 0.05
    bg_offset_max: float = 0.24
    bg_grad_t_abs_max: float = 0.06
    bg_grad_x_abs_max: float = 0.06
    texture_sigma_t: float = 9.0
    texture_sigma_x: float = 9.0
    texture_amp_min: float = 0.003
    texture_amp_max: float = 0.02
    texture_row_amp_min: float = 0.003
    texture_row_amp_max: float = 0.018
    texture_col_amp_min: float = 0.002
    texture_col_amp_max: float = 0.012
    texture_col_mid_amp_min: float = 0.001
    texture_col_mid_amp_max: float = 0.010
    texture_col_hf_amp_min: float = 0.0005
    texture_col_hf_amp_max: float = 0.007
    texture_col_mid_sigma_min: float = 1.5
    texture_col_mid_sigma_max: float = 4.5
    texture_col_hf_sigma_min: float = 0.30
    texture_col_hf_sigma_max: float = 1.20
    texture_col_time_var_min: float = 0.03
    texture_col_time_var_max: float = 0.20
    x_stripe_lf_amp_min: float = 0.010
    x_stripe_lf_amp_max: float = 0.060
    x_stripe_hf_amp_min: float = 0.003
    x_stripe_hf_amp_max: float = 0.020
    x_stripe_vlf_amp_min: float = 0.004
    x_stripe_vlf_amp_max: float = 0.020
    x_stripe_vlf_sigma_min: float = 8.0
    x_stripe_vlf_sigma_max: float = 28.0
    x_stripe_lf_sigma_min: float = 24.0
    x_stripe_lf_sigma_max: float = 72.0
    x_stripe_hf_sigma_min: float = 0.20
    x_stripe_hf_sigma_max: float = 0.80
    x_stripe_time_var_min: float = 0.00
    x_stripe_time_var_max: float = 0.04
    texture_2d_frac_min: float = 0.05
    texture_2d_frac_max: float = 0.20

    psf_sigma_t_min: float = 0.35
    psf_sigma_t_max: float = 0.90
    psf_sigma_x_min: float = 0.18
    psf_sigma_x_max: float = 0.50

    poisson_gain_min: float = 8.0
    poisson_gain_max: float = 30.0
    read_noise_min: float = 0.005
    read_noise_max: float = 0.035
    noise_speck_count_min: int = 12
    noise_speck_count_max: int = 30
    noise_speck_len_min: float = 1.2
    noise_speck_len_max: float = 4.8
    noise_speck_amp_min: float = 0.020
    noise_speck_amp_max: float = 0.080
    noise_speck_sigma_min: float = 0.35
    noise_speck_sigma_max: float = 0.90
    gamma_min: float = 0.8
    gamma_max: float = 1.3
    image_dtype: str = "u8"


def _rand_uniform(rng: np.random.Generator, lo: float, hi: float) -> float:
    a = float(min(lo, hi))
    b = float(max(lo, hi))
    if abs(a - b) <= 1e-12:
        return float(a)
    return float(rng.uniform(a, b))


def _rand_int_inclusive(rng: np.random.Generator, lo: int, hi: int) -> int:
    a = int(min(lo, hi))
    b = int(max(lo, hi))
    if a == b:
        return int(a)
    return int(rng.integers(a, b + 1))


def _sample_shape(config: SynthConfig, rng: np.random.Generator) -> tuple[int, int]:
    t_lo = int(config.T_min) if int(config.T_min) > 0 else int(config.T)
    t_hi = int(config.T_max) if int(config.T_max) > 0 else int(config.T)
    x_lo = int(config.X_min) if int(config.X_min) > 0 else int(config.X)
    x_hi = int(config.X_max) if int(config.X_max) > 0 else int(config.X)

    t_lo = max(16, min(t_lo, t_hi))
    t_hi = max(16, max(t_lo, t_hi))
    x_lo = max(16, min(x_lo, x_hi))
    x_hi = max(16, max(x_lo, x_hi))
    return _rand_int_inclusive(rng, t_lo, t_hi), _rand_int_inclusive(rng, x_lo, x_hi)


def _apply_profile_overrides(cfg: SynthConfig, profile_path: str | Path) -> SynthConfig:
    payload = read_json(profile_path)
    overrides = payload.get("synth_overrides", payload) if isinstance(payload, dict) else {}
    if not isinstance(overrides, dict):
        raise ValueError(f"Profile JSON must be an object (or contain synth_overrides object): {profile_path}")

    allowed = {f.name: f for f in fields(SynthConfig)}
    unknown = sorted(k for k in overrides.keys() if k not in allowed)
    if unknown:
        print(f"[warn] ignoring unknown synth override keys: {unknown}", flush=True)

    cfg_dict = asdict(cfg)
    for key, raw in overrides.items():
        if key not in allowed:
            continue
        cur = cfg_dict[key]
        if isinstance(cur, bool):
            if isinstance(raw, str):
                val = raw.strip().lower() in {"1", "true", "yes", "y", "on"}
            else:
                val = bool(raw)
        elif isinstance(cur, int):
            val = int(round(float(raw)))
        elif isinstance(cur, float):
            val = float(raw)
        else:
            val = raw
        cfg_dict[key] = val

    return SynthConfig(**cfg_dict)


def _draw_labeled_disk(label: np.ndarray, t: int, x: float, radius: int, cls_id: int) -> None:
    t0 = max(0, t - radius)
    t1 = min(label.shape[0], t + radius + 1)
    x0 = max(0, int(np.floor(x)) - radius)
    x1 = min(label.shape[1], int(np.ceil(x)) + radius + 1)
    if t0 >= t1 or x0 >= x1:
        return
    yy, xx = np.ogrid[t0:t1, x0:x1]
    mask = (yy - t) ** 2 + (xx - x) ** 2 <= radius**2
    # Moving class should override static in overlaps.
    if cls_id == 1:
        label[t0:t1, x0:x1][mask] = 1
    else:
        region = label[t0:t1, x0:x1]
        region[np.logical_and(mask, region == 0)] = cls_id


def _draw_instance_disk(instance: np.ndarray, t: int, x: float, radius: int, instance_id: int) -> None:
    t0 = max(0, t - radius)
    t1 = min(instance.shape[0], t + radius + 1)
    x0 = max(0, int(np.floor(x)) - radius)
    x1 = min(instance.shape[1], int(np.ceil(x)) + radius + 1)
    if t0 >= t1 or x0 >= x1:
        return
    yy, xx = np.ogrid[t0:t1, x0:x1]
    mask = (yy - t) ** 2 + (xx - x) ** 2 <= radius**2
    instance[t0:t1, x0:x1][mask] = np.uint16(instance_id)


def _draw_track_segment(
    *,
    label: np.ndarray,
    instance_map: np.ndarray | None,
    cls_id: int,
    instance_id: int,
    t0: float,
    x0: float,
    t1: float,
    x1: float,
    radius: int,
) -> None:
    dt = float(t1 - t0)
    dx = float(x1 - x0)
    n_steps = int(max(abs(dt), abs(dx)))
    if n_steps <= 1:
        return
    for k in range(1, n_steps):
        a = float(k / n_steps)
        tk = int(round(t0 + a * dt))
        xk = float(x0 + a * dx)
        _draw_labeled_disk(label, t=tk, x=xk, radius=radius, cls_id=cls_id)
        if instance_map is not None and instance_id > 0:
            _draw_instance_disk(instance_map, t=tk, x=xk, radius=max(1, radius), instance_id=int(instance_id))


def _sample_track_window(
    rng: np.random.Generator,
    T: int,
    *,
    short_frac: float = 0.35,
    long_frac: float = 0.25,
    short_min: int = 8,
    short_max: int = 36,
    mid_min: int = 24,
    mid_max: int = 110,
    long_min: int = 85,
    long_max: int = 210,
) -> tuple[int, int]:
    if T <= 4:
        return 0, max(1, T - 1)

    r = float(rng.random())
    if r < float(short_frac):
        d_min, d_max = short_min, short_max
    elif r < float(1.0 - long_frac):
        d_min, d_max = mid_min, mid_max
    else:
        d_min, d_max = long_min, long_max

    d_min = max(3, min(int(d_min), T - 1))
    d_max = max(d_min, min(int(d_max), T - 1))
    span = int(rng.integers(d_min, d_max + 1))

    start_max = max(0, T - span - 1)
    t0 = int(rng.integers(0, start_max + 1)) if start_max > 0 else 0
    t1 = min(T - 1, int(t0 + span))
    return int(t0), int(t1)


def _moving_track_params(
    rng: np.random.Generator,
    T: int,
    X: int,
    *,
    fast_track_fraction: float = 0.55,
    fast_short_fraction: float = 0.88,
    fast_short_span_min: int = 5,
    fast_short_span_max: int = 12,
    frame_rate_scale: float = 1.1,
    force_fast: bool | None = None,
) -> tuple[int, int, float, float, float, float, bool]:
    x_low = 3.0
    x_high = float(X - 4)
    fps_scale = float(max(1e-3, frame_rate_scale))
    is_fast = bool(
        rng.random() < float(np.clip(fast_track_fraction, 0.0, 1.0))
        if force_fast is None
        else force_fast
    )
    if is_fast:
        # Mix short-fast and long-fast modes.
        # short-fast is explicitly favored to increase shallow-angle short runs.
        short_fast = bool(rng.random() < float(np.clip(fast_short_fraction, 0.0, 1.0)))
        if short_fast:
            raw_speed = float(rng.uniform(6.8, 10.2))
        else:
            raw_speed = float(rng.uniform(3.6, 6.0))

        speed = float(raw_speed / fps_scale)
        # Span maps to (frames - 1), so min span 5 => at least 6-frame runs.
        fast_span_min = 5
        max_span_for_speed = int((x_high - x_low) / max(1e-6, speed))

        if short_fast:
            short_min = max(fast_span_min, int(fast_short_span_min))
            short_max = max(short_min, int(fast_short_span_max))
            fast_span_max = min(T - 1, short_max, max_span_for_speed)
            if fast_span_max < short_min:
                t0, t1 = _sample_track_window(
                    rng, T, short_frac=1.0, long_frac=0.0, short_min=5, short_max=8
                )
            else:
                span = int(rng.integers(short_min, fast_span_max + 1))
                start_max = max(0, T - span - 1)
                t0 = int(rng.integers(0, start_max + 1)) if start_max > 0 else 0
                t1 = min(T - 1, int(t0 + span))
        else:
            fast_span_max = min(T - 1, max(fast_span_min, max_span_for_speed))
            if fast_span_max < fast_span_min:
                t0, t1 = _sample_track_window(
                    rng, T, short_frac=1.0, long_frac=0.0, short_min=5, short_max=8
                )
            else:
                long_min = max(fast_span_min, int(0.58 * fast_span_max))
                span = int(rng.integers(long_min, fast_span_max + 1))
                start_max = max(0, T - span - 1)
                t0 = int(rng.integers(0, start_max + 1)) if start_max > 0 else 0
                t1 = min(T - 1, int(t0 + span))
    else:
        t0, t1 = _sample_track_window(
            rng,
            T,
            short_frac=0.42,
            long_frac=0.24,
            short_min=6,
            short_max=34,
            mid_min=20,
            mid_max=max(40, int(0.55 * T)),
            long_min=max(48, int(0.35 * T)),
            long_max=max(72, int(0.90 * T)),
        )
        speed = float(rng.uniform(0.35, 2.20) / fps_scale)
    direction = -1.0 if rng.random() < 0.5 else 1.0
    n_steps = max(2, t1 - t0 + 1)
    max_speed = max(0.06, (x_high - x_low) / max(1, n_steps - 1))
    slope = direction * min(speed, 0.995 * max_speed)

    if slope >= 0:
        x0_min = x_low
        x0_max = x_high - slope * (n_steps - 1)
    else:
        x0_min = x_low - slope * (n_steps - 1)
        x0_max = x_high

    if x0_min > x0_max:
        x0 = float((x_low + x_high) * 0.5)
    else:
        x0 = float(rng.uniform(x0_min, x0_max))

    curvature = 0.0
    amp = float(rng.uniform(0.45, 1.15))
    return t0, t1, x0, slope, curvature, amp, is_fast


def _static_track_params(rng: np.random.Generator, T: int, X: int) -> tuple[int, int, float, float]:
    t0, t1 = _sample_track_window(
        rng,
        T,
        short_frac=0.30,
        long_frac=0.28,
        short_min=8,
        short_max=40,
        mid_min=24,
        mid_max=max(50, int(0.60 * T)),
        long_min=max(50, int(0.38 * T)),
        long_max=max(76, int(0.95 * T)),
    )
    x0 = float(rng.uniform(5, X - 5))
    amp = float(rng.uniform(0.30, 0.90))
    return t0, t1, x0, amp


def _reflect_to_bounds(x: float, low: float, high: float) -> float:
    if high <= low:
        return low
    y = float(x)
    for _ in range(8):
        if y < low:
            y = low + (low - y)
        elif y > high:
            y = high - (y - high)
        else:
            break
    return min(max(y, low), high)


def _simulate_diffusive_path(
    rng: np.random.Generator,
    *,
    t0: int,
    t1: int,
    x0: float,
    drift: float,
    step_sigma: float,
    x_low: float,
    x_high: float,
) -> np.ndarray:
    n = max(1, t1 - t0 + 1)
    xs = np.empty((n,), dtype=np.float32)
    xs[0] = float(_reflect_to_bounds(x0, x_low, x_high))
    for i in range(1, n):
        dx = float(rng.normal(loc=drift, scale=step_sigma))
        xs[i] = float(_reflect_to_bounds(float(xs[i - 1] + dx), x_low, x_high))
    return xs


def _diffusive_track_params(
    rng: np.random.Generator,
    T: int,
    X: int,
    biased_fraction: float,
) -> tuple[int, int, float, float, float, float, str]:
    t0, t1 = _sample_track_window(
        rng,
        T,
        short_frac=0.38,
        long_frac=0.24,
        short_min=8,
        short_max=38,
        mid_min=20,
        mid_max=max(44, int(0.52 * T)),
        long_min=max(42, int(0.32 * T)),
        long_max=max(70, int(0.88 * T)),
    )
    x0 = float(rng.uniform(5, X - 5))

    step_sigma = float(rng.uniform(0.30, 1.20))
    amp = float(rng.uniform(0.28, 0.95))

    if rng.random() < biased_fraction:
        drift_mag = float(rng.uniform(0.15, 0.70))
        drift = drift_mag if rng.random() < 0.5 else -drift_mag
        mode = "biased"
    else:
        drift = 0.0
        mode = "brownian"

    return t0, t1, x0, drift, step_sigma, amp, mode


def _sample_pause_segments(
    rng: np.random.Generator,
    *,
    n_steps: int,
    pause_prob: float,
    events_min: int,
    events_max: int,
    dur_min: int,
    dur_max: int,
) -> list[tuple[int, int]]:
    if n_steps < 6 or pause_prob <= 0.0 or events_max <= 0:
        return []
    if rng.random() >= pause_prob:
        return []

    e_min = max(0, int(events_min))
    e_max = max(e_min, int(events_max))
    n_events = int(rng.integers(e_min, e_max + 1))
    if n_events <= 0:
        return []

    d_min = max(2, int(dur_min))
    d_max = max(d_min, int(dur_max))
    occupied = np.zeros((n_steps,), dtype=bool)
    pauses: list[tuple[int, int]] = []

    for _ in range(n_events):
        placed = False
        for _attempt in range(48):
            dur = int(rng.integers(d_min, d_max + 1))
            dur = min(dur, max(2, n_steps - 3))
            if dur < 2:
                continue

            start_max = n_steps - dur - 1
            if start_max <= 1:
                break
            start = int(rng.integers(1, start_max + 1))
            stop = int(start + dur - 1)

            # Keep at least one-frame gap between pause bouts.
            lo = max(0, start - 1)
            hi = min(n_steps, stop + 2)
            if np.any(occupied[lo:hi]):
                continue

            occupied[start : stop + 1] = True
            pauses.append((start, stop))
            placed = True
            break

        if not placed:
            continue

    pauses.sort(key=lambda x: x[0])
    return pauses


def _apply_pauses_to_path(
    rng: np.random.Generator,
    *,
    xs: np.ndarray,
    pauses: list[tuple[int, int]],
    pause_jitter: float,
    x_low: float,
    x_high: float,
    direction_sign: float = 0.0,
    enforce_monotonic: bool = False,
    use_reflect: bool = True,
) -> np.ndarray:
    if not pauses:
        out = xs.astype(np.float32, copy=True)
        if use_reflect:
            for i in range(len(out)):
                out[i] = float(_reflect_to_bounds(float(out[i]), x_low, x_high))
        else:
            out = np.clip(out, x_low, x_high)
        if enforce_monotonic:
            if direction_sign >= 0:
                out = np.maximum.accumulate(out)
            else:
                out = -np.maximum.accumulate(-out)
            out = np.clip(out, x_low, x_high)
        return out

    out = xs.astype(np.float32, copy=True)
    n = len(out)

    for start, stop in pauses:
        if n < 2:
            break
        s = int(np.clip(start, 0, n - 2))
        e = int(np.clip(stop, s + 1, n - 1))

        anchor = float(out[s])
        old_end = float(out[e])
        seg_len = e - s + 1

        if pause_jitter > 0:
            paused_vals = anchor + rng.normal(0.0, pause_jitter, size=seg_len).astype(np.float32)
            paused_vals[0] = anchor
        else:
            paused_vals = np.full((seg_len,), anchor, dtype=np.float32)
        paused_vals = np.clip(paused_vals, x_low, x_high)

        out[s : e + 1] = paused_vals
        delta = float(paused_vals[-1] - old_end)
        if e + 1 < n:
            out[e + 1 :] = out[e + 1 :] + delta

    if use_reflect:
        for i in range(n):
            out[i] = float(_reflect_to_bounds(float(out[i]), x_low, x_high))
    else:
        out = np.clip(out, x_low, x_high)

    if enforce_monotonic:
        if direction_sign >= 0:
            out = np.maximum.accumulate(out)
        else:
            out = -np.maximum.accumulate(-out)
        out = np.clip(out, x_low, x_high)

    return out


def _draw_intensity_segment(
    image: np.ndarray,
    *,
    t0: float,
    x0: float,
    a0: float,
    t1: float,
    x1: float,
    a1: float,
    sigma_x: float,
) -> None:
    """Draw a thin Gaussian segment between two consecutive visible points."""
    dt = float(t1 - t0)
    dx = float(x1 - x0)
    span = float(max(abs(dt), abs(dx)))
    if span <= 1e-6:
        return

    n_steps = max(2, int(np.ceil(1.8 * span)))
    sigma_t = 0.45
    sx = max(1e-3, float(sigma_x))
    rt = max(1, int(np.ceil(3.0 * sigma_t)))
    rx = max(1, int(np.ceil(3.0 * sx)))

    for k in range(1, n_steps):
        u = float(k / n_steps)
        t = float(t0 + (u * dt))
        x = float(x0 + (u * dx))
        a = float((1.0 - u) * a0 + (u * a1))
        if t < -1.0 or t > image.shape[0] or x < -1.0 or x > image.shape[1]:
            continue

        y0 = max(0, int(np.floor(t)) - rt)
        y1 = min(image.shape[0], int(np.floor(t)) + rt + 1)
        x0i = max(0, int(np.floor(x)) - rx)
        x1i = min(image.shape[1], int(np.floor(x)) + rx + 1)
        if y0 >= y1 or x0i >= x1i:
            continue

        yy, xx = np.ogrid[y0:y1, x0i:x1i]
        g = np.exp(-0.5 * ((((yy - t) / sigma_t) ** 2) + (((xx - x) / sx) ** 2))).astype(np.float32)
        image[y0:y1, x0i:x1i] += float(0.45 * a) * g


def _render_track(
    image: np.ndarray,
    label: np.ndarray,
    t0: int,
    t1: int,
    x_of_t,
    amp: float,
    sigma_x: float,
    blink_prob: float,
    cls_id: int,
    rng: np.random.Generator,
    config: SynthConfig,
    instance_map: np.ndarray | None = None,
    instance_id: int = 0,
) -> dict[str, Any]:
    xx = np.arange(image.shape[1], dtype=np.float32)
    n_steps = max(1, int(t1 - t0 + 1))
    # Keep intensity highly stable with only subtle smooth variation along track.
    bleach_tau = float(
        _rand_uniform(rng, float(config.bleach_tau_mult_min), float(config.bleach_tau_mult_max))
        * max(1, t1 - t0)
    )
    if n_steps > 1:
        lf_noise = rng.normal(0.0, 1.0, size=(n_steps,)).astype(np.float32)
        lf_noise = gaussian_filter(lf_noise, sigma=max(1.0, 0.10 * n_steps))
        lf_noise = lf_noise / float(np.std(lf_noise) + 1e-6)
    else:
        lf_noise = np.zeros((n_steps,), dtype=np.float32)
    osc_amp = float(_rand_uniform(rng, float(config.gain_osc_amp_min), float(config.gain_osc_amp_max)))
    osc_cycles = float(_rand_uniform(rng, float(config.gain_osc_cycles_min), float(config.gain_osc_cycles_max)))
    osc_phase = float(rng.uniform(0.0, 2.0 * np.pi))
    noise_amp = float(_rand_uniform(rng, float(config.gain_noise_amp_min), float(config.gain_noise_amp_max)))
    i_idx = np.arange(n_steps, dtype=np.float32)
    osc = np.sin((2.0 * np.pi * osc_cycles * i_idx / max(1, n_steps - 1)) + osc_phase).astype(np.float32)
    gain = 1.0 + osc_amp * osc + noise_amp * lf_noise
    gain = np.clip(
        gain,
        min(float(config.gain_clip_min), float(config.gain_clip_max)),
        max(float(config.gain_clip_min), float(config.gain_clip_max)),
    )

    thickness = _rand_int_inclusive(rng, int(config.thickness_min), int(config.thickness_max))
    path_tx: list[list[float]] = []
    prev_drawn: tuple[int, float] | None = None
    prev_visible: tuple[int, float, float] | None = None

    for t in range(t0, t1 + 1):
        x = float(x_of_t(t))
        if x < -6 or x > image.shape[1] + 6:
            continue
        radius = thickness // 2
        _draw_labeled_disk(label, t=t, x=x, radius=radius, cls_id=cls_id)
        if instance_map is not None and instance_id > 0:
            _draw_instance_disk(
                instance_map,
                t=t,
                x=x,
                radius=max(1, radius),
                instance_id=int(instance_id),
            )
        if prev_drawn is not None:
            _draw_track_segment(
                label=label,
                instance_map=instance_map,
                cls_id=cls_id,
                instance_id=int(instance_id),
                t0=float(prev_drawn[0]),
                x0=float(prev_drawn[1]),
                t1=float(t),
                x1=float(x),
                radius=radius,
            )
        idx = int(np.clip(t - t0, 0, n_steps - 1))
        decay = float(np.exp(-(t - t0) / max(1e-6, bleach_tau)))
        local_amp = float(amp * decay * gain[idx])
        prev_drawn = (int(t), float(x))
        if 0.0 <= x <= float(image.shape[1] - 1):
            # Dense per-frame path for identity-aware training/eval.
            path_tx.append([float(int(t)), float(x)])
        if rng.random() < blink_prob:
            continue
        if prev_visible is not None and int(t - prev_visible[0]) == 1:
            _draw_intensity_segment(
                image,
                t0=float(prev_visible[0]),
                x0=float(prev_visible[1]),
                a0=float(prev_visible[2]),
                t1=float(t),
                x1=float(x),
                a1=float(local_amp),
                sigma_x=sigma_x,
            )
        ridge = np.exp(-0.5 * ((xx - x) / max(1e-3, sigma_x)) ** 2)
        image[t] += local_amp * ridge
        prev_visible = (int(t), float(x), float(local_amp))

    anchors: list[dict[str, float]] = []
    if path_tx:
        if len(path_tx) <= 3:
            idxs = list(range(len(path_tx)))
        else:
            idxs = sorted({int(round(v)) for v in np.linspace(0, len(path_tx) - 1, num=3, endpoint=True)})
        for idx in idxs:
            t_i, x_i = path_tx[idx]
            anchors.append({"t": float(t_i), "x": float(x_i)})
    else:
        # Fallback sparse summary for metadata/debugging.
        for t in np.linspace(t0, t1, num=3, endpoint=True):
            ti = int(round(float(t)))
            anchors.append({"t": float(ti), "x": float(x_of_t(ti))})

    return {
        "anchors": anchors,
        "path_tx": path_tx,
        # Dense synthetic paths are per-frame; only bridge adjacent frames.
        "max_interp_gap": 1,
    }


def _render_crossing_pair(
    image: np.ndarray,
    label: np.ndarray,
    *,
    T: int,
    X: int,
    rng: np.random.Generator,
    pair_id: int,
    frame_rate_scale: float = 1.1,
    amp_scale: float = 1.0,
    config: SynthConfig,
    instance_map: np.ndarray | None = None,
    instance_ids: tuple[int, int] | None = None,
) -> list[dict]:
    t0, t1 = _sample_track_window(
        rng,
        T,
        short_frac=0.42,
        long_frac=0.20,
        short_min=8,
        short_max=28,
        mid_min=20,
        mid_max=max(40, int(0.50 * T)),
        long_min=max(36, int(0.28 * T)),
        long_max=max(62, int(0.78 * T)),
    )
    if t1 - t0 < 8:
        return []

    tc = int(round(0.5 * (t0 + t1)))
    span = max(1, max(tc - t0, t1 - tc))
    x_cross = float(rng.uniform(0.20 * X, 0.80 * X))
    s_cap_left = max(0.0, (x_cross - 2.0) / float(span))
    s_cap_right = max(0.0, ((X - 3.0) - x_cross) / float(span))
    s_cap = min(float(min(s_cap_left, s_cap_right)), 5.6)
    if s_cap < 0.45:
        return []

    # Bias crossings toward higher fractions of feasible speed (near-horizontal crossings).
    speed_high = float(s_cap)
    speed_low = float(0.72 * s_cap)
    if speed_high <= speed_low:
        return []
    fps_scale = float(max(1e-3, frame_rate_scale))
    base_speed = float(rng.uniform(speed_low, speed_high))
    speed_a = float(np.clip(base_speed * rng.uniform(0.96, 1.08), 0.65 * s_cap, s_cap) / fps_scale)
    speed_b = float(np.clip(base_speed * rng.uniform(0.96, 1.08), 0.65 * s_cap, s_cap) / fps_scale)

    def x_of_t_a(t: int, *, _tc=tc, _xc=x_cross, _s=speed_a) -> float:
        return float(_xc + _s * float(t - _tc))

    def x_of_t_b(t: int, *, _tc=tc, _xc=x_cross, _s=speed_b) -> float:
        return float(_xc - _s * float(t - _tc))

    sigma_a = float(_rand_uniform(rng, float(config.sigma_x_min), float(config.sigma_x_max)))
    sigma_b = float(_rand_uniform(rng, float(config.sigma_x_min), float(config.sigma_x_max)))
    amp_a = float(_rand_uniform(rng, float(config.moving_amp_min), float(config.moving_amp_max))) * float(amp_scale)
    amp_b = float(_rand_uniform(rng, float(config.moving_amp_min), float(config.moving_amp_max))) * float(amp_scale)
    blink_a = float(_rand_uniform(rng, float(config.blink_crossing_min), float(config.blink_crossing_max)))
    blink_b = float(_rand_uniform(rng, float(config.blink_crossing_min), float(config.blink_crossing_max)))

    geom_a = _render_track(
        image=image,
        label=label,
        t0=t0,
        t1=t1,
        x_of_t=x_of_t_a,
        amp=amp_a,
        sigma_x=sigma_a,
        blink_prob=blink_a,
        cls_id=1,
        rng=rng,
        config=config,
        instance_map=instance_map,
        instance_id=int(instance_ids[0]) if instance_ids is not None else 0,
    )
    geom_b = _render_track(
        image=image,
        label=label,
        t0=t0,
        t1=t1,
        x_of_t=x_of_t_b,
        amp=amp_b,
        sigma_x=sigma_b,
        blink_prob=blink_b,
        cls_id=1,
        rng=rng,
        config=config,
        instance_map=instance_map,
        instance_id=int(instance_ids[1]) if instance_ids is not None else 0,
    )

    return [
        {
            "class": "moving",
            "motion": "crossing",
            "pair_id": int(pair_id),
            "instance_id": int(instance_ids[0]) if instance_ids is not None else 0,
            "t0": t0,
            "t1": t1,
            "x0": float(x_of_t_a(t0)),
            "slope": float(speed_a),
            "anchors": geom_a["anchors"],
            "path_tx": geom_a["path_tx"],
            "max_interp_gap": int(geom_a.get("max_interp_gap", 1)),
        },
        {
            "class": "moving",
            "motion": "crossing",
            "pair_id": int(pair_id),
            "instance_id": int(instance_ids[1]) if instance_ids is not None else 0,
            "t0": t0,
            "t1": t1,
            "x0": float(x_of_t_b(t0)),
            "slope": float(-speed_b),
            "anchors": geom_b["anchors"],
            "path_tx": geom_b["path_tx"],
            "max_interp_gap": int(geom_b.get("max_interp_gap", 1)),
        },
    ]


def _add_unlabeled_speck_lines(
    image: np.ndarray,
    *,
    rng: np.random.Generator,
    config: SynthConfig,
) -> int:
    """Add tiny short line-like specks as image-only noise (never labeled)."""
    T, X = image.shape
    if T <= 0 or X <= 0:
        return 0

    area_scale = float((T * X) / float(220 * 180))
    base_count = float(
        _rand_uniform(
            rng,
            float(config.noise_speck_count_min),
            float(config.noise_speck_count_max),
        )
    )
    n_specks = int(max(0, round(base_count * max(0.25, area_scale))))
    if n_specks <= 0:
        return 0

    for _ in range(n_specks):
        seg_len = float(
            _rand_uniform(
                rng,
                float(config.noise_speck_len_min),
                float(config.noise_speck_len_max),
            )
        )
        angle = float(rng.uniform(0.0, 2.0 * np.pi))
        dt = float(seg_len * np.sin(angle))
        dx = float(seg_len * np.cos(angle))
        t_center = float(rng.uniform(0.0, max(1.0, T - 1.0)))
        x_center = float(rng.uniform(0.0, max(1.0, X - 1.0)))
        t_start = float(t_center - (0.5 * dt))
        x_start = float(x_center - (0.5 * dx))
        amp = float(
            _rand_uniform(
                rng,
                float(config.noise_speck_amp_min),
                float(config.noise_speck_amp_max),
            )
        )
        sigma = float(
            _rand_uniform(
                rng,
                float(config.noise_speck_sigma_min),
                float(config.noise_speck_sigma_max),
            )
        )
        radius = max(1, int(np.ceil(3.0 * sigma)))
        n_pts = max(2, int(np.ceil(1.8 * max(abs(dt), abs(dx)))) + 1)

        for p in range(n_pts):
            a = float(p / max(1, n_pts - 1))
            t = float(t_start + (a * dt))
            x = float(x_start + (a * dx))
            if t < -1.0 or t > T or x < -1.0 or x > X:
                continue

            t0 = max(0, int(np.floor(t)) - radius)
            t1 = min(T, int(np.floor(t)) + radius + 1)
            x0 = max(0, int(np.floor(x)) - radius)
            x1 = min(X, int(np.floor(x)) + radius + 1)
            if t0 >= t1 or x0 >= x1:
                continue

            yy, xx = np.ogrid[t0:t1, x0:x1]
            g = np.exp(-0.5 * ((((yy - t) / sigma) ** 2) + (((xx - x) / sigma) ** 2))).astype(np.float32)
            image[t0:t1, x0:x1] += amp * g

    return n_specks


def generate_one(config: SynthConfig, rng: np.random.Generator) -> tuple[np.ndarray, np.ndarray, dict, np.ndarray]:
    T, X = _sample_shape(config, rng)
    image = np.zeros((T, X), dtype=np.float32)
    label = np.zeros((T, X), dtype=np.uint8)
    instance = np.zeros((T, X), dtype=np.uint16)

    tracks_meta: list[dict] = []
    next_instance_id = 1

    n_moving = int(rng.integers(config.moving_min, config.moving_max + 1))
    n_static = int(rng.integers(config.static_min, config.static_max + 1))
    n_diffusive = int(rng.integers(config.diffusive_min, config.diffusive_max + 1))
    track_amp_scale = float(
        _rand_uniform(rng, float(config.track_amp_scale_min), float(config.track_amp_scale_max))
    )
    crossing_pairs = int(
        rng.integers(0, max(0, int(config.crossing_pairs_max)) + 1)
    ) if int(config.crossing_pairs_max) > 0 else 0

    if crossing_pairs > 0:
        n_moving = max(0, n_moving - (2 * crossing_pairs))
        for pair_idx in range(crossing_pairs):
            pair_ids = (int(next_instance_id), int(next_instance_id + 1))
            pair_tracks = _render_crossing_pair(
                image=image,
                label=label,
                T=T,
                X=X,
                rng=rng,
                pair_id=pair_idx,
                frame_rate_scale=float(config.frame_rate_scale),
                amp_scale=float(track_amp_scale),
                config=config,
                instance_map=instance,
                instance_ids=pair_ids,
            )
            if pair_tracks:
                tracks_meta.extend(pair_tracks)
                next_instance_id += len(pair_tracks)

    n_fast_target = int(
        np.clip(
            np.round(float(np.clip(config.fast_track_fraction, 0.0, 1.0)) * float(n_moving))
            + float(max(0, int(config.fast_track_extra))),
            0,
            n_moving,
        )
    )
    moving_fast_flags = [True] * n_fast_target + [False] * max(0, n_moving - n_fast_target)
    rng.shuffle(moving_fast_flags)

    for force_fast in moving_fast_flags:
        t0, t1, x0, slope, curvature, amp, is_fast = _moving_track_params(
            rng,
            T,
            X,
            fast_track_fraction=float(config.fast_track_fraction),
            fast_short_fraction=float(config.fast_short_fraction),
            fast_short_span_min=int(config.fast_short_span_min),
            fast_short_span_max=int(config.fast_short_span_max),
            frame_rate_scale=float(config.frame_rate_scale),
            force_fast=bool(force_fast),
        )
        amp = float(_rand_uniform(rng, float(config.moving_amp_min), float(config.moving_amp_max)))
        amp *= float(track_amp_scale)
        sigma_x = float(_rand_uniform(rng, float(config.sigma_x_min), float(config.sigma_x_max)))
        blink_prob = float(_rand_uniform(rng, float(config.blink_moving_min), float(config.blink_moving_max)))
        if is_fast:
            # Keep fast tracks visually more connected by suppressing random dropout.
            blink_prob *= 0.30
        blink_prob = float(np.clip(blink_prob, 0.0, 1.0))
        n_steps = int(t1 - t0 + 1)
        dt = np.arange(n_steps, dtype=np.float32)
        base_xs = (x0 + slope * dt).astype(np.float32)
        base_xs = np.clip(base_xs, 1.0, float(X - 2))

        pause_prob = float(config.pause_prob_moving)
        events_min = int(config.pause_events_min)
        events_max = int(config.pause_events_max)
        dur_min = int(config.pause_dur_min)
        dur_max = int(config.pause_dur_max)
        if is_fast:
            pause_prob *= 0.30
            events_max = min(events_max, 1)
            dur_max = min(dur_max, 8)
            events_min = min(events_min, events_max)
            dur_min = min(dur_min, dur_max)

        pause_segments = _sample_pause_segments(
            rng,
            n_steps=n_steps,
            pause_prob=float(np.clip(pause_prob, 0.0, 1.0)),
            events_min=events_min,
            events_max=events_max,
            dur_min=dur_min,
            dur_max=dur_max,
        )
        xs = _apply_pauses_to_path(
            rng,
            xs=base_xs,
            pauses=pause_segments,
            pause_jitter=float(config.pause_jitter),
            x_low=1.0,
            x_high=float(X - 2),
            direction_sign=1.0 if slope >= 0 else -1.0,
            enforce_monotonic=True,
            use_reflect=False,
        )

        def x_of_t(t: int, *, _t0=t0, _xs=xs) -> float:
            idx = int(np.clip(t - _t0, 0, len(_xs) - 1))
            return float(_xs[idx])

        geom = _render_track(
            image=image,
            label=label,
            t0=t0,
            t1=t1,
            x_of_t=x_of_t,
            amp=amp,
            sigma_x=sigma_x,
            blink_prob=blink_prob,
            cls_id=1,
            rng=rng,
            config=config,
            instance_map=instance,
            instance_id=int(next_instance_id),
        )
        tracks_meta.append(
            {
                "class": "moving",
                "instance_id": int(next_instance_id),
                "t0": t0,
                "t1": t1,
                "x0": x0,
                "slope": slope,
                "fast": bool(is_fast),
                "curvature": curvature,
                "pauses": [
                    {"t_start": int(t0 + s), "t_end": int(t0 + e)}
                    for s, e in pause_segments
                ],
                "anchors": geom["anchors"],
                "path_tx": geom["path_tx"],
                "max_interp_gap": int(geom.get("max_interp_gap", 1)),
            }
        )
        next_instance_id += 1

    for _ in range(n_static):
        t0, t1, x0, amp = _static_track_params(rng, T, X)
        amp = float(_rand_uniform(rng, float(config.static_amp_min), float(config.static_amp_max)))
        amp *= float(track_amp_scale)
        sigma_x = float(_rand_uniform(rng, float(config.sigma_x_min), float(config.sigma_x_max)))
        blink_prob = float(_rand_uniform(rng, float(config.blink_static_min), float(config.blink_static_max)))
        jitter_amp = float(rng.uniform(0.0, 0.20))
        freq = float(rng.uniform(0.02, 0.08))

        def x_of_t(t: int, *, _x0=x0, _a=jitter_amp, _f=freq) -> float:
            return _x0 + _a * np.sin(t * _f)

        geom = _render_track(
            image=image,
            label=label,
            t0=t0,
            t1=t1,
            x_of_t=x_of_t,
            amp=amp,
            sigma_x=sigma_x,
            blink_prob=blink_prob,
            cls_id=2,
            rng=rng,
            config=config,
            instance_map=instance,
            instance_id=int(next_instance_id),
        )
        tracks_meta.append(
            {
                "class": "static",
                "instance_id": int(next_instance_id),
                "t0": t0,
                "t1": t1,
                "x0": x0,
                "anchors": geom["anchors"],
                "path_tx": geom["path_tx"],
                "max_interp_gap": int(geom.get("max_interp_gap", 1)),
            }
        )
        next_instance_id += 1

    for _ in range(n_diffusive):
        t0, t1, x0, drift, step_sigma, amp, mode = _diffusive_track_params(
            rng, T, X, config.biased_fraction
        )
        amp = float(_rand_uniform(rng, float(config.diffusive_amp_min), float(config.diffusive_amp_max)))
        amp *= float(track_amp_scale)
        base_xs = _simulate_diffusive_path(
            rng,
            t0=t0,
            t1=t1,
            x0=x0,
            drift=drift,
            step_sigma=step_sigma,
            x_low=1.0,
            x_high=float(X - 2),
        )
        pause_segments = _sample_pause_segments(
            rng,
            n_steps=len(base_xs),
            pause_prob=float(config.pause_prob_diffusive),
            events_min=int(config.pause_events_min),
            events_max=int(config.pause_events_max),
            dur_min=int(config.pause_dur_min),
            dur_max=int(config.pause_dur_max),
        )
        xs = _apply_pauses_to_path(
            rng,
            xs=base_xs,
            pauses=pause_segments,
            pause_jitter=float(config.pause_jitter),
            x_low=1.0,
            x_high=float(X - 2),
            direction_sign=0.0,
            enforce_monotonic=False,
            use_reflect=True,
        )
        sigma_x = float(_rand_uniform(rng, float(config.sigma_x_min), float(config.sigma_x_max)))
        blink_prob = float(_rand_uniform(rng, float(config.blink_diffusive_min), float(config.blink_diffusive_max)))

        def x_of_t(t: int, *, _t0=t0, _xs=xs) -> float:
            idx = int(np.clip(t - _t0, 0, len(_xs) - 1))
            return float(_xs[idx])

        geom = _render_track(
            image=image,
            label=label,
            t0=t0,
            t1=t1,
            x_of_t=x_of_t,
            amp=amp,
            sigma_x=sigma_x,
            blink_prob=blink_prob,
            cls_id=1,
            rng=rng,
            config=config,
            instance_map=instance,
            instance_id=int(next_instance_id),
        )
        tracks_meta.append(
            {
                "class": "diffusive",
                "instance_id": int(next_instance_id),
                "motion": mode,
                "t0": t0,
                "t1": t1,
                "x0": x0,
                "drift": drift,
                "step_sigma": step_sigma,
                "pauses": [
                    {"t_start": int(t0 + s), "t_end": int(t0 + e)}
                    for s, e in pause_segments
                ],
                "anchors": geom["anchors"],
                "path_tx": geom["path_tx"],
                "max_interp_gap": int(geom.get("max_interp_gap", 1)),
            }
        )
        next_instance_id += 1

    # Background components.
    t_grid = np.linspace(0.0, 1.0, T, dtype=np.float32)[:, None]
    x_grid = np.linspace(0.0, 1.0, X, dtype=np.float32)[None, :]
    bg_offset = float(_rand_uniform(rng, float(config.bg_offset_min), float(config.bg_offset_max)))
    bg_grad_t = float(_rand_uniform(rng, -abs(float(config.bg_grad_t_abs_max)), abs(float(config.bg_grad_t_abs_max))))
    bg_grad_x = float(_rand_uniform(rng, -abs(float(config.bg_grad_x_abs_max)), abs(float(config.bg_grad_x_abs_max))))
    sig_t = max(1e-3, float(config.texture_sigma_t))
    sig_x = max(1e-3, float(config.texture_sigma_x))

    # Microscopy-like low-frequency background: separable drift dominates,
    # with only a weak residual 2D component to avoid leopard-like blotches.
    row_texture = gaussian_filter(
        rng.normal(0.0, 1.0, size=(T,)).astype(np.float32),
        sigma=sig_t,
    )
    row_texture = row_texture / (np.std(row_texture) + 1e-6)
    col_texture = gaussian_filter(
        rng.normal(0.0, 1.0, size=(X,)).astype(np.float32),
        sigma=sig_x,
    )
    col_texture = col_texture / (np.std(col_texture) + 1e-6)
    col_mid_sigma = max(
        1e-3,
        float(
            _rand_uniform(
                rng,
                float(config.texture_col_mid_sigma_min),
                float(config.texture_col_mid_sigma_max),
            )
        ),
    )
    col_hf_sigma = max(
        1e-3,
        float(
            _rand_uniform(
                rng,
                float(config.texture_col_hf_sigma_min),
                float(config.texture_col_hf_sigma_max),
            )
        ),
    )
    col_texture_mid = gaussian_filter(
        rng.normal(0.0, 1.0, size=(X,)).astype(np.float32),
        sigma=col_mid_sigma,
    )
    col_texture_mid = col_texture_mid / (np.std(col_texture_mid) + 1e-6)
    col_texture_hf = gaussian_filter(
        rng.normal(0.0, 1.0, size=(X,)).astype(np.float32),
        sigma=col_hf_sigma,
    )
    col_texture_hf = col_texture_hf / (np.std(col_texture_hf) + 1e-6)
    col_time_mod = gaussian_filter(
        rng.normal(0.0, 1.0, size=(T,)).astype(np.float32),
        sigma=max(1.0, 0.40 * sig_t),
    )
    col_time_mod = col_time_mod / (np.std(col_time_mod) + 1e-6)

    base_texture_amp = float(_rand_uniform(rng, float(config.texture_amp_min), float(config.texture_amp_max)))
    texture_2d_frac = float(_rand_uniform(rng, float(config.texture_2d_frac_min), float(config.texture_2d_frac_max)))
    texture_2d_frac = float(np.clip(texture_2d_frac, 0.0, 1.0))

    row_amp = float(_rand_uniform(rng, float(config.texture_row_amp_min), float(config.texture_row_amp_max)))
    col_amp = float(_rand_uniform(rng, float(config.texture_col_amp_min), float(config.texture_col_amp_max)))
    col_mid_amp = float(_rand_uniform(rng, float(config.texture_col_mid_amp_min), float(config.texture_col_mid_amp_max)))
    col_hf_amp = float(_rand_uniform(rng, float(config.texture_col_hf_amp_min), float(config.texture_col_hf_amp_max)))
    col_time_var = float(_rand_uniform(rng, float(config.texture_col_time_var_min), float(config.texture_col_time_var_max)))
    row_amp += float(base_texture_amp * (1.0 - texture_2d_frac) * 0.6)
    col_amp += float(base_texture_amp * (1.0 - texture_2d_frac) * 0.4)
    x_profile = (col_amp * col_texture) + (col_mid_amp * col_texture_mid) + (col_hf_amp * col_texture_hf)
    time_factor = np.clip(1.0 + (col_time_var * col_time_mod), 0.30, 1.70)
    col_texture_dynamic = time_factor[:, None] * x_profile[None, :]

    residual_2d = gaussian_filter(
        rng.normal(0.0, 1.0, size=(T, X)).astype(np.float32),
        sigma=(1.4 * sig_t, 1.4 * sig_x),
    )
    residual_2d = residual_2d / (np.std(residual_2d) + 1e-6)
    residual_2d_amp = float(base_texture_amp * texture_2d_frac)
    # Column-wise stripe field with mixed very-low/low/high frequency random structure.
    stripe_vlf_sigma = max(
        1e-3,
        float(
            _rand_uniform(
                rng,
                float(config.x_stripe_vlf_sigma_min),
                float(config.x_stripe_vlf_sigma_max),
            )
        ),
    )
    stripe_lf_sigma = max(
        1e-3,
        float(
            _rand_uniform(
                rng,
                float(config.x_stripe_lf_sigma_min),
                float(config.x_stripe_lf_sigma_max),
            )
        ),
    )
    stripe_hf_sigma = max(
        1e-3,
        float(
            _rand_uniform(
                rng,
                float(config.x_stripe_hf_sigma_min),
                float(config.x_stripe_hf_sigma_max),
            )
        ),
    )
    stripe_vlf = gaussian_filter(rng.normal(0.0, 1.0, size=(X,)).astype(np.float32), sigma=stripe_vlf_sigma)
    stripe_vlf = stripe_vlf / (np.std(stripe_vlf) + 1e-6)
    stripe_lf = gaussian_filter(rng.normal(0.0, 1.0, size=(X,)).astype(np.float32), sigma=stripe_lf_sigma)
    stripe_lf = stripe_lf / (np.std(stripe_lf) + 1e-6)
    # Lift valleys for low-frequency bands so troughs are not overly deep.
    valley_lift = 0.70
    stripe_vlf = np.where(stripe_vlf < 0.0, valley_lift * stripe_vlf, stripe_vlf)
    stripe_lf = np.where(stripe_lf < 0.0, valley_lift * stripe_lf, stripe_lf)
    stripe_hf = gaussian_filter(rng.normal(0.0, 1.0, size=(X,)).astype(np.float32), sigma=stripe_hf_sigma)
    stripe_hf = stripe_hf / (np.std(stripe_hf) + 1e-6)
    stripe_vlf_amp = float(_rand_uniform(rng, float(config.x_stripe_vlf_amp_min), float(config.x_stripe_vlf_amp_max)))
    stripe_lf_amp = float(_rand_uniform(rng, float(config.x_stripe_lf_amp_min), float(config.x_stripe_lf_amp_max)))
    stripe_hf_amp = float(_rand_uniform(rng, float(config.x_stripe_hf_amp_min), float(config.x_stripe_hf_amp_max)))
    stripe_profile = (stripe_vlf_amp * stripe_vlf) + (stripe_lf_amp * stripe_lf) + (stripe_hf_amp * stripe_hf)
    stripe_time_var = float(
        _rand_uniform(rng, float(config.x_stripe_time_var_min), float(config.x_stripe_time_var_max))
    )
    stripe_time_mod = gaussian_filter(
        rng.normal(0.0, 1.0, size=(T,)).astype(np.float32),
        sigma=max(1.0, 0.35 * sig_t),
    )
    stripe_time_mod = stripe_time_mod / (np.std(stripe_time_mod) + 1e-6)
    stripe_weight = np.clip(1.0 + (stripe_time_var * stripe_time_mod), 0.85, 1.15)
    stripe_component = stripe_weight[:, None] * stripe_profile[None, :]

    image += (
        bg_offset
        + (bg_grad_t * t_grid)
        + (bg_grad_x * x_grid)
        + (row_amp * row_texture[:, None])
        + col_texture_dynamic
        + (residual_2d_amp * residual_2d)
    )

    # PSF-ish blur.
    image = gaussian_filter(
        image,
        sigma=(
            float(_rand_uniform(rng, float(config.psf_sigma_t_min), float(config.psf_sigma_t_max))),
            float(_rand_uniform(rng, float(config.psf_sigma_x_min), float(config.psf_sigma_x_max))),
        ),
    )
    # Add x-axis stripe component after optical blur (sensor-like banding/fixed-pattern feel).
    # Keep the modulation visible but not dominant.
    stripe_gain = np.clip(1.0 + (1.2 * stripe_component), 0.45, 2.2)
    image = (image * stripe_gain) + (0.22 * stripe_component)

    # Shot noise + read noise.
    image = np.clip(image, 0, None)
    poisson_gain = float(_rand_uniform(rng, float(config.poisson_gain_min), float(config.poisson_gain_max)))
    image = rng.poisson(image * poisson_gain).astype(np.float32) / poisson_gain
    image += rng.normal(
        0.0,
        float(_rand_uniform(rng, float(config.read_noise_min), float(config.read_noise_max))),
        size=image.shape,
    ).astype(np.float32)

    # Normalize into [0,1].
    image -= float(image.min())
    maxv = float(image.max())
    if maxv > 0.0:
        image /= maxv
    else:
        image.fill(0.0)
    gamma = float(_rand_uniform(rng, float(config.gamma_min), float(config.gamma_max)))
    image = np.clip(image, 0.0, 1.0) ** gamma
    # Keep x-axis banding visible after normalization (tracks can otherwise dominate dynamic range).
    stripe_profile_n = stripe_profile / float(np.std(stripe_profile) + 1e-6)
    stripe_post = stripe_profile_n[None, :] * np.clip(1.0 + (0.10 * stripe_time_mod[:, None]), 0.90, 1.10)
    stripe_amp_sum = float(abs(stripe_lf_amp) + abs(stripe_hf_amp))
    post_gain = float(np.clip(1.2 * stripe_amp_sum, 0.02, 0.20))
    post_bias = float(np.clip(0.35 * stripe_amp_sum, 0.00, 0.10))
    image = np.clip((image * (1.0 + (post_gain * stripe_post))) + (post_bias * stripe_post), 0.0, 1.0)
    n_specks = _add_unlabeled_speck_lines(image, rng=rng, config=config)
    image = np.clip(image, 0.0, 1.0)
    dtype_key = str(getattr(config, "image_dtype", "u8")).strip().lower()
    if dtype_key == "u16":
        image_out = np.clip(np.rint(image * 65535.0), 0, 65535).astype(np.uint16)
    else:
        image_out = np.clip(np.rint(image * 255.0), 0, 255).astype(np.uint8)

    meta = {
        "shape": [T, X],
        "n_moving": n_moving,
        "n_crossing_pairs": int(crossing_pairs),
        "n_diffusive": n_diffusive,
        "n_static": n_static,
        "n_noise_specks": int(n_specks),
        "tracks": tracks_meta,
    }
    return image_out, label, meta, instance


def _instance_color(instance_id: int) -> np.ndarray:
    hue = float((instance_id * 0.61803398875) % 1.0)
    sat = 0.76
    val = 1.0
    r, g, b = colorsys.hsv_to_rgb(hue, sat, val)
    return np.array([255.0 * r, 255.0 * g, 255.0 * b], dtype=np.float32)


def _to_preview_u8(image: np.ndarray) -> np.ndarray:
    base = np.asarray(image, dtype=np.float32)
    if base.size == 0:
        return np.zeros((1, 1), dtype=np.uint8)
    p1, p99 = np.percentile(base, (1, 99))
    denom = float(max(1e-6, p99 - p1))
    gray = np.clip((base - p1) / denom, 0.0, 1.0)
    return np.clip(np.rint(gray * 255.0), 0, 255).astype(np.uint8)


def _make_preview(image: np.ndarray, label: np.ndarray, instance: np.ndarray | None = None) -> np.ndarray:
    base_u8 = _to_preview_u8(image)
    rgb = np.repeat(base_u8[..., None], 3, axis=2).astype(np.float32)
    if instance is not None:
        ids = np.unique(instance)
        ids = ids[ids > 0]
        for tid in ids:
            mask = instance == tid
            color = _instance_color(int(tid))
            rgb[mask] = 0.20 * rgb[mask] + 0.80 * color

    # Fallback class overlay only where no instance id was assigned.
    if instance is None:
        no_id_mask = np.ones(label.shape, dtype=bool)
    else:
        no_id_mask = instance == 0
    moving = np.logical_and(label == 1, no_id_mask)
    static = np.logical_and(label == 2, no_id_mask)
    rgb[moving] = 0.35 * rgb[moving] + 0.65 * np.array([255.0, 72.0, 72.0], dtype=np.float32)
    rgb[static] = 0.40 * rgb[static] + 0.60 * np.array([64.0, 176.0, 255.0], dtype=np.float32)
    return np.clip(rgb, 0, 255).astype(np.uint8)


def build_dataset(cfg: SynthConfig, out_dir: str | Path) -> None:
    out = Path(out_dir)
    img_dir = out / "images"
    lbl_dir = out / "labels"
    meta_dir = out / "meta"
    preview_dir = out / "preview"
    for d in (img_dir, lbl_dir, meta_dir, preview_dir):
        d.mkdir(parents=True, exist_ok=True)

    rng = np.random.default_rng(cfg.seed)
    manifest_rows = []

    try:
        from tqdm import trange  # type: ignore

        iterator = trange(cfg.n, desc="Generating synthetic kymographs")
    except Exception:
        iterator = range(cfg.n)

    for i in iterator:
        image, label, meta, instance = generate_one(cfg, rng)
        stem = f"{i:06d}"

        rel_img = Path("images") / f"{stem}.png"
        rel_lbl = Path("labels") / f"{stem}.png"
        rel_meta = Path("meta") / f"{stem}.json"

        save_image(out / rel_img, image)
        save_image(out / rel_lbl, label.astype(np.uint8))
        write_json(out / rel_meta, meta)

        if cfg.preview_every > 0 and (i % cfg.preview_every == 0):
            preview = _make_preview(image, label, instance)
            save_image(preview_dir / f"{stem}.png", preview)

        manifest_rows.append(
            {
                "image": str(rel_img),
                "label": str(rel_lbl),
                "meta": str(rel_meta),
            }
        )

    write_jsonl(out / "index.jsonl", manifest_rows)
    write_json(out / "generation_config.json", asdict(cfg))


def parse_args() -> argparse.Namespace:
    ap = argparse.ArgumentParser(description="Generate synthetic kymograph segmentation data")
    ap.add_argument("--out", required=True, help="Output dataset directory")
    ap.add_argument(
        "--profile_json",
        default="",
        help="Optional profile JSON (or JSON with synth_overrides) from real-data calibration.",
    )
    ap.add_argument("--n", type=int, default=5000)
    ap.add_argument("--T", type=int, default=220)
    ap.add_argument("--X", type=int, default=180)
    ap.add_argument("--T_min", type=int, default=0, help="Per-sample min T (0 uses fixed --T)")
    ap.add_argument("--T_max", type=int, default=0, help="Per-sample max T (0 uses fixed --T)")
    ap.add_argument("--X_min", type=int, default=0, help="Per-sample min X (0 uses fixed --X)")
    ap.add_argument("--X_max", type=int, default=0, help="Per-sample max X (0 uses fixed --X)")
    ap.add_argument("--seed", type=int, default=123)
    ap.add_argument("--preview_every", type=int, default=250)
    ap.add_argument("--moving_min", type=int, default=7)
    ap.add_argument("--moving_max", type=int, default=16)
    ap.add_argument("--static_min", type=int, default=2)
    ap.add_argument("--static_max", type=int, default=6)
    ap.add_argument("--diffusive_min", type=int, default=2)
    ap.add_argument("--diffusive_max", type=int, default=8)
    ap.add_argument("--biased_fraction", type=float, default=0.7)
    ap.add_argument("--pause_prob_moving", type=float, default=0.65)
    ap.add_argument("--pause_prob_diffusive", type=float, default=0.45)
    ap.add_argument("--pause_events_min", type=int, default=1)
    ap.add_argument("--pause_events_max", type=int, default=3)
    ap.add_argument("--pause_dur_min", type=int, default=4)
    ap.add_argument("--pause_dur_max", type=int, default=24)
    ap.add_argument("--pause_jitter", type=float, default=0.06)
    ap.add_argument("--frame_rate_scale", type=float, default=1.1)
    ap.add_argument("--fast_track_fraction", type=float, default=0.55)
    ap.add_argument("--fast_track_extra", type=int, default=2)
    ap.add_argument("--fast_short_fraction", type=float, default=0.88)
    ap.add_argument("--fast_short_span_min", type=int, default=5)
    ap.add_argument("--fast_short_span_max", type=int, default=12)
    ap.add_argument("--crossing_pairs_max", type=int, default=2)
    ap.add_argument("--track_amp_scale_min", type=float, default=1.00)
    ap.add_argument("--track_amp_scale_max", type=float, default=1.45)
    ap.add_argument("--texture_col_mid_amp_min", type=float, default=0.001)
    ap.add_argument("--texture_col_mid_amp_max", type=float, default=0.010)
    ap.add_argument("--texture_col_hf_amp_min", type=float, default=0.0005)
    ap.add_argument("--texture_col_hf_amp_max", type=float, default=0.007)
    ap.add_argument("--texture_col_mid_sigma_min", type=float, default=1.5)
    ap.add_argument("--texture_col_mid_sigma_max", type=float, default=4.5)
    ap.add_argument("--texture_col_hf_sigma_min", type=float, default=0.30)
    ap.add_argument("--texture_col_hf_sigma_max", type=float, default=1.20)
    ap.add_argument("--texture_col_time_var_min", type=float, default=0.03)
    ap.add_argument("--texture_col_time_var_max", type=float, default=0.20)
    ap.add_argument("--x_stripe_lf_amp_min", type=float, default=0.010)
    ap.add_argument("--x_stripe_lf_amp_max", type=float, default=0.060)
    ap.add_argument("--x_stripe_hf_amp_min", type=float, default=0.003)
    ap.add_argument("--x_stripe_hf_amp_max", type=float, default=0.020)
    ap.add_argument("--x_stripe_vlf_amp_min", type=float, default=0.004)
    ap.add_argument("--x_stripe_vlf_amp_max", type=float, default=0.020)
    ap.add_argument("--x_stripe_vlf_sigma_min", type=float, default=8.0)
    ap.add_argument("--x_stripe_vlf_sigma_max", type=float, default=28.0)
    ap.add_argument("--x_stripe_lf_sigma_min", type=float, default=24.0)
    ap.add_argument("--x_stripe_lf_sigma_max", type=float, default=72.0)
    ap.add_argument("--x_stripe_hf_sigma_min", type=float, default=0.20)
    ap.add_argument("--x_stripe_hf_sigma_max", type=float, default=0.80)
    ap.add_argument("--x_stripe_time_var_min", type=float, default=0.00)
    ap.add_argument("--x_stripe_time_var_max", type=float, default=0.04)
    ap.add_argument("--noise_speck_count_min", type=int, default=12)
    ap.add_argument("--noise_speck_count_max", type=int, default=30)
    ap.add_argument("--noise_speck_len_min", type=float, default=1.2)
    ap.add_argument("--noise_speck_len_max", type=float, default=4.8)
    ap.add_argument("--noise_speck_amp_min", type=float, default=0.020)
    ap.add_argument("--noise_speck_amp_max", type=float, default=0.080)
    ap.add_argument("--noise_speck_sigma_min", type=float, default=0.35)
    ap.add_argument("--noise_speck_sigma_max", type=float, default=0.90)
    ap.add_argument(
        "--image_dtype",
        choices=("u8", "u16"),
        default="u8",
        help="Output image dtype for synthetic kymographs (u8=0..255, u16=0..65535).",
    )
    return ap.parse_args()


def main() -> None:
    args = parse_args()
    if args.moving_min > args.moving_max:
        raise ValueError("--moving_min must be <= --moving_max")
    if args.static_min > args.static_max:
        raise ValueError("--static_min must be <= --static_max")
    if args.diffusive_min > args.diffusive_max:
        raise ValueError("--diffusive_min must be <= --diffusive_max")
    if not (0.0 <= args.biased_fraction <= 1.0):
        raise ValueError("--biased_fraction must be in [0, 1]")
    if args.pause_events_min > args.pause_events_max:
        raise ValueError("--pause_events_min must be <= --pause_events_max")
    if args.pause_dur_min > args.pause_dur_max:
        raise ValueError("--pause_dur_min must be <= --pause_dur_max")
    if args.pause_dur_min < 1:
        raise ValueError("--pause_dur_min must be >= 1")
    if args.pause_jitter < 0:
        raise ValueError("--pause_jitter must be >= 0")
    if not (0.0 <= args.pause_prob_moving <= 1.0):
        raise ValueError("--pause_prob_moving must be in [0, 1]")
    if not (0.0 <= args.pause_prob_diffusive <= 1.0):
        raise ValueError("--pause_prob_diffusive must be in [0, 1]")
    if not (0.0 <= args.fast_track_fraction <= 1.0):
        raise ValueError("--fast_track_fraction must be in [0, 1]")
    if args.fast_track_extra < 0:
        raise ValueError("--fast_track_extra must be >= 0")
    if not (0.0 <= args.fast_short_fraction <= 1.0):
        raise ValueError("--fast_short_fraction must be in [0, 1]")
    if args.fast_short_span_min < 1:
        raise ValueError("--fast_short_span_min must be >= 1")
    if args.fast_short_span_min > args.fast_short_span_max:
        raise ValueError("--fast_short_span_min must be <= --fast_short_span_max")
    if args.crossing_pairs_max < 0:
        raise ValueError("--crossing_pairs_max must be >= 0")
    if args.frame_rate_scale <= 0:
        raise ValueError("--frame_rate_scale must be > 0")
    if args.noise_speck_count_min < 0:
        raise ValueError("--noise_speck_count_min must be >= 0")
    if args.noise_speck_count_min > args.noise_speck_count_max:
        raise ValueError("--noise_speck_count_min must be <= --noise_speck_count_max")
    if args.noise_speck_len_min < 0:
        raise ValueError("--noise_speck_len_min must be >= 0")
    if args.noise_speck_len_min > args.noise_speck_len_max:
        raise ValueError("--noise_speck_len_min must be <= --noise_speck_len_max")
    if args.noise_speck_len_max >= 5.0:
        raise ValueError("--noise_speck_len_max must be < 5.0")
    if args.T_min < 0 or args.T_max < 0 or args.X_min < 0 or args.X_max < 0:
        raise ValueError("--T_min/--T_max/--X_min/--X_max must be >= 0")

    cfg = SynthConfig(
        n=args.n,
        T=args.T,
        X=args.X,
        T_min=args.T_min,
        T_max=args.T_max,
        X_min=args.X_min,
        X_max=args.X_max,
        seed=args.seed,
        preview_every=args.preview_every,
        moving_min=args.moving_min,
        moving_max=args.moving_max,
        static_min=args.static_min,
        static_max=args.static_max,
        diffusive_min=args.diffusive_min,
        diffusive_max=args.diffusive_max,
        biased_fraction=args.biased_fraction,
        pause_prob_moving=args.pause_prob_moving,
        pause_prob_diffusive=args.pause_prob_diffusive,
        pause_events_min=args.pause_events_min,
        pause_events_max=args.pause_events_max,
        pause_dur_min=args.pause_dur_min,
        pause_dur_max=args.pause_dur_max,
        pause_jitter=args.pause_jitter,
        frame_rate_scale=args.frame_rate_scale,
        fast_track_fraction=args.fast_track_fraction,
        fast_track_extra=args.fast_track_extra,
        fast_short_fraction=args.fast_short_fraction,
        fast_short_span_min=args.fast_short_span_min,
        fast_short_span_max=args.fast_short_span_max,
        crossing_pairs_max=args.crossing_pairs_max,
        track_amp_scale_min=args.track_amp_scale_min,
        track_amp_scale_max=args.track_amp_scale_max,
        texture_col_mid_amp_min=args.texture_col_mid_amp_min,
        texture_col_mid_amp_max=args.texture_col_mid_amp_max,
        texture_col_hf_amp_min=args.texture_col_hf_amp_min,
        texture_col_hf_amp_max=args.texture_col_hf_amp_max,
        texture_col_mid_sigma_min=args.texture_col_mid_sigma_min,
        texture_col_mid_sigma_max=args.texture_col_mid_sigma_max,
        texture_col_hf_sigma_min=args.texture_col_hf_sigma_min,
        texture_col_hf_sigma_max=args.texture_col_hf_sigma_max,
        texture_col_time_var_min=args.texture_col_time_var_min,
        texture_col_time_var_max=args.texture_col_time_var_max,
        x_stripe_lf_amp_min=args.x_stripe_lf_amp_min,
        x_stripe_lf_amp_max=args.x_stripe_lf_amp_max,
        x_stripe_hf_amp_min=args.x_stripe_hf_amp_min,
        x_stripe_hf_amp_max=args.x_stripe_hf_amp_max,
        x_stripe_vlf_amp_min=args.x_stripe_vlf_amp_min,
        x_stripe_vlf_amp_max=args.x_stripe_vlf_amp_max,
        x_stripe_vlf_sigma_min=args.x_stripe_vlf_sigma_min,
        x_stripe_vlf_sigma_max=args.x_stripe_vlf_sigma_max,
        x_stripe_lf_sigma_min=args.x_stripe_lf_sigma_min,
        x_stripe_lf_sigma_max=args.x_stripe_lf_sigma_max,
        x_stripe_hf_sigma_min=args.x_stripe_hf_sigma_min,
        x_stripe_hf_sigma_max=args.x_stripe_hf_sigma_max,
        x_stripe_time_var_min=args.x_stripe_time_var_min,
        x_stripe_time_var_max=args.x_stripe_time_var_max,
        noise_speck_count_min=args.noise_speck_count_min,
        noise_speck_count_max=args.noise_speck_count_max,
        noise_speck_len_min=args.noise_speck_len_min,
        noise_speck_len_max=args.noise_speck_len_max,
        noise_speck_amp_min=args.noise_speck_amp_min,
        noise_speck_amp_max=args.noise_speck_amp_max,
        noise_speck_sigma_min=args.noise_speck_sigma_min,
        noise_speck_sigma_max=args.noise_speck_sigma_max,
        image_dtype=args.image_dtype,
    )
    if args.profile_json:
        cfg = _apply_profile_overrides(cfg, args.profile_json)
    cfg.image_dtype = str(cfg.image_dtype).strip().lower()
    if cfg.image_dtype not in {"u8", "u16"}:
        raise ValueError("image_dtype must be one of: u8, u16")
    if not (0.0 <= float(cfg.fast_track_fraction) <= 1.0):
        raise ValueError("fast_track_fraction must be in [0, 1]")
    if int(cfg.fast_track_extra) < 0:
        raise ValueError("fast_track_extra must be >= 0")
    if not (0.0 <= float(cfg.fast_short_fraction) <= 1.0):
        raise ValueError("fast_short_fraction must be in [0, 1]")
    if int(cfg.fast_short_span_min) < 1:
        raise ValueError("fast_short_span_min must be >= 1")
    if int(cfg.fast_short_span_min) > int(cfg.fast_short_span_max):
        raise ValueError("fast_short_span_min must be <= fast_short_span_max")
    if int(cfg.noise_speck_count_min) < 0:
        raise ValueError("noise_speck_count_min must be >= 0")
    if int(cfg.noise_speck_count_min) > int(cfg.noise_speck_count_max):
        raise ValueError("noise_speck_count_min must be <= noise_speck_count_max")
    if float(cfg.noise_speck_len_max) >= 5.0:
        raise ValueError("noise_speck_len_max must be < 5.0")

    if cfg.T_min > 0 and cfg.T_max > 0 and cfg.T_min > cfg.T_max:
        raise ValueError("T_min must be <= T_max")
    if cfg.X_min > 0 and cfg.X_max > 0 and cfg.X_min > cfg.X_max:
        raise ValueError("X_min must be <= X_max")
    for lo_key, hi_key in (
        ("moving_amp_min", "moving_amp_max"),
        ("static_amp_min", "static_amp_max"),
        ("diffusive_amp_min", "diffusive_amp_max"),
        ("track_amp_scale_min", "track_amp_scale_max"),
        ("sigma_x_min", "sigma_x_max"),
        ("texture_amp_min", "texture_amp_max"),
        ("texture_row_amp_min", "texture_row_amp_max"),
        ("texture_col_amp_min", "texture_col_amp_max"),
        ("texture_col_mid_amp_min", "texture_col_mid_amp_max"),
        ("texture_col_hf_amp_min", "texture_col_hf_amp_max"),
        ("texture_col_mid_sigma_min", "texture_col_mid_sigma_max"),
        ("texture_col_hf_sigma_min", "texture_col_hf_sigma_max"),
        ("texture_col_time_var_min", "texture_col_time_var_max"),
        ("x_stripe_lf_amp_min", "x_stripe_lf_amp_max"),
        ("x_stripe_hf_amp_min", "x_stripe_hf_amp_max"),
        ("x_stripe_vlf_amp_min", "x_stripe_vlf_amp_max"),
        ("x_stripe_vlf_sigma_min", "x_stripe_vlf_sigma_max"),
        ("x_stripe_lf_sigma_min", "x_stripe_lf_sigma_max"),
        ("x_stripe_hf_sigma_min", "x_stripe_hf_sigma_max"),
        ("x_stripe_time_var_min", "x_stripe_time_var_max"),
        ("noise_speck_len_min", "noise_speck_len_max"),
        ("noise_speck_amp_min", "noise_speck_amp_max"),
        ("noise_speck_sigma_min", "noise_speck_sigma_max"),
        ("texture_2d_frac_min", "texture_2d_frac_max"),
        ("poisson_gain_min", "poisson_gain_max"),
        ("read_noise_min", "read_noise_max"),
        ("gamma_min", "gamma_max"),
    ):
        if float(getattr(cfg, lo_key)) > float(getattr(cfg, hi_key)):
            raise ValueError(f"{lo_key} must be <= {hi_key}")
    for key in (
        "blink_moving_min",
        "blink_moving_max",
        "blink_static_min",
        "blink_static_max",
        "blink_diffusive_min",
        "blink_diffusive_max",
        "blink_crossing_min",
        "blink_crossing_max",
        "texture_2d_frac_min",
        "texture_2d_frac_max",
    ):
        v = float(getattr(cfg, key))
        if not (0.0 <= v <= 1.0):
            raise ValueError(f"{key} must be in [0, 1]")
    for key in (
        "moving_amp_min",
        "moving_amp_max",
        "static_amp_min",
        "static_amp_max",
        "diffusive_amp_min",
        "diffusive_amp_max",
        "track_amp_scale_min",
        "track_amp_scale_max",
        "sigma_x_min",
        "sigma_x_max",
        "texture_amp_min",
        "texture_amp_max",
        "texture_row_amp_min",
        "texture_row_amp_max",
        "texture_col_amp_min",
        "texture_col_amp_max",
        "texture_col_mid_amp_min",
        "texture_col_mid_amp_max",
        "texture_col_hf_amp_min",
        "texture_col_hf_amp_max",
        "texture_col_mid_sigma_min",
        "texture_col_mid_sigma_max",
        "texture_col_hf_sigma_min",
        "texture_col_hf_sigma_max",
        "texture_col_time_var_min",
        "texture_col_time_var_max",
        "x_stripe_lf_amp_min",
        "x_stripe_lf_amp_max",
        "x_stripe_hf_amp_min",
        "x_stripe_hf_amp_max",
        "x_stripe_vlf_amp_min",
        "x_stripe_vlf_amp_max",
        "x_stripe_vlf_sigma_min",
        "x_stripe_vlf_sigma_max",
        "x_stripe_lf_sigma_min",
        "x_stripe_lf_sigma_max",
        "x_stripe_hf_sigma_min",
        "x_stripe_hf_sigma_max",
        "x_stripe_time_var_min",
        "x_stripe_time_var_max",
        "noise_speck_count_min",
        "noise_speck_count_max",
        "noise_speck_len_min",
        "noise_speck_len_max",
        "noise_speck_amp_min",
        "noise_speck_amp_max",
        "noise_speck_sigma_min",
        "noise_speck_sigma_max",
        "poisson_gain_min",
        "poisson_gain_max",
        "read_noise_min",
        "read_noise_max",
        "gamma_min",
        "gamma_max",
    ):
        if float(getattr(cfg, key)) < 0.0:
            raise ValueError(f"{key} must be >= 0")

    build_dataset(cfg, args.out)


if __name__ == "__main__":
    main()
