From 8ca67763c3939bfe583a4bb4e522e5fa7b34e854 Mon Sep 17 00:00:00 2001 From: Jose Eduardo Date: Tue, 11 Aug 2026 15:20:04 +0100 Subject: [PATCH] Feat: Allow cropping whitespace --- .gitignore | 3 + .python-version | 1 + paperminis/forms.py | 1 + paperminis/generate_minis.py | 60 ++++++- .../0023_alter_printsettings_grid_size.py | 18 ++ .../0024_printsettings_crop_whitespace.py | 18 ++ paperminis/models.py | 1 + paperminis/tests.py | 167 +++++++++++++++++- paperminis/views.py | 7 +- requirements.txt | 1 + templates/paperminis/bestiary_print.html | 5 + templates/paperminis/quickbuild.html | 5 + 12 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 .python-version create mode 100644 paperminis/migrations/0023_alter_printsettings_grid_size.py create mode 100644 paperminis/migrations/0024_printsettings_crop_whitespace.py diff --git a/.gitignore b/.gitignore index de7fc5d..bc09bfc 100644 --- a/.gitignore +++ b/.gitignore @@ -69,3 +69,6 @@ venv *pycache* dndtools/settings_secret.py db.sqlite3 + +# Local Django +static/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..c8cfe39 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.10 diff --git a/paperminis/forms.py b/paperminis/forms.py index 82a629c..2da5404 100644 --- a/paperminis/forms.py +++ b/paperminis/forms.py @@ -200,6 +200,7 @@ class QuickCreateSettingsForm(forms.Form): grid_size = forms.ChoiceField(choices=GRID_SIZE_CHOICES, required=True, initial=GRID24) base_shape = forms.ChoiceField(choices=BASE_SHAPE_CHOICES, required=True) enumerate = forms.BooleanField(required=False) + crop_whitespace = forms.BooleanField(required=False) class QuickCreateCreatureForm(forms.Form): diff --git a/paperminis/generate_minis.py b/paperminis/generate_minis.py index e9665a1..d24ed67 100644 --- a/paperminis/generate_minis.py +++ b/paperminis/generate_minis.py @@ -15,6 +15,57 @@ logger = logging.getLogger("django") +def crop_whitespace(img, threshold=15, margin=None, min_content_ratio=0.05): + """ + Crop the uniform border around the actual content of an image. + + The border color is derived from the median of the four edges of the + image. This works for both flattened transparent PNGs (whose + background was replaced with the chosen background color) and + regular images with a white background. + + Cropping is skipped (returns the original) when the image is tiny, + when no border is detected, or when the content only occupies a tiny + fraction of the image (likely noise or artifacts rather than real + art). When a crop does apply, a small margin is kept around the + content so the result is not skin-tight against the art. + """ + if img.shape[0] < 3 or img.shape[1] < 3: + return img + + gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY) + + # Find median border color from the four edges + edge = np.concatenate((gray[0, :], gray[-1, :], gray[:, 0], gray[:, -1])) + border_color = int(np.median(edge)) + + # Mask all pixels different from the border color +/- threshold + diff = cv.absdiff(gray, border_color) + _, mask = cv.threshold(diff, threshold, 255, cv.THRESH_BINARY) + + # Nothing differing from the border color, so keep it as is + points = cv.findNonZero(mask) + if points is None: + return img + + x0, y0, w, h = cv.boundingRect(points) + + # Avoid cropping if the result is way smaller than the original + total = img.shape[0] * img.shape[1] + if (w * h) < min_content_ratio * total: + return img + + if margin is None: + margin = max(1, int(round(0.02 * max(w, h)))) + + x = max(x0 - margin, 0) + y = max(y0 - margin, 0) + x2 = min(x0 + w + margin, img.shape[1]) + y2 = min(y0 + h + margin, img.shape[0]) + + return img[y:y2, x:x2] + + class MiniBuilder: def __init__(self): @@ -36,6 +87,7 @@ def __init__(self): self.base_shape = None self.fixed_height = False self.darken = None + self.crop_whitespace = False self.font = cv.FONT_HERSHEY_SIMPLEX self.paper_format = None self.canvas = None @@ -77,7 +129,8 @@ def load_settings(self, enumerate=False, force_name='no_force', fixed_height=False, - darken=0): + darken=0, + crop_whitespace=False): self.print_margin = print_margin self.dpmm = 10 # not fully supported setting yet, leave at 10 @@ -87,6 +140,7 @@ def load_settings(self, self.base_shape = base_shape self.fixed_height = fixed_height self.darken = darken + self.crop_whitespace = crop_whitespace self.paper_format = paper_format paper = {'a3': np.array([297, 420]), 'a4': np.array([210, 297]), @@ -308,6 +362,10 @@ def build_mini(self, creature): color[bmask] = background_color m_img = color + # crop unnecessary whitespace to increase mini real estate + if self.crop_whitespace: + m_img = crop_whitespace(m_img) + # get Textbox height namebox_height = n_img.shape[0] diff --git a/paperminis/migrations/0023_alter_printsettings_grid_size.py b/paperminis/migrations/0023_alter_printsettings_grid_size.py new file mode 100644 index 0000000..b06cb29 --- /dev/null +++ b/paperminis/migrations/0023_alter_printsettings_grid_size.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2026-08-12 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('paperminis', '0022_creature_cavalry_mode'), + ] + + operations = [ + migrations.AlterField( + model_name='printsettings', + name='grid_size', + field=models.IntegerField(choices=[(28, '28 mm ~ 1.1 inch'), (24, '24 mm ~ 1 inch'), (18, '18 mm ~ 3/4 inch'), (12, '12 mm ~ 1/2 inch')], default=24), + ), + ] diff --git a/paperminis/migrations/0024_printsettings_crop_whitespace.py b/paperminis/migrations/0024_printsettings_crop_whitespace.py new file mode 100644 index 0000000..54d7d19 --- /dev/null +++ b/paperminis/migrations/0024_printsettings_crop_whitespace.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.20 on 2026-08-12 10:28 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('paperminis', '0023_alter_printsettings_grid_size'), + ] + + operations = [ + migrations.AddField( + model_name='printsettings', + name='crop_whitespace', + field=models.BooleanField(default=False), + ), + ] diff --git a/paperminis/models.py b/paperminis/models.py index 744447a..7dda548 100644 --- a/paperminis/models.py +++ b/paperminis/models.py @@ -224,3 +224,4 @@ class PrintSettings(models.Model): force_name = models.CharField(max_length=50, choices=NAME_BEHAVIOUR_CHOICES, default=NO_FORCE) fixed_height = models.BooleanField(default=False) darken = models.IntegerField(default=0) + crop_whitespace = models.BooleanField(default=False) diff --git a/paperminis/tests.py b/paperminis/tests.py index bbe8105..3e765ca 100644 --- a/paperminis/tests.py +++ b/paperminis/tests.py @@ -2,9 +2,20 @@ from django.urls import reverse from django.contrib.auth import get_user_model, get_user from django.contrib.auth.models import Group -from paperminis.models import Bestiary, Creature, CreatureQuantity +from unittest.mock import patch +import numpy as np +from paperminis.generate_minis import MiniBuilder, crop_whitespace +from paperminis.models import Bestiary, Creature, CreatureQuantity, PrintSettings # Create your tests here. + +def _bordered_img(size=400, content=100): + img = np.full((size, size, 3), 255, np.uint8) + start = (size - content) // 2 + img[start:start + content, start:start + content] = (0, 0, 255) + return img + + class QuickViewTests(TestCase): """Testing basic view functionality""" def test_quickbuild(self): @@ -120,3 +131,157 @@ def test_create_ddb_enc(self): self.assertEqual(Creature.objects.all().count(), 10) self.assertEqual(Bestiary.objects.all().count(), 1) self.assertEqual(Bestiary.objects.first().name, "TEST-Forge ALL + Many") + + +class CropWhitespaceTests(TestCase): + """Testing the whitespace cropping helper.""" + + def test_crops_uniform_border(self): + img = _bordered_img() + out = crop_whitespace(img) + self.assertLess(out.shape[0], img.shape[0]) + self.assertLess(out.shape[1], img.shape[1]) + + def test_solid_image_unchanged(self): + img = np.zeros((200, 200, 3), np.uint8) + out = crop_whitespace(img) + self.assertEqual(out.shape, img.shape) + + def test_tiny_content_unchanged(self): + img = np.full((400, 400, 3), 255, np.uint8) + img[200, 200] = (0, 0, 0) + out = crop_whitespace(img) + self.assertEqual(out.shape, img.shape) + + def test_tiny_image_unchanged(self): + img = np.zeros((2, 2, 3), np.uint8) + out = crop_whitespace(img) + self.assertEqual(out.shape, img.shape) + + def test_content_at_or_above_ratio_is_cropped(self): + out = crop_whitespace(_bordered_img(size=400, content=90)) + self.assertLess(out.shape[0], 400) + + def test_explicit_margin(self): + out = crop_whitespace(_bordered_img(size=400, content=100), margin=5) + self.assertEqual(out.shape, (110, 110, 3)) + + def test_threshold(self): + img = np.full((400, 400, 3), 255, np.uint8) + start = 150 + img[start:start + 100, start:start + 100] = (245, 245, 245) + # content differs from the white border by 10, below the default threshold + self.assertEqual(crop_whitespace(img).shape, img.shape) + # a lower threshold picks it up + out = crop_whitespace(img, threshold=5) + self.assertLess(out.shape[0], img.shape[0]) + + def test_crop_margin_is_symmetric(self): + out = crop_whitespace(_bordered_img(size=400, content=100)) + self.assertEqual(out.shape, (104, 104, 3)) # content + 2*margin on each side + red = np.all(out == (0, 0, 255), axis=2) + rows = np.nonzero(red.any(axis=1))[0] + cols = np.nonzero(red.any(axis=0))[0] + self.assertEqual(rows.min(), 2) + self.assertEqual(out.shape[0] - 1 - rows.max(), 2) + self.assertEqual(cols.min(), 2) + self.assertEqual(out.shape[1] - 1 - cols.max(), 2) + + +class MiniBuilderCropTests(TestCase): + """Testing that enabling whitespace cropping increases mini real estate.""" + + def setUp(self): + self.group = Group(name='temp') + self.group.save() + self.user = get_user_model().objects.create_user(email='test@email.com', password='MyPassword1234$') + + @patch('paperminis.generate_minis.download_image') + def test_crop_flag_increases_content(self, mock_download): + mock_download.return_value = _bordered_img() + creature = Creature.objects.create( + name="Test", owner=self.user, + img_url="https://example.com/img.jpg", + size="M", position="bottom", show_name=False, + ) + + builder = MiniBuilder() + builder.load_settings(crop_whitespace=False) + mini_no_crop = builder.build_mini(creature) + + builder = MiniBuilder() + builder.load_settings(crop_whitespace=True) + mini_crop = builder.build_mini(creature) + + self.assertIsInstance(mini_no_crop, np.ndarray) + self.assertIsInstance(mini_crop, np.ndarray) + self.assertEqual(mini_no_crop.shape[1], 240) + self.assertEqual(mini_crop.shape[1], 240) + + red_no_crop = int(np.sum(np.all(mini_no_crop == (0, 0, 255), axis=2))) + red_crop = int(np.sum(np.all(mini_crop == (0, 0, 255), axis=2))) + self.assertGreater(red_crop, red_no_crop) + + @patch('paperminis.generate_minis.download_image') + def test_rgba_png_flattened_before_crop(self, mock_download): + img = np.zeros((400, 400, 4), np.uint8) + img[:, :, :3] = 255 # white border, transparent (alpha 0) + start = 100 + img[start:start + 200, start:start + 200, :3] = (0, 0, 255) # red content + img[start:start + 200, start:start + 200, 3] = 255 # opaque content + mock_download.return_value = img + creature = Creature.objects.create( + name="Test", owner=self.user, + img_url="https://example.com/img.png", + size="M", position="bottom", show_name=False, + ) + + builder = MiniBuilder() + builder.load_settings(crop_whitespace=False) + mini_no_crop = builder.build_mini(creature) + + builder = MiniBuilder() + builder.load_settings(crop_whitespace=True) + mini_crop = builder.build_mini(creature) + + self.assertIsInstance(mini_no_crop, np.ndarray) + self.assertIsInstance(mini_crop, np.ndarray) + self.assertEqual(mini_crop.shape[1], 240) + red_no_crop = int(np.sum(np.all(mini_no_crop == (0, 0, 255), axis=2))) + red_crop = int(np.sum(np.all(mini_crop == (0, 0, 255), axis=2))) + self.assertGreater(red_crop, red_no_crop) + + @patch('paperminis.generate_minis.download_image') + def test_grayscale_image_cropped(self, mock_download): + img = np.full((400, 400), 255, np.uint8) + start = 100 + img[start:start + 200, start:start + 200] = 0 + mock_download.return_value = img + creature = Creature.objects.create( + name="Test", owner=self.user, + img_url="https://example.com/img.jpg", + size="M", position="bottom", show_name=False, + ) + + builder = MiniBuilder() + builder.load_settings(crop_whitespace=False) + mini_no_crop = builder.build_mini(creature) + + builder = MiniBuilder() + builder.load_settings(crop_whitespace=True) + mini_crop = builder.build_mini(creature) + + self.assertIsInstance(mini_no_crop, np.ndarray) + self.assertIsInstance(mini_crop, np.ndarray) + self.assertEqual(mini_crop.shape[1], 240) + dark_no_crop = int(np.sum(np.all(mini_no_crop == 0, axis=2))) + dark_crop = int(np.sum(np.all(mini_crop == 0, axis=2))) + self.assertGreater(dark_crop, dark_no_crop) + + def test_printsettings_crop_whitespace_default(self): + ps = PrintSettings.objects.create(user=self.user) + self.assertFalse(ps.crop_whitespace) + ps.crop_whitespace = True + ps.save() + ps.refresh_from_db() + self.assertTrue(ps.crop_whitespace) diff --git a/paperminis/views.py b/paperminis/views.py index 455375f..251b197 100644 --- a/paperminis/views.py +++ b/paperminis/views.py @@ -144,7 +144,8 @@ def quickbuild(request): minis.load_settings(paper_format=settings_form.cleaned_data["paper_format"], grid_size=int(settings_form.cleaned_data["grid_size"]), base_shape=settings_form.cleaned_data["base_shape"], - enumerate=settings_form.cleaned_data["enumerate"],) + enumerate=settings_form.cleaned_data["enumerate"], + crop_whitespace=settings_form.cleaned_data["crop_whitespace"]) minis.add_quick_creatures(creatures) try: @@ -301,6 +302,7 @@ def bestiary_print(request, pk): print_settings.force_name = new_settings.force_name print_settings.fixed_height = new_settings.fixed_height print_settings.darken = new_settings.darken + print_settings.crop_whitespace = new_settings.crop_whitespace print_settings.save() # load settings into the mini builder minis.load_settings(paper_format=print_settings.paper_format, @@ -309,7 +311,8 @@ def bestiary_print(request, pk): enumerate=print_settings.enumerate, force_name=print_settings.force_name, fixed_height=print_settings.fixed_height, - darken=print_settings.darken) + darken=print_settings.darken, + crop_whitespace=print_settings.crop_whitespace) # load creatures into the mini builder minis.add_bestiary(request.user, pk) # build minis diff --git a/requirements.txt b/requirements.txt index cfe44eb..1a777d2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ gunicorn==21.1.0 https://github.com/ssbothwell/greedypacker/archive/f7219917d7b84ad59ee4ed1ab8019cccf4ca3b83.zip requests==2.31.0 django-plausible==0.5.0 +numpy<2 diff --git a/templates/paperminis/bestiary_print.html b/templates/paperminis/bestiary_print.html index 4b1fd23..e2eba66 100644 --- a/templates/paperminis/bestiary_print.html +++ b/templates/paperminis/bestiary_print.html @@ -69,6 +69,11 @@

Print Bestiary

{% render_field form.fixed_height class+="form-control" %} +
+ {{ form.crop_whitespace.errors }} + + {% render_field form.crop_whitespace class+="form-control" %} +
{{ form.darken.errors }} diff --git a/templates/paperminis/quickbuild.html b/templates/paperminis/quickbuild.html index 7b2c9e8..0b821b8 100644 --- a/templates/paperminis/quickbuild.html +++ b/templates/paperminis/quickbuild.html @@ -223,6 +223,11 @@

Printing Settings

where you have more than one? {% render_field settings_form.enumerate class+="form-control" %}
+
+ {{ settings_form.crop_whitespace.errors }} + + {% render_field settings_form.crop_whitespace class+="form-control" %} +