spacr.qt.imagery

Photographic backgrounds for image-backed themes.

Why this exists next to spacr.qt.space

spacr.qt.space generates procedural backgrounds. This module prepares three bundled photographs—two microscopy images and one deep-field astronomy image—for the Space and Cell themes.

It reuses space’s cache directory, its size clamps and its “never raise, fall back to something” contract, so from the outside a photo background and a generated one behave identically. What is new is the part that photographs need and procedural pixels do not: a decode budget, a crop policy, and a measured legibility check.

Decoded-image memory, not compressed file size, sets the performance limit. Large source photographs are therefore prepared as follows:

  • the masters shipped in spacr/resources/themes are already cropped and capped at MASTER_CAP (3840x2400 — spacr.qt.space.MAX_DIM), so the largest thing ever decoded at runtime is ~27 MB, not 281 MB;

  • each screen size is rendered once and cached as JPEG under ~/.spacr/backgrounds; and

  • decode_count() reports master decodes for diagnostics and tests.

The crop policy

MASTERS records, per image, the sub-rectangle of the original that is usable and the vertical focus for the aspect crop. Two of the three are trimmed for a reason:

  • cell_2.png carries a burned-in “5 um” scale bar and label in the bottom right (measured at x 0.825-0.949, y 0.925-0.953 of the frame). A wallpaper is not a figure, and a stray scale bar reads as an artefact, so its source_crop cuts above it. Because the shipped master is built from that crop, no runtime crop can bring the bar back.

  • space_1.jpeg and cell.png carry no burned-in annotation (checked: zero 32x32 blocks more than 35 % saturated achromatic white), so they are used whole.

Legibility is solved, not eyeballed

These are not a procedural sky of point stars — cell.png’s cyan core fills most of the frame and measures 0.236 in the brightest text-line-sized window, which is far too bright to put white-on-nothing text over. So each master is dimmed at build time by a factor solved from the palette: spacr.qt.theme.max_background_luma() returns the brightest a bare window background may be before some role that gets painted straight onto it (fg, fg_muted, accent, fg_dim, the status hues) drops below its WCAG minimum, and solve_dim() scales the image in linear light until the brightest TEXT_WINDOW-sized region lands on that number less SAFETY_MARGIN.

The same solve runs again in render(), where it is a no-op on the shipped masters and the whole guarantee for an image the user dropped into ~/.spacr/themes themselves.

spacr.qt.space now uses the same three functions — exposure_target(), brightest_window() and solve_dim() — on its generated sky, which for a long time was the one wallpaper in the app that had never been measured against the rule the photographs were held to. It cannot call solve_dim() on the finished frame (that lands the sky on a solid black rectangle; the numbers are in that module’s docstring), so it applies the ceiling where the frame actually breaks the rule and then measures the result here.

Scrimmed surfaces need no such treatment: spacr.qt.theme.contrast_report() already judges every panel against a pure white background, which is the worst case any photograph can present.

Functions

available_keys(→ Tuple[str, ...])

Keys whose master is actually present on this machine.

background_path(→ Optional[pathlib.Path])

On-disk path of key's wallpaper at this size, rendering if needed.

brightest_window(→ Tuple[float, str])

Brightest window-sized region of a uint8 RGB image.

build_master(→ Optional[pathlib.Path])

Turn one original into the master that ships in the wheel.

build_masters(→ Dict[str, Optional[pathlib.Path]])

Build every master in MASTERS. See build_master().

cache_name(→ str)

Return the versioned cache filename for a photographic background.

clear_cache(→ int)

Delete every cached photo background. Returns the number removed.

cover_box(→ Tuple[int, int, int, int])

Largest sub-rectangle of the source that has the output's aspect.

decode_count(→ int)

How many times a master has been decoded this process.

dim(→ numpy.ndarray)

Return arr darkened by factor in linear light.

exposure_target(→ float)

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

legibility(→ Optional[dict])

Measure how readable key's master actually is.

legibility_of(→ dict)

Measure how readable a wallpaper's pixels actually are.

linear_rgb(→ numpy.ndarray)

Map a uint8 (h, w, 3) image to linear-light floats in [0, 1].

luminance_map(→ numpy.ndarray)

