#! /usr/bin/python3 # Last edited on 2025-10-04 20:42:33 by stolfi import sys, re, subprocess from math import sqrt, hypot, sin, cos, pi 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. -textcolor {COLORSPEC} : set text color. -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). -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" or "-fillcolor" to suppress the outline or the filling in the commands "-Q", "-P", "-C", "-LA", "-LC", "-LD". Negative coordinates 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. 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 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(): # Interprets negative coords as elative to opposite edge of image. nonlocal nargs, iarg global image_size ax = next_float_arg(); if ax < 0: ax = image_size[0] + ax; ay = next_float_arg(); 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 if hue < 1/3: ang = 1.5*hue*pi R = cos(ang); G = sin(ang); B = 0 elif hue < 2/3: ang = (1.5*hue - 0.5)*pi R = 0; G = cos(ang); B = sin(ang) else: ang = (1.5*hue - 1.0)*pi R = sin(ang); G = 0; B = cos(ang) # 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"{hue=} {R=} {G=} {B=} {Y=}\n") 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"{hue=} {R=} {G=} {B=} {Y=}\n") assert abs(Y - val) < 0.001, "bug" # R = R**0.45; G = G**0.45; B = B**0.45 return f"rgb({R*255:.0},{G*255:.0},{B*255:.0})" # .................................................................... # Defaults: text_size = 20.0 # Text pointsize text_color = 'rgb(100%,080%,020%)' # Text color stroke_color = 'rgb(020%,080%,100%)' # Line color fill_color = 'rgb(020%,080%,100%)' # Fill color unit_width, text_margin = set_font_size(text_size) line_width = unit_width dot_rad, conn_rad = set_line_width(line_width) while iarg < nargs: opt = next_arg() dbg(f"opt = '{opt}'") 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: outcmd(f"\\{opt}") 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 color for subsequent ops: stroke_color = next_rgb_arg() elif opt == "-fillcolor": # Change stroke and dot color for subsequent ops: fill_color = next_rgb_arg() elif opt == "-hue": # Change stroke, text, and fill color for subsequent ops: hue = next_float_arg() stroke_color = color_from_hue(hue, 0.2) text_color = stroke_color fill_color = color_from_hue(hue, 0.8) elif opt == "-color": # Change stroke and fill (not text) color for subsequent ops: stroke_color = next_rgb_arg() fill_color = stroke_color elif opt == "-textcolor": # Change text color for subsequent ops: text_color = next_rgb_arg() elif opt == "-textsize": # Change text size for subsequent ops: # Save current relative line width. rwid = line_width / unit_width; # Set {text_size}, compute new {unit_width}, {text_margin}: text_size = next_float_arg() unit_width, text_margin = set_font_size(text_size) # Reset the line width to maintain relative width to text size: line_width = rwid * unit_width dot_rad, conn_rad = set_line_width(line_width) elif opt == "-strokewidth": # Change stroke width for subsequent ops: rwid = next_float_arg() line_width = rwid * unit_width dot_rad, conn_rad = set_line_width(line_width) elif opt == "-C": # Circle: ax, ay = next_point_arg(); rd = next_float_arg() bx = ax + rd; by = ay; outcmd(f"-stroke '{stroke_color}' -fill '{fill_color}'" ) outcmd(f"-draw 'circle {pc(ax,ay)} {pc(bx,by)}'" ) 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 outcmd(f"-stroke '{stroke_color}' -fill '{fill_color}'" ) outcmd(f"-draw 'polygon {pc(ax,ay)} {pc(bx,by)} {pc(cx,cy)} {pc(dx,dy)}'" ) 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(); outcmd(f"-stroke '{stroke_color}' -fill '{fill_color}'" ) outcmd(f"-draw 'polygon {pc(ax,ay)} {pc(bx,by)} {pc(cx,cy)} {pc(dx,dy)}'" ) elif opt == "-S" or opt == "-D": # Open polygonal line with round joints ("-S") or knobs ("-D"): outcmd(f"-fill '{stroke_color}'") # Sic - knobs are same color as lines. rpt = ( dot_rad if opt == "-D" else conn_rad ) ax, ay = next_point_arg(); dbg(f"start = ({pc(ax,ay)})") if rpt != 0: outcmd(f"-stroke none") outcmd(f"-draw 'circle {pc(ax,ay)} {pc(ax+rpt,ay)}'") while next_arg_is_num(): bx, by = next_point_arg(); dbg(f"line to = ({pc(bx,by)})") outcmd(f"-stroke '{stroke_color}'" ) outcmd(f"-draw 'line {ax},{ay} {bx},{by}'" ) if rpt != 0: outcmd(f"-stroke none" ) outcmd(f"-draw 'circle {pc(bx,by)} {pc(bx+rpt,by)}'" ) ax = bx; ay = by elif opt[1] == "L": # 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() 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() draw_labeled_pointer \ ( opt[2], dx, dy, dr, line_width, stroke_color, stroke_color, tx, ty, text_size, fill_color, text_color, text_margin, text ) elif opt == "-T": # Text annotation: dx = next_float_arg(); dy = next_float_arg() ax = next_float_arg(); ay = next_float_arg() 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) outcmd(f"-stroke none -fill '{text_color}'" ) outcmd(f"-draw 'text {pc(tx,ty)} \"{text}\"'" ) elif opt == "-I": # Image insertion: -I {XR} {YR} {XA} {YA} {IMG} {MAG} rx = next_float_arg(); ry = next_float_arg() ax = next_float_arg(); ay = next_float_arg() img = next_arg() mag = next_float_arg() # Image size: 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 geom = ("%+d%+d" % (ofx, ofy)) pct = mag * 100 dbg(f"pct = '{pct}' geom = '{geom}'") outcmd(f"\\( \\( {img} -resize '{pct}%' \\) -geometry {geom} \\) -composite" ) else: cmd_error(f"invalid command '{opt}'") return # ---------------------------------------------------------------------- def outcmd(cmd): out(" "); out(cmd); out(" \\\n") return # ---------------------------------------------------------------------- def set_line_width(line_width): # Sets the line width for succeeding commands. # Also returns the corresponding {dot_rad} and {conn_rad}. dbg(f"setting line width = {line_width:.3f}") # Dot radius (assuming not stroked): dot_rad = 2.0 * line_width dbg(f"bare dot radius = {dot_rad:.3f}") # 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) dbg(f"connecting dot radius = {conn_rad:.3f}") outcmd(f"-strokewidth {line_width:.3f}") return dot_rad, conn_rad # ---------------------------------------------------------------------- def set_font_size(text_size): # Sets the font size for succeeding commands. Also # returns the {unit_width,text_margin} for this font size. outcmd(f"-font 'Courier-Bold' -pointsize {text_size:.1f}") unit_width = text_size * 0.042 # Unit for the "-strokewidth" command. text_margin = text_size * 0.28 + 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_labeled_pointer \ ( kind, dx, dy, dr, line_width, line_color, head_color, tx, ty, text_size, back_color, text_color, text_margin, text \ ): # Used by "-LA", "+LA", "-LD", "+LD", "-LC", "+LC" # {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. # {line_width} Width of line. # {line_color} Color of line. # {head_color} Color of arrow or drumstick head. # {tx,ty} Coords of text center. # {text_size} Text size. # {back_color} Color of text background. # {text_color} Color of text. # {text} Text of the label. # Uncomment this when we figure out how to do it: # outcmd(f"\\( -insert 0 -print \"%[pixel:p{{{dx},{dy}}}]\\n\" null:- \\)") if kind == "A": draw_arrow(tx,ty, dx,dy,dr, line_width, line_color, head_color) elif kind == "D": draw_drumstick(tx,ty, dx,dy,dr, line_width, line_color, head_color) elif kind == "C": draw_drumstick(tx,ty, dx,dy,dr, line_width, line_color, 'none') else: cmd_error(f"invalid labelling command '±L{kind}'") draw_label \ ( tx,ty, line_width, line_color, text_size, back_color, text_color, text_margin, text ) return # ---------------------------------------------------------------------- def draw_label(tx,ty, line_width, line_color, text_size, back_color, text_color, text_margin, text): # Draws a label within oval at {(tx,ty)} filled with {fill_color}. # 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. outcmd(f"-stroke '{line_color}' -fill '{back_color}'" ) outcmd(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: outcmd(f"-stroke none -fill '{back_color}'" ) outcmd(f"-draw 'circle {pc(txmin,ty)} {pc(sxmin,ty)}'" ) outcmd(f"-draw 'circle {pc(txmax,ty)} {pc(sxmax,ty)}'" ) outcmd(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. outcmd(f"-stroke '{line_color}' -fill none" ) outcmd(f"-draw 'arc {pc(sxmin,symin)} {pc(bxmin,symax)} +90,+270'" ) outcmd(f"-draw 'line {pc(txmin,symax)} {pc(txmax,symax)}'" ) outcmd(f"-draw 'arc {pc(bxmax,symin)} {pc(sxmax,symax)} -90,+90'" ) outcmd(f"-draw 'line {pc(txmax,symin)} {pc(txmin,symin)}'" ) outcmd(f"-stroke none -fill '{text_color}'" ) outcmd(f"-draw 'text {pc(ux,uy)} \"{text}\"'" ) return # ---------------------------------------------------------------------- def draw_arrow(tx,ty, dx,dy,dr, line_width, line_color, head_color): # Draws an arrow from {(tx,ty) to {(dx,dy)}. The # arrowhead size is proportional to the line width. outcmd(f"-strokewidth '{line_width}'" ) outcmd(f"-stroke '{line_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*line_width ax = dx - ecos * gr; ay = dy - esin * gr dbg(f"tip of arrow = {pc(ax,ay)}") outcmd(f"-draw 'line {pc(tx,ty)} {pc(ax,ay)}'" ) # Compute the neck of the arrow {(px,py)}:: hr = 6 * line_width # Head length. px = ax - ecos * hr; py = ay - esin * hr dbg(f"neck of arrow = {pc(px,py)}") # The head is filled by lines from tip to {(px,py)+k*(hx,hy)}: hx = - esin * line_width; hy = + ecos * line_width # If the head color is 'none', use the same as the line color: if head_color == 'none': head_color = line_color outcmd(f"-stroke '{head_color}'" ) for k in range(-2,+3): ux = px + k*hx; uy = py + k*hy; if k != 0 or head_color != line_color: outcmd(f"-draw 'line {pc(ax,ay)} {pc(ux,uy)}'" ) # Close off the head: outcmd(f"-draw 'line {pc(px-2*hx,py-2*hy)} {pc(px+2*hx,py+2*hy)}'" ) return # ---------------------------------------------------------------------- def draw_drumstick(tx,ty, dx,dy,dr, line_width, line_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}") outcmd(f"-strokewidth '{line_width}'" ) outcmd(f"-stroke '{line_color}' -fill '{head_color}'" ) outcmd(f"-draw 'line {pc(tx,ty)} {pc(px,py)}'" ) outcmd(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()