Frame2KG-Vis / app.py
lewiswatson's picture
Restore graph upload and keep paste input
cc9c644 verified
Raw
History Blame Contribute Delete
20.1 kB
from __future__ import annotations
import base64
import json
import mimetypes
from pathlib import Path
from typing import Any
import gradio as gr
from PIL import Image
from kgvis.plot import build_figure, empty_figure
from kgvis.schema import graph_from_dict, graph_to_dict, parse_graph
DEFAULT_TOGGLES = ["nodes", "edges", "node_labels", "edge_labels"]
CSS = """
.kgvis-shell { max-width: 1600px; margin: 0 auto; }
.kgvis-status textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.kgvis-plot { min-height: 640px; }
"""
def _path_from_upload(upload: Any) -> Path | None:
if upload is None:
return None
if isinstance(upload, (str, Path)):
return Path(upload)
if isinstance(upload, dict):
for key in ("path", "name"):
if upload.get(key):
return Path(upload[key])
name = getattr(upload, "name", None)
if name:
return Path(name)
return None
def _upload_name(upload: Any, path: Path) -> str:
if isinstance(upload, dict):
return str(upload.get("orig_name") or upload.get("name") or path.name)
return str(getattr(upload, "orig_name", None) or path.name)
def _image_state_from_upload(upload: Any) -> dict[str, Any]:
path = _path_from_upload(upload)
if path is None:
raise ValueError("No image file was provided.")
if not path.exists():
raise FileNotFoundError(f"Uploaded image not found: {path}")
with Image.open(path) as img:
width, height = img.size
if height == 0:
raise ValueError("Image height is zero.")
mime, _ = mimetypes.guess_type(path.name)
if not mime:
mime = "image/png"
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return {
"name": _upload_name(upload, path),
"data_uri": f"data:{mime};base64,{encoded}",
"aspect": width / height,
}
def _load_graph_text(text: str) -> tuple[dict[str, Any], dict[str, Any] | str]:
if not text.strip():
raise ValueError("Paste JSON or token graph text first.")
try:
raw = json.loads(text)
except json.JSONDecodeError:
raw = text
graph = parse_graph(raw)
return graph_to_dict(graph), raw
def _load_graph_upload(upload: Any) -> tuple[dict[str, Any], dict[str, Any] | str, str]:
path = _path_from_upload(upload)
if path is None:
raise ValueError("No graph file was provided.")
if not path.exists():
raise FileNotFoundError(f"Uploaded graph file not found: {path}")
graph_payload, raw_graph = _load_graph_text(path.read_text(encoding="utf-8"))
return graph_payload, raw_graph, _upload_name(upload, path)
def _raw_display(raw: dict[str, Any] | str | None) -> str:
if raw is None:
return ""
if isinstance(raw, str):
return raw
return json.dumps(raw, indent=2, ensure_ascii=False)
def _node_choices(graph_payload: dict[str, Any] | None) -> list[tuple[str, str]]:
if not graph_payload:
return []
graph = graph_from_dict(graph_payload)
return [
(f"{node.label or node.id} ({node.id})", node.id)
for node in graph.nodes
if node.id
]
def _summary(graph_payload: dict[str, Any] | None, graph_name: str | None = None) -> str:
if not graph_payload:
return "No graph loaded."
graph = graph_from_dict(graph_payload)
name = f"`{graph_name}`: " if graph_name else ""
schema = graph.meta.get("schema", "unknown")
return f"{name}{len(graph.nodes)} nodes, {len(graph.edges)} edges (schema: {schema})"
def _dangling_markdown(graph_payload: dict[str, Any] | None) -> str:
if not graph_payload:
return "Dangling edges: 0"
graph = graph_from_dict(graph_payload)
node_ids = {node.id for node in graph.nodes}
dangling = [
edge
for edge in graph.edges
if edge.source not in node_ids or edge.target not in node_ids
]
if not dangling:
return "Dangling edges: 0"
lines = [f"Dangling edges: {len(dangling)}"]
lines.extend(
f"- `{edge.predicate}`: `{edge.source}` -> `{edge.target}`"
for edge in dangling
)
return "\n".join(lines)
def _edge_rows(graph_payload: dict[str, Any] | None) -> list[list[str]]:
if not graph_payload:
return []
graph = graph_from_dict(graph_payload)
node_ids = {node.id for node in graph.nodes}
return [
[
edge.source,
edge.predicate,
edge.target,
"yes" if edge.source not in node_ids or edge.target not in node_ids else "no",
]
for edge in graph.edges
]
def _node_details(
graph_payload: dict[str, Any] | None,
selected_node_id: str | None,
) -> dict[str, Any]:
if not graph_payload:
return {"message": "No graph loaded."}
if not selected_node_id:
return {"message": "Select a node to see details."}
graph = graph_from_dict(graph_payload)
node = next((candidate for candidate in graph.nodes if candidate.id == selected_node_id), None)
if node is None:
return {"message": "Selected node not found."}
node_ids = {candidate.id for candidate in graph.nodes}
outgoing = [
{
"predicate": edge.predicate,
"source": edge.source,
"target": edge.target,
}
for edge in graph.edges
if edge.source == selected_node_id and edge.target in node_ids
]
incoming = [
{
"predicate": edge.predicate,
"source": edge.source,
"target": edge.target,
}
for edge in graph.edges
if edge.target == selected_node_id and edge.source in node_ids
]
dangling = [
{
"predicate": edge.predicate,
"source": edge.source,
"target": edge.target,
}
for edge in graph.edges
if (
edge.source == selected_node_id
and edge.target not in node_ids
or edge.target == selected_node_id
and edge.source not in node_ids
)
]
return {
"id": node.id,
"label": node.label,
"attributes": node.attributes,
"outgoing_edges": outgoing,
"incoming_edges": incoming,
"dangling_edges": dangling,
}
def _render_figure(
graph_payload: dict[str, Any] | None,
image_payload: dict[str, Any] | None,
toggles: list[str] | None,
layout_mode: str | None,
show_image: bool,
background_opacity: float | None,
show_bboxes: bool,
selected_node_id: str | None,
):
if not graph_payload:
return empty_figure("Upload a graph file to begin.")
graph = graph_from_dict(graph_payload)
active_toggles = toggles or []
background = None
background_aspect = None
if show_image and image_payload:
background = image_payload.get("data_uri")
background_aspect = image_payload.get("aspect")
return build_figure(
graph,
show_nodes="nodes" in active_toggles,
show_edges="edges" in active_toggles,
show_node_labels="node_labels" in active_toggles and "nodes" in active_toggles,
show_edge_labels="edge_labels" in active_toggles and "edges" in active_toggles,
selected_node_id=selected_node_id,
layout_mode=layout_mode or "force",
background_image=background,
background_aspect=background_aspect,
background_opacity=background_opacity if background_opacity is not None else 0.35,
show_bboxes=show_bboxes,
highlight_node_id=selected_node_id,
)
def _render_visual_outputs(
graph_payload: dict[str, Any] | None,
image_payload: dict[str, Any] | None,
toggles: list[str] | None,
layout_mode: str | None,
show_image: bool,
background_opacity: float | None,
show_bboxes: bool,
selected_node_id: str | None,
):
return (
_render_figure(
graph_payload,
image_payload,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node_id,
),
_node_details(graph_payload, selected_node_id),
)
def handle_graph_upload(
graph_file: Any,
image_payload: dict[str, Any] | None,
toggles: list[str] | None,
layout_mode: str | None,
show_image: bool,
background_opacity: float | None,
show_bboxes: bool,
):
if graph_file is None:
return (
None,
None,
None,
gr.update(choices=[], value=None),
"No graph loaded.",
"Dangling edges: 0",
[],
empty_figure("Upload or paste a graph to begin."),
{"message": "No graph loaded."},
"",
"Graph cleared.",
)
try:
graph_payload, raw_graph, graph_name = _load_graph_upload(graph_file)
except Exception as exc:
return (
None,
None,
None,
gr.update(choices=[], value=None),
f"Failed to parse graph: {exc}",
"Dangling edges: 0",
[],
empty_figure("Failed to parse graph file."),
{"message": "No graph loaded."},
"",
f"Upload failed: {exc}",
)
return (
graph_payload,
raw_graph,
graph_name,
gr.update(choices=_node_choices(graph_payload), value=None),
_summary(graph_payload, graph_name),
_dangling_markdown(graph_payload),
_edge_rows(graph_payload),
_render_figure(
graph_payload,
image_payload,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
None,
),
_node_details(graph_payload, None),
_raw_display(raw_graph),
f"Loaded {graph_name}.",
)
def handle_graph_text(
graph_text: str | None,
image_payload: dict[str, Any] | None,
toggles: list[str] | None,
layout_mode: str | None,
show_image: bool,
background_opacity: float | None,
show_bboxes: bool,
):
if not graph_text or not graph_text.strip():
return (
None,
None,
None,
gr.update(choices=[], value=None),
"No graph loaded.",
"Dangling edges: 0",
[],
empty_figure("Paste a graph to begin."),
{"message": "No graph loaded."},
"",
"Graph cleared.",
)
try:
graph_payload, raw_graph = _load_graph_text(graph_text)
except Exception as exc:
return (
None,
None,
None,
gr.update(choices=[], value=None),
f"Failed to parse graph: {exc}",
"Dangling edges: 0",
[],
empty_figure("Failed to parse graph text."),
{"message": "No graph loaded."},
"",
f"Parse failed: {exc}",
)
return (
graph_payload,
raw_graph,
"pasted graph",
gr.update(choices=_node_choices(graph_payload), value=None),
_summary(graph_payload, "pasted graph"),
_dangling_markdown(graph_payload),
_edge_rows(graph_payload),
_render_figure(
graph_payload,
image_payload,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
None,
),
_node_details(graph_payload, None),
_raw_display(raw_graph),
"Parsed pasted graph.",
)
def handle_image_upload(
image_file: Any,
graph_payload: dict[str, Any] | None,
toggles: list[str] | None,
layout_mode: str | None,
show_image: bool,
background_opacity: float | None,
show_bboxes: bool,
selected_node_id: str | None,
):
if image_file is None:
return (
None,
_render_figure(
graph_payload,
None,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node_id,
),
"Image cleared.",
)
try:
image_payload = _image_state_from_upload(image_file)
except Exception as exc:
return (
None,
_render_figure(
graph_payload,
None,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node_id,
),
f"Image upload failed: {exc}",
)
return (
image_payload,
_render_figure(
graph_payload,
image_payload,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node_id,
),
f"Loaded image {image_payload['name']}.",
)
def create_app() -> gr.Blocks:
with gr.Blocks(title="KGVis", fill_width=True) as app:
graph_state = gr.State(None)
raw_state = gr.State(None)
graph_name_state = gr.State(None)
image_state = gr.State(None)
with gr.Column(elem_classes=["kgvis-shell"]):
gr.Markdown("# KGVis")
with gr.Row():
with gr.Column(scale=1, min_width=300):
graph_file = gr.File(
label="Upload Graph",
file_count="single",
file_types=[".json", ".txt"],
type="filepath",
)
graph_text = gr.Textbox(
label="Paste Graph",
placeholder="Paste JSON or <|graph|> token text here.",
interactive=True,
lines=10,
max_lines=24,
)
parse_graph_button = gr.Button(
"Parse Graph",
variant="primary",
)
image_file = gr.File(
label="Upload Image",
file_count="single",
file_types=[".png", ".jpg", ".jpeg"],
type="filepath",
)
status = gr.Textbox(
label="Status",
value="Upload a graph file, or paste a graph and click Parse Graph.",
interactive=False,
lines=2,
elem_classes=["kgvis-status"],
)
toggles = gr.CheckboxGroup(
label="View Options",
choices=[
("Show nodes", "nodes"),
("Show edges", "edges"),
("Show node labels", "node_labels"),
("Show edge labels", "edge_labels"),
],
value=DEFAULT_TOGGLES,
)
layout_mode = gr.Radio(
label="Layout",
choices=[
("Force layout", "force"),
("Image layout (bbox)", "bbox"),
],
value="force",
)
show_bboxes = gr.Checkbox(label="Show bounding boxes", value=False)
show_image = gr.Checkbox(label="Show image", value=True)
background_opacity = gr.Slider(
label="Background opacity",
minimum=0.0,
maximum=1.0,
step=0.05,
value=0.35,
)
selected_node = gr.Dropdown(
label="Selected Node",
choices=[],
value=None,
interactive=True,
)
with gr.Column(scale=3, min_width=640):
summary = gr.Markdown("No graph loaded.")
plot = gr.Plot(
label="Graph",
value=empty_figure("Upload or paste a graph to begin."),
elem_classes=["kgvis-plot"],
)
dangling = gr.Markdown("Dangling edges: 0")
with gr.Row():
with gr.Column(scale=1):
node_details = gr.JSON(
label="Node Details",
value={"message": "No graph loaded."},
)
with gr.Column(scale=1):
edge_table = gr.Dataframe(
label="Edges",
headers=["Source", "Predicate", "Target", "Dangling"],
datatype=["str", "str", "str", "str"],
interactive=False,
)
raw_json = gr.Textbox(
label="Raw Graph",
value="",
interactive=False,
lines=12,
max_lines=24,
)
graph_file.change(
handle_graph_upload,
inputs=[
graph_file,
image_state,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
],
outputs=[
graph_state,
raw_state,
graph_name_state,
selected_node,
summary,
dangling,
edge_table,
plot,
node_details,
raw_json,
status,
],
)
parse_graph_button.click(
handle_graph_text,
inputs=[
graph_text,
image_state,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
],
outputs=[
graph_state,
raw_state,
graph_name_state,
selected_node,
summary,
dangling,
edge_table,
plot,
node_details,
raw_json,
status,
],
)
image_file.change(
handle_image_upload,
inputs=[
image_file,
graph_state,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node,
],
outputs=[image_state, plot, status],
)
for control in (
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node,
):
control.change(
_render_visual_outputs,
inputs=[
graph_state,
image_state,
toggles,
layout_mode,
show_image,
background_opacity,
show_bboxes,
selected_node,
],
outputs=[plot, node_details],
)
return app
if __name__ == "__main__":
create_app().launch(css=CSS)