spacr.qt.space

Procedural deep-space imagery for the Space theme.

Why generate instead of ship a JPEG

Two things rule out bundling a photograph:

  • a multi-megabyte image per aspect ratio bloats the wheel, and

  • a fixed 1920x1080 asset is soft on a 5K display — the one place a space background actually needs to look good.

So the background is synthesised with numpy at the display’s native pixel size and cached to the user’s config directory, keyed by (width, height, variant, seed, CACHE_VERSION). It is computed once per screen size and reloaded from disk on every later launch.

Everything here is seeded and deterministic: the same (width, height, variant, seed) always produces byte-identical pixels, which is what lets the test suite assert on the output.

Three generators, composed

starfield

A magnitude distribution drawn from the Euclidean number-count law (N(>F) ∝ F^-3/2) — many faint stars, very few bright ones — with stellar colour sampled from a blackbody locus spanning hot blue O/B through white A/F/G to cool red K/M, and 4-way diffraction spikes on only the handful of brightest stars.

galaxy

A two-arm logarithmic spiral (r = a·e^(bθ)) rendered as an analytic field, with dust lanes trailing the arms, a warm Sérsic-ish core bulge and an exponential disc falloff, projected at an inclination.

sun

A star disc with classic linear limb darkening (I(µ) = 1 − u(1 − µ)), value-noise granulation on the photosphere, and a corona that falls off smoothly with radial streamers.

They are composed rather than offered as three separate wallpapers because one sky containing all three reads as a photograph of space; separate wallpapers read as three clip-art assets. variant re-weights the composition (which element is the subject) rather than switching elements on and off, so no variant ever loses the stars.

Legibility is solved here too, not only for the photographs

spacr.qt.imagery has always run every photographic master through spacr.qt.imagery.solve_dim() against spacr.qt.imagery.exposure_target() — the brightest a bare window background may be before white text on it drops under WCAG AA. The generated sky never went through it, and it showed: measured at 1440x900, the brightest text-line-sized region of the galaxy sky was 0.4879 against a 0.0586 limit (8.3x over), sun 0.8201 (14.0x) and stars 0.5041 (8.6x). Bare white text over that measured 1.20-1.96:1 where 4.5:1 is required — the “AI” toggle in the title bar sat on the sun’s halo. render() now solves it, and legibility() reports the same measured dict spacr.qt.imagery.legibility() returns for a photograph.

Reconciling the limit with the 40th-percentile anchor

TARGET_SKY_PERCENTILE exposes the frame so that empty sky — not the mean — lands on TARGET_SKY_LUMA, deliberately, so that a sun stays blown out instead of dragging the whole frame to black. Taken as a statement about one global exposure that is now flatly incompatible with the limit, and both of the obvious ways to force it are ruined pictures. Re-solving the exposure, or dimming the finished frame with spacr.qt.imagery.solve_dim(), needs a factor of 0.064-0.108; both take the sky’s own 40th percentile from 0.00091 to 0.00000, i.e. every faint star and the whole nebula go to pure black, and the peak pixel falls from 236-252 to 67-90. Measured, rendered and looked at: a dark grey smudge.

The reconciliation is that the two rules are about different things. The exposure anchor is about a pixel with nothing in it; the WCAG limit is about the mean over a region the size of a line of text (spacr.qt.imagery.TEXT_WINDOW), which is the point spacr.qt.imagery already makes about photographs — every photograph has a white pixel somewhere and it is a bright patch that makes a caption unreadable. A 3 px star inside a 202x48 px text window moves that window’s mean by a quarter of one per cent; the whole starfield measured on its own comes to 0.0007-0.0067, i.e. 1-11 % of the limit. A 207 px sun disc fills the window completely and cannot.

