From eb175e8361bd58b7f6160250c1f2d74e0e711a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6ngeter?= Date: Wed, 6 May 2026 11:39:50 +0200 Subject: [PATCH 1/5] Compatibilty with current Python versions (3.11, 3.12), updated deprecated biopython implementation, guards on cli modules --- kalmus/command_line_generator.py | 4 ++ kalmus/command_line_gui.py | 4 ++ kalmus/utils/artist.py | 7 +++- kalmus/utils/measure_utils.py | 44 ++++++++++++++----- requirements.txt | 72 +++++--------------------------- setup.py | 20 ++++++--- tests/test_artist.py | 2 +- 7 files changed, 73 insertions(+), 80 deletions(-) diff --git a/kalmus/command_line_generator.py b/kalmus/command_line_generator.py index 7c5331c..abd6473 100644 --- a/kalmus/command_line_generator.py +++ b/kalmus/command_line_generator.py @@ -71,3 +71,7 @@ def main(args=sys.argv[1:]): rescale_frames_factor=args["rescale_frame_factor"]) barcode = barcode_generator.get_barcode() barcode.save_as_json(filename=args["output_path"]) + + +if __name__ == "__main__": + main() diff --git a/kalmus/command_line_gui.py b/kalmus/command_line_gui.py index 5cf63ee..8c25460 100644 --- a/kalmus/command_line_gui.py +++ b/kalmus/command_line_gui.py @@ -33,3 +33,7 @@ def main(args=sys.argv[1:]): # Use the default barcode and the barcode generator to Instantiate the Main window of the kalmus software (GUI) MainWindow(barcode_tmp, barcode_gn, dpi=args["dpi"]) + + +if __name__ == "__main__": + main() diff --git a/kalmus/utils/artist.py b/kalmus/utils/artist.py index bc01a7a..3e52dde 100644 --- a/kalmus/utils/artist.py +++ b/kalmus/utils/artist.py @@ -109,8 +109,11 @@ def compute_mode_color(image, bin_size=10): """ image = flatten_image(image) image = image // bin_size - mode_color, counts = stats.mode(image, axis=0) - return (mode_color[0] * bin_size), counts + # scipy>=1.11 changed stats.mode default to keepdims=False; pin the shape + # explicitly so callers that expect (channels,) keep working across versions. + mode_result = stats.mode(image, axis=0, keepdims=False) + mode_color, counts = mode_result.mode, mode_result.count + return mode_color * bin_size, counts def compute_mean_color(image): diff --git a/kalmus/utils/measure_utils.py b/kalmus/utils/measure_utils.py index 4537fe6..f31f49f 100644 --- a/kalmus/utils/measure_utils.py +++ b/kalmus/utils/measure_utils.py @@ -1,11 +1,26 @@ """ Image Comparison Utility """ -import Bio.pairwise2 as sequence_align import numpy as np +from Bio.Align import PairwiseAligner from skimage.color import rgb2hsv from skimage.metrics import mean_squared_error, structural_similarity +def _build_aligner(mode, match_score, mismatch_penal, gap_penal, extending_gap_penal): + """Construct a PairwiseAligner mirroring the pairwise2.align.{globalms,localms} scoring model. + + pairwise2 applied the same gap penalty to both terminal and internal gaps; assigning + ``open_gap_score`` / ``extend_gap_score`` on PairwiseAligner does the same. + """ + aligner = PairwiseAligner() + aligner.mode = mode + aligner.match_score = match_score + aligner.mismatch_score = mismatch_penal + aligner.open_gap_score = gap_penal + aligner.extend_gap_score = extending_gap_penal + return aligner + + def nrmse_similarity(image_1, image_2, norm_mode="Min max"): """ Normalized root mean squared error (NRMSE). @@ -61,10 +76,19 @@ def ssim_similarity(image_1, image_2, window_size=None): image_1 = image_1.astype("float64") image_2 = image_2.astype("float64") + # skimage requires explicit `data_range` for floating-point inputs since 0.21. + # Derive it from the joint value range so behaviour matches the prior int default. + data_range = max(image_1.max(), image_2.max()) - min(image_1.min(), image_2.min()) + if data_range == 0: + data_range = 1.0 + + # scikit-image>=0.21 dropped the `multichannel` kwarg in favour of `channel_axis`. if len(image_1.shape) == 2: - score = structural_similarity(image_1, image_2, win_size=window_size, multichannel=False) + score = structural_similarity(image_1, image_2, win_size=window_size, + channel_axis=None, data_range=data_range) elif len(image_1.shape) > 2: - score = structural_similarity(image_1, image_2, win_size=window_size, multichannel=True) + score = structural_similarity(image_1, image_2, win_size=window_size, + channel_axis=-1, data_range=data_range) # Renormalize [-1, 1] score to [0, 1] range score += 1 @@ -264,12 +288,11 @@ def compare_needleman_wunsch(str_barcode_1, str_barcode_2, local_sequence_size=2 """ assert len(str_barcode_1) == len(str_barcode_2), "The lengths of two barcodes have to be identical" + aligner = _build_aligner("global", match_score, mismatch_penal, gap_penal, extending_gap_penal) scores = 0 for start_point in range(0, len(str_barcode_1), local_sequence_size): - scores += sequence_align.align.globalms(str_barcode_1[start_point:start_point + local_sequence_size], - str_barcode_2[start_point:start_point + local_sequence_size], - match_score, mismatch_penal, gap_penal, extending_gap_penal, - score_only=True) + scores += aligner.score(str_barcode_1[start_point:start_point + local_sequence_size], + str_barcode_2[start_point:start_point + local_sequence_size]) if normalized: denom = len(str_barcode_1) * match_score @@ -307,12 +330,11 @@ def compare_smith_waterman(str_barcode_1, str_barcode_2, local_sequence_size=200 """ assert len(str_barcode_1) == len(str_barcode_2), "The lengths of two barcodes have to be identical" + aligner = _build_aligner("local", match_score, mismatch_penal, gap_penal, extending_gap_penal) scores = 0 for start_point in range(0, len(str_barcode_1), local_sequence_size): - scores += sequence_align.align.localms(str_barcode_1[start_point:start_point + local_sequence_size], - str_barcode_2[start_point:start_point + local_sequence_size], - match_score, mismatch_penal, gap_penal, extending_gap_penal, - score_only=True) + scores += aligner.score(str_barcode_1[start_point:start_point + local_sequence_size], + str_barcode_2[start_point:start_point + local_sequence_size]) if normalized: denom = len(str_barcode_1) * match_score diff --git a/requirements.txt b/requirements.txt index 6794a85..fab1049 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,61 +1,11 @@ -attrs==19.1.0 -backcall==0.1.0 -biopython==1.78 -bleach>=3.3.0 -certifi==2020.12.5 -cycler==0.10.0 -decorator==4.4.0 -defusedxml==0.6.0 -entrypoints==0.3 -imageio==2.9.0 -ipykernel==5.1.1 -ipython>=7.16.3 -ipython-genutils==0.2.0 -ipywidgets==7.5.0 -jedi==0.14.1 -Jinja2>=2.11.3 -joblib==1.0.0 -jsonschema==3.0.1 -jupyter==1.0.0 -jupyter-console==6.0.0 -kiwisolver==1.3.1 -MarkupSafe==1.1.1 -matplotlib==3.2.2 -mistune==0.8.4 -mpmath==1.1.0 -nbconvert==5.5.0 -nbformat==4.4.0 -networkx==2.5 -nose==1.3.7 -notebook>=6.1.5 -numpy>=1.21 -opencv-python==4.4.0.46 -pandocfilters==1.4.2 -parso==0.5.1 -pickleshare==0.7.5 -Pillow>=8.1.1 -prometheus-client==0.7.1 -prompt-toolkit==2.0.9 -Pygments>=2.7.4 -pyparsing==2.4.1.1 -pyrsistent==0.15.4 -python-dateutil==2.8.0 -pytz==2019.1 -PyWavelets==1.1.1 -pywinpty==0.5.5 -qtconsole==4.5.2 -scikit-image>=0.16.2 -scikit-learn==0.24.0 -scipy==1.5.4 -Send2Trash==1.5.0 -six==1.12.0 -sympy==1.4 -terminado==0.8.2 -testpath==0.4.2 -threadpoolctl==2.1.0 -tifffile==2020.12.8 -traitlets==4.3.2 -wcwidth==0.1.7 -webencodings==0.5.1 -widgetsnbextension==3.5.0 -wincertstore==0.2 +# Runtime dependencies for kalmus. Mirrors setup.py install_requires. +# For the optional notebook/dev environment, install jupyter/ipykernel/nbconvert separately. +biopython>=1.80 +kiwisolver>=1.4 +matplotlib>=3.5 +numpy>=1.23 +opencv-python>=4.6 +pandas>=1.5 +scikit-image>=0.21 +scikit-learn>=1.1 +scipy>=1.11 diff --git a/setup.py b/setup.py index 8d8d7f7..cc46ce3 100644 --- a/setup.py +++ b/setup.py @@ -21,8 +21,9 @@ classifiers=[ "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Development Status :: 5 - Production/Stable", "Intended Audience :: Education", "Intended Audience :: Science/Research", @@ -32,9 +33,18 @@ "Topic :: Software Development :: User Interfaces" ], packages=find_packages(), - python_requires='>=3.7', - install_requires=['numpy', 'opencv-python', 'scikit-image>=0.16.2', 'matplotlib>=3.2.2', - 'scikit-learn', 'biopython', 'scipy', 'kiwisolver>=1.3.1', 'pandas'], + python_requires='>=3.10', + install_requires=[ + 'numpy>=1.23', + 'opencv-python>=4.6', + 'scikit-image>=0.21', # channel_axis kwarg replaces multichannel + 'matplotlib>=3.5', + 'scikit-learn>=1.1', + 'biopython>=1.80', # PairwiseAligner is the supported aligner + 'scipy>=1.11', # stats.mode keepdims default change handled + 'kiwisolver>=1.4', + 'pandas>=1.5', + ], entry_points={ 'console_scripts': ['kalmus-gui=kalmus.command_line_gui:main', 'kalmus-generator=kalmus.command_line_generator:main'], diff --git a/tests/test_artist.py b/tests/test_artist.py index 2887ffb..f559107 100644 --- a/tests/test_artist.py +++ b/tests/test_artist.py @@ -40,7 +40,7 @@ def test_compute_mode_color(get_test_color_image): image = get_test_color_image color, count = artist.compute_mode_color(get_test_color_image) assert color.shape == (3,) - assert count.shape == (1, 3) + assert count.shape == (3,) assert np.all(count < image.size) From 04e0cb10ce8d5074b836a3952531f040c8ca837d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6ngeter?= Date: Wed, 6 May 2026 11:56:58 +0200 Subject: [PATCH 2/5] Docker compatibility fix --- requirements.txt | 2 +- setup.py | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index fab1049..9fd9e3d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ biopython>=1.80 kiwisolver>=1.4 matplotlib>=3.5 numpy>=1.23 -opencv-python>=4.6 +opencv-python-headless>=4.6 pandas>=1.5 scikit-image>=0.21 scikit-learn>=1.1 diff --git a/setup.py b/setup.py index cc46ce3..ab95042 100644 --- a/setup.py +++ b/setup.py @@ -36,7 +36,10 @@ python_requires='>=3.10', install_requires=[ 'numpy>=1.23', - 'opencv-python>=4.6', + # headless variant: same cv2 API, no X/GL system libs required. + # kalmus uses cv2 only for image-processing (VideoCapture, cvtColor, + # kmeans, grabCut, ...); the GUI is tkinter, never cv2.imshow. + 'opencv-python-headless>=4.6', 'scikit-image>=0.21', # channel_axis kwarg replaces multichannel 'matplotlib>=3.5', 'scikit-learn>=1.1', From 2fe5df61a6464054cc51c380b7bceb5ce6676788 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6ngeter?= Date: Wed, 6 May 2026 13:30:32 +0200 Subject: [PATCH 3/5] Deprecation Shim --- kalmus/utils/artist.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/kalmus/utils/artist.py b/kalmus/utils/artist.py index 3e52dde..209f63f 100644 --- a/kalmus/utils/artist.py +++ b/kalmus/utils/artist.py @@ -1,5 +1,6 @@ """ Utility artist """ +import inspect import numpy as np import cv2 import csv @@ -11,6 +12,17 @@ import scipy.stats as stats +# skimage>=0.26 deprecates `min_size` (removes objects with size < N) in favour of +# `max_size` (removes objects with size <= N). The equivalence is `max_size = N - 1`. +# This shim picks the right kwarg at import time so we work across both APIs. +if "max_size" in inspect.signature(remove_small_objects).parameters: + def _remove_objects_smaller_than(arr, threshold): + return remove_small_objects(arr, max_size=threshold - 1) +else: + def _remove_objects_smaller_than(arr, threshold): + return remove_small_objects(arr, min_size=threshold) + + def compute_dominant_color(image, n_clusters=3, max_iter=10, threshold_error=1.0, attempts=10): """ Compute the dominant color of an input image using the kmeans clustering. The centers of the @@ -365,7 +377,7 @@ def watershed_segmentation(image, minimum_segment_size=0.0004, base_ratio=0.5, d segment_min_size = int(num_of_pixels * minimum_segment_size) # find continuous region which marked by gradient lower than critical gradient markers = rank.gradient(denoised, disk(marker_disk_size)) < critical - markers = ndimage.label(remove_small_objects(markers, segment_min_size))[0] + markers = ndimage.label(_remove_objects_smaller_than(markers, segment_min_size))[0] # process the watershed transformation regions = watershed(gradient, markers) From 10b1f5d6007fcde895965ef37e8433027a9645ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6ngeter?= Date: Wed, 6 May 2026 13:42:50 +0200 Subject: [PATCH 4/5] Verified compatibility to Python 3.13 and 3.14 --- setup.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/setup.py b/setup.py index ab95042..0d67274 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,8 @@ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Development Status :: 5 - Production/Stable", "Intended Audience :: Education", "Intended Audience :: Science/Research", From b2c0d613e2d7222c44c50e42a28e976a41a9f21b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Stefan=20K=C3=B6ngeter?= Date: Thu, 7 May 2026 14:37:12 +0200 Subject: [PATCH 5/5] Fixed two crashes: Save Image with no file extension and loading aone color and one brightness barcode --- kalmus/tkinter_windows/SaveImageWindow.py | 23 ++++++++++++++++++----- kalmus/tkinter_windows/gui_utils.py | 5 +++-- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/kalmus/tkinter_windows/SaveImageWindow.py b/kalmus/tkinter_windows/SaveImageWindow.py index 6239cdd..0d59d02 100644 --- a/kalmus/tkinter_windows/SaveImageWindow.py +++ b/kalmus/tkinter_windows/SaveImageWindow.py @@ -125,6 +125,13 @@ def save_image(self): showerror("File Name is Not Given", "Please specify the path to the saved image.") return + # On Linux/X11 the file dialog does not auto-append the filtered extension, and a + # user typing the path by hand may also omit it. Default to .jpg so the call into + # matplotlib/PIL has something to dispatch on. + _, ext = os.path.splitext(filename) + if ext == "": + filename += ".jpg" + # Get which barcode to save if self.barcode_option.get() == "Barcode 1": barcode = self.barcode_1.get_barcode().astype("uint8") @@ -137,11 +144,17 @@ def save_image(self): barcode = cv2.resize(barcode, dsize=(int(self.resize_x_entry.get()), int(self.resize_y_entry.get())), interpolation=cv2.INTER_NEAREST) - # Save the barcode with desirable color map based on its barcode type - if barcode_type == "Color": - plt.imsave(filename, barcode) - else: - plt.imsave(filename, barcode, cmap="gray") + # Save the barcode with desirable color map based on its barcode type. Surface any + # save error (unsupported extension, permission denied, ...) as a dialog instead of + # letting it tear up the Tk callback. + try: + if barcode_type == "Color": + plt.imsave(filename, barcode) + else: + plt.imsave(filename, barcode, cmap="gray") + except (ValueError, OSError) as exc: + showerror("Could Not Save Image", "Failed to save image to {:s}\n\n{:s}".format(filename, str(exc))) + return # Quit the window self.window.destroy() diff --git a/kalmus/tkinter_windows/gui_utils.py b/kalmus/tkinter_windows/gui_utils.py index e7b7af5..07a2a67 100644 --- a/kalmus/tkinter_windows/gui_utils.py +++ b/kalmus/tkinter_windows/gui_utils.py @@ -285,8 +285,9 @@ def update_hist(barcode, ax, bin_step=5): # Paint each bin with its corresponding color in hue paint_hue_hist(bin_step, patches) else: - # If the barcode type is brightness - N, bins, patches = ax.hist(barcode.brightness[:, 0], bins=(np.arange(0, 256, bin_step))) + # If the barcode type is brightness. `brightness` is stored 1-D (one scalar per + # sampled frame), so flatten defensively rather than indexing a phantom 2nd axis. + N, bins, patches = ax.hist(barcode.brightness.ravel(), bins=(np.arange(0, 256, bin_step))) # Then paint each bin with its brightness intensity paint_gray_hist(bin_step, patches, opacity=0.9)