Display and rendering

Added in version 1.5: The whole on-screen image pipeline was rewritten for the GTK4 port. Image pixels now reach the screen as GdkTexture tiles composited by GSK (the GPU compositor); Cairo is used only for the overlays drawn on top. This chapter documents that renderer, the update contract that keeps it in sync with gfit, colour management, and the preview/backup system.

Ground truth: src/gui-gtk4/image_display.c and src/gui-gtk4/image_display.h (the large comment blocks there are authoritative), the struct image_view and struct image_tile types in src/gui-gtk4/gui_state.h, notify_gfit_data_modified in src/io/single_image.c, the SirilRedrawType enum and the redraw_* slots in src/core/gui_iface.h, the ICC transforms in src/gui-gtk4/icc_profile.c, the preview/backup machinery in src/gui-gtk4/siril_preview.c, and zoom/pan in src/gui-gtk4/image_interactions.c.

Pipeline overview

The display path runs in stages, from the 16-bit or float pixels in gfit to the pixels GSK paints:

  1. Remap LUTs. For each viewport a display look-up table maps source pixel values to 8-bit display values. The LUT is a function of the display mode (gui.rendering_mode — linear, autostretch/STF, histogram-equalisation, …) and the low/high cutoff sliders (gui.lo / gui.hi). The 8-bit LUT lives in gui.remap_index[3] (one per channel); a wider high-precision LUT gui.hd_remap_index[3] is used for STF on float data when gui.use_hd_remap is set. make_index_for_current_display and make_hd_index_for_current_display build these; histogram-equalisation and STF are handled by remap per channel, the linear cases by remap_all_vports.

  2. Per-viewport 8-bit buffers / tiles. Applying the LUT to gfit produces a viewport-local buffer of 8-bit BGRX pixels. This is where the tiled renderer lives (next section): the buffer is exposed to GSK as a grid of GdkTexture tiles rather than one giant surface.

  3. GdkTexture → GtkSnapshot. The custom SirilImageView widget (declared with G_DECLARE_FINAL_TYPE in image_display.h, registered by siril_image_view_register) overrides the snapshot vfunc. In siril_image_view_snapshot it appends each visible tile texture into the snapshot tree, scaled by the display matrix, so panning and zooming re-use the cached GSK render-node tree without re-walking pixel data.

  4. Cairo overlays. After the image textures, the same snapshot pushes a single Cairo subnode (gtk_snapshot_append_cairo) into which all overlays are drawn. Cairo never touches image pixels on screen any more — only the selection rectangle, stars, annotations, polygons, measurement line, WCS grid and so on.

The legacy GtkDrawingArea-driven Cairo redraw_drawingarea entry point was removed; the only surviving full-Cairo image path is add_image_and_label_to_cairo, used by the snapshot-to-file / clipboard save feature.

The tiled renderer

Each viewport (struct image_view, one per vport: R, G, B and the composite RGB) owns a tile grid. The relevant constants live near the top of image_display.c:

  • Tile size SIRIL_TILE_DIM = 2048 pixels. Every tile is at most 2048×2048; right- and bottom-edge tiles may be smaller. 2048 sits comfortably inside every GPU's maximum texture size.

  • Guard band SIRIL_TILE_GUARD = 64 pixels. Non-edge tiles carry a 64-pixel guard band of the neighbouring tile's data along their right and bottom edges. GSK's TRILINEAR filter builds an independent mipmap pyramid per texture, so without the guard adjacent tiles would show seams when zoomed out; 64 keeps boundaries seamless down to zoom 1/64. See tile_dims_padded.

Eager vs lazy mode

allocate_full_surface decides per viewport, per image, whether to run eager or lazy. The threshold is a RAM budget, com.pref.lazy_tile_cache_mb (Preferences → Performance; default 128 MB, range 128–4096; SIRIL_LAZY_BUDGET is this value in bytes, read live so a settings change takes effect on the next (re)allocation):

  • Eager — the whole image fits in the budget at native resolution. One contiguous buffer (view->buf, owned by view->buf_gbytes) holds the image; each tile texture is a GdkMemoryTexture slicing into it via the row stride. No LRU, no mip downsampling, no glitching when zoom crosses a mip boundary. This covers almost every realistic image (a 24 Mpix RGB frame is ~96 MB). view_refresh_tile_textures_eager fills all tiles at remap time.

  • Lazy — huge mosaics that do not fit. Each tile owns its own buffer, materialised on demand (materialise_tile) and bounded by the budget.

