Skip to content

Commit fae6fc0

Browse files
authored
Merge pull request #323 from VinLau/gaia-publication-figures-by-gene
Gaia publication figures by gene (GAIA endpoint for OCR bounding boxes and figures)
2 parents 71e5a83 + be25041 commit fae6fc0

6 files changed

Lines changed: 546 additions & 3 deletions

File tree

‎api/models/gaia.py‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,3 +60,22 @@ class Figures(db.Model):
6060
img_name: db.Mapped[str] = db.mapped_column(db.String(64), nullable=False)
6161
caption: db.Mapped[str] = db.mapped_column(db.Text, nullable=True)
6262
img_url: db.Mapped[str] = db.mapped_column(db.String(256), nullable=True)
63+
64+
65+
class AuthorList(db.Model):
66+
__bind_key__ = "gaia"
67+
__tablename__ = "author_list"
68+
69+
id: db.Mapped[int] = db.mapped_column(db.Integer, nullable=False, primary_key=True)
70+
publication_figures_id: db.Mapped[int] = db.mapped_column(
71+
ForeignKey("publication_figures.id", ondelete="CASCADE"), nullable=False
72+
)
73+
author: db.Mapped[str] = db.mapped_column(db.String(128), nullable=False)
74+
75+
76+
class FigureModels(db.Model):
77+
__bind_key__ = "gaia"
78+
__tablename__ = "figure_models"
79+
80+
id: db.Mapped[int] = db.mapped_column(db.Integer, nullable=False, primary_key=True)
81+
data: db.Mapped[dict] = db.mapped_column(db.JSON, nullable=True)

‎api/resources/gaia.py‎

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@
33
from markupsafe import escape
44
from api import db
55
from api.utils.bar_utils import BARUtils
6-
from api.models.gaia import Genes, Aliases, PubIds, Figures
7-
from sqlalchemy import func, or_
6+
from api.models.gaia import Genes, Aliases, PublicationFigures, PubIds, Figures, AuthorList, FigureModels
7+
from sqlalchemy import func, or_, cast, literal
8+
from sqlalchemy.dialects import mysql
89
from marshmallow import Schema, ValidationError, fields as marshmallow_fields
910
import json
11+
import re
1012

1113
gaia = Namespace("Gaia", description="Gaia", path="/gaia")
1214

@@ -174,3 +176,168 @@ def post(self):
174176

