QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
TerrariumTileFetcher.cc
Go to the documentation of this file.
1/****************************************************************************
2 *
3 * (c) 2009-2024 QGROUNDCONTROL PROJECT <http://www.qgroundcontrol.org>
4 *
5 * QGroundControl is licensed according to the terms in the file
6 * COPYING.md in the root of the source code directory.
7 *
8 ****************************************************************************/
9
11
12#include <QtGui/QImage>
13#include <QtNetwork/QNetworkAccessManager>
14#include <QtNetwork/QNetworkReply>
15
16#include <cmath>
17#include <memory>
18
20#include "HeightField.h"
21#include "PatchGeometry.h"
22#include "QGCCacheTile.h"
23#include "QGCLoggingCategory.h"
24#include "QGCMapEngine.h"
25#include "QGCMapTasks.h"
26#include "QGCMapUrlEngine.h"
28#include "QGeoTileFetcherQGC.h"
29
30QGC_LOGGING_CATEGORY(GeoMapTerrariumTileFetcherLog, "GeoMap.TerrariumTileFetcher")
31QGC_LOGGING_CATEGORY(GeoMapTerrariumTileFetcherVerboseLog, "GeoMap.TerrariumTileFetcher.Verbose")
32
33static_assert(TerrariumTileFetcher::kMaxGridSize == PatchGeometry::kMaxGridSize,
34 "fetcher grid cap must match the patch mesh cap");
35
36namespace {
37
38QString terrariumProviderType()
39{
40 return QString::fromLatin1(TerrariumElevationProvider::kProviderKey);
41}
42
44double heightAtPixel(const QImage& image, int px, int py)
45{
46 const QRgb rgb = image.pixel(px, py);
47 return (qRed(rgb) * 256.0) + qGreen(rgb) + (qBlue(rgb) / 256.0) - 32768.0;
48}
49
54double heightAtUV(const QImage& image, double u, double v)
55{
56 const int w = image.width();
57 const int h = image.height();
58 const double px = (u * w) - 0.5;
59 const double py = (v * h) - 0.5;
60 const int x0 = qBound(0, static_cast<int>(std::floor(px)), w - 1);
61 const int y0 = qBound(0, static_cast<int>(std::floor(py)), h - 1);
62 const int x1 = qMin(x0 + 1, w - 1);
63 const int y1 = qMin(y0 + 1, h - 1);
64 const double fx = qBound(0.0, px - x0, 1.0);
65 const double fy = qBound(0.0, py - y0, 1.0);
66
67 const double north = (heightAtPixel(image, x0, y0) * (1.0 - fx)) + (heightAtPixel(image, x1, y0) * fx);
68 const double south = (heightAtPixel(image, x0, y1) * (1.0 - fx)) + (heightAtPixel(image, x1, y1) * fx);
69 return (north * (1.0 - fy)) + (south * fy);
70}
71
74QList<float> sampleGrid(const QImage& image, const QRectF& subWindow, int gridSize)
75{
76 QList<float> heights;
77 heights.reserve(qsizetype(gridSize + 1) * (gridSize + 1));
78 for (int row = 0; row <= gridSize; row++) {
79 const double v = subWindow.y() + (subWindow.height() * row / gridSize);
80 for (int col = 0; col <= gridSize; col++) {
81 const double u = subWindow.x() + (subWindow.width() * col / gridSize);
82 heights.append(static_cast<float>(heightAtUV(image, u, v)));
83 }
84 }
85 return heights;
86}
87
90constexpr int kTileSizePixels = 256;
91
93QImage decodeTile(const QByteArray& data)
94{
95 QImage image;
96 if (!image.loadFromData(data) || image.width() != kTileSizePixels || image.height() != kTileSizePixels) {
97 return QImage();
98 }
99 return image.convertToFormat(QImage::Format_RGB32);
100}
101
103ElevationTilePyramid::Grid gridFromImage(const QImage& image)
104{
106 grid.width = image.width();
107 grid.height = image.height();
108 grid.heights.reserve(qsizetype(grid.width) * grid.height);
109 for (int py = 0; py < grid.height; py++) {
110 for (int px = 0; px < grid.width; px++) {
111 grid.heights.append(static_cast<float>(heightAtPixel(image, px, py)));
112 }
113 }
114 return grid;
115}
116
119TileMath::TileKey fetchKeyFor(const TileMath::TileKey& key)
120{
122 return key;
123 }
124 const int shift = key.zoom - TerrariumTileFetcher::kMaxTileZoom;
125 return TileMath::TileKey{key.x >> shift, key.y >> shift, TerrariumTileFetcher::kMaxTileZoom};
126}
127
128} // namespace
129
130TerrariumTileFetcher::TerrariumTileFetcher(QObject* parent, QNetworkAccessManager* networkManager)
131 : HeightSource(parent),
132 _networkManager(networkManager ? networkManager : new QNetworkAccessManager(this)),
133 _mapId(UrlFactory::getQtMapIdFromProviderType(terrariumProviderType()))
134{}
135
137{
138 const int requestId = _nextRequestId();
139
140 if ((gridSize <= 0) || (gridSize > kMaxGridSize) || !TileMath::isValidKey(key)) {
141 qCWarning(GeoMapTerrariumTileFetcherLog)
142 << "requestPatchHeights rejected: key" << key << "gridSize" << gridSize;
143 _pending.insert(requestId, PendingRequest{});
144 _failAsync(requestId);
145 return requestId;
146 }
147 if (_mapId < 0) {
148 qCDebug(GeoMapTerrariumTileFetcherLog) << "requestPatchHeights: elevation provider not registered";
149 _pending.insert(requestId, PendingRequest{});
150 _failAsync(requestId);
151 return requestId;
152 }
153
154 // Deeper than the dataset: fetch the top-zoom ancestor and sample the
155 // patch's sub-window of it
156 PendingRequest pending;
157 pending.gridSize = gridSize;
158 pending.fetchKey = fetchKeyFor(key);
159 if (key.zoom <= kMaxTileZoom) {
160 pending.subWindow = QRectF(0, 0, 1, 1);
161 } else {
162 const int shift = key.zoom - kMaxTileZoom;
163 const double scale = 1.0 / (1LL << shift);
164 pending.subWindow = QRectF((key.x - (qint64(pending.fetchKey.x) << shift)) * scale,
165 (key.y - (qint64(pending.fetchKey.y) << shift)) * scale, scale, scale);
166 }
167 _pending.insert(requestId, pending);
168 _lastFetchKey = pending.fetchKey;
169
170 const bool inFlight = _fetchInFlight(pending.fetchKey);
171 _waiters[pending.fetchKey].append(requestId);
172 qCDebug(GeoMapTerrariumTileFetcherVerboseLog)
173 << "requestPatchHeights: key" << key << "requestId" << requestId << "fetchKey" << pending.fetchKey
174 << (inFlight ? "(joining in-flight fetch)" : "(starting fetch)");
175 if (inFlight) {
176 return requestId; // a fetch for this tile is already in flight; it serves all waiters
177 }
178
179 if (!_startFetch(pending.fetchKey)) {
180 qCDebug(GeoMapTerrariumTileFetcherLog) << "requestPatchHeights: could not start fetch for" << pending.fetchKey;
181 _waiters.remove(pending.fetchKey);
182 _failAsync(requestId);
183 }
184 return requestId;
185}
186
188{
189 if (!_heightField || !TileMath::isValidKey(key)) {
190 qCWarning(GeoMapTerrariumTileFetcherLog)
191 << "requestTile rejected: key" << key << "heightFieldSet" << (_heightField != nullptr);
192 return false;
193 }
194 if (_mapId < 0) {
195 qCDebug(GeoMapTerrariumTileFetcherLog) << "requestTile: elevation provider not registered";
196 return false;
197 }
198
199 const TileMath::TileKey fetchKey = fetchKeyFor(key);
200 if (_heightField->hasTile(fetchKey) || _fieldRequests.contains(fetchKey)) {
201 return true;
202 }
203
204 const bool inFlight = _fetchInFlight(fetchKey);
205 _fieldRequests.insert(fetchKey);
206 _lastFetchKey = fetchKey;
207 qCDebug(GeoMapTerrariumTileFetcherVerboseLog)
208 << "requestTile: fetchKey" << fetchKey << (inFlight ? "(joining in-flight fetch)" : "(starting fetch)");
209 if (inFlight) {
210 return true; // piggyback on the fetch already serving patch waiters
211 }
212
213 if (!_startFetch(fetchKey)) {
214 qCDebug(GeoMapTerrariumTileFetcherLog) << "requestTile: could not start fetch for" << fetchKey;
215 _fieldRequests.remove(fetchKey);
216 return false;
217 }
218 return true;
219}
220
221bool TerrariumTileFetcher::_fetchInFlight(const TileMath::TileKey& fetchKey) const
222{
223 return _waiters.contains(fetchKey) || _fieldRequests.contains(fetchKey);
224}
225
226bool TerrariumTileFetcher::_startFetch(const TileMath::TileKey& fetchKey)
227{
228 const QString providerType = terrariumProviderType();
229 QGCFetchTileTask* const task =
230 QGeoFileTileCacheQGC::createFetchTileTask(providerType, fetchKey.x, fetchKey.y, fetchKey.zoom);
231 connect(task, &QGCFetchTileTask::tileFetched, this, [this, fetchKey](QGCCacheTile* tile) {
232 const std::unique_ptr<QGCCacheTile> guard(tile); // caller-owned per task contract
233 if (!_fetchInFlight(fetchKey)) {
234 return; // all interest cancelled while the lookup was in flight
235 }
236 // An unusable cached body (empty or corrupt) is a miss, not a
237 // failure: failing here would re-read the same bytes on every retry,
238 // blocking the network fallback until cache eviction
239 const QImage image = tile ? decodeTile(tile->img) : QImage();
240 if (image.isNull()) {
241 qCDebug(GeoMapTerrariumTileFetcherLog)
242 << "tile" << fetchKey << "cached entry unusable, fetching from network";
243 _fetchFromNetwork(fetchKey);
244 return;
245 }
246 qCDebug(GeoMapTerrariumTileFetcherVerboseLog) << "tile" << fetchKey << "served from cache";
247 _deliverAll(fetchKey, image);
248 });
249 connect(task, &QGCMapTask::error, this, [this, fetchKey](QGCMapTask::TaskType, const QString&) {
250 if (!_fetchInFlight(fetchKey)) {
251 return;
252 }
253 qCDebug(GeoMapTerrariumTileFetcherVerboseLog) << "tile" << fetchKey << "not cached, fetching from network";
254 _fetchFromNetwork(fetchKey);
255 });
256
257 if (!getQGCMapEngine()->addTask(task)) {
258 qCDebug(GeoMapTerrariumTileFetcherLog) << "could not queue cache lookup for" << fetchKey;
259 task->deleteLater(); // never enqueued: the worker will not clean it up
260 return false;
261 }
262 return true;
263}
264
266{
267 const auto pendingIt = _pending.constFind(requestId);
268 if (pendingIt == _pending.cend()) {
269 return;
270 }
271 const TileMath::TileKey fetchKey = pendingIt->fetchKey;
272 _pending.erase(pendingIt);
273
274 const auto waitersIt = _waiters.find(fetchKey);
275 if (waitersIt == _waiters.end()) {
276 return;
277 }
278 waitersIt->removeOne(requestId);
279 if (!waitersIt->isEmpty()) {
280 return; // other requests still wait on this tile's fetch
281 }
282 _waiters.erase(waitersIt);
283 if (_fieldRequests.contains(fetchKey)) {
284 return; // the field still wants this tile: keep the fetch alive
285 }
286 QNetworkReply* const reply = _activeReplies.take(fetchKey);
287 if (reply) {
288 qCDebug(GeoMapTerrariumTileFetcherVerboseLog) << "cancelRequest: aborting network fetch for" << fetchKey;
289 reply->abort(); // finished handler is a no-op once no waiters remain
290 reply->deleteLater(); // don't rely on abort emitting finished for cleanup
291 }
292}
293
294void TerrariumTileFetcher::_failAsync(int requestId)
295{
296 // Deliver asynchronously so callers see one consistent flow
297 QMetaObject::invokeMethod(
298 this,
299 [this, requestId] {
300 if (_pending.contains(requestId)) {
301 _finishFailed(requestId);
302 }
303 },
304 Qt::QueuedConnection);
305}
306
307void TerrariumTileFetcher::_fetchFromNetwork(const TileMath::TileKey& fetchKey)
308{
309 if (_activeReplies.contains(fetchKey)) {
310 return; // a reply is already in flight for this tile
311 }
312 const QNetworkRequest request =
313 QGeoTileFetcherQGC::getNetworkRequest(_mapId, fetchKey.x, fetchKey.y, fetchKey.zoom);
314 QNetworkReply* const reply = _networkManager->get(request);
315 _activeReplies.insert(fetchKey, reply);
316
317 connect(reply, &QNetworkReply::finished, this, [this, fetchKey, reply] {
318 reply->deleteLater();
319 if (_activeReplies.value(fetchKey) != reply) {
320 return; // stale: cancelled (and possibly superseded by a newer fetch)
321 }
322 _activeReplies.remove(fetchKey);
323 if (!_fetchInFlight(fetchKey)) {
324 return; // all interest cancelled (abort also lands here)
325 }
326 if (reply->error() != QNetworkReply::NoError) {
327 _failAll(fetchKey, QStringLiteral("network error: %1 (http %2)")
328 .arg(reply->errorString())
329 .arg(reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt()));
330 return;
331 }
332 const QByteArray data = reply->readAll();
333 if (data.isEmpty()) {
334 _failAll(fetchKey, QStringLiteral("network fetch returned empty body"));
335 return;
336 }
337 // Never cache bodies that delivery would reject (e.g. HTTP-200 error
338 // pages): a cached invalid tile would fail every retry from then on
339 const QImage image = decodeTile(data);
340 if (image.isNull()) {
341 _failAll(fetchKey, QStringLiteral("network body is not a terrarium tile, %1 bytes").arg(data.size()));
342 return;
343 }
344 qCDebug(GeoMapTerrariumTileFetcherVerboseLog)
345 << "tile" << fetchKey << "fetched from network," << data.size() << "bytes, caching";
346 // Store back so the shared cache serves this tile from now on
347 const QString providerType = terrariumProviderType();
348 QGeoFileTileCacheQGC::cacheTile(providerType, fetchKey.x, fetchKey.y, fetchKey.zoom, data,
349 UrlFactory::getImageFormat(providerType, data));
350 _deliverAll(fetchKey, image);
351 });
352}
353
354void TerrariumTileFetcher::_deliverAll(const TileMath::TileKey& fetchKey, const QImage& image)
355{
356 const bool forField = _fieldRequests.remove(fetchKey) && _heightField;
357 if (forField) {
358 _heightField->insertTile(fetchKey, gridFromImage(image));
359 }
360
361 const QList<int> requestIds = _waiters.take(fetchKey);
362 qCDebug(GeoMapTerrariumTileFetcherVerboseLog)
363 << "delivering tile" << fetchKey << "to" << requestIds.count() << "waiters, field insert:" << forField;
364 for (int requestId : requestIds) {
365 if (_pending.contains(requestId)) {
366 _deliver(requestId, image);
367 }
368 }
369}
370
371void TerrariumTileFetcher::_failAll(const TileMath::TileKey& fetchKey, const QString& reason)
372{
373 // A failed tile inserts nothing: the field keeps its current estimate, and
374 // clearing the in-flight key lets a later request retry
375 _fieldRequests.remove(fetchKey);
376
377 const QList<int> requestIds = _waiters.take(fetchKey);
378 if (_shouldWarnFailure()) {
379 qCWarning(GeoMapTerrariumTileFetcherLog)
380 << "tile" << fetchKey << "failed:" << reason << "- failing" << requestIds.count() << "waiters";
381 } else {
382 qCDebug(GeoMapTerrariumTileFetcherLog) << "tile" << fetchKey << "failed:" << reason << "- failing"
383 << requestIds.count() << "waiters (warning suppressed)";
384 }
385 for (int requestId : requestIds) {
386 if (_pending.contains(requestId)) {
387 _finishFailed(requestId);
388 }
389 }
390}
391
392bool TerrariumTileFetcher::_shouldWarnFailure()
393{
394 if (_failureWarnTimer.isValid() && (_failureWarnTimer.elapsed() < kFailureWarnIntervalMs)) {
395 return false;
396 }
397 _failureWarnTimer.restart();
398 return true;
399}
400
401void TerrariumTileFetcher::_deliver(int requestId, const QImage& image)
402{
403 const PendingRequest pending = _pending.take(requestId);
404 emit patchHeightsReady(requestId, sampleGrid(image, pending.subWindow, pending.gridSize));
405}
406
407void TerrariumTileFetcher::_finishFailed(int requestId)
408{
409 _pending.remove(requestId);
410 emit patchHeightsFailed(requestId);
411}
#define QGC_LOGGING_CATEGORY(name, categoryStr)
QGCMapEngine * getQGCMapEngine()
bool insertTile(const TileMath::TileKey &key, ElevationTilePyramid::Grid grid)
bool hasTile(const TileMath::TileKey &key) const
True when the backing pyramid holds this exact tile.
Definition HeightField.h:71
void patchHeightsReady(int requestId, const QList< float > &heights)
heights has (gridSize+1)^2 entries, row-major from the north-west corner
void patchHeightsFailed(int requestId)
HeightField * _heightField
tile delivery target for requestTile (not owned)
int _nextRequestId()
void tileFetched(QGCCacheTile *tile)
bool addTask(QGCMapTask *task)
void error(QGCMapTask::TaskType type, const QString &errorString)
static QGCFetchTileTask * createFetchTileTask(const QString &type, int x, int y, int z)
static void cacheTile(const QString &type, int x, int y, int z, const QByteArray &image, const QString &format, qulonglong set=UINT64_MAX)
static QNetworkRequest getNetworkRequest(int mapId, int x, int y, int zoom)
static constexpr const char * kProviderKey
static constexpr int kMaxGridSize
Largest supported patch grid (matches PatchGeometry::kMaxGridSize)
static constexpr int kMaxTileZoom
Highest zoom the terrarium dataset serves (deeper patches sample the ancestor)
bool requestTile(const TileMath::TileKey &key) override
TerrariumTileFetcher(QObject *parent=nullptr, QNetworkAccessManager *networkManager=nullptr)
networkManager overrides the internally created one (test injection seam)
void cancelRequest(int requestId) final
Cancel a pending request. No signal is emitted for a cancelled request.
int requestPatchHeights(const TileMath::TileKey &key, int gridSize) final
Request the vertex height grid for a patch. Returns a request id (> 0).
static QString getImageFormat(QStringView type, QByteArrayView image)
bool isValidKey(const TileKey &key)
True if zoom is within [kMinZoom, kMaxZoom] and x/y address a tile at that zoom.
Definition TileMath.cc:44
Decoded elevation samples for one tile, row-major from the NW corner.
QByteArray img