diff --git a/negpy/services/export/print.py b/negpy/services/export/print.py index 106b88103..a6983a9fa 100644 --- a/negpy/services/export/print.py +++ b/negpy/services/export/print.py @@ -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: @@ -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(":")) @@ -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: diff --git a/negpy/services/rendering/gpu_engine.py b/negpy/services/rendering/gpu_engine.py index b339e03de..a547c4c9b 100644 --- a/negpy/services/rendering/gpu_engine.py +++ b/negpy/services/rendering/gpu_engine.py @@ -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: diff --git a/tests/test_preview_layout_scale.py b/tests/test_preview_layout_scale.py index 395502c36..5e39d0911 100644 --- a/tests/test_preview_layout_scale.py +++ b/tests/test_preview_layout_scale.py @@ -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 @@ -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()