Skip to content

Commit

Permalink
Use NetworkMJPGImage instead of NetworkCamera
Browse files Browse the repository at this point in the history
In Qt > 5.8, changing the source of an Image repeatedly seems to leak a lot of memory. NetworkMJPGImage is a custom QML component that replaces the NetworkCamera -> CameraImageProvider -> QML Image route with an integrated streaming-mjpeg-displaying QML item, foregoing a lot of signaling between pyqt and qml.
  • Loading branch information
fieldOfView committed Oct 25, 2018
1 parent 4231561 commit 8d2003f
Show file tree
Hide file tree
Showing 4 changed files with 163 additions and 35 deletions.
48 changes: 18 additions & 30 deletions MonitorItem.qml
@@ -1,88 +1,76 @@
import QtQuick 2.2
import OctoPrintPlugin 1.0 as OctoPrintPlugin

Component
{
Item
{
Image
OctoPrintPlugin.NetworkMJPGImage
{
id: cameraImage
visible: OutputDevice != null ? OutputDevice.showCamera : false
property real maximumZoom: 2
property bool rotatedImage: (OutputDevice.cameraOrientation.rotation / 90) % 2
property bool proportionalHeight:
{
if (sourceSize.height == 0 || maximumHeight == 0)
if (imageHeight == 0 || maximumHeight == 0)
{
return true;
}
if (!rotatedImage)
{
return (sourceSize.width / sourceSize.height) > (maximumWidth / maximumHeight);
return (imageWidth / imageHeight) > (maximumWidth / maximumHeight);
}
else
{
return (sourceSize.width / sourceSize.height) > (maximumHeight / maximumWidth);
return (imageWidth / imageHeight) > (maximumHeight / maximumWidth);
}
}
property real _width:
{
if (!rotatedImage)
{
return Math.min(maximumWidth, sourceSize.width * screenScaleFactor * maximumZoom);
return Math.min(maximumWidth, imageWidth * screenScaleFactor * maximumZoom);
}
else
{
return Math.min(maximumHeight, sourceSize.width * screenScaleFactor * maximumZoom);
return Math.min(maximumHeight, imageWidth * screenScaleFactor * maximumZoom);
}
}
property real _height:
{
if (!rotatedImage)
{
return Math.min(maximumHeight, sourceSize.height * screenScaleFactor * maximumZoom);
return Math.min(maximumHeight, imageHeight * screenScaleFactor * maximumZoom);
}
else
{
return Math.min(maximumWidth, sourceSize.height * screenScaleFactor * maximumZoom);
return Math.min(maximumWidth, imageHeight * screenScaleFactor * maximumZoom);
}
}
width: proportionalHeight ? _width : sourceSize.width * _height / sourceSize.height
height: !proportionalHeight ? _height : sourceSize.height * _width / sourceSize.width
width: proportionalHeight ? _width : imageWidth * _height / imageHeight
height: !proportionalHeight ? _height : imageHeight * _width / imageWidth
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter

Component.onCompleted:
{
if (visible && OutputDevice.activePrinter != null && OutputDevice.activePrinter.camera != null)
{
OutputDevice.activePrinter.camera.start();
}
start();
}
onVisibleChanged:
{
if (OutputDevice.activePrinter != null && OutputDevice.activePrinter.camera != null)
if (visible)
{
if (visible)
{
OutputDevice.activePrinter.camera.start();
} else
{
OutputDevice.activePrinter.camera.stop();
}
}
}
source:
{
if(OutputDevice.activePrinter != null && OutputDevice.activePrinter.camera != null && OutputDevice.activePrinter.camera.latestImage)
start();
} else
{
return OutputDevice.activePrinter.camera.latestImage;
stop();
}
return "";
}
source: OutputDevice.cameraUrl

rotation: OutputDevice.cameraOrientation.rotation
mirror: OutputDevice.cameraOrientation.mirror
//mirror: OutputDevice.cameraOrientation.mirror
}
}
}
133 changes: 133 additions & 0 deletions NetworkMJPGImage.py
@@ -0,0 +1,133 @@
# Copyright (c) 2018 Aldo Hoeben / fieldOfView
# NetworkMJPGImage is released under the terms of the AGPLv3 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

## Ensure that close gets called when object is destroyed
def __del__(self) -> None:
self.stop()


def paint(self, painter: "QPainter") -> None:
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)


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()
12 changes: 7 additions & 5 deletions OctoPrintOutputDevice.py
Expand Up @@ -12,7 +12,6 @@
from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice
from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel
from cura.PrinterOutput.PrintJobOutputModel import PrintJobOutputModel
from cura.PrinterOutput.NetworkCamera import NetworkCamera

from cura.PrinterOutput.GenericOutputController import GenericOutputController

Expand Down Expand Up @@ -204,6 +203,12 @@ def cameraOrientation(self) -> Dict[str, Any]:
"rotation": self._camera_rotation,
}

cameraUrlChanged = pyqtSignal()

@pyqtProperty("QUrl", notify = cameraUrlChanged)
def cameraUrl(self) -> QUrl:
return QUrl(self._camera_url)

def setShowCamera(self, show_camera: bool) -> None:
if show_camera != self._show_camera:
self._show_camera = show_camera
Expand Down Expand Up @@ -625,8 +630,7 @@ def _onRequestFinished(self, reply: QNetworkReply) -> None:
self._camera_url = ""

Logger.log("d", "Set OctoPrint camera url to %s", self._camera_url)
if self._camera_url != "" and len(self._printers) > 0:
self._printers[0].setCamera(NetworkCamera(self._camera_url))
self.cameraUrlChanged.emit()

if "rotate90" in json_data["webcam"]:
self._camera_rotation = -90 if json_data["webcam"]["rotate90"] else 0
Expand Down Expand Up @@ -715,8 +719,6 @@ def _onUploadProgress(self, bytes_sent: int, bytes_total: int) -> None:

def _createPrinterList(self) -> None:
printer = PrinterOutputModel(output_controller=self._output_controller, number_of_extruders=self._number_of_extruders)
if self._camera_url != "":
printer.setCamera(NetworkCamera(self._camera_url))
printer.updateName(self.name)
self._printers = [printer]
self.printersChanged.emit()
Expand Down
5 changes: 5 additions & 0 deletions __init__.py
Expand Up @@ -5,15 +5,20 @@

from . import OctoPrintOutputDevicePlugin
from . import DiscoverOctoPrintAction
from . import NetworkMJPGImage

from UM.Version import Version
from UM.Application import Application
from UM.Logger import Logger

from PyQt5.QtQml import qmlRegisterType

def getMetaData():
return {}

def register(app):
qmlRegisterType(NetworkMJPGImage.NetworkMJPGImage, "OctoPrintPlugin", 1, 0, "NetworkMJPGImage")

if __matchVersion():
return {
"output_device": OctoPrintOutputDevicePlugin.OctoPrintOutputDevicePlugin(),
Expand Down

0 comments on commit 8d2003f

Please sign in to comment.