[education/kstars/stable-3.8.4] kstars/ekos: Guide: apply dark/defect-map correction to streaming guide frames

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit e94c1fc9a240f6d40537bd4efa57929e1d6b5aa9 by Jasem Mutlaq, on behalf of Andreas R..
Committed on 15/08/2026 at 11:41.
Pushed by mutlaqja into branch 'stable-3.8.4'.

Guide: apply dark/defect-map correction to streaming guide frames

# Guide: apply dark/defect-map correction to streaming guide frames

## Problem

In streaming guide mode the `GUIDE_DARK` operation step is skipped — there is no
single-capture lifecycle to interleave a dark into, so `buildOperationStack()`
never pushes `GUIDE_DARK`. As a result the **"Dark" checkbox was a no-op while
streaming**: the master dark / defect map was applied only in single-frame
guiding. Stream frames go `processStream → newImage → Guide::processData`, and
`processData` never touched `DarkProcessor`.

For sensors with amp glow used as streaming OAG guiders (e.g. a Sony IMX585 run
at HCG gain), this left the fixed-pattern glow gradient in every guide frame,
raising the noise floor and hiding faint stars from the multi-star selector.

## Change

- Apply the correction **inline in `processData()`** for streaming frames,
  **before** the frame is loaded into the guide view or handed to the guider,
  so the live view, the guider's star detection, and any saved guide image all
  use the same corrected buffer.
- New **`DarkProcessor::denoiseSynchronous()`** mirrors the async `denoise()`
  (honouring the `preferDefectsRadio` optical-train setting) but runs
  `denoiseInternal()` on the calling thread and returns whether a correction was
  applied. This keeps the corrected buffer ready before `setCaptureComplete()`
  runs the solver and avoids re-entrancy with the streaming `newImage` dispatch.
  The per-pixel subtraction plus a stats pass is a few ms per frame and does not
  reintroduce the single-capture dead time.
- If no matching dark/defect map is found, `m_streamDarkUnavailable` latches so
  the lookup and log message are not repeated on every incoming frame; it is
  reset when streaming (re)starts or the Dark checkbox is re-enabled.
- Clarify the Dark checkbox tooltip: the dark must be created in the same binning
  mode used while guiding, including hardware vs. software binning if the camera
  supports it, otherwise the correction will not match.

No change to single-frame guiding.

## Testing

Built and tested on real hardware (PlayerOne Xena 585M, IMX585 mono, OAG,
bin 2×2, 0.7 s, gain 218, RAW16 streaming). Covered-sensor A/B, measuring the
corner/edge glow gradient ("edge-spread") and background statistics:

| frame | mean | std | max | edge-spread (glow) |
|---|---|---|---|---|
| stream, Dark off (software bin) | 21.6 | 16.2 | 16404 | 5.0 |
| stream, Dark on (software bin)  | 5.3  | 7.9  | 225   | **0.2** |
| single-frame, Dark on (reference) | 5.2 | 7.8 | 116 | 0.5 |
| stream, Dark off (hardware bin) | 16.2 | 22.4 | 19696 | 4.9 |
| stream, Dark on (hardware bin)  | 7.1  | 9.7  | 135   | **0.7** |

With the dark applied, the streaming result matches single-frame guiding: the
glow pedestal and gradient are removed (edge-spread 5.0 → 0.2/0.7) and the frame
statistics track the known-good single-frame dark. Defect-map mode
("Prefer Defects") was also exercised via the same synchronous path.

M  +19   -0    kstars/ekos/auxiliary/darkprocessor.cpp
M  +7    -0    kstars/ekos/auxiliary/darkprocessor.h
M  +37   -0    kstars/ekos/guide/guide.cpp
M  +7    -0    kstars/ekos/guide/guide.h
M  +1    -1    kstars/ekos/guide/guide.ui

https://invent.kde.org/education/kstars/-/commit/e94c1fc9a240f6d40537bd4efa57929e1d6b5aa9