So the exposure anchor is kept exactly as it was — the sky background is unchanged, to the byte — and the limit is enforced where it is actually violated, by compressing the highlights of the smooth layers only (_compress_highlights(), applied to nebula + galaxy + sun + bloom, never to the starfield). The sun therefore does get noticeably darker, which is the accepted cost; the galaxy is barely touched because it was never the offender; the stars keep white cores and diffraction spikes. _enforce_legibility() then measures the finished frame with the same spacr.qt.imagery.brightest_window() used on the photographs and applies whatever residual dim is left, so the guarantee is a measurement and not an argument.

Real imagery

download_nasa_background() optionally fetches a public-domain NASA/ESA image instead. NASA media is public domain (see https://www.nasa.gov/nasa-brand-center/images-and-media/) and the credit line is recorded so the UI can display it. Nothing here touches the network at import time or from any code path other than that one function, so an offline machine silently gets the procedural sky.

Functions

attribution_text(→ str)

One-line credit for the imagery currently in use.

background_path(→ Optional[pathlib.Path])

Return the on-disk path of the background, generating if needed.

cache_dir(→ pathlib.Path)

Directory holding generated backgrounds.

cache_name(→ str)

Return the versioned cache filename for a procedural background.

clear_cache(→ int)

Delete every cached background. Returns the number removed.

download_nasa_background(→ Optional[dict])

Download a public-domain NASA image and record its credit.

downloaded_background(→ Optional[pathlib.Path])

Path of the downloaded NASA image, or None if there isn't one.

exposure_target(→ float)

Luminance the brightest text-line-sized region is aimed at.

galaxy(, radius_frac, arms, inclination, ...)

Render a logarithmic-spiral galaxy, (height, width, 3) float32.

highlight_ceiling(→ float)

HDR luminance that tone-maps to target's encoded value.

imagery_dir(→ pathlib.Path)

Return the cache directory containing downloaded NASA imagery.

legibility(→ dict)

Measure how readable the generated sky actually is.

read_credits(→ Optional[dict])

Return the recorded attribution for the downloaded image, if any.

render(→ numpy.ndarray)

Render the composed sky as an (height, width, 3) uint8 array.

sample_star_fluxes(→ numpy.ndarray)

Draw n relative fluxes (>= 1.0) from the Euclidean count law.

sample_star_temperatures(→ numpy.ndarray)

Draw n stellar temperatures from the naked-eye class mix.

screen_size() → Tuple[int, int])

Native pixel size of the primary screen, or default headless.

star_colors(→ numpy.ndarray)

Map blackbody temperatures (K) to sRGB floats in [0, 1].

starfield(→ numpy.ndarray)

Render a starfield as an (height, width, 3) float32 HDR buffer.

sun(, radius_frac, temperature, corona_scale)

Render a star with limb darkening, granulation and a corona.

to_qimage(arr)

Convert an (h, w, 3) uint8 array to a detached QImage.

tone_exposure(→ float)

The exposure _tone_map() would use for this frame.

Module Contents

spacr.qt.space.attribution_text() → str[source]

One-line credit for the imagery currently in use.

spacr.qt.space.background_path(width: int, height: int, variant: str = DEFAULT_VARIANT, seed: int = DEFAULT_SEED, regenerate: bool = False) → pathlib.Path | None[source]

Return the on-disk path of the background, generating if needed.

Returns None (never raises) when the background cannot be produced — a read-only home directory, no PNG writer, anything. The Space theme falls back to a flat gradient in that case, so a failure here costs some prettiness and nothing else.

Parameters:
  • width – output width in pixels; clamped to 16-3840.

  • height – output height in pixels; clamped to 16-2400.

spacr.qt.space.cache_dir() → pathlib.Path[source]

Directory holding generated backgrounds.

Honours $SPACR_SPACE_CACHE so tests (and read-only homes) can redirect it; otherwise ~/.spacr/backgrounds, matching where the verbose logger already writes.

spacr.qt.space.cache_name(width: int, height: int, variant: str, seed: int) → str[source]

Return the versioned cache filename for a procedural background.

Parameters:
  • width – background width in pixels.

  • height – background height in pixels.

  • variant – background variant name, such as 'galaxy', 'sun' or 'stars'.

  • seed – random seed the background was rendered with.

