Display and rendering --------------------- .. versionadded:: 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: :file:`src/gui-gtk4/image_display.c` and :file:`src/gui-gtk4/image_display.h` (the large comment blocks there are authoritative), the ``struct image_view`` and ``struct image_tile`` types in :file:`src/gui-gtk4/gui_state.h`, ``notify_gfit_data_modified`` in :file:`src/io/single_image.c`, the ``SirilRedrawType`` enum and the ``redraw_*`` slots in :file:`src/core/gui_iface.h`, the ICC transforms in :file:`src/gui-gtk4/icc_profile.c`, the preview/backup machinery in :file:`src/gui-gtk4/siril_preview.c`, and zoom/pan in :file:`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 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``. #. **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. #. **GdkTexture → GtkSnapshot.** The custom ``SirilImageView`` widget (declared with ``G_DECLARE_FINAL_TYPE`` in :file:`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. #. **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 :file:`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 :file:`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-nullified ``com.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 write ``gfit``. #. 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_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`` (:file:`src/core/gui_iface.h`; aliased as ``remap_type`` in :file:`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_image`` → ``impl_redraw_image``: synchronous. Runs ``redraw`` directly 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 :ref:`threading:threading` for the thread model and :ref:`locking: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`` (:file:`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 (:file:`src/gui-gtk4/ccd-inspector.c`) and the registration preview (:file:`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 :file:`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`` (:file:`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`` (:file:`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 :file:`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 :file:`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 :ref:`locking: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 :ref:`genericworkers:generic workers` and :ref:`threading: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.