Spaces:
No application file
No application file
# Copyright 2013 Leighton Pritchard. All rights reserved. | |
# | |
# This file is part of the Biopython distribution and governed by your | |
# choice of the "Biopython License Agreement" or the "BSD 3-Clause License". | |
# Please see the LICENSE file that should have been included as part of this | |
# package. | |
"""Classes and functions to visualise a KGML Pathway Map. | |
The KGML definition is as of release KGML v0.7.1 | |
(http://www.kegg.jp/kegg/xml/docs/) | |
Classes: | |
""" | |
import os | |
import tempfile | |
from io import BytesIO | |
try: | |
from reportlab.lib import colors | |
from reportlab.pdfgen import canvas | |
except ImportError: | |
from Bio import MissingPythonDependencyError | |
raise MissingPythonDependencyError( | |
"Install reportlab if you want to use KGML_vis." | |
) from None | |
try: | |
from PIL import Image | |
except ImportError: | |
from Bio import MissingPythonDependencyError | |
raise MissingPythonDependencyError( | |
"Install pillow if you want to use KGML_vis." | |
) from None | |
from urllib.request import urlopen | |
from Bio.KEGG.KGML.KGML_pathway import Pathway | |
def darken(color, factor=0.7): | |
"""Return darkened color as a ReportLab RGB color. | |
Take a passed color and returns a Reportlab color that is darker by the | |
factor indicated in the parameter. | |
""" | |
newcol = color_to_reportlab(color) | |
for a in ["red", "green", "blue"]: | |
setattr(newcol, a, factor * getattr(newcol, a)) | |
return newcol | |
def color_to_reportlab(color): | |
"""Return the passed color in Reportlab Color format. | |
We allow colors to be specified as hex values, tuples, or Reportlab Color | |
objects, and with or without an alpha channel. This function acts as a | |
Rosetta stone for conversion of those formats to a Reportlab Color | |
object, with alpha value. | |
Any other color specification is returned directly | |
""" | |
# Reportlab Color objects are in the format we want already | |
if isinstance(color, colors.Color): | |
return color | |
elif isinstance(color, str): # String implies hex color | |
if color.startswith("0x"): # Standardise to octothorpe | |
color.replace("0x", "#") | |
if len(color) == 7: | |
return colors.HexColor(color) | |
else: | |
try: | |
return colors.HexColor(color, hasAlpha=True) | |
except TypeError: # Catch pre-2.7 Reportlab | |
raise RuntimeError( | |
"Your reportlab seems to be too old, try 2.7 onwards" | |
) from None | |
elif isinstance(color, tuple): # Tuple implies RGB(alpha) tuple | |
return colors.Color(*color) | |
return color | |
def get_temp_imagefilename(url): | |
"""Return filename of temporary file containing downloaded image. | |
Create a new temporary file to hold the image file at the passed URL | |
and return the filename. | |
""" | |
img = urlopen(url).read() | |
im = Image.open(BytesIO(img)) | |
# im.transpose(Image.FLIP_TOP_BOTTOM) | |
f = tempfile.NamedTemporaryFile(delete=False, suffix=".png") | |
fname = f.name | |
f.close() | |
im.save(fname, "PNG") | |
return fname | |
class KGMLCanvas: | |
"""Reportlab Canvas-based representation of a KGML pathway map.""" | |
def __init__( | |
self, | |
pathway, | |
import_imagemap=False, | |
label_compounds=True, | |
label_orthologs=True, | |
label_reaction_entries=True, | |
label_maps=True, | |
show_maps=False, | |
fontname="Helvetica", | |
fontsize=6, | |
draw_relations=True, | |
show_orthologs=True, | |
show_compounds=True, | |
show_genes=True, | |
show_reaction_entries=True, | |
margins=(0.02, 0.02), | |
): | |
"""Initialize the class.""" | |
self.pathway = pathway | |
self.show_maps = show_maps | |
self.show_orthologs = show_orthologs | |
self.show_compounds = show_compounds | |
self.show_genes = show_genes | |
self.show_reaction_entries = show_reaction_entries | |
self.label_compounds = label_compounds | |
self.label_orthologs = label_orthologs | |
self.label_reaction_entries = label_reaction_entries | |
self.label_maps = label_maps | |
self.fontname = fontname | |
self.fontsize = fontsize | |
self.draw_relations = draw_relations | |
self.non_reactant_transparency = 0.3 | |
self.import_imagemap = import_imagemap # Import the map .png from URL | |
# percentage of canvas that will be margin in on either side in the | |
# X and Y directions | |
self.margins = margins | |
def draw(self, filename): | |
"""Add the map elements to the drawing.""" | |
# Instantiate the drawing, first | |
# size x_max, y_max for now - we can add margins, later | |
if self.import_imagemap: | |
# We're drawing directly on the image, so we set the canvas to the | |
# same size as the image | |
if os.path.isfile(self.pathway.image): | |
imfilename = self.pathway.image | |
else: | |
imfilename = get_temp_imagefilename(self.pathway.image) | |
im = Image.open(imfilename) | |
cwidth, cheight = im.size | |
else: | |
# No image, so we set the canvas size to accommodate visible | |
# elements | |
cwidth, cheight = (self.pathway.bounds[1][0], self.pathway.bounds[1][1]) | |
# Instantiate canvas | |
self.drawing = canvas.Canvas( | |
filename, | |
bottomup=0, | |
pagesize=( | |
cwidth * (1 + 2 * self.margins[0]), | |
cheight * (1 + 2 * self.margins[1]), | |
), | |
) | |
self.drawing.setFont(self.fontname, self.fontsize) | |
# Transform the canvas to add the margins | |
self.drawing.translate( | |
self.margins[0] * self.pathway.bounds[1][0], | |
self.margins[1] * self.pathway.bounds[1][1], | |
) | |
# Add the map image, if required | |
if self.import_imagemap: | |
self.drawing.saveState() | |
self.drawing.scale(1, -1) | |
self.drawing.translate(0, -cheight) | |
self.drawing.drawImage(imfilename, 0, 0) | |
self.drawing.restoreState() | |
# Add the reactions, compounds and maps | |
# Maps go on first, to be overlaid by more information. | |
# By default, they're slightly transparent. | |
if self.show_maps: | |
self.__add_maps() | |
if self.show_reaction_entries: | |
self.__add_reaction_entries() | |
if self.show_orthologs: | |
self.__add_orthologs() | |
if self.show_compounds: | |
self.__add_compounds() | |
if self.show_genes: | |
self.__add_genes() | |
# TODO: complete draw_relations code | |
# if self.draw_relations: | |
# self.__add_relations() | |
# Write the pathway map to PDF | |
self.drawing.save() | |
def __add_maps(self): | |
"""Add maps to the drawing of the map (PRIVATE). | |
We do this first, as they're regional labels to be overlaid by | |
information. Also, we want to set the color to something subtle. | |
We're using Hex colors because that's what KGML uses, and | |
Reportlab doesn't mind. | |
""" | |
for m in self.pathway.maps: | |
for g in m.graphics: | |
self.drawing.setStrokeColor("#888888") | |
self.drawing.setFillColor("#DDDDDD") | |
self.__add_graphics(g) | |
if self.label_maps: | |
self.drawing.setFillColor("#888888") | |
self.__add_labels(g) | |
def __add_graphics(self, graphics): | |
"""Add the passed graphics object to the map (PRIVATE). | |
Add text, add after the graphics object, for sane Z-ordering. | |
""" | |
if graphics.type == "line": | |
p = self.drawing.beginPath() | |
x, y = graphics.coords[0] | |
# There are optional settings for lines that aren't necessarily | |
# part of the KGML DTD | |
if graphics.width is not None: | |
self.drawing.setLineWidth(graphics.width) | |
else: | |
self.drawing.setLineWidth(1) | |
p.moveTo(x, y) | |
for (x, y) in graphics.coords: | |
p.lineTo(x, y) | |
self.drawing.drawPath(p) | |
self.drawing.setLineWidth(1) # Return to default | |
# KGML defines the (x, y) coordinates as the centre of the circle/ | |
# rectangle/roundrectangle, but Reportlab uses the coordinates of the | |
# lower-left corner for rectangle/elif. | |
if graphics.type == "circle": | |
self.drawing.circle( | |
graphics.x, graphics.y, graphics.width * 0.5, stroke=1, fill=1 | |
) | |
elif graphics.type == "roundrectangle": | |
self.drawing.roundRect( | |
graphics.x - graphics.width * 0.5, | |
graphics.y - graphics.height * 0.5, | |
graphics.width, | |
graphics.height, | |
min(graphics.width, graphics.height) * 0.1, | |
stroke=1, | |
fill=1, | |
) | |
elif graphics.type == "rectangle": | |
self.drawing.rect( | |
graphics.x - graphics.width * 0.5, | |
graphics.y - graphics.height * 0.5, | |
graphics.width, | |
graphics.height, | |
stroke=1, | |
fill=1, | |
) | |
def __add_labels(self, graphics): | |
"""Add labels for the passed graphics objects to the map (PRIVATE). | |
We don't check that the labels fit inside objects such as circles/ | |
rectangles/roundrectangles. | |
""" | |
if graphics.type == "line": | |
# We use the midpoint of the line - sort of - we take the median | |
# line segment (list-wise, not in terms of length), and use the | |
# midpoint of that line. We could have other options here, | |
# maybe even parameterising it to a proportion of the total line | |
# length. | |
mid_idx = len(graphics.coords) * 0.5 | |
if not int(mid_idx) == mid_idx: | |
idx1, idx2 = int(mid_idx - 0.5), int(mid_idx + 0.5) | |
else: | |
idx1, idx2 = int(mid_idx - 1), int(mid_idx) | |
x1, y1 = graphics.coords[idx1] | |
x2, y2 = graphics.coords[idx2] | |
x, y = 0.5 * (x1 + x2), 0.5 * (y1 + y2) | |
elif graphics.type == "circle": | |
x, y = graphics.x, graphics.y | |
elif graphics.type in ("rectangle", "roundrectangle"): | |
x, y = graphics.x, graphics.y | |
# How big so we want the text, and how many characters? | |
if graphics._parent.type == "map": | |
text = graphics.name | |
self.drawing.setFont(self.fontname, self.fontsize + 2) | |
elif len(graphics.name) < 15: | |
text = graphics.name | |
else: | |
text = graphics.name[:12] + "..." | |
self.drawing.drawCentredString(x, y, text) | |
self.drawing.setFont(self.fontname, self.fontsize) | |
def __add_orthologs(self): | |
"""Add 'ortholog' Entry elements to the drawing of the map (PRIVATE). | |
In KGML, these are typically line objects, so we render them | |
before the compound circles to cover the unsightly ends/junctions. | |
""" | |
for ortholog in self.pathway.orthologs: | |
for g in ortholog.graphics: | |
self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor)) | |
self.drawing.setFillColor(color_to_reportlab(g.bgcolor)) | |
self.__add_graphics(g) | |
if self.label_orthologs: | |
# We want the label color to be slightly darker | |
# (where possible), so it can be read | |
self.drawing.setFillColor(darken(g.fgcolor)) | |
self.__add_labels(g) | |
def __add_reaction_entries(self): | |
"""Add Entry elements for Reactions to the map drawing (PRIVATE). | |
In KGML, these are typically line objects, so we render them | |
before the compound circles to cover the unsightly ends/junctions | |
""" | |
for reaction in self.pathway.reaction_entries: | |
for g in reaction.graphics: | |
self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor)) | |
self.drawing.setFillColor(color_to_reportlab(g.bgcolor)) | |
self.__add_graphics(g) | |
if self.label_reaction_entries: | |
# We want the label color to be slightly darker | |
# (where possible), so it can be read | |
self.drawing.setFillColor(darken(g.fgcolor)) | |
self.__add_labels(g) | |
def __add_compounds(self): | |
"""Add compound elements to the drawing of the map (PRIVATE).""" | |
for compound in self.pathway.compounds: | |
for g in compound.graphics: | |
# Modify transparency of compounds that don't participate | |
# in reactions | |
fillcolor = color_to_reportlab(g.bgcolor) | |
if not compound.is_reactant: | |
fillcolor.alpha *= self.non_reactant_transparency | |
self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor)) | |
self.drawing.setFillColor(fillcolor) | |
self.__add_graphics(g) | |
if self.label_compounds: | |
if not compound.is_reactant: | |
t = 0.3 | |
else: | |
t = 1 | |
self.drawing.setFillColor(colors.Color(0.2, 0.2, 0.2, t)) | |
self.__add_labels(g) | |
def __add_genes(self): | |
"""Add gene elements to the drawing of the map (PRIVATE).""" | |
for gene in self.pathway.genes: | |
for g in gene.graphics: | |
self.drawing.setStrokeColor(color_to_reportlab(g.fgcolor)) | |
self.drawing.setFillColor(color_to_reportlab(g.bgcolor)) | |
self.__add_graphics(g) | |
if self.label_compounds: | |
self.drawing.setFillColor(darken(g.fgcolor)) | |
self.__add_labels(g) | |
def __add_relations(self): | |
"""Add relations to the map (PRIVATE). | |
This is tricky. There is no defined graphic in KGML for a | |
relation, and the corresponding entries are typically defined | |
as objects 'to be connected somehow'. KEGG uses KegSketch, which | |
is not public, and most third-party software draws straight line | |
arrows, with heads to indicate the appropriate direction | |
(at both ends for reversible reactions), using solid lines for | |
ECrel relation types, and dashed lines for maplink relation types. | |
The relation has: | |
- entry1: 'from' node | |
- entry2: 'to' node | |
- subtype: what the relation refers to | |
Typically we have entry1 = map/ortholog; entry2 = map/ortholog, | |
subtype = compound. | |
""" | |
# Dashed lines for maplinks, solid for everything else | |
for relation in list(self.pathway.relations): | |
if relation.type == "maplink": | |
self.drawing.setDash(6, 3) | |
else: | |
self.drawing.setDash() | |
for s in relation.subtypes: | |
subtype = self.pathway.entries[s[1]] | |
# Our aim is to draw an arrow from the entry1 object to the | |
# entry2 object, via the subtype object. | |
# 1) Entry 1 to subtype | |
self.__draw_arrow(relation.entry1, subtype) | |
# 2) subtype to Entry 2 | |
self.__draw_arrow(subtype, relation.entry2) | |
def __draw_arrow(self, g_from, g_to): | |
"""Draw an arrow between given Entry objects (PRIVATE). | |
Draws an arrow from the g_from Entry object to the g_to | |
Entry object; both must have Graphics objects. | |
""" | |
# Centre and bound coordinates for the from and two objects | |
bounds_from, bounds_to = g_from.bounds, g_to.bounds | |
centre_from = ( | |
0.5 * (bounds_from[0][0] + bounds_from[1][0]), | |
0.5 * (bounds_from[0][1] + bounds_from[1][1]), | |
) | |
centre_to = ( | |
0.5 * (bounds_to[0][0] + bounds_to[1][0]), | |
0.5 * (bounds_to[0][1] + bounds_to[1][1]), | |
) | |
p = self.drawing.beginPath() | |
# print(True, g_from.name, g_to.name, bounds_to, bounds_from) | |
# If the 'from' and 'to' graphics are vertically-aligned, draw a line | |
# from the 'from' to the 'to' entity | |
if bounds_to[0][0] < centre_from[0] < bounds_to[1][0]: | |
# print(True, g_from.name, g_to.name, bounds_to, bounds_from) | |
if centre_to[1] > centre_from[1]: # to above from | |
p.moveTo(centre_from[0], bounds_from[1][1]) | |
p.lineTo(centre_from[0], bounds_to[0][1]) | |
# Draw arrow point - TODO | |
else: # to below from | |
p.moveTo(centre_from[0], bounds_from[0][1]) | |
p.lineTo(centre_from[0], bounds_to[1][1]) | |
# Draw arrow point - TODO | |
elif bounds_from[0][0] < centre_to[0] < bounds_from[1][0]: | |
# print(True, g_from.name, g_to.name, bounds_to, bounds_from) | |
if centre_to[1] > centre_from[1]: # to above from | |
p.moveTo(centre_to[0], bounds_from[1][1]) | |
p.lineTo(centre_to[0], bounds_to[0][1]) | |
# Draw arrow point - TODO | |
else: # to below from | |
p.moveTo(centre_to[0], bounds_from[0][1]) | |
p.lineTo(centre_to[0], bounds_to[1][1]) | |
# Draw arrow point - TODO | |
self.drawing.drawPath(p) # Draw arrow shaft | |
# print(g_from) | |
# print(bounds_from) | |
# print(g_to) | |
# print(bounds_to) | |