A simple genome browser with Qt and dna_features_viewer

January 25 2020

Background

Genome browsers are regularly used to view genomic annotations (features) in web browsers or desktop programs. These are mostly run on a web server and customisable to some extent with the ability to add and remove tracks. Increasingly, users will want more customised browsers because there are many more different kinds of datasets than ever. Building your own custom genome viewer is not trivial though. Most efforts now focus on web technologies due to their portability. Desktop solutions do have some advantages if you can make use of the power of the graphical interface toolkits they provide. A simple example shown here uses dna_features_viewer, a Python package for drawing genomic features using matplotlib. Combined with the Qt toolkit we can quickly make a small application that makes a handy, though simple, genome browser. The Qt widgets are implemented with PySide2.

Imports

import sys,os
from Bio import SeqIO
from PySide2 import QtCore
from PySide2.QtWidgets import *
from PySide2.QtGui import *
import matplotlib
import pylab as plt
from dna_features_viewer import GraphicFeature, GraphicRecord
from dna_features_viewer import BiopythonTranslator

The application

The code for this mini application is given here in one block. The update method is where the drawing is done. This is connected to any changes in the slider and zoom buttons so that a redraw is done each time the co-ordinates change. The program handles genbank files with one or more contig or continuous sequence. These are selected from the drop down menu. Files are read in with Biopython and converted to a list of SeqRecord objects.

