[education/kstars] kstars: Properly display annotated objects after plate-solving an image

Jasem Mutlaq <[email protected]>
Newsgroups gmane.comp.kde.cvs
Message-ID <[email protected]>
Git commit d7e02368b0aaffd5b25fe595e574e305787736d5 by Jasem Mutlaq.
Committed on 18/08/2026 at 16:28.
Pushed by mutlaqja into branch 'master'.

Properly display annotated objects after plate-solving an image

From Sky Map objects.

M  +3    -3    kstars/ekos/capture/capture.cpp
M  +3    -3    kstars/ekos/manager.cpp
M  +81   -3    kstars/fitsviewer/fitsdata.cpp
M  +9    -2    kstars/fitsviewer/fitslabel.cpp
M  +7    -0    kstars/fitsviewer/fitsskyobject.cpp
M  +25   -0    kstars/fitsviewer/fitsskyobject.h
M  +68   -4    kstars/fitsviewer/fitsview.cpp
M  +6    -1    kstars/skycomponents/catalogscomponent.cpp
M  +8    -0    kstars/skycomponents/catalogscomponent.h
M  +3    -4    kstars/skycomponents/skymapcomposite.cpp
M  +11   -1    kstars/skycomponents/skymapcomposite.h

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

diff --git a/kstars/ekos/capture/capture.cpp b/kstars/ekos/capture/capture.cpp
index 7b28ac5f61..6b6eefc61a 100644
--- a/kstars/ekos/capture/capture.cpp
+++ b/kstars/ekos/capture/capture.cpp
@@ -506,13 +506,13 @@ void Capture::checkCloseCameraTab(int tabIndex)
 const QSharedPointer<Camera> Capture::mainCamera() const
 {
     if (cameras().size() > 0)
-    return moduleState()->cameras()[0];
+        return moduleState()->cameras()[0];
     else
     {
         QSharedPointer<CaptureModuleState> cms;
         cms.reset(new CaptureModuleState());
-            return QSharedPointer<Camera>(new Camera(0));
-        }
+        return QSharedPointer<Camera>(new Camera(0));
+    }
 }
 
 int Capture::findCameraPosition(QString train, bool addIfNecessary)