spacr.qt.space.clear_cache() → int[source]

Delete every cached background. Returns the number removed.

spacr.qt.space.download_nasa_background(key: str = 'carina', timeout: float = 20.0, opener=None) → dict | None[source]

Download a public-domain NASA image and record its credit.

Never called at import time, and never from a test. The Space theme works without it; this is strictly an upgrade the user opts into from Preferences.

Parameters:
  • key – which entry of NASA_IMAGES to fetch.

  • timeout – socket timeout in seconds.

  • opener – injection point for tests — a callable taking (url, timeout) and returning bytes. Defaults to urllib.request.urlopen.

Returns:

the credit dict on success, None on any failure (offline, 404, unwritable cache). Callers must treat None as “keep using the procedural sky”, not as an error.

spacr.qt.space.downloaded_background() → pathlib.Path | None[source]

Path of the downloaded NASA image, or None if there isn’t one.

spacr.qt.space.exposure_target() → float[source]

Luminance the brightest text-line-sized region is aimed at.

The Space palette’s own limit from spacr.qt.theme.max_background_luma(), backed off by spacr.qt.imagery.SAFETY_MARGIN — the identical number the photographic masters are solved to, fetched from the identical function, so the two pipelines cannot drift apart.

spacr.qt.imagery imports this module at module scope, so the import has to be deferred to call time. By then space is fully loaded whichever of the two the caller reached first.

spacr.qt.space.galaxy(width: int, height: int, seed: int = DEFAULT_SEED, center: Tuple[float, float] = (0.3, 0.34), radius_frac: float = 0.38, arms: int = 2, inclination: float = 0.62, position_angle: float = -0.55, pitch: float = 0.3) → numpy.ndarray[source]

Render a logarithmic-spiral galaxy, (height, width, 3) float32.

Parameters:
  • width – width of the rendered image in pixels.

  • height – height of the rendered image in pixels.

  • center – galaxy centre as a fraction of (width, height).

  • radius_frac – disc scale radius as a fraction of the short edge.

  • arms – number of spiral arms.

  • inclination – 1.0 = face on, 0.0 = edge on.

  • position_angle – rotation of the disc, radians.

  • pitch – b in r = a·e^(bθ); smaller = more tightly wound.

spacr.qt.space.highlight_ceiling(exposure: float, target: float | None = None) → float[source]

HDR luminance that tone-maps to target’s encoded value.

exposure_target() is a linear-light relative luminance of the finished image. _tone_map’s output is written straight to 8-bit without a gamma encode, so it is an sRGB signal value, and the two are a transfer function apart — the 0.0586 limit is a mid-dark grey around #444444, not a 6 % signal. Encode first, then invert 1 - exp(-x·E).

Parameters:

exposure – tone-mapping exposure, as returned by tone_exposure(); zero or a negative value returns inf.

Returns:

inf when there is nothing to solve for — a palette that admits no wallpaper at all (spacr.qt.theme.max_background_luma() is negative for the light theme) or a zero exposure. Callers read that as “no ceiling”, never as “clamp everything to zero”. A palette that admits a white wallpaper lands on a ceiling far above anything the generators emit, which comes to the same thing without a second branch to leave untested.

spacr.qt.space.imagery_dir() → pathlib.Path[source]

Return the cache directory containing downloaded NASA imagery.

spacr.qt.space.legibility(variant: str = DEFAULT_VARIANT, width: int = 0, height: int = 0, seed: int = DEFAULT_SEED) → dict[source]

Measure how readable the generated sky actually is.

The same dict, measured the same way over the same region, that spacr.qt.imagery.legibility() returns for a photographic master — so “is the wallpaper legible” is one question with one answer shape whether the pixels were generated or photographed.

width/height default to screen_size().

spacr.qt.space.read_credits() → dict | None[source]

Return the recorded attribution for the downloaded image, if any.

