Kstars

media.cpp
1/*
2 SPDX-FileCopyrightText: 2018 Jasem Mutlaq <mutlaqja@ikarustech.com>
3
4 Media Channel
5
6 SPDX-License-Identifier: GPL-2.0-or-later
7*/
8
9#include "media.h"
10#include "commands.h"
11#include "skymapcomposite.h"
12#include "fitsviewer/fitsview.h"
13#include "fitsviewer/fitsdata.h"
14#include "indi/indilistener.h"
15#include "hips/hipsfinder.h"
16#include "kstarsdata.h"
17#include "ekos/auxiliary/darklibrary.h"
18#include "ekos/guide/guide.h"
19#include "ekos/align/align.h"
20#include "kspaths.h"
21#include "Options.h"
22
23#include "ekos_debug.h"
24#include "kstars.h"
25#include "version.h"
26
27#include <QtConcurrent>
28#include <KFormat>
29
30namespace EkosLive
31{
32
33///////////////////////////////////////////////////////////////////////////////////////////
34///
35///////////////////////////////////////////////////////////////////////////////////////////
36Media::Media(Ekos::Manager * manager, QVector<QSharedPointer<NodeManager>> &nodeManagers):
37 m_Manager(manager), m_NodeManagers(nodeManagers)
38{
39 for (auto &nodeManager : m_NodeManagers)
40 {
41 connect(nodeManager->media(), &Node::connected, this, &Media::onConnected);
42 connect(nodeManager->media(), &Node::disconnected, this, &Media::onDisconnected);
43 connect(nodeManager->media(), &Node::onTextReceived, this, &Media::onTextReceived);
44 connect(nodeManager->media(), &Node::onBinaryReceived, this, &Media::onBinaryReceived);
45 }
46
47 connect(this, &Media::newMetadata, this, &Media::uploadMetadata);
48 connect(this, &Media::newImage, this, [this](const QByteArray & image)
49 {
50 uploadImage(image);
51 m_TemporaryView.clear();
52 });
53}
54
55///////////////////////////////////////////////////////////////////////////////////////////
56///
57///////////////////////////////////////////////////////////////////////////////////////////
58bool Media::isConnected() const
59{
60 return std::any_of(m_NodeManagers.begin(), m_NodeManagers.end(), [](auto & nodeManager)
61 {
62 return nodeManager->media()->isConnected();
63 });
64}
65
66///////////////////////////////////////////////////////////////////////////////////////////
67///
68///////////////////////////////////////////////////////////////////////////////////////////
69void Media::onConnected()
70{
71 auto node = qobject_cast<Node*>(sender());
72 if (!node)
73 return;
74
75 qCInfo(KSTARS_EKOS) << "Connected to Media Websocket server at" << node->url().toDisplayString();
76
77 emit connected();
78}
79
80///////////////////////////////////////////////////////////////////////////////////////////
81///
82///////////////////////////////////////////////////////////////////////////////////////////
83void Media::onDisconnected()
84{
85 auto node = qobject_cast<Node*>(sender());
86 if (!node)
87 return;
88
89 qCInfo(KSTARS_EKOS) << "Disconnected from Message Websocket server at" << node->url().toDisplayString();
90
91 if (isConnected() == false)
92 {
93 m_sendBlobs = true;
94
95 for (const QString &oneFile : temporaryFiles)
97 temporaryFiles.clear();
98
99 emit disconnected();
100 }
101}
102
103///////////////////////////////////////////////////////////////////////////////////////////
104///
105///////////////////////////////////////////////////////////////////////////////////////////
106void Media::onTextReceived(const QString &message)
107{
108 qCInfo(KSTARS_EKOS) << "Media Text Websocket Message" << message;
110 auto serverMessage = QJsonDocument::fromJson(message.toLatin1(), &error);
111 if (error.error != QJsonParseError::NoError)
112 {
113 qCWarning(KSTARS_EKOS) << "Ekos Live Parsing Error" << error.errorString();
114 return;
115 }
116
117 const QJsonObject msgObj = serverMessage.object();
118 const QString command = msgObj["type"].toString();
119 const QJsonObject payload = msgObj["payload"].toObject();
120
121 if (command == commands[ALIGN_SET_FILE_EXTENSION])
122 extension = payload["ext"].toString();
123 else if (command == commands[SET_BLOBS])
124 m_sendBlobs = msgObj["payload"].toBool();
125 // Get a list of object based on criteria
126 else if (command == commands[ASTRO_GET_OBJECTS_IMAGE])
127 {
128 int level = payload["level"].toInt(5);
129 double zoom = payload["zoom"].toInt(20000);
130
131 // Object Names
132 QVariantList objectNames = payload["names"].toArray().toVariantList();
133
134 for (auto &oneName : objectNames)
135 {
136 const QString name = oneName.toString();
137 SkyObject *oneObject = KStarsData::Instance()->skyComposite()->findByName(name, false);
138 if (oneObject)
139 {
140 QImage centerImage(HIPS_TILE_WIDTH, HIPS_TILE_HEIGHT, QImage::Format_ARGB32_Premultiplied);
141 double fov_w = 0, fov_h = 0;
142
143 if (oneObject->type() == SkyObject::MOON || oneObject->type() == SkyObject::PLANET)
144 {
146 const QString output = KSPaths::writableLocation(QStandardPaths::TempLocation) + QDir::separator() + "xplanet.jpg";
147 xplanetProcess.start(Options::xplanetPath(), QStringList()
148 << "--num_times" << "1"
149 << "--geometry" << QString("%1x%2").arg(HIPS_TILE_WIDTH).arg(HIPS_TILE_HEIGHT)
150 << "--body" << name.toLower()
151 << "--output" << output);
152 xplanetProcess.waitForFinished(5000);
153 centerImage.load(output);
154 }
155 else
156 HIPSFinder::Instance()->render(oneObject, level, zoom, &centerImage, fov_w, fov_h);
157
158 if (!centerImage.isNull())
159 {
160 // Use seed from name, level, and zoom so that it is unique
161 // even if regenerated again.
162 auto seed = QString("%1%2%3").arg(QString::number(level), QString::number(zoom), name);
163 QString uuid = "hips_" + QCryptographicHash::hash(seed.toLatin1(), QCryptographicHash::Md5).toHex();
164 // Send everything as strings
165 QJsonObject metadata =
166 {
167 {"uuid", uuid},
168 {"name", name},
169 {"zoom", zoom},
170 {"resolution", QString("%1x%2").arg(HIPS_TILE_WIDTH).arg(HIPS_TILE_HEIGHT)},
171 {"bin", "1x1"},
172 {"fov_w", QString::number(fov_w)},
173 {"fov_h", QString::number(fov_h)},
174 {"ext", "jpg"}
175 };
176
178 QBuffer buffer(&jpegData);
179 buffer.open(QIODevice::WriteOnly);
180
181 // First METADATA_PACKET bytes of the binary data is always allocated
182 // to the metadata, the rest to the image data.
184 meta = meta.leftJustified(METADATA_PACKET, 0);
185 buffer.write(meta);
186 centerImage.save(&buffer, "jpg", 90);
187 buffer.close();
188
189 emit newImage(jpegData);
190 }
191 }
192 }
193 }
194 else if (command == commands[ASTRO_GET_SKYPOINT_IMAGE])
195 {
196 int level = payload["level"].toInt(5);
197 double zoom = payload["zoom"].toInt(20000);
198 double ra = payload["ra"].toDouble(0);
199 double de = payload["de"].toDouble(0);
200 double width = payload["width"].toDouble(512);
201 double height = payload["height"].toDouble(512);
202
204 SkyPoint coords(ra, de);
205 SkyPoint J2000Coord(coords.ra(), coords.dec());
206 J2000Coord.catalogueCoord(KStars::Instance()->data()->ut().djd());
207 coords.setRA0(J2000Coord.ra());
208 coords.setDec0(J2000Coord.dec());
209 coords.EquatorialToHorizontal(KStarsData::Instance()->lst(), KStarsData::Instance()->geo()->lat());
210
211 volatile auto jnowRAString = coords.ra().toHMSString();
212 volatile auto jnowDEString = coords.dec().toDMSString();
213 volatile auto j2000RAString = coords.ra0().toHMSString();
214 volatile auto j2000DEString = coords.dec0().toDMSString();
215
216
217 double fov_w = 0, fov_h = 0;
218 HIPSFinder::Instance()->render(&coords, level, zoom, &centerImage, fov_w, fov_h);
219
220 if (!centerImage.isNull())
221 {
222 // Use seed from name, level, and zoom so that it is unique
223 // even if regenerated again.
224 // Send everything as strings
225 QJsonObject metadata =
226 {
227 {"uuid", "skypoint_hips"},
228 {"name", "skypoint_hips"},
229 {"zoom", zoom},
230 {"resolution", QString("%1x%2").arg(width).arg(height)},
231 {"bin", "1x1"},
232 {"fov_w", QString::number(fov_w)},
233 {"fov_h", QString::number(fov_h)},
234 {"ext", "jpg"}
235 };
236
238 QBuffer buffer(&jpegData);
239 buffer.open(QIODevice::WriteOnly);
240
241 // First METADATA_PACKET bytes of the binary data is always allocated
242 // to the metadata, the rest to the image data.
244 meta = meta.leftJustified(METADATA_PACKET, 0);
245 buffer.write(meta);
246 centerImage.save(&buffer, "jpg", 95);
247 buffer.close();
248
249 emit newImage(jpegData);
250 }
251 }
252}
253
254///////////////////////////////////////////////////////////////////////////////////////////
255///
256///////////////////////////////////////////////////////////////////////////////////////////
257void Media::onBinaryReceived(const QByteArray &message)
258{
259 // Sometimes this is triggered even though it's a text message
260 Ekos::Align * align = m_Manager->alignModule();
261 if (align)
262 {
263 QString metadataString = message.left(METADATA_PACKET);
266 QString extension = metadataJSON.value("ext").toString();
267 align->loadAndSlew(message.mid(METADATA_PACKET), extension);
268 }
269}
270
271///////////////////////////////////////////////////////////////////////////////////////////
272///
273///////////////////////////////////////////////////////////////////////////////////////////
274void Media::sendDarkLibraryData(const QSharedPointer<FITSData> &data)
275{
276 sendData(data, "+D");
277};
278
279///////////////////////////////////////////////////////////////////////////////////////////
280///
281///////////////////////////////////////////////////////////////////////////////////////////
282void Media::sendData(const QSharedPointer<FITSData> &data, const QString &uuid)
283{
284 if (Options::ekosLiveImageTransfer() == false || m_sendBlobs == false)
285 return;
286
287 m_UUID = uuid;
288
289 m_TemporaryView.reset(new FITSView());
290 m_TemporaryView->loadData(data);
291 QtConcurrent::run(this, &Media::upload, m_TemporaryView);
292}
293
294///////////////////////////////////////////////////////////////////////////////////////////
295///
296///////////////////////////////////////////////////////////////////////////////////////////
297void Media::sendFile(const QString &filename, const QString &uuid)
298{
299 if (Options::ekosLiveImageTransfer() == false || m_sendBlobs == false)
300 return;
301
302 m_UUID = uuid;
303
304 QSharedPointer<FITSView> previewImage(new FITSView());
305 connect(previewImage.get(), &FITSView::loaded, this, [this, previewImage]()
306 {
307 QtConcurrent::run(this, &Media::upload, previewImage);
308 });
309 previewImage->loadFile(filename);
310}
311
312///////////////////////////////////////////////////////////////////////////////////////////
313///
314///////////////////////////////////////////////////////////////////////////////////////////
315void Media::sendView(const QSharedPointer<FITSView> &view, const QString &uuid)
316{
317 if (Options::ekosLiveImageTransfer() == false || m_sendBlobs == false)
318 return;
319
320 m_UUID = uuid;
321
322 upload(view);
323}
324
325///////////////////////////////////////////////////////////////////////////////////////////
326///
327///////////////////////////////////////////////////////////////////////////////////////////
328void Media::upload(const QSharedPointer<FITSView> &view)
329{
330 const QString ext = "jpg";
332 QBuffer buffer(&jpegData);
333 buffer.open(QIODevice::WriteOnly);
334
335 const QSharedPointer<FITSData> imageData = view->imageData();
336 QString resolution = QString("%1x%2").arg(imageData->width()).arg(imageData->height());
337 QString sizeBytes = KFormat().formatByteSize(imageData->size());
338 QVariant xbin(1), ybin(1), exposure(0), focal_length(0), gain(0), pixel_size(0), aperture(0);
339 imageData->getRecordValue("XBINNING", xbin);
340 imageData->getRecordValue("YBINNING", ybin);
341 imageData->getRecordValue("EXPTIME", exposure);
342 imageData->getRecordValue("GAIN", gain);
343 imageData->getRecordValue("PIXSIZE1", pixel_size);
344 imageData->getRecordValue("FOCALLEN", focal_length);
345 imageData->getRecordValue("APTDIA", aperture);
346
347 auto stretchParameters = view->getStretchParams();
348
349 // Account for binning
350 const double binned_pixel = pixel_size.toDouble() * xbin.toInt();
351 // Send everything as strings
352 QJsonObject metadata =
353 {
354 {"resolution", resolution},
355 {"size", sizeBytes},
356 {"channels", imageData->channels()},
357 {"mean", imageData->getAverageMean()},
358 {"median", imageData->getAverageMedian()},
359 {"stddev", imageData->getAverageStdDev()},
360 {"min", imageData->getMin()},
361 {"max", imageData->getMax()},
362 {"bin", QString("%1x%2").arg(xbin.toString(), ybin.toString())},
363 {"bpp", QString::number(imageData->bpp())},
364 {"uuid", m_UUID},
365 {"exposure", exposure.toString()},
366 {"focal_length", focal_length.toString()},
367 {"aperture", aperture.toString()},
368 {"gain", gain.toString()},
369 {"pixel_size", QString::number(binned_pixel, 'f', 4)},
370 {"shadows", stretchParameters.grey_red.shadows},
371 {"midtones", stretchParameters.grey_red.midtones},
372 {"highlights", stretchParameters.grey_red.highlights},
373 {"hasWCS", imageData->hasWCS()},
374 {"hfr", imageData->getHFR()},
375 {"ext", ext}
376 };
377
378 // First METADATA_PACKET bytes of the binary data is always allocated
379 // to the metadata
380 // the rest to the image data.
382 meta = meta.leftJustified(METADATA_PACKET, 0);
383 buffer.write(meta);
384
385 auto fastImage = (!Options::ekosLiveHighBandwidth() || m_UUID[0] == "+");
386 auto scaleWidth = fastImage ? HB_IMAGE_WIDTH / 2 : HB_IMAGE_WIDTH;
387
388 // For low bandwidth images
389 // Except for dark frames +D
390 QPixmap scaledImage = view->getDisplayPixmap().width() > scaleWidth ?
391 view->getDisplayPixmap().scaledToWidth(scaleWidth, fastImage ? Qt::FastTransformation : Qt::SmoothTransformation) :
392 view->getDisplayPixmap();
393 scaledImage.save(&buffer, ext.toLatin1().constData(), HB_IMAGE_QUALITY);
394
395 buffer.close();
396
397 emit newImage(jpegData);
398}
399
400///////////////////////////////////////////////////////////////////////////////////////////
401///
402///////////////////////////////////////////////////////////////////////////////////////////
403void Media::sendUpdatedFrame(const QSharedPointer<FITSView> &view)
404{
405 QString ext = "jpg";
407 QBuffer buffer(&jpegData);
408 buffer.open(QIODevice::WriteOnly);
409
410 const QSharedPointer<FITSData> imageData = view->imageData();
411
412 if (!imageData)
413 return;
414
415 const int32_t width = imageData->width();
416 const int32_t height = imageData->height();
417 QString resolution = QString("%1x%2").arg(width).arg(height);
418 QString sizeBytes = KFormat().formatByteSize(imageData->size());
419 QVariant xbin(1), ybin(1), exposure(0), focal_length(0), gain(0), pixel_size(0), aperture(0);
420 imageData->getRecordValue("XBINNING", xbin);
421 imageData->getRecordValue("YBINNING", ybin);
422 imageData->getRecordValue("EXPTIME", exposure);
423 imageData->getRecordValue("GAIN", gain);
424 imageData->getRecordValue("PIXSIZE1", pixel_size);
425 imageData->getRecordValue("FOCALLEN", focal_length);
426 imageData->getRecordValue("APTDIA", aperture);
427
428 // Account for binning
429 const double binned_pixel = pixel_size.toDouble() * xbin.toInt();
430 // Send everything as strings
431 QJsonObject metadata =
432 {
433 {"resolution", resolution},
434 {"size", sizeBytes},
435 {"channels", imageData->channels()},
436 {"mean", imageData->getAverageMean()},
437 {"median", imageData->getAverageMedian()},
438 {"stddev", imageData->getAverageStdDev()},
439 {"bin", QString("%1x%2").arg(xbin.toString()).arg(ybin.toString())},
440 {"bpp", QString::number(imageData->bpp())},
441 {"uuid", "+A"},
442 {"exposure", exposure.toString()},
443 {"focal_length", focal_length.toString()},
444 {"aperture", aperture.toString()},
445 {"gain", gain.toString()},
446 {"pixel_size", QString::number(binned_pixel, 'f', 4)},
447 {"ext", ext}
448 };
449
450 // First METADATA_PACKET bytes of the binary data is always allocated
451 // to the metadata
452 // the rest to the image data.
454 meta = meta.leftJustified(METADATA_PACKET, 0);
455 buffer.write(meta);
456
457 // For low bandwidth images
459 // Align images
460 if (correctionVector.isNull() == false)
461 {
462 scaledImage = view->getDisplayPixmap();
463 const double currentZoom = view->getCurrentZoom();
464 const double normalizedZoom = currentZoom / 100;
465 // If zoom level is not 100%, then scale.
466 if (fabs(normalizedZoom - 1) > 0.001)
467 scaledImage = view->getDisplayPixmap().scaledToWidth(view->zoomedWidth());
468 else
469 scaledImage = view->getDisplayPixmap();
470 // as we factor in the zoom level, we adjust center and length accordingly
471 QPointF center = 0.5 * correctionVector.p1() * normalizedZoom + 0.5 * correctionVector.p2() * normalizedZoom;
472 uint32_t length = qMax(correctionVector.length() / normalizedZoom, 100 / normalizedZoom);
473
475 boundingRectable.setSize(QSize(length * 2, length * 2));
476 QPoint topLeft = (center - QPointF(length, length)).toPoint();
477 boundingRectable.moveTo(topLeft);
478 boundingRectable = boundingRectable.intersected(scaledImage.rect());
479
480 emit newBoundingRect(boundingRectable, scaledImage.size(), currentZoom);
481
483 }
484 else
485 {
486 scaledImage = view->getDisplayPixmap().width() > HB_IMAGE_WIDTH / 2 ?
487 view->getDisplayPixmap().scaledToWidth(HB_IMAGE_WIDTH / 2, Qt::FastTransformation) :
488 view->getDisplayPixmap();
489 emit newBoundingRect(QRect(), QSize(), 100);
490 }
491
492 scaledImage.save(&buffer, ext.toLatin1().constData(), HB_IMAGE_QUALITY);
493 buffer.close();
494 emit newImage(jpegData);
495}
496
497///////////////////////////////////////////////////////////////////////////////////////////
498///
499///////////////////////////////////////////////////////////////////////////////////////////
500void Media::sendVideoFrame(const QSharedPointer<QImage> &frame)
501{
502 if (Options::ekosLiveImageTransfer() == false || m_sendBlobs == false || !frame)
503 return;
504
505 int32_t width = Options::ekosLiveHighBandwidth() ? HB_VIDEO_WIDTH : HB_VIDEO_WIDTH / 2;
506 QByteArray image;
507 QBuffer buffer(&image);
508 buffer.open(QIODevice::WriteOnly);
509
510 QImage videoImage = (frame->width() > width) ? frame->scaledToWidth(width) : *frame;
511
512 QString resolution = QString("%1x%2").arg(videoImage.width()).arg(videoImage.height());
513
514 // First METADATA_PACKET bytes of the binary data is always allocated
515 // to the metadata
516 // the rest to the image data.
517 QJsonObject metadata =
518 {
519 {"resolution", resolution},
520 {"ext", "jpg"}
521 };
523 meta = meta.leftJustified(METADATA_PACKET, 0);
524 buffer.write(meta);
525
526 QImageWriter writer;
527 writer.setDevice(&buffer);
528 writer.setFormat("JPG");
529 writer.setCompression(6);
530 writer.write(videoImage);
531 buffer.close();
532
533 for (auto &nodeManager : m_NodeManagers)
534 {
535 nodeManager->media()->sendBinaryMessage(image);
536 }
537}
538
539///////////////////////////////////////////////////////////////////////////////////////////
540///
541///////////////////////////////////////////////////////////////////////////////////////////
542void Media::registerCameras()
543{
544 static const QRegularExpression re("[-{}]");
545 for(auto &oneDevice : INDIListener::devices())
546 {
547 auto camera = oneDevice->getCamera();
548 if (camera)
549 {
550 camera->disconnect(this);
551 connect(camera, &ISD::Camera::newVideoFrame, this, &Media::sendVideoFrame);
552 connect(camera, &ISD::Camera::newView, this, [this](const QSharedPointer<FITSView> &view)
553 {
555 uuid = uuid.remove(re);
556 sendView(view, uuid);
557 });
558 }
559 }
560}
561
562void Media::resetPolarView()
563{
564 this->correctionVector = QLineF();
565 m_Manager->alignModule()->zoomAlignView();
566}
567
568void Media::uploadMetadata(const QByteArray &metadata)
569{
570 for (auto &nodeManager : m_NodeManagers)
571 {
572 nodeManager->media()->sendTextMessage(metadata);
573 }
574}
575
576void Media::uploadImage(const QByteArray &image)
577{
578 for (auto &nodeManager : m_NodeManagers)
579 {
580 nodeManager->media()->sendBinaryMessage(image);
581 }
582}
583
584void Media::processNewBLOB(IBLOB * bp)
585{
586 Q_UNUSED(bp)
587}
588
589void Media::sendModuleFrame(const QSharedPointer<FITSView> &view)
590{
591 if (Options::ekosLiveImageTransfer() == false || m_sendBlobs == false)
592 return;
593
594 if (qobject_cast<Ekos::Align*>(sender()) == m_Manager->alignModule())
595 sendView(view, "+A");
596 else if (qobject_cast<Ekos::Focus*>(sender()) == m_Manager->focusModule())
597 sendView(view, "+F");
598 else if (qobject_cast<Ekos::Guide*>(sender()) == m_Manager->guideModule())
599 sendView(view, "+G");
600 else if (qobject_cast<Ekos::DarkLibrary*>(sender()) == Ekos::DarkLibrary::Instance())
601 sendView(view, "+D");
602}
603}
Align class handles plate-solving and polar alignment measurement and correction using astrometry....
Definition align.h:74
bool loadAndSlew(const QByteArray &image, const QString &extension)
DBUS interface function.
Definition align.cpp:3071
INDIListener is responsible for creating ISD::GDInterface generic devices as new devices arrive from ...
QString formatByteSize(double size, int precision=1, KFormat::BinaryUnitDialect dialect=KFormat::DefaultBinaryDialect, KFormat::BinarySizeUnits units=KFormat::DefaultBinaryUnits) const
static KStars * Instance()
Definition kstars.h:123
Provides all necessary information about an object in the sky: its coordinates, name(s),...
Definition skyobject.h:42
The sky coordinates of a point in the sky.
Definition skypoint.h:45
Generic record interfaces and implementations.
Definition cloud.cpp:23
GeoCoordinates geo(const QVariant &location)
void error(QWidget *parent, const QString &text, const QString &title, const KGuiItem &buttonOk, Options options=Notify)
QStringView level(QStringView ifopt)
QAction * zoom(const QObject *recvr, const char *slot, QObject *parent)
KGuiItem remove()
QString name(StandardShortcut id)
QByteArray left(qsizetype len) const const
QByteArray mid(qsizetype pos, qsizetype len) const const
QByteArray toHex(char separator) const const
QByteArray hash(QByteArrayView data, Algorithm method)
QChar separator()
Int toInt() const const
Format_ARGB32_Premultiplied
void setCompression(int compression)
void setDevice(QIODevice *device)
void setFormat(const QByteArray &format)
bool write(const QImage &image)
QJsonDocument fromJson(const QByteArray &json, QJsonParseError *error)
QByteArray toJson(JsonFormat format) const const
QString arg(Args &&... args) const const
QString number(double n, char format, int precision)
QString & remove(QChar ch, Qt::CaseSensitivity cs)
QByteArray toLatin1() const const
QString toLower() const const
FastTransformation
QTextStream & center(QTextStream &stream)
QFuture< T > run(Function function,...)
QFuture< ArgsType< Signal > > connect(Sender *sender, Signal signal)
QUuid createUuid()
QString toString(StringFormat mode) const const
This file is part of the KDE documentation.
Documentation copyright © 1996-2024 The KDE developers.
Generated on Tue Mar 26 2024 11:19:02 by doxygen 1.10.0 written by Dimitri van Heesch, © 1997-2006

KDE's Doxygen guidelines are available online.