diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 41dadc2d84..2b3677bcb1 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -114,6 +114,8 @@ from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions from cura.ObjectsModel import ObjectsModel +from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage + from UM.FlameProfiler import pyqtSlot from UM.Decorators import override @@ -947,6 +949,8 @@ class CuraApplication(QtApplication): qmlRegisterSingletonType(SimpleModeSettingsManager, "Cura", 1, 0, "SimpleModeSettingsManager", self.getSimpleModeSettingsManager) qmlRegisterSingletonType(MachineActionManager.MachineActionManager, "Cura", 1, 0, "MachineActionManager", self.getMachineActionManager) + qmlRegisterType(NetworkMJPGImage, "Cura", 1, 0, "NetworkMJPGImage") + qmlRegisterSingletonType(ObjectsModel, "Cura", 1, 0, "ObjectsModel", self.getObjectsModel) qmlRegisterType(BuildPlateModel, "Cura", 1, 0, "BuildPlateModel") qmlRegisterType(MultiBuildPlateModel, "Cura", 1, 0, "MultiBuildPlateModel") diff --git a/cura/PrinterOutput/NetworkMJPGImage.py b/cura/PrinterOutput/NetworkMJPGImage.py new file mode 100644 index 0000000000..1efc21d2ea --- /dev/null +++ b/cura/PrinterOutput/NetworkMJPGImage.py @@ -0,0 +1,153 @@ +# Copyright (c) 2018 Aldo Hoeben / fieldOfView +# NetworkMJPGImage is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import QUrl, pyqtProperty, pyqtSignal, pyqtSlot, QRect +from PyQt5.QtGui import QImage +from PyQt5.QtQuick import QQuickPaintedItem +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply, QNetworkAccessManager + +from UM.Logger import Logger + +# +# A QQuickPaintedItem that progressively downloads a network mjpeg stream, +# picks it apart in individual jpeg frames, and paints it. +# +class NetworkMJPGImage(QQuickPaintedItem): + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + + self._stream_buffer = b"" + self._stream_buffer_start_index = -1 + self._network_manager = None + self._image_request = None + self._image_reply = None + self._image = QImage() + self._image_rect = QRect() + + self._source_url = QUrl() + self._started = False + + self._mirror = False + + self.setAntialiasing(True) + + ## Ensure that close gets called when object is destroyed + def __del__(self) -> None: + self.stop() + + + def paint(self, painter: "QPainter") -> None: + if self._mirror: + painter.drawImage(self.contentsBoundingRect(), self._image.mirrored()) + return + + painter.drawImage(self.contentsBoundingRect(), self._image) + + + def setSourceURL(self, source_url: "QUrl") -> None: + self._source_url = source_url + self.sourceURLChanged.emit() + if self._started: + self.start() + + def getSourceURL(self) -> "QUrl": + return self._source_url + + sourceURLChanged = pyqtSignal() + source = pyqtProperty(QUrl, fget = getSourceURL, fset = setSourceURL, notify = sourceURLChanged) + + def setMirror(self, mirror: bool) -> None: + if mirror == self._mirror: + return + self._mirror = mirror + self.mirrorChanged.emit() + self.update() + + def getMirror(self) -> bool: + return self._mirror + + mirrorChanged = pyqtSignal() + mirror = pyqtProperty(bool, fget = getMirror, fset = setMirror, notify = mirrorChanged) + + imageSizeChanged = pyqtSignal() + + @pyqtProperty(int, notify = imageSizeChanged) + def imageWidth(self) -> int: + return self._image.width() + + @pyqtProperty(int, notify = imageSizeChanged) + def imageHeight(self) -> int: + return self._image.height() + + + @pyqtSlot() + def start(self) -> None: + self.stop() # Ensure that previous requests (if any) are stopped. + + if not self._source_url: + Logger.log("w", "Unable to start camera stream without target!") + return + self._started = True + + self._image_request = QNetworkRequest(self._source_url) + if self._network_manager is None: + self._network_manager = QNetworkAccessManager() + + self._image_reply = self._network_manager.get(self._image_request) + self._image_reply.downloadProgress.connect(self._onStreamDownloadProgress) + + @pyqtSlot() + def stop(self) -> None: + self._stream_buffer = b"" + self._stream_buffer_start_index = -1 + + if self._image_reply: + try: + try: + self._image_reply.downloadProgress.disconnect(self._onStreamDownloadProgress) + except Exception: + pass + + if not self._image_reply.isFinished(): + self._image_reply.close() + except Exception as e: # RuntimeError + pass # It can happen that the wrapped c++ object is already deleted. + + self._image_reply = None + self._image_request = None + + self._network_manager = None + + self._started = False + + + def _onStreamDownloadProgress(self, bytes_received: int, bytes_total: int) -> None: + # An MJPG stream is (for our purpose) a stream of concatenated JPG images. + # JPG images start with the marker 0xFFD8, and end with 0xFFD9 + if self._image_reply is None: + return + self._stream_buffer += self._image_reply.readAll() + + if len(self._stream_buffer) > 2000000: # No single camera frame should be 2 Mb or larger + Logger.log("w", "MJPEG buffer exceeds reasonable size. Restarting stream...") + self.stop() # resets stream buffer and start index + self.start() + return + + if self._stream_buffer_start_index == -1: + self._stream_buffer_start_index = self._stream_buffer.indexOf(b'\xff\xd8') + stream_buffer_end_index = self._stream_buffer.lastIndexOf(b'\xff\xd9') + # If this happens to be more than a single frame, then so be it; the JPG decoder will + # ignore the extra data. We do it like this in order not to get a buildup of frames + + if self._stream_buffer_start_index != -1 and stream_buffer_end_index != -1: + jpg_data = self._stream_buffer[self._stream_buffer_start_index:stream_buffer_end_index + 2] + self._stream_buffer = self._stream_buffer[stream_buffer_end_index + 2:] + self._stream_buffer_start_index = -1 + self._image.loadFromData(jpg_data) + + if self._image.rect() != self._image_rect: + self.imageSizeChanged.emit() + + self.update()