Source code for spacr.qt.imagery

"""Photographic backgrounds for image-backed themes.

Why this exists next to :mod:`spacr.qt.space`
---------------------------------------------
:mod:`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 :data:`MASTER_CAP` (3840x2400 — :data:`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
* :func:`decode_count` reports master decodes for diagnostics and tests.

The crop policy
---------------
:data:`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: :func:`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
:func:`solve_dim` scales the image in linear light until the brightest
:data:`TEXT_WINDOW`-sized region lands on that number less
:data:`SAFETY_MARGIN`.

The same solve runs again in :func:`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.

:mod:`spacr.qt.space` now uses the same three functions —
:func:`exposure_target`, :func:`brightest_window` and :func:`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 :func:`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: :func:`spacr.qt.theme.contrast_report`
already judges every panel against a **pure white** background, which is
the worst case any photograph can present.
"""
from __future__ import annotations

import os
from pathlib import Path
from typing import Dict, Optional, Tuple

import numpy as np

from .space import (
    MAX_DIM, MIN_DIM, _clampi, cache_dir, screen_size,
)


#: Bumped whenever the rendering below changes output, so a cache written
#: by an older spaCR is not reused for a different-looking wallpaper.
CACHE_VERSION = 1

#: Aspect the shipped masters are cropped to. 16:10 is the widest shape
#: that still covers every display in :data:`spacr.qt.space.MAX_DIM`
#: without ever having to *upscale*: a 16:9 screen crops a band out of
#: it, a 4:3 screen crops columns, and both are centre crops of an
#: already annotation-free image.
MASTER_ASPECT = 1.6

#: Largest master shipped. Same ceiling as the generated sky, for the
#: same reason: past 4K the extra pixels cost megabytes and buy nothing
#: behind a UI.
MASTER_CAP = MAX_DIM

#: Region the legibility solve is run over, as (height, width) fractions
#: of the frame: one line of body text, roughly 540x48 px at 3840x2400.
#: Fractions rather than pixels because the metric then does not move
#: when the same wallpaper is rendered for a different screen.
#:
#: A *pixel* metric would be meaningless (every photograph has a white
#: pixel somewhere) and a whole-frame mean would be far too lenient —
#: it is a bright patch the size of a caption that makes a caption
#: unreadable.
TEXT_WINDOW = (0.02, 0.14)

#: Fraction of the palette's hard limit the exposure actually aims at.
#: Solving straight onto the limit leaves nothing for the three lossy
#: steps that follow the solve — JPEG encoding, the Lanczos resample to
#: the user's screen size, and rounding the measured region to a
#: 24-bit colour — and the first cut of this landed a wallpaper at
#: 2.99:1 against a 3.0:1 rule for exactly that reason. 10 % is about
#: two hundred times the observed drift and costs a barely perceptible
#: half-stop of exposure.
SAFETY_MARGIN = 0.90

#: Encoder settings for both the shipped masters and the per-screen
#: cache. JPEG, not PNG: these are photographs, and PNG is 4-6x larger
#: for pixels no one can tell apart. 4:2:0 chroma at q90 is
#: indistinguishable from lossless at 1:1 on this material — it was
#: compared crop by crop — and roughly a third smaller than 4:4:4.
JPEG_QUALITY = 90
JPEG_SUBSAMPLING = 2

#: Where the shipped masters live.
RESOURCE_DIR = Path(__file__).resolve().parent.parent / "resources" / "themes"

#: A user can drop their own image here under any master's filename and
#: it wins over the bundled one — the cheapest possible "use my own
#: wallpaper" feature, and the escape hatch if they dislike these three.
USER_DIR_NAME = "themes"

#: Environment override for where masters are read from. Used by the
#: tests; also lets a packager relocate the assets.
ENV_MASTER_DIR = "SPACR_THEME_IMAGES"


