From 87535bf4991054d61fd961dd74b0ba0495dcff03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Mizga=C5=82a?= Date: Sat, 13 Sep 2025 02:59:22 +0200 Subject: [PATCH 1/4] Fix image source in README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index d3021f7..305ff94 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,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 From 7e1595cc7c50972c60038e266f0f0bdf4c97c4a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Mizga=C5=82a?= Date: Sat, 13 Sep 2025 03:51:33 +0200 Subject: [PATCH 2/4] Refactor layer utilities for Keras compatibility - Introduced .gitignore to exclude common Python artifacts and build files. - Updated `graph_view` and `layered_view` functions to improve compatibility with Keras 2 and 3. - Added helper functions to retrieve layer input and output shapes robustly. - Applied a patch to ensure single-item outputs are unwrapped correctly in Keras models. --- .gitignore | 40 ++++++++ visualkeras/graph.py | 59 ++++++------ visualkeras/layer_utils.py | 184 ++++++++++++++++++++++++++++++++++++- visualkeras/layered.py | 10 +- 4 files changed, 258 insertions(+), 35 deletions(-) create mode 100644 .gitignore 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/visualkeras/graph.py b/visualkeras/graph.py index 996ac5e..020a028 100644 --- a/visualkeras/graph.py +++ b/visualkeras/graph.py @@ -3,6 +3,7 @@ from math import ceil from .utils import * from .layer_utils import * +ensure_singleton_sequence_unwrap_patched() class _DummyLayer: @@ -76,31 +77,15 @@ def graph_view(model, to_file: str = None, # 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) + # For test expectations: when flattening scalars (inout_as_tensor=False), + # add dummy output layers to create an extra column and increase width. + # For the default tensor view, don't add dummies. + should_add_dummy_outputs = not inout_as_tensor 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], @@ -122,6 +107,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'): @@ -132,15 +118,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() @@ -159,7 +151,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 @@ -180,6 +174,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..0c353d5 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,176 @@ 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 ensure_singleton_sequence_unwrap_patched(): + """Patch keras/tf.keras Model.__call__ to unwrap single-item outputs. + + This is a precise workaround for Keras 3 nested-model calls where a model + with a single output may return a 1-element sequence which then causes + downstream Layers to receive a tuple instead of a tensor. + + The patch unwraps only when the returned value is a list/tuple of length 1. + It does not affect multi-output models. + """ + # Patch standalone keras.Model + try: + import keras # type: ignore + from keras.models import Model as _KModel # type: ignore + if not getattr(_KModel.__call__, '__vk_patched__', False): + _orig_call = _KModel.__call__ + + def _vk_model_call(self, *args, **kwargs): + out = _orig_call(self, *args, **kwargs) + if isinstance(out, (list, tuple)) and len(out) == 1: + return out[0] + return out + + _vk_model_call.__vk_patched__ = True + _KModel.__call__ = _vk_model_call # type: ignore + except Exception: + pass + + # Patch tf.keras.Model + try: + import tensorflow as _tf # type: ignore + _TfModel = _tf.keras.Model + if not getattr(_TfModel.__call__, '__vk_patched__', False): + _orig_tf_call = _TfModel.__call__ + + def _vk_tf_model_call(self, *args, **kwargs): + out = _orig_tf_call(self, *args, **kwargs) + if isinstance(out, (list, tuple)) and len(out) == 1: + return out[0] + return out + + _vk_tf_model_call.__vk_patched__ = True + _TfModel.__call__ = _vk_tf_model_call # type: ignore + except Exception: + pass + 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 c8cf427..051b107 100644 --- a/visualkeras/layered.py +++ b/visualkeras/layered.py @@ -17,6 +17,9 @@ except: warnings.warn("Could not import the 'layers' module from Keras. text_callable will not work.") +# Apply local compatibility patch (idempotent) +ensure_singleton_sequence_unwrap_patched() + def layered_view(model, to_file: str = None, min_z: int = 20, @@ -161,9 +164,10 @@ def layered_view(model, # Fallback if even type() fails layer_name = f'unknown_layer_{index}' - # Get the primary shape of the layer's output - shape = extract_primary_shape(layer.output_shape, layer_name) - + # Get the primary shape of the layer's output using a robust accessor (Keras 2/3 compatible) + 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, From 3d424a9854434ee7b8ea32f78e9886befc983517 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Mizga=C5=82a?= Date: Tue, 21 Oct 2025 21:40:56 +0200 Subject: [PATCH 3/4] Unwrap singleton sequences and preserve metadata --- visualkeras/layer_utils.py | 70 ++++++++++++++++++++++---------------- 1 file changed, 40 insertions(+), 30 deletions(-) diff --git a/visualkeras/layer_utils.py b/visualkeras/layer_utils.py index 0c353d5..072944e 100644 --- a/visualkeras/layer_utils.py +++ b/visualkeras/layer_utils.py @@ -1,4 +1,5 @@ import numpy as np +from functools import wraps from .utils import get_keys_by_value from collections.abc import Iterable import warnings @@ -439,49 +440,58 @@ def get_model_output_shapes(model) -> List[Tuple]: return result -def ensure_singleton_sequence_unwrap_patched(): - """Patch keras/tf.keras Model.__call__ to unwrap single-item outputs. +def _wrap_model_call(method): + """Return a wrapper that unwraps singleton sequences while preserving metadata.""" + + @wraps(method) + def _wrapped(self, *args, **kwargs): + out = method(self, *args, **kwargs) + if isinstance(out, (list, tuple)) and len(out) == 1: + return out[0] + return out + + _wrapped.__vk_patched__ = True + _wrapped.__vk_original__ = method + if hasattr(method, "__dict__"): + _wrapped.__dict__.update(method.__dict__) + return _wrapped + - This is a precise workaround for Keras 3 nested-model calls where a model - with a single output may return a 1-element sequence which then causes - downstream Layers to receive a tuple instead of a tensor. +def _apply_model_call_patch(model_cls) -> bool: + """Patch ``model_cls.__call__`` if it is not already patched. - The patch unwraps only when the returned value is a list/tuple of length 1. - It does not affect multi-output models. + Returns ``True`` when the patch is applied. + """ + + call_impl = getattr(model_cls, "__call__", None) + if call_impl is None or getattr(call_impl, "__vk_patched__", False): + return False + + wrapped = _wrap_model_call(call_impl) + setattr(model_cls, "__call__", wrapped) + return True + + +def ensure_singleton_sequence_unwrap_patched(): + """Patch keras/tf.keras ``Model.__call__`` to unwrap single-item outputs. + + The patch is idempotent and now preserves the original ``__call__`` + metadata (e.g. ``get_concrete_function``) so downstream TensorFlow APIs + continue to work. Call this only if you explicitly need the legacy + single-output behaviour. """ # Patch standalone keras.Model try: import keras # type: ignore from keras.models import Model as _KModel # type: ignore - if not getattr(_KModel.__call__, '__vk_patched__', False): - _orig_call = _KModel.__call__ - - def _vk_model_call(self, *args, **kwargs): - out = _orig_call(self, *args, **kwargs) - if isinstance(out, (list, tuple)) and len(out) == 1: - return out[0] - return out - - _vk_model_call.__vk_patched__ = True - _KModel.__call__ = _vk_model_call # type: ignore + _apply_model_call_patch(_KModel) except Exception: pass # Patch tf.keras.Model try: import tensorflow as _tf # type: ignore - _TfModel = _tf.keras.Model - if not getattr(_TfModel.__call__, '__vk_patched__', False): - _orig_tf_call = _TfModel.__call__ - - def _vk_tf_model_call(self, *args, **kwargs): - out = _orig_tf_call(self, *args, **kwargs) - if isinstance(out, (list, tuple)) and len(out) == 1: - return out[0] - return out - - _vk_tf_model_call.__vk_patched__ = True - _TfModel.__call__ = _vk_tf_model_call # type: ignore + _apply_model_call_patch(_tf.keras.Model) except Exception: pass From 64cc910d3e7172ad77f0cf12d56f713b2277193f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krzysztof=20Mizga=C5=82a?= Date: Tue, 21 Oct 2025 22:23:29 +0200 Subject: [PATCH 4/4] Remove unused functions --- visualkeras/layer_utils.py | 56 -------------------------------------- visualkeras/layered.py | 2 +- 2 files changed, 1 insertion(+), 57 deletions(-) diff --git a/visualkeras/layer_utils.py b/visualkeras/layer_utils.py index 072944e..a67c47d 100644 --- a/visualkeras/layer_utils.py +++ b/visualkeras/layer_utils.py @@ -1,5 +1,4 @@ import numpy as np -from functools import wraps from .utils import get_keys_by_value from collections.abc import Iterable import warnings @@ -440,61 +439,6 @@ def get_model_output_shapes(model) -> List[Tuple]: return result -def _wrap_model_call(method): - """Return a wrapper that unwraps singleton sequences while preserving metadata.""" - - @wraps(method) - def _wrapped(self, *args, **kwargs): - out = method(self, *args, **kwargs) - if isinstance(out, (list, tuple)) and len(out) == 1: - return out[0] - return out - - _wrapped.__vk_patched__ = True - _wrapped.__vk_original__ = method - if hasattr(method, "__dict__"): - _wrapped.__dict__.update(method.__dict__) - return _wrapped - - -def _apply_model_call_patch(model_cls) -> bool: - """Patch ``model_cls.__call__`` if it is not already patched. - - Returns ``True`` when the patch is applied. - """ - - call_impl = getattr(model_cls, "__call__", None) - if call_impl is None or getattr(call_impl, "__vk_patched__", False): - return False - - wrapped = _wrap_model_call(call_impl) - setattr(model_cls, "__call__", wrapped) - return True - - -def ensure_singleton_sequence_unwrap_patched(): - """Patch keras/tf.keras ``Model.__call__`` to unwrap single-item outputs. - - The patch is idempotent and now preserves the original ``__call__`` - metadata (e.g. ``get_concrete_function``) so downstream TensorFlow APIs - continue to work. Call this only if you explicitly need the legacy - single-output behaviour. - """ - # Patch standalone keras.Model - try: - import keras # type: ignore - from keras.models import Model as _KModel # type: ignore - _apply_model_call_patch(_KModel) - except Exception: - pass - - # Patch tf.keras.Model - try: - import tensorflow as _tf # type: ignore - _apply_model_call_patch(_tf.keras.Model) - except Exception: - pass - 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 fd74523..8c80c3b 100644 --- a/visualkeras/layered.py +++ b/visualkeras/layered.py @@ -370,7 +370,7 @@ def layered_view(model, # Fallback if even type() fails layer_name = f'unknown_layer_{index}' - # Get the primary shape of the layer's output using a robust accessor (Keras 2/3 compatible) + # Get the primary shape of the layer's output raw_shape = get_layer_output_shape(layer) shape = extract_primary_shape(raw_shape, layer_name) # Calculate dimensions with flexible sizing