diff --git a/kstars/ekos/manager.cpp b/kstars/ekos/manager.cpp
index a28035bc48..3881b63ef1 100644
--- a/kstars/ekos/manager.cpp
+++ b/kstars/ekos/manager.cpp
@@ -3222,9 +3222,9 @@ void Manager::wizardProfile()
 bool Manager::getCurrentProfile(QSharedPointer<ProfileInfo> &profile) const
 {
     // Get current profile
-for (auto &pi : profiles)
-{
-    if (profileCombo->currentText() == pi->name)
+    for (auto &pi : profiles)
+    {
+        if (profileCombo->currentText() == pi->name)
         {
             profile = pi;
             return true;
diff --git a/kstars/fitsviewer/fitsdata.cpp b/kstars/fitsviewer/fitsdata.cpp
index 2ccb3c5aeb..0cf48f53ec 100644
--- a/kstars/fitsviewer/fitsdata.cpp
+++ b/kstars/fitsviewer/fitsdata.cpp
@@ -21,6 +21,9 @@
 #include "kspaths.h"
 #include "Options.h"
 #include "skymapcomposite.h"
+#include "skycomponents/starcomponent.h"
+#include "skyobjects/starobject.h"
+#include "skyobjects/catalogobject.h"
 #include "skycomponents/constellationboundarylines.h"
 #include "auxiliary/ksnotification.h"
 #include "auxiliary/robuststatistics.h"
@@ -5572,15 +5575,54 @@ bool FITSData::findObjectsInImage(SkyPoint startPoint, SkyPoint endPoint)
 
     m_SkyObjects.clear();
 
-    QList<SkyObject *> list = KStarsData::Instance()->skyComposite()->findObjectsInArea(startPoint, endPoint);
+    // Query the catalog independently of what is toggled on for the interactive Sky Map
+    // display (e.g. "Show Stars" / "Show Deep Sky Objects"), since those settings have
+    // nothing to do with whether the FITS Viewer should be able to annotate this image.
+    QList<SkyObject *> list = KStarsData::Instance()->skyComposite()->findObjectsInArea(startPoint, endPoint, true);
+    // Note: stars returned here already exclude unnamed catalog stars (see
+    // StarComponent::objectsInArea), so what remains are bright, named field stars
+    // worth annotating -- similar in spirit to astrometry.net's bright-star overlay.
     list.erase(std::remove_if(list.begin(), list.end(), [](SkyObject * oneObject)
     {
         int type = oneObject->type();
-        return (type == SkyObject::STAR || type == SkyObject::PLANET || type == SkyObject::ASTEROID ||
+        return (type == SkyObject::PLANET || type == SkyObject::ASTEROID ||
                 type == SkyObject::COMET || type == SkyObject::SUPERNOVA || type == SkyObject::MOON ||
                 type == SkyObject::SATELLITE);
     }), list.end());
 
+    // The named-star list above is small and curated (magnitude ~8 and brighter), so
+    // it frequently has zero coverage in an arbitrary field. Also search KStars' much
+    // deeper, on-demand-loaded star catalogs (which carry Henry Draper cross-refs) for
+    // additional bright field stars, similar to astrometry.net's HD-catalog overlay.
+    if (auto * stars = KStarsData::Instance()->skyComposite()->starComponent())
+    {
+        SkyPoint centerPoint;
+        if (pixelToWCS(QPointF(w / 2.0, h / 2.0), centerPoint))
+        {
+            // pixelToWCS() only sets RA0/Dec0 (J2000); starsInAperture() filters
+            // by angular distance using the apparent RA/Dec, so those must be
+            // derived too or every star fails the distance check.
+            centerPoint.updateCoordsNow(num);
+
+            const double searchRadius = startPoint.angularDistanceTo(&endPoint).Degrees() / 2.0;
+            QList<StarObject *> deepStars;
+            stars->starsInAperture(deepStars, centerPoint, searchRadius, 10.0f);
+            for (auto * star : deepStars)
+            {
+                // Skip stars already covered by the named-star list above, and
+                // anonymous stars with no HD cross-reference (to avoid clutter).
+                // Note: hasName() is *not* the right test here -- StarObject
+                // synthesizes "HD ####" as the Name for any star that only has
+                // an HD cross-reference (see StarObject's constructor), so
+                // hasName() is true for exactly the stars this loop wants to
+                // add. hasLatinName() correctly excludes that synthesized name.
+                if (star->hasLatinName() || star->getHDIndex() <= 0)
+                    continue;
+                list.append(star);
+            }
+        }
+    }
+
     double world[2], phi, theta, imgcrd[2], pixcrd[2];
     int stat[2];
     for (auto &object : list)
@@ -5594,7 +5636,43 @@ bool FITSData::findObjectsInImage(SkyPoint startPoint, SkyPoint endPoint)
             int x = pixcrd[0];
             int y = pixcrd[1];
             if (x > 0 && y > 0 && x < w && y < h)
-                m_SkyObjects.append(new FITSSkyObject(object, x, y));
+            {
+                auto * fitsObject = new FITSSkyObject(object, x, y);
+
+                // For extended objects (galaxies, clusters, nebulae, etc.) with a
+                // known angular size, derive the on-screen size/rotation of the
+                // annotation ellipse by projecting a point on the major axis
+                // through the same WCS transform used for the object's position.
+                // This avoids having to reason by hand about image orientation
+                // and parity conventions -- the WCS transform already encodes them.
+                if (auto * dso = dynamic_cast<CatalogObject *>(object); dso && dso->a() > 0)
+                {
+                    const double decRad = world[1] * M_PI / 180.0;
+                    const double cosDec = qMax(0.01, std::cos(decRad));
+                    const double paRad = dso->pa() * M_PI / 180.0;
+                    const double halfMajorDeg = dso->a() / 2.0 / 60.0;
+                    const double halfMinorDeg = (dso->b() > 0 ? dso->b() : dso->a()) / 2.0 / 60.0;
+
+                    double majorWorld[2], minorWorld[2], majorPix[2], minorPix[2];
+                    majorWorld[0] = world[0] + halfMajorDeg * std::sin(paRad) / cosDec;
+                    majorWorld[1] = world[1] + halfMajorDeg * std::cos(paRad);
+                    minorWorld[0] = world[0] + halfMinorDeg * std::sin(paRad + M_PI_2) / cosDec;
+                    minorWorld[1] = world[1] + halfMinorDeg * std::cos(paRad + M_PI_2);
+
+                    if (wcss2p(m_WCSHandle, 1, 2, &majorWorld[0], &phi, &theta, &imgcrd[0], &majorPix[0], &stat[0]) == 0 &&
+                            wcss2p(m_WCSHandle, 1, 2, &minorWorld[0], &phi, &theta, &imgcrd[0], &minorPix[0], &stat[0]) == 0)
+                    {
+                        const double dx = majorPix[0] - pixcrd[0];
+                        const double dy = majorPix[1] - pixcrd[1];
+                        const double majorRadiusPixels = std::hypot(dx, dy);
+                        const double minorRadiusPixels = std::hypot(minorPix[0] - pixcrd[0], minorPix[1] - pixcrd[1]);
+                        const double rotationDegrees = std::atan2(dy, dx) * 180.0 / M_PI;
+                        fitsObject->setEllipse(majorRadiusPixels, minorRadiusPixels, rotationDegrees);
+                    }
+                }
+
+                m_SkyObjects.append(fitsObject);
+            }
         }
     }
 
diff --git a/kstars/fitsviewer/fitslabel.cpp b/kstars/fitsviewer/fitslabel.cpp
index e68e8b3b84..8a046fcd17 100644
--- a/kstars/fitsviewer/fitslabel.cpp
+++ b/kstars/fitsviewer/fitslabel.cpp
@@ -305,8 +305,15 @@ void FITSLabel::mouseMoveEvent(QMouseEvent *e)
         {
             if ((std::abs(listObject->x() - x) < 5 / scale) && (std::abs(listObject->y() - y) < 5 / scale))
             {
-                QToolTip::showText(QtCompat::mouseGlobalPos(e).toPoint(),
-                                   QToolTip::text() + '\n' + listObject->skyObject()->name() + '\n' + listObject->skyObject()->longname(), this);
+                // Build a fresh tooltip rather than appending to QToolTip::text() --
+                // that returns whatever tooltip is currently on screen, so appending
+                // to it on every mouse-move tick over the same object made the text
+                // grow without bound.
+                SkyObject * hoveredObject = listObject->skyObject();
+                QString objTip = hoveredObject->name();
+                if (hoveredObject->hasLongName() && hoveredObject->longname() != hoveredObject->name())
+                    objTip += '\n' + hoveredObject->longname();
+                QToolTip::showText(QtCompat::mouseGlobalPos(e).toPoint(), objTip, this);
                 objFound = true;
                 break;
             }
diff --git a/kstars/fitsviewer/fitsskyobject.cpp b/kstars/fitsviewer/fitsskyobject.cpp
index c3bf3ac266..f97dfdada5 100644
--- a/kstars/fitsviewer/fitsskyobject.cpp
+++ b/kstars/fitsviewer/fitsskyobject.cpp
@@ -41,3 +41,10 @@ void FITSSkyObject::setY(int yPos)
 {
     yLoc = yPos;
 }
+
+void FITSSkyObject::setEllipse(double majorAxisPixels, double minorAxisPixels, double rotationDegrees)
+{
+    m_MajorAxisPixels = majorAxisPixels;
+    m_MinorAxisPixels = minorAxisPixels;
+    m_RotationDegrees = rotationDegrees;
+}
diff --git a/kstars/fitsviewer/fitsskyobject.h b/kstars/fitsviewer/fitsskyobject.h
index 8e1ac65c17..8336fa8ea9 100644
--- a/kstars/fitsviewer/fitsskyobject.h
+++ b/kstars/fitsviewer/fitsskyobject.h
@@ -45,10 +45,35 @@ class FITSSkyObject : public QObject
         void setY(int yPos);
         /** @} */
 
+    public:
+        /** @brief Set the on-screen major/minor axis lengths (in native, unscaled
+         * image pixels) and rotation (degrees, clockwise, for QPainter::rotate())
+         * of this object's annotation ellipse, as derived from the WCS solution.
+         * A major axis of 0 (the default) means the object has no known angular
+         * extent and should be drawn as a plain point marker.
+         */
+        void setEllipse(double majorAxisPixels, double minorAxisPixels, double rotationDegrees);
+
+        double majorAxisPixels() const
+        {
+            return m_MajorAxisPixels;
+        }
+        double minorAxisPixels() const
+        {
+            return m_MinorAxisPixels;
+        }
+        double rotationDegrees() const
+        {
+            return m_RotationDegrees;
+        }
+
     protected:
         SkyObject /*const*/ *skyObjectStored { nullptr };
         int xLoc { 0 };
         int yLoc { 0 };
+        double m_MajorAxisPixels { 0.0 };
+        double m_MinorAxisPixels { 0.0 };
+        double m_RotationDegrees { 0.0 };
 };
 
 #endif // FITSSKYOBJECT_H
diff --git a/kstars/fitsviewer/fitsview.cpp b/kstars/fitsviewer/fitsview.cpp
index 27df7be012..4ce3562cbe 100644
--- a/kstars/fitsviewer/fitsview.cpp
+++ b/kstars/fitsviewer/fitsview.cpp
@@ -8,6 +8,8 @@
 #include "config-kstars.h"
 #include "fitsview.h"
 
+#include <algorithm>
+
 #include "fitsdata.h"
 #include "fitslabel.h"
 #include "hips/hipsfinder.h"
@@ -1802,11 +1804,73 @@ void FITSView::drawObjectNames(QPainter * painter, double scale)
         return;
     }
 
-    painter->setPen(QPen(QColor(KStarsData::Instance()->colorScheme()->colorNamed("FITSObjectLabelColor"))));
-    for (const auto &listObject : m_ImageData->getSkyObjects())
+    painter->setPen(QPen(QColor(KStarsData::Instance()->colorScheme()->colorNamed("FITSObjectLabelColor")), 2));
+
+    // Crowded fields (e.g. the Rosette Nebula's cluster of near-coincident NGC
+    // entries) can have several labels competing for the same screen space.
+    // Let the brightest/most prominent objects claim label space first, and
+    // skip drawing a label -- but still draw its marker -- if it would
+    // overlap one already placed by a higher-priority object.
+    QList<FITSSkyObject *> sortedObjects = m_ImageData->getSkyObjects();
+    std::sort(sortedObjects.begin(), sortedObjects.end(), [](FITSSkyObject * a, FITSSkyObject * b)
+    {
+        return a->skyObject()->mag() < b->skyObject()->mag();
+    });
+
+    const QFontMetrics fontMetrics(painter->font());
+    QList<QRectF> placedLabels;
+
+    for (const auto &listObject : sortedObjects)
     {
-        painter->drawRect(listObject->x() * scale - 5, listObject->y() * scale - 5, 10, 10);
-        painter->drawText(listObject->x() * scale + 10, listObject->y() * scale + 10, listObject->skyObject()->name());
+        SkyObject * so = listObject->skyObject();
+        const double px = listObject->x() * scale;
+        const double py = listObject->y() * scale;
+
+        // Extended objects (galaxies, clusters, nebulae, etc.) with a known angular
+        // size/rotation (derived by FITSData through the WCS solution) are drawn as
+        // an ellipse, so they read like astrometry.net-style annotations rather than
+        // a generic marker. Point sources (stars) get a small circle.
+        const double majorRadius = qMax(5.0, listObject->majorAxisPixels() * scale);
+        const double minorRadius = listObject->majorAxisPixels() > 0
+                                   ? qMax(5.0, listObject->minorAxisPixels() * scale)
+                                   : majorRadius;
+        const double rotation = listObject->rotationDegrees();
+
+        if (rotation != 0.0)
+        {
+            painter->save();
+            painter->translate(px, py);
+            painter->rotate(rotation);
+            painter->drawEllipse(QPointF(0, 0), majorRadius, minorRadius);
+            painter->restore();
+        }
+        else
+            painter->drawEllipse(QPointF(px, py), majorRadius, minorRadius);
+
+        // Prefer the object's common name when it has one (e.g. "Crescent Nebula"
+        // over "NGC 6888") -- it's what most users will recognize. Stars pulled
+        // from KStars' deep, on-demand-loaded catalogs have no proper name --
+        // StarObject already synthesizes "HD ####" as their name() in that case,
+        // so no special-casing is needed for them.
+        const QString label = so->hasLongName() ? so->longname() : so->name();
+
+        // Keep the label close to the marker even for huge objects (e.g. M31 spans
+        // hundreds of pixels) rather than offsetting it by the full radius.
+        const double labelOffset = qMin(majorRadius, 20.0) + 4;
+        const QPointF labelPos(px + labelOffset, py + 4);
+        const QRectF labelRect = fontMetrics.boundingRect(label).translated(labelPos.toPoint());
+
+        const bool collides = std::any_of(placedLabels.cbegin(), placedLabels.cend(),
+                                          [&labelRect](const QRectF & other)
+        {
+            return labelRect.intersects(other);
+        });
+
+        if (!collides)
+        {
+            painter->drawText(labelPos, label);
+            placedLabels.append(labelRect);
+        }
     }
 }
 
diff --git a/kstars/skycomponents/catalogscomponent.cpp b/kstars/skycomponents/catalogscomponent.cpp
index 15acf85708..d2ada33e94 100644
--- a/kstars/skycomponents/catalogscomponent.cpp
+++ b/kstars/skycomponents/catalogscomponent.cpp
@@ -345,7 +345,12 @@ SkyObject *CatalogsComponent::findByName(const QString &name, bool exact)
 
 void CatalogsComponent::objectsInArea(QList<SkyObject *> &list, const SkyRegion &region)
 {
-    if (!selected())
+    objectsInArea(list, region, false);
+}
+
+void CatalogsComponent::objectsInArea(QList<SkyObject *> &list, const SkyRegion &region, bool ignoreVisibility)
+{
+    if (!ignoreVisibility && !selected())
         return;
 
     for (SkyRegion::const_iterator it = region.constBegin(); it != region.constEnd();
diff --git a/kstars/skycomponents/catalogscomponent.h b/kstars/skycomponents/catalogscomponent.h
index 04bdd2d8f4..dc17357dd0 100644
--- a/kstars/skycomponents/catalogscomponent.h
+++ b/kstars/skycomponents/catalogscomponent.h
@@ -92,6 +92,14 @@ class CatalogsComponent : public SkyComponent
 
         void objectsInArea(QList<SkyObject *> &list, const SkyRegion &region) override;
 
+        /**
+         * Same as objectsInArea(), but optionally bypasses the selected()
+         * (i.e. "Show Deep Sky Objects") check. Used by callers, such as the
+         * FITS Viewer's object annotation, that need catalog objects
+         * regardless of what is toggled on for the interactive Sky Map display.
+         */
+        void objectsInArea(QList<SkyObject *> &list, const SkyRegion &region, bool ignoreVisibility);
+
         SkyObject *objectNearest(SkyPoint *p, double &maxrad) override;
 
         /**
diff --git a/kstars/skycomponents/skymapcomposite.cpp b/kstars/skycomponents/skymapcomposite.cpp
index cfb310b393..6a60e3a0a1 100644
--- a/kstars/skycomponents/skymapcomposite.cpp
+++ b/kstars/skycomponents/skymapcomposite.cpp
@@ -547,16 +547,15 @@ QHash<int, QVector<QPair<QString, const SkyObject *>>> &SkyMapComposite::getObje
 }
 
 QList<SkyObject *> SkyMapComposite::findObjectsInArea(const SkyPoint &p1,
-        const SkyPoint &p2)
+        const SkyPoint &p2, bool ignoreVisibility)
 {
     const SkyRegion &region = m_skyMesh->skyRegion(p1, p2);
     QList<SkyObject *> list;
     // call objectsInArea( QList<SkyObject*>&, const SkyRegion& ) for each of the
     // components of the SkyMapComposite
-    if (m_Stars->selected())
+    if (ignoreVisibility || m_Stars->selected())
         m_Stars->objectsInArea(list, region);
-    if (m_Catalogs->selected())
-        m_Catalogs->objectsInArea(list, region);
+    m_Catalogs->objectsInArea(list, region, ignoreVisibility);
     return list;
 }
 
diff --git a/kstars/skycomponents/skymapcomposite.h b/kstars/skycomponents/skymapcomposite.h
index 35a91440f3..0eadc63bd6 100644
--- a/kstars/skycomponents/skymapcomposite.h
+++ b/kstars/skycomponents/skymapcomposite.h
@@ -150,8 +150,13 @@ class SkyMapComposite : public QObject, public SkyComposite
              * @return the list of objects in the region defined by skypoints
              * @param p1 first sky point (top-left vertex of rectangular region)
              * @param p2 second sky point (bottom-right vertex of rectangular region)
+             * @param ignoreVisibility if true, stars and catalog objects are included
+             * even if their component is currently hidden in the Sky Map (e.g. via
+             * "Show Stars" / "Show Deep Sky Objects"). Useful for callers, such as the
+             * FITS Viewer's object annotation, that query the catalog independently of
+             * what is toggled on for the interactive sky map display.
              */
-        QList<SkyObject *> findObjectsInArea(const SkyPoint &p1, const SkyPoint &p2);
+        QList<SkyObject *> findObjectsInArea(const SkyPoint &p1, const SkyPoint &p2, bool ignoreVisibility = false);
 
         bool addNameLabel(SkyObject *o);
         bool removeNameLabel(SkyObject *o);
@@ -227,6 +232,11 @@ class SkyMapComposite : public QObject, public SkyComposite
             return m_Catalogs;
         }
 
+        inline StarComponent *starComponent()
+        {
+            return m_Stars;
+        }
+
         inline MilkyWay *milkyWay()
         {
             return m_MilkyWay;
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.