Annotation Processing
This page describes how we processed annotation files for the Aplsysia genome assembly.
Liftoff
Docker compose file
We pulled a container for liftoff with
services:
liftoff:
image: staphb/liftoff:latest
stdin_open: true
tty: true
command: /bin/sh
volumes:
- /home/rcalinjageman:/mnt/rcalinjagemanWhich at the time of this writing pulls Liftoff v1.6.3, the most recent release of Liftoff (released February 2022).
We spun up the container and shelled into it with:
# start container
docker compose up -d
# shell in
docker compose exec liftoff bashWe downloaded annotation files from NCBI and Ensembl and then lifted them to the new assembly using default values:
# For NCBI annotation
/opt/conda/bin/liftoff -a 0.80 -s 0.80 -p 8 -exclude_partial -o apcal_3_0_lifted.gff3 -u apcal_3_0_unmapped.txt -g apcal_3_0_genomic.gff domcal_with_mitochondrial.fna apcal_3_0_genomic.fna
# And for ensemble
/opt/conda/bin/liftoff -a 0.80 -s 0.80 -p 8 -exclude_partial -o ensemble_lifted.gff3 -u ensemble_unmapped.txt -g ensembl_genes.gff3 domcal_with_mitochondrial.fna apcal_3_0_genomic.fnaGffCompare
We next pulled a container for Gffcompare:
services:
gffcompare:
image: quay.io/biocontainers/gffcompare:0.12.10--h9948957_0
stdin_open: true
tty: true
command: /bin/sh
volumes:
- /home/rcalinjageman:/mnt/rcalinjageman
- /home/rcalinjageman/docker_stuff/gffcompare:/gffcompareSpun up the container, shelled in, and queried the de novo annotation against both the NCBI and Ensembl annotations:
# start container
docker compose up -d
# shell in
docker compose exec gffcompare /bin/bash# Compare to apcal_3_0
gffcompare -r apcal_3_0_lifted.gff3 -o apcal_3_0_to_braker braker-loose.utrs.ipr.sorted.gff3
# Compare to ensemble
gffcompare -r ensemble_lifted.gff3 -o ensemble_to_braker braker-loose.utrs.ipr.sorted.gff3 Cross-Referencing Annotations
We then used R 4.4.1 and the rtracklayer package (1.66.0) to add the cross-references found by Gffcompare into the annotation files (TBD: Probably post this script to a repo rather than just text):
# Script with some Gemini help to update aplysia genome annotations
# With cross references
# 0. Setup
library(rtracklayer)
gff_codes <- c(
"=" = "Exact match",
"c" = "Contained in reference",
"k" = "Containtment in reference (reverse containmnet)",
"m" = "Retained intron(s), all introns matched or retained",
"n" = "Retained intron(s), not all introns matched/covered",
"j" = "Multi-exon with at least one junction match",
"e" = "Single exon overlapping intron",
"y" = "Contains reference within its intron(s)",
"i" = "Fully intronic",
"o" = "Exonic overlap",
"p" = "Polymerase run-on",
"r" = "Repeat",
"u" = "Intergenic (unknown)",
"x" = "Anti-sense exonic overlap",
"s" = "Anti-sense intronic overlap",
"." = "Unclassified",
"-" = "Unclassified"
)
gff_codes <- gsub(" ", "_", gff_codes)
# 1. Load the new annotation, the gffcompare results, and additional ncbi and ensembl details
new_gff <- import("braker-loose.utrs.ipr.sorted.gff3")
tmap <- read.delim("apcal_3_0_to_braker.braker-loose.utrs.ipr.sorted.gff3.tmap", header=TRUE, stringsAsFactors=FALSE)
emap <- read.delim("ensemble_to_braker.braker-loose.utrs.ipr.sorted.gff3.tmap", header = TRUE, stringsAsFactors = FALSE)
ncbi_table <- read.delim("GCF_000002075.1_AplCal3.0_feature_table.txt", header=TRUE, stringsAsFactors=FALSE)
ensembl_gff <- import("ensemble_lifted.gff3")
ncbi_gff <- import("liftoff.sorted.gff")
# Make character so we can grab this column
ncbi_gff$Note <- as.character(ncbi_gff$Note)
# Store code meanings
tmap$code_meaning <- gff_codes[tmap$class_code]
emap$code_meaning <- gff_codes[emap$class_code]
# NCBI
# Get matches from new annotation to gffcompare results
match_gene_ids <- match(new_gff$ID, tmap$qry_gene_id)
match_rna_ids <- match(new_gff$ID, tmap$qry_id)
# Store match code and explanation in gff
new_gff$ncbi_match_code <- tmap$class_code[match_gene_ids]
new_gff$ncbi_match_code_meaning <- tmap$code_meaning[match_gene_ids]
# Store in the gff the ncbi gene and transcript ids identified by gffcompare
new_gff$ncbi_genes <- tmap$ref_gene_id[match_gene_ids]
new_gff$ncbi_rnas <- tmap$ref_id[match_rna_ids]
# Now get indexes from new gff to ncbi gff
match_ncbi_genes <- match(new_gff$ncbi_genes, ncbi_gff$ID)
match_ncbi_rnas <- match(new_gff$ncbi_rnas, ncbi_gff$ID)
# Now store attributes from ncbi genes into the new gff
new_gff$ncbi_gene <- ncbi_gff$gene[match_ncbi_genes]
new_gff$ncbi_gene_Name <- ncbi_gff$Name[match_ncbi_genes]
new_gff$ncbi_gene_description <- ncbi_gff$description[match_ncbi_genes]
new_gff$ncbi_gene_biotype <- ncbi_gff$gene_biotype[match_ncbi_genes]
# Now store attributes from ncbi transcripts ino the new gff
new_gff$ncbi_transcript_Name <- ncbi_gff$Name[match_ncbi_rnas]
new_gff$ncbi_transcript_Note <- ncbi_gff$Note[match_ncbi_rnas]
new_gff$ncbi_transcript_inference <- ncbi_gff$inference[match_ncbi_rnas]
new_gff$ncbi_transcript_product <- ncbi_gff$product[match_ncbi_rnas]
new_gff$ncbi_transcript_id <- ncbi_gff$transcript_id[match_ncbi_rnas]
# Now the same steps but for ensembl
# Store match code and explanation in gff
match_egene_ids <- match(new_gff$ID, emap$qry_gene_id)
match_erna_ids <- match(new_gff$ID, emap$qry_id)
# Get matches from new annotation to gffcompare results
new_gff$ensembl_match_code <- emap$class_code[match_egene_ids]
new_gff$ensembl_match_code_meaning <- emap$code_meaning[match_egene_ids]
# Store in the gff the ensembl gene and transcript ids identified by gffcompare
new_gff$ensembl_genes <- emap$ref_gene_id[match_egene_ids]
new_gff$ensembl_rnas <- emap$ref_id[match_erna_ids]
# Now get indexes from new gff to ensembl gff
match_ensembl_genes <- match(new_gff$ensembl_genes, ensembl_gff$ID)
match_ensembl_rnas <- match(new_gff$ensembl_rnas, ensembl_gff$ID)
# Now store attributes from ensembl genes into the new gff
new_gff$ensembl_gene <- ensembl_gff$gene[match_ensembl_genes]
new_gff$ensembl_gene_Name <- ensembl_gff$Name[match_ensembl_genes]
new_gff$ensembl_gene_description <- ensembl_gff$description[match_ensembl_genes]
new_gff$ensembl_gene_biotype <- ensembl_gff$gene_biotype[match_ensembl_genes]
new_gff$ensembl_gene_id <- ensembl_gff$gene_id[match_ensembl_genes]
# Now store attributes from ncbi transcripts ino the new gff
new_gff$ensembl_transcript_tag <- ensembl_gff$tag[match_ensembl_rnas]
new_gff$ensembl_transcript_biotype <- ensembl_gff$biotype[match_ensembl_rnas]
new_gff$ensembl_transcript_id <- ensembl_gff$transcript_id[match_ensembl_rnas]
# Bring some of this into global attributes for our annotation
# Bring NCBI's product description into each transcript
new_gff$product <- NA
new_gff[! is.na(new_gff$ncbi_transcript_product), ]$product <- paste("Match NCBI", new_gff[! is.na(new_gff$ncbi_transcript_product), ]$ncbi_transcript_product, sep = " ")
# Bring NCBI's match and description into description for each gene
# First, get NCBI descriptions
new_gff$Description <- NA
new_gff[! is.na(new_gff$ncbi_gene_description), ]$Description <- paste("Match NCBI", new_gff[! is.na(new_gff$ncbi_gene_description), ]$ncbi_match_code_meaning, new_gff[! is.na(new_gff$ncbi_gene_description), ]$ncbi_gene_description, sep = " ")
# Next Ensembl
new_gff$EDescription <- NA
new_gff[! is.na(new_gff$ensembl_gene_description), ]$EDescription <- paste("Match Ensembl", new_gff[! is.na(new_gff$ensembl_gene_description), ]$ensembl_match_code_meaning, new_gff[! is.na(new_gff$ensembl_gene_description), ]$ensembl_gene_description, sep = " ")
# Merge together when we have both
new_gff[! (is.na(new_gff$Description) | is.na(new_gff$EDescription) ) ]$Description <- paste(new_gff[! (is.na(new_gff$Description) | is.na(new_gff$EDescription) ) ]$Description, new_gff[! (is.na(new_gff$Description) | is.na(new_gff$EDescription) ) ]$EDescription)
# Pull in Ensembl when no NCBI
new_gff[is.na(new_gff$Description) & !is.na(new_gff$EDescription)]$Description <- new_gff[is.na(new_gff$Description) & !is.na(new_gff$EDescription)]$EDescription
# Get rid of temporary column
new_gff$EDescription <- NULL
# Clean up empty attributes
new_gff$ncbi_genes <- gsub("-", NA, new_gff$ncbi_genes)
new_gff$ncbi_rnas <- gsub("-", NA, new_gff$ncbi_rnas)
new_gff$ensembl_genes <- gsub("-", NA, new_gff$ncbi_genes)
new_gff$ensembl_rnas <- gsub("-", NA, new_gff$ncbi_rnas)
# 3. Export
export(new_gff, "domucal_de_novo_2025_crossreferenced.gff3", format="gff3")
# Now ensembl
# Fix description
ensembl_gff$Description <- ensembl_gff$description
ensembl_gff$description <- NULL
# Pull matches to new annotation
em_transcripts <- match(ensembl_gff$ID, emap$ref_id)
em_genes <- match(ensembl_gff$ID, emap$ref_gene_id)
ensembl_gff$match_domucal_transcript <- emap$qry_id[em_transcripts]
ensembl_gff$match_domucal_gene <- emap$qry_gene_id[em_genes]
# Then save
export(ensembl_gff, "ensembl_2024_11_lifted_crossreferenced.gff3", format="gff3")
# And ncbi
# Pull matches to new annotation
ncbi_transcripts <- match(ncbi_gff$ID, tmap$ref_id)
ncbi_genes <- match(ncbi_gff$ID, tmap$ref_gene_id)
ncbi_gff$match_domucal_transcript <- tmap$qry_id[ncbi_transcripts]
ncbi_gff$match_domucal_gene <- tmap$qry_gene_id[ncbi_genes]
export(ncbi_gff, "ncbi_apcal102_lifted_crossreferenced.gff3", format="gff3")Posting to JBrowse
Sort, Zip, and Index
To post to JBrowse, each annotation file was sorted, zipped, and then indexed with this helper script
# process_track.sh
#!/bin/bash
# Exit immediately if any command fails
set -e
INPUT_GFF=$1
TRACK_ID=$2
# Check if the user provided the required arguments
if [ -z "$INPUT_GFF" ] || [ -z "$TRACK_ID" ]; then
echo "Usage: ./update_track.sh <input.gff3> <track_id>"
echo "Example: ./update_track.sh aplysia_californica_annotation.gff3 my_annotation_track"
exit 1
fi
SORTED_GFF="${INPUT_GFF%.gff3}_sorted.gff3"
COMPRESSED_GFF="${SORTED_GFF}.gz"
echo "Step 1: Sorting $INPUT_GFF..."
# Preserves the header lines (starting with #) and sorts the data lines by sequence and start coordinate
(grep ^"#" "$INPUT_GFF"; grep -v ^"#" "$INPUT_GFF" | sort -k1,1 -k4,4n) > "$SORTED_GFF"
echo "Step 2: Compressing with bgzip..."
# The -f flag forces an overwrite if a compressed file already exists
bgzip -f "$SORTED_GFF"
echo "Step 3: Indexing with tabix..."
tabix -p gff "$COMPRESSED_GFF"
echo "Success! The track data and search index have been updated."
echo "Note: Perform a hard refresh in your browser to clear any cached .ixx files."And then
./process_track.sh domucal_de_novo_2025_crossreferenced.gff3 domucal_de_novo_2025
./process_track.sh ncbi_apcal102_lifted_crossreferenced.gff3 ncbi_apcal102_lifted_crossreferenced_sorted.gff3
./process_track.sh ensembl_2024_11_lifted_crossreferenced.gff3 ensembl_2024_11Adding and Indexing Tracks
Tracks were then added to JBrowse
jbrowse add-track ./anno/domucal_de_novo_2025_crossreferenced_sorted.gff3.gz --trackId domucal_de_novo_2025 --load inPlace
jbrowse add-track ./anno/ncbi_apcal102_lifted_crossreferenced_sorted.gff3.gz --trackId ncbi_apcal102_lifted --load inPlace
jbrowse add-track ./anno/ensembl_2024_11_lifted_crossreferenced_sorted.gff3.gz --trackId ensembl_2024_11_lifted --load inPlace Note that jbrowse add-track has difficulty with directories, so config.json had to be manually edited for each track to correct the paths to the annotation tracks.
Tracks were then indexed with:
jbrowse text-index --force --attributes=ID,Name,Description,productLinkout Plugin
To provide links from track attributes to relevant databases, we created a JBrowse Plugin (with help from Gemini):
export default class LinkoutPlugin {
name = 'LinkoutPlugin'
version = '1.0.0'
install() {}
configure(pluginManager) {
// Added a third parameter 'defaultDb' to handle attributes that lack prefixes
pluginManager.jexl.addFunction('formatLinks', (feature, attrName, defaultDb = null) => {
const val = typeof feature.get === 'function' ? feature.get(attrName) : feature[attrName];
if (!val) return val;
const termList = Array.isArray(val) ? val : val.toString().split(',');
return termList.map(term => {
const cleanTerm = term.trim();
let prefix, id;
// Split on the first colon if it exists
const colonIndex = cleanTerm.indexOf(':');
if (colonIndex !== -1) {
// It has a colon (e.g., "PFAM:PF00001")
prefix = cleanTerm.substring(0, colonIndex).toUpperCase();
id = cleanTerm.substring(colonIndex + 1);
} else if (defaultDb) {
// No colon, but a default database was passed (e.g., an Ensembl ID)
prefix = defaultDb.toUpperCase();
id = cleanTerm;
} else {
// No colon and no default database; return as standard unlinked text
return cleanTerm;
}
const ebi = 'https://www.ebi.ac.uk/interpro/entry/';
switch(prefix) {
case 'GO': return `<a href="https://amigo.geneontology.org/amigo/term/${cleanTerm}" target="_blank">${cleanTerm}</a>`;
case 'BOB': return `<a href="https://www.ncbi.nlm.nih.gov/nuccore/?term=${cleanTerm}" target="_blank">${cleanTerm}</a>`;
case 'ENSEMBLG': return `<a href="https://beta.ensembl.org/feature-explorer/264cff85-f586-4e0f-afd3-29ea13f42ef2/gene:${cleanTerm}" target="_blank>
case 'ENSEMBLT': return `<a href="https://beta.ensembl.org/genome-browser/264cff85-f586-4e0f-afd3-29ea13f42ef2?focus=transcript:${cleanTerm}" targ>
case 'INTERPRO': return `<a href="${ebi}InterPro/${id}" target="_blank">${cleanTerm}</a>`;
case 'PFAM':
case 'PF': return `<a href="${ebi}pfam/${id}" target="_blank">${cleanTerm}</a>`;
case 'CDD': return `<a href="${ebi}cdd/${id}" target="_blank">${cleanTerm}</a>`;
case 'GENE3D': return `<a href="${ebi}cathgene3d/${id}" target="_blank">${cleanTerm}</a>`;
case 'PANTHER': return `<a href="${ebi}panther/${id}" target="_blank">${cleanTerm}</a>`;
case 'PROSITE': return `<a href="https://www.uniprot.org/uniprotkb?query=${id}" target="_blank">${cleanTerm}</a>`;
case 'SMART': return `<a href="${ebi}smart/${id}" target="_blank">${cleanTerm}</a>`;
case 'SUPERFAMILY':
case 'SSF': return `<a href="${ebi}ssf/${id}" target="_blank">${cleanTerm}</a>`;
case 'TRANSCRIPT': return `<a href="https://rapid.ensembl.org/id/${id}" target="_blank">${cleanTerm}</a>`;
case 'NCBI': return `<a href="https://www.ncbi.nlm.nih.gov/nuccore/?term=${id}" target="_blank">${cleanTerm}</a>`;
case 'NCBI_GENE': return `<a href="https://www.ncbi.nlm.nih.gov/nuccore/?term=${id}" target="_blank">${cleanTerm}</a>`;
default: return cleanTerm;
}
}).join(', ');
});
}
}And then updated the track feature handling in config.json:
{
"assemblies": [
{
"name": "GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna",
"sequence": {
"type": "ReferenceSequenceTrack",
"trackId": "GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna-ReferenceSequenceTrack",
"adapter": {
"type": "IndexedFastaAdapter",
"fastaLocation": {
"uri": "GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna",
"locationType": "UriLocation"
},
"faiLocation": {
"uri": "GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna.fai",
"locationType": "UriLocation"
}
}
}
}
],
"configuration": {},
"connections": [],
"defaultSession": {
"name": "DomU Aplysia Genome Assembly"
},
"plugins": [
{
"name": "LinkoutPlugin",
"esmLoc": {
"uri": "linkoutPlugin.js"
}
}
],
"tracks": [
{
"type": "FeatureTrack",
"trackId": "domucal_de_novo_2025",
"name": "DomUCal de novo annotation 2025",
"formatDetails": {
"feature": "jexl: {ensembl_transcript_id: formatLinks(feature, 'ensembl_transcript_id', 'ENSEMBLT'), ensembl_gene_id: formatLinks(feature, 'ensembl_gen>
},
"adapter": {
"type": "Gff3TabixAdapter",
"gffGzLocation": {
"uri": "anno/domucal_de_novo_2025_crossreferenced_sorted.gff3.gz",
"locationType": "UriLocation"
},
"index": {
"location": {
"uri": "anno/domucal_de_novo_2025_crossreferenced_sorted.gff3.gz.tbi",
"locationType": "UriLocation"
},
"indexType": "TBI"
}
},
"assemblyNames": [
"GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna"
]
},
{
"type": "FeatureTrack",
"trackId": "ncbi_apcal102_lifted",
"name": "NCBI ApCal02 Lifted",
"formatDetails": {
"feature": "jexl: {gene: formatLinks(feature, 'gene', 'BOB'), transcript_id: formatLinks(feature, 'transcript_id', 'BOB') }"
},
"adapter": {
"type": "Gff3TabixAdapter",
"gffGzLocation": {
"uri": "anno/ncbi_apcal102_lifted_crossreferenced_sorted.gff3.gz",
"locationType": "UriLocation"
},
"index": {
"location": {
"uri": "anno/ncbi_apcal102_lifted_crossreferenced_sorted.gff3.gz.tbi",
"locationType": "UriLocation"
},
"indexType": "TBI"
}
},
"assemblyNames": [
"GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna"
]
},
{
"type": "FeatureTrack",
"trackId": "ensembl_2024_11_lifted",
"name": "Ensembl 2024_01 Lifted",
"formatDetails": {
"feature": "jexl: {transcript_id: formatLinks(feature, 'transcript_id', 'ENSEMBLT'), gene_id: formatLinks(feature, 'gene_id', 'ENSEMBLG') }"
},
"adapter": {
"type": "Gff3TabixAdapter",
"gffGzLocation": {
"uri": "anno/ensembl_2024_11_lifted_crossreferenced_sorted.gff3.gz",
"locationType": "UriLocation"
},
"index": {
"location": {
"uri": "anno/ensembl_2024_11_lifted_crossreferenced_sorted.gff3.gz.tbi",
"locationType": "UriLocation"
},
"indexType": "TBI"
}
},
"assemblyNames": [
"GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna"
]
}
],
"aggregateTextSearchAdapters": [
{
"type": "TrixTextSearchAdapter",
"textSearchAdapterId": "GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna-index",
"ixFilePath": {
"uri": "trix/GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna.ix",
"locationType": "UriLocation"
},
"ixxFilePath": {
"uri": "trix/GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna.ixx",
"locationType": "UriLocation"
},
"metaFilePath": {
"uri": "trix/GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna_meta.json",
"locationType": "UriLocation"
},
"assemblyNames": [
"GCA_041379995.1_DOMU_ApCal_1.1_genomic.fna"
]
}
]
}