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:
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 ingui.remap_index[3](one per channel); a wider high-precision LUTgui.hd_remap_index[3]is used for STF on float data whengui.use_hd_remapis set.make_index_for_current_displayandmake_hd_index_for_current_displaybuild these; histogram-equalisation and STF are handled byremapper channel, the linear cases byremap_all_vports.Per-viewport 8-bit buffers / tiles. Applying the LUT to
gfitproduces 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 ofGdkTexturetiles rather than one giant surface.GdkTexture → GtkSnapshot. The custom
SirilImageViewwidget (declared withG_DECLARE_FINAL_TYPEinimage_display.h, registered bysiril_image_view_register) overrides thesnapshotvfunc. Insiril_image_view_snapshotit 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.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'sTRILINEARfilter 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. Seetile_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 byview->buf_gbytes) holds the image; each tile texture is aGdkMemoryTextureslicing 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_eagerfills 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_usedtracks resident tile bytes;materialise_tile_evict_untilfrees least-recently-used tiles (bytile->last_used) to make headroom before filling a new one.tile_releasedrops the texture but frees the bytes through the texture'sGBytesfree func, so GSK finishing an in-flight render can never use-after-free.Mip levels.
tile->miprecords a downsample level (texture dimension = source >> mip).compute_target_mippicks the level from the current zoom (capped atSIRIL_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 toSIRIL_MATERIALISE_MAX_THREADS= 16), never on the GTK main thread. A worker (materialise_worker) claims a tile viatile->buildingundergui.cairo_mutex, then does the heavy fill holding onlygfit's reader lock.workers_activecounts 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 byensure_proxy) as a backdrop; the real, sharp tiles then fill in over the next few frames.proxy_dirtyis set on every lazy remap and grid reallocation.
Invalidation and stale-tile safety
Two sequence counters on struct image_view prevent stale tiles:
generationis 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_seqis 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 thedirtyflag 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:
Returns immediately if
com.quitting.Invalidates the cached statistics (
invalidate_stats_from_fit).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-nullifiedcom.layers_hist[].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 writegfit.Recomputes the display range (
init_layers_hi_and_lo_values; a no-op in USER slider mode, so manual slider values survive).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_scriptthe 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_scriptcalls the function again after clearingcom.scriptso the final result is displayed.Active worker job. If another job is writing
gfit(processing_is_job_activeand notprocessing_in_worker_thread) the remap is skipped to avoid racing the writer; that job's own call plus theend_gfit_operationidle 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— likeREDRAW_OVERLAYplus a refresh of subordinate panels (currently the aberration-inspector mosaic). Despite the name it does not remap: since the remap moved intonotify_gfit_data_modifiedthe display buffers are already fresh when this fires at end-of-operation. (The historicalREMAP_ALLthat remapped directly no longer exists.)
The GUI interface (gui_iface) exposes three redraw variants; pick by the
thread you are on:
gui_iface.redraw_image→impl_redraw_image: synchronous. Runsredrawdirectly if on the main thread, otherwise transparently queues it.gui_iface.redraw_image_async→queue_redraw: queue from any thread, returns immediately.gui_iface.redraw_image_sync→queue_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_backupcopiesgfit(pixels, mask, metadata, and — outside script mode — the ICC profile viacopy_gfit_icc_to_backup) intopreview_gfit_backupand setspreview_is_active. It refuses if there is not enough free memory for the copy.copy_backup_to_gfitrestores it. It takesgfit's writer lock and quiesces the lazy materialise pool first (viagui.suppress_drawarea_redraw, exactly asgeneric_image_workerdoes) — otherwise a background tile fill readinggfitwould race the restore, producing torn tiles, and the writer lock could starve against a busy pool. See Locking.clear_backupfrees the backup and clearspreview_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.