spacr.qt.space.render(width: int, height: int, variant: str = DEFAULT_VARIANT, seed: int = DEFAULT_SEED, legible: bool = True) → numpy.ndarray[source]

Render the composed sky as an (height, width, 3) uint8 array.

Deterministic: identical arguments always give identical bytes.

Parameters:
  • width – width of the rendered image in pixels.

  • height – height of the rendered image in pixels.

  • legible – when true (always, outside the tests) the finished frame is bounded by the Space palette’s bare-text limit — see _compress_highlights() and _enforce_legibility(). False renders the unbounded sky, which exists so the test suite can measure what the bound is worth rather than assert that it was called.

The starfield is kept in its own buffer to the very end. That is not tidiness: it is the one layer the highlight ceiling must not touch, and keeping it separate is what lets a star core stay at 255 while the sun beside it comes down by two and a half stops.

spacr.qt.space.sample_star_fluxes(rng, n: int) → numpy.ndarray[source]

Draw n relative fluxes (>= 1.0) from the Euclidean count law.

The tail is unbounded in principle; it is clipped at FLUX_SATURATION so one absurd draw cannot whiteout the sky.

The resulting distribution is emphatically not uniform: by construction the fraction brighter than k·F_min is k^-1.5, i.e. ~65 % of stars sit in the faintest factor-of-two bin while only ~4 % are 8x brighter than the limit.

Parameters:
  • rng – NumPy random generator; its random method supplies the draws.

  • n – number of fluxes to draw; zero or fewer returns an empty array.

spacr.qt.space.sample_star_temperatures(rng, n: int) → numpy.ndarray[source]

Draw n stellar temperatures from the naked-eye class mix.

Parameters:
  • rng – NumPy random generator; its choice and random methods supply the draws.

  • n – number of temperatures to draw; zero or fewer returns an empty array.

spacr.qt.space.screen_size(default: Tuple[int, int] = (2560, 1440)) → Tuple[int, int][source]

Native pixel size of the primary screen, or default headless.

spacr.qt.space.star_colors(temps: numpy.ndarray) → numpy.ndarray[source]

Map blackbody temperatures (K) to sRGB floats in [0, 1].

Parameters:

temps – 1-D array of temperatures in kelvin; values outside 2000-40000 K are clipped to that range.

spacr.qt.space.starfield(width: int, height: int, seed: int = DEFAULT_SEED, density: float = STAR_DENSITY, spike_count: int = SPIKE_COUNT) → numpy.ndarray[source]

Render a starfield as an (height, width, 3) float32 HDR buffer.

Parameters:
  • width – output width in pixels.

  • height – output height in pixels.

spacr.qt.space.sun(width: int, height: int, seed: int = DEFAULT_SEED, center: Tuple[float, float] = (0.8, 0.74), radius_frac: float = 0.085, temperature: float = 5800.0, corona_scale: float = 1.9) → numpy.ndarray[source]

Render a star with limb darkening, granulation and a corona.

Parameters:
  • width – output width in pixels; the star is rendered at a third of this and upsampled.

  • height – output height in pixels; the star is rendered at a third of this and upsampled.

spacr.qt.space.to_qimage(arr: numpy.ndarray)[source]

Convert an (h, w, 3) uint8 array to a detached QImage.

Parameters:

arr – RGB image, an array of shape (h, w, 3); it is converted to contiguous uint8.

spacr.qt.space.tone_exposure(luma: numpy.ndarray) → float[source]

The exposure _tone_map() would use for this frame.

Split out of _tone_map() because render() needs the number itself: the highlight ceiling is a luminance in HDR units, and converting the palette’s limit — which is a luminance in the finished, tone-mapped image — back into HDR units is exactly inverting this curve at this exposure.

Parameters:

luma – 2-D array of HDR luminance for the frame.

Nested helpers

_bilinear_upsample._axis(n_out: int, n_in: int)

Sample positions and weights for one axis of the upsample.

A single input sample is handled separately: interpolating between one point and itself is a division by zero.

spacr/qt/space.py:177