diff --git a/hls4ml/converters/keras_v3/conv.py b/hls4ml/converters/keras_v3/conv.py index eccc4c7129..d70cda83b6 100644 --- a/hls4ml/converters/keras_v3/conv.py +++ b/hls4ml/converters/keras_v3/conv.py @@ -137,6 +137,26 @@ def handle( config['depthwise_data'] = kernel config['pointwise_data'] = self.load_weight(layer, 'pointwise_kernel') config['depth_multiplier'] = layer.depth_multiplier + elif isinstance(layer, BaseConv) and getattr(layer, 'groups', 1) > 1: + # A grouped convolution with groups == in_channels and one filter per channel + # (depth_multiplier == 1) is a depthwise convolution. Keras stores its kernel as + # (*kernel_size, 1, n_chan); reshape it to the (*kernel_size, n_chan, 1) layout the + # depthwise codegen expects and emit it as a DepthwiseConv. Without this a grouped + # Conv falls through to the dense branch below and is silently miscomputed. + # + # Other grouped shapes have no correct hls4ml kernel: general grouping + # (1 < groups < in_channels) has no implementation, and depthwise with + # depth_multiplier > 1 is unsupported by the depthwise kernels (true for a native + # DepthwiseConv too). Reject them rather than emit a wrong result. + n_chan, n_filt = config['n_chan'], config['n_filt'] + if layer.groups != n_chan or n_filt != n_chan: + raise NotImplementedError( + 'Grouped convolution is only supported as depthwise (groups == in_channels == filters); ' + f'layer {layer.name} has groups={layer.groups}, in_channels={n_chan}, filters={n_filt}.' + ) + config['class_name'] = f'DepthwiseConv{len(ker_px_shape)}D' + config['depthwise_data'] = kernel.reshape(*kernel.shape[:-2], n_chan, 1) + config['depth_multiplier'] = 1 elif isinstance(layer, BaseConv): config['weight_data'] = kernel diff --git a/test/pytest/test_depthwise_via_groups.py b/test/pytest/test_depthwise_via_groups.py new file mode 100644 index 0000000000..0675b7c7f5 --- /dev/null +++ b/test/pytest/test_depthwise_via_groups.py @@ -0,0 +1,97 @@ +"""Depthwise Conv1D / Conv2D expressed as a grouped convolution (groups == channels). + +A Keras ``Conv1D`` / ``Conv2D`` with ``groups == in_channels`` is a depthwise +convolution, but the keras_v3 ``ConvHandler`` routed it to the dense Conv path, +loading the (k, 1, n_chan) depthwise kernel into a dense matmul and silently +producing wrong output. It is now reshaped to the depthwise layout and emitted as +a ``DepthwiseConv``. General grouped convolutions (1 < groups < in_channels) and +depth_multiplier > 1 have no correct hls4ml kernel and are rejected with a clear +error rather than silently miscomputed. +""" + +from pathlib import Path + +import keras +import numpy as np +import pytest + +if keras.__version__ < '3.0': + pytest.skip('Only applicable to the Keras 3 (keras_v3) converter', allow_module_level=True) + +from keras.layers import Conv1D, Conv2D, Input # noqa: E402 + +import hls4ml # noqa: E402 + +test_root_path = Path(__file__).parent + + +@pytest.mark.parametrize('backend', ['Vivado', 'Vitis']) +@pytest.mark.parametrize('io_type', ['io_parallel', 'io_stream']) +@pytest.mark.parametrize('padding', ['same', 'valid', 'causal']) +def test_depthwise1d_via_groups(test_case_id, backend, io_type, padding): + """Conv1D(groups == channels) must be emitted as DepthwiseConv1D and match Keras.""" + n_chan = 4 + X = np.random.rand(10, 16, n_chan) + X = np.round(X * 2**10) * 2**-10 # exact on the fixed-point grid + model = keras.Sequential([Input((16, n_chan)), Conv1D(n_chan, 3, padding=padding, groups=n_chan, name='gc')]) + model.compile() + + config = hls4ml.utils.config_from_keras_model( + model, granularity='name', default_precision='fixed<32,12>', backend=backend + ) + output_dir = str(test_root_path / test_case_id) + hls_model = hls4ml.converters.convert_from_keras_model( + model, hls_config=config, output_dir=output_dir, backend=backend, io_type=io_type + ) + # Routed to the depthwise implementation, not a dense Conv1D (which produced garbage pre-fix). + assert hls_model.graph['gc'].class_name == 'DepthwiseConv1D' + hls_model.compile() + + y_keras = model.predict(X, verbose=0) + y_hls = hls_model.predict(X).reshape(y_keras.shape) + np.testing.assert_allclose(y_hls, y_keras, rtol=1e-2, atol=0.01) + + +@pytest.mark.parametrize('backend', ['Vivado', 'Vitis']) +@pytest.mark.parametrize('io_type', ['io_parallel', 'io_stream']) +def test_depthwise2d_via_groups(test_case_id, backend, io_type): + """Conv2D(groups == channels) must be emitted as DepthwiseConv2D and match Keras.""" + n_chan = 4 + X = np.random.rand(10, 8, 8, n_chan) + X = np.round(X * 2**10) * 2**-10 + model = keras.Sequential([Input((8, 8, n_chan)), Conv2D(n_chan, (3, 3), padding='same', groups=n_chan, name='gc')]) + model.compile() + + config = hls4ml.utils.config_from_keras_model( + model, granularity='name', default_precision='fixed<32,12>', backend=backend + ) + output_dir = str(test_root_path / test_case_id) + hls_model = hls4ml.converters.convert_from_keras_model( + model, hls_config=config, output_dir=output_dir, backend=backend, io_type=io_type + ) + assert hls_model.graph['gc'].class_name == 'DepthwiseConv2D' + hls_model.compile() + + y_keras = model.predict(X, verbose=0) + y_hls = hls_model.predict(X).reshape(y_keras.shape) + np.testing.assert_allclose(y_hls, y_keras, rtol=1e-2, atol=0.01) + + +@pytest.mark.parametrize( + 'n_chan, filters, groups', + [(4, 4, 2), (8, 8, 4), (4, 8, 4)], + ids=['grouped_2of4', 'grouped_4of8', 'depth_multiplier_2'], +) +def test_unsupported_grouped_conv_raises(test_case_id, n_chan, filters, groups): + """General grouped convs and depth_multiplier > 1 have no correct hls4ml kernel and + must raise at conversion rather than be emitted as a (wrong) dense or depthwise conv.""" + model = keras.Sequential([Input((16, n_chan)), Conv1D(filters, 3, padding='same', groups=groups, name='gc')]) + model.compile() + output_dir = str(test_root_path / test_case_id) + # The keras_v3 ConvHandler runs during model parsing (config_from_keras_model already + # invokes it), so the rejection surfaces as soon as the model is parsed. + with pytest.raises(NotImplementedError): + config = hls4ml.utils.config_from_keras_model(model, granularity='name') + hls4ml.converters.convert_from_keras_model( + model, hls_config=config, output_dir=output_dir, backend='Vitis', io_type='io_parallel' + )