diff --git a/kstars/ekos/auxiliary/darkprocessor.cpp b/kstars/ekos/auxiliary/darkprocessor.cpp
index a00bf3a732..f19ebe9be7 100644
--- a/kstars/ekos/auxiliary/darkprocessor.cpp
+++ b/kstars/ekos/auxiliary/darkprocessor.cpp
@@ -240,6 +240,25 @@ void DarkProcessor::denoise(int trainID, ISD::CameraChip *m_TargetChip, const QS
     m_Watcher.setFuture(result);
 }
 
+///////////////////////////////////////////////////////////////////////////////////////
+///
+///////////////////////////////////////////////////////////////////////////////////////
+bool DarkProcessor::denoiseSynchronous(int trainID, ISD::CameraChip *targetChip,
+                                       const QSharedPointer<FITSData> &targetData,
+                                       double duration, uint16_t offsetX, uint16_t offsetY)
+{
+    info = {trainID, targetChip, targetData, duration, offsetX, offsetY};
+
+    bool useDefect = false;
+    // Get the train settings
+    OpticalTrainSettings::Instance()->setOpticalTrainID(trainID);
+    auto settings = OpticalTrainSettings::Instance()->getOneSetting(OpticalTrainSettings::DarkLibrary);
+    if (settings.isValid())
+        useDefect = settings.toMap().contains("preferDefectsRadio");
+
+    return denoiseInternal(useDefect);
+}
+
 ///////////////////////////////////////////////////////////////////////////////////////
 ///
 ///////////////////////////////////////////////////////////////////////////////////////
diff --git a/kstars/ekos/auxiliary/darkprocessor.h b/kstars/ekos/auxiliary/darkprocessor.h
index bbf3bc56ca..7aa499ddd6 100644
--- a/kstars/ekos/auxiliary/darkprocessor.h
+++ b/kstars/ekos/auxiliary/darkprocessor.h
@@ -43,6 +43,13 @@ class DarkProcessor : public QObject
         void denoise(int trainID, ISD::CameraChip *targetChip, const QSharedPointer<FITSData> &targetData, double duration,
                      uint16_t offsetX, uint16_t offsetY);
 
+        // Synchronous variant of denoise(): applies the defect map or dark subtraction on
+        // the calling thread and returns true if a correction was applied. Used by the
+        // streaming guide path, which has no capture lifecycle to await the asynchronous
+        // darkFrameCompleted signal and must have the frame corrected before star detection.
+        bool denoiseSynchronous(int trainID, ISD::CameraChip *targetChip, const QSharedPointer<FITSData> &targetData,
+                                double duration, uint16_t offsetX, uint16_t offsetY);
+
 
     private:
 
diff --git a/kstars/ekos/guide/guide.cpp b/kstars/ekos/guide/guide.cpp
index 8dae2d0d64..af7ade11fb 100644
--- a/kstars/ekos/guide/guide.cpp
+++ b/kstars/ekos/guide/guide.cpp
@@ -980,6 +980,9 @@ bool Guide::startGuideStreaming()
 
     m_StreamingGuide = true;
 
+    // Allow the per-frame dark/defect-map lookup to run again for this streaming session.
+    m_streamDarkUnavailable = false;
+
     // Tell the internal guider not to request captures after pulses — frames arrive continuously
     if (guiderType == GUIDE_INTERNAL)
         internalGuider->setStreamingMode(true);
@@ -1327,6 +1330,36 @@ void Guide::processData(const QSharedPointer<FITSData> &data)
     captureTimeout.stop();
     m_CaptureTimeoutCounter = 0;
 
