From ad5ebeb6507fa7c90d0b256a88b1df5fb4ac5e87 Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Mon, 28 Apr 2025 10:33:51 +0200 Subject: [PATCH 1/8] fix: include model card README.md --- README.md | 79 ++++++++++++++++++++++++------------------------------- 1 file changed, 34 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index 0049f52..1da493c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ [![GitHub issues](https://img.shields.io/github/issues/Kohulan/DECIMER-Image_Transformer.svg?style=for-the-badge)](https://GitHub.com/Kohulan/DECIMER-Image_Transformer/issues/) [![GitHub contributors](https://img.shields.io/github/contributors/Kohulan/DECIMER-Image_Transformer.svg?style=for-the-badge)](https://GitHub.com/Kohulan/DECIMER-Image_Transformer/graphs/contributors/) [![tensorflow](https://img.shields.io/badge/TensorFlow-2.10.1-FF6F00.svg?style=for-the-badge&logo=tensorflow)](https://www.tensorflow.org) +[![Model Card](https://img.shields.io/badge/Model_Card-DECIMER-9cf.svg?style=for-the-badge)](https://zenodo.org/records/8300489) [![DOI](https://zenodo.org/badge/293572361.svg)](https://zenodo.org/badge/latestdoi/293572361) [![Documentation Status](https://readthedocs.org/projects/decimer-image-transformer/badge/?version=latest&style=for-the-badge)](https://decimer-image-transformer.readthedocs.io/en/latest/?badge=latest) [![GitHub release](https://img.shields.io/github/release/Kohulan/DECIMER-Image_Transformer.svg?style=for-the-badge)](https://GitHub.com/Kohulan/DECIMER-Image_Transformer/releases/) @@ -24,40 +25,40 @@ ## 📚 Table of Contents -- [Abstract](#-abstract) -- [Method and Model Changes](#-method-and-model-changes) -- [Installation](#-installation) -- [Usage](#-usage) -- [Hand-drawn Model](#-decimer---hand-drawn-model) -- [Citation](#-citation) -- [Acknowledgements](#-acknowledgements) -- [Author](#-author-kohulan) -- [Project Website](#-project-website) -- [Research Group](#-research-group) +- [📝 Abstract](#-abstract) +- [💡 Method and Model Changes](#-method-and-model-changes) +- [⚙️ Installation](#️-installation) +- [🚀 Usage](#-usage) +- [✍️ Hand-drawn Model](#️-decimer---hand-drawn-model) +- [📄 Citation](#-citation) +- [🙏 Acknowledgements](#-acknowledgements) +- [👨‍🔬 Author](#-author-kohulan) +- [🌐 Project Website](#-project-website) +- [🏛️ Research Group](#️-research-group) --- -## 🔬 Abstract - -
- -The DECIMER 2.2 project tackles the OCSR (Optical Chemical Structure Recognition) challenge using cutting-edge computational intelligence methods. Our goal? To provide an automated, open-source software solution for chemical image recognition. - -We've supercharged DECIMER with Google's TPU (Tensor Processing Unit) to handle datasets of over 1 million images with lightning speed! +## 📝 Abstract +
+
+> The DECIMER 2.2 project tackles the OCSR (Optical Chemical Structure Recognition) challenge using cutting-edge computational intelligence methods. Our goal? To provide an automated, open-source software solution for chemical image recognition. +> +> We've supercharged DECIMER with Google's TPU (Tensor Processing Unit) to handle datasets of over 1 million images with lightning speed! + --- -## 🧠 Method and Model Changes +## 💡 Method and Model Changes - - @@ -66,14 +67,14 @@ We've supercharged DECIMER with Google's TPU (Tensor Processing Unit) to handle ### 🚀 Training Enhancements -1. **TFRecord Files**: Lightning-fast data reading -2. **Google Cloud Buckets**: Efficient cloud storage solution -3. **TensorFlow Data Pipeline**: Optimized data loading -4. **TPU Strategy**: Harnessing the power of Google's TPUs +1. **📦 TFRecord Files** - Lightning-fast data reading +2. **☁️ Google Cloud Buckets** - Efficient cloud storage solution +3. **🔄 TensorFlow Data Pipeline** - Optimized data loading +4. **⚡ TPU Strategy** - Harnessing the power of Google's TPUs --- -## 💻 Installation +## ⚙️ Installation ```bash # Create a conda wonderland @@ -86,7 +87,7 @@ pip install decimer --- -## 🎮 Usage +## 🚀 Usage ```python from DECIMER import predict_SMILES @@ -101,21 +102,15 @@ print(f"🎉 Decoded SMILES: {SMILES}") ## ✍️ DECIMER - Hand-drawn Model -
- -🌟 **New Feature Alert!** 🌟 - -Our latest model brings the magic of AI to hand-drawn chemical structures! - -[![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.10781330.svg)](https://doi.org/10.5281/zenodo.10781330) - -
+> 🌟 **New Feature Alert!** 🌟 +> +> Our latest model brings the magic of AI to hand-drawn chemical structures! +> +> [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.10781330.svg)](https://doi.org/10.5281/zenodo.10781330) --- -## 📜 Citation - -
+## 📄 Citation If DECIMER helps your research, please cite: @@ -123,8 +118,6 @@ If DECIMER helps your research, please cite: 2. Rajan, K., et al. "DECIMER 1.0: deep learning for chemical image recognition using transformers." *J Cheminform* 13, 61 (2021). 3. Rajan, K., et al. "Advancements in hand-drawn chemical structure recognition through an enhanced DECIMER architecture," *J Cheminform* 16, 78 (2024). -
- --- ## 🙏 Acknowledgements @@ -140,10 +133,6 @@ If DECIMER helps your research, please cite: ## 👨‍🔬 Author: [Kohulan](https://kohulanr.com) -

- -

- --- ## 🌐 Project Website @@ -152,7 +141,7 @@ Experience DECIMER in action at [decimer.ai](https://decimer.ai), brilliantly im --- -## 🏫 Research Group +## 🏛️ Research Group

From d1f660d3fcd46bc0d0dd51894eaf71e06edeeda5 Mon Sep 17 00:00:00 2001 From: Srinivas Gorur-Shandilya Date: Thu, 10 Jul 2025 09:52:40 -0400 Subject: [PATCH 2/8] fix: removed an unused import --- DECIMER/Efficient_Net_encoder.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/DECIMER/Efficient_Net_encoder.py b/DECIMER/Efficient_Net_encoder.py index 4516817..79f21d6 100644 --- a/DECIMER/Efficient_Net_encoder.py +++ b/DECIMER/Efficient_Net_encoder.py @@ -1,8 +1,6 @@ # EfficientNet-V2 config import tensorflow as tf -import DECIMER.efficientnetv2 -from DECIMER.efficientnetv2 import effnetv2_configs from DECIMER.efficientnetv2 import effnetv2_model BATCH_SIZE_DEBUG = 2 From 7cde9ae1c593aad508f012a8cfd10ff4c7a2c7b1 Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Tue, 2 Dec 2025 12:55:58 +0100 Subject: [PATCH 3/8] feat: Update for Keras 3 compatibility, python 3.12 compatibility and improve model download logic --- .github/workflows/test-release.yml | 2 +- DECIMER/__init__.py | 30 ++-- DECIMER/config.py | 276 ----------------------------- DECIMER/decimer.py | 48 ++++- DECIMER/pre_process.py | 243 +++++++++++++++++++++++++ DECIMER/utils.py | 82 +++++++-- setup.py | 16 +- 7 files changed, 374 insertions(+), 323 deletions(-) create mode 100644 DECIMER/pre_process.py diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 0bbf91f..063e979 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.10'] + python-version: ['3.12', '3.11', '3.10'] steps: - uses: actions/checkout@v2 diff --git a/DECIMER/__init__.py b/DECIMER/__init__.py index 3167d30..c36961e 100644 --- a/DECIMER/__init__.py +++ b/DECIMER/__init__.py @@ -1,25 +1,27 @@ # -*- coding: utf-8 -*- -"""DECIMER V2.6.0 Python Package. ============================ - -This repository contains DECIMER-V2,Deep lEarning for Chemical ImagE Recognition) project -was launched to address the OCSR problem with the latest computational intelligence methods -to provide an automated open-source software solution. +""" +DECIMER V2.7.2 Python Package +============================= +Deep lEarning for Chemical ImagE Recognition (DECIMER) project +was launched to address the Optical Chemical Structure Recognition (OCSR) +problem using deep learning based methods, +providing an automated open-source software solution. -Typical usage example: +Typical usage example:: -from decimer import predict_SMILES + from decimer import predict_SMILES -# Chemical depiction to SMILES translation -image_path = "path/to/imagefile" -SMILES = predict_SMILES(image_path) -print(SMILES) + # Chemical depiction to SMILES translation + image_path = "path/to/imagefile" + SMILES = predict_SMILES(image_path) + print(SMILES) -For comments, bug reports or feature ideas, -please raise a issue on the Github repository. +For comments, bug reports, or feature ideas, +please raise an issue on the GitHub repository. """ -__version__ = "2.7.1" +__version__ = "2.7.2" __all__ = [ "DECIMER", diff --git a/DECIMER/config.py b/DECIMER/config.py index 086d590..4712cbc 100644 --- a/DECIMER/config.py +++ b/DECIMER/config.py @@ -1,257 +1,11 @@ -# Network configuration file -import io -import zipfile -from pathlib import Path - -import cv2 -import efficientnet.tfkeras as efn -import numpy as np -import pystow import tensorflow as tf -from PIL import Image -from PIL import ImageEnhance -from pillow_heif import register_heif_opener -from typing import Union import DECIMER.Efficient_Net_encoder as Efficient_Net_encoder import DECIMER.Transformer_decoder as Transformer_decoder - TARGET_DTYPE = tf.float32 -def resize_byratio(image): - """This function takes a Pillow Image object and will resize the image by - 512 x 512 To upscale or to downscale the image LANCZOS resampling method is - used. - - with the new pillow version the antialias is turned on when using LANCZOS. - Args: PIL.Image - Returns: PIL.Image - """ - maxwidth = 512 - ratio = maxwidth / max(image.width, image.height) - new_size = int((float(image.width) * ratio)), int((float(image.height) * ratio)) - resized_image = image.resize(new_size, resample=Image.LANCZOS) - return resized_image - - -def central_square_image(image): - """This function takes a Pillow Image object and will add white padding so - that the image has a square shape with the width/height of the longest side - of the original image. - - Args: PIL.Image - Returns: PIL.Image - """ - max_wh = int(1.2 * max(image.size)) - # If the new image is smaller than 299x299, then let's paste it into an empty image - # of that size instead of distorting it later while resizing. - if max_wh < 512: - max_wh = 512 - new_im = Image.new(image.mode, (max_wh, max_wh), "white") - paste_pos = ( - int((new_im.size[0] - image.size[0]) / 2), - int((new_im.size[1] - image.size[1]) / 2), - ) - new_im.paste(image, paste_pos) - return new_im - - -def delete_empty_borders(image): - """This function takes a Pillow Image object, converts it to grayscale and - deletes white space at the borders. - - Args: PIL.Image - Returns: PIL.Image - """ - image = np.asarray(image.convert("L")) - mask = image > 200 - rows = np.flatnonzero((~mask).sum(axis=1)) - cols = np.flatnonzero((~mask).sum(axis=0)) - crop = image[rows.min() : rows.max() + 1, cols.min() : cols.max() + 1] - return Image.fromarray(crop) - - -def PIL_im_to_BytesIO(image): - """ - Convert pillow image to io.BytesIO object - Args: PIL.Image - Returns: io.BytesIO object with the image data - """ - output = io.BytesIO() - image.save(output, format="PNG") - return output - - -def HEIF_to_pillow(image_path: str): - """ - Converts Apple's HEIF format to useful pillow object - Returns: image_path (str): path of input image - Returns: PIL.Image - """ - register_heif_opener() - - heif_file = Image.open(image_path).convert("RGBA") - return heif_file - - -def remove_transparent(image: Union[str, np.ndarray]) -> Image.Image: - """ - Removes the transparent layer from a PNG image with an alpha channel. - - Args: - image (Union[str, np.ndarray]): Path of the input image or a numpy array representing the image. - - Returns: - PIL.Image.Image: The image with transparency removed. - """ - def process_image(png: Image.Image) -> Image.Image: - """ - Helper function to remove transparency from a single image. - - Args: - png (PIL.Image.Image): The input PIL image with transparency. - - Returns: - PIL.Image.Image: The image with transparency removed. - """ - background = Image.new("RGBA", png.size, (255, 255, 255)) - alpha_composite = Image.alpha_composite(background, png) - return alpha_composite - - def handle_image_path(image_path: str) -> Image.Image: - """ - Helper function to handle image paths. - - Args: - image_path (str): The path to the input image. - - Returns: - PIL.Image.Image: The image with transparency removed. - """ - try: - png = Image.open(image_path).convert("RGBA") - except Exception as e: - if type(e).__name__ == "UnidentifiedImageError": - png = HEIF_to_pillow(image_path) - else: - print(e) - raise Exception - return process_image(png) - - def handle_numpy_array(array: np.ndarray) -> Image.Image: - """ - Helper function to handle a numpy array. - - Args: - array (np.ndarray): The numpy array representing the image. - - Returns: - PIL.Image.Image: The image with transparency removed. - """ - png = Image.fromarray(array).convert("RGBA") - return process_image(png) - - # Check if input is a numpy array - if isinstance(image, np.ndarray): - return handle_numpy_array(array=image) - - return handle_image_path(image_path=image) - - -def get_bnw_image(image): - """ - converts images to black and white - Args: PIL.Image - Returns: PIL.Image - """ - - im_np = np.asarray(image) - grayscale = cv2.cvtColor(im_np, cv2.COLOR_BGR2GRAY) - # (thresh, im_bw) = cv2.threshold(grayscale, 128, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) - im_pil = Image.fromarray(grayscale) - enhancer = ImageEnhance.Contrast(im_pil) - im_output = enhancer.enhance(1.8) - return im_output - - -def increase_contrast(image): - """This function increases the contrast of an image input. - - Args: PIL.Image - Returns: PIL.Image - """ - - # Get brightness range - min = np.min(image) - max = np.max(image) - - # Use LUT to convert image values - LUT = np.zeros(256, dtype=np.uint8) - LUT[min : max + 1] = np.linspace( - start=0, stop=255, num=(max - min) + 1, endpoint=True, dtype=np.uint8 - ) - - # Apply LUT and return image - return Image.fromarray(LUT[image]) - - -def get_resize(image): - """This function used to decide how to resize a given image without losing - much information. - - Args: PIL.Image - Returns: PIL.Image - """ - width, height = image.size - - # Checks the image size and resizes using the LANCOS image resampling - if (width == height) and (width < 512): - image = image.resize((512, 512), resample=Image.LANCZOS) - elif width >= 512 or height >= 512: - return image - else: - image = resize_byratio(image) - - return image - - -def increase_brightness(image): - """This function adjusts the brightness of the given image. - - Args: PIL.Image - Returns: PIL.Image - """ - enhancer = ImageEnhance.Brightness(image) - image = enhancer.enhance(1.6) - return image - - -def decode_image(image_path: Union[str, np.ndarray]): - """Loads an image and preprocesses the input image in several steps to get - the image ready for DECIMER input. - - Args: - image_path (Union[str, np.ndarray]): path of input image or numpy array representing the image. - - Returns: - Processed image - """ - img = remove_transparent(image_path) - img = increase_contrast(img) - img = get_bnw_image(img) - img = get_resize(img) - img = delete_empty_borders(img) - img = central_square_image(img) - img = increase_brightness(img) - img = PIL_im_to_BytesIO(img) - img = tf.image.decode_png(img.getvalue(), channels=3) - img = tf.image.resize(img, (512, 512), method="gaussian", antialias=True) - img = efn.preprocess_input(img) - return img - - class Config: """Configuration class.""" @@ -392,33 +146,3 @@ def prepare_models(encoder_config, transformer_config, replica_batch_size, verbo print(transformer.summary()) return optimizer, encoder, transformer - - -# Downloads the model and unzips the file downloaded, if the model is not present on the working directory. -def download_trained_weights(model_url: str, model_path: str, verbose=1): - """This function downloads the trained models and tokenizers to a default - location. After downloading the zipped file the function unzips the file - automatically. If the model exists on the default location this function - will not work. - - Args: - model_url (str): trained model url for downloading. - model_path (str): model default path to download. - - Returns: - path (str): downloaded model. - """ - # Download trained models - if verbose > 0: - print("Downloading trained model to " + str(model_path)) - model_path = pystow.ensure("DECIMER-V2", url=model_url) - if verbose > 0: - print(model_path) - print("... done downloading trained model!") - - with zipfile.ZipFile(model_path.as_posix(), "r") as zip_ref: - zip_ref.extractall(model_path.parent.as_posix()) - - # Delete zipfile after downloading - if Path(model_path).exists(): - Path(model_path).unlink() diff --git a/DECIMER/decimer.py b/DECIMER/decimer.py index a0831e1..8c95e26 100644 --- a/DECIMER/decimer.py +++ b/DECIMER/decimer.py @@ -9,7 +9,7 @@ import pystow import tensorflow as tf -import DECIMER.config as config +import DECIMER.pre_process as pre_process import DECIMER.utils as utils # Silence tensorflow model loading warnings. @@ -18,6 +18,46 @@ # Silence tensorflow errors - not recommended if your model is not working properly. os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" + +# ============================================================================ +# Keras 2 to Keras 3 Compatibility Layer +# ============================================================================ +class KerasCompatibilityUnpickler(pickle.Unpickler): + """Custom unpickler to handle Keras 2.x tokenizers in Keras 3 environment. + + This handles the module path changes between Keras 2 and Keras 3: + - keras.preprocessing.text -> tensorflow.keras.preprocessing.text + """ + + def find_class(self, module, name): + # Redirect old Keras 2.x imports to TensorFlow Keras + if module.startswith("keras."): + module = module.replace("keras.", "tensorflow.keras.") + return super().find_class(module, name) + + +def load_tokenizer(tokenizer_path: str) -> object: + """Load tokenizer with Keras 2/3 compatibility. + + Args: + tokenizer_path (str): Path to the pickled tokenizer file + + Returns: + tokenizer: Loaded tokenizer object + """ + try: + # Try standard pickle load first (for Keras 3 tokenizers) + with open(tokenizer_path, "rb") as f: + return pickle.load(f) + except ModuleNotFoundError as e: + if "keras.preprocessing" in str(e): + # Use compatibility unpickler for Keras 2.x tokenizers + with open(tokenizer_path, "rb") as f: + return KerasCompatibilityUnpickler(f).load() + else: + raise + + # Set the absolute path HERE = os.path.dirname(os.path.abspath(__file__)) @@ -59,7 +99,7 @@ def get_models(model_urls: dict): tokenizer_path = os.path.join( model_paths["DECIMER"], "assets", "tokenizer_SMILES.pkl" ) - tokenizer = pickle.load(open(tokenizer_path, "rb")) + tokenizer = load_tokenizer(tokenizer_path) # Load DECIMER models DECIMER_V2 = tf.saved_model.load(model_paths["DECIMER"]) @@ -123,7 +163,7 @@ def detokenize_output_add_confidence( def predict_SMILES( - image_input: [str, np.ndarray], confidence: bool = False, hand_drawn: bool = False + image_input: [str, np.ndarray], confidence: bool = False, hand_drawn: bool = False ) -> str: """Predicts SMILES representation of a molecule depicted in the given image. @@ -135,7 +175,7 @@ def predict_SMILES( Returns: str: SMILES representation of the molecule in the input image, optionally with confidence values. """ - chemical_structure = config.decode_image(image_input) + chemical_structure = pre_process.decode_image(image_input) model = DECIMER_Hand_drawn if hand_drawn else DECIMER_V2 predicted_tokens, confidence_values = model(tf.constant(chemical_structure)) diff --git a/DECIMER/pre_process.py b/DECIMER/pre_process.py new file mode 100644 index 0000000..3196f2a --- /dev/null +++ b/DECIMER/pre_process.py @@ -0,0 +1,243 @@ +import io +import cv2 +import efficientnet.tfkeras as efn +import tensorflow as tf +import numpy as np + +from PIL import Image +from PIL import ImageEnhance +from pillow_heif import register_heif_opener +from typing import Union + + +def resize_byratio(image): + """This function takes a Pillow Image object and will resize the image by + 512 x 512 To upscale or to downscale the image LANCZOS resampling method is + used. + + with the new pillow version the antialias is turned on when using LANCZOS. + Args: PIL.Image + Returns: PIL.Image + """ + maxwidth = 512 + ratio = maxwidth / max(image.width, image.height) + new_size = int((float(image.width) * ratio)), int((float(image.height) * ratio)) + resized_image = image.resize(new_size, resample=Image.LANCZOS) + return resized_image + + +def central_square_image(image): + """This function takes a Pillow Image object and will add white padding so + that the image has a square shape with the width/height of the longest side + of the original image. + + Args: PIL.Image + Returns: PIL.Image + """ + max_wh = int(1.2 * max(image.size)) + # If the new image is smaller than 299x299, then let's paste it into an empty image + # of that size instead of distorting it later while resizing. + if max_wh < 512: + max_wh = 512 + new_im = Image.new(image.mode, (max_wh, max_wh), "white") + paste_pos = ( + int((new_im.size[0] - image.size[0]) / 2), + int((new_im.size[1] - image.size[1]) / 2), + ) + new_im.paste(image, paste_pos) + return new_im + + +def delete_empty_borders(image): + """This function takes a Pillow Image object, converts it to grayscale and + deletes white space at the borders. + + Args: PIL.Image + Returns: PIL.Image + """ + image = np.asarray(image.convert("L")) + mask = image > 200 + rows = np.flatnonzero((~mask).sum(axis=1)) + cols = np.flatnonzero((~mask).sum(axis=0)) + crop = image[rows.min() : rows.max() + 1, cols.min() : cols.max() + 1] + return Image.fromarray(crop) + + +def PIL_im_to_BytesIO(image): + """ + Convert pillow image to io.BytesIO object + Args: PIL.Image + Returns: io.BytesIO object with the image data + """ + output = io.BytesIO() + image.save(output, format="PNG") + return output + + +def HEIF_to_pillow(image_path: str): + """ + Converts Apple's HEIF format to useful pillow object + Returns: image_path (str): path of input image + Returns: PIL.Image + """ + register_heif_opener() + + heif_file = Image.open(image_path).convert("RGBA") + return heif_file + + +def remove_transparent(image: Union[str, np.ndarray]) -> Image.Image: + """ + Removes the transparent layer from a PNG image with an alpha channel. + + Args: + image (Union[str, np.ndarray]): Path of the input image or a numpy array representing the image. + + Returns: + PIL.Image.Image: The image with transparency removed. + """ + + def process_image(png: Image.Image) -> Image.Image: + """ + Helper function to remove transparency from a single image. + + Args: + png (PIL.Image.Image): The input PIL image with transparency. + + Returns: + PIL.Image.Image: The image with transparency removed. + """ + background = Image.new("RGBA", png.size, (255, 255, 255)) + alpha_composite = Image.alpha_composite(background, png) + return alpha_composite + + def handle_image_path(image_path: str) -> Image.Image: + """ + Helper function to handle image paths. + + Args: + image_path (str): The path to the input image. + + Returns: + PIL.Image.Image: The image with transparency removed. + """ + try: + png = Image.open(image_path).convert("RGBA") + except Exception as e: + if type(e).__name__ == "UnidentifiedImageError": + png = HEIF_to_pillow(image_path) + else: + print(e) + raise Exception + return process_image(png) + + def handle_numpy_array(array: np.ndarray) -> Image.Image: + """ + Helper function to handle a numpy array. + + Args: + array (np.ndarray): The numpy array representing the image. + + Returns: + PIL.Image.Image: The image with transparency removed. + """ + png = Image.fromarray(array).convert("RGBA") + return process_image(png) + + # Check if input is a numpy array + if isinstance(image, np.ndarray): + return handle_numpy_array(array=image) + + return handle_image_path(image_path=image) + + +def get_bnw_image(image): + """ + converts images to black and white + Args: PIL.Image + Returns: PIL.Image + """ + + im_np = np.asarray(image) + grayscale = cv2.cvtColor(im_np, cv2.COLOR_BGR2GRAY) + # (thresh, im_bw) = cv2.threshold(grayscale, 128, 255, cv2.THRESH_BINARY | cv2.THRESH_OTSU) + im_pil = Image.fromarray(grayscale) + enhancer = ImageEnhance.Contrast(im_pil) + im_output = enhancer.enhance(1.8) + return im_output + + +def increase_contrast(image): + """This function increases the contrast of an image input. + + Args: PIL.Image + Returns: PIL.Image + """ + + # Get brightness range + min = np.min(image) + max = np.max(image) + + # Use LUT to convert image values + LUT = np.zeros(256, dtype=np.uint8) + LUT[min : max + 1] = np.linspace( + start=0, stop=255, num=(max - min) + 1, endpoint=True, dtype=np.uint8 + ) + + # Apply LUT and return image + return Image.fromarray(LUT[image]) + + +def get_resize(image): + """This function used to decide how to resize a given image without losing + much information. + + Args: PIL.Image + Returns: PIL.Image + """ + width, height = image.size + + # Checks the image size and resizes using the LANCOS image resampling + if (width == height) and (width < 512): + image = image.resize((512, 512), resample=Image.LANCZOS) + elif width >= 512 or height >= 512: + return image + else: + image = resize_byratio(image) + + return image + + +def increase_brightness(image): + """This function adjusts the brightness of the given image. + + Args: PIL.Image + Returns: PIL.Image + """ + enhancer = ImageEnhance.Brightness(image) + image = enhancer.enhance(1.6) + return image + + +def decode_image(image_path: Union[str, np.ndarray]): + """Loads an image and preprocesses the input image in several steps to get + the image ready for DECIMER input. + + Args: + image_path (Union[str, np.ndarray]): path of input image or numpy array representing the image. + + Returns: + Processed image + """ + img = remove_transparent(image_path) + img = increase_contrast(img) + img = get_bnw_image(img) + img = get_resize(img) + img = delete_empty_borders(img) + img = central_square_image(img) + img = increase_brightness(img) + img = PIL_im_to_BytesIO(img) + img = tf.image.decode_png(img.getvalue(), channels=3) + img = tf.image.resize(img, (512, 512), method="gaussian", antialias=True) + img = efn.preprocess_input(img) + return img diff --git a/DECIMER/utils.py b/DECIMER/utils.py index 003eb4b..f02a66d 100644 --- a/DECIMER/utils.py +++ b/DECIMER/utils.py @@ -2,10 +2,12 @@ import re import shutil -import DECIMER.config as config +import pystow +import zipfile +from pathlib import Path pattern = "R([0-9]*)|X([0-9]*)|Y([0-9]*)|Z([0-9]*)" -add_space_re = "^(\W+)|(\W+)$" +add_space_re = r"^(\W+)|(\W+)$" def split_and_modify_atoms(SMILES): @@ -63,10 +65,41 @@ def decoder(predictions): return modified +# Downloads the model and unzips the file downloaded, if the model is not present on the working directory. +def download_trained_weights(model_url: str, model_path: str, verbose=1): + """This function downloads the trained models and tokenizers to a default + location. After downloading the zipped file the function unzips the file + automatically. If the model exists on the default location this function + will not work. + + Args: + model_url (str): trained model url for downloading. + model_path (str): model default path to download. + + Returns: + path (str): downloaded model. + """ + # Download trained models + if verbose > 0: + print("Downloading trained model to " + str(model_path)) + model_path = pystow.ensure("DECIMER-V2", url=model_url) + if verbose > 0: + print(model_path) + print("... done downloading trained model!") + + with zipfile.ZipFile(model_path.as_posix(), "r") as zip_ref: + zip_ref.extractall(model_path.parent.as_posix()) + + # Delete zipfile after downloading + if Path(model_path).exists(): + Path(model_path).unlink() + + def ensure_models(default_path: str, model_urls: dict) -> dict: """Function to ensure models are present locally. - Convenient function to ensure model downloads before usage + Convenient function to ensure model downloads before usage. + Models are re-downloaded if the URL changes (e.g., new Zenodo record). Args: default_path (str): Default path for model data @@ -76,22 +109,35 @@ def ensure_models(default_path: str, model_urls: dict) -> dict: dict: A dictionary containing model names as keys and their local paths as values """ model_paths = {} - # Store st_size of each model - model_sizes = { - "DECIMER": 28080309, - "DECIMER_HandDrawn": 28080328, - } + for model_name, model_url in model_urls.items(): model_path = os.path.join(default_path, f"{model_name}_model") - if os.path.exists(model_path) and os.stat( - os.path.join(model_path, "saved_model.pb") - ).st_size != model_sizes.get(model_name): - print(f"Working with model {model_name}") - shutil.rmtree(model_path) - config.download_trained_weights(model_url, default_path) - elif not os.path.exists(model_path): - config.download_trained_weights(model_url, default_path) - - # Store the model path + saved_model_file = os.path.join(model_path, "saved_model.pb") + version_file = os.path.join(model_path, ".model_url") + + # Check if model needs to be downloaded: + # 1. saved_model.pb doesn't exist, OR + # 2. The URL has changed (model was updated) + needs_download = not os.path.exists(saved_model_file) + + if not needs_download and os.path.exists(version_file): + with open(version_file, "r") as f: + cached_url = f.read().strip() + if cached_url != model_url: + needs_download = True + print(f"Model {model_name} has been updated, re-downloading...") + + if needs_download: + # Clean up incomplete/corrupted model directory if it exists + if os.path.exists(model_path): + shutil.rmtree(model_path) + download_trained_weights(model_url, default_path) + + # Store the URL used for this download + os.makedirs(model_path, exist_ok=True) + with open(version_file, "w") as f: + f.write(model_url) + model_paths[model_name] = model_path + return model_paths diff --git a/setup.py b/setup.py index ea2f303..142104b 100644 --- a/setup.py +++ b/setup.py @@ -6,21 +6,21 @@ if ( platform.processor() == "arm" or platform.processor() == "i386" ) and platform.system() == "Darwin": - tensorflow_os = "tensorflow-macos>=2.10.0,<=2.15.1" + tensorflow_os = "tensorflow-macos>=2.10.0,<=2.20.0" else: - tensorflow_os = "tensorflow>=2.12.0,<=2.15.1" + tensorflow_os = "tensorflow>=2.12.0,<=2.20.0" with open("README.md", "r") as fh: long_description = fh.read() setuptools.setup( name="decimer", - version="2.7.1", + version="2.7.2", author="Kohulan Rajan", author_email="kohulan.rajan@uni-jena.de", maintainer="Kohulan Rajan, Otto Brinkhaus ", maintainer_email="kohulan.rajan@uni-jena.de, otto.brinkhaus@uni-jena.de", - description="DECIMER 2.7.1: Deep Learning for Chemical Image Recognition using Efficient-Net V2 + Transformer", + description="DECIMER 2.7.2: Deep Learning for Chemical Image Recognition using Efficient-Net V2 + Transformer", long_description=long_description, long_description_content_type="text/markdown", entry_points={ @@ -40,17 +40,13 @@ ], package_data={"DECIMER": ["repack/*.*", "efficientnetv2/*.*", "Utils/*.*"]}, classifiers=[ - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3 :: Only", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ], - python_requires=">=3.5", + python_requires=">=3.9", ) From 96557123a3a54c72272b28762c480374108841eb Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Tue, 2 Dec 2025 13:00:40 +0100 Subject: [PATCH 4/8] fix: Add setuptools to tox conda_deps for build --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index 0e2cfd7..2b67411 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ setenv = whitelist_externals = python conda_deps = pytest + setuptools conda_channels = conda-forge commands = pytest -p no:warnings --basetemp="{envtmpdir}" {posargs} From e98884ccc9969383dd6dd804055916765b63939c Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Tue, 2 Dec 2025 13:07:58 +0100 Subject: [PATCH 5/8] fix: Add pyproject.toml with build-system requirements --- pyproject.toml | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..452a827 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" From 41a5dc73afd72c8a2eb16d20b9633c45ba5d3c31 Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Tue, 2 Dec 2025 13:11:33 +0100 Subject: [PATCH 6/8] fix: tests to work with python 3.12 --- .github/workflows/test-release.yml | 2 +- tox.ini | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 063e979..764f5db 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ['3.12', '3.11', '3.10'] + python-version: ['3.12'] steps: - uses: actions/checkout@v2 diff --git a/tox.ini b/tox.ini index 2b67411..ea56466 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py310, lint +envlist = py312, lint requires = tox-conda [testenv] From 4be4c37d50cda22c9fee863d1c080f72a47d603c Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Tue, 2 Dec 2025 13:15:17 +0100 Subject: [PATCH 7/8] fix: Modernize CI to use tox 4 with isolated builds - Remove tox-conda dependency (causes issues with tox provisioning) - Use isolated_build = true with pyproject.toml - Update GitHub Actions to v4/v5 - Add development branch to CI triggers - Separate test and lint jobs for better visibility - Test across Python 3.10, 3.11, 3.12 --- .github/workflows/test-release.yml | 42 ++++++++++++++++++++++-------- tox.ini | 14 +++------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/.github/workflows/test-release.yml b/.github/workflows/test-release.yml index 764f5db..5d8a527 100644 --- a/.github/workflows/test-release.yml +++ b/.github/workflows/test-release.yml @@ -6,38 +6,58 @@ name: Error checking and testing on: # Triggers the workflow on push or pull request events but only for the main branch push: - branches: [ master ] + branches: [ master, development ] pull_request: - branches: [ master ] + branches: [ master, development ] # A workflow run is made up of one or more jobs that can run sequentially or in parallel jobs: - build: + test: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: ['3.12'] + python-version: ['3.10', '3.11', '3.12'] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - - name: Install basic dependencies + + - name: Install dependencies run: | python -m pip install --upgrade pip - - name: Test with tox + pip install tox + + - name: Run tests with tox + run: tox -e py$(echo ${{ matrix.python-version }} | tr -d '.') + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Install dependencies run: | - pip install -e . + python -m pip install --upgrade pip pip install tox - tox + + - name: Run linting + run: tox -e lint release-please: runs-on: ubuntu-latest - needs: build + needs: [test, lint] steps: - uses: google-github-actions/release-please-action@v3 with: diff --git a/tox.ini b/tox.ini index ea56466..65e4e17 100644 --- a/tox.ini +++ b/tox.ini @@ -1,22 +1,16 @@ [tox] -envlist = py312, lint -requires = tox-conda +envlist = py{310,311,312}, lint +isolated_build = true [testenv] -setenv = - CONDA_DLL_SEARCH_MODIFICATION_ENABLE = 1 -whitelist_externals = python -conda_deps = +deps = pytest - setuptools -conda_channels = - conda-forge commands = pytest -p no:warnings --basetemp="{envtmpdir}" {posargs} [testenv:lint] basepython = python3 skip_install = true -conda_deps = flake8 +deps = flake8 commands = flake8 . [flake8] From 9f01ec5104e270e8959ffdde4656a4655131a137 Mon Sep 17 00:00:00 2001 From: Kohulan Rajan Date: Tue, 2 Dec 2025 13:39:06 +0100 Subject: [PATCH 8/8] fix: Fix uint8 overflow in increase_contrast for NumPy 2.x compatibility --- DECIMER/pre_process.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/DECIMER/pre_process.py b/DECIMER/pre_process.py index 3196f2a..e0a9ad0 100644 --- a/DECIMER/pre_process.py +++ b/DECIMER/pre_process.py @@ -174,14 +174,14 @@ def increase_contrast(image): Returns: PIL.Image """ - # Get brightness range - min = np.min(image) - max = np.max(image) + # Get brightness range - cast to int to avoid uint8 overflow + min_val = int(np.min(image)) + max_val = int(np.max(image)) # Use LUT to convert image values LUT = np.zeros(256, dtype=np.uint8) - LUT[min : max + 1] = np.linspace( - start=0, stop=255, num=(max - min) + 1, endpoint=True, dtype=np.uint8 + LUT[min_val : max_val + 1] = np.linspace( + start=0, stop=255, num=(max_val - min_val) + 1, endpoint=True, dtype=np.uint8 ) # Apply LUT and return image

+

🖼️ Image Feature Extraction

Now utilizing EfficientNet-V2 for superior image analysis

+

🔮 SMILES Prediction

Employing a state-of-the-art transformer model