Lazy-mode machinery

  • LRU eviction. lazy_bytes_used tracks resident tile bytes; materialise_tile_evict_until frees least-recently-used tiles (by tile->last_used) to make headroom before filling a new one. tile_release drops the texture but frees the bytes through the texture's GBytes free func, so GSK finishing an in-flight render can never use-after-free.

  • Mip levels. tile->mip records a downsample level (texture dimension = source >> mip). compute_target_mip picks the level from the current zoom (capped at SIRIL_MAX_MIP = 6), deliberately one level finer than strictly necessary so zooming in one octave doesn't force re-materialising every visible tile.

  • Background materialise workers. The per-pixel tile fill runs on a GThreadPool (materialise_pool, up to SIRIL_MATERIALISE_MAX_THREADS = 16), never on the GTK main thread. A worker (materialise_worker) claims a tile via tile->building under gui.cairo_mutex, then does the heavy fill holding only gfit's reader lock. workers_active counts in-flight jobs; redraw_pending (atomic) coalesces the per-tile "tile landed, redraw" requests into at most one queued redraw. The main thread is never blocked on tile materialisation.

  • Low-res proxy. During a fast zoom-out many never-materialised tiles become visible at once. Rather than block, the snapshot draws a resident, point-sampled full-image proxy (view->proxy, long edge ≤ SIRIL_PROXY_MAX = 2048, built by ensure_proxy) as a backdrop; the real, sharp tiles then fill in over the next few frames. proxy_dirty is set on every lazy remap and grid reallocation.

Invalidation and stale-tile safety

Two sequence counters on struct image_view prevent stale tiles:

  • generation is bumped whenever the tile grid is reallocated (allocate_full_surface). Each worker job captures the generation and discards its finished texture if the grid moved under it, so a job that started against a freed grid never writes into the replacement.

  • invalidate_seq is bumped by every lazy invalidate (a LUT change, view_invalidate_tiles_lazy). A worker captures it alongside its LUT pointers and discards a finished texture if the value changed during the unlocked fill — otherwise the worker would clear the dirty flag that the mid-flight invalidate just set, leaving the tile clean-but-stale.

When gfit's pixels are replaced wholesale (e.g. a sequence frame swap), drop_lazy_tile_textures (exposed as the drop_lazy_tile_textures GUI-iface slot) drops stale lazy tiles so the display can't show the previous frame's content.

The update contract

This is the single most important rule for anyone writing a processing feature.

After you modify ``gfit`` pixels you do not call any redraw function directly. The worker/idle machinery calls notify_gfit_data_modified (in src/io/single_image.c) on the processing thread. Read that function in full; in order it:

  1. Returns immediately if com.quitting.

  2. Invalidates the cached statistics (invalidate_stats_from_fit).

  3. Under com.histogram_mutex, invalidates the histogram (gui_iface.invalidate_histogram) and, unless it early-returns, recomputes fresh histogram data (gui_iface.compute_histo_for_fit). The mutex is held across the invalidate+recompute pair so the main thread never sees a half-nullified com.layers_hist[].

  4. Merges an active ROI back into gfit (copy_roi_into_gfit) before the histogram and remap run, so both see fully-updated pixels. Redraw must stay a pure "repaint from the display buffers" operation and must never itself write gfit.

  5. Recomputes the display range (init_layers_hi_and_lo_values; a no-op in USER slider mode, so manual slider values survive).

  6. Remaps every viewport (gui_iface.remap_all_vports), rebuilding the display buffers/tiles from the LUT.

Two early-returns matter:

  • Script mode. When com.script && !com.python_script the expensive work (histogram compute, remap, display-range recompute) is skipped — there is no point rendering intermediate results the user never sees. The cache invalidations still run so later commands recompute on demand. execute_script calls the function again after clearing com.script so the final result is displayed.

  • Active worker job. If another job is writing gfit (processing_is_job_active and not processing_in_worker_thread) the remap is skipped to avoid racing the writer; that job's own call plus the end_gfit_operation idle will update the display.

SirilRedrawType and the redraw slots

SirilRedrawType (src/core/gui_iface.h; aliased as remap_type in image_display.h for older callers) has exactly three members. All three queue a widget redraw; they differ only in what housekeeping piggy-backs:

  • REDRAW_OVERLAY — the display buffers are assumed fresh; only the Cairo overlay is repainted.

  • REDRAW_IMAGE — the image-render cache is stale; invalidate it (invalidate_image_render_cache) then queue a paint. Use when pixels changed but no side panels need refreshing.

  • REDRAW_ALL — like REDRAW_OVERLAY plus a refresh of subordinate panels (currently the aberration-inspector mosaic). Despite the name it does not remap: since the remap moved into notify_gfit_data_modified the display buffers are already fresh when this fires at end-of-operation. (The historical REMAP_ALL that remapped directly no longer exists.)

The GUI interface (gui_iface) exposes three redraw variants; pick by the thread you are on:

  • gui_iface.redraw_imageimpl_redraw_image: synchronous. Runs redraw directly if on the main thread, otherwise transparently queues it.

  • gui_iface.redraw_image_asyncqueue_redraw: queue from any thread, returns immediately.

  • gui_iface.redraw_image_syncqueue_redraw_and_wait_for_it: queue from a worker thread and block until the redraw has run (never call from the main thread — it joins a worker thread and would deadlock).

