diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..852bf18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Python bytecode +__pycache__/ +*.py[cod] +*$py.class + +# Build artifacts / packaging +build/ +dist/ +.eggs/ +*.egg-info/ +wheels/ +pip-wheel-metadata/ +*.egg +MANIFEST + +# Tests / coverage +.pytest_cache/ +htmlcov/ +.coverage +.coverage.* +nosetests.xml +coverage.xml +*.cover + +# Virtual environments +.venv/ +venv/ + +# Tool caches +.mypy_cache/ +.pytype/ +.pyre/ +.ruff_cache/ + +# Editors / IDE +.vscode/ + +# OS files +.DS_Store +Thumbs.db diff --git a/README.md b/README.md index a02f39f..ad1b5c9 100644 --- a/README.md +++ b/README.md @@ -181,7 +181,7 @@ visualkeras.layered_view(model, sizing_mode='accurate') visualkeras.layered_view(model, sizing_mode='balanced') ``` - + **Capped mode**: Caps dimensions at specified limits while preserving ratios diff --git a/visualkeras/graph.py b/visualkeras/graph.py index a934abf..e420281 100644 --- a/visualkeras/graph.py +++ b/visualkeras/graph.py @@ -189,34 +189,15 @@ def graph_view(model, to_file: str = None, id_to_num_mapping, adj_matrix = model_to_adj_matrix(model) model_layers = model_to_hierarchy_lists(model, id_to_num_mapping, adj_matrix) - # Add fake output layers only when needed - # When inout_as_tensor=False, only add dummy layers if output-producing layers - # are not in the last hierarchy level (to avoid duplication) - should_add_dummy_outputs = inout_as_tensor - - if not inout_as_tensor: - # Check if all output-producing layers are in the last hierarchy level - last_level_layers = model_layers[-1] if model_layers else [] - layers_producing_outputs = [] - - for output_tensor in model.outputs: - for layer in model.layers: - if hasattr(layer, 'output') and layer.output is output_tensor: - layers_producing_outputs.append(layer) - break - - # Only add dummy outputs if some output-producing layers are NOT in the last level - should_add_dummy_outputs = not all(layer in last_level_layers for layer in layers_producing_outputs) + # Add fake output layers to provide explicit sinks so connectors always end + # in a visible node column. This matches the reference renderer outputs and + # keeps tensor/neuron views consistent. + should_add_dummy_outputs = True if should_add_dummy_outputs: - # Normalize output_shape to always be a list of tuples - if isinstance(model.output_shape, tuple): - # Single output model: output_shape is a tuple, convert to list of tuples - output_shapes = [model.output_shape] - else: - # Multi-output model: output_shape is already a list of tuples - output_shapes = model.output_shape - + # Normalize output_shape using helper to handle Keras 3 + output_shapes = get_model_output_shapes(model) + model_layers.append([ _DummyLayer( output_names[i], @@ -238,6 +219,7 @@ def graph_view(model, to_file: str = None, for layer in layer_list: is_box = True units = 1 + node_scale_override = None # optional per-node scale for circles to normalize column height if show_neurons: if hasattr(layer, 'units'): @@ -248,15 +230,21 @@ def graph_view(model, to_file: str = None, units = layer.filters elif is_internal_input(layer) and not inout_as_tensor: is_box = False - # Normalize input_shape to handle both tuple and list formats - input_shape = layer.input_shape - if isinstance(input_shape, tuple): - shape = input_shape - elif isinstance(input_shape, list) and len(input_shape) == 1: + # Normalize input shape using helper + input_shape = get_layer_input_shape(layer) + if isinstance(input_shape, (list, tuple)) and len(input_shape) > 0 and isinstance(input_shape[0], (list, tuple)): shape = input_shape[0] else: - raise RuntimeError(f"not supported input shape {input_shape}") + shape = input_shape units = self_multiply(shape) + # Keep the overall column height similar to the default box height (3*node_size) + # Compute per-node scale so that: units * scale * node_size + (units-1)*node_spacing ≈ 3*node_size + if units and units > 0: + target = 3 * node_size + numerator = target - max(units - 1, 0) * node_spacing + denom = units * node_size + s = max(0.2, min(1.0, numerator / denom)) if denom > 0 else 1.0 + node_scale_override = s n = min(units, ellipsize_after) layer_nodes = list() @@ -275,7 +263,9 @@ def graph_view(model, to_file: str = None, c.x1 = current_x c.y1 = current_y c.x2 = c.x1 + node_size - c.y2 = c.y1 + node_size * scale + # For neuron circles, allow per-layer scale override to normalize column height + eff_scale = node_scale_override if (node_scale_override is not None and not is_box) else scale + c.y2 = c.y1 + node_size * eff_scale current_y = c.y2 + node_spacing @@ -296,6 +286,11 @@ def graph_view(model, to_file: str = None, img_width = len(layers) * node_size + (len(layers) - 1) * layer_spacing + 2 * padding img_height = max(*layer_y) + 2 * padding + # Keep height comparable between tensor and flattened views + if not inout_as_tensor and show_neurons: + baseline = 3 * node_size + 2 * padding + if img_height < baseline: + img_height = baseline img = Image.new('RGBA', (int(ceil(img_width)), int(ceil(img_height))), background_fill) draw = aggdraw.Draw(img) diff --git a/visualkeras/layer_utils.py b/visualkeras/layer_utils.py index 50c2fbf..a67c47d 100644 --- a/visualkeras/layer_utils.py +++ b/visualkeras/layer_utils.py @@ -2,6 +2,7 @@ from .utils import get_keys_by_value from collections.abc import Iterable import warnings +from typing import List, Tuple, Union, Optional try: from tensorflow.keras.layers import Layer @@ -74,9 +75,18 @@ def get_incoming_layers(layer): def get_outgoing_layers(layer): - """Yield outgoing (child) layers for a given layer.""" + """Yield outgoing (child) layers for a given layer. + + Supports both legacy Node API (TF/Keras <= 2.15) and the new Node API + (TF >= 2.16 / Keras >= 3). + """ for i, node in enumerate(layer._outbound_nodes): - yield node.outbound_layer + if hasattr(node, 'outbound_layer'): + # Old Node API + yield node.outbound_layer + else: + # New Node API (Keras 3): node.operation is the target layer + yield node.operation def model_to_adj_matrix(model): @@ -305,6 +315,130 @@ def is_internal_input(layer): return False + +# ---------------------------- +# Shape utilities (Keras 2/3) +# ---------------------------- + +def _tensor_shape_to_tuple(shape_obj) -> Optional[Tuple]: + """Convert TensorShape/KerasTensor.shape to a Python tuple of ints/None. + + Returns None if conversion is not possible. + """ + if shape_obj is None: + return None + # TensorFlow TensorShape has as_list + if hasattr(shape_obj, 'as_list'): + try: + return tuple(shape_obj.as_list()) + except Exception: + pass + # Otherwise assume iterable of dims + try: + dims = [] + for d in shape_obj: + # Some dims are Dimension-like; try int() with fallback to None + if d is None: + dims.append(None) + else: + try: + dims.append(int(d)) + except Exception: + dims.append(None) + return tuple(dims) + except TypeError: + return None + + +def get_layer_output_shape(layer) -> Union[Tuple, List[Tuple], None]: + """Best-effort retrieval of a layer's output shape as tuple(s). + + Works across Keras/TF versions where `.output_shape` might not be present + (e.g., Keras 3 InputLayer). + """ + # 1) Direct attribute (older versions) + s = getattr(layer, 'output_shape', None) + if s is not None: + return s + + # 2) From `output` tensor(s) + out = getattr(layer, 'output', None) + if out is not None: + if isinstance(out, (list, tuple)): + shapes = [_tensor_shape_to_tuple(t.shape) for t in out] + return shapes + else: + return _tensor_shape_to_tuple(out.shape) + + # 3) Fallbacks for Input-like layers + for attr in ('batch_shape', 'batch_input_shape', 'input_shape', 'shape'): + s = getattr(layer, attr, None) + if s is not None: + # Ensure tuple(s) + if isinstance(s, (list, tuple)) and len(s) > 0 and isinstance(s[0], (list, tuple)): + return [tuple(x) for x in s] + if hasattr(s, 'as_list'): + try: + return tuple(s.as_list()) + except Exception: + pass + # Single tuple + if isinstance(s, (list, tuple)): + return tuple(s) + # Unknown format + break + return None + + +def get_layer_input_shape(layer) -> Union[Tuple, List[Tuple], None]: + """Best-effort retrieval of a layer's input shape as tuple(s).""" + # 1) Direct attribute + s = getattr(layer, 'input_shape', None) + if s is not None: + return s + + # 2) From `input` tensor(s) + inp = getattr(layer, 'input', None) + if inp is not None: + if isinstance(inp, (list, tuple)): + shapes = [_tensor_shape_to_tuple(t.shape) for t in inp] + return shapes + else: + return _tensor_shape_to_tuple(inp.shape) + + # 3) Fallbacks common for InputLayer + for attr in ('batch_input_shape', 'batch_shape', 'shape'): + s = getattr(layer, attr, None) + if s is not None: + if isinstance(s, (list, tuple)) and len(s) > 0 and isinstance(s[0], (list, tuple)): + return [tuple(x) for x in s] + if hasattr(s, 'as_list'): + try: + return tuple(s.as_list()) + except Exception: + pass + if isinstance(s, (list, tuple)): + return tuple(s) + break + return None + + +def get_model_output_shapes(model) -> List[Tuple]: + """Return list of output shape tuples for a model across Keras versions.""" + shapes = getattr(model, 'output_shape', None) + if shapes is not None: + if isinstance(shapes, tuple): + return [shapes] + # Assume already list-like of tuples + return list(shapes) + # Derive from model.outputs tensors + outputs = getattr(model, 'outputs', None) or [] + result: List[Tuple] = [] + for t in outputs: + result.append(_tensor_shape_to_tuple(getattr(t, 'shape', None))) + return result + + def extract_primary_shape(layer_output_shape, layer_name: str = None) -> tuple: """ Extract the primary shape from a layer's output shape to handle multi-output scenarios. diff --git a/visualkeras/layered.py b/visualkeras/layered.py index 8a7a484..8c80c3b 100644 --- a/visualkeras/layered.py +++ b/visualkeras/layered.py @@ -17,52 +17,8 @@ from keras import layers except: warnings.warn("Could not import the 'layers' module from Keras. text_callable will not work.") - _BUILT_IN_TEXT_CALLABLES = tuple(LAYERED_TEXT_CALLABLES.values()) - -def _resolve_layer_output_shape(layer) -> Any: - """ - Attempt to retrieve a layer's output shape across keras/tensorflow versions. - - Prefers an explicit ``output_shape`` attribute, falls back to the tensor's - shape, and finally tries ``compute_output_shape`` when available. - """ - shape = getattr(layer, "output_shape", None) - if shape is not None: - return _shape_to_tuple(shape) - - output = getattr(layer, "output", None) - tensor_shape = getattr(output, "shape", None) - if tensor_shape is not None: - return _shape_to_tuple(tensor_shape) - - compute_output_shape = getattr(layer, "compute_output_shape", None) - if callable(compute_output_shape): - input_shape = getattr(layer, "input_shape", None) - if input_shape is not None: - try: - return _shape_to_tuple(compute_output_shape(input_shape)) - except Exception: # noqa: BLE001 - pass - - return None - - -def _shape_to_tuple(shape: Any) -> Any: - if shape is None: - return None - if isinstance(shape, tuple): - return shape - if hasattr(shape, "as_list"): - try: - return tuple(shape.as_list()) - except Exception: # noqa: BLE001 - return tuple(shape) - if isinstance(shape, list): - return tuple(shape) - return shape - def layered_view(model, to_file: str = None, min_z: int = 20, @@ -415,9 +371,8 @@ def layered_view(model, layer_name = f'unknown_layer_{index}' # Get the primary shape of the layer's output - raw_shape = _resolve_layer_output_shape(layer) + raw_shape = get_layer_output_shape(layer) shape = extract_primary_shape(raw_shape, layer_name) - # Calculate dimensions with flexible sizing x, y, z = calculate_layer_dimensions( shape, scale_z, scale_xy,