From d9fa96a26d16ae0a1034617ed352089ef10c43ed Mon Sep 17 00:00:00 2001 From: raphaellyzzz Date: Fri, 6 Nov 2026 21:57:31 -0300 Subject: [PATCH] feat(neo4j): create sample scene graph manually Closes #5 --- README.md | 17 ++++ .../graph/cypher/seed_sample_scene.cypher | 51 ++++++++++ backend/src/visual_graph/graph/seed.py | 40 ++++++++ backend/tests/integration/test_seed.py | 95 +++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher create mode 100644 backend/src/visual_graph/graph/seed.py create mode 100644 backend/tests/integration/test_seed.py diff --git a/README.md b/README.md index 6ea4bec..78adc7c 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,23 @@ cd backend uv run python -m visual_graph.graph ``` +### Sample scene graph + +Loads a small hand-made scene (person, dog, bicycle, two chairs) and is idempotent: + +```bash +cd backend +uv run python -m visual_graph.graph.seed +``` + +Then open and run: + +```cypher +MATCH p=(:Image {id: "sample-street-1"})-[*1..2]->() RETURN p +``` + +The seed lives in `backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher`. + ## License This project is licensed under the MIT License. diff --git a/backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher b/backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher new file mode 100644 index 0000000..8132652 --- /dev/null +++ b/backend/src/visual_graph/graph/cypher/seed_sample_scene.cypher @@ -0,0 +1,51 @@ +// Sample scene graph created manually (no computer vision involved). +// Idempotent: uses MERGE, so it can be run repeatedly without duplicating data. +// Coordinates: origin top-left, y grows downward. Image is 1280x720. + +MERGE (img:Image {id: "sample-street-1"}) + SET img.uri = "samples/street.jpg", img.width = 1280, img.height = 720 + +MERGE (person:Class {name: "person"}) +MERGE (dog:Class {name: "dog"}) +MERGE (bicycle:Class {name: "bicycle"}) +MERGE (chair:Class {name: "chair"}) + +MERGE (o_person:ObjectInstance {id: "sample-person-1"}) + SET o_person.x_min = 100, o_person.y_min = 100, o_person.x_max = 300, o_person.y_max = 600 +MERGE (o_dog:ObjectInstance {id: "sample-dog-1"}) + SET o_dog.x_min = 320, o_dog.y_min = 450, o_dog.x_max = 420, o_dog.y_max = 600 +MERGE (o_bike:ObjectInstance {id: "sample-bicycle-1"}) + SET o_bike.x_min = 500, o_bike.y_min = 200, o_bike.x_max = 800, o_bike.y_max = 650 +// Two chairs: several instances of the same class in one image. +MERGE (o_chair1:ObjectInstance {id: "sample-chair-1"}) + SET o_chair1.x_min = 900, o_chair1.y_min = 300, o_chair1.x_max = 1050, o_chair1.y_max = 600 +MERGE (o_chair2:ObjectInstance {id: "sample-chair-2"}) + SET o_chair2.x_min = 1100, o_chair2.y_min = 320, o_chair2.x_max = 1230, o_chair2.y_max = 600 + +MERGE (img)-[:CONTAINS]->(o_person) +MERGE (img)-[:CONTAINS]->(o_dog) +MERGE (img)-[:CONTAINS]->(o_bike) +MERGE (img)-[:CONTAINS]->(o_chair1) +MERGE (img)-[:CONTAINS]->(o_chair2) + +MERGE (o_person)-[:IS_A]->(person) +MERGE (o_dog)-[:IS_A]->(dog) +MERGE (o_bike)-[:IS_A]->(bicycle) +MERGE (o_chair1)-[:IS_A]->(chair) +MERGE (o_chair2)-[:IS_A]->(chair) + +// Spatial relationships (directional ones are stored in both directions; +// NEAR is symmetric and stored once). +MERGE (o_person)-[:NEAR]->(o_dog) +MERGE (o_person)-[:LEFT_OF]->(o_bike) +MERGE (o_bike)-[:RIGHT_OF]->(o_person) +MERGE (o_dog)-[:BELOW]->(o_person) +MERGE (o_person)-[:ABOVE]->(o_dog) +MERGE (o_dog)-[:LEFT_OF]->(o_bike) +MERGE (o_bike)-[:RIGHT_OF]->(o_dog) +MERGE (o_bike)-[:NEAR]->(o_chair1) +MERGE (o_bike)-[:LEFT_OF]->(o_chair1) +MERGE (o_chair1)-[:RIGHT_OF]->(o_bike) +MERGE (o_chair1)-[:LEFT_OF]->(o_chair2) +MERGE (o_chair2)-[:RIGHT_OF]->(o_chair1) +MERGE (o_chair1)-[:NEAR]->(o_chair2) diff --git a/backend/src/visual_graph/graph/seed.py b/backend/src/visual_graph/graph/seed.py new file mode 100644 index 0000000..c8de606 --- /dev/null +++ b/backend/src/visual_graph/graph/seed.py @@ -0,0 +1,40 @@ +"""Load the manually created sample scene graph into Neo4j.""" + +from importlib import resources + +from neo4j import Driver + +from visual_graph.graph.connection import create_driver, verify_connection +from visual_graph.graph.schema import apply_schema + +SAMPLE_IMAGE_ID = "sample-street-1" + + +def sample_scene_cypher() -> str: + return ( + resources.files("visual_graph.graph") + .joinpath("cypher/seed_sample_scene.cypher") + .read_text(encoding="utf-8") + ) + + +def seed_sample_scene(driver: Driver, database: str | None = None) -> None: + """Create the schema (if needed) and the sample scene. Safe to run repeatedly.""" + apply_schema(driver, database) + with driver.session(database=database) as session: + session.run(sample_scene_cypher()).consume() + + +def main() -> None: + driver = create_driver() + try: + verify_connection(driver) + seed_sample_scene(driver) + finally: + driver.close() + print(f"Sample scene '{SAMPLE_IMAGE_ID}' loaded. Inspect it in Neo4j Browser with:") + print(f'MATCH p=(:Image {{id: "{SAMPLE_IMAGE_ID}"}})-[*1..2]->() RETURN p') + + +if __name__ == "__main__": + main() diff --git a/backend/tests/integration/test_seed.py b/backend/tests/integration/test_seed.py new file mode 100644 index 0000000..bff47bb --- /dev/null +++ b/backend/tests/integration/test_seed.py @@ -0,0 +1,95 @@ +from collections.abc import Iterator + +import pytest +from neo4j import Driver + +from visual_graph.graph.connection import create_driver +from visual_graph.graph.seed import SAMPLE_IMAGE_ID, seed_sample_scene + +pytestmark = pytest.mark.integration + + +@pytest.fixture +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 scalar(driver: Driver, query: str) -> int: + with driver.session() as session: + record = session.run(query, image_id=SAMPLE_IMAGE_ID).single() + assert record is not None + return int(record[0]) + + +def test_graph_shape(driver: Driver) -> None: + assert scalar(driver, "MATCH (:Image) RETURN count(*)") == 1 + assert scalar(driver, "MATCH (:ObjectInstance) RETURN count(*)") == 5 + assert scalar(driver, "MATCH (:Class) RETURN count(*)") == 4 + + +def test_seed_is_idempotent(driver: Driver) -> None: + before = scalar(driver, "MATCH (n) RETURN count(n)"), scalar( + driver, "MATCH ()-[r]->() RETURN count(r)" + ) + seed_sample_scene(driver) + after = scalar(driver, "MATCH (n) RETURN count(n)"), scalar( + driver, "MATCH ()-[r]->() RETURN count(r)" + ) + assert before == after + + +def test_every_instance_has_one_class_and_image(driver: Driver) -> None: + assert ( + scalar( + driver, + "MATCH (o:ObjectInstance) " + "WHERE size([(o)-[:IS_A]->(:Class) | 1]) <> 1 " + "OR size([(:Image)-[:CONTAINS]->(o) | 1]) <> 1 RETURN count(o)", + ) + == 0 + ) + + +def test_multiple_instances_of_same_class(driver: Driver) -> None: + assert ( + scalar( + driver, + "MATCH (:Image {id: $image_id})-[:CONTAINS]->(o)-[:IS_A]->(:Class {name: 'chair'}) " + "RETURN count(o)", + ) + == 2 + ) + + +def test_spatial_relationships_are_queryable(driver: Driver) -> None: + assert ( + scalar( + driver, + "MATCH (:Class {name:'person'})<-[:IS_A]-(p)-[:NEAR]-(d)" + "-[:IS_A]->(:Class {name:'dog'}) RETURN count(*)", + ) + == 1 + ) + assert ( + scalar( + driver, + "MATCH (:Class {name:'person'})<-[:IS_A]-(p)-[:LEFT_OF]->(b)-[:IS_A]->" + "(:Class {name:'bicycle'}) RETURN count(*)", + ) + == 1 + ) + assert ( + scalar( + driver, + "MATCH (:Class {name:'dog'})<-[:IS_A]-(d)-[:BELOW]->(p)-[:IS_A]->" + "(:Class {name:'person'}) RETURN count(*)", + ) + == 1 + )