Source code for graphizy.cli

"""
Command Line Interface for graphizy

.. moduleauthor:: Charles Fosseprez
.. contact:: charles.fosseprez.pro@gmail.com
.. license:: GPL2 or later
.. copyright:: Copyright (C) 2025 Charles Fosseprez
"""

import argparse
import logging
import sys
import json
from pathlib import Path
from typing import Dict, Any
import numpy as np

from .main import Graphing
from .config import GraphizyConfig
from .positions import generate_positions, format_positions
from .exceptions import GraphizyError


[docs] def create_parser() -> argparse.ArgumentParser: """Create command line argument parser""" parser = argparse.ArgumentParser( description="Graphizy - A graph maker for computational geometry and network visualization", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: graphizy delaunay --size 800 --particles 100 --output delaunay.jpg graphizy proximity --size 1200 --particles 200 --threshold 50 --output proximity.jpg graphizy both --size 1000 --particles 150 --show """ ) # Subcommands subparsers = parser.add_subparsers(dest='command', help='Graph type to create', required=True) # Common arguments common_parser = argparse.ArgumentParser(add_help=False) common_parser.add_argument('--size', type=int, default=1200, help='Size of the graph canvas (default: 1200)') common_parser.add_argument('--particles', type=int, default=200, help='Number of particles/points (default: 200)') common_parser.add_argument('--output', '-o', type=str, help='Output filename for saving the graph') common_parser.add_argument('--show', action='store_true', help='Display the graph in a window') common_parser.add_argument('--config', type=str, help='JSON configuration file') common_parser.add_argument('--verbose', '-v', action='store_true', help='Enable verbose logging') common_parser.add_argument('--line-color', type=str, default='0,255,0', help='Line color as R,G,B (default: 0,255,0)') common_parser.add_argument('--point-color', type=str, default='0,0,255', help='Point color as R,G,B (default: 0,0,255)') common_parser.add_argument('--line-thickness', type=int, default=1, help='Line thickness (default: 1)') common_parser.add_argument('--point-radius', type=int, default=8, help='Point radius (default: 8)') # Delaunay subcommand delaunay_parser = subparsers.add_parser('delaunay', parents=[common_parser], help='Create Delaunay triangulation graph') # Proximity subcommand proximity_parser = subparsers.add_parser('proximity', parents=[common_parser], help='Create proximity graph') proximity_parser.add_argument('--threshold', type=float, default=50.0, help='Proximity threshold distance (default: 50.0)') proximity_parser.add_argument('--metric', type=str, default='euclidean', choices=['euclidean', 'manhattan', 'chebyshev'], help='Distance metric (default: euclidean)') # Memory subcommand memory_parser = subparsers.add_parser('memory', parents=[common_parser], help='Create memory-based graph') memory_parser.add_argument('--memory-size', type=int, default=100, help='Maximum memory connections per object (default: 100)') memory_parser.add_argument('--memory-iterations', type=int, default=None, help='Maximum iterations to keep in memory (default: unlimited)') memory_parser.add_argument('--proximity-thresh', type=float, default=50.0, help='Proximity threshold for updating memory (default: 50.0)') memory_parser.add_argument('--iterations', type=int, default=10, help='Number of simulation iterations (default: 10)') # Both subcommand both_parser = subparsers.add_parser('both', parents=[common_parser], help='Create both Delaunay and proximity graphs') both_parser.add_argument('--threshold', type=float, default=50.0, help='Proximity threshold distance (default: 50.0)') both_parser.add_argument('--metric', type=str, default='euclidean', choices=['euclidean', 'manhattan', 'chebyshev'], help='Distance metric (default: euclidean)') both_parser.add_argument('--delaunay-output', type=str, help='Output filename for Delaunay graph') both_parser.add_argument('--proximity-output', type=str, help='Output filename for proximity graph') # Info subcommand info_parser = subparsers.add_parser('info', parents=[common_parser], help='Generate graph and show statistics') info_parser.add_argument('--threshold', type=float, default=50.0, help='Proximity threshold distance (default: 50.0)') return parser
[docs] def parse_color(color_str: str) -> tuple: """Parse color string to tuple""" try: r, g, b = map(int, color_str.split(',')) return (b, g, r) # OpenCV uses BGR except: raise ValueError(f"Invalid color format: {color_str}. Use R,G,B format.")
[docs] def load_config(config_file: str) -> Dict[str, Any]: """Load configuration from JSON file""" try: with open(config_file, 'r') as f: return json.load(f) except Exception as e: raise GraphizyError(f"Failed to load config file {config_file}: {e}")
[docs] def setup_logging(verbose: bool) -> None: """Setup logging configuration""" level = logging.DEBUG if verbose else logging.INFO logging.basicConfig( level=level, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' )
[docs] def create_config_from_args(args) -> GraphizyConfig: """Create configuration from command line arguments""" config = GraphizyConfig() # Load from file if specified if hasattr(args, 'config') and args.config: file_config = load_config(args.config) config.update(**file_config) # Override with command line arguments (only if explicitly provided) config.graph.dimension = (args.size, args.size) config.generation.size_x = args.size config.generation.size_y = args.size config.generation.num_particles = args.particles # Drawing configuration - only override if explicitly set (not default) if hasattr(args, 'line_color'): config.drawing.line_color = parse_color(args.line_color) if hasattr(args, 'point_color'): config.drawing.point_color = parse_color(args.point_color) # Only override line_thickness if it's not the default value if hasattr(args, 'line_thickness') and args.line_thickness != 1: config.drawing.line_thickness = args.line_thickness if hasattr(args, 'point_radius'): config.drawing.point_radius = args.point_radius # Graph-specific configuration if hasattr(args, 'threshold'): config.graph.proximity_threshold = args.threshold if hasattr(args, 'metric'): config.graph.distance_metric = args.metric # Memory configuration (if this is a memory command) if hasattr(args, 'memory_size'): config.memory.max_memory_size = args.memory_size if hasattr(args, 'memory_iterations'): config.memory.max_iterations = args.memory_iterations # Logging configuration config.logging.level = 'DEBUG' if args.verbose else 'INFO' return config
[docs] def generate_data(config: GraphizyConfig) -> np.ndarray: """Generate particle data""" position_list = generate_positions( config.generation.size_x, config.generation.size_y, config.generation.num_particles, config.generation.to_array, config.generation.convert_to_float ) # Create particle stack with IDs particle_stack = format_positions(positions=position_list) return particle_stack
[docs] def cmd_delaunay(args) -> None: """Handle delaunay command""" config = create_config_from_args(args) setup_logging(args.verbose) try: # Generate data particle_stack = generate_data(config) # Create grapher grapher = Graphing(config=config) # Create Delaunay graph logging.info("Creating Delaunay triangulation...") graph_del = grapher.make_graph("delaunay", particle_stack) # Get statistics info = grapher.get_graph_info(graph_del) print(f"Delaunay Graph Statistics:") print(f" Vertices: {info['vertex_count']}") print(f" Edges: {info['edge_count']}") print(f" Density: {info['density']:.4f}") if info.get('average_path_length'): print(f" Average Path Length: {info['average_path_length']:.4f}") # Draw graph image = grapher.draw_graph(graph_del) # Save if requested if args.output: grapher.save_graph(image, args.output) print(f"Delaunay graph saved to {args.output}") # Show if requested if args.show: grapher.show_graph(image, "Delaunay Triangulation") except Exception as e: logging.error(f"Failed to create Delaunay graph: {e}") sys.exit(1)
[docs] def cmd_proximity(args) -> None: """Handle proximity command""" config = create_config_from_args(args) setup_logging(args.verbose) try: # Generate data particle_stack = generate_data(config) # Create grapher grapher = Graphing(config=config) # Create proximity graph logging.info(f"Creating proximity graph with threshold {args.threshold}...") graph_prox = grapher.make_graph("proximity", particle_stack, proximity_thresh=args.threshold, metric=args.metric) # Get statistics info = grapher.get_graph_info(graph_prox) print(f"Proximity Graph Statistics:") print(f" Vertices: {info['vertex_count']}") print(f" Edges: {info['edge_count']}") print(f" Density: {info['density']:.4f}") print(f" Connected: {info['is_connected']}") if info.get('average_path_length'): print(f" Average Path Length: {info['average_path_length']:.4f}") # Draw graph image = grapher.draw_graph(graph_prox) # Save if requested if args.output: grapher.save_graph(image, args.output) print(f"Proximity graph saved to {args.output}") # Show if requested if args.show: grapher.show_graph(image, f"Proximity Graph (proximity threshold={args.threshold})") except Exception as e: logging.error(f"Failed to create proximity graph: {e}") sys.exit(1)
[docs] def cmd_memory(args): """Handle memory command""" config = create_config_from_args(args) setup_logging(args.verbose) try: # Generate initial data particle_stack = generate_data(config) # Create grapher with memory grapher = Graphing(config=config) grapher.init_memory_manager( max_memory_size=args.memory_size, max_iterations=args.memory_iterations, track_edge_ages=True ) # Simulate multiple iterations print(f"Simulating {args.iterations} iterations...") memory_graph = None for i in range(args.iterations): # Add some random movement (optional) if i > 0: # Small random movements particle_stack[:, 1:3] += np.random.normal(0, 5, (len(particle_stack), 2)) # Keep within bounds particle_stack[:, 1] = np.clip(particle_stack[:, 1], 0, config.graph.dimension[0] - 1) particle_stack[:, 2] = np.clip(particle_stack[:, 2], 0, config.graph.dimension[1] - 1) # Create a proximity graph. This automatically updates memory due to smart defaults. # We set use_memory=True to get the combined historical graph at each step. memory_graph = grapher.make_graph( "proximity", particle_stack, proximity_thresh=args.proximity_thresh, use_memory=True, update_memory=True ) print(f"Iteration {i + 1}: Memory graph has {memory_graph.ecount()} edges") # Get statistics stats = grapher.get_memory_analysis() graph_info = grapher.get_graph_info(memory_graph) print(f"\nMemory Graph Results:") print(f" Total Objects: {stats['total_objects']}") print(f" Memory Connections: {stats['total_connections']}") print(f" Graph Vertices: {graph_info['vertex_count']}") print(f" Graph Edges: {graph_info['edge_count']}") print(f" Graph Density: {graph_info['density']:.4f}") # Draw graph image = grapher.draw_graph(memory_graph) # Save if requested if args.output: grapher.save_graph(image, args.output) print(f"Memory graph saved to {args.output}") # Show if requested if args.show: grapher.show_graph(image, f"Memory Graph ({stats['total_connections']} connections)") except Exception as e: logging.error(f"Failed to create memory graph: {e}") sys.exit(1)
[docs] def cmd_both(args) -> None: """Handle both command""" config = create_config_from_args(args) setup_logging(args.verbose) try: # ... (data generation and graph creation is the same) particle_stack = generate_data(config) grapher = Graphing(config=config) graph_del = grapher.make_graph("delaunay", particle_stack) graph_prox = grapher.make_graph("proximity", particle_stack, proximity_thresh=args.threshold, metric=args.metric) # ... (statistics printing is the same) del_image = grapher.draw_graph(graph_del) prox_image = grapher.draw_graph(graph_prox) # --- Simplified and more robust output logic --- delaunay_output = args.delaunay_output proximity_output = args.proximity_output if args.output and not (delaunay_output or proximity_output): # If a base output name is given, derive names from it. base_path = Path(args.output) delaunay_output = base_path.with_stem(f"{base_path.stem}_delaunay") proximity_output = base_path.with_stem(f"{base_path.stem}_proximity") if delaunay_output: grapher.save_graph(del_image, str(delaunay_output)) print(f"Delaunay graph saved to {delaunay_output}") if proximity_output: grapher.save_graph(prox_image, str(proximity_output)) print(f"Proximity graph saved to {proximity_output}") # Show if requested if args.show: grapher.show_graph(del_image, "Delaunay Triangulation") grapher.show_graph(prox_image, f"Proximity Graph (proximity threshold={args.threshold})") except Exception as e: logging.error(f"Failed to create graphs: {e}") sys.exit(1)
[docs] def cmd_info(args) -> None: """Handle info command""" config = create_config_from_args(args) setup_logging(args.verbose) try: # Generate data particle_stack = generate_data(config) # Create grapher grapher = Graphing(config=config) # Create both graphs graph_del = grapher.make_graph("delaunay", particle_stack) graph_prox = grapher.make_graph("proximity", particle_stack, proximity_thresh=args.threshold) # Detailed statistics del_info = grapher.get_graph_info(graph_del) prox_info = grapher.get_graph_info(graph_prox) print(f"Dataset Information:") print(f" Canvas Size: {args.size}x{args.size}") print(f" Number of Particles: {args.particles}") print() print("Delaunay Triangulation:") for key, value in del_info.items(): if value is not None: if isinstance(value, float): print(f" {key.replace('_', ' ').title()}: {value:.4f}") else: print(f" {key.replace('_', ' ').title()}: {value}") print() print(f"Proximity Graph (threshold={args.threshold}):") for key, value in prox_info.items(): if value is not None: if isinstance(value, float): print(f" {key.replace('_', ' ').title()}: {value:.4f}") else: print(f" {key.replace('_', ' ').title()}: {value}") # Save summary if output specified if args.output: summary = { "dataset": { "canvas_size": f"{args.size}x{args.size}", "particles": args.particles }, "delaunay": del_info, "proximity": prox_info } output_path = Path(args.output) json_path = output_path.with_suffix('.json') with open(json_path, 'w') as f: json.dump(summary, f, indent=2) print(f"\nSummary saved to {json_path}") except Exception as e: logging.error(f"Failed to generate info: {e}") sys.exit(1)
[docs] def main() -> None: """Main CLI entry point""" parser = create_parser() args = parser.parse_args() if not args.command: parser.print_help() sys.exit(1) try: if args.command == 'delaunay': cmd_delaunay(args) elif args.command == 'proximity': cmd_proximity(args) elif args.command == 'memory': cmd_memory(args) elif args.command == 'both': cmd_both(args) elif args.command == 'info': cmd_info(args) else: parser.print_help() sys.exit(1) except KeyboardInterrupt: print("\nOperation cancelled by user") sys.exit(1) except Exception as e: logging.error(f"Unexpected error: {e}") sys.exit(1)
if __name__ == '__main__': main()