You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Ender3V2S1/slicer scripts/cura/professionalfirmware.py

256 lines
9.8 KiB
Python

#------------------------------------------------------------------------------
# Cura Professional firmware post processing script
# Miguel A. Risco-Castillo
# version: 2.1
# date: 2023-09-17
#
# Contains thumbnail code from:
# https://github.com/Ultimaker/Cura/blob/master/plugins/PostProcessingPlugin/scripts/CreateThumbnail.py
#------------------------------------------------------------------------------
import re
import base64
import json
from typing import Callable, TypeVar, Optional
from enum import Enum, auto
from UM.Logger import Logger
from cura.Snapshot import Snapshot
from cura.CuraApplication import CuraApplication
from cura.CuraVersion import CuraVersion
from ..Script import Script
T = TypeVar("T")
class Ordering(Enum):
LESS = auto()
EQUAL = auto()
GREATER = auto()
def binary_search(list: list[T], compare: Callable[[T], Ordering]) -> Optional[T]:
left: int = 0
right: int = len(list) - 1
while left <= right:
middle: int = (left + right) // 2
comparison = compare(list[middle])
if comparison == Ordering.LESS:
left = middle + 1
elif comparison == Ordering.GREATER:
right = middle - 1
else:
return middle
return None
class QualityFinder:
compute_image_size: Callable[[int], int]
# Keep track of which quality value produced the closest match to the target_size
closest_match: Optional[tuple[int, float]]
# The size that the image should have
target_size: int
# A lower bound for the acceptable image size in percent
# For example when the acceptable_bound is 0.9 and a value produces an image that
# has a size of target_size * 94%, then the value is accepted, because 0.94 >= 0.9
acceptable_bound: float
def __init__(self, compute_image_size: Callable[[int], int], target_size: int, acceptable_bound: float = 0.9) -> None:
self.compute_image_size = compute_image_size
self.closest_match = None
self.target_size = target_size
self.acceptable_bound = acceptable_bound
def __get_ratio(self, quality: int) -> float:
# calculate the size of the image with the specified quality:
current_size: int = self.compute_image_size(quality)
# check if the new image size is closer to 100% than the previous one (but ideally less than 1.0)
ratio = float(current_size) / float(self.target_size)
if self.closest_match is None:
self.closest_match = (quality, ratio)
else:
(_, best_ratio) = self.closest_match
if best_ratio > 1.0 and ratio <= 1.0:
self.closest_match = (quality, ratio)
elif ratio >= self.acceptable_bound and abs(1.0 - ratio) < abs(1.0 - best_ratio):
self.closest_match = (quality, ratio)
return ratio
def compare_quality(self, value: int) -> Ordering:
Logger.log("d", f"Trying a quality of {value}%")
ratio = self.__get_ratio(value)
Logger.log("d", f"Image size is {ratio * 100.0:.2f}% of {self.target_size}")
# check if the image is too large
if ratio > 1.0:
return Ordering.GREATER
if ratio >= self.acceptable_bound:
# check if the next quality would produce an even better result
next_ratio: float = self.__get_ratio(value + 1)
if next_ratio <= 1.0 and next_ratio > ratio:
return Ordering.LESS
return Ordering.EQUAL
else:
return Ordering.LESS
class professionalfirmware(Script):
def __init__(self):
super().__init__()
def _createSnapshot(self, width, height):
Logger.log("d", "Creating thumbnail image...")
try:
return Snapshot.snapshot(width, height)
except Exception:
Logger.logException("w", "Failed to create snapshot image")
def _encodeSnapshot(self, snapshot, quality=-1):
Major=0
Minor=0
try:
Major = int(CuraVersion.split(".")[0])
Minor = int(CuraVersion.split(".")[1])
except:
pass
if Major < 5 :
from PyQt5.QtCore import QByteArray, QIODevice, QBuffer
else :
from PyQt6.QtCore import QByteArray, QIODevice, QBuffer
Logger.log("d", "Encoding thumbnail image...")
try:
thumbnail_buffer = QBuffer()
if Major < 5 :
thumbnail_buffer.open(QBuffer.ReadWrite)
else:
thumbnail_buffer.open(QBuffer.OpenModeFlag.ReadWrite)
thumbnail_image = snapshot
thumbnail_image.save(thumbnail_buffer, "JPG", quality=quality)
base64_bytes = base64.b64encode(thumbnail_buffer.data())
base64_message = base64_bytes.decode('ascii')
thumbnail_buffer.close()
return base64_message
except Exception:
Logger.logException("w", "Failed to encode snapshot image")
def _convertSnapshotToGcode(self, encoded_snapshot, width, height, chunk_size=78):
gcode = []
encoded_snapshot_length = len(encoded_snapshot)
gcode.append(";")
gcode.append("; thumbnail begin {}x{} {}".format(
width, height, encoded_snapshot_length))
chunks = ["; {}".format(encoded_snapshot[i:i+chunk_size])
for i in range(0, len(encoded_snapshot), chunk_size)]
gcode.extend(chunks)
gcode.append("; thumbnail end")
gcode.append(";")
gcode.append("")
return gcode
def getSettingDataString(self):
return json.dumps({
"name": "Professional Firmware support",
"key": "professionalfirmware",
"metadata": {},
"version": 2,
"settings":
{
"thumbnail_width":
{
"label": "Thumbnail width",
"description": "Width of the generated thumbnail",
"unit": "px",
"type": "int",
"default_value": 180,
"minimum_value": "20",
"minimum_value_warning": "100",
"maximum_value": "200"
},
"thumbnail_height":
{
"label": "Thumbnail height",
"description": "Height of the generated thumbnail",
"unit": "px",
"type": "int",
"default_value": 180,
"minimum_value": "20",
"minimum_value_warning": "100",
"maximum_value": "230"
},
"thumbnail_max_size":
{
"label": "Maximum thumbnail size",
"description": "The maximum size of the thumbnail in bytes. Thumbnails must be smaller than 20 kbytes for TJC displays. If the thumbnail size should not be changed, write -1.",
"unit": "byte",
"type": "int",
"default_value": 15000,
"minimum_value": "-1"
}
}
}, indent=4)
def execute(self, data):
header_string = ';Generated for MRiscoC Professional Firmware\n'
header_string = header_string + ';https://github.com/mriscoc/Ender3V2S1\n'
header_string = header_string + ';===========================================================\n'
layer = data[0]
layer_index = data.index(layer)
lines = layer.split("\n")
lines.insert(0, header_string)
final_lines = "\n".join(lines)
data[layer_index] = final_lines
width = self.getSettingValueByKey("thumbnail_width")
height = self.getSettingValueByKey("thumbnail_height")
max_size = self.getSettingValueByKey("thumbnail_max_size")
Logger.log("d", f"Options: width={width}, height={height}, max_size={max_size}")
snapshot = self._createSnapshot(width, height)
if snapshot:
encoded_snapshot = self._encodeSnapshot(snapshot)
# reduce the quality of the image until the size is below max_size
# this option is necessary for some displays like TJC where the image must be smaller than 20kb
if max_size != -1:
if len(encoded_snapshot) > max_size:
Logger.log("d", f"Image size of {len(encoded_snapshot)} is larger than {max_size}")
finder = QualityFinder(lambda quality: len(self._encodeSnapshot(snapshot, quality=quality)), target_size=max_size)
# quality ranges from 95 (best) to 1 (worst)
qualities = list(range(1, 95 + 1))
index = binary_search(qualities, finder.compare_quality)
quality = finder.closest_match[0]
if index is not None:
quality = qualities[index]
else:
Logger.log("e", f"Failed to reduce image size to at most {max_size} bytes")
Logger.log("d", f"Image encoded at quality of {quality}%")
encoded_snapshot = self._encodeSnapshot(snapshot, quality=quality)
snapshot_gcode = self._convertSnapshotToGcode(
encoded_snapshot, width, height)
for layer in data:
layer_index = data.index(layer)
lines = data[layer_index].split("\n")
for line in lines:
if line.startswith(";Generated with Cura"):
line_index = lines.index(line)
insert_index = line_index + 1
lines[insert_index:insert_index] = snapshot_gcode
break
final_lines = "\n".join(lines)
data[layer_index] = final_lines
return data