"""
Drawing utilities for graphizy
.. moduleauthor:: Charles Fosseprez
.. contact:: charles.fosseprez.pro@gmail.com
.. license:: GPL2 or later
.. copyright:: Copyright (C) 2025 Charles Fosseprez
"""
import logging
import numpy as np
from typing import Tuple, Union, Any
from .exceptions import DrawingError, DependencyError
from .config import DrawingConfig
try:
import cv2
except ImportError:
raise DependencyError("OpenCV is required but not installed. Install with: pip install opencv-python")
[docs]
class Visualizer:
"""
Handles all visualization tasks for Graphizy.
This class is responsible for drawing graphs, overlaying information,
and managing the display or saving of the resulting images. It separates
the visualization logic from the graph creation and analysis logic.
"""
def __init__(self, config: DrawingConfig, dimension: Tuple[int, int]):
"""
Initialize the Visualizer.
Args:
config: A DrawingConfig object with styling parameters.
dimension: A tuple (width, height) for the canvas.
"""
self.config = config
self.dimension = dimension
self.memory_manager=None # Injected by parent method
[docs]
def update_config(self, config: DrawingConfig, dimension: Tuple[int, int]):
"""Update the visualizer's configuration at runtime."""
self.config = config
self.dimension = dimension
[docs]
def draw_graph(self, graph: Any, radius: int = None, thickness: int = None) -> np.ndarray:
"""
Draw a graph to an image array with customizable appearance.
This is the primary method for converting igraph Graph objects into
visual representations. It handles both vertices (as circles) and
edges (as lines) with configurable styling.
Args:
graph: igraph Graph object with vertices having "x", "y" coordinates
radius: Point radius override. If None, uses self.point_radius from config.
thickness: Point border thickness override. If None, uses self.point_thickness.
direct_show: If True, immediately display the graph using show_graph().
Convenient for interactive use.
kwargs_show: Additional parameters passed to show_graph() if direct_show=True.
E.g., {'title': 'My Graph', 'block': False}
Returns:
np.ndarray: RGB image array with shape (height, width, 3) and dtype uint8.
Background is black (0,0,0), drawn elements use configured colors.
Raises:
DrawingError: If graph is None, missing coordinates, or drawing operations fail.
Examples:
>>> # Basic drawing
>>> graph = grapher.make_delaunay(data)
>>> image = grapher.draw_graph(graph)
>>> grapher.show_graph(image)
>>> # Custom appearance
>>> image = grapher.draw_graph(graph, radius=10, thickness=3)
>>> # Draw and show immediately
>>> image = grapher.draw_graph(
... graph,
... direct_show=True,
... kwargs_show={'title': 'Delaunay Triangulation', 'block': True}
... )
>>> # Multiple graphs on same image
>>> image = grapher.draw_graph(delaunay_graph)
>>> image = grapher.overlay_graph(image, mst_graph) # Overlay MST
>>> grapher.show_graph(image, title="Combined Graph")
Note:
- Graph must have vertex attributes "x" and "y" for coordinates
- Coordinates are in image pixel space (0 to dimension)
- Drawing order: edges first, then vertices (vertices on top)
- Image dimensions set by self.dimension from config
- Colors set by self.point_color and self.line_color from config
"""
try:
if graph is None:
raise DrawingError("Graph cannot be None")
# Use config defaults if parameters not provided
radius = radius if radius is not None else self.config.point_radius
thickness = thickness if thickness is not None else self.config.point_thickness
# Create image array
width, height = self.dimension
image_graph = np.zeros((height, width, 3), dtype=np.uint8)
# Draw edges first
for edge in graph.es:
source_idx, target_idx = edge.tuple
x0, y0 = int(graph.vs[source_idx]["x"]), int(graph.vs[source_idx]["y"])
x1, y1 = int(graph.vs[target_idx]["x"]), int(graph.vs[target_idx]["y"])
draw_line(image_graph, x0, y0, x1, y1, self.config.line_color, self.config.line_thickness)
# Draw vertices on top
for vertex in graph.vs:
point_coords = (vertex["x"], vertex["y"])
draw_point(image_graph, point_coords, self.config.point_color,
thickness=thickness, radius=radius)
return image_graph
except Exception as e:
raise DrawingError(f"Failed to draw graph: {str(e)}")
[docs]
def draw_memory_graph(self, graph: Any, radius: int = None, thickness: int = None,
use_age_colors: bool = True, alpha_range: Tuple[float, float] = (0.3, 1.0)) -> np.ndarray:
"""
Draw memory graph with optional age-based edge coloring and transparency.
This specialized drawing method can visualize temporal information by
varying edge appearance based on connection age/persistence in memory.
Newer or more frequent connections can be highlighted while older
connections are drawn more subtly.
Args:
graph: igraph Graph object to draw
radius: Point radius override. If None, uses config default.
thickness: Point thickness override. If None, uses config default.
use_age_colors: Whether to apply age-based styling to edges.
If True, requires memory_manager with age tracking enabled.
alpha_range: (min_alpha, max_alpha) tuple for transparency range.
Older connections use min_alpha, newer use max_alpha.
Returns:
np.ndarray: Image array representing the drawn graph.
Raises:
DrawingError: If drawing fails or memory manager required but not available.
Examples:
>>> # Basic memory graph drawing
>>> memory_graph = grapher.make_memory_graph(data)
>>> image = grapher.draw_memory_graph(memory_graph)
>>> grapher.show_graph(image, title="Memory Graph")
>>> # Custom age-based visualization
>>> image = grapher.draw_memory_graph(
... memory_graph,
... use_age_colors=True,
... alpha_range=(0.1, 1.0), # Very faded old connections
... radius=8,
... thickness=2
... )
>>> # Disable age coloring for standard appearance
>>> image = grapher.draw_memory_graph(
... memory_graph,
... use_age_colors=False
... )
Note:
- Age-based coloring requires memory manager with track_edge_ages=True
- Alpha blending may not be supported in all drawing backends
- Performance may be slower with age-based coloring for large graphs
- Falls back to standard drawing if age information unavailable
"""
try:
if graph is None:
raise DrawingError("Graph cannot be None")
if self.memory_manager is None:
raise DrawingError("The memory was not initialized")
width, height = self.dimension
image = np.zeros((height, width, 3), dtype=np.uint8)
draw_memory_graph_with_aging(
image, graph, self.memory_manager,
point_color=self.config.point_color,
line_color=self.config.line_color,
point_radius=radius if radius is not None else self.config.point_radius,
point_thickness=thickness if thickness is not None else self.config.point_thickness,
line_thickness=self.config.line_thickness,
use_age_colors=use_age_colors,
alpha_range=alpha_range
)
return image
except Exception as e:
raise DrawingError(f"Failed to draw memory graph: {str(e)}")
[docs]
def overlay_graph(self, image_graph: np.ndarray, graph: Any) -> np.ndarray:
"""
Overlay additional graph elements onto an existing image.
This method allows combining multiple graphs in a single visualization
by drawing additional vertices and edges on top of an existing image.
Useful for comparing different graph types or showing graph evolution.
Args:
image_graph: Existing image array to draw on. Modified in-place.
graph: igraph Graph object to overlay with vertices having "x", "y" coordinates.
Returns:
np.ndarray: The modified image array (same object as input for chaining).
Raises:
DrawingError: If either image or graph is None, or if drawing operations fail.
Examples:
>>> # Compare Delaunay triangulation with MST
>>> delaunay_graph = grapher.make_delaunay(data)
>>> mst_graph = grapher.make_mst(data)
>>>
>>> # Draw Delaunay as base
>>> image = grapher.draw_graph(delaunay_graph)
>>>
>>> # Overlay MST with different color
>>> grapher.update_config(line_color=(255, 0, 0)) # Red for MST
>>> image = grapher.overlay_graph(image, mst_graph)
>>> grapher.show_graph(image, title="Delaunay + MST")
>>> # Chain multiple overlays
>>> image = grapher.draw_graph(delaunay_graph)
>>> image = grapher.overlay_graph(image, mst_graph)
>>> image = grapher.overlay_graph(image, knn_graph)
Note:
- Image is modified in-place and also returned for method chaining
- Overlay uses current drawing configuration (colors, thickness, etc.)
- Later overlays draw on top of earlier ones
- Vertices are drawn on top of edges within each overlay
- Consider using different colors for different overlays
"""
try:
if image_graph is None or graph is None:
raise DrawingError("Image and graph cannot be None")
# Draw edges
for edge in graph.es:
source_idx, target_idx = edge.tuple
x0, y0 = int(graph.vs[source_idx]["x"]), int(graph.vs[source_idx]["y"])
x1, y1 = int(graph.vs[target_idx]["x"]), int(graph.vs[target_idx]["y"])
draw_line(image_graph, x0, y0, x1, y1, self.config.line_color, self.config.line_thickness)
# Draw vertices
for vertex in graph.vs:
point_coords = (vertex["x"], vertex["y"])
draw_point(image_graph, point_coords, self.config.point_color,
thickness=self.config.point_thickness, radius=self.config.point_radius)
return image_graph
except Exception as e:
raise DrawingError(f"Failed to overlay graph: {str(e)}")
[docs]
def overlay_collision(self, image_graph: np.ndarray, graph: Any) -> np.ndarray:
"""
Overlay collision/intersection points on graph edges.
This debugging/analysis method draws midpoints of all edges with
prominent markers. Useful for visualizing edge density, detecting
potential intersections, or highlighting edge midpoints for analysis.
Args:
image_graph: Existing image array to draw on. Modified in-place.
graph: igraph Graph object with edges to mark.
Returns:
np.ndarray: The modified image array with collision points added.
Raises:
DrawingError: If either image or graph is None, or if drawing operations fail.
Examples:
>>> # Visualize edge midpoints for analysis
>>> graph = grapher.make_delaunay(data)
>>> image = grapher.draw_graph(graph)
>>> image = grapher.overlay_collision(image, graph)
>>> grapher.show_graph(image, title="Graph with Edge Midpoints")
>>> # Analyze edge density in different regions
>>> dense_graph = grapher.make_proximity(data, proximity_thresh=50)
>>> image = grapher.draw_graph(dense_graph)
>>> image = grapher.overlay_collision(image, dense_graph)
Note:
- Collision points are drawn as large, prominent circles
- Useful for debugging edge placement and density analysis
- Midpoint calculation uses integer arithmetic (may have rounding)
- Collision markers use current point_color configuration
"""
try:
if image_graph is None:
raise DrawingError("Image cannot be None")
if graph is None:
raise DrawingError("Graph cannot be None")
for edge in graph.es:
source_idx, target_idx = edge.tuple
x0, y0 = int(graph.vs[source_idx]["x"]), int(graph.vs[source_idx]["y"])
x1, y1 = int(graph.vs[target_idx]["x"]), int(graph.vs[target_idx]["y"])
# Draw the edge
draw_line(image_graph, x0, y0, x1, y1, self.config.line_color, self.config.line_thickness)
# Draw prominent midpoint marker
mid_x = int((x0 + x1) / 2)
mid_y = int((y0 + y1) / 2)
draw_point(image_graph, (mid_x, mid_y), self.config.point_color, radius=25, thickness=6)
return image_graph
except Exception as e:
raise DrawingError(f"Failed to overlay collision points: {str(e)}")
[docs]
def show_graph(self, image_graph: np.ndarray, title: str = "Graphizy", **kwargs) -> None:
"""Display a graph image in a window."""
show_graph(image_graph, title, **kwargs)
[docs]
def save_graph(self, image_graph: np.ndarray, filename: str) -> None:
"""Save a graph image to a file."""
save_graph(image_graph, filename)
[docs]
def draw_point(img: np.ndarray, p: Tuple[float, float], color: Tuple[int, int, int],
radius: int = 4, thickness: int = 1) -> None:
"""Draw a point on the image with enhanced error handling
Args:
img: Image array to draw on
p: Point coordinates (x, y)
color: Color tuple (B, G, R)
radius: Point radius
thickness: Line thickness
Raises:
DrawingError: If drawing operation fails
"""
logger = logging.getLogger('graphizy.drawing.draw_point')
try:
# Input validation
if img is None:
raise DrawingError("Image cannot be None", img, p)
if len(p) != 2:
raise DrawingError("Point must have exactly 2 coordinates", img, p)
if len(color) != 3:
raise DrawingError("Color must be a tuple of 3 values", img, p)
if radius < 1:
raise DrawingError("Radius must be >= 1", img, p)
if thickness < 1:
raise DrawingError("Thickness must be >= 1", img, p)
x, y = int(p[0]), int(p[1])
# Enhanced bounds checking - log warning but don't crash
if x < 0 or x >= img.shape[1] or y < 0 or y >= img.shape[0]:
from .exceptions import log_warning_with_context
log_warning_with_context(
f"Point ({x}, {y}) is outside image bounds {img.shape}",
point_coordinates=(x, y),
image_shape=img.shape,
image_bounds=f"[0, {img.shape[1]}) x [0, {img.shape[0]})"
)
# Return early instead of attempting to draw
return
# Draw the point
cv2.circle(img, (x, y), radius, color, thickness)
cv2.drawMarker(img, (x, y), color, markerType=cv2.MARKER_CROSS,
markerSize=radius, thickness=1, line_type=cv2.LINE_8)
logger.debug(f"Successfully drew point at ({x}, {y})")
except DrawingError:
# Re-raise DrawingError as-is
raise
except Exception as e:
# Convert other exceptions to DrawingError
error = DrawingError(f"Failed to draw point: {str(e)}", img, p, original_exception=e)
error.log_error()
raise error
[docs]
def draw_line(img: np.ndarray, x0: int, y0: int, x1: int, y1: int,
color: Tuple[int, int, int], thickness: int = 1) -> None:
"""Draw a line on the image with enhanced error handling
Args:
img: Image array to draw on
x0, y0: Start point coordinates
x1, y1: End point coordinates
color: Color tuple (B, G, R)
thickness: Line thickness
Raises:
DrawingError: If drawing operation fails
"""
logger = logging.getLogger('graphizy.drawing.draw_line')
try:
# Input validation
if img is None:
raise DrawingError("Image cannot be None", img, (x0, y0, x1, y1))
if len(color) != 3:
raise DrawingError("Color must be a tuple of 3 values", img, (x0, y0, x1, y1))
if thickness < 1:
raise DrawingError("Thickness must be >= 1", img, (x0, y0, x1, y1))
# Convert to integers
x0, y0, x1, y1 = int(x0), int(y0), int(x1), int(y1)
# Check if line endpoints are within reasonable bounds (allow some overflow for partial lines)
max_coord = max(img.shape[0], img.shape[1]) * 2
if any(abs(coord) > max_coord for coord in [x0, y0, x1, y1]):
from .exceptions import log_warning_with_context
log_warning_with_context(
f"Line coordinates ({x0}, {y0}) to ({x1}, {y1}) are extremely large",
line_coords=(x0, y0, x1, y1),
image_shape=img.shape
)
# Draw the line
cv2.line(img, (x0, y0), (x1, y1), color, thickness, cv2.LINE_AA, 0)
logger.debug(f"Successfully drew line from ({x0}, {y0}) to ({x1}, {y1})")
except DrawingError:
# Re-raise DrawingError as-is
raise
except Exception as e:
# Convert other exceptions to DrawingError
error = DrawingError(f"Failed to draw line: {str(e)}", img, (x0, y0, x1, y1), original_exception=e)
error.log_error()
raise error
[docs]
def draw_delaunay(img: np.ndarray, subdiv: Any, color_line: Tuple[int, int, int] = (0, 255, 0),
thickness_line: int = 1, color_point: Tuple[int, int, int] = (0, 0, 255),
thickness_point: int = 1) -> None:
"""Draw delaunay triangles from openCV Subdiv2D
Args:
img: Image to draw on
subdiv: OpenCV Subdiv2D object
color_line: Line color (B, G, R)
thickness_line: Line thickness
color_point: Point color (B, G, R)
thickness_point: Point thickness
Raises:
DrawingError: If drawing operation fails
"""
try:
if img is None:
raise DrawingError("Image cannot be None")
if subdiv is None:
raise DrawingError("Subdivision cannot be None")
triangle_list = subdiv.getTriangleList()
if len(triangle_list) == 0:
logging.warning("No triangles found in subdivision")
return
for t in triangle_list:
if len(t) != 6:
logging.warning(f"Invalid triangle format: expected 6 values, got {len(t)}")
continue
pt1 = (int(t[0]), int(t[1]))
pt2 = (int(t[2]), int(t[3]))
pt3 = (int(t[4]), int(t[5]))
# Draw points
draw_point(img, pt1, color_point, thickness=thickness_point)
draw_point(img, pt2, color_point, thickness=thickness_point)
draw_point(img, pt3, color_point, thickness=thickness_point)
# Draw lines
draw_line(img, *pt1, *pt2, color_line, thickness_line)
draw_line(img, *pt2, *pt3, color_line, thickness_line)
draw_line(img, *pt1, *pt3, color_line, thickness_line)
except Exception as e:
raise DrawingError(f"Failed to draw Delaunay triangulation: {str(e)}")
[docs]
def show_graph(
image_graph: np.ndarray,
title: str = "My beautiful graph",
block: bool = False,
delay_display: int = 100
) -> None:
"""
Display a graph image using the configured display backend.
This is a convenience wrapper around the global show_graph function
that provides consistent image display with customizable options.
Args:
image_graph: Image array to display with shape (height, width, 3).
title: Window title for the display.
**kwargs: Additional arguments passed to the underlying show_graph function:
- block: Whether to block execution until window is closed
- delay_display: Delay before showing (for animations)
- save_path: Optionally save image while displaying
- Backend-specific options
Examples:
>>> image = grapher.draw_graph(graph)
>>> Graphing.show_graph(image, title="Delaunay Triangulation")
>>> # Non-blocking display for animations
>>> Graphing.show_graph(image, title="Animation Frame", block=False)
>>> # Display with save
>>> Graphing.show_graph(
... image,
... title="Final Result",
... save_path="output.png"
... )
Note:
- Display backend depends on system configuration (matplotlib, opencv, etc.)
- Window behavior (blocking, resizing) depends on backend
- Static method can be called without Graphing instance
"""
try:
if image_graph is None or not isinstance(image_graph, np.ndarray):
raise DrawingError("Provided image must be a valid numpy array.")
if image_graph.size == 0:
raise DrawingError("Provided image is empty.")
cv2.imshow(title, image_graph)
cv2.waitKey(0 if block else delay_display)
try:
cv2.destroyWindow(title)
except Exception:
pass
except Exception as e:
raise DrawingError(f"Failed to display graph: {e}")
[docs]
def save_graph(image_graph: np.ndarray, filename: str) -> None:
"""
Save graph image to file.
This is a convenience wrapper around the global save_graph function
that handles various image formats and provides error handling.
Args:
image_graph: Image array to save with shape (height, width, 3).
filename: Output filename with extension. Extension determines format
(e.g., .png, .jpg, .tiff, .bmp).
Examples:
>>> image = grapher.draw_graph(graph)
>>> Graphing.save_graph(image, "delaunay_triangulation.png")
>>> # Save with timestamp
>>> import datetime
>>> timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
>>> filename = f"graph_{timestamp}.png"
>>> Graphing.save_graph(image, filename)
Note:
- File format determined by extension
- Overwrites existing files without warning
- Directory must exist (not created automatically)
- Static method can be called without Graphing instance
"""
try:
if image_graph is None:
raise DrawingError("Image cannot be None")
if image_graph.size == 0:
raise DrawingError("Image cannot be empty")
if not filename:
raise DrawingError("Filename cannot be empty")
success = cv2.imwrite(filename, image_graph)
if not success:
raise DrawingError(f"Failed to save image to {filename}")
logging.info(f"Graph saved to {filename}")
except Exception as e:
raise DrawingError(f"Failed to save graph: {str(e)}")
[docs]
def draw_memory_graph_with_aging(img: np.ndarray, graph: Any, memory_manager: Any,
point_color: Tuple[int, int, int], line_color: Tuple[int, int, int],
point_radius: int = 8, point_thickness: int = 3,
line_thickness: int = 1, use_age_colors: bool = True,
alpha_range: Tuple[float, float] = (0.3, 1.0)) -> None:
"""Draw memory graph with optional edge aging visualization
Args:
img: Image array to draw on
graph: igraph Graph object
memory_manager: MemoryManager instance (for edge ages)
point_color: Color for points (B, G, R)
line_color: Base color for lines (B, G, R)
point_radius: Point radius
point_thickness: Point thickness
line_thickness: Line thickness
use_age_colors: Whether to use age-based edge coloring
alpha_range: (min_alpha, max_alpha) for age-based transparency
Raises:
DrawingError: If drawing operation fails
"""
try:
if img is None:
raise DrawingError("Image cannot be None")
if graph is None:
raise DrawingError("Graph cannot be None")
# Draw points first
for point in graph.vs:
draw_point(img, (point["x"], point["y"]), point_color,
radius=point_radius, thickness=point_thickness)
# Draw edges with optional age-based styling
if (use_age_colors and memory_manager and
hasattr(memory_manager, 'track_edge_ages') and memory_manager.track_edge_ages and
hasattr(memory_manager, 'get_edge_age_normalized')):
edge_ages = memory_manager.get_edge_age_normalized()
for edge in graph.es:
x0, y0 = int(graph.vs["x"][edge.tuple[0]]), int(graph.vs["y"][edge.tuple[0]])
x1, y1 = int(graph.vs["x"][edge.tuple[1]]), int(graph.vs["y"][edge.tuple[1]])
# Get edge age for color/alpha calculation
vertex1_id = str(graph.vs[edge.tuple[0]]["id"])
vertex2_id = str(graph.vs[edge.tuple[1]]["id"])
edge_key = tuple(sorted([vertex1_id, vertex2_id]))
if edge_key in edge_ages:
age_normalized = edge_ages[edge_key]
# Older edges are more transparent (higher age = lower alpha)
alpha = alpha_range[1] - (age_normalized * (alpha_range[1] - alpha_range[0]))
# Apply alpha to color (simple blend with background)
aged_color = tuple(int(c * alpha) for c in line_color)
else:
aged_color = line_color
draw_line(img, x0, y0, x1, y1, aged_color, thickness=line_thickness)
else:
# Standard edge drawing
for edge in graph.es:
x0, y0 = int(graph.vs["x"][edge.tuple[0]]), int(graph.vs["y"][edge.tuple[0]])
x1, y1 = int(graph.vs["x"][edge.tuple[1]]), int(graph.vs["y"][edge.tuple[1]])
draw_line(img, x0, y0, x1, y1, line_color, thickness=line_thickness)
logging.debug(f"Successfully drew memory graph with aging (use_age_colors={use_age_colors})")
except Exception as e:
raise DrawingError(f"Failed to draw memory graph with aging: {str(e)}")
[docs]
def create_memory_graph_image(graph: Any, memory_manager: Any, dimension: Tuple[int, int],
point_color: Tuple[int, int, int] = (0, 0, 255),
line_color: Tuple[int, int, int] = (0, 255, 0),
point_radius: int = 8, point_thickness: int = 3,
line_thickness: int = 1, use_age_colors: bool = True,
alpha_range: Tuple[float, float] = (0.3, 1.0)) -> np.ndarray:
"""Create a complete memory graph image
Args:
graph: igraph Graph object
memory_manager: MemoryManager instance
dimension: Image dimensions (width, height)
point_color: Color for points (B, G, R)
line_color: Base color for lines (B, G, R)
point_radius: Point radius
point_thickness: Point thickness
line_thickness: Line thickness
use_age_colors: Whether to use age-based edge coloring
alpha_range: (min_alpha, max_alpha) for age-based transparency
Returns:
Image array
Raises:
DrawingError: If image creation fails
"""
try:
if graph is None:
raise DrawingError("Graph cannot be None")
if len(dimension) != 2:
raise DrawingError("Dimension must be a tuple of (width, height)")
width, height = dimension
# Create image (height, width, 3) for OpenCV
image = np.zeros((height, width, 3), dtype=np.uint8)
# Draw the memory graph
draw_memory_graph_with_aging(
image, graph, memory_manager, point_color, line_color,
point_radius, point_thickness, line_thickness,
use_age_colors, alpha_range
)
return image
except Exception as e:
raise DrawingError(f"Failed to create memory graph image: {str(e)}")