WCAG relative luminance of every pixel of a uint8 RGB array.

master_array(→ Optional[numpy.ndarray])

The shipped master as a uint8 (h, w, 3) array, or None.

master_dirs(→ Tuple[pathlib.Path, ...])

Directories searched for a master, most specific first.

master_path(→ Optional[pathlib.Path])

Absolute path of key's master image, or None.

rects_overlap(→ bool)

True when two (x0, y0, x1, y1) rectangles share any area.

render(key, width, height)

Render key's wallpaper at exactly width x height.

reset_decode_count(→ None)

Zero the decode counter.

solid_annotation_blocks(→ int)

Count blocks that look like burned-in annotation.

solve_dim(→ float)

Exposure factor taking measured luminance down to target.

solve_image_file(→ bool)

Exposure-solve an image file in place. True when it is legible.

srgb_encode(→ float)

Encode one linear-light value as an sRGB signal value in [0, 1].

theme_for(→ Optional[str])

Which theme's palette key is judged against, or None.

title_for(→ str)

Human-readable name of a wallpaper, for Preferences.

user_dir(→ pathlib.Path)

Directory where a user may drop replacement masters.

Module Contents

spacr.qt.imagery.available_keys() → Tuple[str, ...][source]

Keys whose master is actually present on this machine.

spacr.qt.imagery.background_path(key: str, width: int = 0, height: int = 0, regenerate: bool = False) → pathlib.Path | None[source]

On-disk path of key’s wallpaper at this size, rendering if needed.

Returns None — never raises — when it cannot be produced: no master, a read-only home directory, no image encoder. Callers treat that as “use the procedural sky” or “use the flat gradient”, so a failure here costs some prettiness and nothing else.

Parameters:

key – wallpaper key in MASTERS, e.g. "microtubules"; also names the cached file.

spacr.qt.imagery.brightest_window(arr: numpy.ndarray, window: Tuple[float, float] = TEXT_WINDOW) → Tuple[float, str][source]

Brightest window-sized region of a uint8 RGB image.

Parameters:

arr – uint8 RGB image array of shape (h, w, 3).

Returns:

(luminance, "#rrggbb") — the region’s mean relative luminance and its mean colour, which is what spacr.qt.theme.image_contrast_report() should be handed as the thing text has to be readable over.

Averaging is done in linear light, so the returned colour’s own luminance is exactly the returned number.

spacr.qt.imagery.build_master(key: str, src_dir, dst_dir) → pathlib.Path | None[source]

Turn one original into the master that ships in the wheel.

Crop away burned-in annotation, take the MASTER_ASPECT crop around the subject, cap at MASTER_CAP, dim until the brightest text-line-sized region satisfies the theme’s palette, and write JPEG.

Kept in the shipped module rather than a build script so the assets can be re-derived from the originals by anyone who has them, and so the crop rectangles live next to the code that documents why they are where they are.

Parameters:
  • key – wallpaper key in MASTERS; an unknown key raises KeyError.

  • src_dir – directory holding the original image named by the entry’s "source".

  • dst_dir – directory the JPEG master is written into, under the entry’s "file" name; created if missing.

Returns:

the written path, or None if the original is missing.

spacr.qt.imagery.build_masters(src_dir, dst_dir=None) → Dict[str, pathlib.Path | None][source]

Build every master in MASTERS. See build_master().

Parameters:

src_dir – directory holding the originals, passed to build_master() for every key.

spacr.qt.imagery.cache_name(key: str, width: int, height: int) → str[source]

Return the versioned cache filename for a photographic background.

Parameters:
  • key – wallpaper key, embedded in the name as photo-<key>-....

  • width – image width in pixels.

  • height – image height in pixels.

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

Delete every cached photo background. Returns the number removed.

spacr.qt.imagery.cover_box(src_w: int, src_h: int, out_w: int, out_h: int, focus: float = 0.5) → Tuple[int, int, int, int][source]

Largest sub-rectangle of the source that has the output’s aspect.

“Cover”, never “contain”: the result always fills the target, so the stylesheet — which centres the image without repeating it — can never end up letterboxing it into bands of flat colour.

