File: compositor_file_output_tests.py

package info (click to toggle)
blender 5.0.1%2Bdfsg-1
  • links: PTS, VCS
  • area: main
  • in suites: experimental
  • size: 329,128 kB
  • sloc: cpp: 2,489,823; python: 349,859; ansic: 261,364; xml: 2,103; sh: 999; javascript: 317; makefile: 193
file content (250 lines) | stat: -rw-r--r-- 10,230 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: Apache-2.0

import sys
import os
from shutil import copyfile, rmtree
import argparse
import pathlib
import OpenImageIO as oiio
import unittest

import bpy

# Test utils are not accessible when run inside blender.
current_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(current_dir)
from modules.colored_print import print_message


class FileOutputTest(unittest.TestCase):
    """
    This class extends the compositor tests by supporting the File Output node.
    Unlike the `render_test` framework, this framework supports multiple output files per blend file.

    For consistency, all test cases for the file output node are part of a single ctest test case.
    To run such a test for CPU, run `ctest -R compositor_cpu_file_output --verbose`.
    To update a failing test, run `BLENDER_TEST_UPDATE=1 ctest -R compositor_cpu_file_output`
    """

    @classmethod
    def setUpClass(cls):
        cls.testdir = pathlib.Path(args.testdir)
        cls.outdir = pathlib.Path(args.outdir)
        cls.execution_device = "GPU" if args.gpu_backend else "CPU"
        cls.update = os.getenv("BLENDER_TEST_UPDATE") is not None

        # Images that look similar enough should pass the test
        cls.fail_threshold = 0.001

        comp_outdir = os.path.dirname(cls.outdir)
        if not os.path.exists(comp_outdir):
            os.mkdir(comp_outdir)

    def compare_images(self, ref_img_path, out_img_path, verbose=True):
        """
        Compare content and metadata of two images.
        """
        img_name = os.path.basename(ref_img_path)

        ref_input = oiio.ImageInput.open(ref_img_path)
        out_input = oiio.ImageInput.open(out_img_path)

        # We'll compare all subimages, so that every pass in multilayer, multiview
        # and multipart EXR files is checked.
        ref_subimages = ref_input.spec().get_int_attribute("oiio:subimages", 1)
        out_subimages = out_input.spec().get_int_attribute("oiio:subimages", 1)
        if ref_subimages != out_subimages:
            if verbose:
                print_message("Image subimages mismatch for '{:s}': {} -> {}".
                              format(img_name, ref_subimages, out_subimages),
                              'FAILURE', 'FAILED')
            return False

        ok = True

        for subimage in range(0, ref_subimages):
            ref_img = oiio.ImageBuf(ref_img_path, subimage, 0, oiio.ImageSpec())
            out_img = oiio.ImageBuf(out_img_path, subimage, 0, oiio.ImageSpec())

            # Compare image content
            comp = oiio.ImageBufAlgo.compare(ref_img, out_img, self.fail_threshold, 0)
            if comp.nfail != 0:
                if verbose:
                    print_message("Image content mismatch for '{:s}'".format(img_name),
                                  'FAILURE', 'FAILED')
                ok = False

            # Compare Metadata
            metadata_ignore = set(("Time", "File", "Date", "RenderTime", "Software"))

            ref_meta = ref_img.spec().extra_attribs
            out_meta = out_img.spec().extra_attribs

            for attrib in ref_meta:
                if attrib.name in metadata_ignore:
                    continue
                if attrib.name not in out_meta:
                    if verbose:
                        print_message(
                            "Image metadata mismatch: metadata '{:s}' does not exist in output image '{:s}'".format(
                                attrib.name, img_name), 'FAILURE', 'FAILED')
                    ok = False
                    continue
                if attrib.value != out_meta[attrib.name]:
                    if verbose:
                        print_message(
                            "Image metadata mismatch for metadata '{:s}' in image '{:s}'".format(attrib.name, img_name),
                            'FAILURE',
                            'FAILED')
                    ok = False

        return ok

    def update_tests(self, testdir, outdir):
        """
        Update tests by copying changed output images to the test directory.
        """
        print_message("Updating test {:s}...".format(os.path.basename(outdir)), 'SUCCESS', 'RUN')
        # The directory usually does not exist when a new test case (i.e. new blend file) is added,
        # So add the new reference test directory and copy all files from the output folder.
        if not os.path.exists(testdir):
            os.mkdir(testdir)
            for filename in os.listdir(outdir):
                copyfile(os.path.join(outdir, filename),
                         os.path.join(testdir, filename))
            return

        for filename in os.listdir(outdir):
            ref_img = os.path.join(testdir, filename)
            out_img = os.path.join(outdir, filename)
            if filename not in os.listdir(testdir):
                # A newly created image is found in the output directory,
                # so copy it to the reference test directory.
                copyfile(out_img, ref_img)
            else:
                if not self.compare_images(ref_img, out_img, verbose=False):
                    # An image in the output directory is found to be modified,
                    # so update the reference image by overwriting it with the output image.
                    copyfile(out_img, ref_img)

        # Delete reference images that have no corresponding output image.
        for filename in os.listdir(testdir):
            if filename not in os.listdir(outdir):
                pathlib.Path.unlink(os.path.join(testdir, filename))

    def compare_dirs(self, curr_testdir, curr_outdir):
        """
        Compare all images in both directories. Missing or too many output images will cause the test to fail.
        """
        ok = True
        ref_images = set()
        out_images = set()
        if not os.path.exists(curr_testdir):
            print_message("Test directory {:s} does not exist".format(curr_testdir), 'FAILURE', 'FAILED')
            if self.update:
                self.update_tests(curr_testdir, curr_outdir)
                return True
            else:
                return False

        for filename in os.listdir(curr_testdir):
            ref_images.add(filename)

        for filename in os.listdir(curr_outdir):
            out_images.add(filename)

        for img in out_images:
            if img not in ref_images:
                print_message("Output image '{:s}' has no corresponding test image".format(img),
                              'FAILURE', 'FAILED')
                ok = False

        for img in ref_images:
            if img not in out_images:
                print_message("Test image '{:s}' not found in output images".format(img), 'FAILURE', 'FAILED')
                ok = False
                continue

            if not self.compare_images(os.path.join(curr_testdir, img), os.path.join(curr_outdir, img), verbose=True):
                ok = False

        if not ok and self.update:
            self.update_tests(curr_testdir, curr_outdir)
            ok = True

        if ok:
            print_message("Passed", 'SUCCESS', 'PASSED')

        return ok

    def test_file_output_node(self):
        if not os.path.exists(self.testdir):
            print_message("Test directory '{:s}' does not exist.".format(self.testdir), 'FAILURE', 'FAILED')
            return False
        if not os.listdir(self.testdir):
            print_message("Test directory '{:s}' is empty.".format(self.testdir), 'FAILURE', 'FAILED')
            return False

        if not os.path.exists(self.outdir):
            os.mkdir(self.outdir)

        ok = True
        for filename in os.listdir(self.testdir):
            test_name, ext = os.path.splitext(filename)
            if ext != '.blend':
                continue
            curr_out_dir = os.path.join(self.outdir, test_name)
            curr_test_dir = os.path.join(self.testdir, test_name)
            blendfile = os.path.join(self.testdir, filename)

            # A test may fail because there are too many outputs.
            # So start with a fresh folder on every run to avoid false positives.
            if os.path.exists(curr_out_dir):
                rmtree(curr_out_dir)
            os.mkdir(curr_out_dir)

            print_message("Running test {:s}... ".format(os.path.basename(curr_out_dir)), 'SUCCESS', 'RUN')
            self.run_test_script(blendfile, curr_out_dir)

            if not self.compare_dirs(curr_test_dir, curr_out_dir):
                ok = False

        self.assertTrue(ok)

    def run_test_script(self, blendfile, curr_out_dir):
        def set_directory(node_tree, base_path):
            for node in node_tree.nodes:
                if node.type == 'OUTPUT_FILE':
                    node.directory = f'{curr_out_dir}/'
                    # Can't use empty filename for multilayer, as multiple such
                    # nodes will write to the same file name then.
                    if node.format.media_type != 'MULTI_LAYER_IMAGE':
                        node.file_name = ""
                elif node.type == 'GROUP' and node.node_tree:
                    set_directory(node.node_tree, base_path)

        bpy.ops.wm.open_mainfile(filepath=blendfile)
        # Set output directory for all existing file output nodes.
        set_directory(bpy.data.scenes[0].compositing_node_group, f'{curr_out_dir}/')
        bpy.data.scenes[0].render.compositor_device = f'{self.execution_device}'
        bpy.ops.render.render(animation=True, frame_start=1, frame_end=1)


if __name__ == "__main__":
    if '--' in sys.argv:
        argv = [sys.argv[0]] + sys.argv[sys.argv.index('--') + 1:]
    else:
        argv = sys.argv

    parser = argparse.ArgumentParser(
        description="Run test script for each blend file containing a File Output node in TESTDIR, "
        "comparing all render outputs with known outputs."
    )
    parser.add_argument("--testdir", required=True)
    parser.add_argument("--outdir", required=True)
    parser.add_argument("--gpu-backend", required=False)
    args, remaining = parser.parse_known_args(argv)

    unittest.main(argv=remaining)