diff --git a/flow-state/HOWTO-variety.md b/flow-state/HOWTO-variety.md index 4a69f7f..24e2238 100644 --- a/flow-state/HOWTO-variety.md +++ b/flow-state/HOWTO-variety.md @@ -37,11 +37,11 @@ the song. Some reference points, measured: ``` -0.219 Droste Feedback varies a lot -0.181 Spectrum Sculpture -0.049 Isometric Blocks after work — see below -0.039 Apollonian Gasket beautiful, and the same six times -0.031 Moiré Grid the same picture in six palettes +0.324 Droste Feedback varies a lot +0.082 Plasma Bloom +0.062 Voronoi Shatter after a focus +0.062 Apollonian Gasket +0.047 Moiré Grid after a subject; 0.031 before ``` --- @@ -70,8 +70,20 @@ Design against this, because half of "why is my score low" is here. | `orient` | grid vs radial vs stripes, rotation-blind | break or vary a regular lattice | | `layout` | where in the frame structure sits | move the subject, change what is empty | | `texture` | element count, sparsity, mirror and radial symmetry | vary how many things there are | +| `region` | how differently the parts of the frame behave from each other | give the frame a subject, so one region is unlike its neighbours | | `motion` | what moves and where, not how much | vary the KIND of movement, not the rate | +**`region` is the block that rewards having something to look at.** Layout says +where the energy is, normalised, so a uniform field and a field with a subject in +it can normalise to nearly the same answer. Region characterises each part of the +frame in its own right — detail, direction, element count — and reports how far +each deviates from the frame's average. A pattern spread evenly deviates by +nothing everywhere, which is exactly why there is nothing to watch. + +Measured when it was added: Apollonian Gasket 0.039 to 0.062 and Plasma Bloom to +0.082, both on region alone, because both have a subject the old blocks were not +crediting. It is the strongest single block in the descriptor. + **A known blind spot.** `layout` is nearly useless for full-frame fields. Voronoi Shatter measures 0.004 there no matter what changes, because edge-to-edge cells occupy the same frame however they fall. That is honest — there genuinely is no diff --git a/flow-state/src/checks/variety/descriptors.js b/flow-state/src/checks/variety/descriptors.js index c135c29..191f1a6 100644 --- a/flow-state/src/checks/variety/descriptors.js +++ b/flow-state/src/checks/variety/descriptors.js @@ -279,6 +279,65 @@ function colourBlock(pixels) { return [...Array.from(hist, (x) => x / total), sat / n, val / n]; } +/** + * Region block: how differently the parts of the frame behave from each other. + * + * The gap `layout` leaves. Layout says WHERE the energy is, normalised, so a + * uniform field and a field with a subject in it can normalise to nearly the + * same answer — measured, every full-frame scene sat at 0.004 there no matter + * what was done to it. What distinguishes them is not the distribution of + * energy but whether one part of the picture is doing something different from + * another. A pattern spread evenly has nothing to look at precisely because + * every region is the same region. + * + * So each region is characterised in its own right — how much detail, how + * anisotropic, how many elements — and then expressed as its DEVIATION from the + * frame's average. A uniform field deviates by nothing everywhere; a subject + * shows up as a region behaving unlike its neighbours, and which region and how + * is something the song can change. + */ +function regionBlock(band, cells = 3) { + const { data, w, h } = band; + const rw = Math.floor(w / cells), rh = Math.floor(h / cells); + const rows = []; + + for (let gy = 0; gy < cells; gy++) { + for (let gx = 0; gx < cells; gx++) { + let energy = 0, gxx = 0, gyy = 0, cross = 0, n = 0; + for (let y = gy * rh + 1; y < (gy + 1) * rh - 1; y++) { + for (let x = gx * rw + 1; x < (gx + 1) * rw - 1; x++) { + const i = y * w + x; + const v = data[i]; + energy += v * v; + const dx = data[i + 1] - data[i - 1]; + const dy = data[i + w] - data[i - w]; + gxx += dx * dx; + gyy += dy * dy; + if ((v > 0) !== (data[i - 1] > 0)) cross++; + n++; + } + } + n = n || 1; + rows.push([ + Math.sqrt(energy / n), // how much detail + (gxx - gyy) / (gxx + gyy + 1e-6), // which way it runs + cross / n, // how many elements + ]); + } + } + + // Deviation from the frame's own average, so this measures HETEROGENEITY + // rather than overall level — a brighter or busier frame does not register + // as a more varied one. + const mean = [0, 1, 2].map((k) => rows.reduce((a, r) => a + r[k], 0) / rows.length); + const scale = [0, 1, 2].map((k) => Math.max(1e-4, Math.abs(mean[k])) ); + return rows.flatMap((r) => [ + (r[0] - mean[0]) / scale[0], + r[1] - mean[1], + (r[2] - mean[2]) / scale[2], + ]); +} + /** * Full descriptor for one frame. * @@ -296,6 +355,7 @@ export function frameDescriptor(pixels, width, height) { scale: scaleBlock(bands), orient: orientBlock(band), layout: layoutBlock(band), + region: regionBlock(band), texture: textureBlock(band), colour: colourBlock(pixels), }; diff --git a/flow-state/src/checks/variety/signature.js b/flow-state/src/checks/variety/signature.js index d365db0..9adb2ff 100644 --- a/flow-state/src/checks/variety/signature.js +++ b/flow-state/src/checks/variety/signature.js @@ -15,7 +15,7 @@ import { frameDescriptor, motionDescriptor } from './descriptors.js'; /** Blocks that count toward the structural score. Colour is measured, not counted. */ -export const STRUCTURAL = ['scale', 'orient', 'layout', 'texture', 'motion']; +export const STRUCTURAL = ['scale', 'orient', 'layout', 'region', 'texture', 'motion']; export const ALL_BLOCKS = [...STRUCTURAL, 'colour']; /** @@ -26,7 +26,7 @@ export const ALL_BLOCKS = [...STRUCTURAL, 'colour']; * block anyway — so the diagnosis survives even if the single number is * weighted wrong. */ -const WEIGHTS = { scale: 1, orient: 1, layout: 1, texture: 1, motion: 1 }; +const WEIGHTS = { scale: 1, orient: 1, layout: 1, region: 1, texture: 1, motion: 1 }; /** * Chi-square distance, 0..1, for the blocks that are normalised histograms. @@ -61,8 +61,15 @@ function scaledL1(a, b, spans) { return d / a.length; } +// Region entries are signed deviations from a frame mean, in three repeating +// kinds, so they need spans rather than a histogram distance. +const REGION_SPAN = [1.2, 1.0, 1.2]; + function blockDistance(name, a, b) { if (name === 'texture') return scaledL1(a, b, TEXTURE_SPAN); + if (name === 'region') { + return scaledL1(a, b, a.map((_, i) => REGION_SPAN[i % 3])); + } if (name === 'colour') { // Hue distribution, then saturation and brightness, weighted so a hue // rotation reads as the large colour change it is.