Parameters:
  • src_w – width of the source image in pixels.

  • src_h – height of the source image in pixels.

  • out_w – width of the target area in pixels.

  • out_h – height of the target area in pixels.

  • focus – vertical centre of the crop as a fraction of the source height. Clamped so the box stays inside the frame.

spacr.qt.imagery.decode_count() → int[source]

How many times a master has been decoded this process.

Every master decode goes through _open_master(), which bumps this. A resize or a repaint must not move it: the window paints a cached, screen-sized JPEG that Qt loaded once when the stylesheet was applied. reset_decode_count() exists so a test can measure a span rather than an absolute.

spacr.qt.imagery.dim(arr: numpy.ndarray, factor: float) → numpy.ndarray[source]

Return arr darkened by factor in linear light.

Parameters:
  • arr – uint8 image array; every value is mapped through a 256-entry lookup table, so any shape works.

  • factor – multiplier applied to linear-light values; 1.0 or more returns arr unchanged.

spacr.qt.imagery.exposure_target(theme: str) → float[source]

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

The palette’s hard WCAG limit, backed off by SAFETY_MARGIN.

Parameters:

theme – theme name whose palette sets the limit, e.g. "cell" or "space"; see spacr.qt.theme.max_background_luma().

spacr.qt.imagery.legibility(key: str) → dict | None[source]

Measure how readable key’s master actually is.

See legibility_of() for the returned dict. None when the master is not installed.

Parameters:

key – wallpaper key in MASTERS; the master is judged against that entry’s theme.

spacr.qt.imagery.legibility_of(arr: numpy.ndarray, theme: str, key: str = '') → dict[source]

Measure how readable a wallpaper’s pixels actually are.

Everything in the returned dict comes from the real image data:

brightest luminance of the worst TEXT_WINDOW-sized region color that region’s mean colour, as #rrggbb limit the most that region is allowed to be, from the palette target what the exposure aimed at — limit less the margin passes whether it is within the limit failures every WCAG rule the theme fails over that region

Takes an array rather than a registry key so the generated sky can be held to the identical measurement — spacr.qt.space.legibility() is this function over a rendered frame. Until it was, the sky was the one background in the app that had never been measured, and it was 8-14x over the limit.

Parameters:
  • arr – uint8 RGB image array of shape (h, w, 3).

  • theme – theme name whose palette sets limit and failures.

spacr.qt.imagery.linear_rgb(arr: numpy.ndarray) → numpy.ndarray[source]

Map a uint8 (h, w, 3) image to linear-light floats in [0, 1].

Parameters:

arr – uint8 image array; each value is looked up in a 256-entry table, so the result has the same shape.

spacr.qt.imagery.luminance_map(arr: numpy.ndarray) → numpy.ndarray[source]

WCAG relative luminance of every pixel of a uint8 RGB array.

Parameters:

arr – uint8 RGB image array of shape (h, w, 3); the result has shape (h, w).

spacr.qt.imagery.master_array(key: str) → numpy.ndarray | None[source]

The shipped master as a uint8 (h, w, 3) array, or None.

Decoded at 1/8 scale where the format allows it and box-averaged the rest of the way — see _probe().

Parameters:

key – wallpaper key in MASTERS; see master_path().

spacr.qt.imagery.master_dirs() → Tuple[pathlib.Path, ...][source]

Directories searched for a master, most specific first.

spacr.qt.imagery.master_path(key: str) → pathlib.Path | None[source]

Absolute path of key’s master image, or None.

None is a normal outcome, not an error: a source build with the assets stripped, or an unknown key. Callers fall back to the procedural sky or to the flat gradient.

Parameters:

key – wallpaper key in MASTERS; its "file" is looked for in each of master_dirs() in turn.

spacr.qt.imagery.rects_overlap(a: Tuple[float, float, float, float], b: Tuple[float, float, float, float]) → bool[source]

True when two (x0, y0, x1, y1) rectangles share any area.

Parameters:
  • a – first rectangle as (x0, y0, x1, y1).

  • b – second rectangle, same form. Rectangles that only touch along an edge do not overlap.

spacr.qt.imagery.render(key: str, width: int, height: int)[source]

Render key’s wallpaper at exactly width x height.