+    // Streaming guide has no single-capture lifecycle, so the GUIDE_DARK operation step
+    // is skipped (see buildOperationStack). Apply the master dark / defect map here,
+    // synchronously and BEFORE the frame is loaded into the view or handed to the guider,
+    // so the live view, the guider's star detection, and any saved guide image all use the
+    // same corrected buffer. Inline (not the async denoise()) so it is ready before
+    // setCaptureComplete() runs the solver, and to avoid re-entrancy with the streaming
+    // newImage dispatch.
+    if (m_StreamingGuide && guideDarkFrame->isChecked() && data && !m_streamDarkUnavailable)
+    {
+        uint16_t offsetX = 0, offsetY = 0;
+        if (frameSettings.contains(targetChip))
+        {
+            QVariantMap settings = frameSettings[targetChip];
+            if (settings["x"].isValid() && settings["y"].isValid() &&
+                    settings["binx"].isValid() && settings["biny"].isValid())
+            {
+                offsetX = settings["x"].toInt() / settings["binx"].toInt();
+                offsetY = settings["y"].toInt() / settings["biny"].toInt();
+            }
+        }
+
+        const int trainID = OpticalTrainManager::Instance()->id(opticalTrainCombo->currentText());
+        if (!m_DarkProcessor->denoiseSynchronous(trainID, targetChip, data,
+                guideExposure->value(), offsetX, offsetY))
+            // No matching dark/defect map — stop retrying so we don't repeat the lookup
+            // and log message on every incoming stream frame. Reset when streaming
+            // restarts or the dark checkbox is toggled.
+            m_streamDarkUnavailable = true;
+    }
+
     if (data && (!guideShowFrame->isEnabled() || guideShowFrame->isChecked()))
     {
         m_GuideView->loadData(data);
@@ -2258,6 +2291,10 @@ void Guide::setDarkFrameEnabled(bool enable)
 {
     if (guideDarkFrame->isChecked() != enable)
         guideDarkFrame->setChecked(enable);
+
+    // Re-arm the streaming per-frame dark lookup whenever darks are (re-)enabled.
+    if (enable)
+        m_streamDarkUnavailable = false;
 }
 
 void Guide::saveDefaultGuideExposure()
diff --git a/kstars/ekos/guide/guide.h b/kstars/ekos/guide/guide.h
index 59d932653c..6a5c194f5b 100644
--- a/kstars/ekos/guide/guide.h
+++ b/kstars/ekos/guide/guide.h
@@ -753,6 +753,13 @@ class Guide : public QWidget, public Ui::Guide
         // Setting this flag causes those re-entrant calls to be silently dropped.
         bool m_isProcessingFrame { false };
 
+        // Streaming-mode dark/defect-map handling. The GUIDE_DARK operation step is
+        // skipped in streaming (no single-capture lifecycle), so dark subtraction is
+        // applied inline per stream frame in processData(). If no matching dark/defect
+        // map is found, this latches true so we stop retrying (and logging) every frame.
+        // Reset when streaming (re)starts or the dark checkbox is re-enabled.
+        bool m_streamDarkUnavailable { false };
+
         // Single-shot timer used as a "pulse in-flight" gate in streaming mode.
         // After a guide pulse is sent the timer is started for the pulse duration
         // plus the configured guide delay.  While it is active, processData() discards
diff --git a/kstars/ekos/guide/guide.ui b/kstars/ekos/guide/guide.ui
index 63e7e74dc9..ffbae4317e 100644
--- a/kstars/ekos/guide/guide.ui
+++ b/kstars/ekos/guide/guide.ui
@@ -139,7 +139,7 @@
             <item row="4" column="1">
              <widget class="QCheckBox" name="guideDarkFrame">
               <property name="toolTip">
-               <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Subtract dark frame. Create dark frames or defect maps in the Dark Library tool in the capture module.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
+               <string>&lt;html&gt;&lt;head/&gt;&lt;body&gt;&lt;p&gt;Subtract dark frame. Create dark frames or defect maps in the Dark Library tool in the capture module.&lt;/p&gt;&lt;p&gt;The dark must be created in the same binning mode used while guiding — including hardware vs. software binning if the camera supports it — otherwise the correction will not match.&lt;/p&gt;&lt;/body&gt;&lt;/html&gt;</string>
               </property>
               <property name="text">
                <string>Dark</string>
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.