Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,6 @@ venv
*pycache*
dndtools/settings_secret.py
db.sqlite3

# Local Django
static/
1 change: 1 addition & 0 deletions .python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.10
1 change: 1 addition & 0 deletions paperminis/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
60 changes: 59 additions & 1 deletion paperminis/generate_minis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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]),
Expand Down Expand Up @@ -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]

Expand Down
18 changes: 18 additions & 0 deletions paperminis/migrations/0023_alter_printsettings_grid_size.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
18 changes: 18 additions & 0 deletions paperminis/migrations/0024_printsettings_crop_whitespace.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions paperminis/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
167 changes: 166 additions & 1 deletion paperminis/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
7 changes: 5 additions & 2 deletions paperminis/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
5 changes: 5 additions & 0 deletions templates/paperminis/bestiary_print.html
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ <h4 class="card-title">Print Bestiary</h4>
<label for="{{ form.fixed_height.id_for_label }}">Use fixed height for minis of the same size category?</label>
{% render_field form.fixed_height class+="form-control" %}
</div>
<div class="form-group">
{{ form.crop_whitespace.errors }}
<label for="{{ form.crop_whitespace.id_for_label }}">Crop empty border to minimize unused space?</label>
{% render_field form.crop_whitespace class+="form-control" %}
</div>
<div class="form-group">
{{ form.darken.errors }}
<label for="{{ form.darken.id_for_label }}">How much darker should the backside of your mini be? The maximum gives you a black silhouette.</label>
Expand Down
5 changes: 5 additions & 0 deletions templates/paperminis/quickbuild.html
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,11 @@ <h4 class="card-title">Printing Settings</h4>
where you have more than one?</label>
{% render_field settings_form.enumerate class+="form-control" %}
</div>
<div class="form-group">
{{ settings_form.crop_whitespace.errors }}
<label for="{{ settings_form.crop_whitespace.id_for_label }}">Crop empty border to minimize unused space?</label>
{% render_field settings_form.crop_whitespace class+="form-control" %}
</div>
</div>
<input class="btn btn-success btn-fill btn-block mt-1 mb-2" type="submit" value="Generate my Minis!"/>
</div>
Expand Down