File indexing completed on 2024-05-12 15:23:44

0001 /*
0002     SPDX-FileCopyrightText: 2020 Hy Murveit <hy@murveit.com>
0003 
0004     SPDX-License-Identifier: GPL-2.0-or-later
0005 */
0006 
0007 #include "starcorrespondence.h"
0008 
0009 #include <math.h>
0010 #include "ekos_guide_debug.h"
0011 
0012 // Finds the star in sortedStars that's closest to x,y and within maxDistance pixels.
0013 // Returns the index of the closest star in  sortedStars, or -1 if none satisfies the criteria.
0014 // sortedStars must be sorted by x, from lower x to higher x.
0015 // Fills distance to the pixel distance to the closest star.
0016 int StarCorrespondence::findClosestStar(double x, double y, const QList<Edge> sortedStars,
0017                                         double maxDistance, double *distance) const
0018 {
0019     if (x < -maxDistance || y < -maxDistance ||
0020             x > imageWidth + maxDistance || y > imageWidth + maxDistance)
0021         return -1;
0022 
0023     const int size = sortedStars.size();
0024     int startPoint = 0;
0025 
0026     // Efficiency improvement to quickly find the start point.
0027     // Increments by size/10 to find a better start point.
0028     const int coarseIncrement = size / 10;
0029     if (coarseIncrement > 1)
0030     {
0031         for (int i = 0; i < size; i += coarseIncrement)
0032         {
0033             if (sortedStars[i].x < x - maxDistance)
0034                 startPoint = i;
0035             else
0036                 break;
0037         }
0038     }
0039 
0040     // Iterate through the stars starting with the star with lowest x where x >= x - maxDistance
0041     // through the star with greatest x where x <= x + maxDistance.  Find the closest star to x,y.
0042     int bestIndex = -1;
0043     double bestSquaredDistance = maxDistance * maxDistance;
0044     for (int i = startPoint; i < size; ++i)
0045     {
0046         auto &star = sortedStars[i];
0047         if (star.x < x - maxDistance) continue;
0048         if (star.x > x + maxDistance) break;
0049         const double xDiff = star.x - x;
0050         const double yDiff = star.y - y;
0051         const double squaredDistance = xDiff * xDiff + yDiff * yDiff;
0052         if (squaredDistance <= bestSquaredDistance)
0053         {
0054             bestIndex = i;
0055             bestSquaredDistance = squaredDistance;
0056         }
0057     }
0058     if (distance != nullptr) *distance = sqrt(bestSquaredDistance);
0059     return bestIndex;
0060 }
0061 
0062 namespace
0063 {
0064 // Sorts stars by their x values, places the sorted stars into sortedStars.
0065 // sortedToOriginal is a map whose index is the index of a star in sortedStars and whose
0066 // value is the index of a star in stars.
0067 // Therefore, sortedToOrigial[a] = b, means that sortedStars[a] corresponds to stars[b].
0068 void sortByX(const QList<Edge> &stars, QList<Edge> *sortedStars, QVector<int> *sortedToOriginal)
0069 {
0070     sortedStars->clear();
0071     sortedToOriginal->clear();
0072 
0073     // Sort the stars by their x-positions, lower to higher.
0074     QVector<std::pair<int, float>> rx;
0075     for (int i = 0; i < stars.size(); ++i)
0076         rx.push_back(std::pair<int, float>(i, stars[i].x));
0077     std::sort(rx.begin(), rx.end(), [](const std::pair<int, float> &a, const std::pair<int, double> &b)
0078     {
0079         return a.second < b.second;
0080     });
0081     for (int i = 0; i < stars.size(); ++i)
0082     {
0083         sortedStars->append(stars[ rx[i].first ]);
0084         sortedToOriginal->push_back(rx[i].first);
0085     }
0086 }
0087 
0088 }  // namespace
0089 
0090 StarCorrespondence::StarCorrespondence(const QList<Edge> &stars, int guideStar)
0091 {
0092     initialize(stars, guideStar);
0093 }
0094 
0095 StarCorrespondence::StarCorrespondence()
0096 {
0097 }
0098 
0099 // Initialize with the reference stars and the guide-star index.
0100 void StarCorrespondence::initialize(const QList<Edge> &stars, int guideStar)
0101 {
0102     qCDebug(KSTARS_EKOS_GUIDE) << "StarCorrespondence initialized with " << stars.size() << guideStar;
0103     references = stars;
0104     guideStarIndex = guideStar;
0105     float guideX = references[guideStarIndex].x;
0106     float guideY = references[guideStarIndex].y;
0107     guideStarOffsets.clear();
0108     referenceSums.clear();
0109     referenceNumPixels.clear();
0110 
0111     // Compute the x and y offsets from the guide star to all reference stars
0112     // and store them in guideStarOffsets.
0113     const int numRefs = references.size();
0114     for (int i = 0; i < numRefs; ++i)
0115     {
0116         const auto &ref = references[i];
0117         guideStarOffsets.push_back(Offsets(ref.x - guideX, ref.y - guideY));
0118         referenceSums.push_back(ref.sum);
0119         referenceNumPixels.push_back(ref.numPixels);
0120     }
0121 
0122     initializeAdaptation();
0123 
0124     initialized = true;
0125 }
0126 
0127 void StarCorrespondence::reset()
0128 {
0129     references.clear();
0130     guideStarOffsets.clear();
0131     referenceSums.clear();
0132     referenceNumPixels.clear();
0133     initialized = false;
0134 }
0135 
0136 int StarCorrespondence::findInternal(const QList<Edge> &stars, double maxDistance, QVector<int> *starMap,
0137                                      int guideStarIndex, const QVector<Offsets> &offsets,
0138                                      int *numFound, int *numNotFound, double minFraction) const
0139 {
0140     // This is the cost of not finding one of the reference stars.
0141     constexpr double missingRefStarCost = 100;
0142     // This weight multiplies the distance between a reference star position and the closest star in stars.
0143     constexpr double distanceWeight = 1.0;
0144 
0145     // Initialize all stars to not-corresponding to any reference star.
0146     *starMap = QVector<int>(stars.size(), -1);
0147 
0148     // We won't accept a solution worse than bestCost.
0149     // In the default case, we need to find about half the reference stars.
0150     // Another possibility is a constraint on the input stars being mapped
0151     // E.g. that the more likely solution is one where the stars are close to the references.
0152     // This can be an issue if the number of input stars is way less than the number of reference stars
0153     // but in that case we can fail and go to the default star-finding algorithm.
0154     int bestCost = offsets.size() * missingRefStarCost * (1 - minFraction);
0155     // Note that the above implies that if stars.size() < offsets.size() * minFraction
0156     // then it is impossible to succeed.
0157     if (stars.size() < minFraction * offsets.size())
0158         return -1;
0159 
0160     // Assume the guide star corresponds to each of the stars.
0161     // Score the assignment, pick the best, and then assign the rest.
0162     const int numStars = stars.size();
0163     int bestStarIndex = -1, bestNumFound = 0, bestNumNotFound = 0;
0164     for (int starIndex = 0; starIndex < numStars; ++starIndex)
0165     {
0166         const float starX = stars[starIndex].x;
0167         const float starY = stars[starIndex].y;
0168 
0169         double cost = 0.0;
0170         QVector<int> mapping(stars.size(), -1);
0171         int numFound = 0, numNotFound = 0;
0172         for (int offsetIndex = 0; offsetIndex < offsets.size(); ++offsetIndex)
0173         {
0174             // Of course, the guide star has offsets 0 to itself.
0175             if (offsetIndex == guideStarIndex) continue;
0176 
0177             // We're already worse than the best cost. No need to search any more.
0178             if (cost > bestCost) break;
0179 
0180             // Look for an input star at the offset position.
0181             // Note the index value returned is the sorted index, which is converted back later.
0182             const auto &offset = offsets[offsetIndex];
0183             double distance;
0184             const int closestIndex = findClosestStar(starX + offset.x, starY + offset.y,
0185                                      stars, maxDistance, &distance);
0186             if (closestIndex < 0)
0187             {
0188                 // This reference star position had no corresponding input star.
0189                 cost += missingRefStarCost;
0190                 numNotFound++;
0191                 continue;
0192             }
0193             numFound++;
0194 
0195             // If starIndex is the star that corresponds to guideStarIndex, then
0196             // stars[index] corresponds to references[offsetIndex]
0197             mapping[closestIndex] = offsetIndex;
0198             cost += distance * distanceWeight;
0199         }
0200         if (cost < bestCost)
0201         {
0202             bestCost = cost;
0203             bestStarIndex = starIndex;
0204             bestNumFound = numFound;
0205             bestNumNotFound = numNotFound;
0206 
0207             *starMap = QVector<int>(stars.size(), -1);
0208             for (int i = 0; i < mapping.size(); ++i)
0209                 (*starMap)[i] = mapping[i];
0210             (*starMap)[starIndex] = guideStarIndex;
0211         }
0212     }
0213     *numFound = bestNumFound;
0214     *numNotFound = bestNumNotFound;
0215     return bestStarIndex;
0216 }
0217 
0218 // Converts offsets that are from the point-of-view of the guidestar, to offsets from targetStar.
0219 void StarCorrespondence::makeOffsets(const QVector<Offsets> &offsets, QVector<Offsets> *targetOffsets,
0220                                      int targetStar) const
0221 {
0222     targetOffsets->resize(offsets.size());
0223     int xOffset = offsets[targetStar].x;
0224     int yOffset = offsets[targetStar].y;
0225     for (int i = 0; i < offsets.size(); ++i)
0226     {
0227         if (i == targetStar)
0228         {
0229             (*targetOffsets)[i] = Offsets(0, 0);
0230         }
0231         else
0232         {
0233             const double x = offsets[i].x - xOffset;
0234             const double y = offsets[i].y - yOffset;
0235             (*targetOffsets)[i] = Offsets(x, y);
0236         }
0237     }
0238 
0239 }
0240 
0241 // We create an imaginary star from the ones we did find.
0242 Edge StarCorrespondence::inventStarPosition(const QList<Edge> &stars, const QVector<int> &starMap,
0243         QVector<Offsets> offsets, Offsets offset) const
0244 {
0245     Edge inventedStar;
0246     inventedStar.invalidate();
0247 
0248     QVector<double> xPositions, yPositions;
0249     float refSum = 0, origSum = 0, refNumPixels = 0, origNumPixels = 0;
0250     for (int i = 0; i < starMap.size(); ++i)
0251     {
0252         int reference = starMap[i];
0253         if (reference >= 0)
0254         {
0255             xPositions.push_back(stars[i].x - offsets[reference].x);
0256             yPositions.push_back(stars[i].y - offsets[reference].y);
0257 
0258             // These are used to help invent the SNR.
0259             refSum += stars[i].sum;
0260             refNumPixels += stars[i].numPixels;
0261             origSum += referenceSums[reference];
0262             origNumPixels += referenceNumPixels[reference];
0263         }
0264     }
0265     float inventedSum = 0;
0266     float inventedNumPixels = 0;
0267     if (origSum > 0 && origNumPixels > 0)
0268     {
0269         // If possible, interpolate to estimate the invented star's flux and number of pixels.
0270         inventedSum = (refSum / origSum) * referenceSums[guideStarIndex];
0271         inventedNumPixels = (refNumPixels / origNumPixels) * referenceNumPixels[guideStarIndex];
0272     }
0273 
0274     if (xPositions.size() == 0)
0275         return inventedStar;
0276 
0277     // Compute the median x and y values. After gathering the values above,
0278     // we sort them and use the middle positions.
0279     std::sort(xPositions.begin(),  xPositions.end(),  [] (double d1, double d2)
0280     {
0281         return d1 < d2;
0282     });
0283     std::sort(yPositions.begin(), yPositions.end(), [] (double d1, double d2)
0284     {
0285         return d1 < d2;
0286     });
0287     const int num = xPositions.size();
0288     double xVal = 0, yVal = 0;
0289     if (num % 2 == 0)
0290     {
0291         const int middle = num / 2;
0292         xVal = (xPositions[middle - 1] + xPositions[middle]) / 2.0;
0293         yVal = (yPositions[middle - 1] + yPositions[middle]) / 2.0;
0294     }
0295     else
0296     {
0297         const int middle = num / 2;  // because it's 0-based
0298         xVal = xPositions[middle];
0299         yVal = yPositions[middle];
0300     }
0301     inventedStar.x = xVal - offset.x;
0302     inventedStar.y = yVal - offset.y;
0303     inventedStar.sum = inventedSum;
0304     inventedStar.numPixels = inventedNumPixels;
0305     return inventedStar;
0306 }
0307 
0308 namespace
0309 {
0310 // This utility converts the starMap to refer to indexes in the original star QVector
0311 // instead of the sorted-by-x version.
0312 void unmapStarMap(const QVector<int> &sortedStarMap, const QVector<int> &sortedToOriginal, QVector<int> *starMap)
0313 {
0314     for (int i = 0; i < sortedStarMap.size(); ++i)
0315         (*starMap)[sortedToOriginal[i]] = sortedStarMap[i];
0316 }
0317 }
0318 
0319 Edge StarCorrespondence::find(const QList<Edge> &stars, double maxDistance,
0320                               QVector<int> *starMap, bool adapt, double minFraction)
0321 {
0322     m_NumReferencesFound = 0;
0323     *starMap = QVector<int>(stars.size(), -1);
0324     Edge foundStar;
0325     foundStar.invalidate();
0326     if (!initialized)  return foundStar;
0327     int numFound, numNotFound;
0328 
0329     // findClosestStar needs an input with stars sorted by their x.
0330     // Do this outside of the loops.
0331     QList<Edge> sortedStars;
0332     QVector<int> sortedToOriginal;
0333     sortByX(stars, &sortedStars, &sortedToOriginal);
0334 
0335     QVector<int> sortedStarMap;
0336     int bestStarIndex = findInternal(sortedStars, maxDistance, &sortedStarMap, guideStarIndex,
0337                                      guideStarOffsets, &numFound, &numNotFound, minFraction);
0338 
0339     if (bestStarIndex > -1)
0340     {
0341         // Convert back to the unsorted index value.
0342         bestStarIndex = sortedToOriginal[bestStarIndex];
0343         unmapStarMap(sortedStarMap, sortedToOriginal, starMap);
0344 
0345         foundStar = stars[bestStarIndex];
0346         qCDebug(KSTARS_EKOS_GUIDE)
0347                 << "StarCorrespondence found guideStar at " << bestStarIndex << "found/not"
0348                 << numFound << numNotFound;
0349         m_NumReferencesFound = numFound;
0350     }
0351     else if (allowMissingGuideStar && bestStarIndex == -1 &&
0352              stars.size() >= minFraction * guideStarOffsets.size())
0353     {
0354         qCDebug(KSTARS_EKOS_GUIDE)
0355                 << "Trying in invent. StarCorrespondence did NOT find guideStar. Found/not"
0356                 << numFound << numNotFound << "minFraction" << minFraction << "maxDistance" << maxDistance;
0357         // If we didn't find a good solution, perhaps the guide star was not detected.
0358         // See if we can get a reasonable solution from the other stars.
0359         int bestNumFound = 0;
0360         int bestNumNotFound = 0;
0361         Edge bestInvented;
0362         bestInvented.invalidate();
0363         for (int gStarIndex = 0; gStarIndex < guideStarOffsets.size(); gStarIndex++)
0364         {
0365             if (gStarIndex == guideStarIndex)
0366                 continue;
0367             QVector<Offsets> gStarOffsets;
0368             makeOffsets(guideStarOffsets, &gStarOffsets, gStarIndex);
0369             QVector<int> newStarMap;
0370             int detectedStarIndex = findInternal(sortedStars, maxDistance, &newStarMap,
0371                                                  gStarIndex, gStarOffsets,
0372                                                  &numFound, &numNotFound, minFraction);
0373             if (detectedStarIndex >= 0 && numFound > bestNumFound)
0374             {
0375                 Edge invented = inventStarPosition(sortedStars, newStarMap, gStarOffsets,
0376                                                    guideStarOffsets[gStarIndex]);
0377                 if (invented.x < 0 || invented.y < 0)
0378                     continue;
0379 
0380                 bestInvented = invented;
0381                 bestNumFound = numFound;
0382                 bestNumNotFound = numNotFound;
0383                 sortedStarMap = newStarMap;
0384 
0385                 if (numNotFound <= 1)
0386                     // We can't do better than this.
0387                     break;
0388             }
0389         }
0390         if (bestNumFound > 0)
0391         {
0392             // Convert back to the unsorted index value.
0393             unmapStarMap(sortedStarMap, sortedToOriginal, starMap);
0394             qCDebug(KSTARS_EKOS_GUIDE)
0395                     << "StarCorrespondence found guideStar (invented) at "
0396                     << bestInvented.x << bestInvented.y << "found/not" << bestNumFound << bestNumNotFound;
0397             m_NumReferencesFound = bestNumFound;
0398             return bestInvented;
0399         }
0400         else qCDebug(KSTARS_EKOS_GUIDE) << "StarCorrespondence could not invent guideStar.";
0401     }
0402     else qCDebug(KSTARS_EKOS_GUIDE) << "StarCorrespondence could not find guideStar.";
0403 
0404     if (adapt && (bestStarIndex != -1))
0405         adaptOffsets(stars, *starMap);
0406     return foundStar;
0407 }
0408 
0409 // Compute the alpha cooeficient for a simple IIR filter used in adaptOffsets()
0410 // that causes the offsets to be, roughtly, the moving average of the last timeConstant samples.
0411 // See the discussion here:
0412 // https://dsp.stackexchange.com/questions/378/what-is-the-best-first-order-iir-ar-filter-approximation-to-a-moving-average-f
0413 void StarCorrespondence::initializeAdaptation()
0414 {
0415     // Approximately average the last 25 samples.
0416     const double timeConstant = 25.0;
0417     alpha = 1.0 / pow(timeConstant, 0.865);
0418 
0419     // Don't let the adapted offsets move far from the original ones.
0420     originalGuideStarOffsets = guideStarOffsets;
0421 }
0422 
0423 void StarCorrespondence::adaptOffsets(const QList<Edge> &stars, const QVector<int> &starMap)
0424 {
0425     const int numStars = stars.size();
0426     if (starMap.size() != numStars)
0427     {
0428         qCDebug(KSTARS_EKOS_GUIDE) << "Adapt error: StarMap size != stars.size()" << starMap.size() << numStars;
0429         return;
0430     }
0431     // guideStar will be the index in stars corresponding to the reference guide star.
0432     int guideStar = -1;
0433     for (int i = 0; i < numStars; ++i)
0434     {
0435         if (starMap[i] == guideStarIndex)
0436         {
0437             guideStar = i;
0438             break;
0439         }
0440     }
0441     if (guideStar < 0)
0442     {
0443         qCDebug(KSTARS_EKOS_GUIDE) << "Adapt error: no guide star in map";
0444         return;
0445     }
0446 
0447     for (int i = 0; i < numStars; ++i)
0448     {
0449         // We don't adapt if the ith star doesn't correspond to any of the references,
0450         // or if it corresponds to the guide star (whose offsets are, of course, always 0).
0451         const int refIndex = starMap[i];
0452         if (refIndex == -1 || refIndex == guideStarIndex)
0453             continue;
0454 
0455         if ((refIndex >= references.size()) || (refIndex < 0))
0456         {
0457             qCDebug(KSTARS_EKOS_GUIDE) << "Adapt error: bad refIndex[" << i << "] =" << refIndex;
0458             return;
0459         }
0460         // Adapt the x and y offsets using the following IIR filter:
0461         // output[t] = alpha * offset[t] + (1-alpha) * output[t-1]
0462         const double xOffset = stars[i].x - stars[guideStar].x;
0463         const double yOffset = stars[i].y - stars[guideStar].y;
0464         const double currentXOffset = guideStarOffsets[refIndex].x;
0465         const double currentYOffset = guideStarOffsets[refIndex].y;
0466         const double newXOffset = alpha * xOffset + (1 - alpha) * currentXOffset;
0467         const double newYOffset = alpha * yOffset + (1 - alpha) * currentYOffset;
0468 
0469         // The adaptation is meant for small movements of at most a few pixels.
0470         // We don't want it to move enough to find a new star.
0471         constexpr double maxMovement = 2.5;  // pixels
0472         if ((fabs(newXOffset - originalGuideStarOffsets[refIndex].x) < maxMovement) &&
0473                 (fabs(newYOffset - originalGuideStarOffsets[refIndex].y) < maxMovement))
0474         {
0475             guideStarOffsets[refIndex].x = newXOffset;
0476             guideStarOffsets[refIndex].y = newYOffset;
0477         }
0478     }
0479 }