Skip to content
Merged
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
18 changes: 9 additions & 9 deletions negpy/services/export/print.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,11 @@ def _toned_paper_white(toning: ToningConfig) -> Tuple[float, float, float]:

@staticmethod
def fit_content(img_w: int, img_h: int, max_w: int, max_h: int) -> tuple[int, int]:
"""The largest image size inside max_w x max_h; one formula for every engine's layout."""
aspect = img_w / img_h
if aspect > max_w / max_h:
return max_w, int(max_w / aspect)
return int(max_h * aspect), max_h
"""The largest image size inside max_w x max_h; one formula for every engine's layout.
Integer math: a float aspect round trip gives 907.999... for a side that is exactly 908."""
if img_w * max_h > max_w * img_h:
return max_w, max_w * img_h // img_w
return max_h * img_w // img_h, max_h

@staticmethod
def effective_border_color(finish: FinishConfig, toning: ToningConfig) -> str:
Expand Down Expand Up @@ -145,9 +145,9 @@ def paper_dims_from_long_edge(long_edge_px: int, aspect_ratio_str: str, img_w: i
"""Paper dims in pixels given a paper long edge and aspect ratio."""
if aspect_ratio_str == AspectRatio.ORIGINAL:
if img_w >= img_h:
return long_edge_px, int(long_edge_px * (img_h / img_w))
return long_edge_px, long_edge_px * img_h // img_w
else:
return int(long_edge_px * (img_w / img_h)), long_edge_px
return long_edge_px * img_w // img_h, long_edge_px

try:
w_r, h_r = map(float, aspect_ratio_str.split(":"))
Expand Down Expand Up @@ -231,10 +231,10 @@ def apply_layout(
if export_settings.paper_aspect_ratio == AspectRatio.ORIGINAL:
if img_w >= img_h:
target_w = max(10, paper_long_px - 2 * border_px)
target_h = int(target_w / img_aspect)
target_h = target_w * img_h // img_w
else:
target_h = max(10, paper_long_px - border_y_px)
target_w = int(target_h * img_aspect)
target_w = target_h * img_w // img_h
paper_w = target_w + 2 * border_px
paper_h = target_h + border_y_px
else:
Expand Down
4 changes: 2 additions & 2 deletions negpy/services/rendering/gpu_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -2557,10 +2557,10 @@ def _calculate_layout_dims(
# Same formulas as PrintService.apply_layout, or the mat gains a 1 px line.
if cw >= ch:
content_w = max(10, paper_long_px - 2 * border_px)
content_h = max(1, int(content_w / (cw / ch)))
content_h = max(1, content_w * ch // cw)
else:
content_h = max(10, paper_long_px - border_y_px)
content_w = max(1, int(content_h * (cw / ch)))
content_w = max(1, content_h * cw // ch)
paper_w, paper_h = content_w + 2 * border_px, content_h + border_y_px
off_x, off_y = border_px, border_px
else:
Expand Down
55 changes: 54 additions & 1 deletion tests/test_preview_layout_scale.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
import unittest
from dataclasses import replace

from negpy.domain.models import WorkspaceConfig
import numpy as np

from negpy.domain.models import AspectRatio, ExportConfig, ExportResolutionMode, WorkspaceConfig
from negpy.infrastructure.gpu.device import GPUDevice
from negpy.services.export.print import PrintService
from negpy.services.rendering.gpu_engine import GPUEngine


Expand Down Expand Up @@ -43,6 +47,55 @@ def test_export_path_keeps_its_own_resolution(self):
_, _, cw, ch, _, _, _ = _dims(WorkspaceConfig(), 800, 533, None)
self.assertEqual((cw, ch), (800, 533))

def test_unscaled_content_keeps_every_row(self):
"""1355 / (1355 / 908) is 907.999... in float; the layout keeps the 908th row."""
_, ph, cw, ch, _, _, _ = _dims(WorkspaceConfig(), 1355, 908, 1355.0)
self.assertEqual((cw, ch, ph), (1355, 908, 908))

def test_unscaled_portrait_content_keeps_every_column(self):
pw, _, cw, ch, _, _, _ = _dims(WorkspaceConfig(), 908, 1355, 1355.0)
self.assertEqual((cw, ch, pw), (908, 1355, 908))


class TestLayoutIntegerAspect(unittest.TestCase):
"""Every layout formula scales a side by the image's own w:h without a float round trip."""

SIZES = [(1355, 908), (500, 390), (1792, 1216), (1353, 911)]

def test_fit_content_returns_the_image_at_its_own_size(self):
for w, h in self.SIZES:
self.assertEqual(PrintService.fit_content(w, h, w, h + 50), (w, h))
self.assertEqual(PrintService.fit_content(h, w, h + 50, w), (h, w))

def test_original_paper_matches_the_image(self):
for w, h in self.SIZES:
self.assertEqual(PrintService.paper_dims_from_long_edge(w, AspectRatio.ORIGINAL, w, h), (w, h))
self.assertEqual(PrintService.paper_dims_from_long_edge(w, AspectRatio.ORIGINAL, h, w), (h, w))

def test_apply_layout_keeps_an_unscaled_image_whole(self):
export = replace(ExportConfig(), export_resolution_mode=ExportResolutionMode.TARGET_PX, export_target_long_edge_px=1355)
out, rect = PrintService.apply_layout(np.zeros((908, 1355, 3), np.float32), export)
self.assertEqual(out.shape[:2], (908, 1355))
self.assertEqual(rect[2:], (1355, 908))


@unittest.skipUnless(GPUDevice.get().is_available, "GPU not available")
class TestPreviewSizeParity(unittest.TestCase):
def test_cpu_and_gpu_render_the_same_size(self):
from negpy.services.rendering.image_processor import ImageProcessor

processor = ImageProcessor()
if processor.engine_gpu is None:
self.skipTest("GPU engine not initialised")
img = np.random.default_rng(1).uniform(0.05, 0.9, (908, 1355, 3)).astype(np.float32)
shapes = []
for gpu in (False, True):
result, _ = processor.run_pipeline(img, WorkspaceConfig(), "row-gap", render_size_ref=1355.0, prefer_gpu=gpu)
arr = result.readback() if hasattr(result, "readback") else result
shapes.append(np.asarray(arr).shape[:2])
self.assertEqual(shapes[0], shapes[1])
self.assertEqual(shapes[1], (908, 1355))


if __name__ == "__main__":
unittest.main()
Loading