class SeqFeaturesViewer(QMainWindow):
    """Sequence records features viewer using dna_features_viewer"""
    def __init__(self, genbank=None, gff=None):

        QMainWindow.__init__(self)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.setWindowTitle('Genomic Features Viewer')
        self.setGeometry(QtCore.QRect(300, 200, 1000, 400))
        self.setMinimumHeight(150)
        self.main = QWidget()
        self.main.setFocus()
        self.setCentralWidget(self.main)
        self.add_widgets()
        self.color_map = {
            "rep_origin": "yellow",
            "CDS": "lightblue",
            "regulatory": "red",
            "misc_recomb": "darkblue",
            "misc_feature": "lightgreen",
            "tRNA": "lightred"
        }
        if genbank != None:
            self.load_genbank(genbank)
        elif gff !=None:
            self.load_gff(gff)
        return

    def load_genbank(self, filename):
        """Load a genbank file"""

        recs = list(SeqIO.parse(filename, 'gb'))
        self.load_records(recs)
        self.update()
        return

    def load_gff(self, filename):
        """Load a gff file"""

        from BCBio import GFF
        in_handle = open(filename,'r')
        recs = list(GFF.parse(in_handle))
        self.load_records(recs)
        self.update()
        return

    def add_widgets(self):
        """Add widgets"""

        l = QVBoxLayout(self.main)
        self.main.setLayout(l)
        val=0
        navpanel = QWidget()
        navpanel.setMaximumHeight(60)
        l.addWidget(navpanel)
        bl = QHBoxLayout(navpanel)
        slider = QSlider(QtCore.Qt.Horizontal)
        slider.setTickPosition(slider.TicksBothSides)
        slider.setTickInterval(1000)
        slider.setPageStep(200)
        slider.setValue(1)
        slider.valueChanged.connect(self.value_changed)
        self.slider = slider
        bl.addWidget(slider)

        zoomoutbtn = QPushButton('-')
        zoomoutbtn.setMaximumWidth(50)
        bl.addWidget(zoomoutbtn)
        zoomoutbtn.clicked.connect(self.zoom_out)
        zoominbtn = QPushButton('+')
        zoominbtn.setMaximumWidth(50)
        bl.addWidget(zoominbtn)
        zoominbtn.clicked.connect(self.zoom_in)

        self.recselect = QComboBox()
        self.recselect.currentIndexChanged.connect(self.update_record)
        bl.addWidget(self.recselect)

        from matplotlib.backends.backend_qt4agg import FigureCanvasQTAgg as FigureCanvas
        import matplotlib.pyplot as plt
        fig,ax = plt.subplots(1,1,figsize=(15,2))
        self.canvas = FigureCanvas(fig)
        l.addWidget(self.canvas)
        self.ax = ax

        bottom = QWidget()
        bottom.setMaximumHeight(50)
        l.addWidget(bottom)
        bl2 = QHBoxLayout(bottom)
        self.loclbl = QLabel('')
        bl2.addWidget(self.loclbl)
        savebtn = QPushButton('Save Image')
        savebtn.clicked.connect(self.save_image)
        bl2.addWidget(savebtn)
        return

    def load_records(self, recs):
        """Load list of SeqRecord objects"""

        from Bio import SeqIO
        self.records = SeqIO.to_dict(recs)
        recnames = list(self.records.keys())
        self.rec = self.records[recnames[0]]
        length = len(self.rec.seq)
        self.recselect.addItems(recnames)
        self.recselect.setStyleSheet("QComboBox { combobox-popup: 0; }");
        self.recselect.setMaxVisibleItems(10)
        sl = self.slider
        sl.setMinimum(1)
        sl.setMaximum(length)
        sl.setTickInterval(length/20)
        return

    def update_record(self, recname=None):
        """Update record"""

        recname = self.recselect.currentText()
        self.rec = self.records[recname]
        length = len(self.rec.seq)
        sl = self.slider
        sl.setMinimum(1)
        sl.setMaximum(length)
        sl.setTickInterval(length/20)
        self.update()
        return

    def value_changed(self):
        """Callback for widgets"""

        length = len(self.rec.seq)
        r = self.view_range
        start = int(self.slider.value())
        end = int(start+r)
        if end > length:
            end=length
        self.update(start, end)
        return

    def zoom_in(self):
        """Zoom in"""

        length = len(self.rec.seq)
        fac = 1.2
        r = int(self.view_range/fac)
        start = int(self.slider.value())
        end = start + r
        if end > length:
            end=length
        self.update(start, end)
        return

    def zoom_out(self):
        """Zoom out"""

        length = len(self.rec.seq)
        fac = 1.2
        r = int(self.view_range*fac)
        start = int(self.slider.value())
        end = start + r
        if end > length:
            end=length
        self.update(start, end)
        return

    def update(self, start=1, end=2000):
        """Plot the features"""

        ax=self.ax
        ax.clear()
        if start<0:
            start=0
        if end == 0:
            end = start+1000
        if end-start > 100000:
            end = start+100000
        #print (start, end)
        rec = self.rec
        translator = BiopythonTranslator(
            features_filters=(lambda f: f.type not in ["gene", "source"],),
            features_properties=lambda f: {"color": self.color_map.get(f.type, "white")},
        )
        graphic_record = translator.translate_record(rec)
        cropped_record = graphic_record.crop((start, end))
        cropped_record.plot( strand_in_label_threshold=7, ax=ax)
        if end-start < 150:
            cropped_record.plot_sequence(ax=ax, location=(start,end))
            cropped_record.plot_translation(ax=ax, location=(start,end),fontdict={'weight': 'bold'})
        plt.tight_layout()
        self.canvas.draw()
        self.view_range = end-start
        self.loclbl.setText(str(start)+'-'+str(end))
        return

    def save_image(self):

        filters = "png files (*.png);;svg files (*.svg);;jpg files (*.jpg);;All files (*.*)"
        filename, _ = QFileDialog.getSaveFileName(self,"Save Figure",
                                                  "",filters)
        if not filename:
            return
        self.ax.figure.savefig(filename, bbox_inches='tight')
        return

def main():
    "Run the application"

    import sys, os
    from argparse import ArgumentParser
    parser = ArgumentParser(description='Genomic Features Viewer')
    parser.add_argument("-f", "--genbank", dest="genbank",default=None,
                        help="input genbank file", metavar="FILE")
    parser.add_argument("-g", "--gff", dest="gff",default=None,
                        help="input gff file", metavar="FILE")
    args = vars(parser.parse_args())
    app = QApplication(sys.argv)
    aw = SeqFeaturesViewer(**args)
    aw.show()
    app.exec_()

if __name__ == '__main__':
    main()