#! /usr/bin/python3 # Last edited on 2026-02-05 11:51:54 by stolfi import sys, re, subprocess from math import sqrt, hypot, sin, cos, pi, floor, ceil PROG_HELP = """ annotate_image.py {SOURCE_IMG} {COMMAND}... Commands: -imagesize {NUM} {NUM} : inform the width and height of the input image. -textsize {NUM} : set point text size. -strokewidth {NUM} : set stroke width (rel to 0.042 of text size). -hue {HUE} : set text, stroke, and fill color from hue. -strokecolor {COLORSPEC} : set stroke color. -fillcolor {COLORSPEC} : set fill color (but not text color). -color {COLORSPEC} : set stroke and fill color (but not text color). -textcolor {COLORSPEC} : set text color. -shadowcolor {COLORSPEC} : set color of shadows. -origin {XR} {YR} : add {XR,YR} to all point coords that follow. -S {X0} {Y0} [ {Xi} {Yi} ].. : open polygonal line. -D {X0} {Y0} [ {Xi} {Yi} ].. : open polygonal line with corner dots. -Q {X1} {Y1} ... {X4} {Y4} : quadrilateral with given corners. -P {XO} {YO} {XU} {YU} {XV} {YV} : paralleg with corner {XO,YO} and sides {XU,YU} and {XV,YV}. -C {XC} {YC} {RD} : circle of center {XC,YC} radius {RD}. -T {XT} {YT} {AX} {AY} {TEXT} : text with alignment {AX,AY} at {XT,YT} -I {XR} {YR} {XA} {YA} {IMG} {MAG} : image {IMG} scaled by {MAG} with ref {XA} {YA} at {XR} {YR}. -LA {XD} {YD} {RD} {XT} {YT} {TEXT} : label with arrow at {XD,YD} displ {RD} and text at {XT,YT}. -LD {XD} {YD} {RD} {XT} {YT} {TEXT} : label with dot at {XD,YD} radius {RD} and text at {XT,YT}. -LC {XD} {YD} {RD} {XT} {YT} {TEXT} : label with circle at {XD,YD} radius {RD} and text at {XT,YT}. ( or ) : grouping for ImageMagick. Specify "none" as "-strokecolor", "-fillcolor", or "-shadowcolor" to suppress the outline, the filling, or the shadow in the commands "-Q", "-P", "-C", "-LA", "-LC", "-LD". Coodinates of labels and text that are negative (after adding the origin) are counted backwards from the image dimensions: {-1 -1} is the lower right corner. The dimensions must have been informed with "-imagesize". The labelling commands will write the text within a disk or sausage filled with the current fill color. Specify label = "none" to suppress it. Also "+LA" "+LD" and "+LC" are like "-LA" "-LD" and "-LC" except that {XT} and {YT} are displacements relative to the labeled point; that is, the position of the text is {XD+XT,XD+YT} instead of {XT,YT}. The options "-CL" and "-L" of the previous version are flagged as obsolete. """ debug = False; image_size = ( None, None ) # Used to translate negative coords. def main(): global debug, image_size sh_cmds, fg_cmds = parse_commands() for cmds in sh_cmds, fg_cmds: for cmd in cmds: outcmd(cmd) return # ---------------------------------------------------------------------- def outcmd(cmd): out(" "); out(cmd); out(" \\\n") # err(cmd); err("\n") return # ---------------------------------------------------------------------- def parse_commands(): global debug, image_size iarg = 1; nargs = len(sys.argv) def next_arg_is_num(): nonlocal nargs, iarg return iarg < len(sys.argv) and re.match(r"[-+]?[0-9]", sys.argv[iarg]) # ...................................................................... def next_arg(): nonlocal nargs, iarg if iarg >= nargs: cmd_error(f"missing arg after {sys.argv[nargs-1]}") res = sys.argv[iarg]; iarg += 1 return res # ...................................................................... def next_float_arg(): nonlocal nargs, iarg res = float(next_arg()) return res # ...................................................................... def next_int_arg(): nonlocal nargs, iarg res = int(next_arg()) return res # ...................................................................... def next_rgb_arg(): nonlocal nargs, iarg res = next_arg() # !!! Should validate value !!! return res # ...................................................................... def next_point_arg(): # Gets next two args as point coords. Adjusts for origin. nonlocal nargs, iarg, ox, oy global image_size ax = next_float_arg() + ox; ay = next_float_arg() + oy; return ax, ay # ...................................................................... def fold_negative_coords(ax, ay): # Returns the pair of coordinates {ax} or {ay}, except that any # negative values are interpreted as relative to the size of the # image. Thus {ax = -1} is interpreted as the last pixel column, {ax # = -2} as the next-to-last one, etc. if ax < 0: ax = image_size[0] + ax; if ay < 0: ay = image_size[1] + ay; return ax, ay # ---------------------------------------------------------------------- def next_vec_arg(): nonlocal nargs, iarg ax = next_float_arg(); ay = next_float_arg(); return ax, ay # ...................................................................... def color_from_hue(hue, val): # Returns an "rgb(...)" color with given hue and value. hue = hue % 1.0 # Crude linear formula. Should # sys.stderr.write(f"{val=} {hue=}") if hue < 1/3: tt = 3*hue R = 1-tt; G = tt; B = 0 elif hue < 2/3: tt = 3*(hue - 1/3) R = 0; G = 1-tt; B = tt else: tt = 3*(hue - 2/3) R = tt; G = 0; B = 1-tt # sys.stderr.write(f" -> {R=:.4f} {G=:.4f} {B=:.4f}") # Adjust relative saturation to 1.0: Cmax = max(R,G,B) Cmin = min(R,G,B) assert Cmin == 0, "bug Cmin" R = R/Cmax; G = G/Cmax; B = B/Cmax Y = 0.3*R + 0.6*G + 0.1*B; # sys.stderr.write(f" -> {R=:.4f} {G=:.4f} {B=:.4f} {Y=:.4f}") if val > Y: # Add white: t = (val - Y)/(1 - Y) R = (1-t)*R + t; G = (1-t)*G + t; B = (1-t)*B + t else: # Add black: t = val/Y; R = t*R; G = t*G; B = t*B Y = 0.3*R + 0.6*G + 0.1*B; # sys.stderr.write(f" -> {R=:.4f} {G=:.4f} {B=:.4f} {Y=:.4f}\n") assert abs(Y - val) < 0.001, "bug" # R = R**0.45; G = G**0.45; B = B**0.45 return f"rgb({R*100:.1f}%,{G*100:.1f}%,{B*100:.1f}%)" # .................................................................... # Defaults: text_size = 20.0 # Text pointsize. text_color = 'rgb(100%,080%,020%)' # Text color. fg_stroke_color = 'rgb(020%,080%,100%)' # Line color. sh_stroke_color = 'none' # Line shadow color. fill_color = 'rgb(020%,080%,100%)' # Fill color. ox = 0; oy = 0 # Origin for point coords. # Commands of shadow layer and foreground layer: sh_cmds = [] fg_cmds = [] unit_width, text_margin = set_font_size(fg_cmds, text_size) fg_stroke_width = unit_width # Foreground stroke width. sh_stroke_width, sh_dot_rad, sh_conn_rad, fg_dot_rad, fg_conn_rad = \ set_sh_fg_stroke_width(sh_cmds, fg_cmds, fg_stroke_width) while iarg < nargs: opt = next_arg() dbg(f"opt = '{opt}' {iarg = }") if re.fullmatch(r"-?-(help|info)", opt): err("usage:\n") err(PROG_HELP) sys.exit(0) if opt == "(" or opt == ")": # Pass grouping op to ImageMagick: cmd = f"\\{opt}" sh_cmds.append(cmd) fg_cmds.append(cmd) continue if opt[0] != "-" and opt[0] != "+": cmd_error(f"expected '+{{CMD}}' or '-{{CMD}}', got '{opt}'") # Back incompatibility: if opt[1:] == "L": cmd_error("command '{opt}' is obsolete. Use '-LD' or '+LD', and check line/font sizes") elif opt[1:] == "CL": cmd_error("command '{opt}' is obsolete. Use '-LC' or '+LC', and check line/font sizes") # Now parse the command: if opt == "-debug": # set the global debug flag: debug = bool(next_arg()) elif opt == "-imagesize": # set the global image size parameters: image_size = ( next_int_arg(), next_int_arg()) elif opt == "-strokecolor": # Change stroke and dot for subsequent foreground ops: fg_stroke_color = next_rgb_arg() elif opt == "-shadowcolor": # Change the line shadow color for subsequent shadow ops: sh_stroke_color = next_rgb_arg() elif opt == "-fillcolor": # Change stroke and dot color for subsequent foreground ops: fill_color = next_rgb_arg() elif opt == "-hue": # Change stroke, text, and fill color for subsequent foreground ops: hue = next_float_arg() fg_stroke_color = color_from_hue(hue, 0.2) text_color = fg_stroke_color fill_color = color_from_hue(hue, 0.8) elif opt == "-color": # Change stroke and fill (not text) color for subsequent foregroundops: fg_stroke_color = next_rgb_arg() fill_color = fg_stroke_color elif opt == "-textcolor": # Change text color for subsequent foreground ops: text_color = next_rgb_arg() elif opt == "-textsize": # Change text size for subsequent foreground ops: # Save current relative line width. rwid = fg_stroke_width / unit_width; # Set {text_size}, compute new {unit_width}, {text_margin}: text_size = next_float_arg() unit_width, text_margin = set_font_size(fg_cmds, text_size) # Reset the line width to maintain relative width to text size: fg_stroke_width = rwid * unit_width sh_stroke_width, sh_dot_rad, sh_conn_rad, fg_dot_rad, fg_conn_rad = \ set_sh_fg_stroke_width(sh_cmds, fg_cmds, fg_stroke_width) elif opt == "-strokewidth": # Change stroke width for subsequent foreground and shadow ops: rwid = next_float_arg() fg_stroke_width = rwid * unit_width sh_stroke_width, sh_dot_rad, sh_conn_rad, fg_dot_rad, fg_conn_rad = \ set_sh_fg_stroke_width(sh_cmds, fg_cmds, fg_stroke_width) elif opt == "-origin": # Change origin for subsequent point coords: ox = next_float_arg() oy = next_float_arg() elif opt == "-C": # Circle: ax, ay = next_point_arg(); rd = next_float_arg() draw_sh_fg_circle \ ( sh_cmds, fg_cmds, ax, ay, rd, sh_stroke_color, fill_color, fg_stroke_color ) elif opt == "-P": # Parallelogram defined by point and two vectors ax, ay = next_point_arg(); ux, uy = next_vec_arg(); vx, vy = next_vec_arg(); bx = ax + ux; by = ay + uy cx = bx + vx; cy = by + vy dx = ax + vx; dy = ay + vy verts = ( (ax,ay), (bx,by), (cx,cy), (dx,dy) ) closed = True draw_sh_fg_polygon \ ( sh_cmds, fg_cmds, verts, closed, sh_conn_rad, sh_stroke_color, fg_conn_rad, fg_stroke_color, fill_color ) elif opt == "-Q": # Quadrilateral given vertices: ax, ay = next_point_arg(); bx, by = next_point_arg(); cx, cy = next_point_arg(); dx, dy = next_point_arg(); verts = ( (ax,ay), (bx,by), (cx,cy), (dx,dy) ) closed = True draw_sh_fg_polygon \ ( sh_cmds, fg_cmds, verts, closed, sh_conn_rad, sh_stroke_color, fg_conn_rad, fg_stroke_color, fill_color ) elif opt == "-S" or opt == "-D": # Open polygonal line with round joints ("-S") or knobs ("-D"): sh_vert_rad = ( sh_dot_rad if opt == "-D" else sh_conn_rad ) fg_vert_rad = ( fg_dot_rad if opt == "-D" else fg_conn_rad ) ax, ay = next_point_arg(); verts = [ (ax,ay) ] # Vertices of polygonal line. rdots = [] # Points defining dots, or empty. dbg(f"start = ({pc(ax,ay)})") while next_arg_is_num(): bx, by = next_point_arg(); verts.append((bx,by)) closed = False draw_sh_fg_polygon \ ( sh_cmds, fg_cmds, verts, closed, sh_vert_rad, sh_stroke_color, fg_vert_rad, fg_stroke_color, 'none' ) elif opt[1:] == "LA" or opt[1:] == "LD" or opt[1:] == "LC": # Label in oval with line and arrowhead, drumstick, or loupe: dx, dy = next_point_arg(); dr = next_float_arg(); if opt[0] == "-": tx, ty = next_point_arg() tx,ty = fold_negative_coords(tx, ty) elif opt[0] == "+": tx, ty = next_vec_arg() tx = dx + tx; ty = dy + ty else: cmd_error(f"invalid labelling command '{opt}'") text = next_arg() text_bg_color = fill_color draw_sh_fg_labeled_pointer \ ( sh_cmds, fg_cmds, opt[2], dx, dy, dr, \ sh_stroke_width, sh_stroke_color, fg_stroke_width, fg_stroke_color, tx, ty, text_size, text_bg_color, text_color, text_margin, text ) elif opt == "-T": # Text annotation: dx, dy = next_point_arg() # Text coordinates. dx, dy = fold_negative_coords(dx, dy) ax = next_float_arg(); ay = next_float_arg() # Alignment. text = next_arg() # Number of chars in text: nc = len(text) # Courier bold with pointsize 100 has height 120 and width 60 # So we must shift the text by {text_size*(-60*ax*nc,+60*ay)/100} for centered. # Shift for text: tx = dx - (text_size * nc * ax * 0.60) ty = dy + (text_size * ay * 0.60) draw_text(fg_cmds, tx, ty, text_color, text) elif opt == "-I": # Image insertion: -I {XR} {YR} {XA} {YA} {IMG} {MAG} rx, ry = next_point_arg() # Image position. rx, ry = fold_negative_coords(rx, ry); ax = next_float_arg(); ay = next_float_arg() # Alignment. img = next_arg() mag = next_float_arg() sx, sy = get_image_size(img) dbg(f"image size = {sx} x {sy}") # Compute offset taking into account the specified alignment: ofx = rx - (sx * ax) * mag ofy = ry - (sy * (1 - ay)) * mag draw_image(fg_cmds, ofx, ofy, mag, img) else: cmd_error(f"invalid command '{opt}'") return sh_cmds, fg_cmds # ---------------------------------------------------------------------- def set_sh_fg_stroke_width(sh_cmds, fg_cmds, fg_stroke_width): # Sets the foreground line width to {fg_stroke_width} # and the shadow line width to the proper value for that. # Also returns the new {sh_stroke_width} and # the corresponding dot and connector radii # {sh_dot_rad,sh_conn_rad,fg_dot_rad,fg_conn_rad}. shadow_wd = 0.75 # Extra width of shadow on each side of strokes. dbg(f"setting fg line width = {fg_stroke_width:.3f}") fg_conn_rad = set_layer_stroke_width(fg_cmds, fg_stroke_width) fg_dot_rad = 2.0 * fg_stroke_width # Dot radius (assuming dots will not be stroked). dbg(f"radius of fg dots = {fg_dot_rad:.3f} connectors = {fg_conn_rad:.3f}") sh_stroke_width = fg_stroke_width + 2 * shadow_wd dbg(f"setting sh line width = {sh_stroke_width:.3f}") sh_conn_rad = set_layer_stroke_width(sh_cmds, sh_stroke_width) sh_dot_rad = fg_dot_rad + shadow_wd dbg(f"radius of sh dots = {sh_dot_rad:.3f} connectors = {sh_conn_rad:.3f}") return sh_stroke_width, sh_dot_rad, sh_conn_rad, fg_dot_rad, fg_conn_rad # ---------------------------------------------------------------------- def set_layer_stroke_width(cmds, line_width): # Sets the line width for one layer (shadow or foreground). # Also computes the connecting dot radius {conn_rad} for polygons without dots. # Returns {conn_rad}. # Connecting dot radius (assuming not stroked): # Dots with radius less than 1 are rendered incorrectly, set them to 0: conn_rad = max(0.5*line_width - 0.5, 0) cmds.append(f"-strokewidth {line_width:.3f}") return conn_rad #---------------------------------------------------------------------- def set_font_size(cmds, text_size): # Sets the font size for succeeding commands in layer {cmds}. Also # returns the {unit_width,text_margin} for this font size. cmds.append(f"-font 'Courier-Bold' -pointsize {text_size:.1f}") unit_width = text_size * 0.042 # Unit for the "-strokewidth" command. text_margin = text_size * 0.20 + 0.5 # Margin for text boxes. dbg(f"unit_width = {unit_width:.3f}") dbg(f"label margin = {text_margin:.3f}") return unit_width, text_margin # ---------------------------------------------------------------------- def draw_image(cmds, ofx, ofy, mag, img): geom = ("%+d%+d" % (ofx, ofy)) pct = mag * 100 dbg(f"pct = '{pct}' geom = '{geom}'") cmds.append(f"\\( \\( {img} +repage -resize '{pct}%' \\) -geometry {geom} \\) -composite" ) return # ---------------------------------------------------------------------- def draw_text(cmds, tx, ty, text_color, text): cmds.append(f"-stroke none -fill '{text_color}'") cmds.append(f"-draw 'text {pc(tx,ty)} \"{text}\"'") return # ---------------------------------------------------------------------- def draw_sh_fg_circle \ ( sh_cmds, fg_cmds, ax, ay, rd, sh_stroke_color, fg_stroke_color, fill_color ): if sh_stroke_color != 'none': draw_circle(sh_cmds, ax, ay, rd, 'none', sh_stroke_color) draw_circle(fg_cmds, ax, ay, rd, fill_color, fg_stroke_color) return # ---------------------------------------------------------------------- def draw_sh_fg_polygon \ ( sh_cmds, fg_cmds, verts, closed, sh_vert_rad, sh_stroke_color, \ fg_vert_rad, fg_stroke_color, fill_color ): if sh_stroke_color != 'none': draw_polygon(sh_cmds, verts, closed, sh_vert_rad, sh_stroke_color, 'none') draw_polygon(fg_cmds, verts, closed, fg_vert_rad, fg_stroke_color, fill_color) return # ---------------------------------------------------------------------- def draw_polygon(cmds, verts, closed, vert_rad, stroke_color, fill_color): # Draws a closed or open polygonal line with vertices {verts} on layer {cmds}. If # {vert_rad} is positive, also fill a circle with that radius at each # vertex. If the polygonal line is closed, fills it with {fill_color}. assert len(verts) > 0, "no vertices?" if closed: assert len(verts) >= 3, "too short to be closed" if fill_color != 'none': assert closed, "can't fill if not closed" shape = 'polygon' if closed else 'polyline' cmds.append(f"-stroke '{stroke_color}' -fill '{fill_color}'") cmd = f"-draw '{shape}" for vx, vy in verts: cmd += f" {pc(vx,vy)}" cmd += "'" # err(f" cmd = [[{cmd}]]\n") cmds.append(cmd) if vert_rad > 0: # Draw the dots, not stroked, filled with {stroke_color}: cmds.append(f"-stroke 'none' -fill '{stroke_color}'") for vx, vy in verts: cmds.append(f"-draw 'circle {pc(vx,vy)} {pc(vx+vert_rad,vy)}'") return # ---------------------------------------------------------------------- def draw_circle(cmds, ax, ay, rd, fill_color, stroke_color): # Appends to {cmds} commands to draw a circle with center {(ax,ay)}, # the current stroke width, with the given bx = ax + rd; by = ay; cmds.append(f"-stroke '{stroke_color}' -fill '{fill_color}'" ) cmds.append(f"-draw 'circle {pc(ax,ay)} {pc(bx,by)}'" ) return # ---------------------------------------------------------------------- def draw_sh_fg_labeled_pointer \ ( sh_cmds, fg_cmds, kind, dx, dy, dr, \ sh_stroke_width, sh_stroke_color, fg_stroke_width, fg_stroke_color, tx, ty, text_size, text_bg_color, text_color, text_margin, text ): # Used by "-LA", "+LA", "-LD", "+LD", "-LC", "+LC" # {sh_cmds}, {fg_cmds} : Lists of shadow and foreground commands. # {kind} : Label kind "A", "D", or "C". # {dx,dy} : Coords of point to be labeled. # {dr} : Radius of circle/dot at labeled point; negative for arrow. # {sh_stroke_width} : Width of shadow strokes. # {sh_stroke_color} : Color of shadow strokes, or 'none'. # {fg_stroke_width} : Width of foreground strokes. # {fg_stroke_color} : Color of foreground strokes. # {tx,ty} : Coords of text center. # {text_size} : Text size. # {text_bg_color} : Color of text background. # {text_color} : Color of text. # {text} : Text of the label, or "none" or "NONE". # {text_margin} : ??? # Uncomment this when we figure out how to do it: # cmds.append(f"\\( -insert 0 -print \"%[pixel:p{{{dx},{dy}}}]\\n\" null:- \\)") if kind == "A": head_length = 6 * fg_stroke_width # Head length. if sh_stroke_color != 'none': draw_arrow(sh_cmds, tx,ty, dx,dy,dr, sh_stroke_width, sh_stroke_color, head_length, 'none') head_color = fg_stroke_color draw_arrow(fg_cmds, tx,ty, dx,dy,dr, fg_stroke_width, fg_stroke_color, head_length, head_color) elif kind == "D": if sh_stroke_color != 'none': draw_drumstick(sh_cmds, tx,ty, dx,dy,dr, sh_stroke_width, sh_stroke_color, 'none') head_color = fg_stroke_color draw_drumstick(fg_cmds, tx,ty, dx,dy,dr, fg_stroke_width, fg_stroke_color, head_color) elif kind == "C": if sh_stroke_color != 'none': draw_drumstick(sh_cmds, tx,ty, dx,dy,dr, sh_stroke_width, sh_stroke_color, 'none' ) head_color = 'none' draw_drumstick(fg_cmds, tx,ty, dx,dy,dr, fg_stroke_width, fg_stroke_color, 'none') else: cmd_error(f"invalid labelling command '±L{kind}'") if text.lower() != "none": draw_label \ ( fg_cmds, tx,ty, text_size, text_bg_color, fg_stroke_color, text_color, text_margin, text ) return # ---------------------------------------------------------------------- def draw_label(cmds, tx,ty, text_size, text_bg_color, stroke_color, text_color, text_margin, text): # Draws a label within oval at {(tx,ty)} filled with # {text_bg_color}, with frame of {stroke_color}, with the {text} in {text_color}. # Assumes that the current text size is {text_size} (does not set it). # Number of chars in text. nc = len(text) # Nominal char width and height: # Courier bold with pointsize 100 has total height 120 and width 60, # but its effective height seems to be 60: wx = text_size * 0.6; wy = text_size * 0.6 # dbg(f"effective character dimensions = {wx} by {wy}") # Half-distance between half-circle centers: hx = 0.5 * wx*(nc-1) # dbg(f"half-distance between centers = {hx}") # Radius of a single char bbox: cr = 0.5 * hypot(wx, wy) # dbg(f"single char radius = {cr}") # Radius of half-circles: trd = cr + text_margin # dbg(f"label disk radius = {trd}") # Shift the text by {0.5*(-wx*nc,+wy)}: ux = tx - 0.5* wx*nc; uy = ty + 0.5* wy; dbg(f"shifted text coords = {ux},{uy}") if nc <= 1: # Tag is a single disk: cxmax = tx + trd # Max X of circle. cmds.append(f"-stroke '{stroke_color}' -fill '{text_bg_color}'" ) cmds.append(f"-draw 'circle {pc(tx,ty)} {pc(cxmax,ty)}'" ) else: # Tag is a rectangle and two disks: txmin = tx - hx; txmax = tx + hx # Coordinates of text endpoints: dbg(f"txmin = {txmin} txmax = {txmax}") sxmin = txmin - trd; sxmax = txmax + trd # Min and max X of of sausage. dbg(f"sxmin = {sxmin} sxmax = {sxmax}") symin = ty - trd; symax = ty + trd # Min and max Y of sausage. dbg(f"symin = {symin} symax = {symax}") # Fill the sausage: cmds.append(f"-stroke none -fill '{text_bg_color}'" ) cmds.append(f"-draw 'circle {pc(txmin,ty)} {pc(sxmin,ty)}'" ) cmds.append(f"-draw 'circle {pc(txmax,ty)} {pc(sxmax,ty)}'" ) cmds.append(f"-draw 'rectangle {pc(txmin,symin)} {pc(txmax,symax)}'" ) # Draw the sausage's outline: bxmin = sxmin + 2*trd; bxmax = sxmax - 2*trd # Min and max Y of sausage core. cmds.append(f"-stroke '{stroke_color}' -fill none" ) cmds.append(f"-draw 'arc {pc(sxmin,symin)} {pc(bxmin,symax)} +90,+270'" ) cmds.append(f"-draw 'line {pc(txmin,symax)} {pc(txmax,symax)}'" ) cmds.append(f"-draw 'arc {pc(bxmax,symin)} {pc(sxmax,symax)} -90,+90'" ) cmds.append(f"-draw 'line {pc(txmax,symin)} {pc(txmin,symin)}'" ) cmds.append(f"-stroke none -fill '{text_color}'" ) cmds.append(f"-draw 'text {pc(ux,uy)} \"{text}\"'" ) return # ---------------------------------------------------------------------- def draw_arrow(cmds, tx,ty, dx,dy,dr, shaft_width, shaft_color, head_length, head_color): # Draws an arrow from {(tx,ty) to {(dx,dy)}. The # arrowhead size is proportional to the line width. cmds.append(f"-strokewidth '{shaft_width}'" ) cmds.append(f"-stroke '{shaft_color}'" ) dbg(f"target point = {pc(dx,dy)}") # Arrow shaft direction: ex = dx - tx; ey = dy - ty ed = hypot(ex, ey); ecos = ex/ed; esin = ey/ed # Adjust the tip of the arrow {(ax,ay)}: gr = dr + 0.5*shaft_width ax = dx - ecos * gr; ay = dy - esin * gr dbg(f"tip of arrow = {pc(ax,ay)}") cmds.append(f"-draw 'line {pc(tx,ty)} {pc(ax,ay)}'" ) # Compute the neck of the arrow {(px,py)}:: px = ax - ecos * head_length; py = ay - esin * head_length dbg(f"neck of arrow = {pc(px,py)}") # The head is filled by {2*nh+1} lines from tip to {(px,py)+k*(hx,hy)}: head_width = head_length/2 nh = ceil(1.1*head_width/shaft_width/2) dh = head_width/(2*nh); hx = - esin * dh; hy = + ecos * dh # If the head color is 'none', use the same as the line color: if head_color == 'none': head_color = shaft_color cmds.append(f"-stroke '{head_color}'" ) for k in range(-nh,+nh+1): ux = px + k*hx; uy = py + k*hy; if k != 0 or head_color != shaft_color: cmds.append(f"-draw 'line {pc(ax,ay)} {pc(ux,uy)}'" ) # Close off the head: cmds.append(f"-draw 'line {pc(px-2*hx,py-2*hy)} {pc(px+2*hx,py+2*hy)}'" ) return # ---------------------------------------------------------------------- def draw_drumstick(cmds, tx,ty, dx,dy,dr, shaft_width, shaft_color, head_color): # The line must stop at the periphery of the dot or circle: ex = tx - dx; ey = ty - dy ed = hypot(ex, ey); ecos = ex/ed; esin = ey/ed px = dx + ecos*dr; py = dy + esin*dr dbg(f"tip of line = {pc(px,py)}") pxr = dx + dr dbg(f"disk edge X = {pxr:.1f}") cmds.append(f"-strokewidth '{shaft_width}'" ) cmds.append(f"-stroke '{shaft_color}' -fill '{head_color}'" ) cmds.append(f"-draw 'line {pc(tx,ty)} {pc(px,py)}'" ) cmds.append(f"-draw 'circle {pc(dx,dy)} {pc(pxr,dy)}'" ) return # ---------------------------------------------------------------------- def pc(x,y): # Formats the point {(x,y)} as expected by "-draw" commands. return f"{x:.1f},{y:.1f}" # ---------------------------------------------------------------------- def get_image_size(file_name): # Obtains the size of the image in file "{file_name}". # Returns two floats, {sx} and {sy}. res = bash("identify " + file_name); m = re.search(r"[ ]([0-9]+)[x]([0-9]+)[ ]", res) if m == None: dbg(f"res = '{res}'") cmd_error("failed to get size of '{file_name}'") sx = None; sy = None # To keep the parser happy. else: sx = int(m.group(1)) sy = int(m.group(2)) return sx, sy # ---------------------------------------------------------------------- def bash(cmd): # Execute the string {cmd} with "/bin/bash". result = subprocess.run \ ( [ cmd ], shell = True, stdout = subprocess.PIPE, stderr = subprocess.STDOUT, executable = "/bin/bash", ) if result.returncode != 0: print(result.stderr) print(result.stdout) assert False, f"** {cmd} failed - returned status = {result}" else: return str(result.stderr) + "\n" + str(result.stdout) + "\n" # ---------------------------------------------------------------------- def cmd_error(msg): err("** "); err(msg); err("\n") assert False, "aborted" # ---------------------------------------------------------------------- def dbg(msg): global debug if debug: err(msg); err("\n") return # ---------------------------------------------------------------------- def out(str): sys.stdout.write(str) return # ...................................................................... def err(str): sys.stderr.write(str) return # ...................................................................... main()