redraw itself early-returns in script mode (com.script && !com.python_script) so no per-command redraws happen during scripts. See Threading for the thread model and Locking for the gfit read/write lock discipline these paths rely on.

Overlays

Overlays are drawn by the Cairo subnode at the tail of siril_image_view_snapshot, in a fixed sequence: draw_selection, draw_measurement_line, draw_user_polygons, draw_stars, draw_wcs_grid, draw_wcs_disto, and finally the pluggable gui.draw_extra hook if a feature has installed one. Each receives a draw_data_t carrying the Cairo context, viewport index, zoom, negative-view flag and dimensions. Overlay state lives in struct guiinf (gui_state.h): com.selection, gui.user_polygons, gui.measure_start / gui.measure_end, annotation and WCS flags, the mask tint, etc. Because overlays are a separate snapshot pass, repainting them (e.g. during a selection-rubber-band drag with REDRAW_OVERLAY) costs nothing on the image side.

Inspection-style views render pixels only, no overlays. The CCD inspector (src/gui-gtk4/ccd-inspector.c) and the registration preview (src/gui-gtk4/registration_preview.c) show raw display pixels with no grid, stars or selection drawn over them. They obtain their pixels through the region-copy helper siril_image_view_copy_region (declared in image_display.h), which materialises only the overlapping tiles into a caller-supplied buffer, so an arbitrary window can be shown without ever allocating a full-image surface — and it works in lazy mode too.

Zoom and pan

Zoom lives in gui.zoom_value (1.0 = 100 %). The effective value is obtained through get_zoom_val because gui.zoom_value may hold the sentinel ZOOM_FIT (-1.0, i.e. fit-to-window); ZOOM_DEFAULT is ZOOM_FIT and the zoom range is ZOOM_MIN (0.03125) to ZOOM_MAX (128). Two Cairo matrices on struct guiinf convert coordinates: gui.display_matrix (image → display) and gui.image_matrix (display → image, its inverse), with gui.display_offset the pan offset and gui.translating set while a pan is in progress. update_zoom (image_interactions.c) recomputes zoom around the cursor and refreshes the fit-to-window button; the snapshot rebuilds the display matrix from the current zoom/offset each frame and inverts it to find which tiles are visible.

Display colour management

When gfit is colour-managed the display is soft-proofed to the monitor profile. check_gfit_profile_identical_to_monitor (image_display.c) compares gfit->icc_profile against com.gui_icc.monitor (profiles_identical) and caches the result in the static identical flag. The tile fill applies the ICC transform (cmsDoTransformLineStride with com.gui_icc.proofing_transform) only when gfit->color_managed and the transform exists and the profile is not identical to the monitor and !com.gui_icc.same_primaries — so the common "image already in the monitor space" case skips the transform entirely. Soft proofing and gamut-check state (custom proofing profile, ISO 12646 grey surround, gamut visualisation) are managed in src/gui-gtk4/icc_profile.c; com.gui_icc.monitor is (re)built there when the monitor profile or rendering intent changes, and the ICC status icon is updated via the check_icc_identical_to_monitor and update_icc_status_icon GUI-iface slots.

Preview and backup system

Interactive filter/stretch dialogs preview their effect by mutating gfit in place and restoring it from a backup when cancelled. The machinery is in src/gui-gtk4/siril_preview.c:

  • copy_gfit_to_backup copies gfit (pixels, mask, metadata, and — outside script mode — the ICC profile via copy_gfit_icc_to_backup) into preview_gfit_backup and sets preview_is_active. It refuses if there is not enough free memory for the copy.

  • copy_backup_to_gfit restores it. It takes gfit's writer lock and quiesces the lazy materialise pool first (via gui.suppress_drawarea_redraw, exactly as generic_image_worker does) — otherwise a background tile fill reading gfit would race the restore, producing torn tiles, and the writer lock could starve against a busy pool. See Locking.

  • clear_backup frees the backup and clears preview_is_active.

Request coalescing (for slider drags) is handled by notify_update / dispatch_preview: preview_in_flight marks a preview in progress; a new request arriving while one is running replaces the single pending_preview (coalescing), and if a worker job is active (preview_job_active) the running job is cancelled with processing_request_cancel so the newer request starts sooner. The post-job callback then drives the pending request. This is the same cooperative-cancellation path documented under Generic workers and Threading.

Histogram display invalidate/update pairing

The histogram cache (com.layers_hist[]) follows a strict invalidate/update pairing. Any code that changes gfit pixels must mark the histogram stale — invalidate_gfit_histogram (exposed as gui_iface.invalidate_histogram), which nulls every layer's cached histogram under the histogram lock. The matching recompute is gui_iface.update_histogram (recomputes only if stale). In the normal end-of-operation path both halves are done for you inside notify_gfit_data_modified (which recomputes the histogram itself under com.histogram_mutex), and end_gfit_operation merely refreshes the panel if it is visible. Only pair these manually if you touch gfit outside that contract: never leave a stale histogram uninvalidated, and never invalidate without arranging a recompute before it is read.