moonraker/scripts/extract_metadata.py

356 lines
12 KiB
Python

# GCode metadata extraction utility
#
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license.
import json
import argparse
import re
import os
import sys
import time
# regex helpers
def _regex_find_floats(pattern, data, strict=False):
# If strict is enabled, pattern requires a floating point
# value, otherwise it can be an integer value
fptrn = r'\d+\.\d*' if strict else r'\d+\.?\d*'
matches = re.findall(pattern, data)
if matches:
# return the maximum height value found
try:
return [float(h) for h in re.findall(
fptrn, " ".join(matches))]
except Exception:
pass
return []
def _regex_find_ints(pattern, data):
matches = re.findall(pattern, data)
if matches:
# return the maximum height value found
try:
return [int(h) for h in re.findall(
r'\d+', " ".join(matches))]
except Exception:
pass
return []
# Slicer parsing implementations
class BaseSlicer(object):
def __init__(self, name, id_pattern):
self.name = name
self.id_pattern = id_pattern
self.header_data = self.footer_data = self.log = None
def set_data(self, header_data, footer_data, log):
self.header_data = header_data
self.footer_data = footer_data
self.log = log
def get_name(self):
return self.name
def get_id_pattern(self):
return self.id_pattern
def _parse_min_float(self, pattern, data):
result = _regex_find_floats(pattern, data)
if result:
return min(result)
else:
return None
def _parse_max_float(self, pattern, data):
result = _regex_find_floats(pattern, data)
if result:
return max(result)
else:
return None
class PrusaSlicer(BaseSlicer):
def __init__(self, name="PrusaSlicer", id_pattern=r"PrusaSlicer\s.*\son"):
super(PrusaSlicer, self).__init__(name, id_pattern)
def parse_first_layer_height(self):
return self._parse_min_float(
r"; first_layer_height =.*", self.footer_data)
def parse_layer_height(self):
return self._parse_min_float(r"; layer_height =.*", self.footer_data)
def parse_object_height(self):
return self._parse_max_float(r"G1\sZ\d+\.\d*\sF", self.footer_data)
def parse_filament_total(self):
return self._parse_max_float(
r"filament\sused\s\[mm\]\s=\s\d+\.\d*", self.footer_data)
def parse_estimated_time(self):
time_matches = re.findall(
r';\sestimated\sprinting\stime.*', self.footer_data)
if not time_matches:
return None
total_time = 0
time_match = time_matches[0]
time_patterns = [(r"\d+h", 60*60), (r"\d+m", 60), (r"\d+s", 1)]
for pattern, multiplier in time_patterns:
t = _regex_find_ints(pattern, time_match)
if t:
total_time += max(t) * multiplier
return round(total_time, 2)
def parse_thumbnails(self):
thumb_matches = re.findall(
r"; thumbnail begin[;/\+=\w\s]+?; thumbnail end", self.header_data)
if not thumb_matches:
return None
parsed_matches = []
for match in thumb_matches:
lines = re.split(r"\r?\n", match.replace('; ', ''))
info = _regex_find_ints(r".*", lines[0])
data = "".join(lines[1:-1])
if len(info) != 3:
self.log.append(
{'MetadataError': "Error parsing thumbnail header: %s"
% (lines[0])})
continue
if len(data) != info[2]:
self.log.append(
{'MetadataError': "Thumbnail Size Mismatch: detected %d, "
"actual %d" % (info[2], len(data))})
continue
parsed_matches.append({
'width': info[0], 'height': info[1],
'size': info[2], 'data': data})
return parsed_matches
class Slic3rPE(PrusaSlicer):
def __init__(self, name="Slic3r PE",
id_pattern=r"Slic3r\sPrusa\sEdition\s.*\son"):
super(Slic3rPE, self).__init__(name, id_pattern)
def parse_filament_total(self):
return self._parse_max_float(
r"filament\sused\s=\s\d+\.\d+mm", self.footer_data)
def parse_thumbnails(self):
return None
class Slic3r(Slic3rPE):
def __init__(self, name="Slic3r", id_pattern=r"Slic3r\s\d.*\son"):
super(Slic3r, self).__init__(name, id_pattern)
def parse_estimated_time(self):
return None
class SuperSlicer(PrusaSlicer):
def __init__(self, name="SuperSlicer", id_pattern=r"SuperSlicer\s.*\son"):
super(SuperSlicer, self).__init__(name, id_pattern)
class Cura(BaseSlicer):
def __init__(self, name="Cura", id_pattern=r"Cura_SteamEngine.*"):
super(Cura, self).__init__(name, id_pattern)
def parse_first_layer_height(self):
return self._parse_min_float(r";MINZ:.*", self.header_data)
def parse_layer_height(self):
return self._parse_min_float(r";Layer\sheight:.*", self.header_data)
def parse_object_height(self):
return self._parse_max_float(r";MAXZ:.*", self.header_data)
def parse_filament_total(self):
filament = self._parse_max_float(
r";Filament\sused:.*", self.header_data)
if filament is not None:
filament *= 1000
return filament
def parse_estimated_time(self):
return self._parse_max_float(r";TIME:.*", self.header_data)
def parse_thumbnails(self):
return None
class Simplify3D(BaseSlicer):
def __init__(self, name="Simplify3D", id_pattern=r"Simplify3D\(R\)"):
super(Simplify3D, self).__init__(name, id_pattern)
def parse_first_layer_height(self):
return self._parse_min_float(r"G1\sZ\d+\.\d*", self.header_data)
def parse_layer_height(self):
return self._parse_min_float(r";\s+layerHeight,.*", self.header_data)
def parse_object_height(self):
return self._parse_max_float(r"G1\sZ\d+\.\d*", self.footer_data)
def parse_filament_total(self):
return self._parse_max_float(
r";\s+Filament\slength:.*mm", self.footer_data)
def parse_estimated_time(self):
time_matches = re.findall(
r';\s+Build time:.*', self.footer_data)
if not time_matches:
return None
total_time = 0
time_match = time_matches[0]
time_patterns = [(r"\d+\shours", 60*60), (r"\d+\smin", 60),
(r"\d+\ssec", 1)]
for pattern, multiplier in time_patterns:
t = _regex_find_ints(pattern, time_match)
if t:
total_time += max(t) * multiplier
return round(total_time, 2)
def parse_thumbnails(self):
return None
class KISSlicer(BaseSlicer):
def __init__(self, name="KISSlicer", id_pattern=r";\sKISSlicer"):
super(KISSlicer, self).__init__(name, id_pattern)
def parse_first_layer_height(self):
return self._parse_min_float(
r";\s+first_layer_thickness_mm\s=\s\d.*", self.header_data)
def parse_layer_height(self):
return self._parse_min_float(
r";\s+max_layer_thickness_mm\s=\s\d.*", self.header_data)
def parse_object_height(self):
return self._parse_max_float(
r";\sEND_LAYER_OBJECT\sz.*", self.footer_data)
def parse_filament_total(self):
filament = _regex_find_floats(
r";\s+Ext\s.*mm", self.footer_data, strict=True)
if filament:
return sum(filament)
return None
def parse_estimated_time(self):
time = self._parse_max_float(
r";\sCalculated.*Build\sTime:.*", self.footer_data)
if time is not None:
time *= 60
return round(time, 2)
def parse_thumbnails(self):
return None
class IdeaMaker(BaseSlicer):
def __init__(self, name="IdeaMaker", id_pattern=r"\sideaMaker\s.*,",):
super(IdeaMaker, self).__init__(name, id_pattern)
def parse_first_layer_height(self):
layer_info = _regex_find_floats(
r";LAYER:0\s*.*\s*;HEIGHT.*", self.header_data)
if len(layer_info) >= 3:
return layer_info[2]
return None
def parse_layer_height(self):
layer_info = _regex_find_floats(
r";LAYER:1\s*.*\s*;HEIGHT.*", self.header_data)
if len(layer_info) >= 3:
return layer_info[2]
return None
def parse_object_height(self):
bounds = _regex_find_floats(
r";Bounding Box:.*", self.footer_data)
if len(bounds) >= 6:
return bounds[5]
return None
def parse_filament_total(self):
filament = _regex_find_floats(
r";Material.\d\sUsed:.*", self.header_data, strict=True)
if filament:
return sum(filament)
return None
def parse_estimated_time(self):
return self._parse_max_float(r";Print\sTime:.*", self.footer_data)
def parse_thumbnails(self):
return None
READ_SIZE = 512 * 1024
SUPPORTED_SLICERS = [
PrusaSlicer, Slic3rPE, Slic3r, SuperSlicer,
Cura, Simplify3D, KISSlicer, IdeaMaker]
SUPPORTED_DATA = [
'first_layer_height', 'layer_height', 'object_height',
'filament_total', 'estimated_time', 'thumbnails']
def main(path, filename):
file_path = os.path.join(path, filename)
slicers = [s() for s in SUPPORTED_SLICERS]
log = []
metadata = {}
if not os.path.isfile(file_path):
log.append("File Not Found: %s" % (file_path))
else:
header_data = footer_data = slicer = None
size = os.path.getsize(file_path)
metadata['size'] = size
metadata['modified'] = time.ctime(os.path.getmtime(file_path))
with open(file_path, 'r') as f:
# read the default size, which should be enough to
# identify the slicer
header_data = f.read(READ_SIZE)
for s in slicers:
if re.search(s.get_id_pattern(), header_data) is not None:
slicer = s
break
if slicer is not None:
metadata['slicer'] = slicer.get_name()
if size > READ_SIZE * 2:
f.seek(size - READ_SIZE)
footer_data = f.read()
elif size > READ_SIZE:
remaining = size - READ_SIZE
footer_data = header_data[remaining - READ_SIZE:] + f.read()
else:
footer_data = header_data
slicer.set_data(header_data, footer_data, log)
for key in SUPPORTED_DATA:
func = getattr(slicer, "parse_" + key)
result = func()
if result is not None:
metadata[key] = result
fd = sys.stdout.fileno()
data = json.dumps(
{'file': filename, 'log': log, 'metadata': metadata}).encode()
while data:
try:
ret = os.write(fd, data)
except OSError:
continue
data = data[ret:]
if __name__ == "__main__":
# Parse start arguments
parser = argparse.ArgumentParser(
description="GCode Metadata Extraction Utility")
parser.add_argument(
"-f", "--filename", metavar='<filename>',
help="name gcode file to parse")
parser.add_argument(
"-p", "--path", default=os.path.abspath(os.path.dirname(__file__)),
metavar='<path>',
help="optional absolute path for file"
)
args = parser.parse_args()
main(args.path, args.filename)