#! /usr/bin/env python # Read pair-wise alignments in MAF or LAST tabular format: write an # "Oxford grid", a.k.a. dotplot. # TODO: Currently, pixels with zero aligned nt-pairs are white, and # pixels with one or more aligned nt-pairs are black. This can look # too crowded for large genome alignments. I tried shading each pixel # according to the number of aligned nt-pairs within it, but the # result is too faint. How can this be done better? import gzip from fnmatch import fnmatchcase import itertools, optparse, os, re, sys # Try to make PIL/PILLOW work: try: from PIL import Image, ImageDraw, ImageFont, ImageColor except ImportError: import Image, ImageDraw, ImageFont, ImageColor def myOpen(fileName): # faster than fileinput if fileName == "-": return sys.stdin if fileName.endswith(".gz"): return gzip.open(fileName) return open(fileName) def warn(message): if opts.verbose: prog = os.path.basename(sys.argv[0]) sys.stderr.write(prog + ": " + message + "\n") def croppedBlocks(blocks, range1, range2): cropBeg1, cropEnd1 = range1 cropBeg2, cropEnd2 = range2 if blocks[0][0] < 0: cropBeg1, cropEnd1 = -cropEnd1, -cropBeg1 if blocks[0][1] < 0: cropBeg2, cropEnd2 = -cropEnd2, -cropBeg2 for beg1, beg2, size in blocks: b1 = max(cropBeg1, beg1) e1 = min(cropEnd1, beg1 + size) if b1 >= e1: continue offset = beg2 - beg1 b2 = max(cropBeg2, b1 + offset) e2 = min(cropEnd2, e1 + offset) if b2 >= e2: continue yield b2 - offset, b2, e2 - b2 def tabBlocks(beg1, beg2, blocks): '''Get the gapless blocks of an alignment, from LAST tabular format.''' for i in blocks.split(","): if ":" in i: x, y = i.split(":") beg1 += int(x) beg2 += int(y) else: size = int(i) yield beg1, beg2, size beg1 += size beg2 += size def mafBlocks(beg1, beg2, seq1, seq2): '''Get the gapless blocks of an alignment, from MAF format.''' size = 0 for x, y in itertools.izip(seq1, seq2): if x == "-": if size: yield beg1, beg2, size beg1 += size beg2 += size size = 0 beg2 += 1 elif y == "-": if size: yield beg1, beg2, size beg1 += size beg2 += size size = 0 beg1 += 1 else: size += 1 if size: yield beg1, beg2, size def alignmentInput(lines): '''Get alignments and sequence lengths, from MAF or tabular format.''' mafCount = 0 for line in lines: w = line.split() if line[0].isdigit(): # tabular format chr1, beg1, seqlen1 = w[1], int(w[2]), int(w[5]) if w[4] == "-": beg1 -= seqlen1 chr2, beg2, seqlen2 = w[6], int(w[7]), int(w[10]) if w[9] == "-": beg2 -= seqlen2 blocks = tabBlocks(beg1, beg2, w[11]) yield chr1, seqlen1, chr2, seqlen2, blocks elif line[0] == "s": # MAF format if mafCount == 0: chr1, beg1, seqlen1, seq1 = w[1], int(w[2]), int(w[5]), w[6] if w[4] == "-": beg1 -= seqlen1 mafCount = 1 else: chr2, beg2, seqlen2, seq2 = w[1], int(w[2]), int(w[5]), w[6] if w[4] == "-": beg2 -= seqlen2 blocks = mafBlocks(beg1, beg2, seq1, seq2) yield chr1, seqlen1, chr2, seqlen2, blocks mafCount = 0 def seqRequestFromText(text): if ":" in text: pattern, interval = text.rsplit(":", 1) if "-" in interval: beg, end = interval.rsplit("-", 1) return pattern, int(beg), int(end) # beg may be negative return text, 0, sys.maxsize def rangeFromSeqName(seqRequests, name, seqLen): if not seqRequests: return 0, seqLen base = name.split(".")[-1] # allow for names like hg19.chr7 for pat, beg, end in seqRequests: if fnmatchcase(name, pat) or fnmatchcase(base, pat): return max(beg, 0), min(end, seqLen) return None def updateSeqs(isTrim, seqNames, seqLimits, seqName, seqRange, blocks, index): if seqName not in seqLimits: seqNames.append(seqName) if isTrim: beg = blocks[0][index] end = blocks[-1][index] + blocks[-1][2] if beg < 0: beg, end = -end, -beg if seqName in seqLimits: b, e = seqLimits[seqName] seqLimits[seqName] = min(b, beg), max(e, end) else: seqLimits[seqName] = beg, end else: seqLimits[seqName] = seqRange def readAlignments(fileName, opts): '''Get alignments and sequence limits, from MAF or tabular format.''' seqRequests1 = map(seqRequestFromText, opts.seq1) seqRequests2 = map(seqRequestFromText, opts.seq2) alignments = [] seqNames1 = [] seqNames2 = [] seqLimits1 = {} seqLimits2 = {} lines = myOpen(fileName) for seqName1, seqLen1, seqName2, seqLen2, blocks in alignmentInput(lines): range1 = rangeFromSeqName(seqRequests1, seqName1, seqLen1) if not range1: continue range2 = rangeFromSeqName(seqRequests2, seqName2, seqLen2) if not range2: continue b = list(croppedBlocks(list(blocks), range1, range2)) if not b: continue aln = seqName1, seqName2, b alignments.append(aln) updateSeqs(opts.trim1, seqNames1, seqLimits1, seqName1, range1, b, 0) updateSeqs(opts.trim2, seqNames2, seqLimits2, seqName2, range2, b, 1) return alignments, seqNames1, seqNames2, seqLimits1, seqLimits2 def natural_sort_key(my_string): '''Return a sort key for "natural" ordering, e.g. chr9 < chr10.''' parts = re.split(r'(\d+)', my_string) parts[1::2] = map(int, parts[1::2]) return parts def prettyNum(n): t = str(n) groups = [] while t: groups.append(t[-3:]) t = t[:-3] return " ".join(reversed(groups)) def sizeText(size): suffixes = "bp", "kb", "Mb", "Gb" for i, x in enumerate(suffixes): j = 10 ** (i * 3) if size < j * 10: return "%.2g" % (1.0 * size / j) + x if size < j * 1000 or i == len(suffixes) - 1: return "%.0f" % (1.0 * size / j) + x def labelText(seqRange, labelOpt): seqName, beg, end = seqRange if labelOpt == 1: return seqName + ": " + sizeText(end - beg) if labelOpt == 2: return seqName + ": " + prettyNum(beg) + ": " + sizeText(end - beg) if labelOpt == 3: return seqName + ": " + prettyNum(beg) + " - " + prettyNum(end) return seqName def rangeLabels(seqRanges, labelOpt, font, fontsize, image_mode, textRot): if fontsize: image_size = 1, 1 im = Image.new(image_mode, image_size) draw = ImageDraw.Draw(im) x = y = 0 for r in seqRanges: text = labelText(r, labelOpt) if fontsize: x, y = draw.textsize(text, font=font) if textRot: x, y = y, x yield text, x, y def getSeqInfo(sortOpt, seqNames, seqLimits, font, fontsize, image_mode, labelOpt, textRot): '''Return miscellaneous information about the sequences.''' if sortOpt == 1: seqNames.sort(key=natural_sort_key) seqSizes = [seqLimits[i][1] - seqLimits[i][0] for i in seqNames] for i in seqNames: r = seqLimits[i] out = i, str(r[0]), str(r[1]) warn("\t".join(out)) warn("") if sortOpt == 2: seqRecords = sorted(zip(seqSizes, seqNames), reverse=True) seqSizes = [i[0] for i in seqRecords] seqNames = [i[1] for i in seqRecords] seqRanges = [(i, seqLimits[i][0], seqLimits[i][1]) for i in seqNames] labelData = list(rangeLabels(seqRanges, labelOpt, font, fontsize, image_mode, textRot)) margin = max(i[2] for i in labelData) # xxx the margin may be too big, because some labels may get omitted return seqNames, seqSizes, labelData, margin def div_ceil(x, y): '''Return x / y rounded up.''' q, r = divmod(x, y) return q + (r != 0) def get_bp_per_pix(rangeSizes, pixTweenRanges, maxPixels): '''Get the minimum bp-per-pixel that fits in the size limit.''' numOfRanges = len(rangeSizes) maxPixelsInRanges = maxPixels - pixTweenRanges * (numOfRanges - 1) if maxPixelsInRanges < numOfRanges: raise Exception("can't fit the image: too many sequences?") negLimit = -maxPixelsInRanges negBpPerPix = sum(rangeSizes) // negLimit while True: if sum(i // negBpPerPix for i in rangeSizes) >= negLimit: return -negBpPerPix negBpPerPix -= 1 def get_seq_starts(seq_pix, pixTweenRanges, margin): '''Get the start pixel for each sequence.''' seq_starts = [] pix_tot = margin - pixTweenRanges for i in seq_pix: pix_tot += pixTweenRanges seq_starts.append(pix_tot) pix_tot += i return seq_starts def pixelData(rangeSizes, bp_per_pix, pixTweenRanges, margin): '''Return pixel information about the sequences.''' seq_pix = [div_ceil(i, bp_per_pix) for i in rangeSizes] seq_starts = get_seq_starts(seq_pix, pixTweenRanges, margin) tot_pix = seq_starts[-1] + seq_pix[-1] return seq_pix, seq_starts, tot_pix def drawLineForward(hits, width, bp_per_pix, beg1, beg2, size): while True: q1, r1 = divmod(beg1, bp_per_pix) q2, r2 = divmod(beg2, bp_per_pix) hits[q2 * width + q1] |= 1 next_pix = min(bp_per_pix - r1, bp_per_pix - r2) if next_pix >= size: break beg1 += next_pix beg2 += next_pix size -= next_pix def drawLineReverse(hits, width, bp_per_pix, beg1, beg2, size): beg2 = -1 - beg2 while True: q1, r1 = divmod(beg1, bp_per_pix) q2, r2 = divmod(beg2, bp_per_pix) hits[q2 * width + q1] |= 2 next_pix = min(bp_per_pix - r1, r2 + 1) if next_pix >= size: break beg1 += next_pix beg2 -= next_pix size -= next_pix def alignmentPixels(width, height, alignments, bp_per_pix, origins1, origins2): hits = [0] * (width * height) # the image data for seq1, seq2, blocks in alignments: ori1 = origins1[seq1] ori2 = origins2[seq2] for beg1, beg2, size in blocks: if beg1 < 0: beg1 = -(beg1 + size) beg2 = -(beg2 + size) if beg2 >= 0: drawLineForward(hits, width, bp_per_pix, beg1 + ori1, beg2 + ori2, size) else: drawLineReverse(hits, width, bp_per_pix, beg1 + ori1, beg2 - ori2, size) return hits def expandedSeqDict(seqDict): '''Allow lookup by short sequence names, e.g. chr7 as well as hg19.chr7.''' newDict = seqDict.copy() for name, x in seqDict.items(): if "." in name: base = name.split(".")[-1] if base in newDict: # an ambiguous case was found: return seqDict # so give up completely newDict[base] = x return newDict def readBed(fileName, seqLimits): if not fileName: return for line in myOpen(fileName): w = line.split() if not w: continue seqName = w[0] if seqName not in seqLimits: continue beg = int(w[1]) end = int(w[2]) layer = 900 color = "#fbf" if len(w) > 4: if w[4] != ".": layer = float(w[4]) if len(w) > 5: if len(w) > 8 and w[8].count(",") == 2: color = "rgb(" + w[8] + ")" elif w[5] == "+": color = "#ffe8e8" elif w[5] == "-": color = "#e8e8ff" yield layer, color, seqName, beg, end def commaSeparatedInts(text): return map(int, text.rstrip(",").split(",")) def readGenePred(opts, fileName, seqLimits): if not fileName: return for line in myOpen(fileName): fields = line.split() if not fields: continue if fields[2] not in "+-": fields = fields[1:] seqName = fields[1] if seqName not in seqLimits: continue #strand = fields[2] cdsBeg = int(fields[5]) cdsEnd = int(fields[6]) exonBegs = commaSeparatedInts(fields[8]) exonEnds = commaSeparatedInts(fields[9]) for beg, end in zip(exonBegs, exonEnds): yield 300, opts.exon_color, seqName, beg, end b = max(beg, cdsBeg) e = min(end, cdsEnd) if b < e: yield 400, opts.cds_color, seqName, b, e def readRmsk(fileName, seqLimits): if not fileName: return for line in myOpen(fileName): fields = line.split() if len(fields) == 17: # rmsk.txt seqName = fields[5] if seqName not in seqLimits: continue # do this ASAP for speed beg = int(fields[6]) end = int(fields[7]) strand = fields[9] repeatClass = fields[11] elif len(fields) == 15: # .out seqName = fields[4] if seqName not in seqLimits: continue beg = int(fields[5]) - 1 end = int(fields[6]) strand = fields[8] repeatClass = fields[10] else: continue if repeatClass in ("Low_complexity", "Simple_repeat"): yield 200, "#fbf", seqName, beg, end elif strand == "+": yield 100, "#ffe8e8", seqName, beg, end else: yield 100, "#e8e8ff", seqName, beg, end def isExtraFirstGapField(fields): return fields[4].isdigit() def readGaps(opts, fileName, seqLimits): '''Read locations of unsequenced gaps, from an agp or gap file.''' if not fileName: return for line in myOpen(fileName): w = line.split() if not w or w[0][0] == "#": continue if isExtraFirstGapField(w): w = w[1:] if w[4] not in "NU": continue seqName = w[0] if seqName not in seqLimits: continue end = int(w[2]) beg = end - int(w[5]) # zero-based coordinate if w[7] == "yes": yield 3000, opts.bridged_color, seqName, beg, end else: yield 2000, opts.unbridged_color, seqName, beg, end def bedBoxes(beds, seqLimits, origins, margin, edge, isTop, bpPerPix): for layer, color, seqName, bedBeg, bedEnd in beds: cropBeg, cropEnd = seqLimits[seqName] beg = max(bedBeg, cropBeg) end = min(bedEnd, cropEnd) if beg >= end: continue ori = origins[seqName] if layer <= 1000: # include partly-covered pixels b = (ori + beg) // bpPerPix e = div_ceil(ori + end, bpPerPix) else: # exclude partly-covered pixels b = div_ceil(ori + beg, bpPerPix) e = (ori + end) // bpPerPix if e <= b: continue if isTop: box = b, margin, e, edge else: box = margin, b, edge, e yield layer, color, box def drawAnnotations(im, boxes): # xxx use partial transparency for different-color overlaps? for layer, color, box in boxes: im.paste(color, box) def make_label(labelData, range_start, range_size): '''Return an axis label with endpoint & sort-order information.''' text, text_width, text_height = labelData label_start = range_start + (range_size - text_width) // 2 label_end = label_start + text_width sort_key = text_width - range_size return sort_key, label_start, label_end, text, text_height def nonoverlappingLabels(labels, minPixTweenLabels): '''Get a subset of non-overlapping axis labels, greedily.''' out = [] for i in labels: beg = i[1] - minPixTweenLabels end = i[2] + minPixTweenLabels if all(j[2] <= beg or j[1] >= end for j in out): out.append(i) return out def axisImage(labelData, seq_starts, seq_pix, textRot, textAln, font, image_mode, opts): '''Make an image of axis labels.''' beg = seq_starts[0] end = seq_starts[-1] + seq_pix[-1] margin = max(i[2] for i in labelData) labels = map(make_label, labelData, seq_starts, seq_pix) labels = [i for i in labels if i[1] >= beg and i[2] <= end] labels.sort() minPixTweenLabels = 0 if textRot else opts.label_space labels = nonoverlappingLabels(labels, minPixTweenLabels) image_size = (margin, end) if textRot else (end, margin) im = Image.new(image_mode, image_size, opts.margin_color) draw = ImageDraw.Draw(im) for i in labels: base = margin - i[4] if textAln else 0 position = (base, i[1]) if textRot else (i[1], base) draw.text(position, i[3], font=font, fill=opts.text_color) return im def seqOrigins(seqNames, seq_starts, seqLimits, bp_per_pix): for i, j in zip(seqNames, seq_starts): yield i, bp_per_pix * j - seqLimits[i][0] def lastDotplot(opts, args): if opts.fontfile: font = ImageFont.truetype(opts.fontfile, opts.fontsize) else: font = ImageFont.load_default() image_mode = 'RGB' forward_color = ImageColor.getcolor(opts.forwardcolor, image_mode) reverse_color = ImageColor.getcolor(opts.reversecolor, image_mode) zipped_colors = zip(forward_color, reverse_color) overlap_color = tuple([(i + j) // 2 for i, j in zipped_colors]) warn("reading alignments...") alignmentInfo = readAlignments(args[0], opts) alignments, seqNames1, seqNames2, seqLimits1, seqLimits2 = alignmentInfo warn("done") if not alignments: raise Exception("there are no alignments") textRot1 = "vertical".startswith(opts.rot1) i1 = getSeqInfo(opts.sort1, seqNames1, seqLimits1, font, opts.fontsize, image_mode, opts.labels1, textRot1) seqNames1, rangeSizes1, labelData1, tMargin = i1 textRot2 = "horizontal".startswith(opts.rot2) i2 = getSeqInfo(opts.sort2, seqNames2, seqLimits2, font, opts.fontsize, image_mode, opts.labels2, textRot2) seqNames2, rangeSizes2, labelData2, lMargin = i2 warn("choosing bp per pixel...") maxPixels1 = opts.width - lMargin maxPixels2 = opts.height - tMargin bpPerPix1 = get_bp_per_pix(rangeSizes1, opts.border_pixels, maxPixels1) bpPerPix2 = get_bp_per_pix(rangeSizes2, opts.border_pixels, maxPixels2) bpPerPix = max(bpPerPix1, bpPerPix2) warn("bp per pixel = " + str(bpPerPix)) seq_pix1, seq_starts1, width = pixelData(rangeSizes1, bpPerPix, opts.border_pixels, lMargin) seq_pix2, seq_starts2, height = pixelData(rangeSizes2, bpPerPix, opts.border_pixels, tMargin) warn("width: " + str(width)) warn("height: " + str(height)) origins1 = dict(seqOrigins(seqNames1, seq_starts1, seqLimits1, bpPerPix)) origins2 = dict(seqOrigins(seqNames2, seq_starts2, seqLimits2, bpPerPix)) warn("processing alignments...") hits = alignmentPixels(width, height, alignments, bpPerPix, origins1, origins2) warn("done") image_size = width, height im = Image.new(image_mode, image_size, opts.background_color) seqLimits1 = expandedSeqDict(seqLimits1) seqLimits2 = expandedSeqDict(seqLimits2) origins1 = expandedSeqDict(origins1) origins2 = expandedSeqDict(origins2) beds1 = itertools.chain(readBed(opts.bed1, seqLimits1), readRmsk(opts.rmsk1, seqLimits1), readGenePred(opts, opts.genePred1, seqLimits1), readGaps(opts, opts.gap1, seqLimits1)) b1 = bedBoxes(beds1, seqLimits1, origins1, tMargin, height, True, bpPerPix) beds2 = itertools.chain(readBed(opts.bed2, seqLimits2), readRmsk(opts.rmsk2, seqLimits2), readGenePred(opts, opts.genePred2, seqLimits2), readGaps(opts, opts.gap2, seqLimits2)) b2 = bedBoxes(beds2, seqLimits2, origins2, lMargin, width, False, bpPerPix) boxes = sorted(itertools.chain(b1, b2)) drawAnnotations(im, boxes) for i in range(height): for j in range(width): store_value = hits[i * width + j] xy = j, i if store_value == 1: im.putpixel(xy, forward_color) elif store_value == 2: im.putpixel(xy, reverse_color) elif store_value == 3: im.putpixel(xy, overlap_color) if opts.fontsize != 0: axis1 = axisImage(labelData1, seq_starts1, seq_pix1, textRot1, False, font, image_mode, opts) if textRot1: axis1 = axis1.transpose(Image.ROTATE_90) axis2 = axisImage(labelData2, seq_starts2, seq_pix2, textRot2, textRot2, font, image_mode, opts) if not textRot2: axis2 = axis2.transpose(Image.ROTATE_270) im.paste(axis1, (0, 0)) im.paste(axis2, (0, 0)) for i in seq_starts1[1:]: box = i - opts.border_pixels, tMargin, i, height im.paste(opts.border_color, box) for i in seq_starts2[1:]: box = lMargin, i - opts.border_pixels, width, i im.paste(opts.border_color, box) im.save(args[1]) if __name__ == "__main__": usage = """%prog --help or: %prog [options] maf-or-tab-alignments dotplot.png or: %prog [options] maf-or-tab-alignments dotplot.gif or: ...""" description = "Draw a dotplot of pair-wise sequence alignments in MAF or tabular format." op = optparse.OptionParser(usage=usage, description=description) op.add_option("-v", "--verbose", action="count", help="show progress messages & data about the plot") op.add_option("-1", "--seq1", metavar="PATTERN", action="append", default=[], help="which sequences to show from the 1st genome") op.add_option("-2", "--seq2", metavar="PATTERN", action="append", default=[], help="which sequences to show from the 2nd genome") # Replace "width" & "height" with a single "length" option? op.add_option("-x", "--width", type="int", default=1000, help="maximum width in pixels (default: %default)") op.add_option("-y", "--height", type="int", default=1000, help="maximum height in pixels (default: %default)") op.add_option("-c", "--forwardcolor", metavar="COLOR", default="red", help="color for forward alignments (default: %default)") op.add_option("-r", "--reversecolor", metavar="COLOR", default="blue", help="color for reverse alignments (default: %default)") op.add_option("--sort1", type="int", default=1, metavar="N", help="genome1 sequence order: 0=input order, 1=name order, " "2=length order (default=%default)") op.add_option("--sort2", type="int", default=1, metavar="N", help="genome2 sequence order: 0=input order, 1=name order, " "2=length order (default=%default)") op.add_option("--trim1", action="store_true", help="trim unaligned sequence flanks from the 1st genome") op.add_option("--trim2", action="store_true", help="trim unaligned sequence flanks from the 2nd genome") op.add_option("--border-pixels", metavar="INT", type="int", default=1, help="number of pixels between sequences (default=%default)") op.add_option("--border-color", metavar="COLOR", default="black", help="color for pixels between sequences (default=%default)") op.add_option("--margin-color", metavar="COLOR", default="#dcdcdc", help="margin color") og = optparse.OptionGroup(op, "Text options") og.add_option("-f", "--fontfile", metavar="FILE", help="TrueType or OpenType font file") og.add_option("-s", "--fontsize", metavar="SIZE", type="int", default=11, help="TrueType or OpenType font size (default: %default)") og.add_option("--labels1", type="int", default=0, metavar="N", help= "genome1 labels: 0=name, 1=name:length, " "2=name:start:length, 3=name:start-end (default=%default)") og.add_option("--labels2", type="int", default=0, metavar="N", help= "genome2 labels: 0=name, 1=name:length, " "2=name:start:length, 3=name:start-end (default=%default)") og.add_option("--rot1", metavar="ROT", default="h", help="text rotation for the 1st genome (default=%default)") og.add_option("--rot2", metavar="ROT", default="v", help="text rotation for the 2nd genome (default=%default)") op.add_option_group(og) og = optparse.OptionGroup(op, "Annotation options") og.add_option("--bed1", metavar="FILE", help="read genome1 annotations from BED file") og.add_option("--bed2", metavar="FILE", help="read genome2 annotations from BED file") og.add_option("--rmsk1", metavar="FILE", help="read genome1 repeats from " "RepeatMasker .out or rmsk.txt file") og.add_option("--rmsk2", metavar="FILE", help="read genome2 repeats from " "RepeatMasker .out or rmsk.txt file") op.add_option_group(og) og = optparse.OptionGroup(op, "Gene options") og.add_option("--genePred1", metavar="FILE", help="read genome1 genes from genePred file") og.add_option("--genePred2", metavar="FILE", help="read genome2 genes from genePred file") og.add_option("--exon-color", metavar="COLOR", default="PaleGreen", help="color for exons (default=%default)") og.add_option("--cds-color", metavar="COLOR", default="LimeGreen", help="color for protein-coding regions (default=%default)") op.add_option_group(og) og = optparse.OptionGroup(op, "Unsequenced gap options") og.add_option("--gap1", metavar="FILE", help="read genome1 unsequenced gaps from agp or gap file") og.add_option("--gap2", metavar="FILE", help="read genome2 unsequenced gaps from agp or gap file") og.add_option("--bridged-color", metavar="COLOR", default="yellow", help="color for bridged gaps (default: %default)") og.add_option("--unbridged-color", metavar="COLOR", default="orange", help="color for unbridged gaps (default: %default)") op.add_option_group(og) (opts, args) = op.parse_args() if len(args) != 2: op.error("2 arguments needed") opts.text_color = "black" opts.background_color = "white" opts.label_space = 5 # minimum number of pixels between axis labels try: lastDotplot(opts, args) except KeyboardInterrupt: pass # avoid silly error message except Exception, e: prog = os.path.basename(sys.argv[0]) sys.exit(prog + ": error: " + str(e))