Parameters:
  • key – wallpaper key in MASTERS.

  • width – output width in pixels, clamped to spacr.qt.space.MIN_DIM and spacr.qt.space.MAX_DIM.

  • height – output height in pixels, clamped the same way.

Returns:

a PIL image, or None when the master is missing.

The crop and the resample happen in a single Image.resize call with a box, so no intermediate full-resolution crop is materialised.

The dim solve runs here as well as in build_master(), and it is deliberately not redundant: on the shipped masters it resolves to a no-op because they are already at the limit, but a user who drops their own photograph into ~/.spacr/themes gets the same guarantee without having to know it exists. Solving costs one pass over a 480 px thumbnail plus one 256-entry lookup table.

spacr.qt.imagery.reset_decode_count() → None[source]

Zero the decode counter.

spacr.qt.imagery.solid_annotation_blocks(arr: numpy.ndarray) → int[source]

Count blocks that look like burned-in annotation.

Annotation — a scale bar, a label, a timestamp — is solid, achromatic and at the frame’s peak brightness, and it covers whole blocks. Real content in these images does not: the brightest galaxy core in the deep field fills 15 % of a block, a drawn scale bar fills 63 %.

The thresholds are relative to the image’s own 99.99th percentile rather than absolute, because these masters are deliberately exposed differently — the microtubule frame peaks at 218, not 255, and an absolute “≥ 190 is white” test would go blind on it.

Parameters:

arr – uint8 RGB image array of shape (h, w, 3); an empty array or one smaller than ANNOTATION_BLOCK on either side gives 0.

Returns:

number of blocks that look like annotation. Zero for every shipped master; the tests also check it is non-zero on the same masters with a bar drawn on, so a detector that has quietly stopped detecting cannot pass.

spacr.qt.imagery.solve_dim(measured: float, target: float) → float[source]

Exposure factor taking measured luminance down to target.

Closed form rather than the bisection spacr.qt.space needs, because scaling linear light scales relative luminance by exactly the same factor — there is no tone curve in the way. Never brightens: an image already dark enough is left alone.

Parameters:
  • measured – current linear relative luminance; 0 or less gives 1.0.

  • target – luminance to reach; negative values are treated as 0.

spacr.qt.imagery.solve_image_file(path, theme: str, fmt: str = 'JPEG') → bool[source]

Exposure-solve an image file in place. True when it is legible.

For pixels that arrive at runtime rather than in the wheel — today that is the optional NASA/ESA download in spacr.qt.space.download_nasa_background(). render() cannot help there: that file is handed to the stylesheet directly, at whatever size it arrived, so the solve has to happen to the file.

False — never an exception — when it cannot be read, solved or rewritten. A caller must then refuse the image rather than install it. A wallpaper that is not bounded, under a theme whose scrims are solved against the bound, is precisely the failure spacr.qt.theme.EXPOSURE_BOUNDED_THEMES warns about: panels thinned to what a dark sky can carry, with a solar flare behind them.

Parameters:
  • path – image file to read and, if it needs dimming, overwrite.

  • theme – theme name whose palette sets the exposure target.

spacr.qt.imagery.srgb_encode(linear: float) → float[source]

Encode one linear-light value as an sRGB signal value in [0, 1].

Public because spacr.qt.space needs it, and because the distinction it carries is the one that is easiest to get wrong here: every limit in this module — exposure_target(), spacr.qt.theme.max_background_luma() — is a linear relative luminance, while the sky generator’s tone map emits an sRGB signal value. Space’s 0.0586 limit is #444444, not a 6 % signal; the two readings are a factor of 4.6 apart, which is the difference between a dimmed sun and a black rectangle.

Parameters:

linear – linear-light value; clipped to [0, 1] before encoding.

spacr.qt.imagery.theme_for(key: str) → str | None[source]

Which theme’s palette key is judged against, or None.

Parameters:

key – wallpaper key in MASTERS; unknown keys give None.

spacr.qt.imagery.title_for(key: str) → str[source]

Human-readable name of a wallpaper, for Preferences.

Parameters:

key – wallpaper key in MASTERS; an unknown key is returned as its own title.

spacr.qt.imagery.user_dir() → pathlib.Path[source]

Directory where a user may drop replacement masters.