Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Retrieve images where an object of class $class_a is LEFT_OF an object of class $class_b.
// Params: $class_a, $class_b
MATCH (img:Image)-[:CONTAINS]->(a:ObjectInstance)-[:IS_A]->(:Class {name: $class_a})
MATCH (img)-[:CONTAINS]->(b:ObjectInstance)-[:IS_A]->(:Class {name: $class_b})
MATCH (a)-[:LEFT_OF]->(b)
RETURN DISTINCT img.id AS image_id
ORDER BY image_id
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Retrieve images where an object of class $class_a is NEAR an object of class $class_b.
// Params: $class_a, $class_b
// Why a graph: this requires a relationship between two specific instances of the same
// image. Plain co-occurrence of the classes (images_with_two_classes) cannot express it.
MATCH (img:Image)-[:CONTAINS]->(a:ObjectInstance)-[:IS_A]->(:Class {name: $class_a})
MATCH (img)-[:CONTAINS]->(b:ObjectInstance)-[:IS_A]->(:Class {name: $class_b})
MATCH (a)-[:NEAR]-(b)
RETURN DISTINCT img.id AS image_id
ORDER BY image_id
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Retrieve images containing two specified classes.
// Params: $class_a, $class_b
MATCH (img:Image)-[:CONTAINS]->(:ObjectInstance)-[:IS_A]->(:Class {name: $class_a})
MATCH (img)-[:CONTAINS]->(:ObjectInstance)-[:IS_A]->(:Class {name: $class_b})
RETURN DISTINCT img.id AS image_id
ORDER BY image_id
5 changes: 5 additions & 0 deletions backend/src/visual_graph/graph/cypher/objects_by_class.cypher
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Retrieve objects by class (across all images).
// Params: $class_name
MATCH (img:Image)-[:CONTAINS]->(o:ObjectInstance)-[:IS_A]->(:Class {name: $class_name})
RETURN img.id AS image_id, o.id AS object_id
ORDER BY image_id, object_id
5 changes: 5 additions & 0 deletions backend/src/visual_graph/graph/cypher/objects_in_image.cypher
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Retrieve all objects contained in an image.
// Params: $image_id
MATCH (:Image {id: $image_id})-[:CONTAINS]->(o:ObjectInstance)-[:IS_A]->(c:Class)
RETURN o.id AS object_id, c.name AS class
ORDER BY object_id
5 changes: 5 additions & 0 deletions backend/src/visual_graph/graph/cypher/objects_near.cypher
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Retrieve objects near another object (NEAR is symmetric: match without direction).
// Params: $object_id
MATCH (:ObjectInstance {id: $object_id})-[:NEAR]-(other:ObjectInstance)-[:IS_A]->(c:Class)
RETURN DISTINCT other.id AS object_id, c.name AS class
ORDER BY object_id
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// Retrieve spatial relationships between objects of an image.
// Params: $image_id
MATCH (:Image {id: $image_id})-[:CONTAINS]->(a:ObjectInstance)
MATCH (a)-[r:LEFT_OF|RIGHT_OF|ABOVE|BELOW|NEAR|OVERLAPS]->(b:ObjectInstance)
RETURN a.id AS source, type(r) AS relationship, b.id AS target
ORDER BY source, relationship, target
22 changes: 22 additions & 0 deletions backend/src/visual_graph/graph/queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"""Reusable Cypher queries (``cypher/*.cypher``) for the backend."""

from importlib import resources
from typing import Any

from neo4j import Driver


def load_query(name: str) -> str:
return (
resources.files("visual_graph.graph")
.joinpath(f"cypher/{name}.cypher")
.read_text(encoding="utf-8")
)


def run_query(
driver: Driver, name: str, database: str | None = None, **params: Any
) -> list[dict[str, Any]]:
"""Run the named query file and return rows as dictionaries."""
with driver.session(database=database) as session:
return [record.data() for record in session.run(load_query(name), **params)]
81 changes: 81 additions & 0 deletions backend/tests/integration/test_queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from collections.abc import Iterator

import pytest
from neo4j import Driver

from visual_graph.graph.connection import create_driver
from visual_graph.graph.queries import run_query
from visual_graph.graph.seed import SAMPLE_IMAGE_ID, seed_sample_scene

pytestmark = pytest.mark.integration


@pytest.fixture(scope="module")
def driver() -> Iterator[Driver]:
drv = create_driver()
with drv.session() as session:
session.run("MATCH (n) DETACH DELETE n").consume()
seed_sample_scene(drv)
yield drv
with drv.session() as session:
session.run("MATCH (n) DETACH DELETE n").consume()
drv.close()


def test_objects_in_image(driver: Driver) -> None:
rows = run_query(driver, "objects_in_image", image_id=SAMPLE_IMAGE_ID)
assert rows == [
{"object_id": "sample-bicycle-1", "class": "bicycle"},
{"object_id": "sample-chair-1", "class": "chair"},
{"object_id": "sample-chair-2", "class": "chair"},
{"object_id": "sample-dog-1", "class": "dog"},
{"object_id": "sample-person-1", "class": "person"},
]


def test_objects_by_class_returns_every_instance(driver: Driver) -> None:
rows = run_query(driver, "objects_by_class", class_name="chair")
assert [r["object_id"] for r in rows] == ["sample-chair-1", "sample-chair-2"]
assert run_query(driver, "objects_by_class", class_name="cat") == []


def test_relationships_in_image(driver: Driver) -> None:
rows = run_query(driver, "relationships_in_image", image_id=SAMPLE_IMAGE_ID)
triples = {(r["source"], r["relationship"], r["target"]) for r in rows}
assert ("sample-person-1", "NEAR", "sample-dog-1") in triples
assert ("sample-person-1", "LEFT_OF", "sample-bicycle-1") in triples
assert ("sample-dog-1", "BELOW", "sample-person-1") in triples
assert len(rows) == 13 # matches the seed exactly


def test_objects_near_is_symmetric(driver: Driver) -> None:
from_person = run_query(driver, "objects_near", object_id="sample-person-1")
from_dog = run_query(driver, "objects_near", object_id="sample-dog-1")
assert from_person == [{"object_id": "sample-dog-1", "class": "dog"}]
assert from_dog == [{"object_id": "sample-person-1", "class": "person"}]


def test_images_with_two_classes(driver: Driver) -> None:
rows = run_query(driver, "images_with_two_classes", class_a="person", class_b="dog")
assert rows == [{"image_id": SAMPLE_IMAGE_ID}]
assert run_query(driver, "images_with_two_classes", class_a="person", class_b="cat") == []


def test_spatial_pattern_queries(driver: Driver) -> None:
near = run_query(driver, "images_with_near_pattern", class_a="person", class_b="dog")
assert near == [{"image_id": SAMPLE_IMAGE_ID}]
left = run_query(driver, "images_with_left_of_pattern", class_a="person", class_b="bicycle")
assert left == [{"image_id": SAMPLE_IMAGE_ID}]
reverse = run_query(driver, "images_with_left_of_pattern", class_a="bicycle", class_b="person")
assert reverse == []


def test_graph_relationships_distinguish_what_class_cooccurrence_cannot(driver: Driver) -> None:
"""A person and a chair appear in the same image, but they are not near each other.

Class co-occurrence matches; the NEAR pattern correctly does not.
"""
both = run_query(driver, "images_with_two_classes", class_a="person", class_b="chair")
near = run_query(driver, "images_with_near_pattern", class_a="person", class_b="chair")
assert both == [{"image_id": SAMPLE_IMAGE_ID}]
assert near == []
Loading