MASTERS: Dict[str, dict] = {
    "microtubules": {
        "source": "cell.png",
        "file": "microtubules.jpg",
        "theme": "cell",
        "source_crop": (0.0, 0.0, 1.0, 1.0),
        "focus": 0.46,
        "title": "Tubules",
    },
    "filopodia": {
        "source": "cell_2.png",
        "file": "filopodia.jpg",
        "theme": "cell",
        "source_crop": (0.03, 0.025, 0.97, 0.90),
        "focus": 0.50,
        "title": "Cytoskeleton",
        "annotation": (0.8252, 0.9249, 0.9488, 0.9528),
    },
    "deep_field": {
        "source": "space_1.jpeg",
        "file": "deep_field.jpg",
        "theme": "space",
        "source_crop": (0.0, 0.0, 1.0, 1.0),
        "focus": 0.50,
        "title": "Galaxy deep field (photograph)",
    },
}

#: Variants of the Cell theme.
CELL_VARIANTS = ("microtubules", "filopodia")
DEFAULT_CELL_VARIANT = "microtubules"

#: Photographic variants offered by the *Space* theme, alongside the
#: procedural ones in :data:`spacr.qt.space.VARIANTS`. Kept out of that
#: tuple because those three index :data:`spacr.qt.space._VARIANT_MIX`
#: and this one has no mix — it is a photograph.
SPACE_PHOTO_VARIANTS = ("deep_field",)


