From a0b78270e6560fb5500479b661d94422fd6e0655 Mon Sep 17 00:00:00 2001 From: derVedro Date: Sat, 31 Jan 2026 19:33:14 +0100 Subject: [PATCH 01/15] vectorize, cache and precompute --- smartcrop/library.py | 163 +++++++++++++++++++++++++++---------------- 1 file changed, 101 insertions(+), 62 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 2af8381..5f4fa9a 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -82,6 +82,9 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals ), Image.Resampling.LANCZOS) + precomputed_features = self.precompute_features(score_image) + features_sum = np.sum(precomputed_features, axis=(0, 1)) + crops = self.crops( image, crop_width, @@ -89,10 +92,24 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals max_scale=max_scale, min_scale=min_scale, scale_step=scale_step, - step=step) + step=step + ) + + cached_importances = {} for crop in crops: - crop['score'] = self.score(score_image, crop) + w, h = map( + lambda val: int(val / self.score_down_sample), + [crop['width'], crop['height']] + ) + importance = cached_importances.get( + (w, h), self.get_importance(width=w, height=h) + ) + cached_importances[(w, h)] = importance + + crop['score'] = self.score( + precomputed_features, features_sum, crop, importance + ) top_crop = max(crops, key=lambda c: c['score']['total']) @@ -190,17 +207,24 @@ def debug_crop(self, analyse_image, crop: dict, orig_size: tuple[int, int]) -> I ratio_horizontal = debug_image.size[0] / orig_size[0] ratio_vertical = debug_image.size[1] / orig_size[1] - fake_crop = { - 'x': crop['x'] * ratio_horizontal, - 'y': crop['y'] * ratio_vertical, - 'width': crop['width'] * ratio_horizontal, - 'height': crop['height'] * ratio_vertical, - } + + # just inplace quick-fix without any numpy magic yet + i_x, i_width, = map( + lambda n: int(n * ratio_horizontal), (crop['x'], crop['width']) + ) + i_y, i_height = map( + lambda n: int(n * ratio_vertical), (crop['y'], crop['height']) + ) + importance_map = self.get_importance(height=i_height, width=i_width) for y in range(analyse_image.size[1]): # height for x in range(analyse_image.size[0]): # width index = y * analyse_image.size[0] + x - importance = self.importance(fake_crop, x, y) + if i_y < y < i_y + i_height and i_x < x < i_x + i_width: + importance = importance_map[y - i_y, x - i_x] + else: + importance = self.outside_importance + redder, greener = (-64, 0) if importance < 0 else (0, 32) debug_pixels.putpixel( (x, y), @@ -270,63 +294,78 @@ def detect_skin(self, cie_array: np.ndarray, source_image) -> Image: return Image.fromarray(skin_data.astype('uint8')) - def importance(self, crop: dict, x: int, y: int) -> float: - if ( - crop['x'] > x or x >= crop['x'] + crop['width'] or - crop['y'] > y or y >= crop['y'] + crop['height'] - ): - return self.outside_importance - - x = (x - crop['x']) / crop['width'] - y = (y - crop['y']) / crop['height'] - px, py = abs(0.5 - x) * 2, abs(0.5 - y) * 2 # pylint:disable=invalid-name - - # distance from edge - dx = max(px - 1 + self.edge_radius, 0) # pylint:disable=invalid-name - dy = max(py - 1 + self.edge_radius, 0) # pylint:disable=invalid-name - d = (dx * dx + dy * dy) * self.edge_weight # pylint:disable=invalid-name - s = 1.41 - math.sqrt(px * px + py * py) # pylint:disable=invalid-name + def get_importance(self, height, width) -> np.ndarray: + def thirds(x): + x = 1 - np.square(8 * x - 8 / 3) + x[x < 0] = 0 + return x + + # the original importance has a scaling that not include 1.0 + X = np.linspace(0, 1, int(width) + 1)[:-1] + Y = np.linspace(0, 1, int(height) + 1)[:-1] + px = np.abs(0.5 - X) * 2 + py = np.abs(0.5 - Y) * 2 + dx = px - (1 - self.edge_radius) + dy = py - (1 - self.edge_radius) + dx[dx < 0] = 0 + dy[dy < 0] = 0 + + d = (np.vstack(dy * dy) + (dx * dx)) * self.edge_weight + # 1.41 is just an approximation of the square root of 2, no magic + s = 1.41 - np.sqrt(np.vstack(py * py) + px * px) if self.rule_of_thirds: - # pylint:disable=invalid-name - s += (max(0, s + d + 0.5) * 1.2) * (thirds(px) + thirds(py)) + intermediate_val = s + d + 0.5 + mask = intermediate_val > 0.0 + # 1.2 is pure magic from original js code + intermediate_val *= (np.vstack(thirds(py)) + thirds(px)) * 1.2 + s[mask] += intermediate_val[mask] return s + d - def score(self, target_image, crop: dict) -> dict: # pylint:disable=too-many-locals - score = { - 'detail': 0, - 'saturation': 0, - 'skin': 0, - 'total': 0, - } - target_data = target_image.getdata() - target_width, target_height = target_image.size - - down_sample = self.score_down_sample - inv_down_sample = 1 / down_sample - target_width_down_sample = target_width * down_sample - target_height_down_sample = target_height * down_sample - - for y in range(0, target_height_down_sample, down_sample): - for x in range(0, target_width_down_sample, down_sample): - index = int( - math.floor(y * inv_down_sample) * target_width + - math.floor(x * inv_down_sample) - ) - importance = self.importance(crop, x, y) - detail = target_data[index][1] / 255 - score['skin'] += ( - target_data[index][0] / 255 * (detail + self.skin_bias) * importance - ) - score['detail'] += detail * importance - score['saturation'] += ( - target_data[index][2] / 255 * (detail + self.saturation_bias) * importance - ) - score['total'] = ( - score['detail'] * self.detail_weight + - score['skin'] * self.skin_weight + - score['saturation'] * self.saturation_weight - ) / (crop['width'] * crop['height']) + def precompute_features(self, target_image: Image) -> np.ndarray: + target_ = np.array(target_image) + + skin = target_[..., 0] + detail = target_[..., 1] + satur = target_[..., 2] + + detail = detail / 255 + skin = skin / 255 * (detail + self.skin_bias) + satur = satur / 255 * (detail + self.saturation_bias) + + precomputed = np.stack( + [ + skin * self.skin_weight, + detail * self.detail_weight, + satur * self.saturation_weight + ], + axis=2) + + return precomputed + + def score( + self, + target_data: np.ndarray, + target_sum, crop: dict, + importance: np.ndarray + ) -> dict: # pylint:disable=too-many-locals + score = {} + inv_down_sample = 1 / self.score_down_sample + x, y, w, h = map( + lambda n: int(n * inv_down_sample), + [crop['x'], crop['y'], crop['width'], crop['height']] + ) + + prescore = target_sum * self.outside_importance + prescore += np.sum( + target_data[y : y + h, x : x + w] * + (importance - self.outside_importance)[..., np.newaxis], + axis=(0, 1) + ) + total = np.sum(prescore) / (w * h) + + score['skin'], score['detail'], score['saturation'] = prescore + score['total'] = total return score From 9ef740f08c2bb6fbe8ea3300c16f3482b318d99d Mon Sep 17 00:00:00 2001 From: derVedro Date: Sun, 1 Feb 2026 17:23:10 +0100 Subject: [PATCH 02/15] drop unused stuff --- smartcrop/__init__.py | 4 ++-- smartcrop/library.py | 16 ---------------- 2 files changed, 2 insertions(+), 18 deletions(-) diff --git a/smartcrop/__init__.py b/smartcrop/__init__.py index 56ba90d..7a6a2d9 100644 --- a/smartcrop/__init__.py +++ b/smartcrop/__init__.py @@ -1,3 +1,3 @@ -from .library import SmartCrop, saturation, thirds +from .library import SmartCrop, saturation -__all__ = ['SmartCrop', 'saturation', 'thirds'] +__all__ = ['SmartCrop', 'saturation'] diff --git a/smartcrop/library.py b/smartcrop/library.py index 5f4fa9a..45f8653 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -1,6 +1,5 @@ from __future__ import annotations from dataclasses import dataclass -from functools import lru_cache import math import sys @@ -26,14 +25,6 @@ def saturation(image) -> np.ndarray: return d / s # [0.0; 1.0] -@lru_cache(maxsize=4096) -def thirds(x) -> float: - """gets value in the range of [0, 1] where 0 is the center of the pictures - returns weight of rule of thirds [0, 1]""" - x = 8 * (x + 2 / 3) - 8 # 8*x-8/3 is even simpler, but with ~e-16 floating error - return max(1 - x * x, 0) - - # a quite odd workaround for using slots for python > 3.9 @dataclass(eq=False, **{"slots": True} if sys.version_info.minor > 9 else {}) class SmartCrop: # pylint:disable=too-many-instance-attributes @@ -234,13 +225,6 @@ def debug_crop(self, analyse_image, crop: dict, orig_size: tuple[int, int]) -> I debug_pixels[index][2] )) - # in case you want a whitish outline to mark the crop - # ImageDraw.Draw(debug_image).rectangle([fake_crop['x'], - # fake_crop['y'], - # fake_crop['x'] + fake_crop['width'], - # fake_crop['y'] + fake_crop['height']], - # outline=(175, 175, 175), width=2) - return debug_image def prepare_features_image(self, image: Image) -> Image: From 0abef9a1917c0f806869f1f4ba3f0e3278e135ee Mon Sep 17 00:00:00 2001 From: derVedro Date: Sun, 1 Feb 2026 18:10:01 +0100 Subject: [PATCH 03/15] match score magnitude to previous version --- smartcrop/library.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 45f8653..5decf91 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -347,7 +347,12 @@ def score( (importance - self.outside_importance)[..., np.newaxis], axis=(0, 1) ) - total = np.sum(prescore) / (w * h) + + # Last factor of squared inv_down_sample is not mandatory for finding + # max score, it's here to match the score magnitude of previous version. + # To be honest, that can lead to some inaccuracies, as it brings the + # values even closer to zero. Recommend to drop it later. + total = np.sum(prescore) / (w * h) * inv_down_sample * inv_down_sample score['skin'], score['detail'], score['saturation'] = prescore score['total'] = total From d169f268aae703544435e64029ddc0e7de871aed Mon Sep 17 00:00:00 2001 From: derVedro Date: Sun, 1 Feb 2026 18:21:11 +0100 Subject: [PATCH 04/15] better parameter names in score and precompute_features; rename variable in precompute_features --- smartcrop/library.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 5decf91..76636ee 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -307,12 +307,12 @@ def thirds(x): return s + d - def precompute_features(self, target_image: Image) -> np.ndarray: - target_ = np.array(target_image) + def precompute_features(self, features_image: Image) -> np.ndarray: + features = np.array(features_image) - skin = target_[..., 0] - detail = target_[..., 1] - satur = target_[..., 2] + skin = features[..., 0] + detail = features[..., 1] + satur = features[..., 2] detail = detail / 255 skin = skin / 255 * (detail + self.skin_bias) @@ -330,8 +330,8 @@ def precompute_features(self, target_image: Image) -> np.ndarray: def score( self, - target_data: np.ndarray, - target_sum, crop: dict, + features_data: np.ndarray, + features_pre_sum, crop: dict, importance: np.ndarray ) -> dict: # pylint:disable=too-many-locals score = {} @@ -341,9 +341,9 @@ def score( [crop['x'], crop['y'], crop['width'], crop['height']] ) - prescore = target_sum * self.outside_importance + prescore = features_pre_sum * self.outside_importance prescore += np.sum( - target_data[y : y + h, x : x + w] * + features_data[y: y + h, x: x + w] * (importance - self.outside_importance)[..., np.newaxis], axis=(0, 1) ) From c44687b35551cfef2001762bbf394feedf651e67 Mon Sep 17 00:00:00 2001 From: derVedro Date: Sun, 1 Feb 2026 18:45:56 +0100 Subject: [PATCH 05/15] add docstrings --- smartcrop/library.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/smartcrop/library.py b/smartcrop/library.py index 76636ee..48c0ced 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -193,6 +193,11 @@ def crops( # pylint:disable=too-many-arguments return crops def debug_crop(self, analyse_image, crop: dict, orig_size: tuple[int, int]) -> Image: + """ + This function is for internal use only and should not be called. It + would not do, what your expect. The `crop` you probably have does not + match the dimensions of `analyse_image` you probably pass in. + """ debug_image = analyse_image.copy() debug_pixels = debug_image.getdata() @@ -279,6 +284,9 @@ def detect_skin(self, cie_array: np.ndarray, source_image) -> Image: return Image.fromarray(skin_data.astype('uint8')) def get_importance(self, height, width) -> np.ndarray: + """ + Generate composite weighting map for a scoring crop. + """ def thirds(x): x = 1 - np.square(8 * x - 8 / 3) x[x < 0] = 0 @@ -308,6 +316,9 @@ def thirds(x): return s + d def precompute_features(self, features_image: Image) -> np.ndarray: + """ + Apply scaling, biasing, and weighting transformations to image features. + """ features = np.array(features_image) skin = features[..., 0] @@ -334,6 +345,10 @@ def score( features_pre_sum, crop: dict, importance: np.ndarray ) -> dict: # pylint:disable=too-many-locals + """ + Calculate region scores for skin, detail, and saturation features. + Returns a dictionary with individual channel scores and total score. + """ score = {} inv_down_sample = 1 / self.score_down_sample x, y, w, h = map( From 9e64449e9533a2f3b761a154b2cfa522d102f683 Mon Sep 17 00:00:00 2001 From: derVedro Date: Tue, 3 Feb 2026 09:01:05 +0100 Subject: [PATCH 06/15] avoid linter ranting --- smartcrop/library.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 48c0ced..8763665 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -293,10 +293,10 @@ def thirds(x): return x # the original importance has a scaling that not include 1.0 - X = np.linspace(0, 1, int(width) + 1)[:-1] - Y = np.linspace(0, 1, int(height) + 1)[:-1] - px = np.abs(0.5 - X) * 2 - py = np.abs(0.5 - Y) * 2 + xx = np.linspace(0, 1, int(width) + 1)[:-1] + yy = np.linspace(0, 1, int(height) + 1)[:-1] + px = np.abs(0.5 - xx) * 2 + py = np.abs(0.5 - yy) * 2 dx = px - (1 - self.edge_radius) dy = py - (1 - self.edge_radius) dx[dx < 0] = 0 From c3f721c2b0800ef0f60dc33d442acc54a636f4f5 Mon Sep 17 00:00:00 2001 From: derVedro Date: Wed, 4 Feb 2026 02:48:32 +0100 Subject: [PATCH 07/15] vectorize debug_crop --- smartcrop/library.py | 49 +++++++++++++++++++------------------------- 1 file changed, 21 insertions(+), 28 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 8763665..023f578 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -194,43 +194,36 @@ def crops( # pylint:disable=too-many-arguments def debug_crop(self, analyse_image, crop: dict, orig_size: tuple[int, int]) -> Image: """ - This function is for internal use only and should not be called. It - would not do, what your expect. The `crop` you probably have does not - match the dimensions of `analyse_image` you probably pass in. + Creates a debug visualization showing how importance weights affect a + specific crop region. This function is intended to be used for internal + debugging. The original image dimensions `orig_size` are required to + correctly prescale the crop coordinates. """ - debug_image = analyse_image.copy() - debug_pixels = debug_image.getdata() - - ratio_horizontal = debug_image.size[0] / orig_size[0] - ratio_vertical = debug_image.size[1] / orig_size[1] - - # just inplace quick-fix without any numpy magic yet + ratio_horizontal = analyse_image.size[0] / orig_size[0] + ratio_vertical = analyse_image.size[1] / orig_size[1] i_x, i_width, = map( lambda n: int(n * ratio_horizontal), (crop['x'], crop['width']) ) i_y, i_height = map( lambda n: int(n * ratio_vertical), (crop['y'], crop['height']) ) + + features_data = np.array(analyse_image).astype(np.float32) importance_map = self.get_importance(height=i_height, width=i_width) - for y in range(analyse_image.size[1]): # height - for x in range(analyse_image.size[0]): # width - index = y * analyse_image.size[0] + x - if i_y < y < i_y + i_height and i_x < x < i_x + i_width: - importance = importance_map[y - i_y, x - i_x] - else: - importance = self.outside_importance - - redder, greener = (-64, 0) if importance < 0 else (0, 32) - debug_pixels.putpixel( - (x, y), - ( - debug_pixels[index][0] + int(importance * redder), - debug_pixels[index][1] + int(importance * greener), - debug_pixels[index][2] - )) - - return debug_image + # window there the importance is applied + i_window = features_data[i_y : i_y + i_height, i_x : i_x + i_width] + + # place the outside importance + features_data += np.array([-64 * self.outside_importance, 0, 0]) + + # apply the importance on the window + mask = importance_map > 0 + i_window[~mask, 0] += -64 * importance_map[~mask] # redder + i_window[mask, 1] += 32 * importance_map[mask] # greener + features_data[i_y : i_y + i_height, i_x : i_x + i_width] = i_window + + return Image.fromarray(np.clip(features_data, 0, 255).astype(np.uint8)) def prepare_features_image(self, image: Image) -> Image: # luminance From b0d912f84cdab6bb575a58e875234ee2236d5c82 Mon Sep 17 00:00:00 2001 From: derVedro Date: Wed, 4 Feb 2026 21:59:57 +0100 Subject: [PATCH 08/15] ignore flake8 false positives --- smartcrop/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 023f578..36af245 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -212,7 +212,7 @@ def debug_crop(self, analyse_image, crop: dict, orig_size: tuple[int, int]) -> I importance_map = self.get_importance(height=i_height, width=i_width) # window there the importance is applied - i_window = features_data[i_y : i_y + i_height, i_x : i_x + i_width] + i_window = features_data[i_y : i_y + i_height, i_x : i_x + i_width] # noqa: E203 # place the outside importance features_data += np.array([-64 * self.outside_importance, 0, 0]) @@ -221,7 +221,7 @@ def debug_crop(self, analyse_image, crop: dict, orig_size: tuple[int, int]) -> I mask = importance_map > 0 i_window[~mask, 0] += -64 * importance_map[~mask] # redder i_window[mask, 1] += 32 * importance_map[mask] # greener - features_data[i_y : i_y + i_height, i_x : i_x + i_width] = i_window + features_data[i_y : i_y + i_height, i_x : i_x + i_width] = i_window # noqa: E203 return Image.fromarray(np.clip(features_data, 0, 255).astype(np.uint8)) From 018470258ca49a114d32ac64b5cf445513773e2c Mon Sep 17 00:00:00 2001 From: derVedro Date: Mon, 9 Feb 2026 04:31:39 +0100 Subject: [PATCH 09/15] factor out the recurring application of outside_importance on precomputed prescores from score method - avoid a multiplication each score run. --- smartcrop/library.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 36af245..782dc9f 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -75,6 +75,7 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals precomputed_features = self.precompute_features(score_image) features_sum = np.sum(precomputed_features, axis=(0, 1)) + prescore = features_sum * self.outside_importance crops = self.crops( image, @@ -99,7 +100,7 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals cached_importances[(w, h)] = importance crop['score'] = self.score( - precomputed_features, features_sum, crop, importance + precomputed_features, prescore, crop, importance ) top_crop = max(crops, key=lambda c: c['score']['total']) @@ -335,7 +336,8 @@ def precompute_features(self, features_image: Image) -> np.ndarray: def score( self, features_data: np.ndarray, - features_pre_sum, crop: dict, + prescore: np.ndarray, + crop: dict, importance: np.ndarray ) -> dict: # pylint:disable=too-many-locals """ @@ -349,8 +351,7 @@ def score( [crop['x'], crop['y'], crop['width'], crop['height']] ) - prescore = features_pre_sum * self.outside_importance - prescore += np.sum( + scores = prescore + np.sum( features_data[y: y + h, x: x + w] * (importance - self.outside_importance)[..., np.newaxis], axis=(0, 1) @@ -360,9 +361,9 @@ def score( # max score, it's here to match the score magnitude of previous version. # To be honest, that can lead to some inaccuracies, as it brings the # values even closer to zero. Recommend to drop it later. - total = np.sum(prescore) / (w * h) * inv_down_sample * inv_down_sample + total = np.sum(scores) / (w * h) * inv_down_sample * inv_down_sample - score['skin'], score['detail'], score['saturation'] = prescore + score['skin'], score['detail'], score['saturation'] = scores score['total'] = total return score From 6b5e4ea5f261618f174ad18c73828ed5b700d313 Mon Sep 17 00:00:00 2001 From: derVedro Date: Mon, 9 Feb 2026 20:40:13 +0100 Subject: [PATCH 10/15] better name for scaled score_image --- smartcrop/library.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 782dc9f..f01bf21 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -66,14 +66,14 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals image = image.convert('RGB') analyse_image = self.prepare_features_image(image) - score_image = analyse_image.resize( + downsampled_features = analyse_image.resize( ( int(math.ceil(image.size[0] / self.score_down_sample)), int(math.ceil(image.size[1] / self.score_down_sample)) ), Image.Resampling.LANCZOS) - precomputed_features = self.precompute_features(score_image) + precomputed_features = self.precompute_features(downsampled_features) features_sum = np.sum(precomputed_features, axis=(0, 1)) prescore = features_sum * self.outside_importance @@ -287,8 +287,8 @@ def thirds(x): return x # the original importance has a scaling that not include 1.0 - xx = np.linspace(0, 1, int(width) + 1)[:-1] - yy = np.linspace(0, 1, int(height) + 1)[:-1] + xx = np.linspace(0, 1, int(width), endpoint=False) + yy = np.linspace(0, 1, int(height), endpoint=False) px = np.abs(0.5 - xx) * 2 py = np.abs(0.5 - yy) * 2 dx = px - (1 - self.edge_radius) From c28aea268987df8371e916ea40ba69f09bdd1066 Mon Sep 17 00:00:00 2001 From: derVedro Date: Mon, 9 Feb 2026 21:08:48 +0100 Subject: [PATCH 11/15] factor out crop dimensions scaling from scrore() into analyse() --- smartcrop/library.py | 20 +++++++++----------- 1 file changed, 9 insertions(+), 11 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index f01bf21..26ecd04 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -88,19 +88,20 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals ) cached_importances = {} + inv_down_sample = 1 / self.score_down_sample for crop in crops: - w, h = map( - lambda val: int(val / self.score_down_sample), - [crop['width'], crop['height']] + cx, cy, cw, ch = map( + lambda val: int(val * inv_down_sample), + [crop['x'], crop['y'], crop['width'], crop['height']] ) importance = cached_importances.get( - (w, h), self.get_importance(width=w, height=h) + (cw, ch), self.get_importance(width=cw, height=ch) ) - cached_importances[(w, h)] = importance + cached_importances[(cw, ch)] = importance crop['score'] = self.score( - precomputed_features, prescore, crop, importance + precomputed_features, prescore, (cx, cy, cw, ch), importance ) top_crop = max(crops, key=lambda c: c['score']['total']) @@ -337,7 +338,7 @@ def score( self, features_data: np.ndarray, prescore: np.ndarray, - crop: dict, + crop_dimensions: tuple[int, int, int, int], # (x, y, w, h) importance: np.ndarray ) -> dict: # pylint:disable=too-many-locals """ @@ -346,10 +347,7 @@ def score( """ score = {} inv_down_sample = 1 / self.score_down_sample - x, y, w, h = map( - lambda n: int(n * inv_down_sample), - [crop['x'], crop['y'], crop['width'], crop['height']] - ) + x, y, w, h = crop_dimensions scores = prescore + np.sum( features_data[y: y + h, x: x + w] * From 42e6d67253f9a3c1771f16f6e02899441d4b948f Mon Sep 17 00:00:00 2001 From: derVedro Date: Mon, 9 Feb 2026 21:19:10 +0100 Subject: [PATCH 12/15] fixed the unnecessary rewriting of the importance cache --- smartcrop/library.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 26ecd04..6e9d751 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -95,10 +95,12 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals lambda val: int(val * inv_down_sample), [crop['x'], crop['y'], crop['width'], crop['height']] ) - importance = cached_importances.get( - (cw, ch), self.get_importance(width=cw, height=ch) + + if (cw, ch) not in cached_importances: + cached_importances[(cw, ch)] = self.get_importance( + width=cw, height=ch ) - cached_importances[(cw, ch)] = importance + importance = cached_importances[(cw, ch)] crop['score'] = self.score( precomputed_features, prescore, (cx, cy, cw, ch), importance From f32db6d5c61f8a2272e00b8165276eefa7ffd5b3 Mon Sep 17 00:00:00 2001 From: derVedro Date: Wed, 11 Feb 2026 06:34:46 +0100 Subject: [PATCH 13/15] squeeze last performance quantum of importance: broadcasting instead of stacking, maximum instead of boolean masks --- smartcrop/library.py | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index 6e9d751..d01ebfa 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -284,31 +284,27 @@ def get_importance(self, height, width) -> np.ndarray: """ Generate composite weighting map for a scoring crop. """ - def thirds(x): - x = 1 - np.square(8 * x - 8 / 3) - x[x < 0] = 0 - return x - # the original importance has a scaling that not include 1.0 - xx = np.linspace(0, 1, int(width), endpoint=False) - yy = np.linspace(0, 1, int(height), endpoint=False) + xx = np.linspace(0.0, 1.0, width, endpoint=False) + yy = np.linspace(0.0, 1.0, height, endpoint=False) px = np.abs(0.5 - xx) * 2 py = np.abs(0.5 - yy) * 2 - dx = px - (1 - self.edge_radius) - dy = py - (1 - self.edge_radius) - dx[dx < 0] = 0 - dy[dy < 0] = 0 - - d = (np.vstack(dy * dy) + (dx * dx)) * self.edge_weight + edge_threshold = 1.0 - self.edge_radius + dx = np.maximum(px - edge_threshold, 0.0) + dy = np.maximum(py - edge_threshold, 0.0) + d = (np.square(dy[:, np.newaxis]) + np.square(dx)) * self.edge_weight # 1.41 is just an approximation of the square root of 2, no magic - s = 1.41 - np.sqrt(np.vstack(py * py) + px * px) + s = 1.41 - np.sqrt(np.square(py[:, np.newaxis]) + np.square(px)) if self.rule_of_thirds: - intermediate_val = s + d + 0.5 - mask = intermediate_val > 0.0 + def thirds(t): + # that's kind of parabola centered at 1/3 + t = 1.0 - 64.0 * np.square(t - 1.0 / 3) + return np.maximum(t, 0.0) # 1.2 is pure magic from original js code - intermediate_val *= (np.vstack(thirds(py)) + thirds(px)) * 1.2 - s[mask] += intermediate_val[mask] + thirds_weight = (thirds(py)[:, np.newaxis] + thirds(px)) * 1.2 + intermediate = s + d + 0.5 + s += np.maximum(intermediate, 0.0) * thirds_weight return s + d From 76f1a326990b292b7c9d4c457618bef951e09d05 Mon Sep 17 00:00:00 2001 From: derVedro Date: Fri, 13 Feb 2026 16:48:28 +0100 Subject: [PATCH 14/15] change test values --- tests/test_smartcrop.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_smartcrop.py b/tests/test_smartcrop.py index 3d235c6..bab5075 100644 --- a/tests/test_smartcrop.py +++ b/tests/test_smartcrop.py @@ -13,9 +13,9 @@ def load_image(name): @pytest.mark.parametrize('image, crop', [ ('business-work-1.jpg', (41, 0, 1193, 1152)), - ('nature-1.jpg', (705, 235, 3639, 3169)), + ('nature-1.jpg', (822, 235, 3756, 3169)), ('travel-1.jpg', (52, 52, 1370, 1370)), - ('orientation.jpg', (972, 216, 3669, 2913)) + ('orientation.jpg', (972, 0, 3969, 2997)) ]) def test_square_thumbs(image, crop): cropper = SmartCrop() From db7cebe18aace1fe5c9787d172587757d5b48e1b Mon Sep 17 00:00:00 2001 From: derVedro Date: Fri, 13 Feb 2026 16:52:39 +0100 Subject: [PATCH 15/15] make linter happy --- smartcrop/library.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/smartcrop/library.py b/smartcrop/library.py index d01ebfa..3c0ba4d 100644 --- a/smartcrop/library.py +++ b/smartcrop/library.py @@ -99,7 +99,7 @@ def analyse( # pylint:disable=too-many-arguments,too-many-locals if (cw, ch) not in cached_importances: cached_importances[(cw, ch)] = self.get_importance( width=cw, height=ch - ) + ) importance = cached_importances[(cw, ch)] crop['score'] = self.score( @@ -336,7 +336,7 @@ def score( self, features_data: np.ndarray, prescore: np.ndarray, - crop_dimensions: tuple[int, int, int, int], # (x, y, w, h) + crop_dimensions: tuple[int, int, int, int], # (x, y, w, h) importance: np.ndarray ) -> dict: # pylint:disable=too-many-locals """