175177
# Return final data
176178
return BARUtils.success_exit(data)
179+
180+
181+
@gaia.route("/publication_figures_by_gene/<string:identifier>")
182+
class GaiaPublicationFiguresByGene(Resource):
183+
@gaia.param("identifier", _in="path", default="ABI3")
184+
def get(self, identifier=""):
185+
186+
# Escape input
187+
identifier = escape(identifier)
188+
189+
# Is it valid
190+
if not BARUtils.is_gaia_alias(identifier):
191+
return BARUtils.error_exit("Invalid identifier"), 400
192+
193+
# Resolve to gene ids: try alias first, then locus / ncbi id
194+
rows = db.session.execute(db.select(Aliases.genes_id).filter(Aliases.alias == identifier)).fetchall()
195+
gene_ids = [r.genes_id for r in rows]
196+
197+
if not gene_ids:
198+
rows = db.session.execute(
199+
db.select(Genes.id).filter(or_(Genes.locus == identifier, Genes.geneid == identifier))
200+
).fetchall()
201+
gene_ids = [r.id for r in rows]
202+
203+
if not gene_ids:
204+
return BARUtils.error_exit("Nothing found"), 404
205+
206+
# Get the gene's full alias set
207+
aliases = [
208+
r.alias.lower()
209+
for r in db.session.execute(db.select(Aliases.alias).filter(Aliases.genes_id.in_(gene_ids))).fetchall()
210+
]
211+
212+
# Match OCR words: word-boundary regex for long aliases, exact match for short ones
213+
long_aliases = sorted({re.escape(a) for a in aliases if len(a) >= 4})
214+
short_aliases = sorted({a for a in aliases if len(a) < 4})
215+
216+
# No usable aliases, nothing to match on
217+
if not long_aliases and not short_aliases:
218+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
219+
220+
word_expr = func.lower(func.json_unquote(func.json_extract(FigureModels.data, "$.word")))
221+
match_conds = []
222+
if long_aliases:
223+
alias_re = "(^|[^a-z0-9])(" + "|".join(long_aliases) + ")([^a-z0-9]|$)"
224+
match_conds.append(word_expr.regexp_match(alias_re))
225+
if short_aliases:
226+
match_conds.append(word_expr.in_(short_aliases))
227+
228+
matched_rows = db.session.execute(db.select(FigureModels.data).where(or_(*match_conds))).fetchall()
229+
if not matched_rows:
230+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
231+
232+
# Collect each matched image and its boxes (keep the image even if a box is missing)
233+
bbox_by_name = {}
234+
for row in matched_rows:
235+
d = row.data if isinstance(row.data, dict) else json.loads(row.data)
236+
for img in d.get("image", []):
237+
name = (img.get("imageName") or "").lstrip("/")
238+
if not name:
239+
continue
240+
bbox_list = bbox_by_name.setdefault(name, [])
241+
bbox = img.get("bbox")
242+
if bbox is not None:
243+
bbox_list.append(bbox)
244+
245+
stripped_names = list(bbox_by_name.keys())
246+
if not stripped_names:
247+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
248+
249+
# Drop image names used by more than one publication, we can't attribute those
250+
collision = (
251+
db.select(Figures.img_name)
252+
.group_by(Figures.img_name)
253+
.having(func.count(func.distinct(Figures.publication_figures_id)) > 1)
254+
)
255+
256+
# Pull the figures and their publication info, skip null urls, newest pubmed first
257+
core_stmt = (
258+
db.select(
259+
PubIds.pmc,
260+
PubIds.pubmed,
261+
PublicationFigures.id.label("pf_id"),
262+
PublicationFigures.title,
263+
PublicationFigures.abstract,
264+
Figures.img_name,
265+
Figures.img_url,
266+
Figures.caption,
267+
)
268+
.select_from(Figures)
269+
.join(PublicationFigures, PublicationFigures.id == Figures.publication_figures_id)
270+
.join(PubIds, PubIds.publication_figures_id == PublicationFigures.id)
271+
.where(Figures.img_name.in_(stripped_names))
272+
.where(Figures.img_url.isnot(None))
273+
.where(Figures.img_name.not_in(collision))
274+
.order_by(cast(PubIds.pubmed, mysql.INTEGER(unsigned=True)).desc())
275+
)
276+
fig_rows = db.session.execute(core_stmt).fetchall()
277+
278+
if not fig_rows:
279+
return BARUtils.success_exit({"figures": {}, "allImageWords": {}})
280+
281+
# Group figures by PMC, one entry per image name
282+
figures_by_pmc, pmc_to_pf, pf_ids, seen_names = {}, {}, set(), set()
283+
for r in fig_rows:
284+
pf_ids.add(r.pf_id)
285+
pmc_to_pf[r.pmc] = r.pf_id
286+
if r.pmc not in figures_by_pmc:
287+
figures_by_pmc[r.pmc] = {
288+
"title": r.title,
289+
"abstract": r.abstract,
290+
"authors": [],
291+
"pubmed": r.pubmed,
292+
"figures": [],
293+
}
294+
if r.img_name in seen_names:
295+
continue
296+
seen_names.add(r.img_name)
297+
figures_by_pmc[r.pmc]["figures"].append(
298+
{
299+
"img_name": r.img_name,
300+
"img_url": r.img_url,
301+
"caption": r.caption,
302+
"bbox": bbox_by_name.get(r.img_name, []),
303+
}
304+
)
305+
306+
# Attach authors to each publication
307+
authors_by_pf = {}
308+
for r in db.session.execute(
309+
db.select(AuthorList.publication_figures_id, AuthorList.author).filter(
310+
AuthorList.publication_figures_id.in_(pf_ids)
311+
)
312+
).fetchall():
313+
authors_by_pf.setdefault(r.publication_figures_id, []).append(r.author)
314+
for pmc, pf_id in pmc_to_pf.items():
315+
figures_by_pmc[pmc]["authors"] = authors_by_pf.get(pf_id, [])
316+
317+
# allImageWords: gene words detected on the shown figures, for the gene-name filter
318+
displayed_names = list({r.img_name for r in fig_rows})
319+
all_image_words = {}
320+
if displayed_names:
321+
displayed_slashed = json.dumps(["/" + n for n in displayed_names]) # stored names keep a leading /
322+
words_rows = db.session.execute(
323+
db.select(FigureModels.data)
324+
.where(func.json_unquote(func.json_extract(FigureModels.data, "$.gene")) == "true")
325+
.where(
326+
func.json_overlaps(
327+
func.json_extract(FigureModels.data, "$.image[*].imageName"),
328+
cast(literal(displayed_slashed), mysql.JSON),
329+
)
330+
)
331+
).fetchall()
332+
displayed_set = set(displayed_names)
333+
for row in words_rows:
334+
d = row.data if isinstance(row.data, dict) else json.loads(row.data)
335+
word = (d.get("word") or "").lower()
336+
for img in d.get("image", []):
337+
name = (img.get("imageName") or "").lstrip("/")
338+
if name in displayed_set:
339+
bbox = img.get("bbox")
340+
all_image_words.setdefault(word, {})[name] = bbox if bbox is not None else []
341+
342+
# Return final data
343+
return BARUtils.success_exit({"figures": figures_by_pmc, "allImageWords": all_image_words})

‎config/BAR_API.cfg‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,5 +42,6 @@ SQLALCHEMY_BINDS = {
4242
'striga' : 'mysql://root:root@localhost/striga',
4343
'tomato_nssnp' : 'mysql://root:root@localhost/tomato_nssnp',
4444
'tomato_sequence' : 'mysql://root:root@localhost/tomato_sequence',
45-
'triphysaria' : 'mysql://root:root@localhost/triphysaria'
45+
'triphysaria' : 'mysql://root:root@localhost/triphysaria',
46+
'gaia' : 'mysql://root:root@localhost/gaia'
4647
}

0 commit comments

Comments
 (0)