[docs] def theme_for(key: str) -> Optional[str]: """Which theme's palette ``key`` is judged against, or ``None``. :param key: wallpaper key in :data:`MASTERS`; unknown keys give ``None``. """ entry = MASTERS.get(key) return entry["theme"] if entry else None
[docs] def title_for(key: str) -> str: """Human-readable name of a wallpaper, for Preferences. :param key: wallpaper key in :data:`MASTERS`; an unknown key is returned as its own title. """ entry = MASTERS.get(key) return entry["title"] if entry else str(key)
_DECODES = 0
[docs] def decode_count() -> int: """How many times a master has been decoded this process. Every master decode goes through :func:`_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. :func:`reset_decode_count` exists so a test can measure a span rather than an absolute. """ return _DECODES
[docs] def reset_decode_count() -> None: """Zero the decode counter.""" global _DECODES _DECODES = 0
[docs] def user_dir() -> Path: """Directory where a user may drop replacement masters.""" return cache_dir().parent / USER_DIR_NAME
[docs] def master_dirs() -> Tuple[Path, ...]: """Directories searched for a master, most specific first.""" override = os.environ.get(ENV_MASTER_DIR) if override: return (Path(override),) return (user_dir(), RESOURCE_DIR)
[docs] def master_path(key: str) -> Optional[Path]: """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. :param key: wallpaper key in :data:`MASTERS`; its ``"file"`` is looked for in each of :func:`master_dirs` in turn. """ entry = MASTERS.get(key) if entry is None: return None try: for directory in master_dirs(): candidate = directory / entry["file"] if candidate.is_file(): return candidate except Exception: return None return None
[docs] def available_keys() -> Tuple[str, ...]: """Keys whose master is actually present on this machine.""" return tuple(k for k in MASTERS if master_path(k) is not None)
def _srgb_to_linear_lut() -> np.ndarray: """Build the 256-entry sRGB-to-linear lookup table. A table rather than the formula per pixel: the input is 8-bit, so there are only 256 possible answers and computing them once is the whole cost. :returns: the table, single-precision. """ c = np.arange(256, dtype=np.float64) / 255.0 return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4).astype(np.float32) _TO_LINEAR = _srgb_to_linear_lut() def _linear_to_srgb(value: np.ndarray) -> np.ndarray: """Convert linear-light values back to sRGB. :param value: linear values; clipped to ``[0, 1]`` first, because a composite can overshoot and the transfer function is only defined on that range. :returns: the sRGB values. """ value = np.clip(value, 0.0, 1.0) return np.where(value <= 0.0031308, value * 12.92, 1.055 * np.power(value, 1.0 / 2.4) - 0.055)
[docs] def srgb_encode(linear: float) -> float: """Encode one linear-light value as an sRGB signal value in [0, 1]. Public because :mod:`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 — :func:`exposure_target`, :func:`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. :param linear: linear-light value; clipped to ``[0, 1]`` before encoding. """ return float(_linear_to_srgb(np.asarray(float(linear), dtype=np.float64)))
[docs] def linear_rgb(arr: np.ndarray) -> np.ndarray: """Map a uint8 (h, w, 3) image to linear-light floats in [0, 1]. :param arr: uint8 image array; each value is looked up in a 256-entry table, so the result has the same shape. """ return _TO_LINEAR[arr]
[docs] def luminance_map(arr: np.ndarray) -> np.ndarray: """WCAG relative luminance of every pixel of a uint8 RGB array. :param arr: uint8 RGB image array of shape ``(h, w, 3)``; the result has shape ``(h, w)``. """ lin = linear_rgb(arr) return (0.2126 * lin[:, :, 0] + 0.7152 * lin[:, :, 1] + 0.0722 * lin[:, :, 2])
def _window_means(values: np.ndarray, box_h: int, box_w: int) -> np.ndarray: """Mean of ``values`` over every ``box_h`` x ``box_w`` window. Summed-area table, so the cost is one pass regardless of the window size — a sliding mean written as a loop over a 480x300 window is minutes, this is milliseconds. """ integral = np.cumsum(np.cumsum( np.pad(values, ((1, 0), (1, 0))), axis=0), axis=1) total = (integral[box_h:, box_w:] - integral[:-box_h, box_w:] - integral[box_h:, :-box_w] + integral[:-box_h, :-box_w]) return total / float(box_h * box_w)
[docs] def brightest_window(arr: np.ndarray, window: Tuple[float, float] = TEXT_WINDOW ) -> Tuple[float, str]: """Brightest ``window``-sized region of a uint8 RGB image. :param 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 :func:`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. """ lin = linear_rgb(arr) luma = (0.2126 * lin[:, :, 0] + 0.7152 * lin[:, :, 1] + 0.0722 * lin[:, :, 2]) h, w = luma.shape box_h = _clampi(round(h * window[0]), 1, h) box_w = _clampi(round(w * window[1]), 1, w) means = _window_means(luma, box_h, box_w) flat = int(np.argmax(means)) row, col = divmod(flat, means.shape[1]) value = float(means[row, col]) patch = lin[row:row + box_h, col:col + box_w].reshape(-1, 3).mean(axis=0) rgb = np.clip(_linear_to_srgb(patch) * 255.0 + 0.5, 0, 255).astype(int) return value, "#%02x%02x%02x" % tuple(rgb)
[docs] def exposure_target(theme: str) -> float: """Luminance the brightest text-line-sized region is aimed at. The palette's hard WCAG limit, backed off by :data:`SAFETY_MARGIN`. :param theme: theme name whose palette sets the limit, e.g. ``"cell"`` or ``"space"``; see :func:`spacr.qt.theme.max_background_luma`. """ from .theme import max_background_luma return max(0.0, max_background_luma(theme)) * SAFETY_MARGIN
[docs] def solve_dim(measured: float, target: float) -> float: """Exposure factor taking ``measured`` luminance down to ``target``. Closed form rather than the bisection :mod:`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. :param measured: current linear relative luminance; ``0`` or less gives ``1.0``. :param target: luminance to reach; negative values are treated as ``0``. """ if measured <= 0.0: return 1.0 return float(min(1.0, max(0.0, target) / measured))
def _dim_lut(factor: float) -> np.ndarray: """256-entry uint8 LUT applying ``factor`` in linear light. A LUT rather than float arithmetic on the image: a 3840x2400 float32 RGB buffer is 88 MB and this is 256 bytes, for identical output. """ return np.clip(_linear_to_srgb(_TO_LINEAR * factor) * 255.0 + 0.5, 0, 255).astype(np.uint8)
[docs] def dim(arr: np.ndarray, factor: float) -> np.ndarray: """Return ``arr`` darkened by ``factor`` in linear light. :param arr: uint8 image array; every value is mapped through a 256-entry lookup table, so any shape works. :param factor: multiplier applied to linear-light values; ``1.0`` or more returns ``arr`` unchanged. """ if factor >= 1.0: return arr return _dim_lut(factor)[arr]
[docs] def rects_overlap(a: Tuple[float, float, float, float], b: Tuple[float, float, float, float]) -> bool: """True when two ``(x0, y0, x1, y1)`` rectangles share any area. :param a: first rectangle as ``(x0, y0, x1, y1)``. :param b: second rectangle, same form. Rectangles that only touch along an edge do not overlap. """ return (a[0] < b[2] and b[0] < a[2] and a[1] < b[3] and b[1] < a[3])
#: Block size and fill fraction used by :func:`solid_annotation_blocks`. #: A scale bar is at least a couple of hundred pixels long and tens tall, #: so it saturates several 32 px blocks; nothing in a fluorescence frame #: or a deep field fills a third of one at peak brightness. ANNOTATION_BLOCK = 32 ANNOTATION_FILL = 0.35
[docs] def solid_annotation_blocks(arr: np.ndarray) -> int: """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. :param arr: uint8 RGB image array of shape ``(h, w, 3)``; an empty array or one smaller than :data:`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. """ if arr.size == 0 or min(arr.shape[:2]) < ANNOTATION_BLOCK: return 0 lo = arr.min(axis=2).astype(np.float32) hi = arr.max(axis=2).astype(np.float32) peak = float(np.percentile(hi, 99.99)) if peak <= 0.0: return 0 solid = ((lo >= 0.82 * peak) & ((hi - lo) <= 0.12 * peak)).astype(np.float32) block = ANNOTATION_BLOCK rows = solid.shape[0] // block cols = solid.shape[1] // block tiles = solid[:rows * block, :cols * block].reshape( rows, block, cols, block).mean(axis=(1, 3)) return int((tiles > ANNOTATION_FILL).sum())
[docs] def cover_box(src_w: int, src_h: int, out_w: int, out_h: int, focus: float = 0.5) -> Tuple[int, int, int, int]: """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. :param src_w: width of the source image in pixels. :param src_h: height of the source image in pixels. :param out_w: width of the target area in pixels. :param out_h: height of the target area in pixels. :param focus: vertical centre of the crop as a fraction of the source height. Clamped so the box stays inside the frame. """ src_w = max(1, int(src_w)) src_h = max(1, int(src_h)) out_w = max(1, int(out_w)) out_h = max(1, int(out_h)) if src_w * out_h > out_w * src_h: box_h = src_h box_w = _clampi(round(src_h * out_w / out_h), 1, src_w) else: box_w = src_w box_h = _clampi(round(src_w * out_h / out_w), 1, src_h) left = _clampi(round(src_w * 0.5 - box_w / 2.0), 0, src_w - box_w) top = _clampi(round(src_h * focus - box_h / 2.0), 0, src_h - box_h) return (left, top, left + box_w, top + box_h)
def _open_master(path: Path, hint: Optional[Tuple[int, int]] = None): """Decode a master into a PIL RGB image. **The only decode site.** :param hint: target size. For a JPEG this is passed to ``Image.draft``, which lets libjpeg decode at 1/2, 1/4 or 1/8 scale directly — the difference between holding 281 MB and holding 18 MB when a full-size original is used as the master. """ global _DECODES from PIL import Image Image.MAX_IMAGE_PIXELS = None with Image.open(path) as handle: if hint is not None: try: handle.draft("RGB", (max(1, hint[0]), max(1, hint[1]))) except Exception: pass image = handle.convert("RGB") _DECODES += 1 return image def _probe(image, long_edge: int = 480) -> np.ndarray: """A box-averaged thumbnail of ``image``, for measurement only. Every measurement in this module is a mean over a region hundreds of pixels across, and a box-averaged thumbnail answers those to several decimals for a fraction of the memory — the alternative is a 221 MB float array to learn one number. """ from PIL import Image scale = max(1, int(max(image.size) // max(1, long_edge))) if scale > 1: image = image.resize((max(1, image.width // scale), max(1, image.height // scale)), Image.BOX) return np.asarray(image.convert("RGB"), dtype=np.uint8)
[docs] def render(key: str, width: int, height: int): """Render ``key``'s wallpaper at exactly ``width`` x ``height``. :param key: wallpaper key in :data:`MASTERS`. :param width: output width in pixels, clamped to :data:`spacr.qt.space.MIN_DIM` and :data:`spacr.qt.space.MAX_DIM`. :param 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 :func:`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. """ path = master_path(key) if path is None: return None from PIL import Image width = _clampi(width, MIN_DIM[0], MAX_DIM[0]) height = _clampi(height, MIN_DIM[1], MAX_DIM[1]) source = _open_master(path, hint=(width, height)) box = cover_box(source.width, source.height, width, height) image = source.resize((width, height), Image.LANCZOS, box=box, reducing_gap=2.0) source.close() del source measured, _ = brightest_window(_probe(image)) factor = solve_dim(measured, exposure_target(MASTERS[key]["theme"])) if factor >= 1.0: return image arr = np.asarray(image, dtype=np.uint8) image.close() return Image.fromarray(dim(arr, factor))
[docs] def cache_name(key: str, width: int, height: int) -> str: """Return the versioned cache filename for a photographic background. :param key: wallpaper key, embedded in the name as ``photo-<key>-...``. :param width: image width in pixels. :param height: image height in pixels. """ return f"photo-{key}-{width}x{height}-v{CACHE_VERSION}.jpg"
def _qt_can_read(path: Path, width: int, height: int) -> bool: """True when Qt decodes ``path`` at the expected size.""" try: from PySide6.QtGui import QImage probe = QImage() if not probe.load(str(path)): return False return probe.width() == width and probe.height() == height except Exception: return False def _load_cached(path: Path, width: int, height: int) -> bool: """True when ``path`` already holds a usable image of that size. A truncated or garbage file — interrupted write, half-synced home directory — must regenerate rather than raise, and a file of the wrong size must regenerate rather than be stretched by the stylesheet. """ try: if not path.is_file() or path.stat().st_size < 128: return False return _qt_can_read(path, width, height) except Exception: return False def _write(image, path: Path, width: int, height: int) -> Optional[Path]: """Write ``image`` to ``path`` atomically, as JPEG if Qt can read it. Falls back to PNG when the Qt build has no JPEG plugin: a wallpaper Qt cannot decode is worse than a wallpaper that is four times larger, and silently regenerating a JPEG on every launch because ``_load_cached`` can never validate it would be worse still. """ tmp = path.with_suffix(path.suffix + ".part") image.save(tmp, "JPEG", quality=JPEG_QUALITY, subsampling=JPEG_SUBSAMPLING, optimize=True) if not _qt_can_read(tmp, width, height): image.save(tmp, "PNG") if not _qt_can_read(tmp, width, height): tmp.unlink(missing_ok=True) return None os.replace(tmp, path) return path
[docs] def background_path(key: str, width: int = 0, height: int = 0, regenerate: bool = False) -> Optional[Path]: """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. :param key: wallpaper key in :data:`MASTERS`, e.g. ``"microtubules"``; also names the cached file. """ try: if width <= 0 or height <= 0: width, height = screen_size() width = _clampi(width, MIN_DIM[0], MAX_DIM[0]) height = _clampi(height, MIN_DIM[1], MAX_DIM[1]) directory = cache_dir() path = directory / cache_name(key, width, height) if not regenerate and _load_cached(path, width, height): return path image = render(key, width, height) if image is None: return None directory.mkdir(parents=True, exist_ok=True) return _write(image, path, width, height) except Exception: return None
[docs] def clear_cache() -> int: """Delete every cached photo background. Returns the number removed.""" removed = 0 try: for pattern in ("photo-*.jpg", "photo-*.png"): for entry in cache_dir().glob(pattern): try: entry.unlink() removed += 1 except OSError: pass except Exception: pass return removed
[docs] def master_array(key: str) -> Optional[np.ndarray]: """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 :func:`_probe`. :param key: wallpaper key in :data:`MASTERS`; see :func:`master_path`. """ path = master_path(key) if path is None: return None image = _open_master(path, hint=(MASTER_CAP[0] // 8, MASTER_CAP[1] // 8)) arr = _probe(image) image.close() return arr
[docs] def legibility_of(arr: np.ndarray, theme: str, key: str = "") -> dict: """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 :data:`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 — :func:`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. :param arr: uint8 RGB image array of shape ``(h, w, 3)``. :param theme: theme name whose palette sets ``limit`` and ``failures``. """ from .theme import image_contrast_failures, max_background_luma value, color = brightest_window(arr) limit = max_background_luma(theme) return { "key": key, "theme": theme, "brightest": value, "color": color, "limit": limit, "target": exposure_target(theme), "passes": value <= limit, "failures": image_contrast_failures(theme, color), }
[docs] def legibility(key: str) -> Optional[dict]: """Measure how readable ``key``'s master actually is. See :func:`legibility_of` for the returned dict. ``None`` when the master is not installed. :param key: wallpaper key in :data:`MASTERS`; the master is judged against that entry's theme. """ arr = master_array(key) if arr is None: return None return legibility_of(arr, MASTERS[key]["theme"], key=key)
[docs] def solve_image_file(path, theme: str, fmt: str = "JPEG") -> bool: """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 :func:`spacr.qt.space.download_nasa_background`. :func:`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 :data:`spacr.qt.theme.EXPOSURE_BOUNDED_THEMES` warns about: panels thinned to what a dark sky can carry, with a solar flare behind them. :param path: image file to read and, if it needs dimming, overwrite. :param theme: theme name whose palette sets the exposure target. """ try: from PIL import Image path = Path(path) Image.MAX_IMAGE_PIXELS = None with Image.open(path) as handle: image = handle.convert("RGB") try: measured, _ = brightest_window(_probe(image)) factor = solve_dim(measured, exposure_target(theme)) if factor >= 1.0: return True arr = dim(np.asarray(image, dtype=np.uint8), factor) finally: image.close() Image.fromarray(arr).save(path, fmt, quality=JPEG_QUALITY, subsampling=JPEG_SUBSAMPLING, optimize=True) return True except Exception: return False
[docs] def build_master(key: str, src_dir, dst_dir) -> Optional[Path]: """Turn one original into the master that ships in the wheel. Crop away burned-in annotation, take the :data:`MASTER_ASPECT` crop around the subject, cap at :data:`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. :param key: wallpaper key in :data:`MASTERS`; an unknown key raises ``KeyError``. :param src_dir: directory holding the original image named by the entry's ``"source"``. :param 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. """ from PIL import Image entry = MASTERS[key] source = Path(src_dir) / entry["source"] if not source.is_file(): return None image = _open_master(source, hint=(MASTER_CAP[0] * 2, MASTER_CAP[1] * 2)) width, height = image.size x0, y0, x1, y1 = entry["source_crop"] image = image.crop((int(x0 * width), int(y0 * height), int(x1 * width), int(y1 * height))) out_w = MASTER_CAP[0] out_h = int(round(MASTER_CAP[0] / MASTER_ASPECT)) box = cover_box(image.width, image.height, out_w, out_h, focus=entry["focus"]) box_w, box_h = box[2] - box[0], box[3] - box[1] scale = min(1.0, out_w / box_w, out_h / box_h) image = image.resize((max(1, int(round(box_w * scale))), max(1, int(round(box_h * scale)))), Image.LANCZOS, box=box, reducing_gap=2.0) measured, _ = brightest_window(_probe(image)) factor = solve_dim(measured, exposure_target(entry["theme"])) if factor < 1.0: image = Image.fromarray(dim(np.asarray(image, dtype=np.uint8), factor)) dst = Path(dst_dir) dst.mkdir(parents=True, exist_ok=True) out = dst / entry["file"] image.save(out, "JPEG", quality=JPEG_QUALITY, subsampling=JPEG_SUBSAMPLING, optimize=True) return out
[docs] def build_masters(src_dir, dst_dir=None) -> Dict[str, Optional[Path]]: """Build every master in :data:`MASTERS`. See :func:`build_master`. :param src_dir: directory holding the originals, passed to :func:`build_master` for every key. """ dst_dir = RESOURCE_DIR if dst_dir is None else dst_dir return {key: build_master(key, src_dir, dst_dir) for key in MASTERS}