From 2ac3b0daedec35819b9148461d1b9ab84522bb9c Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Mon, 28 Jul 2025 11:26:52 -0400 Subject: [PATCH 01/17] handle multiple keras input and output layers, better output inference handling --- src/tensorflow_module.py | 28 +++++++++------------------- 1 file changed, 9 insertions(+), 19 deletions(-) diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index 8b4716e..3e8e657 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -52,7 +52,6 @@ def validate_config( LOGGER.info( "Detected Keras model file at " + model_path - + ". Please note Keras support is limited." ) return ([], []) @@ -96,25 +95,16 @@ def reconfigure( self.model = tf.keras.models.load_model(self.model_path) self.is_keras = True - # For now, we use first and last layer to get input and output info - in_config = self.model.layers[0].get_config() - out_config = self.model.layers[-1].get_config() - - # Keras model's output config's dtype is (sometimes?) a whole dict - outType = out_config.get("dtype") - if not isinstance(outType, str): - outType = None - - self.input_info.append( - ( - in_config.get("name"), - in_config.get("batch_shape"), - in_config.get("dtype"), + for input in self.model.inputs: + input_config = input.get_config() + self.input_info.append( + (input_config.get("name"), input_config.get("batch_shape"), input_config.get("dtype")) + ) + for output in self.model.outputs: + output_config = output.get_config() + self.output_info.append( + (output_config.get("name"), output_config.get("batch_shape"), output_config.get("dtype")) ) - ) - self.output_info.append( - (out_config.get("name"), out_config.get("batch_shape"), outType) - ) return From 96eab46eb8a9a94dcd704700587b8973c1a260e4 Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 14:59:07 -0400 Subject: [PATCH 02/17] rebase --- src/tensorflow_module.py | 45 ++++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 6 deletions(-) diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index 3e8e657..531379b 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -95,15 +95,48 @@ def reconfigure( self.model = tf.keras.models.load_model(self.model_path) self.is_keras = True - for input in self.model.inputs: - input_config = input.get_config() + # So instead of handling just a single-input and single-output layer (as is when the Model is created using the + # Sequential API), we need to support the Functional API too which may have multi-input and output layers + for inputs in self.model.inputs: self.input_info.append( - (input_config.get("name"), input_config.get("batch_shape"), input_config.get("dtype")) + ( + inputs.name, + inputs.shape, + inputs.dtype + ) ) - for output in self.model.outputs: - output_config = output.get_config() + for outputs in self.model.outputs: self.output_info.append( - (output_config.get("name"), output_config.get("batch_shape"), output_config.get("dtype")) + ( + outputs.name, + outputs.shape, + outputs.dtype + ) + ) + + # If input_info and output_info are empty, default to the first and last layer of the model + if len(self.input_info) == 0 and len(self.output_info) == 0: + in_config = self.model.layers[0].get_config() + out_config = self.model.layers[-1].get_config() + + # Keras model's output config's dtype is (sometimes?) a whole dict + outType = out_config.get("dtype") + if not isinstance(outType, str): + outType = None + + self.input_info.append( + ( + in_config.get("name"), + in_config.get("batch_shape"), + in_config.get("dtype"), + ) + ) + self.output_info.append( + ( + out_config.get("name"), + out_config.get("batch_shape"), + outType, + ) ) return From 0f0954d1c65cfe7d81e1bc776b5c4fc682e1948d Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 14:59:38 -0400 Subject: [PATCH 03/17] rebase --- tests/test_tensorflow.py | 38 +++++++++++++++++++++++++++++++++++--- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/tests/test_tensorflow.py b/tests/test_tensorflow.py index 9d66ab1..8f831e6 100644 --- a/tests/test_tensorflow.py +++ b/tests/test_tensorflow.py @@ -14,11 +14,17 @@ def make_component_config(dictionary: Mapping[str, Any]) -> ComponentConfig: struct.update(dictionary=dictionary) return ComponentConfig(attributes=struct) - +def make_sequential_keras_model(): + model = tf.keras.Sequential([ + tf.keras.layers.Input(shape=(5,)), + tf.keras.layers.Softmax()]) + model.save("./tests/testmodel.keras") class TestTensorflowCPU: empty_config = make_component_config({}) + make_sequential_keras_model() + badconfig =make_component_config({ "model_path": "testModel" }) @@ -59,7 +65,7 @@ async def test_saved_model_infer(self): tfmodel = self.getTFCPU() tfmodel.reconfigure(config=self.saved_model_config, dependencies=None) fakeInput = {"input": np.ones([1,10,10,3])} # make a fake input thingy - out = await tfmodel.infer(input_tensors=fakeInput) + out = await tfmodel.infer(input_tensors=fakeInput) assert isinstance(out, Dict) for output in out: assert isinstance(out[output], np.ndarray) @@ -95,4 +101,30 @@ async def test_keras_metadata(self): assert isinstance(md, Metadata) assert hasattr(md, "name") assert hasattr(md, "input_info") - assert hasattr(md, "output_info") \ No newline at end of file + assert hasattr(md, "output_info") + + # KERAS TESTS + def getTFCPUKeras(self): + tfmodel = TensorflowModule("test") + tfmodel.model = tf.keras.models.load_model("./tests/testmodel.keras") + return tfmodel + + @pytest.mark.asyncio + async def test_infer_keras(self): + tf_keras_model = TensorflowModule("test") + tf_keras_model.reconfigure(config=self.config_keras, dependencies=None) + fakeInput = {"input_1": np.ones([1, 5])} + out = await tf_keras_model.infer(input_tensors=fakeInput) + assert isinstance(out, Dict) + for output in out: + assert isinstance(out[output], np.ndarray) + + @pytest.mark.asyncio + async def test_metadata_keras(self): + tf_keras_model = self.getTFCPUKeras() + tf_keras_model.reconfigure(config=self.config_keras, dependencies=None) + md = await tf_keras_model.metadata() + assert isinstance(md, Metadata) + assert hasattr(md, "name") + assert hasattr(md, "input_info") + assert hasattr(md, "output_info") From 49150d2125ae49dad439f5e5ba0204284a532200 Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Wed, 6 Aug 2025 14:08:30 -0400 Subject: [PATCH 04/17] gitignore final --- .gitignore | 206 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/.gitignore b/.gitignore index 7a4b798..8e54388 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,209 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ + +# Streamlit +.streamlit/secrets.toml +# Keras executable +*.keras + .setup .venv build/ From 496d5d2061dfa1d8a70437b4fa97a17512d5f506 Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 15:00:15 -0400 Subject: [PATCH 05/17] rebase --- .gitignore | 1 + build.sh | 36 ++++++++++++++++++++++++++++++++++++ src/tensorflow_module.py | 18 ++---------------- 3 files changed, 39 insertions(+), 16 deletions(-) create mode 100755 build.sh diff --git a/.gitignore b/.gitignore index 8e54388..c496ce3 100644 --- a/.gitignore +++ b/.gitignore @@ -201,6 +201,7 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + # Keras executable *.keras diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..30abf11 --- /dev/null +++ b/build.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# setup.sh -- environment bootstrapper for python virtualenv + +set -euo pipefail + +SUDO=sudo +if ! command -v $SUDO; then + echo no sudo on this system, proceeding as current user + SUDO="" +fi + + +if command -v apt-get; then + $SUDO apt-get -y install python3-venv + if dpkg -l python3-venv; then + echo "python3-venv is installed, skipping setup" + else + if ! apt info python3-venv; then + echo python3-venv package info not found, trying apt update + $SUDO apt-get -qq update + fi + $SUDO apt-get install -qqy python3-venv + fi +else + echo Skipping tool installation because your platform is missing apt-get. + echo If you see failures below, install the equivalent of python3-venv for your system. +fi + +source .env +echo creating virtualenv at $VIRTUAL_ENV +python3 -m venv $VIRTUAL_ENV +echo installing dependencies from requirements.txt +$VIRTUAL_ENV/bin/pip install --prefer-binary -r requirements.txt -U +source $VIRTUAL_ENV/bin/activate +$PYTHON -m PyInstaller --onefile --hidden-import="googleapiclient" --add-data="./src:src" src/main.py +tar -czvf dist/archive.tar.gz ./dist/main meta.json diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index 531379b..6d1b4f2 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -97,22 +97,8 @@ def reconfigure( # So instead of handling just a single-input and single-output layer (as is when the Model is created using the # Sequential API), we need to support the Functional API too which may have multi-input and output layers - for inputs in self.model.inputs: - self.input_info.append( - ( - inputs.name, - inputs.shape, - inputs.dtype - ) - ) - for outputs in self.model.outputs: - self.output_info.append( - ( - outputs.name, - outputs.shape, - outputs.dtype - ) - ) + self.input_info = [(i.name, i.shape, i.dtype) for i in self.model.inputs] + self.output_info = [(o.name, o.shape, o.dtype) for o in self.model.outputs] # If input_info and output_info are empty, default to the first and last layer of the model if len(self.input_info) == 0 and len(self.output_info) == 0: From c32628b11a5847968a8714598b45c6763a2c2e6b Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Tue, 5 Aug 2025 10:59:31 -0400 Subject: [PATCH 06/17] update main.py module loading --- src/main.py | 43 +++++++++++++++++++++++-------------------- 1 file changed, 23 insertions(+), 20 deletions(-) diff --git a/src/main.py b/src/main.py index 20ef71e..887efac 100755 --- a/src/main.py +++ b/src/main.py @@ -1,30 +1,33 @@ import asyncio from viam.module.module import Module -from viam.resource.registry import Registry, ResourceCreatorRegistration -from viam.services.mlmodel import MLModel -from src.tensorflow_module import TensorflowModule +from viam.logging import getLogger +# from viam.resource.registry import Registry, ResourceCreatorRegistration +# from viam.services.mlmodel import MLModel +import src.tensorflow_module as tensorflow_module -async def main(): - """ - This function creates and starts a new module, after adding all desired - resource models. Resource creators must be registered to the resource - registry before the module adds the resource model. - """ +# async def main(): +# """ +# This function creates and starts a new module, after adding all desired +# resource models. Resource creators must be registered to the resource +# registry before the module adds the resource model. +# """ - Registry.register_resource_creator( - MLModel.API, - TensorflowModule.MODEL, - ResourceCreatorRegistration( - TensorflowModule.new_service, TensorflowModule.validate_config - ), - ) - module = Module.from_args() +# Registry.register_resource_creator( +# MLModel.API, +# TensorflowModule.MODEL, +# ResourceCreatorRegistration( +# TensorflowModule.new_service, TensorflowModule.validate_config +# ), +# ) +# module = Module.from_args() - module.add_model_from_registry(MLModel.API, TensorflowModule.MODEL) - await module.start() +# module.add_model_from_registry(MLModel.API, TensorflowModule.MODEL) +# await module.start() if __name__ == "__main__": - asyncio.run(main()) + LOGGER = getLogger("ADWAIT") + LOGGER.info("Starting TensorFlow Module") + asyncio.run(Module.run_from_registry()) From 0a8ee149f1e724e8fdb5036272ff045f0755ed4a Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 15:00:44 -0400 Subject: [PATCH 07/17] rebase --- src/tensorflow_module.py | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index 6d1b4f2..3b52457 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -14,6 +14,7 @@ import numpy as np import google.protobuf.struct_pb2 as pb import tensorflow as tf +import keras LOGGER = getLogger(__name__) @@ -37,6 +38,7 @@ def new_service( def validate_config( cls, config: ServiceConfig ) -> Tuple[Sequence[str], Sequence[str]]: + LOGGER.info("Validating config") model_path_err = ( "model_path must be the location of the Tensorflow SavedModel directory " "or the location of a Keras model file (.keras)" @@ -78,11 +80,13 @@ def validate_config( if not isValidSavedModel: raise Exception(model_path_err) + LOGGER.info("Config validated") return ([], []) def reconfigure( self, config: ServiceConfig, dependencies: Mapping[ResourceName, ResourceBase] ): + LOGGER.info("Reconfiguring") self.model_path = config.attributes.fields["model_path"].string_value self.label_path = config.attributes.fields["label_path"].string_value self.is_keras = False @@ -92,24 +96,16 @@ def reconfigure( _, ext = os.path.splitext(self.model_path) if ext.lower() == ".keras": # If it's a Keras model, load it using the Keras API - self.model = tf.keras.models.load_model(self.model_path) + self.model = keras.models.load_model(self.model_path) self.is_keras = True # So instead of handling just a single-input and single-output layer (as is when the Model is created using the # Sequential API), we need to support the Functional API too which may have multi-input and output layers - self.input_info = [(i.name, i.shape, i.dtype) for i in self.model.inputs] - self.output_info = [(o.name, o.shape, o.dtype) for o in self.model.outputs] - # If input_info and output_info are empty, default to the first and last layer of the model - if len(self.input_info) == 0 and len(self.output_info) == 0: + if self.model.inputs: + self.input_info = [(i.name, i.shape, i.dtype) for i in self.model.inputs] + else: in_config = self.model.layers[0].get_config() - out_config = self.model.layers[-1].get_config() - - # Keras model's output config's dtype is (sometimes?) a whole dict - outType = out_config.get("dtype") - if not isinstance(outType, str): - outType = None - self.input_info.append( ( in_config.get("name"), @@ -117,6 +113,16 @@ def reconfigure( in_config.get("dtype"), ) ) + + if self.model.outputs: + self.output_info = [(o.name, o.shape, o.dtype) for o in self.model.outputs] + else: + out_config = self.model.layers[-1].get_config() + # Keras model's output config's dtype is (sometimes?) a whole dict + outType = out_config.get("dtype") + if not isinstance(outType, str): + outType = None + self.output_info.append( ( out_config.get("name"), @@ -124,7 +130,7 @@ def reconfigure( outType, ) ) - + LOGGER.info("Reconfigured") return # This is where we do the actual loading of the SavedModel @@ -236,7 +242,7 @@ async def metadata( Returns: Metadata: The metadata """ - + LOGGER.info("Getting metadata") extra = pb.Struct() extra["labels"] = self.label_path @@ -260,6 +266,7 @@ async def metadata( ) output_info.append(info) + LOGGER.info("Metadata complete") return Metadata( name="tensorflow_model", input_info=input_info, output_info=output_info ) From 9a5fa8be933b2654178194ede636ce5b01187bea Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 15:01:12 -0400 Subject: [PATCH 08/17] rebase --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c496ce3..211a8e9 100644 --- a/.gitignore +++ b/.gitignore @@ -208,4 +208,4 @@ __marimo__/ .setup .venv build/ -dist/ \ No newline at end of file +dist/ From 56e39cffea305680c108527156008f60f7cc8260 Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Wed, 6 Aug 2025 13:54:58 -0400 Subject: [PATCH 09/17] main returned to initial state after sdk update --- src/main.py | 43 ++++++++++++++++++++----------------------- 1 file changed, 20 insertions(+), 23 deletions(-) diff --git a/src/main.py b/src/main.py index 887efac..20ef71e 100755 --- a/src/main.py +++ b/src/main.py @@ -1,33 +1,30 @@ import asyncio from viam.module.module import Module -from viam.logging import getLogger -# from viam.resource.registry import Registry, ResourceCreatorRegistration -# from viam.services.mlmodel import MLModel -import src.tensorflow_module as tensorflow_module +from viam.resource.registry import Registry, ResourceCreatorRegistration +from viam.services.mlmodel import MLModel +from src.tensorflow_module import TensorflowModule -# async def main(): -# """ -# This function creates and starts a new module, after adding all desired -# resource models. Resource creators must be registered to the resource -# registry before the module adds the resource model. -# """ +async def main(): + """ + This function creates and starts a new module, after adding all desired + resource models. Resource creators must be registered to the resource + registry before the module adds the resource model. + """ -# Registry.register_resource_creator( -# MLModel.API, -# TensorflowModule.MODEL, -# ResourceCreatorRegistration( -# TensorflowModule.new_service, TensorflowModule.validate_config -# ), -# ) -# module = Module.from_args() + Registry.register_resource_creator( + MLModel.API, + TensorflowModule.MODEL, + ResourceCreatorRegistration( + TensorflowModule.new_service, TensorflowModule.validate_config + ), + ) + module = Module.from_args() -# module.add_model_from_registry(MLModel.API, TensorflowModule.MODEL) -# await module.start() + module.add_model_from_registry(MLModel.API, TensorflowModule.MODEL) + await module.start() if __name__ == "__main__": - LOGGER = getLogger("ADWAIT") - LOGGER.info("Starting TensorFlow Module") - asyncio.run(Module.run_from_registry()) + asyncio.run(main()) From ba89c345ccdd8ae3fc4bf52bf7581d71b432124b Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 15:02:01 -0400 Subject: [PATCH 10/17] rebase --- src/tensorflow_module.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index 3b52457..9107b6d 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -38,7 +38,6 @@ def new_service( def validate_config( cls, config: ServiceConfig ) -> Tuple[Sequence[str], Sequence[str]]: - LOGGER.info("Validating config") model_path_err = ( "model_path must be the location of the Tensorflow SavedModel directory " "or the location of a Keras model file (.keras)" @@ -80,13 +79,11 @@ def validate_config( if not isValidSavedModel: raise Exception(model_path_err) - LOGGER.info("Config validated") return ([], []) def reconfigure( self, config: ServiceConfig, dependencies: Mapping[ResourceName, ResourceBase] ): - LOGGER.info("Reconfiguring") self.model_path = config.attributes.fields["model_path"].string_value self.label_path = config.attributes.fields["label_path"].string_value self.is_keras = False @@ -102,9 +99,13 @@ def reconfigure( # So instead of handling just a single-input and single-output layer (as is when the Model is created using the # Sequential API), we need to support the Functional API too which may have multi-input and output layers # If input_info and output_info are empty, default to the first and last layer of the model - if self.model.inputs: - self.input_info = [(i.name, i.shape, i.dtype) for i in self.model.inputs] - else: + try: + inputs = self.model.inputs + if inputs: + self.input_info = [(i.name, i.shape, i.dtype) for i in inputs] + else: + raise AttributeError("No inputs") + except (AttributeError, ValueError): in_config = self.model.layers[0].get_config() self.input_info.append( ( @@ -114,15 +115,18 @@ def reconfigure( ) ) - if self.model.outputs: - self.output_info = [(o.name, o.shape, o.dtype) for o in self.model.outputs] - else: + try: + outputs = self.model.outputs + if outputs: + self.output_info = [(o.name, o.shape, o.dtype) for o in outputs] + else: + raise AttributeError("No outputs") + except (AttributeError, ValueError): out_config = self.model.layers[-1].get_config() # Keras model's output config's dtype is (sometimes?) a whole dict outType = out_config.get("dtype") if not isinstance(outType, str): outType = None - self.output_info.append( ( out_config.get("name"), @@ -130,7 +134,6 @@ def reconfigure( outType, ) ) - LOGGER.info("Reconfigured") return # This is where we do the actual loading of the SavedModel @@ -242,7 +245,6 @@ async def metadata( Returns: Metadata: The metadata """ - LOGGER.info("Getting metadata") extra = pb.Struct() extra["labels"] = self.label_path @@ -266,7 +268,6 @@ async def metadata( ) output_info.append(info) - LOGGER.info("Metadata complete") return Metadata( name="tensorflow_model", input_info=input_info, output_info=output_info ) From befc7b12b0b6bff6d0ea2578876e8299ef28ae6a Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 15:02:50 -0400 Subject: [PATCH 11/17] rebase --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 157fcb0..6004c28 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,4 +8,4 @@ pyinstaller pylint pytest pytest-asyncio -tensorflow_probability == 0.24.0 \ No newline at end of file +tensorflow_probability == 0.24.0 From 1e580213af1b3d6afa3fee4bfc4dfbef955d8535 Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Wed, 6 Aug 2025 13:57:07 -0400 Subject: [PATCH 12/17] tensorflow tests --- tests/test_tensorflow.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/tests/test_tensorflow.py b/tests/test_tensorflow.py index 8f831e6..39ee95f 100644 --- a/tests/test_tensorflow.py +++ b/tests/test_tensorflow.py @@ -7,7 +7,7 @@ import tensorflow as tf import numpy as np from numpy.typing import NDArray - +import keras def make_component_config(dictionary: Mapping[str, Any]) -> ComponentConfig: struct = Struct() @@ -15,9 +15,7 @@ def make_component_config(dictionary: Mapping[str, Any]) -> ComponentConfig: return ComponentConfig(attributes=struct) def make_sequential_keras_model(): - model = tf.keras.Sequential([ - tf.keras.layers.Input(shape=(5,)), - tf.keras.layers.Softmax()]) + model = keras.Sequential([keras.layers.Dense(10), keras.layers.Dense(5)]) model.save("./tests/testmodel.keras") class TestTensorflowCPU: From a2b2e6cd0bcde9d305dfdba3ee819ed720c0dfb0 Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Wed, 6 Aug 2025 15:43:38 -0400 Subject: [PATCH 13/17] get rid of build.sh --- build.sh | 36 ------------------------------------ 1 file changed, 36 deletions(-) delete mode 100755 build.sh diff --git a/build.sh b/build.sh deleted file mode 100755 index 30abf11..0000000 --- a/build.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# setup.sh -- environment bootstrapper for python virtualenv - -set -euo pipefail - -SUDO=sudo -if ! command -v $SUDO; then - echo no sudo on this system, proceeding as current user - SUDO="" -fi - - -if command -v apt-get; then - $SUDO apt-get -y install python3-venv - if dpkg -l python3-venv; then - echo "python3-venv is installed, skipping setup" - else - if ! apt info python3-venv; then - echo python3-venv package info not found, trying apt update - $SUDO apt-get -qq update - fi - $SUDO apt-get install -qqy python3-venv - fi -else - echo Skipping tool installation because your platform is missing apt-get. - echo If you see failures below, install the equivalent of python3-venv for your system. -fi - -source .env -echo creating virtualenv at $VIRTUAL_ENV -python3 -m venv $VIRTUAL_ENV -echo installing dependencies from requirements.txt -$VIRTUAL_ENV/bin/pip install --prefer-binary -r requirements.txt -U -source $VIRTUAL_ENV/bin/activate -$PYTHON -m PyInstaller --onefile --hidden-import="googleapiclient" --add-data="./src:src" src/main.py -tar -czvf dist/archive.tar.gz ./dist/main meta.json From 8dcd69ad2ffe7e21a15a770933f51ff564337dbd Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Wed, 6 Aug 2025 15:44:06 -0400 Subject: [PATCH 14/17] comment clean up, make sure to build test model --- src/tensorflow_module.py | 2 +- tests/test_tensorflow.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index 9107b6d..d8df6ed 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -87,7 +87,7 @@ def reconfigure( self.model_path = config.attributes.fields["model_path"].string_value self.label_path = config.attributes.fields["label_path"].string_value self.is_keras = False - self.input_info = [] # input and output info are lists of tuples (name, shape, underlying type) + self.input_info = [] self.output_info = [] _, ext = os.path.splitext(self.model_path) diff --git a/tests/test_tensorflow.py b/tests/test_tensorflow.py index 39ee95f..7235665 100644 --- a/tests/test_tensorflow.py +++ b/tests/test_tensorflow.py @@ -16,6 +16,7 @@ def make_component_config(dictionary: Mapping[str, Any]) -> ComponentConfig: def make_sequential_keras_model(): model = keras.Sequential([keras.layers.Dense(10), keras.layers.Dense(5)]) + model.build(input_shape=(None, 10)) model.save("./tests/testmodel.keras") class TestTensorflowCPU: @@ -111,7 +112,7 @@ def getTFCPUKeras(self): async def test_infer_keras(self): tf_keras_model = TensorflowModule("test") tf_keras_model.reconfigure(config=self.config_keras, dependencies=None) - fakeInput = {"input_1": np.ones([1, 5])} + fakeInput = {"input_1": np.ones([1, 10])} out = await tf_keras_model.infer(input_tensors=fakeInput) assert isinstance(out, Dict) for output in out: From a98043e84724fd378742b1fe5f7115a77c1e30fe Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 15:03:17 -0400 Subject: [PATCH 15/17] rebase cont --- .gitignore | 3 +++ src/tensorflow_module.py | 7 ++++--- tests/test_tensorflow.py | 4 ++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.gitignore b/.gitignore index 211a8e9..172ea14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +# Largely copied from this link with a few additions at the bottom: https://github.com/github/gitignore/blob/main/Python.gitignore # Byte-compiled / optimized / DLL files __pycache__/ *.py[codz] @@ -202,6 +203,8 @@ __marimo__/ # Streamlit .streamlit/secrets.toml +# START OF ADDITIONS + # Keras executable *.keras diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index d8df6ed..b335583 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -87,7 +87,7 @@ def reconfigure( self.model_path = config.attributes.fields["model_path"].string_value self.label_path = config.attributes.fields["label_path"].string_value self.is_keras = False - self.input_info = [] + self.input_info = [] # input and output info are lists of tuples (name, shape, underlying type) self.output_info = [] _, ext = os.path.splitext(self.model_path) @@ -105,7 +105,7 @@ def reconfigure( self.input_info = [(i.name, i.shape, i.dtype) for i in inputs] else: raise AttributeError("No inputs") - except (AttributeError, ValueError): + except AttributeError: in_config = self.model.layers[0].get_config() self.input_info.append( ( @@ -121,11 +121,12 @@ def reconfigure( self.output_info = [(o.name, o.shape, o.dtype) for o in outputs] else: raise AttributeError("No outputs") - except (AttributeError, ValueError): + except AttributeError: out_config = self.model.layers[-1].get_config() # Keras model's output config's dtype is (sometimes?) a whole dict outType = out_config.get("dtype") if not isinstance(outType, str): + LOGGER.info("Output dtype is not a string, using 'None' instead") outType = None self.output_info.append( ( diff --git a/tests/test_tensorflow.py b/tests/test_tensorflow.py index 7235665..7ac8ef6 100644 --- a/tests/test_tensorflow.py +++ b/tests/test_tensorflow.py @@ -115,8 +115,8 @@ async def test_infer_keras(self): fakeInput = {"input_1": np.ones([1, 10])} out = await tf_keras_model.infer(input_tensors=fakeInput) assert isinstance(out, Dict) - for output in out: - assert isinstance(out[output], np.ndarray) + for output in out.values(): + assert isinstance(output, np.ndarray) @pytest.mark.asyncio async def test_metadata_keras(self): From e0a2b13ab3122848f07c3a7653fae1c83786667c Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 14:55:51 -0400 Subject: [PATCH 16/17] error message change: --- src/tensorflow_module.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tensorflow_module.py b/src/tensorflow_module.py index b335583..2a78fac 100755 --- a/src/tensorflow_module.py +++ b/src/tensorflow_module.py @@ -104,7 +104,7 @@ def reconfigure( if inputs: self.input_info = [(i.name, i.shape, i.dtype) for i in inputs] else: - raise AttributeError("No inputs") + raise AttributeError("'inputs' attributed not defined on the model, defaulting to the first layer instead") except AttributeError: in_config = self.model.layers[0].get_config() self.input_info.append( @@ -120,7 +120,7 @@ def reconfigure( if outputs: self.output_info = [(o.name, o.shape, o.dtype) for o in outputs] else: - raise AttributeError("No outputs") + raise AttributeError("'outputs' attributed not defined on the model, defaulting to the last layer instead") except AttributeError: out_config = self.model.layers[-1].get_config() # Keras model's output config's dtype is (sometimes?) a whole dict From fbf770cb6b24ea038961e69ed82383bfc0f6f64f Mon Sep 17 00:00:00 2001 From: Adwait Ganguly Date: Fri, 22 Aug 2025 16:02:57 -0400 Subject: [PATCH 17/17] fixing tests --- tests/test_tensorflow.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_tensorflow.py b/tests/test_tensorflow.py index 7ac8ef6..c3b2320 100644 --- a/tests/test_tensorflow.py +++ b/tests/test_tensorflow.py @@ -110,9 +110,9 @@ def getTFCPUKeras(self): @pytest.mark.asyncio async def test_infer_keras(self): - tf_keras_model = TensorflowModule("test") - tf_keras_model.reconfigure(config=self.config_keras, dependencies=None) - fakeInput = {"input_1": np.ones([1, 10])} + tf_keras_model = self.getTFCPUKeras() + tf_keras_model.reconfigure(config=self.keras_config, dependencies=None) + fakeInput = {"input_1": np.ones([1, 4])} out = await tf_keras_model.infer(input_tensors=fakeInput) assert isinstance(out, Dict) for output in out.values(): @@ -121,7 +121,7 @@ async def test_infer_keras(self): @pytest.mark.asyncio async def test_metadata_keras(self): tf_keras_model = self.getTFCPUKeras() - tf_keras_model.reconfigure(config=self.config_keras, dependencies=None) + tf_keras_model.reconfigure(config=self.keras_config, dependencies=None) md = await tf_keras_model.metadata() assert isinstance(md, Metadata) assert hasattr(md, "name")