aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorFranklin Wei <franklin@rockbox.org>2019-12-25 21:08:41 -0500
committerFranklin Wei <franklin@rockbox.org>2019-12-25 21:08:41 -0500
commitd3e2a6b089740b774d47beadfa684f5d0c59c254 (patch)
tree89da8b6a1a090764f42556b2ac1a3507f62bc0cb /src
parent4cb6527658588b94ab3e39dcd5895a1948f31a88 (diff)
downloadrastercarve-d3e2a6b089740b774d47beadfa684f5d0c59c254.zip
rastercarve-d3e2a6b089740b774d47beadfa684f5d0c59c254.tar.gz
rastercarve-d3e2a6b089740b774d47beadfa684f5d0c59c254.tar.bz2
rastercarve-d3e2a6b089740b774d47beadfa684f5d0c59c254.tar.xz
Overhaul CLI. Version 1.0!
Diffstat (limited to 'src')
-rwxr-xr-xsrc/rastercarve.py258
1 files changed, 164 insertions, 94 deletions
diff --git a/src/rastercarve.py b/src/rastercarve.py
index 74b1b10..73135e1 100755
--- a/src/rastercarve.py
+++ b/src/rastercarve.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
##############################################################################
-# Rastercarve v0.0
+# Rastercarve 1.0
#
# Copyright (C) 2019 Franklin Wei
#
@@ -22,54 +22,78 @@ This program outputs G-code to engrave a bitmap image on a 3-axis
milling machine.
"""
-import cv2
+import argparse
+import cv2 # image scaling
import math
-import numpy as np
+import numpy as np # a little vector stuff
import sys
+from tqdm import tqdm # progress bar
+__version__ = '1.0'
+
+glob_args = None
+
+##### Default parameters
#### Machine configuration
-FEED_RATE = 80 # in / min
-PLUNGE_RATE = 30 # in / min
-RAPID_RATE = 180 # in / min (used only for time estimation)
-SAFE_Z = .1 # tool will start/end this high from material
-TRAVERSE_Z = 2 # ending height (in)
-MAX_DEPTH = .080 # full black is this many inches deep
-TOOL_ANGLE = 30 # included angle of tool (we assume a V-bit). change if needed
-
-#### Image size
-DESIRED_WIDTH = 4 # desired width in inches (change this to scale image)
-
-#### Cutting Parameters
-LINE_SPACING_FACTOR = 1.0 # Vectric recommends 10-20% for wood
-LINE_ANGLE = 22.5 # angle of lines across image, [0-90) degrees
-LINEAR_RESOLUTION = .01 # spacing between image samples along a line (inches)
+DEF_FEED_RATE = 100 # in / min
+DEF_PLUNGE_RATE = 30 # in / min
+DEF_RAPID_RATE = 240 # in / min (used only for time estimation)
+DEF_SAFE_Z = .1 # tool will start/end this high from material
+DEF_TRAVERSE_Z = 2 # ending height (in)
+DEF_MAX_DEPTH = .080 # full black is this many inches deep
+DEF_TOOL_ANGLE = 30 # included angle of tool (we assume a V-bit). change if needed
+
+#### Cutting parameters
+DEF_STEPOVER = 110
+DEF_LINE_ANGLE = 22.5 # angle of lines across image, [0-90) degrees
+DEF_LINEAR_RESOLUTION = .01 # spacing between image samples along a line (inches)
#### Image interpolation
SUPERSAMPLE = 2 # scale heightmap by this factor before cutting
-#### G-Code options
-LINE_NOS = True # Generate line "N"umbers (required for ShopBot)
-
#### Internal stuff - don't mess with this
DEG2RAD = math.pi / 180
-DEPTH_TO_WIDTH = 2 * math.tan(TOOL_ANGLE / 2 * DEG2RAD) # multiply by this to get the width of a cut
-LINE_WIDTH = MAX_DEPTH * DEPTH_TO_WIDTH
-LINE_SPACING = LINE_SPACING_FACTOR * LINE_WIDTH # orthogonal distance between lines
-OUTPUT_PPI = 1 / LINEAR_RESOLUTION # linear PPI of engraved image
+
+#### Parameter constraints
+CONSTRAINTS = [
+ '0 <= glob_args.line_angle < 90',
+ '0 < glob_args.tool_angle < 180',
+ '0 < glob_args.feed_rate',
+ '0 < glob_args.plunge_rate',
+ '0 < glob_args.safe_z',
+ '0 < glob_args.traverse_z',
+ '0 < glob_args.max_depth',
+ '100 <= glob_args.stepover',
+ '0 < glob_args.linear_resolution',
+ '(hasattr(glob_args, "height") and glob_args.height > 0) or (hasattr(glob_args, "width") and glob_args.width > 0)'
+]
# floating-point range
def frange(x, y, jump):
+ # this is hugely inefficient but gives us nice progress bar
+ # stats... what matters more?
+ ret = []
while x < y:
- yield x
+ ret.append(x)
x += jump
+ return ret
def eprint(s):
- print(s, file=sys.stderr)
+ if not hasattr(glob_args, 'quiet'):
+ print(s, file=sys.stderr)
+
+debug_msgs = 0
+def debug(str):
+ global debug_msgs
+ if hasattr(glob_args, 'debug'):
+ eprint(str)
+ debug_msgs += 1
line = 1
+gcodebuf = ""
def gcode(s):
global line
- print(("N%d %s" % (line, s)) if LINE_NOS else s)
+ print((s if hasattr(glob_args, 'suppress_linenos') else "N%d %s" % (line, s)) )
line += 1
pathlen = 0 # in
@@ -79,7 +103,8 @@ movelen = 0 # in
pathtime = 0 # sec
lastpos = None
-def updatePos(pos, feedrate):
+# movetype: 1 = feed, 2 = rapid, 3 = plunge
+def updatePos(pos, feedrate, movetype):
global pathlen, rapidlen, plungelen, movelen, lastpos, pathtime
if lastpos is None:
lastpos = pos
@@ -88,14 +113,14 @@ def updatePos(pos, feedrate):
pathlen += d
# account for different types of moves separately
- if feedrate == FEED_RATE:
+ if movetype == 1:
movelen += d
- elif feedrate == RAPID_RATE:
+ elif movetype == 2:
rapidlen += d
- elif feedrate == PLUNGE_RATE:
+ elif movetype == 3:
plungelen += d
- pathtime += d / feedrate
+ pathtime += d / (feedrate / 60)
lastpos = pos
# reflect as needed
@@ -103,31 +128,32 @@ def transform(x, y):
return x, -y
# we will negate the Y axis in all these
-def move(x, y, z, f = FEED_RATE):
+def move(x, y, z, f):
x, y = transform(x, y)
gcode("G1 F%d X%f Y%f Z%f" % (f, x, y, z))
- updatePos(np.array([x, y, z]), f)
+ updatePos(np.array([x, y, z]), f, 1)
def moveRapid(x, y, z):
x, y = transform(x, y)
gcode("G0 X%f Y%f Z%f" % (x, y, z))
- updatePos(np.array([x, y, z]), RAPID_RATE)
+ updatePos(np.array([x, y, z]), glob_args.rapid_rate, 2)
def moveSlow(x, y, z):
- # we don't want to transform X, Y here
- move(x, y, z, PLUNGE_RATE)
- # we also don't update position (handled by move)
+ x, y = transform(x, y)
+ f = glob_args.plunge_rate
+ gcode("G1 F%d X%f Y%f Z%f" % (f, x, y, z))
+ updatePos(np.array([x, y, z]), f, 3)
def moveRapidXY(x, y):
x, y = transform(x, y)
gcode("G0 X%f Y%f" % (x, y))
- updatePos(np.array([x, y, lastpos[2]]), RAPID_RATE)
+ updatePos(np.array([x, y, lastpos[2]]), glob_args.rapid_rate, 2)
-def moveZ(z, f = PLUNGE_RATE):
+def moveZ(z, f):
gcode("G1 F%d Z%f" % (f, z))
newpos = lastpos
newpos[2] = z
- updatePos(newpos, f)
+ updatePos(newpos, f, 2)
def getPix(image, x, y):
# clamp
@@ -139,7 +165,7 @@ def getPix(image, x, y):
# return how deep to cut given a pixel value
def getDepth(pix):
# may want to do gamma mapping
- return -float(pix) / 256 * MAX_DEPTH
+ return -float(pix) / 256 * glob_args.max_depth
def inBounds(img_size, x):
return 0 <= x[0] and x[0] < img_size[0] and 0 <= x[1] and x[1] < img_size[1]
@@ -148,14 +174,15 @@ def inBounds(img_size, x):
# output space representing the start point and direction of
# machining, respectively. start should be on the border of the image,
# and d should point INTO the image.
-def engraveLine(img_interp, img_size, ppi, start, d, step = LINEAR_RESOLUTION):
+def engraveLine(img_interp, img_size, ppi, start, d, step):
v = start
d = d / np.linalg.norm(d)
if not inBounds(img_size, v):
- print("WARNING: Engraving out of bounds! (Possible programming error, you idiot!): ", img_size, v, file=sys.stderr)
+ debug("Refusing to engrave out of bounds. (Possible programming error, you idiot!): %s, %s" % (v, img_size))
+ return start
- moveZ(SAFE_Z)
+ moveZ(glob_args.safe_z, glob_args.plunge_rate)
moveRapidXY(v[0], v[1])
first = True
@@ -166,7 +193,7 @@ def engraveLine(img_interp, img_size, ppi, start, d, step = LINEAR_RESOLUTION):
x, y = v
depth = getDepth(getPix(img_interp, img_x, img_y))
if not first:
- move(x, y, depth)
+ move(x, y, depth, glob_args.feed_rate)
else:
first = False
moveSlow(x, y, depth)
@@ -175,69 +202,77 @@ def engraveLine(img_interp, img_size, ppi, start, d, step = LINEAR_RESOLUTION):
# return last engraved point
return v - step * d
-def doEngrave(filename):
+def checkCondition(cond):
+ success = eval(cond)
+ if not success:
+ eprint("ERROR: Invalid parameter: %s" % cond)
+ return success
+
+def doEngrave():
# check parameter sanity
- if ( not(0 <= LINE_ANGLE < 90) or
- not(0 < TOOL_ANGLE < 180) or
- not(0 < FEED_RATE) or
- not(0 < PLUNGE_RATE) or
- not(0 < SAFE_Z) or
- not(0 < TRAVERSE_Z) or
- not(0 < MAX_DEPTH) or
- not(0 < DESIRED_WIDTH) or
- not(1 <= LINE_SPACING_FACTOR) or
- not(0 < LINEAR_RESOLUTION) ):
- eprint("WARNING: Invalid parameter(s).")
+ for c in CONSTRAINTS:
+ if not checkCondition(c):
+ eprint("Refusing to generate G-code.")
+ return
# invert and convert to grayscale
- img = ~cv2.cvtColor(cv2.imread(filename), cv2.COLOR_BGR2GRAY)
+ img = ~cv2.cvtColor(cv2.imread(glob_args.filename), cv2.COLOR_BGR2GRAY)
orig_h, orig_w = img.shape[:2]
- img_w, img_h = img_size = DESIRED_WIDTH, DESIRED_WIDTH * (orig_h / orig_w)
+ img_w, img_h = img_size = (glob_args.width, glob_args.width * (orig_h / orig_w)) if hasattr(glob_args, 'width') else (glob_args.height * (orig_w / orig_h), glob_args.height)
img_ppi = orig_w / img_w # should be the same for X and Y directions
+ depth2width = 2 * math.tan(glob_args.tool_angle / 2 * DEG2RAD) # multiply by this to get the width of a cut
+ line_width = glob_args.max_depth * depth2width
+ line_spacing = glob_args.stepover * line_width / 100.0 # orthogonal distance between lines
+ output_ppi = 1 / glob_args.linear_resolution # linear PPI of engraved image
+
# scale up the image with interpolation
- # we want the image DPI to match our engraving DPI (which is LINEAR_RESOLUTION)
- scale_factor = SUPERSAMPLE * OUTPUT_PPI / img_ppi
+ # we want the image DPI to match our engraving DPI (which is glob_args.linear_resolution)
+ scale_factor = SUPERSAMPLE * output_ppi / img_ppi
img_interp = cv2.resize(img, None, fx = scale_factor, fy = scale_factor)
interp_ppi = img_ppi * scale_factor
# preamble: https://www.instructables.com/id/How-to-write-G-code-basics/
print("( Generated by rastercarve: github.com/built1n/rastercarve )")
- print("( Image name: %s )" % (filename))
+ print("( Image name: %s )" % (glob_args.filename))
gcode("G00 G90 G80 G28 G17 G20 G40 G49\n")
gcode("M03") # start spindle
- d = np.array([math.cos(LINE_ANGLE * DEG2RAD),
- -math.sin(LINE_ANGLE * DEG2RAD)])
+ d = np.array([math.cos(glob_args.line_angle * DEG2RAD),
+ -math.sin(glob_args.line_angle * DEG2RAD)])
max_y = img_h + img_w * -d[1] / d[0] # highest Y we'll loop to
- yspace = LINE_SPACING / math.cos(LINE_ANGLE * DEG2RAD) # vertical spacing between lines
- xspace = LINE_SPACING / math.sin(LINE_ANGLE * DEG2RAD) if LINE_ANGLE != 0 else 0 # horizontal space
+ yspace = line_spacing / math.cos(glob_args.line_angle * DEG2RAD) # vertical spacing between lines
+ xspace = line_spacing / math.sin(glob_args.line_angle * DEG2RAD) if glob_args.line_angle != 0 else 0 # horizontal space
nlines = round(max_y / yspace)
### Generate toolpath
- moveRapid(0, 0, SAFE_Z)
+ moveRapid(0, 0, glob_args.safe_z)
end = None
- for y in frange(0, max_y - yspace, yspace * 2):
+ for y in tqdm(frange(0, max_y - yspace, yspace * 2),
+ desc='Generating G-code',
+ unit=' lines',
+ unit_scale = 2,
+ disable = hasattr(glob_args, 'quiet')): # we engrave two lines per loop
start = np.array([0, y]).astype('float64')
# start some vectors on the bottom edge of the image
if d[1] != 0:
c = (img_h - y) / d[1] # solve (start + cd)_y = h for c
if c >= 0:
- start += (c + LINEAR_RESOLUTION) * d
+ start += (c + glob_args.linear_resolution) * d
- start = engraveLine(img_interp, img_size, interp_ppi, start, d)
+ start = engraveLine(img_interp, img_size, interp_ppi, start, d, glob_args.linear_resolution)
# now engrave the other direction
# we just need to flip d and move start over
# see which side of the image the last line ran out on (either top or right side)
- last = start + LINEAR_RESOLUTION * d
+ last = start + glob_args.linear_resolution * d
if last[1] < 0:
start[0] += xspace
@@ -246,42 +281,77 @@ def doEngrave(filename):
# check if we ran out the top-right corner (this needs special treatment)
if start[0] >= img_w:
- eprint("Special case TRIGGERED")
+ debug("Special case TRIGGERED")
c = (start[0] - img_w) / d[0]
- start -= (c + LINEAR_RESOLUTION * .01) * d
+ start -= (c + glob_args.linear_resolution * .01) * d
- end = engraveLine(img_interp, img_size, interp_ppi, start, -d)
+ end = engraveLine(img_interp, img_size, interp_ppi, start, -d, glob_args.linear_resolution)
- moveSlow(end[0], end[1], TRAVERSE_Z)
- moveRapid(0, 0, TRAVERSE_Z)
+ moveSlow(end[0], end[1], glob_args.traverse_z)
+ moveRapid(0, 0, glob_args.traverse_z)
gcode("M05") # stop spindle
### Dump stats
eprint("=== Statistics ===")
- eprint("Input resolution: %dx%dpx" % (orig_w, orig_h))
+ eprint("Input resolution: %dx%d px" % (orig_w, orig_h))
eprint("Output dimensions: %.2f\" wide by %.2f\" tall = %.1f in^2" % (img_w, img_h, img_w * img_h))
- eprint("Max line depth: %.3f in" % (MAX_DEPTH))
- eprint("Max line width: %.3f in (%.1f deg V-bit)" % (LINE_WIDTH, TOOL_ANGLE))
- eprint("Line spacing: %.3f in (%d%% stepover)" % (LINE_SPACING, int(round(100 * LINE_SPACING_FACTOR))))
- eprint("Line angle: %.1f deg" % (LINE_ANGLE))
+ eprint("Max line depth: %.3f in" % (glob_args.max_depth))
+ eprint("Max line width: %.3f in (%.1f deg V-bit)" % (line_width, glob_args.tool_angle))
+ eprint("Line spacing: %.3f in (%d%% stepover)" % (line_spacing, int(round(glob_args.stepover))))
+ eprint("Line angle: %.1f deg" % (glob_args.line_angle))
eprint("Number of lines: %d" % (nlines))
eprint("Input resolution: %.1f PPI" % (img_ppi))
- eprint("Output resolution: %.1f PPI" % (OUTPUT_PPI))
+ eprint("Output resolution: %.1f PPI" % (output_ppi))
eprint("Scaled image by f=%.2f (%.1f PPI)" % (scale_factor, interp_ppi))
eprint("Total toolpath length: %.1f in" % (pathlen))
- eprint(" - Rapids: %.1f in (%.1f s)" % (rapidlen, rapidlen / (RAPID_RATE / 60)))
- eprint(" - Plunges: %.1f in (%.1f s)" % (plungelen, plungelen / (PLUNGE_RATE / 60)))
- eprint(" - Moves: %.1f in (%.1f s)" % (movelen, movelen / (FEED_RATE / 60)))
- eprint("Feed rate: %.1f in/min" % (FEED_RATE))
- eprint("Plunge rate: %.1f in/min" % (PLUNGE_RATE))
- eprint("Total machining time: %.1f sec" % (pathlen / (FEED_RATE / 60)))
+ eprint(" - Rapids: %.1f in (%.1f s)" % (rapidlen, rapidlen / (glob_args.rapid_rate / 60)))
+ eprint(" - Plunges: %.1f in (%.1f s)" % (plungelen, plungelen / (glob_args.plunge_rate / 60)))
+ eprint(" - Moves: %.1f in (%.1f s)" % (movelen, movelen / (glob_args.feed_rate / 60)))
+ eprint("Feed rate: %.1f in/min" % (glob_args.feed_rate))
+ eprint("Plunge rate: %.1f in/min" % (glob_args.plunge_rate))
+ eprint("Estimated machining time: %.1f sec" % (pathtime))
+
+ if not hasattr(glob_args, 'debug') and debug_msgs > 0:
+ eprint("%d suppressed debug message(s)." % (debug_msgs))
def main():
- if len(sys.argv) != 2:
- eprint("Usage: rastercarve.py IMAGE")
- return
- doEngrave(sys.argv[1])
+ parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter,
+ description='Generate G-code to engrave raster images.',
+ epilog='Defaults are usually safe to leave unchanged.')
+ parser.add_argument('filename', help='input image (any OpenCV-supported format)')
+
+ dim_group = parser.add_argument_group('output dimensions', 'Exactly one required.')
+ mutex_group = dim_group.add_mutually_exclusive_group(required=True)
+ mutex_group.add_argument('--width', help='output width (in)', action='store', dest='width', type=float, default=argparse.SUPPRESS)
+ mutex_group.add_argument('--height', help='output height (in)', action='store', dest='height', type=float, default=argparse.SUPPRESS)
+
+ mach_group = parser.add_argument_group('machine configuration')
+ mach_group.add_argument('-f', help='engraving feed rate (in/min)', action='store', dest='feed_rate', default=DEF_FEED_RATE, type=float)
+ mach_group.add_argument('-p', help='engraving plunge rate (in/min)', action='store', dest='plunge_rate', default=DEF_PLUNGE_RATE, type=float)
+ mach_group.add_argument('--rapid', help='rapid traverse rate (for time estimation only)', action='store', dest='rapid_rate', default=DEF_RAPID_RATE, type=float)
+ mach_group.add_argument('-z', help='rapid Z traverse height (in)', action='store', dest='safe_z', default=DEF_SAFE_Z, type=float)
+ mach_group.add_argument('--end-z', help='Z height of final traverse (in)', action='store', dest='traverse_z', default=DEF_TRAVERSE_Z, type=float)
+ mach_group.add_argument('-d', help='maximum engraving depth (in)', action='store', dest='max_depth', default=DEF_MAX_DEPTH, type=float)
+ mach_group.add_argument('-t', help='included angle of tool (deg)', action='store', dest='tool_angle', default=DEF_TOOL_ANGLE, type=float)
+
+ cut_group = parser.add_argument_group('engraving parameters')
+ cut_group.add_argument('-a', help='angle of grooves from horizontal (deg)', action='store', dest='line_angle', default=DEF_LINE_ANGLE, type=float)
+ cut_group.add_argument('-s', help='stepover percentage (affects spacing between lines)', action='store', dest='stepover', default=DEF_STEPOVER, type=float)
+ cut_group.add_argument('-r', help='distance between successive G-code points (in)', action='store', dest='linear_resolution', default=DEF_LINEAR_RESOLUTION, type=float)
+
+ gcode_group = parser.add_argument_group('G-code parameters')
+ gcode_group.add_argument('--no-line-numbers', help='suppress G-code line numbers (dangerous on ShopBot!)', action='store_true', dest='suppress_linenos', default=argparse.SUPPRESS)
+
+ parser.add_argument('--debug', help='print debug messages', action='store_true', dest='debug', default=argparse.SUPPRESS)
+ parser.add_argument('-q', help='disable progress and statistics', action='store_true', dest='quiet', default=argparse.SUPPRESS)
+ parser.add_argument('--version', help="show program's version number and exit", action='version', version=__version__)
+
+ global glob_args
+ glob_args = parser.parse_args()
+ debug(glob_args)
+
+ doEngrave()
if __name__=="__main__":
main()