lewiswatson commited on
Commit
f32a7f1
·
verified ·
1 Parent(s): b2a0ecb

Upload self-contained KGVis Gradio Space

Browse files
README.md CHANGED
@@ -1,14 +1,32 @@
1
  ---
2
- title: Frame2KG Vis
3
- emoji: 🚀
4
- colorFrom: blue
5
- colorTo: red
6
  sdk: gradio
7
- sdk_version: 6.16.0
8
- python_version: '3.13'
9
  app_file: app.py
10
- pinned: false
11
- short_description: Visualisation Tool for Frame2kg Outputs
12
  ---
13
 
14
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: KGVis
 
 
 
3
  sdk: gradio
 
 
4
  app_file: app.py
 
 
5
  ---
6
 
7
+ # KGVis
8
+
9
+ Standalone Hugging Face Space export for the KGVis visualizer.
10
+
11
+ Push the contents of this directory to the root of a Hugging Face Space
12
+ repository. The Space expects `app.py`, `requirements.txt`, and `kgvis/` at the
13
+ repository root.
14
+
15
+ This Space contains the minimal local `kgvis` package files needed by the
16
+ Gradio app. The package does not need to be published to PyPI because the
17
+ `kgvis/` directory is included directly in this repository.
18
+
19
+ ## Included
20
+
21
+ - `app.py`: Gradio upload-only visualizer.
22
+ - `requirements.txt`: external Python dependencies for Spaces.
23
+ - `kgvis/plot.py`: Plotly figure construction.
24
+ - `kgvis/schema/`: graph schema parsing.
25
+
26
+ ## Not Included
27
+
28
+ - No Dash app files.
29
+ - No storage helpers.
30
+ - No saved data, uploaded images, or local test datasets.
31
+
32
+ Upload one graph JSON file and, optionally, one matching image file in the UI.
app.py ADDED
@@ -0,0 +1,540 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import mimetypes
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import gradio as gr
10
+ from PIL import Image
11
+
12
+ from kgvis.plot import build_figure, empty_figure
13
+ from kgvis.schema import graph_from_dict, graph_to_dict, parse_graph
14
+
15
+
16
+ DEFAULT_TOGGLES = ["nodes", "edges", "node_labels", "edge_labels"]
17
+
18
+ CSS = """
19
+ .kgvis-shell { max-width: 1600px; margin: 0 auto; }
20
+ .kgvis-status textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
21
+ .kgvis-plot { min-height: 640px; }
22
+ """
23
+
24
+
25
+ def _path_from_upload(upload: Any) -> Path | None:
26
+ if upload is None:
27
+ return None
28
+ if isinstance(upload, (str, Path)):
29
+ return Path(upload)
30
+ if isinstance(upload, dict):
31
+ for key in ("path", "name"):
32
+ if upload.get(key):
33
+ return Path(upload[key])
34
+ name = getattr(upload, "name", None)
35
+ if name:
36
+ return Path(name)
37
+ return None
38
+
39
+
40
+ def _upload_name(upload: Any, path: Path) -> str:
41
+ if isinstance(upload, dict):
42
+ return str(upload.get("orig_name") or upload.get("name") or path.name)
43
+ return str(getattr(upload, "orig_name", None) or path.name)
44
+
45
+
46
+ def _image_state_from_upload(upload: Any) -> dict[str, Any]:
47
+ path = _path_from_upload(upload)
48
+ if path is None:
49
+ raise ValueError("No image file was provided.")
50
+ if not path.exists():
51
+ raise FileNotFoundError(f"Uploaded image not found: {path}")
52
+
53
+ with Image.open(path) as img:
54
+ width, height = img.size
55
+ if height == 0:
56
+ raise ValueError("Image height is zero.")
57
+
58
+ mime, _ = mimetypes.guess_type(path.name)
59
+ if not mime:
60
+ mime = "image/png"
61
+ encoded = base64.b64encode(path.read_bytes()).decode("ascii")
62
+ return {
63
+ "name": _upload_name(upload, path),
64
+ "data_uri": f"data:{mime};base64,{encoded}",
65
+ "aspect": width / height,
66
+ }
67
+
68
+
69
+ def _load_graph_upload(upload: Any) -> tuple[dict[str, Any], dict[str, Any], str]:
70
+ path = _path_from_upload(upload)
71
+ if path is None:
72
+ raise ValueError("No JSON file was provided.")
73
+ if not path.exists():
74
+ raise FileNotFoundError(f"Uploaded JSON not found: {path}")
75
+
76
+ raw = json.loads(path.read_text(encoding="utf-8"))
77
+ graph = parse_graph(raw)
78
+ return graph_to_dict(graph), raw, _upload_name(upload, path)
79
+
80
+
81
+ def _node_choices(graph_payload: dict[str, Any] | None) -> list[tuple[str, str]]:
82
+ if not graph_payload:
83
+ return []
84
+ graph = graph_from_dict(graph_payload)
85
+ return [
86
+ (f"{node.label or node.id} ({node.id})", node.id)
87
+ for node in graph.nodes
88
+ if node.id
89
+ ]
90
+
91
+
92
+ def _summary(graph_payload: dict[str, Any] | None, graph_name: str | None = None) -> str:
93
+ if not graph_payload:
94
+ return "No graph loaded."
95
+ graph = graph_from_dict(graph_payload)
96
+ name = f"`{graph_name}`: " if graph_name else ""
97
+ schema = graph.meta.get("schema", "unknown")
98
+ return f"{name}{len(graph.nodes)} nodes, {len(graph.edges)} edges (schema: {schema})"
99
+
100
+
101
+ def _dangling_markdown(graph_payload: dict[str, Any] | None) -> str:
102
+ if not graph_payload:
103
+ return "Dangling edges: 0"
104
+ graph = graph_from_dict(graph_payload)
105
+ node_ids = {node.id for node in graph.nodes}
106
+ dangling = [
107
+ edge
108
+ for edge in graph.edges
109
+ if edge.source not in node_ids or edge.target not in node_ids
110
+ ]
111
+ if not dangling:
112
+ return "Dangling edges: 0"
113
+ lines = [f"Dangling edges: {len(dangling)}"]
114
+ lines.extend(
115
+ f"- `{edge.predicate}`: `{edge.source}` -> `{edge.target}`"
116
+ for edge in dangling
117
+ )
118
+ return "\n".join(lines)
119
+
120
+
121
+ def _edge_rows(graph_payload: dict[str, Any] | None) -> list[list[str]]:
122
+ if not graph_payload:
123
+ return []
124
+ graph = graph_from_dict(graph_payload)
125
+ node_ids = {node.id for node in graph.nodes}
126
+ return [
127
+ [
128
+ edge.source,
129
+ edge.predicate,
130
+ edge.target,
131
+ "yes" if edge.source not in node_ids or edge.target not in node_ids else "no",
132
+ ]
133
+ for edge in graph.edges
134
+ ]
135
+
136
+
137
+ def _node_details(
138
+ graph_payload: dict[str, Any] | None,
139
+ selected_node_id: str | None,
140
+ ) -> dict[str, Any]:
141
+ if not graph_payload:
142
+ return {"message": "No graph loaded."}
143
+ if not selected_node_id:
144
+ return {"message": "Select a node to see details."}
145
+
146
+ graph = graph_from_dict(graph_payload)
147
+ node = next((candidate for candidate in graph.nodes if candidate.id == selected_node_id), None)
148
+ if node is None:
149
+ return {"message": "Selected node not found."}
150
+
151
+ node_ids = {candidate.id for candidate in graph.nodes}
152
+ outgoing = [
153
+ {
154
+ "predicate": edge.predicate,
155
+ "source": edge.source,
156
+ "target": edge.target,
157
+ }
158
+ for edge in graph.edges
159
+ if edge.source == selected_node_id and edge.target in node_ids
160
+ ]
161
+ incoming = [
162
+ {
163
+ "predicate": edge.predicate,
164
+ "source": edge.source,
165
+ "target": edge.target,
166
+ }
167
+ for edge in graph.edges
168
+ if edge.target == selected_node_id and edge.source in node_ids
169
+ ]
170
+ dangling = [
171
+ {
172
+ "predicate": edge.predicate,
173
+ "source": edge.source,
174
+ "target": edge.target,
175
+ }
176
+ for edge in graph.edges
177
+ if (
178
+ edge.source == selected_node_id
179
+ and edge.target not in node_ids
180
+ or edge.target == selected_node_id
181
+ and edge.source not in node_ids
182
+ )
183
+ ]
184
+
185
+ return {
186
+ "id": node.id,
187
+ "label": node.label,
188
+ "attributes": node.attributes,
189
+ "outgoing_edges": outgoing,
190
+ "incoming_edges": incoming,
191
+ "dangling_edges": dangling,
192
+ }
193
+
194
+
195
+ def _render_figure(
196
+ graph_payload: dict[str, Any] | None,
197
+ image_payload: dict[str, Any] | None,
198
+ toggles: list[str] | None,
199
+ layout_mode: str | None,
200
+ show_image: bool,
201
+ background_opacity: float | None,
202
+ show_bboxes: bool,
203
+ selected_node_id: str | None,
204
+ ):
205
+ if not graph_payload:
206
+ return empty_figure("Upload a graph JSON to begin.")
207
+
208
+ graph = graph_from_dict(graph_payload)
209
+ active_toggles = toggles or []
210
+ background = None
211
+ background_aspect = None
212
+ if show_image and image_payload:
213
+ background = image_payload.get("data_uri")
214
+ background_aspect = image_payload.get("aspect")
215
+
216
+ return build_figure(
217
+ graph,
218
+ show_nodes="nodes" in active_toggles,
219
+ show_edges="edges" in active_toggles,
220
+ show_node_labels="node_labels" in active_toggles and "nodes" in active_toggles,
221
+ show_edge_labels="edge_labels" in active_toggles and "edges" in active_toggles,
222
+ selected_node_id=selected_node_id,
223
+ layout_mode=layout_mode or "force",
224
+ background_image=background,
225
+ background_aspect=background_aspect,
226
+ background_opacity=background_opacity if background_opacity is not None else 0.35,
227
+ show_bboxes=show_bboxes,
228
+ highlight_node_id=selected_node_id,
229
+ )
230
+
231
+
232
+ def _render_visual_outputs(
233
+ graph_payload: dict[str, Any] | None,
234
+ image_payload: dict[str, Any] | None,
235
+ toggles: list[str] | None,
236
+ layout_mode: str | None,
237
+ show_image: bool,
238
+ background_opacity: float | None,
239
+ show_bboxes: bool,
240
+ selected_node_id: str | None,
241
+ ):
242
+ return (
243
+ _render_figure(
244
+ graph_payload,
245
+ image_payload,
246
+ toggles,
247
+ layout_mode,
248
+ show_image,
249
+ background_opacity,
250
+ show_bboxes,
251
+ selected_node_id,
252
+ ),
253
+ _node_details(graph_payload, selected_node_id),
254
+ )
255
+
256
+
257
+ def handle_graph_upload(
258
+ graph_file: Any,
259
+ image_payload: dict[str, Any] | None,
260
+ toggles: list[str] | None,
261
+ layout_mode: str | None,
262
+ show_image: bool,
263
+ background_opacity: float | None,
264
+ show_bboxes: bool,
265
+ ):
266
+ if graph_file is None:
267
+ return (
268
+ None,
269
+ None,
270
+ None,
271
+ gr.update(choices=[], value=None),
272
+ "No graph loaded.",
273
+ "Dangling edges: 0",
274
+ [],
275
+ empty_figure("Upload a graph JSON to begin."),
276
+ {"message": "No graph loaded."},
277
+ None,
278
+ "Graph cleared.",
279
+ )
280
+
281
+ try:
282
+ graph_payload, raw_json, graph_name = _load_graph_upload(graph_file)
283
+ except Exception as exc:
284
+ return (
285
+ None,
286
+ None,
287
+ None,
288
+ gr.update(choices=[], value=None),
289
+ f"Failed to parse graph: {exc}",
290
+ "Dangling edges: 0",
291
+ [],
292
+ empty_figure("Failed to parse graph JSON."),
293
+ {"message": "No graph loaded."},
294
+ None,
295
+ f"Upload failed: {exc}",
296
+ )
297
+
298
+ return (
299
+ graph_payload,
300
+ raw_json,
301
+ graph_name,
302
+ gr.update(choices=_node_choices(graph_payload), value=None),
303
+ _summary(graph_payload, graph_name),
304
+ _dangling_markdown(graph_payload),
305
+ _edge_rows(graph_payload),
306
+ _render_figure(
307
+ graph_payload,
308
+ image_payload,
309
+ toggles,
310
+ layout_mode,
311
+ show_image,
312
+ background_opacity,
313
+ show_bboxes,
314
+ None,
315
+ ),
316
+ _node_details(graph_payload, None),
317
+ raw_json,
318
+ f"Loaded {graph_name}.",
319
+ )
320
+
321
+
322
+ def handle_image_upload(
323
+ image_file: Any,
324
+ graph_payload: dict[str, Any] | None,
325
+ toggles: list[str] | None,
326
+ layout_mode: str | None,
327
+ show_image: bool,
328
+ background_opacity: float | None,
329
+ show_bboxes: bool,
330
+ selected_node_id: str | None,
331
+ ):
332
+ if image_file is None:
333
+ return (
334
+ None,
335
+ _render_figure(
336
+ graph_payload,
337
+ None,
338
+ toggles,
339
+ layout_mode,
340
+ show_image,
341
+ background_opacity,
342
+ show_bboxes,
343
+ selected_node_id,
344
+ ),
345
+ "Image cleared.",
346
+ )
347
+
348
+ try:
349
+ image_payload = _image_state_from_upload(image_file)
350
+ except Exception as exc:
351
+ return (
352
+ None,
353
+ _render_figure(
354
+ graph_payload,
355
+ None,
356
+ toggles,
357
+ layout_mode,
358
+ show_image,
359
+ background_opacity,
360
+ show_bboxes,
361
+ selected_node_id,
362
+ ),
363
+ f"Image upload failed: {exc}",
364
+ )
365
+
366
+ return (
367
+ image_payload,
368
+ _render_figure(
369
+ graph_payload,
370
+ image_payload,
371
+ toggles,
372
+ layout_mode,
373
+ show_image,
374
+ background_opacity,
375
+ show_bboxes,
376
+ selected_node_id,
377
+ ),
378
+ f"Loaded image {image_payload['name']}.",
379
+ )
380
+
381
+
382
+ def create_app() -> gr.Blocks:
383
+ with gr.Blocks(title="KGVis", fill_width=True) as app:
384
+ graph_state = gr.State(None)
385
+ raw_state = gr.State(None)
386
+ graph_name_state = gr.State(None)
387
+ image_state = gr.State(None)
388
+
389
+ with gr.Column(elem_classes=["kgvis-shell"]):
390
+ gr.Markdown("# KGVis")
391
+
392
+ with gr.Row():
393
+ with gr.Column(scale=1, min_width=300):
394
+ graph_file = gr.File(
395
+ label="Upload JSON",
396
+ file_count="single",
397
+ file_types=[".json"],
398
+ type="filepath",
399
+ )
400
+ image_file = gr.File(
401
+ label="Upload Image",
402
+ file_count="single",
403
+ file_types=[".png", ".jpg", ".jpeg"],
404
+ type="filepath",
405
+ )
406
+ status = gr.Textbox(
407
+ label="Status",
408
+ value="Upload a graph JSON to begin.",
409
+ interactive=False,
410
+ lines=2,
411
+ elem_classes=["kgvis-status"],
412
+ )
413
+ toggles = gr.CheckboxGroup(
414
+ label="View Options",
415
+ choices=[
416
+ ("Show nodes", "nodes"),
417
+ ("Show edges", "edges"),
418
+ ("Show node labels", "node_labels"),
419
+ ("Show edge labels", "edge_labels"),
420
+ ],
421
+ value=DEFAULT_TOGGLES,
422
+ )
423
+ layout_mode = gr.Radio(
424
+ label="Layout",
425
+ choices=[
426
+ ("Force layout", "force"),
427
+ ("Image layout (bbox)", "bbox"),
428
+ ],
429
+ value="force",
430
+ )
431
+ show_bboxes = gr.Checkbox(label="Show bounding boxes", value=False)
432
+ show_image = gr.Checkbox(label="Show image", value=True)
433
+ background_opacity = gr.Slider(
434
+ label="Background opacity",
435
+ minimum=0.0,
436
+ maximum=1.0,
437
+ step=0.05,
438
+ value=0.35,
439
+ )
440
+ selected_node = gr.Dropdown(
441
+ label="Selected Node",
442
+ choices=[],
443
+ value=None,
444
+ interactive=True,
445
+ )
446
+
447
+ with gr.Column(scale=3, min_width=640):
448
+ summary = gr.Markdown("No graph loaded.")
449
+ plot = gr.Plot(
450
+ label="Graph",
451
+ value=empty_figure("Upload a graph JSON to begin."),
452
+ elem_classes=["kgvis-plot"],
453
+ )
454
+ dangling = gr.Markdown("Dangling edges: 0")
455
+
456
+ with gr.Row():
457
+ with gr.Column(scale=1):
458
+ node_details = gr.JSON(
459
+ label="Node Details",
460
+ value={"message": "No graph loaded."},
461
+ )
462
+ with gr.Column(scale=1):
463
+ edge_table = gr.Dataframe(
464
+ label="Edges",
465
+ headers=["Source", "Predicate", "Target", "Dangling"],
466
+ datatype=["str", "str", "str", "str"],
467
+ interactive=False,
468
+ )
469
+
470
+ raw_json = gr.JSON(label="Raw JSON", value=None)
471
+
472
+ graph_file.change(
473
+ handle_graph_upload,
474
+ inputs=[
475
+ graph_file,
476
+ image_state,
477
+ toggles,
478
+ layout_mode,
479
+ show_image,
480
+ background_opacity,
481
+ show_bboxes,
482
+ ],
483
+ outputs=[
484
+ graph_state,
485
+ raw_state,
486
+ graph_name_state,
487
+ selected_node,
488
+ summary,
489
+ dangling,
490
+ edge_table,
491
+ plot,
492
+ node_details,
493
+ raw_json,
494
+ status,
495
+ ],
496
+ )
497
+
498
+ image_file.change(
499
+ handle_image_upload,
500
+ inputs=[
501
+ image_file,
502
+ graph_state,
503
+ toggles,
504
+ layout_mode,
505
+ show_image,
506
+ background_opacity,
507
+ show_bboxes,
508
+ selected_node,
509
+ ],
510
+ outputs=[image_state, plot, status],
511
+ )
512
+
513
+ for control in (
514
+ toggles,
515
+ layout_mode,
516
+ show_image,
517
+ background_opacity,
518
+ show_bboxes,
519
+ selected_node,
520
+ ):
521
+ control.change(
522
+ _render_visual_outputs,
523
+ inputs=[
524
+ graph_state,
525
+ image_state,
526
+ toggles,
527
+ layout_mode,
528
+ show_image,
529
+ background_opacity,
530
+ show_bboxes,
531
+ selected_node,
532
+ ],
533
+ outputs=[plot, node_details],
534
+ )
535
+
536
+ return app
537
+
538
+
539
+ if __name__ == "__main__":
540
+ create_app().launch(css=CSS)
kgvis/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """KGVis package."""
kgvis/plot.py ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Iterable, Tuple
4
+
5
+ import networkx as nx
6
+ import plotly.graph_objects as go
7
+
8
+ from .schema import GraphData
9
+
10
+
11
+ def _build_layout(graph: GraphData, mode: str) -> dict[str, tuple[float, float]]:
12
+ if not graph.nodes:
13
+ return {}
14
+
15
+ graph_nx = nx.DiGraph()
16
+ for node in graph.nodes:
17
+ graph_nx.add_node(node.id)
18
+ for edge in graph.edges:
19
+ graph_nx.add_edge(edge.source, edge.target, predicate=edge.predicate)
20
+
21
+ if mode == "bbox":
22
+ explicit_positions: dict[str, tuple[float, float]] = {}
23
+ for node in graph.nodes:
24
+ center = _center_from_location(node.attributes)
25
+ if center is not None:
26
+ explicit_positions[node.id] = center
27
+
28
+ if explicit_positions and len(explicit_positions) == len(graph.nodes):
29
+ return explicit_positions
30
+
31
+ if explicit_positions:
32
+ return _spring_layout(graph_nx, pos=explicit_positions, fixed=explicit_positions.keys())
33
+
34
+ return _layout_force(graph_nx)
35
+
36
+
37
+ def _spring_layout(
38
+ graph_nx: nx.DiGraph,
39
+ pos: dict[str, tuple[float, float]] | None = None,
40
+ fixed: Iterable[str] | None = None,
41
+ ) -> dict[str, tuple[float, float]]:
42
+ n = max(graph_nx.number_of_nodes(), 1)
43
+ k = 0.9 / (n**0.5)
44
+ return nx.spring_layout(
45
+ graph_nx,
46
+ seed=42,
47
+ k=k,
48
+ iterations=300,
49
+ pos=pos,
50
+ fixed=fixed,
51
+ )
52
+
53
+
54
+ def _center_from_location(attributes: dict) -> Tuple[float, float] | None:
55
+ if not isinstance(attributes, dict):
56
+ return None
57
+
58
+ bbox = attributes.get("bbox")
59
+ if isinstance(bbox, dict):
60
+ try:
61
+ x1 = float(bbox["x1"])
62
+ y1 = float(bbox["y1"])
63
+ x2 = float(bbox["x2"])
64
+ y2 = float(bbox["y2"])
65
+ return _to_plot_center(x1, y1, x2, y2)
66
+ except (KeyError, TypeError, ValueError):
67
+ pass
68
+
69
+ raw = attributes.get("location_raw") or attributes.get("location")
70
+ parsed = _parse_location_value(raw)
71
+ if parsed:
72
+ x1, y1, x2, y2 = parsed
73
+ return _to_plot_center(x1, y1, x2, y2)
74
+ return None
75
+
76
+
77
+ def _parse_location_value(value) -> Tuple[float, float, float, float] | None:
78
+ parts: list[float] = []
79
+ if isinstance(value, str):
80
+ try:
81
+ parts = [float(p.strip()) for p in value.split(",") if p.strip()]
82
+ except ValueError:
83
+ return None
84
+ elif isinstance(value, (list, tuple)):
85
+ try:
86
+ parts = [float(p) for p in value]
87
+ except (TypeError, ValueError):
88
+ return None
89
+ else:
90
+ return None
91
+
92
+ if len(parts) < 4:
93
+ return None
94
+ return parts[0], parts[1], parts[2], parts[3]
95
+
96
+
97
+ def _to_plot_center(x1: float, y1: float, x2: float, y2: float) -> Tuple[float, float]:
98
+ center_x = (x1 + x2) / 2
99
+ center_y = (y1 + y2) / 2
100
+ # Normalize to plot coords: y=0 at bottom. Flip so y=0 is top like images.
101
+ return center_x, 1 - center_y
102
+
103
+
104
+ def _normalize_positions(
105
+ pos: dict[str, tuple[float, float]],
106
+ padding: float = 0.05,
107
+ ) -> dict[str, tuple[float, float]]:
108
+ if not pos:
109
+ return {}
110
+ xs = [p[0] for p in pos.values()]
111
+ ys = [p[1] for p in pos.values()]
112
+ min_x, max_x = min(xs), max(xs)
113
+ min_y, max_y = min(ys), max(ys)
114
+ span_x = max(max_x - min_x, 1e-6)
115
+ span_y = max(max_y - min_y, 1e-6)
116
+ scale_x = (1 - 2 * padding) / span_x
117
+ scale_y = (1 - 2 * padding) / span_y
118
+ normalized = {}
119
+ for node_id, (x, y) in pos.items():
120
+ nx = padding + (x - min_x) * scale_x
121
+ ny = padding + (y - min_y) * scale_y
122
+ normalized[node_id] = (nx, ny)
123
+ return normalized
124
+
125
+
126
+ def _layout_force(graph_nx: nx.DiGraph) -> dict[str, tuple[float, float]]:
127
+ if graph_nx.number_of_nodes() == 0:
128
+ return {}
129
+
130
+ undirected = graph_nx.to_undirected()
131
+ components = [list(c) for c in nx.connected_components(undirected)]
132
+ components.sort(key=len, reverse=True)
133
+
134
+ count = len(components)
135
+ cols = max(1, int((count**0.5) + 0.999))
136
+ rows = (count + cols - 1) // cols
137
+ cell_w = 1 / cols
138
+ cell_h = 1 / rows
139
+ cell_pad = 0.08
140
+
141
+ positions: dict[str, tuple[float, float]] = {}
142
+ for idx, nodes in enumerate(components):
143
+ sub = graph_nx.subgraph(nodes)
144
+ if len(nodes) == 1:
145
+ pos_comp = {nodes[0]: (0.5, 0.5)}
146
+ else:
147
+ pos_comp = _spring_layout(sub)
148
+ pos_comp = _normalize_positions(pos_comp, padding=0.1)
149
+
150
+ col = idx % cols
151
+ row = idx // cols
152
+ x0 = col * cell_w
153
+ y0 = row * cell_h
154
+ scale_x = cell_w * (1 - 2 * cell_pad)
155
+ scale_y = cell_h * (1 - 2 * cell_pad)
156
+ for node_id, (x, y) in pos_comp.items():
157
+ px = x0 + cell_pad * cell_w + x * scale_x
158
+ py = y0 + cell_pad * cell_h + y * scale_y
159
+ positions[node_id] = (px, py)
160
+
161
+ return _normalize_positions(positions, padding=0.04)
162
+
163
+
164
+ def build_figure(
165
+ graph: GraphData,
166
+ *,
167
+ show_nodes: bool,
168
+ show_edges: bool,
169
+ show_node_labels: bool,
170
+ show_edge_labels: bool,
171
+ selected_node_id: str | None,
172
+ layout_mode: str,
173
+ background_image: str | None = None,
174
+ background_aspect: float | None = None,
175
+ background_opacity: float = 0.35,
176
+ show_bboxes: bool = False,
177
+ highlight_node_id: str | None = None,
178
+ ) -> go.Figure:
179
+ pos = _build_layout(graph, layout_mode)
180
+ image_frame = _image_frame(background_aspect) if background_aspect else None
181
+ image_domain = _image_domain(background_aspect) if background_aspect else None
182
+ if layout_mode == "bbox" and image_domain:
183
+ pos = _scale_positions(pos, image_domain)
184
+
185
+ edge_x: list[float] = []
186
+ edge_y: list[float] = []
187
+ edge_text_x: list[float] = []
188
+ edge_text_y: list[float] = []
189
+ edge_text: list[str] = []
190
+
191
+ if show_edges:
192
+ for edge in graph.edges:
193
+ if edge.source not in pos or edge.target not in pos:
194
+ continue
195
+ x0, y0 = pos[edge.source]
196
+ x1, y1 = pos[edge.target]
197
+ edge_x.extend([x0, x1, None])
198
+ edge_y.extend([y0, y1, None])
199
+ edge_text_x.append((x0 + x1) / 2)
200
+ edge_text_y.append((y0 + y1) / 2)
201
+ edge_text.append(edge.predicate)
202
+
203
+ edge_trace = None
204
+ if show_edges:
205
+ edge_trace = go.Scatter(
206
+ x=edge_x,
207
+ y=edge_y,
208
+ mode="lines",
209
+ line={"width": 1.2, "color": "#9aa4b2"},
210
+ hoverinfo="none",
211
+ )
212
+
213
+ node_x: list[float] = []
214
+ node_y: list[float] = []
215
+ node_text: list[str] = []
216
+ node_hover: list[str] = []
217
+ node_color: list[str] = []
218
+ node_ids: list[str] = []
219
+
220
+ if show_nodes:
221
+ for node in graph.nodes:
222
+ if node.id not in pos:
223
+ continue
224
+ x, y = pos[node.id]
225
+ node_x.append(x)
226
+ node_y.append(y)
227
+ node_ids.append(node.id)
228
+ node_text.append(node.label if show_node_labels else "")
229
+ node_hover.append(f"{node.label} ({node.id})")
230
+ if selected_node_id and node.id == selected_node_id:
231
+ node_color.append("#ff7b7b")
232
+ else:
233
+ node_color.append("#3a7bd5")
234
+
235
+ node_trace = None
236
+ if show_nodes:
237
+ node_trace = go.Scatter(
238
+ x=node_x,
239
+ y=node_y,
240
+ mode="markers+text" if show_node_labels else "markers",
241
+ text=node_text,
242
+ textposition="top center",
243
+ textfont={"size": 12, "color": "#1f2a44"},
244
+ hovertext=node_hover,
245
+ hoverinfo="text",
246
+ marker={
247
+ "size": 16,
248
+ "color": node_color,
249
+ "line": {"width": 1.2, "color": "#0f172a"},
250
+ },
251
+ customdata=node_ids,
252
+ )
253
+
254
+ traces: list[go.Scatter] = []
255
+ if edge_trace is not None:
256
+ traces.append(edge_trace)
257
+ if node_trace is not None:
258
+ traces.append(node_trace)
259
+
260
+ if show_edges and show_edge_labels and edge_text:
261
+ edge_label_trace = go.Scatter(
262
+ x=edge_text_x,
263
+ y=edge_text_y,
264
+ mode="text",
265
+ text=edge_text,
266
+ textfont={"size": 11, "color": "#475569"},
267
+ hoverinfo="none",
268
+ )
269
+ traces.append(edge_label_trace)
270
+
271
+ fig = go.Figure(data=traces)
272
+ layout_update = dict(
273
+ showlegend=False,
274
+ hovermode="closest",
275
+ margin={"l": 10, "r": 10, "t": 10, "b": 10},
276
+ xaxis={"visible": False, "range": [0, 1]},
277
+ yaxis={"visible": False, "range": [0, 1]},
278
+ plot_bgcolor="#ffffff",
279
+ annotations=_edge_annotations(graph, pos) if show_edges else [],
280
+ dragmode="pan",
281
+ )
282
+ if layout_mode == "bbox" and image_domain:
283
+ width, height = image_domain
284
+ layout_update["xaxis"] = {
285
+ "visible": False,
286
+ "range": [0, width],
287
+ "constrain": "domain",
288
+ }
289
+ layout_update["yaxis"] = {
290
+ "visible": False,
291
+ "range": [0, height],
292
+ "scaleanchor": "x",
293
+ "scaleratio": 1,
294
+ "constrain": "domain",
295
+ }
296
+ fig.update_layout(**layout_update)
297
+
298
+ if show_bboxes:
299
+ shapes = _bbox_shapes(
300
+ graph,
301
+ image_domain=image_domain if layout_mode == "bbox" else None,
302
+ frame=image_frame if layout_mode != "bbox" else None,
303
+ highlight_node_id=highlight_node_id,
304
+ )
305
+ if shapes:
306
+ fig.update_layout(shapes=shapes)
307
+
308
+ if background_image:
309
+ if layout_mode == "bbox" and image_domain:
310
+ x0, y0, width, height = 0, 0, image_domain[0], image_domain[1]
311
+ elif image_frame:
312
+ x0, y0, width, height = image_frame
313
+ else:
314
+ x0, y0, width, height = 0, 0, 1, 1
315
+ fig.update_layout(
316
+ images=[
317
+ dict(
318
+ source=background_image,
319
+ xref="x",
320
+ yref="y",
321
+ x=x0,
322
+ y=y0 + height,
323
+ sizex=width,
324
+ sizey=height,
325
+ sizing="stretch",
326
+ xanchor="left",
327
+ yanchor="top",
328
+ opacity=max(0.0, min(background_opacity, 1.0)),
329
+ layer="below",
330
+ )
331
+ ]
332
+ )
333
+ return fig
334
+
335
+
336
+ def _image_frame(aspect: float) -> tuple[float, float, float, float]:
337
+ if aspect <= 0:
338
+ return 0, 0, 1, 1
339
+ if aspect >= 1:
340
+ width = 1.0
341
+ height = 1.0 / aspect
342
+ pad_x = 0.0
343
+ pad_y = (1.0 - height) / 2
344
+ else:
345
+ width = aspect
346
+ height = 1.0
347
+ pad_x = (1.0 - width) / 2
348
+ pad_y = 0.0
349
+ return pad_x, pad_y, width, height
350
+
351
+
352
+ def _image_domain(aspect: float) -> tuple[float, float]:
353
+ if aspect <= 0:
354
+ return 1.0, 1.0
355
+ if aspect >= 1:
356
+ return aspect, 1.0
357
+ return 1.0, 1.0 / aspect
358
+
359
+
360
+ def _apply_image_frame(
361
+ pos: dict[str, tuple[float, float]],
362
+ frame: tuple[float, float, float, float],
363
+ ) -> dict[str, tuple[float, float]]:
364
+ if not pos:
365
+ return {}
366
+ x0, y0, width, height = frame
367
+ adjusted = {}
368
+ for node_id, (x, y) in pos.items():
369
+ adjusted[node_id] = (x0 + x * width, y0 + y * height)
370
+ return adjusted
371
+
372
+
373
+ def _scale_positions(
374
+ pos: dict[str, tuple[float, float]],
375
+ domain: tuple[float, float],
376
+ ) -> dict[str, tuple[float, float]]:
377
+ if not pos:
378
+ return {}
379
+ width, height = domain
380
+ scaled = {}
381
+ for node_id, (x, y) in pos.items():
382
+ scaled[node_id] = (x * width, y * height)
383
+ return scaled
384
+
385
+
386
+ def _bbox_from_attrs(attributes: dict) -> tuple[float, float, float, float] | None:
387
+ if not isinstance(attributes, dict):
388
+ return None
389
+ bbox = attributes.get("bbox")
390
+ if isinstance(bbox, dict):
391
+ try:
392
+ x1 = float(bbox["x1"])
393
+ y1 = float(bbox["y1"])
394
+ x2 = float(bbox["x2"])
395
+ y2 = float(bbox["y2"])
396
+ return x1, y1, x2, y2
397
+ except (KeyError, TypeError, ValueError):
398
+ pass
399
+
400
+ raw = attributes.get("location_raw") or attributes.get("location")
401
+ parsed = _parse_location_value(raw)
402
+ if parsed:
403
+ return parsed
404
+ return None
405
+
406
+
407
+ def _bbox_shapes(
408
+ graph: GraphData,
409
+ image_domain: tuple[float, float] | None,
410
+ frame: tuple[float, float, float, float] | None,
411
+ highlight_node_id: str | None,
412
+ ) -> list[dict]:
413
+ shapes: list[dict] = []
414
+ for node in graph.nodes:
415
+ bbox = _bbox_from_attrs(node.attributes)
416
+ if not bbox:
417
+ continue
418
+ x1, y1, x2, y2 = bbox
419
+ # Convert from image coords (origin top-left) to plot coords (origin bottom-left).
420
+ px1, px2 = x1, x2
421
+ py1, py2 = 1 - y2, 1 - y1
422
+
423
+ if image_domain:
424
+ width, height = image_domain
425
+ px1 *= width
426
+ px2 *= width
427
+ py1 *= height
428
+ py2 *= height
429
+ elif frame:
430
+ fx, fy, fw, fh = frame
431
+ px1 = fx + px1 * fw
432
+ px2 = fx + px2 * fw
433
+ py1 = fy + py1 * fh
434
+ py2 = fy + py2 * fh
435
+
436
+ is_highlight = highlight_node_id and node.id == highlight_node_id
437
+ line_color = "#ef4444" if is_highlight else "rgba(242,95,76,0.55)"
438
+ fill_color = "rgba(239,68,68,0.18)" if is_highlight else "rgba(242,95,76,0.08)"
439
+ line_width = 2.4 if is_highlight else 1.2
440
+
441
+ shapes.append(
442
+ dict(
443
+ type="rect",
444
+ xref="x",
445
+ yref="y",
446
+ x0=min(px1, px2),
447
+ x1=max(px1, px2),
448
+ y0=min(py1, py2),
449
+ y1=max(py1, py2),
450
+ line={"color": line_color, "width": line_width},
451
+ fillcolor=fill_color,
452
+ layer="below",
453
+ )
454
+ )
455
+ return shapes
456
+
457
+
458
+ def empty_figure(message: str) -> go.Figure:
459
+ fig = go.Figure()
460
+ fig.add_annotation(
461
+ text=message,
462
+ x=0.5,
463
+ y=0.5,
464
+ xref="paper",
465
+ yref="paper",
466
+ xanchor="center",
467
+ yanchor="middle",
468
+ showarrow=False,
469
+ font={"size": 16, "color": "#64748b"},
470
+ )
471
+ fig.update_layout(
472
+ xaxis={"visible": False},
473
+ yaxis={"visible": False},
474
+ plot_bgcolor="#ffffff",
475
+ margin={"l": 20, "r": 20, "t": 20, "b": 20},
476
+ )
477
+ return fig
478
+
479
+
480
+ def _edge_annotations(graph: GraphData, pos: dict[str, tuple[float, float]]):
481
+ annotations = []
482
+ for edge in graph.edges:
483
+ if edge.source not in pos or edge.target not in pos:
484
+ continue
485
+ x0, y0 = pos[edge.source]
486
+ x1, y1 = pos[edge.target]
487
+ dx = x1 - x0
488
+ dy = y1 - y0
489
+ length = (dx**2 + dy**2) ** 0.5
490
+ if length == 0:
491
+ continue
492
+
493
+ # Shorten the arrow so it doesn't overlap the node markers.
494
+ shrink = min(0.05, length * 0.25)
495
+ ux = dx / length
496
+ uy = dy / length
497
+ tail_x = x0 + ux * shrink
498
+ tail_y = y0 + uy * shrink
499
+ head_x = x1 - ux * shrink
500
+ head_y = y1 - uy * shrink
501
+
502
+ annotations.append(
503
+ dict(
504
+ x=head_x,
505
+ y=head_y,
506
+ ax=tail_x,
507
+ ay=tail_y,
508
+ xref="x",
509
+ yref="y",
510
+ axref="x",
511
+ ayref="y",
512
+ showarrow=True,
513
+ arrowhead=3,
514
+ arrowsize=1.15,
515
+ arrowwidth=1.2,
516
+ arrowcolor="#94a3b8",
517
+ opacity=0.9,
518
+ )
519
+ )
520
+ return annotations
kgvis/schema/__init__.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Iterable
4
+
5
+ from .base import Edge, GraphData, Node, SchemaAdapter, graph_from_dict, graph_to_dict
6
+ from .v1 import V1Adapter
7
+
8
+ DEFAULT_ADAPTERS: list[SchemaAdapter] = [V1Adapter()]
9
+
10
+
11
+ def parse_graph(data: Dict[str, Any], adapters: Iterable[SchemaAdapter] | None = None) -> GraphData:
12
+ candidates = list(adapters or DEFAULT_ADAPTERS)
13
+ for adapter in candidates:
14
+ if adapter.can_parse(data):
15
+ return adapter.parse(data)
16
+ raise ValueError("No schema adapter matched the provided graph data.")
17
+
18
+
19
+ __all__ = [
20
+ "Edge",
21
+ "GraphData",
22
+ "Node",
23
+ "SchemaAdapter",
24
+ "DEFAULT_ADAPTERS",
25
+ "parse_graph",
26
+ "graph_from_dict",
27
+ "graph_to_dict",
28
+ ]
kgvis/schema/base.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, Protocol
5
+
6
+
7
+ @dataclass(frozen=True)
8
+ class Node:
9
+ id: str
10
+ label: str
11
+ attributes: Dict[str, Any] = field(default_factory=dict)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Edge:
16
+ source: str
17
+ target: str
18
+ predicate: str
19
+ attributes: Dict[str, Any] = field(default_factory=dict)
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class GraphData:
24
+ nodes: list[Node]
25
+ edges: list[Edge]
26
+ meta: Dict[str, Any] = field(default_factory=dict)
27
+
28
+
29
+ class SchemaAdapter(Protocol):
30
+ name: str
31
+
32
+ def can_parse(self, data: Dict[str, Any]) -> bool: ...
33
+ def parse(self, data: Dict[str, Any]) -> GraphData: ...
34
+
35
+
36
+ def graph_to_dict(graph: GraphData) -> Dict[str, Any]:
37
+ return {
38
+ "nodes": [
39
+ {"id": n.id, "label": n.label, "attributes": n.attributes} for n in graph.nodes
40
+ ],
41
+ "edges": [
42
+ {
43
+ "source": e.source,
44
+ "target": e.target,
45
+ "predicate": e.predicate,
46
+ "attributes": e.attributes,
47
+ }
48
+ for e in graph.edges
49
+ ],
50
+ "meta": dict(graph.meta),
51
+ }
52
+
53
+
54
+ def graph_from_dict(payload: Dict[str, Any]) -> GraphData:
55
+ nodes = [
56
+ Node(
57
+ id=n.get("id", ""),
58
+ label=n.get("label", ""),
59
+ attributes=dict(n.get("attributes", {})),
60
+ )
61
+ for n in payload.get("nodes", [])
62
+ ]
63
+ edges = [
64
+ Edge(
65
+ source=e.get("source", ""),
66
+ target=e.get("target", ""),
67
+ predicate=e.get("predicate", ""),
68
+ attributes=dict(e.get("attributes", {})),
69
+ )
70
+ for e in payload.get("edges", [])
71
+ ]
72
+ meta = dict(payload.get("meta", {}))
73
+ return GraphData(nodes=nodes, edges=edges, meta=meta)
kgvis/schema/v1.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Dict, Tuple
4
+
5
+ from .base import Edge, GraphData, Node
6
+
7
+
8
+ class V1Adapter:
9
+ name = "frame2kg_v1"
10
+
11
+ def can_parse(self, data: Dict[str, Any]) -> bool:
12
+ if not isinstance(data, dict):
13
+ return False
14
+ payload = data.get("graph") if isinstance(data.get("graph"), dict) else data
15
+ nodes = payload.get("nodes")
16
+ edges = payload.get("edges")
17
+ if not isinstance(nodes, list) or not isinstance(edges, list):
18
+ return False
19
+ if nodes and not isinstance(nodes[0], dict):
20
+ return False
21
+ if edges and not isinstance(edges[0], dict):
22
+ return False
23
+ return True
24
+
25
+ def parse(self, data: Dict[str, Any]) -> GraphData:
26
+ payload = data.get("graph") if isinstance(data.get("graph"), dict) else data
27
+ nodes = []
28
+ for raw in payload.get("nodes", []):
29
+ known_keys = {"id", "label", "attributes", "location"}
30
+ extra = {k: v for k, v in raw.items() if k not in known_keys}
31
+ attributes = {**dict(raw.get("attributes", {})), **extra}
32
+ location = raw.get("location")
33
+ if location is not None:
34
+ parsed = _parse_location(location)
35
+ if parsed:
36
+ bbox, confidence = parsed
37
+ attributes.setdefault("bbox", bbox)
38
+ if confidence is not None:
39
+ attributes.setdefault("confidence", confidence)
40
+ attributes.setdefault("location_raw", location)
41
+ nodes.append(
42
+ Node(
43
+ id=str(raw.get("id", "")),
44
+ label=str(raw.get("label", "")),
45
+ attributes=attributes,
46
+ )
47
+ )
48
+
49
+ edges = []
50
+ for raw in payload.get("edges", []):
51
+ known_keys = {"source", "target", "predicate", "attributes"}
52
+ extra = {k: v for k, v in raw.items() if k not in known_keys}
53
+ attributes = {**dict(raw.get("attributes", {})), **extra}
54
+ edges.append(
55
+ Edge(
56
+ source=str(raw.get("source", "")),
57
+ target=str(raw.get("target", "")),
58
+ predicate=str(raw.get("predicate", "")),
59
+ attributes=attributes,
60
+ )
61
+ )
62
+
63
+ meta = {"schema": self.name}
64
+ return GraphData(nodes=nodes, edges=edges, meta=meta)
65
+
66
+
67
+ def _parse_location(value: Any) -> Tuple[dict[str, float], float | None] | None:
68
+ parts: list[float] = []
69
+ if isinstance(value, str):
70
+ try:
71
+ parts = [float(p.strip()) for p in value.split(",") if p.strip()]
72
+ except ValueError:
73
+ return None
74
+ elif isinstance(value, (list, tuple)):
75
+ try:
76
+ parts = [float(p) for p in value]
77
+ except (TypeError, ValueError):
78
+ return None
79
+ else:
80
+ return None
81
+
82
+ if len(parts) < 4:
83
+ return None
84
+ x1, y1, x2, y2 = parts[:4]
85
+ confidence = parts[4] if len(parts) >= 5 else None
86
+ bbox = {"x1": x1, "y1": y1, "x2": x2, "y2": y2}
87
+ return bbox, confidence
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ gradio>=6,<7
2
+ networkx>=3.3
3
+ pillow>=10.0.0
4
+ plotly>=5.22.0