[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 ®ion)
{
- if (!selected())
+ objectsInArea(list, region, false);
+}
+
+void CatalogsComponent::objectsInArea(QList<SkyObject *> &list, const SkyRegion ®ion, 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 ®ion) 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 ®ion, 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 ®ion = 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;