# Sparse GP noise

A single-octave experiment in smooth noise with sparse handles. Bridson
Poisson-disk locations get IID Gaussian values; a Gaussian process completes the
field. A posterior draw retains variation between constraints.

## Run

Requires Python 3, NumPy, SciPy, and Pillow (`requirements.txt`).

```bash
OPENBLAS_NUM_THREADS=1 python3 gp_noise.py
OPENBLAS_NUM_THREADS=1 python3 gp_noise.py --compare --resolution 128 --out output_nosync/comparison
OPENBLAS_NUM_THREADS=1 python3 -m unittest -v
```

If dependencies are missing, create a virtual environment named `.venv_nosync`
and use its `pip3 install -r requirements.txt`. Limiting BLAS threads avoids
thread overhead on these small matrix operations.

[Comparison sheet](http://bigger/art/gaussian-process-noise/output_nosync/comparison/comparison.png)

[Example draw](http://bigger/art/gaussian-process-noise/output_nosync/example/sample.png)

Each run saves mean, sample, and residual images, unchanged floating-point
fields in NPZ, and the CLI parameters in JSON. Comparison runs save one NPZ per
case. All image colors use the same fixed symmetric display range; saturation is
clipping for display only. Array row zero is y=0; image bottom is y=0.

## Parameters

| Flag               | Default               | Meaning                                                         |
| ------------------ | --------------------- | --------------------------------------------------------------- |
| `--radius`         | 0.18                  | Minimum Bridson spacing on the unit square                      |
| `--count`          | automatic             | Random subset of a saturated set; errors if insufficient points |
| `--seed`           | 7                     | Locations and IID handle values                                 |
| `--sample-seed`    | seed + 1              | Independent posterior randomness                                |
| `--kernel`         | matern32              | rbf, matern32, or matern52                                      |
| `--length`         | 0.10                  | Kernel correlation length                                       |
| `--value-variance` | 1                     | Variance of random handle values                                |
| `--variance`       | 1                     | Prior marginal field variance                                   |
| `--noise`          | 0                     | Observation noise variance; positive values soften constraints  |
| `--alpha`          | 1                     | Residual multiplier; zero gives mean                            |
| `--features`       | 2048                  | Number of random spectral frequency pairs                       |
| `--resolution`     | 256                   | Pixels along each edge                                          |
| `--display-limit`  | 3                     | Colors map from minus to plus this value                        |
| `--controls`       | none                  | JSON file of additional `[x, y, value]` triples                 |
| `--out`            | output_nosync/example | Output directory                                                |
| `--compare`        | off                   | Fixed sweep: 2 spacings, 3 lengths, 3 kernels, mean/draw pairs  |

`--count 0 --controls controls.json` allows a field driven entirely by explicit
handles. For example, `[[0.25, 0.5, 1], [0.75, 0.5, -1]]`. Coordinates use the
unit square. Conflicting duplicate controls should be avoided with zero noise.
This is a data hook, not an interactive editor.

## Architecture and approximation

`poisson_disk` implements Bridson's active-list algorithm with a sparse spatial
hash and uniform-area annulus proposals. Optional count selection happens after
saturation, avoiding a partial cluster grown from the initial seed. Small radii
can still produce very large observation sets; use modest spacing for this
prototype.

`GP` accepts arbitrary points and scalar values. It uses analytic stationary
kernels and a Cholesky factorization of the observation covariance only:

```text
A = K(X, X) + (noise_variance + jitter) I
mean(x) = K(x, X) A^-1 values
residual(x) = prior_draw(x) - K(x, X) A^-1 (prior_draw(X) + noise_draw)
field(x) = mean(x) + alpha * residual(x)
```

The mean is exact up to a fixed 1e-10 numerical nugget. The posterior draw is
**approximate**: the prior uses random Fourier features, with Gaussian
frequencies for RBF and multivariate Student-t frequencies with 3 or 5 degrees
of freedom for Matern. Sine/cosine coefficients are Gaussian. Increasing feature
count improves the prior covariance approximation. This is pathwise conditioning
with an approximate prior and analytic correction, following
[Wilson et al.](https://proceedings.mlr.press/v119/wilson20a.html).

Alpha=1 approximates the specified posterior. Other alpha values deliberately
alter its covariance by alpha squared while preserving its mean. With zero
observation noise, constraints hold up to numerical nugget effects. With
positive noise, draws and means can depart from observed values.

`PosteriorDraw` stores frequencies and coefficients, so changing resolution,
chunk size, or evaluation order evaluates the same function. No periodic tiling
is imposed. Seed reproducibility assumes the same numerical library environment.

For N handles, M pixels, and F frequency pairs, fitting costs O(N^3), and a draw
costs O(M(N+F)) after fitting. Evaluation is chunked: memory is O(N^2 + M +
chunk\*(N+F)). There is no M-by-M covariance or dense grid factorization.

Tests cover point separation, interpolation, deterministic chunked evaluation,
alpha=0, empty observations, and empirical posterior covariance at off-grid
points for all three kernels, including noisy observations.

## First visual findings

- Sparse handles with length much smaller than spacing produce isolated bumps in
  the mean. Posterior samples fill the blank regions with coherent variation.
- The sparse RBF row at length 0.12 is a promising smooth direction: broad,
  irregular patches without an obvious lattice. Matern 5/2 adds finer structure;
  Matern 3/2 is visibly rougher at the same nominal length.
- Long correlation lengths plus dense IID handles can fight each other. RBF at
  radius 0.12 and length 0.24 reaches absolute values around 18 between handles.
  The fixed color range intentionally exposes this overshoot. More smoothing
  does not necessarily mean calmer fields.
- Dense constraints suppress the difference between mean and draw. Sparse
  constraints leave more scope for changing the sample seed without moving the
  handles.
- This does not yet establish a visual advantage over Perlin. There is no
  matched-spectrum Perlin comparison here; avoiding a lattice alone does not
  guarantee a distinct aesthetic. The most promising distinction is the ability
  to preserve a few deliberate landmarks across different random completions.

A useful next experiment is holding three deliberate landmarks fixed and making
a sheet of different posterior seeds. That isolates artistic steering from the
particular layout of random handles. The Python harness stays single-octave; the
browser now includes the separate two-layer demo described below.

## Browser playground

[Open the playground](http://bigger/art/gaussian-process-noise/browser/).

The static `browser/` directory needs only an HTTP server. It runs entirely in
JavaScript, with a Web Worker keeping the interface responsive. It shows the
mean and posterior draw together, supports all three kernels, and exposes
spacing, correlation length, residual amplitude, variances, seeds, and quality.
Changing residual amplitude or display settings only repaints cached fields.
Other changes produce a 64-pixel preview followed by the selected resolution.
Obsolete workers are terminated when parameters change.

The browser uses the same mathematical model but its own seeded random number
generator, so Python and browser seeds do not produce identical fields. The
browser defaults to 1,024 frequency pairs. Sine/cosine recurrence along
scanlines reduces trigonometric evaluation cost without changing the spectral
model.

Initial timings on bigger (single run, not a rigorous benchmark):

| Resolution | Python, 2,048 pairs, 24 handles | JS engine in Node, 1,024 pairs, 15 handles |
| ---------- | ------------------------------- | ------------------------------------------ |
| 64         | 0.34 s                          | 0.034 s                                    |
| 128        | 1.35 s                          | 0.066 s                                    |
| 256        | 5.30 s                          | 0.257 s                                    |
| 512        | 20.75 s                         | 0.956 s                                    |

These are field evaluation times, excluding image drawing. Python fit took about
3 ms, while the JS fit including Bridson sampling took about 10 ms. Browser
status reports measured fit and field times on your device. Different settings,
hardware, and browser engines change performance. The two columns use different
feature counts and handle sets, so they are not an equal-work speed comparison.

### Editing handles

Handles are separate HTML overlays over the clean canvas images. Select a handle
in either view, drag it to move, and adjust its value with the sidebar slider or
numeric field. Arrow keys move it by 0.005; Shift + arrow moves it by 0.05.
Double-click empty space to add a zero-valued handle. Use Remove handle or
Delete to remove the selection. Nearly coincident handles are rejected to avoid
conflicting pinned observations. Removing all handles leaves an unconstrained
prior draw.

Edited handles survive changes to kernel, correlation length, posterior seed,
and render quality. New handles, spacing, handle seed, and handle value variance
regenerate the observations and replace edits. Edits are held for the current
page session only. Hide handles hides the overlay without modifying image
pixels.

Hover a parameter control or its question-mark button for an explanation. The
question-mark buttons also work by keyboard focus or tap. Explanations describe
visual effects and flag controls that replace edited handles.

### Density-gradient demo

[Open the density-gradient demo](http://bigger/art/gaussian-process-noise/browser/?demo=density).

Handle values remain IID Gaussian. Only the spatial density changes: a dense
Bridson set is thinned with retention probability `0.06 + 0.94*x^4`, making the
left moderately dense and the right much denser. The minimum spacing still
holds, but the thinned set is not saturated. Defaults use spacing 0.04 and
Matern 5/2 at length 0.10. The link opens a separate tab so the uniform
playground keeps its edits. Manual additions are unrestricted by the density
profile.

New manual handles use a separate deterministic random sequence derived from the
handle seed and placement count, scaled by handle value variance. Deleting and
replacing a handle gives a new value. Regenerating the layout resets that
sequence. Selecting a handle also reveals a Delete button directly over each
image, in addition to the sidebar button and keyboard shortcut.

## Hosting and source

- [Playground](https://noise.joshu.dev/)
- [Density-gradient demo](https://noise.joshu.dev/?demo=density)
- [Private GitHub repository](https://github.com/jschachter/gaussian-process-noise)

The Cloudflare Pages project uses direct upload. To publish the current browser
code with Python 3, npm, and Wrangler installed and Cloudflare authentication
configured (and Chromium installed for the live-browser check):

```bash
npm run deploy
```

`npm run build` puts only the browser files and README into `site_nosync/`.
JavaScript filenames include a content hash shared by the interface, worker, and
engine so cached scripts from different builds cannot get mixed. The deployment
does not contain generated media, field arrays, or credentials. GitHub pushes
and Cloudflare deployments are separate steps; pushing to GitHub alone does not
automatically deploy the site.

## Octave editor

[Open the two-layer demo](https://noise.joshu.dev/?demo=octaves).

The broad-structure layer starts with RBF at length 0.18 and amplitude 1. The
fine-detail layer starts with Matern 5/2 at length 0.05 and amplitude 0.3.
Scales are freely adjustable; there is no forced frequency ratio.

Select a layer to edit its handles, kernel, spacing, length, seeds, variance,
randomness, and spectral quality. The selected layer retains its own edits and
selection when switching. Resolution and display range are shared. Only the
selected layer's handles appear over the combined mean and combined draw. Handle
values constrain their own layer; other layers can change the final sum at those
locations.

Each enabled layer contributes `amplitude * (mean + randomness * residual)`.
Disabling a layer mutes it. If any enabled layers are soloed, only those layers
contribute. Amplitude, mute, solo, and randomness changes repaint cached fields.
Other edits currently recompute both independent fields in the worker, using
sparse solves for each layer. The two existing single-layer demos remain
available. Layer edits last for the current page session.

`npm run verify:live` checks the deployed octave, density, and single-layer
demos in a fresh Chromium browser, waits for completed renders, and checks layer
switching. Deployment runs this check automatically.
