Sequence alignment viewer with Qt/PySide2

July 21 2020

Background

Title speaks for itself here. A sequence alignment is made here from a fasta file with clustal and read in using BioPython to a list of SeqRecord objects. The rest is just making a graphical interface for showing the alignment. We use a custom QPlainTextEdit class for the text displays. The AlignmentWidget class contains a QSplitter with areas for the labels and sequence separated. The draw_alignment method handles the drawing and coloring of the sequence alignment. The fastest way to do this is add the text letter by letter as html with the color specified. Despite it’s name QPlainTextEdit can handle html. This widget was developed for use in the pathogenie tool.

What’s missing

Missing is a way to make the two parts of the alignment scroll vertically together. These are in a QSplitter and need to use a shared scrollbar. Also needed is a way to set a reference sequence from one of the entries so that we can color

Imports

from PySide2 import QtCore, QtGui
from PySide2.QtCore import QObject
from PySide2.QtWidgets import *
from PySide2.QtGui import *

import sys, os, io
import numpy as np
import pandas as pd
import string
from Bio import SeqIO
def clustal_alignment(filename=None, seqs=None, command="clustalw"):
    """Align sequences with clustal"""

    if filename == None:
        filename = 'temp.faa'
        SeqIO.write(seqs, filename, "fasta")
    name = os.path.splitext(filename)[0]
    command = get_cmd('clustalw')
    from Bio.Align.Applications import ClustalwCommandline
    cline = ClustalwCommandline(command, infile=filename)
    stdout, stderr = cline()
    align = AlignIO.read(name+'.aln', 'clustal')
    return align

class PlainTextEditor(QPlainTextEdit):
    def __init__(self, parent=None, **kwargs):
        super(PlainTextEditor, self).__init__(parent, **kwargs)
        font = QFont("Monospace")
        font.setPointSize(10)
        font.setStyleHint(QFont.TypeWriter)
        self.setFont(font)
        return

    def zoom(self, delta):
        if delta < 0:
            self.zoomOut(1)
        elif delta > 0:
            self.zoomIn(1)

    def contextMenuEvent(self, event):

        menu = QMenu(self)
        copyAction = menu.addAction("Copy")
        clearAction = menu.addAction("Clear")
        zoominAction = menu.addAction("Zoom In")
        zoomoutAction = menu.addAction("Zoom Out")
        action = menu.exec_(self.mapToGlobal(event.pos()))
        if action == copyAction:
            self.copy()
        elif action == clearAction:
            self.clear()
        elif action == zoominAction:
            self.zoom(1)
        elif action == zoomoutAction:
            self.zoom(-1)

class AlignmentWidget(QWidget):
    """Widget for showing sequence alignments"""
    def __init__(self, parent=None):
        super(AlignmentWidget, self).__init__(parent)
        l = QHBoxLayout(self)
        self.setLayout(l)
        self.m = QSplitter(self)
        l.addWidget(self.m)
        self.left = PlainTextEditor(self.m, readOnly=True)
        self.right = PlainTextEditor(self.m, readOnly=True)
        self.left.setLineWrapMode(QPlainTextEdit.NoWrap)
        self.right.setLineWrapMode(QPlainTextEdit.NoWrap)
        self.m.setSizes([200,300])
        self.m.setStretchFactor(1,2)
        return

class SequencesViewer(QMainWindow):
    """Viewer for sequences and alignments"""

    def __init__(self, parent=None, filename=None, title='Sequence Viewer'):
        super(SequencesViewer, self).__init__(parent)
        self.setWindowTitle(title)
        self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
        self.setGeometry(QtCore.QRect(200, 200, 1000, 600))
        self.setMinimumHeight(150)
        self.recs = None
        self.aln = None
        self.add_widgets()
        self.show()
        return

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

        self.main = QWidget(self)
        self.setCentralWidget(self.main)
        l = QHBoxLayout(self.main)
        self.main.setLayout(l)
        self.tabs = QTabWidget(self.main)
        l.addWidget(self.tabs)

        self.ed = ed = PlainTextEditor(self, readOnly=True)
        self.ed.setLineWrapMode(QPlainTextEdit.NoWrap)
        self.tabs.addTab(self.ed, 'fasta')

        self.alnview = AlignmentWidget(self.main)
        self.tabs.addTab(self.alnview, 'alignment')

        sidebar = QWidget()
        sidebar.setFixedWidth(180)
        l.addWidget(sidebar)
        l2 = QVBoxLayout(sidebar)
        l2.setSpacing(5)
        l2.setAlignment(QtCore.Qt.AlignTop)

        btn = QPushButton()
        btn.clicked.connect(self.zoom_out)
        iconw = QIcon.fromTheme('zoom-out')
        btn.setIcon(QIcon(iconw))
        l2.addWidget(btn)
        btn = QPushButton()
        btn.clicked.connect(self.zoom_in)
        iconw = QIcon.fromTheme('zoom-in')
        btn.setIcon(QIcon(iconw))
        l2.addWidget(btn)
        lbl = QLabel('Format')
        l2.addWidget(lbl)
        w = QComboBox()
        w.addItems(['no color','color by residue','color by difference'])
        w.setCurrentIndex(1)
        w.activated.connect(self.show_alignment)
        self.formatchoice = w
        l2.addWidget(w)
        lbl = QLabel('Set Reference')
        l2.addWidget(lbl)
        w = QComboBox()
        w.activated.connect(self.set_reference)
        self.referencechoice = w
        l2.addWidget(w)
        lbl = QLabel('Aligner')
        l2.addWidget(lbl)
        w = QComboBox()
        w.setCurrentIndex(1)
        w.addItems(['clustal','muscle'])
        self.alignerchoice = w
        l2.addWidget(w)
        self.create_menu()
        return

    def create_menu(self):
        """Create the menu bar for the application. """

        self.file_menu = QMenu('&File', self)
        self.menuBar().addMenu(self.file_menu)
        self.file_menu.addAction('&Load Fasta File', self.load_fasta,
                QtCore.Qt.CTRL + QtCore.Qt.Key_F)
        self.file_menu.addAction('&Save Alignment', self.save_alignment,
                QtCore.Qt.CTRL + QtCore.Qt.Key_S)
        return

    def scroll_top(self):
        vScrollBar = self.ed.verticalScrollBar()
        vScrollBar.triggerAction(QScrollBar.SliderToMinimum)
        return

    def zoom_out(self):
        self.ed.zoom(-1)
        self.alnview.left.zoom(-1)
        self.alnview.right.zoom(-1)
        return

    def zoom_in(self):
        self.ed.zoom(1)
        self.alnview.left.zoom(1)
        self.alnview.right.zoom(1)
        return

    def load_fasta(self, filename=None):
        """Load fasta file"""

        if filename == None:
            filename, _ = QFileDialog.getOpenFileName(self, 'Open File', './',
                            filter="Fasta Files(*.fa *.fna *.fasta);;All Files(*.*)")
        if not filename:
            return
        recs = list(SeqIO.parse(filename, 'fasta'))
        self.load_records(recs)
        return

    def load_records(self, recs):
        """Load seqrecords list"""

        self.recs = recs
        self.reference = self.recs[0]
        rdict = SeqIO.to_dict(recs)
        self.show_fasta()
        self.show_alignment()
        self.referencechoice.addItems(list(rdict.keys()))
        return

    def set_reference(self):
        ref = self.referencechoice.currentText()
        return

    def show_fasta(self):
        """Show records as fasta"""

        recs = self.recs
        if recs == None:
            return
        self.ed.clear()
        for rec in recs:
            s = rec.format('fasta')
            self.ed.insertPlainText(s)
        self.scroll_top()
        return

    def align(self):
        """Align current sequences"""

        if self.aln == None:
            outfile = 'temp.fa'
            SeqIO.write(self.recs, outfile, 'fasta')
            self.aln = clustal_alignment(outfile)
        return

    def show_alignment(self):

        format = self.formatchoice.currentText()
        self.draw_alignment(format)
        return

    def draw_alignment(self, format='color by residue'):
        """Show alignment with colored columns"""

        left = self.alnview.left
        right = self.alnview.right
        chunks=0
        offset=0
        diff=False
        self.align()
        aln = self.aln
        left.clear()
        right.clear()
        self.scroll_top()

        colors = tools.get_protein_colors()
        format = QtGui.QTextCharFormat()
        format.setBackground(QtGui.QBrush(QtGui.QColor('white')))
        cursor = right.textCursor()

        ref = aln[0]
        l = len(aln[0])
        n=60
        s=[]
        if chunks > 0:
            chunks = [(i,i+n) for i in range(0, l, n)]
        else:
            chunks = [(0,l)]
        for c in chunks:
            start,end = c
            lbls = np.arange(start+1,end+1,10)-offset
            head = ''.join([('%-10s' %i) for i in lbls])
            cursor.insertText(head)
            right.insertPlainText('\n')
            left.appendPlainText(' ')
            for a in aln:
                name = a.id
                seq = a.seq[start:end]
                left.appendPlainText(name)
                line = ''
                for aa in seq:
                    c = colors[aa]
                    line += '<span style="background-color:%s;">%s</span>' %(c,aa)
                cursor.insertHtml(line)
                right.insertPlainText('\n')
        return

    def save_alignment(self):

        filters = "clustal files (*.aln);;All files (*.*)"
        filename, _ = QFileDialog.getSaveFileName(self,"Save Alignment",
                                                  "",filters)
        if not filename:
            return
        SeqIO.write(self.aln,filename,format='clustal')
        return

Result

Running the app

Here is the code to make a script that will launch from the command line:

def main():
    "Run the application"

    import sys, os
    from argparse import ArgumentParser
    parser = ArgumentParser(description='sequence viewer tool')
    parser.add_argument("-f", "--fasta", dest="filename",default=None,
                        help="input fasta file", metavar="FILE")
    args = vars(parser.parse_args())

    app = QApplication(sys.argv)
    sv = widgets.SequencesViewer(**args)
    if args['filename'] != None:
        sv.load_fasta(args['filename'])
    sv.show()
    app.exec_()

if __name__ == '__main__':
    main()