Skip to content

Commit 746e904

Browse files
authoredSep 14, 2021
Merge pull request #25 from Kwasniok/feature/advanced-renderer
adds meta data and filters
2 parents 5438b04 + 3377af5 commit 746e904

20 files changed

+1983
-9
lines changed
 

‎src/demo_0.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def make_scene(canvas_dimension: int) -> Scene:
141141
return scene
142142

143143

144-
def render(
144+
def render( # pylint: disable=R0913
145145
scene: Scene,
146146
geometry: Geometry,
147147
render_ray_depth: bool,

‎src/demo_1.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ def make_scene(canvas_dimension: int) -> Scene:
141141
return scene
142142

143143

144-
def render(
144+
def render( # pylint: disable=R0913
145145
scene: Scene,
146146
geometry: Geometry,
147147
render_ray_depth: bool,

‎src/demo_2.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def make_scene(canvas_dimension: int) -> Scene:
110110
return scene
111111

112112

113-
def render(
113+
def render( # pylint: disable=R0913
114114
scene: Scene,
115115
geometry: Geometry,
116116
render_ray_depth: bool,
@@ -124,7 +124,6 @@ def render(
124124
"""
125125

126126
for projection_mode in ProjectionMode:
127-
# for mode in (ImageRenderer.Mode.PERSPECTIVE,):
128127
print(f"rendering {projection_mode.name} projection ...")
129128
if render_ray_depth:
130129
image_renderer: ImageRenderer = ImageRayDepthRenderer(

‎src/demo_3.py

+210
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
1+
"""
2+
Demonstartes the image filter renderer with an example scene in cylindrical
3+
coordinates an various filters.
4+
"""
5+
6+
import os
7+
import math
8+
9+
from nerte.values.coordinates import Coordinates3D
10+
from nerte.values.domain import Domain1D
11+
from nerte.values.linalg import AbstractVector
12+
from nerte.values.face import Face
13+
from nerte.values.manifolds.cylindrical import (
14+
Plane as CarthesianPlaneInCylindric,
15+
)
16+
from nerte.world.object import Object
17+
from nerte.world.camera import Camera
18+
from nerte.world.scene import Scene
19+
from nerte.geometry.geometry import Geometry
20+
from nerte.geometry.cylindircal_swirl_geometry import (
21+
SwirlCylindricRungeKuttaGeometry,
22+
)
23+
from nerte.render.projection import ProjectionMode
24+
from nerte.render.image_filter_renderer import (
25+
ImageFilterRenderer,
26+
Filter,
27+
HitFilter,
28+
)
29+
from nerte.render.ray_depth_filter import RayDepthFilter
30+
from nerte.render.meta_info_filter import MetaInfoFilter
31+
from nerte.util.random_color_generator import RandomColorGenerator
32+
33+
# pseudo-random color generator
34+
COLOR = RandomColorGenerator()
35+
36+
37+
def make_camera(canvas_dimension: int) -> Camera:
38+
"""Creates a camera with preset values."""
39+
40+
location = Coordinates3D((2.0, 0.0, 2.0))
41+
manifold = CarthesianPlaneInCylindric(
42+
b0=AbstractVector((0.0, -1.0, 0.0)),
43+
b1=AbstractVector((-0.4, 0.0, 0.4)),
44+
x0_domain=Domain1D(-1.0, +1.0),
45+
x1_domain=Domain1D(-1.0, +1.0),
46+
offset=AbstractVector((1.5, 0.0, 1.5)),
47+
)
48+
camera = Camera(
49+
location=location,
50+
detector_manifold=manifold,
51+
canvas_dimensions=(canvas_dimension, canvas_dimension),
52+
)
53+
return camera
54+
55+
56+
def add_cylinder(scene: Scene, radius: float, height: float) -> None:
57+
"""Adds a cylinder at the center of the scene."""
58+
59+
# cylinder
60+
# top 1
61+
point0 = Coordinates3D((0.0, -math.pi, +height))
62+
point1 = Coordinates3D((radius, -math.pi, +height))
63+
point2 = Coordinates3D((radius, math.pi, +height))
64+
tri = Face(point0, point1, point2)
65+
obj = Object(color=next(COLOR)) # pseudo-random color
66+
obj.add_face(tri)
67+
scene.add_object(obj)
68+
# top 2
69+
point0 = Coordinates3D((0.0, -math.pi, +height))
70+
point1 = Coordinates3D((0.0, +math.pi, +height))
71+
point2 = Coordinates3D((radius, +math.pi, +height))
72+
tri = Face(point0, point1, point2)
73+
obj = Object(color=next(COLOR)) # pseudo-random color
74+
obj.add_face(tri)
75+
scene.add_object(obj)
76+
# side 1
77+
point0 = Coordinates3D((radius, -math.pi, -height))
78+
point1 = Coordinates3D((radius, -math.pi, +height))
79+
point2 = Coordinates3D((radius, +math.pi, +height))
80+
tri = Face(point0, point1, point2)
81+
obj = Object(color=next(COLOR)) # pseudo-random color
82+
obj.add_face(tri)
83+
scene.add_object(obj)
84+
# side 2
85+
point0 = Coordinates3D((radius, -math.pi, -height))
86+
point1 = Coordinates3D((radius, +math.pi, -height))
87+
point2 = Coordinates3D((radius, +math.pi, +height))
88+
tri = Face(point0, point1, point2)
89+
obj = Object(color=next(COLOR)) # pseudo-random color
90+
obj.add_face(tri)
91+
scene.add_object(obj)
92+
# bottom 1
93+
point0 = Coordinates3D((0.0, -math.pi, -height))
94+
point1 = Coordinates3D((radius, -math.pi, -height))
95+
point2 = Coordinates3D((radius, +math.pi, -height))
96+
tri = Face(point0, point1, point2)
97+
obj = Object(color=next(COLOR)) # pseudo-random color
98+
obj.add_face(tri)
99+
scene.add_object(obj)
100+
# bottom 2
101+
point0 = Coordinates3D((0.0, -math.pi, -height))
102+
point1 = Coordinates3D((0.0, +math.pi, -height))
103+
point2 = Coordinates3D((radius, +math.pi, -height))
104+
tri = Face(point0, point1, point2)
105+
obj = Object(color=next(COLOR)) # pseudo-random color
106+
obj.add_face(tri)
107+
scene.add_object(obj)
108+
109+
110+
def make_scene(canvas_dimension: int) -> Scene:
111+
"""
112+
Creates a scene with a camera pointing towards an object.
113+
"""
114+
115+
camera = make_camera(canvas_dimension=canvas_dimension)
116+
scene = Scene(camera=camera)
117+
add_cylinder(scene, radius=1.0, height=1.0)
118+
119+
return scene
120+
121+
122+
def render( # pylint: disable=R0913
123+
scene: Scene,
124+
geometry: Geometry,
125+
filtr: Filter,
126+
output_path: str,
127+
file_prefix: str,
128+
show: bool,
129+
) -> None:
130+
"""
131+
Renders a preset scene with non-euclidean geometry in orthographic and
132+
perspective projection.
133+
"""
134+
135+
projection_mode = ProjectionMode.PERSPECTIVE
136+
print(
137+
f"rendering {projection_mode.name} projection for filter type"
138+
f" '{type(filtr).__name__}' ..."
139+
)
140+
renderer = ImageFilterRenderer(
141+
projection_mode=projection_mode,
142+
filtr=filtr,
143+
print_warings=False,
144+
)
145+
renderer.render(scene=scene, geometry=geometry)
146+
renderer.apply_filter()
147+
os.makedirs(output_path, exist_ok=True)
148+
image = renderer.last_image()
149+
if image is not None:
150+
image.save(f"{output_path}/{file_prefix}_{projection_mode.name}.png")
151+
if show:
152+
image.show()
153+
154+
155+
def main() -> None:
156+
"""Creates and renders the demo scene."""
157+
158+
# NOTE: Increase the canvas dimension to improve the image quality.
159+
# This will also increase rendering time!
160+
scene = make_scene(canvas_dimension=100)
161+
max_steps = 25
162+
geo = SwirlCylindricRungeKuttaGeometry(
163+
max_ray_depth=math.inf,
164+
step_size=0.2,
165+
max_steps=max_steps,
166+
swirl_strength=0.25,
167+
)
168+
169+
output_path = "../images"
170+
file_prefix = "demo3"
171+
show = True # disable if images cannot be displayed
172+
173+
# hit filter
174+
filtr: Filter = HitFilter()
175+
render(
176+
scene=scene,
177+
geometry=geo,
178+
filtr=filtr,
179+
output_path=output_path,
180+
file_prefix=file_prefix + "_hit_filter",
181+
show=show,
182+
)
183+
184+
# ray depth filter
185+
filtr = RayDepthFilter()
186+
render(
187+
scene=scene,
188+
geometry=geo,
189+
filtr=filtr,
190+
output_path=output_path,
191+
file_prefix=file_prefix + "_ray_depth_filter",
192+
show=show,
193+
)
194+
195+
# meta info filter
196+
filtr = MetaInfoFilter(
197+
meta_data_key="steps", min_value=0, max_value=max_steps
198+
)
199+
render(
200+
scene=scene,
201+
geometry=geo,
202+
filtr=filtr,
203+
output_path=output_path,
204+
file_prefix=file_prefix + "_meta_info_steps_filter",
205+
show=show,
206+
)
207+
208+
209+
if __name__ == "__main__":
210+
main()

‎src/nerte/geometry/runge_kutta_geometry.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
add_ray_segment_delta,
2222
)
2323
from nerte.values.intersection_info import IntersectionInfo, IntersectionInfos
24+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
2425
from nerte.geometry.geometry import Geometry, intersection_ray_depth
2526

2627

@@ -165,7 +166,9 @@ def intersection_info(self, face: Face) -> IntersectionInfo:
165166
)
166167
if relative_segment_depth < math.inf:
167168
total_ray_depth += relative_segment_depth * segment_length
168-
return IntersectionInfo(ray_depth=total_ray_depth)
169+
return ExtendedIntersectionInfo(
170+
ray_depth=total_ray_depth, meta_data={"steps": step + 1}
171+
)
169172

170173
step += 1
171174
total_ray_depth += segment_length

‎src/nerte/geometry/runge_kutta_geometry_unittest.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import unittest
99

10-
from typing import Callable, Type, TypeVar
10+
from typing import Callable, Type, TypeVar, cast
1111

1212
from itertools import permutations
1313
import math
@@ -20,6 +20,7 @@
2020
from nerte.values.ray_segment_delta import RaySegmentDelta
2121
from nerte.values.face import Face
2222
from nerte.values.intersection_info import IntersectionInfo
23+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
2324
from nerte.values.util.convert import coordinates_as_vector
2425
from nerte.geometry.runge_kutta_geometry import RungeKuttaGeometry
2526

@@ -387,6 +388,51 @@ def test_runge_kutta_geometry_intersects(self) -> None:
387388
)
388389

389390

391+
class RungeKuttaGeometryRayIntersectsMetaDataTest(GeometryTestCase):
392+
def setUp(self) -> None:
393+
p1 = Coordinates3D((1.0, 0.0, 0.0))
394+
p2 = Coordinates3D((0.0, 1.0, 0.0))
395+
p3 = Coordinates3D((0.0, 0.0, 1.0))
396+
self.face = Face(p1, p2, p3)
397+
# geometry (carthesian & euclidean)
398+
DummyRungeKuttaGeometryGeo = _make_dummy_runge_kutta_geometry()
399+
geos = (
400+
DummyRungeKuttaGeometryGeo(
401+
max_ray_depth=1000.0,
402+
step_size=1, # direct hit
403+
max_steps=100,
404+
),
405+
DummyRungeKuttaGeometryGeo(
406+
max_ray_depth=1000.0,
407+
step_size=0.1, # 6 steps until hit (1/sqrt(3) ~ 0.577...)
408+
max_steps=100,
409+
),
410+
)
411+
self.rays = tuple(
412+
geo.ray_from_tangent(
413+
start=Coordinates3D((0.0, 0.0, 0.0)),
414+
direction=AbstractVector((1.0, 1.0, 1.0)),
415+
)
416+
for geo in geos
417+
)
418+
self.steps = (1, 6)
419+
420+
def test_runge_kutta_geometry_intersects_meta_data(self) -> None:
421+
"""
422+
Tests if ray's meta data.
423+
"""
424+
for ray, steps in zip(self.rays, self.steps):
425+
info = ray.intersection_info(self.face)
426+
self.assertIsInstance(info, ExtendedIntersectionInfo)
427+
if isinstance(info, ExtendedIntersectionInfo):
428+
info = cast(ExtendedIntersectionInfo, info)
429+
meta_data = info.meta_data
430+
self.assertIsNotNone(meta_data)
431+
if meta_data is not None:
432+
self.assertTrue("steps" in meta_data)
433+
self.assertAlmostEqual(meta_data["steps"], steps)
434+
435+
390436
class DummyRungeKuttaGeometryRayFromTest(GeometryTestCase):
391437
def setUp(self) -> None:
392438
DummyRungeKuttaGeometryGeo = _make_dummy_runge_kutta_geometry()

‎src/nerte/geometry/segmented_ray_geometry.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from nerte.values.linalg import AbstractVector
1515
from nerte.values.ray_segment import RaySegment
1616
from nerte.values.intersection_info import IntersectionInfo, IntersectionInfos
17+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
1718
from nerte.geometry.geometry import Geometry, intersection_ray_depth
1819

1920

@@ -113,7 +114,9 @@ def intersection_info(self, face: Face) -> IntersectionInfo:
113114
total_ray_depth = (
114115
step + relative_segment_ray_depth
115116
) * geometry.ray_segment_length()
116-
return IntersectionInfo(ray_depth=total_ray_depth)
117+
return ExtendedIntersectionInfo(
118+
ray_depth=total_ray_depth, meta_data={"steps": step + 1}
119+
)
117120

118121
return IntersectionInfos.NO_INTERSECTION
119122

‎src/nerte/geometry/segmented_ray_geometry_unittest.py

+47-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import unittest
99

10-
from typing import Type, Optional
10+
from typing import Type, Optional, cast
1111

1212
import math
1313

@@ -18,6 +18,7 @@
1818
from nerte.values.ray_segment import RaySegment
1919
from nerte.values.face import Face
2020
from nerte.values.intersection_info import IntersectionInfo
21+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
2122
from nerte.values.util.convert import coordinates_as_vector
2223
from nerte.geometry.segmented_ray_geometry import SegmentedRayGeometry
2324

@@ -276,6 +277,51 @@ def test_ray_leaves_manifold_immediately(self) -> None:
276277
)
277278

278279

280+
class SegmentedRayGeometryRayIntersectsMetaDataTest(GeometryTestCase):
281+
def setUp(self) -> None:
282+
p1 = Coordinates3D((1.0, 0.0, 0.0))
283+
p2 = Coordinates3D((0.0, 1.0, 0.0))
284+
p3 = Coordinates3D((0.0, 0.0, 1.0))
285+
self.face = Face(p1, p2, p3)
286+
# geometry (carthesian & euclidean)
287+
DummySegmentedRayGeometry = _dummy_segmented_ray_geometry_class()
288+
geos = (
289+
DummySegmentedRayGeometry(
290+
max_ray_depth=10.0,
291+
max_steps=10,
292+
# step size = 1 -> direct hit
293+
),
294+
DummySegmentedRayGeometry(
295+
max_ray_depth=10.0,
296+
max_steps=100,
297+
# step size = 0.1 -> 6 steps until hit (1/sqrt(3) ~ 0.577...)
298+
),
299+
)
300+
self.rays = tuple(
301+
geo.ray_from_tangent(
302+
start=Coordinates3D((0.0, 0.0, 0.0)),
303+
direction=AbstractVector((1.0, 1.0, 1.0)),
304+
)
305+
for geo in geos
306+
)
307+
self.steps = (1, 6)
308+
309+
def test_runge_kutta_geometry_intersects_meta_data(self) -> None:
310+
"""
311+
Tests if ray's meta data.
312+
"""
313+
for ray, steps in zip(self.rays, self.steps):
314+
info = ray.intersection_info(self.face)
315+
self.assertIsInstance(info, ExtendedIntersectionInfo)
316+
if isinstance(info, ExtendedIntersectionInfo):
317+
info = cast(ExtendedIntersectionInfo, info)
318+
meta_data = info.meta_data
319+
self.assertIsNotNone(meta_data)
320+
if meta_data is not None:
321+
self.assertTrue("steps" in meta_data)
322+
self.assertAlmostEqual(meta_data["steps"], steps)
323+
324+
279325
class SegmentedRayGeometryRayFromTest(GeometryTestCase):
280326
def setUp(self) -> None:
281327
DummySegmentedRayGeometry = _dummy_segmented_ray_geometry_class()
+284
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"""
2+
Module for rendering a scene with respect to a geometry.
3+
The data is rendered first and filters may be applied afterwards.
4+
"""
5+
6+
from typing import Optional, NewType
7+
8+
from abc import ABC, abstractmethod
9+
10+
import numpy as np
11+
from PIL import Image
12+
13+
from nerte.values.color import Color, Colors
14+
from nerte.values.intersection_info import IntersectionInfo, IntersectionInfos
15+
from nerte.world.camera import Camera
16+
from nerte.world.object import Object
17+
from nerte.world.scene import Scene
18+
from nerte.geometry.geometry import Geometry
19+
from nerte.render.image_renderer import ImageRenderer
20+
from nerte.render.projection import ProjectionMode
21+
22+
# TODO: provide proper container type
23+
IntersectionInfoMatrix = NewType(
24+
"IntersectionInfoMatrix", list[list[IntersectionInfo]]
25+
)
26+
27+
28+
def color_for_normalized_value(value: float) -> Color:
29+
"""
30+
Returns color assosiated with a value from 0.0 to 1.0 value.
31+
"""
32+
if not 0.0 <= value <= 1.0:
33+
raise ValueError(
34+
f"Cannot obtain color from normalized value {value}."
35+
f" Value must be between 0.0 and 1.0 inf or nan."
36+
)
37+
level = int(value * 255)
38+
return Color(level, level, level)
39+
40+
41+
def color_miss_reason(miss_reason: IntersectionInfo.MissReason) -> Color:
42+
"""
43+
Returns a color for a pixel to denote a ray failing to hit a surface.
44+
"""
45+
if miss_reason is IntersectionInfo.MissReason.UNINIALIZED:
46+
return Color(0, 0, 0)
47+
if miss_reason is IntersectionInfo.MissReason.NO_INTERSECTION:
48+
return Color(0, 0, 255)
49+
if miss_reason is IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD:
50+
return Color(0, 255, 0)
51+
if (
52+
miss_reason
53+
is IntersectionInfo.MissReason.RAY_INITIALIZED_OUTSIDE_MANIFOLD
54+
):
55+
return Color(255, 255, 0)
56+
raise NotImplementedError(
57+
f"Cannot pick color for miss reason {miss_reason}."
58+
f"No color was implemented."
59+
)
60+
61+
62+
class Filter(ABC):
63+
# pylint: disable=R0903
64+
"""
65+
Interface for filters which convert the raw intersctio information of a
66+
ray into a color
67+
."""
68+
69+
@abstractmethod
70+
def apply(
71+
self,
72+
info_matrix: IntersectionInfoMatrix,
73+
) -> Image:
74+
"""
75+
Returns color for pixel based.
76+
77+
WARNING: Results are only valid if analyze was run first.
78+
"""
79+
# pylint: disable=W0107
80+
pass
81+
82+
83+
class HitFilter(Filter):
84+
"""
85+
False color filter for displaying rays based on whether they hit a surface
86+
or missed it.
87+
88+
The details about the miss are encoded into (unique) colors.
89+
"""
90+
91+
def color_hit(self) -> Color:
92+
"""Returns a color for a pixel to denote a ray hitting a surface."""
93+
# pylint: disable=R0201
94+
return Color(128, 128, 128)
95+
96+
def color_for_info(
97+
self,
98+
info: IntersectionInfo,
99+
) -> Color:
100+
"""Returns color associated with the intersection info."""
101+
102+
if info.hits():
103+
return self.color_hit()
104+
miss_reason = info.miss_reason()
105+
if miss_reason is None:
106+
raise RuntimeError(
107+
f"Cannot pick color for intersectio info {info}."
108+
" No miss reason specified despite ray is missing."
109+
)
110+
return color_miss_reason(miss_reason)
111+
112+
def apply(self, info_matrix: IntersectionInfoMatrix) -> Image:
113+
if len(info_matrix) == 0 or len(info_matrix[0]) == 0:
114+
raise ValueError(
115+
"Cannot apply hit filter. Intersection info matrix is empty."
116+
)
117+
width = len(info_matrix)
118+
height = len(info_matrix[0])
119+
120+
# initialize image with pink background
121+
image = Image.new(
122+
mode="RGB", size=(width, height), color=Colors.BLACK.rgb
123+
)
124+
125+
# paint-in pixels
126+
for pixel_x in range(width):
127+
for pixel_y in range(height):
128+
pixel_location = (pixel_x, pixel_y)
129+
info = info_matrix[pixel_x][pixel_y]
130+
pixel_color = self.color_for_info(info)
131+
image.putpixel(pixel_location, pixel_color.rgb)
132+
133+
return image
134+
135+
136+
class ImageFilterRenderer(ImageRenderer):
137+
"""
138+
Renderer which renders the data first and allows to apply filters after.
139+
140+
This workflow costs more resources during rendering but also allows for
141+
more experimentation.
142+
"""
143+
144+
def __init__(
145+
self,
146+
projection_mode: ProjectionMode,
147+
filtr: Filter,
148+
print_warings: bool = True,
149+
auto_apply_filter: bool = True,
150+
):
151+
152+
ImageRenderer.__init__(
153+
self, projection_mode, print_warings=print_warings
154+
)
155+
156+
self._filter = filtr
157+
self.auto_apply_filter = auto_apply_filter
158+
self._last_info_matrix: Optional[IntersectionInfoMatrix] = None
159+
160+
def has_render_data(self) -> bool:
161+
"""Returns True, iff render was called previously."""
162+
163+
return self._last_info_matrix is not None
164+
165+
def filter(self) -> Filter:
166+
"""Returns the filter currently used."""
167+
return self._filter
168+
169+
def change_filter(self, filtr: Filter) -> None:
170+
"""
171+
Changes the filter to a new one.
172+
173+
Note: This may cause the filter to be applied automatically if
174+
auto_apply_filter is enabled. Otherwise apply_filter must be
175+
called manually to apply the filter. Results may be outdated
176+
until then.
177+
"""
178+
self._filter = filtr
179+
if self.auto_apply_filter and self.has_render_data():
180+
self.apply_filter()
181+
182+
def render_pixel_intersection_info(
183+
self,
184+
camera: Camera,
185+
geometry: Geometry,
186+
objects: list[Object],
187+
pixel_location: tuple[int, int],
188+
) -> IntersectionInfo:
189+
"""
190+
Returns the intersection info of the ray cast for the pixel.
191+
"""
192+
193+
ray = self.ray_for_pixel(camera, geometry, pixel_location)
194+
if ray is None:
195+
return IntersectionInfos.RAY_INITIALIZED_OUTSIDE_MANIFOLD
196+
197+
# detect intersections with objects
198+
current_depth = np.inf
199+
current_info = IntersectionInfos.NO_INTERSECTION
200+
for obj in objects:
201+
for face in obj.faces():
202+
intersection_info = ray.intersection_info(face)
203+
if intersection_info.hits():
204+
# update intersection info to closest
205+
if intersection_info.ray_depth() < current_depth:
206+
current_depth = intersection_info.ray_depth()
207+
current_info = intersection_info
208+
else:
209+
# update intersection info to ray left manifold
210+
# if no face was intersected yet and ray left manifold
211+
if (
212+
current_info is IntersectionInfos.NO_INTERSECTION
213+
and intersection_info.miss_reason()
214+
is IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD
215+
):
216+
current_info = IntersectionInfos.RAY_LEFT_MANIFOLD
217+
return current_info
218+
219+
def render_intersection_info(
220+
self, scene: Scene, geometry: Geometry
221+
) -> IntersectionInfoMatrix:
222+
"""
223+
Returns matrix with intersection infos per pixel.
224+
"""
225+
226+
width, height = scene.camera.canvas_dimensions
227+
# initialize background with nan
228+
info_matrix: IntersectionInfoMatrix = IntersectionInfoMatrix(
229+
[
230+
[IntersectionInfos.UNINIALIZED for _ in range(height)]
231+
for _ in range(width)
232+
]
233+
)
234+
# obtain pixel ray info
235+
for pixel_x in range(width):
236+
for pixel_y in range(height):
237+
pixel_location = (pixel_x, pixel_y)
238+
pixel_info = self.render_pixel_intersection_info(
239+
scene.camera,
240+
geometry,
241+
scene.objects(),
242+
pixel_location,
243+
)
244+
info_matrix[pixel_x][pixel_y] = pixel_info
245+
return info_matrix
246+
247+
def render(self, scene: Scene, geometry: Geometry) -> None:
248+
"""
249+
Renders ray intersection information.
250+
251+
First stage in processing. Apply a filter next.
252+
253+
Note: Filter may be applied automatically if auto_apply_filter is True.
254+
Else apply_filter must be called manually.
255+
"""
256+
# obtain ray depth information and normalize it
257+
info_matrix = self.render_intersection_info(
258+
scene=scene, geometry=geometry
259+
)
260+
self._last_info_matrix = info_matrix
261+
262+
if self.auto_apply_filter:
263+
self.apply_filter()
264+
265+
def apply_filter(self) -> None:
266+
"""
267+
Convert intersection information into an image.
268+
269+
Second and last stage in processing. Must have rendererd first.
270+
271+
Note: This method may be called automatically depending on
272+
auto_apply_filter. If not apply_filter must be called manually.
273+
"""
274+
if self._last_info_matrix is None:
275+
print(
276+
"WARNING: Cannot apply filter without rendering first."
277+
" No intersection info matrix found."
278+
)
279+
return
280+
self._last_image = self._filter.apply(self._last_info_matrix)
281+
282+
def last_image(self) -> Optional[Image.Image]:
283+
"""Returns the last image rendered iff it exists or else None."""
284+
return self._last_image

‎src/nerte/render/image_filter_renderer_unittest.py

+587
Large diffs are not rendered by default.

‎src/nerte/render/image_ray_depth_renderer_unittest.py

-1
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ def setUp(self) -> None:
3131
self.invalid_ray_depths = (math.inf, math.nan, -1e-8, -1.0)
3232

3333
def test_image_ray_Depth_renderer_costructor(self) -> None:
34-
# pylint: disable=R0201
3534
"""Tests constructor."""
3635

3736
# preconditions

‎src/nerte/render/meta_info_filter.py

+104
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""
2+
Module for the ray meta info filter - a false-color filter to display the raw ray meta data.
3+
"""
4+
5+
from typing import cast
6+
7+
import math
8+
from PIL import Image
9+
10+
from nerte.values.color import Color, Colors
11+
from nerte.values.intersection_info import IntersectionInfo
12+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
13+
from nerte.render.image_filter_renderer import (
14+
IntersectionInfoMatrix,
15+
Filter,
16+
color_for_normalized_value,
17+
)
18+
19+
20+
def _clip(value: float) -> float:
21+
"""
22+
Returns value clipped to 0.0 ... 1.0.
23+
24+
Note: Value must be finite.
25+
"""
26+
if value < 0.0:
27+
return 0.0
28+
if value > 1.0:
29+
return 1.0
30+
return value
31+
32+
33+
class MetaInfoFilter(Filter):
34+
"""
35+
False-color filter for displaying meta info of rays.
36+
37+
Note: Providing meta info is optional and only one meta info attribute
38+
can be filtered for at a time.
39+
"""
40+
41+
def __init__(
42+
self,
43+
meta_data_key: str,
44+
min_value: float,
45+
max_value: float,
46+
):
47+
if not -math.inf < min_value < math.inf:
48+
raise ValueError(
49+
f"Cannot create meta info filter with min_value={min_value}."
50+
f" Value must be finite."
51+
)
52+
if not -math.inf < max_value < math.inf:
53+
raise ValueError(
54+
f"Cannot create meta info filter with max_value={max_value}."
55+
f" Value must be finite."
56+
)
57+
if min_value == max_value:
58+
raise ValueError(
59+
f"Cannot create meta info filter with min_value={min_value} and"
60+
f" max_value={max_value}. Values must be different."
61+
)
62+
self.meta_data_key = meta_data_key
63+
self.min_value = min_value
64+
self.max_value = max_value
65+
self.color_no_meta_data = Color(0, 255, 255)
66+
67+
def color_for_info(self, info: IntersectionInfo) -> Color:
68+
"""Returns color for intersection info."""
69+
70+
if not isinstance(info, ExtendedIntersectionInfo):
71+
return self.color_no_meta_data
72+
info = cast(ExtendedIntersectionInfo, info)
73+
74+
if info.meta_data is not None:
75+
if self.meta_data_key in info.meta_data:
76+
value = info.meta_data[self.meta_data_key] - self.min_value
77+
value /= self.max_value - self.min_value
78+
value = _clip(value)
79+
return color_for_normalized_value(value)
80+
return self.color_no_meta_data
81+
82+
def apply(self, info_matrix: IntersectionInfoMatrix) -> Image:
83+
if len(info_matrix) == 0 or len(info_matrix[0]) == 0:
84+
raise ValueError(
85+
"Cannot apply meta data filter. Intersection info matrix is"
86+
" empty."
87+
)
88+
width = len(info_matrix)
89+
height = len(info_matrix[0])
90+
91+
# initialize image with pink background
92+
image = Image.new(
93+
mode="RGB", size=(width, height), color=Colors.BLACK.rgb
94+
)
95+
96+
# paint-in pixels
97+
for pixel_x in range(width):
98+
for pixel_y in range(height):
99+
pixel_location = (pixel_x, pixel_y)
100+
info = info_matrix[pixel_x][pixel_y]
101+
pixel_color = self.color_for_info(info)
102+
image.putpixel(pixel_location, pixel_color.rgb)
103+
104+
return image
+141
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
# pylint: disable=R0801
2+
# pylint: disable=C0103
3+
# pylint: disable=C0114
4+
# pylint: disable=C0115
5+
# pylint: disable=C0144
6+
7+
import unittest
8+
9+
import math
10+
11+
from nerte.values.color import Color
12+
from nerte.values.intersection_info import IntersectionInfo
13+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
14+
from nerte.render.image_filter_renderer import IntersectionInfoMatrix
15+
from nerte.render.meta_info_filter import MetaInfoFilter
16+
17+
18+
class MetaInfoFilterConstructorTest(unittest.TestCase):
19+
def setUp(self) -> None:
20+
self.meta_data_keys = ("", "a", "B")
21+
self.valid_values = (-1.0, 0.0, 1.0)
22+
self.invalid_values = (math.inf, math.nan, -math.inf)
23+
24+
def test_constructor(self) -> None:
25+
"""Tests the constructor."""
26+
27+
for key in self.meta_data_keys:
28+
for min_val in self.valid_values:
29+
for max_val in self.valid_values:
30+
if min_val == max_val:
31+
with self.assertRaises(ValueError):
32+
MetaInfoFilter(
33+
meta_data_key=key,
34+
min_value=min_val,
35+
max_value=max_val,
36+
)
37+
else:
38+
MetaInfoFilter(
39+
meta_data_key=key,
40+
min_value=min_val,
41+
max_value=max_val,
42+
)
43+
# invalid
44+
for key in self.meta_data_keys:
45+
for min_val in self.invalid_values:
46+
for max_val in self.valid_values:
47+
with self.assertRaises(ValueError):
48+
MetaInfoFilter(
49+
meta_data_key=key,
50+
min_value=min_val,
51+
max_value=max_val,
52+
)
53+
for min_val in self.valid_values:
54+
for max_val in self.invalid_values:
55+
with self.assertRaises(ValueError):
56+
MetaInfoFilter(
57+
meta_data_key=key,
58+
min_value=min_val,
59+
max_value=max_val,
60+
)
61+
62+
63+
class MetaInfoFilterProperties(unittest.TestCase):
64+
def setUp(self) -> None:
65+
self.key = "key"
66+
self.min_val = 0.0
67+
self.max_val = 1.0
68+
self.filter = MetaInfoFilter(
69+
meta_data_key=self.key,
70+
min_value=self.min_val,
71+
max_value=self.max_val,
72+
)
73+
74+
def test_properties(self) -> None:
75+
"""Tests properties."""
76+
self.assertIs(self.filter.meta_data_key, self.key)
77+
self.assertAlmostEqual(self.filter.min_value, self.min_val)
78+
self.assertAlmostEqual(self.filter.max_value, self.max_val)
79+
self.assertTupleEqual(self.filter.color_no_meta_data.rgb, (0, 255, 255))
80+
81+
82+
class MetaInfoFilterColorForInfoTest(unittest.TestCase):
83+
def setUp(self) -> None:
84+
self.infos = (
85+
IntersectionInfo(ray_depth=0.3),
86+
ExtendedIntersectionInfo(ray_depth=0.3),
87+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={}),
88+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 0.0}),
89+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 1.0}),
90+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 1.5}),
91+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 2.0}),
92+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 3.0}),
93+
)
94+
self.filter = MetaInfoFilter(
95+
meta_data_key="key", min_value=1.0, max_value=2.0
96+
)
97+
self.colors = (
98+
self.filter.color_no_meta_data,
99+
self.filter.color_no_meta_data,
100+
self.filter.color_no_meta_data,
101+
Color(0, 0, 0),
102+
Color(0, 0, 0),
103+
Color(127, 127, 127),
104+
Color(255, 255, 255),
105+
Color(255, 255, 255),
106+
)
107+
108+
def test_color_for_info(self) -> None:
109+
"""Tests the color for an intersection information."""
110+
for info, col in zip(self.infos, self.colors):
111+
c = self.filter.color_for_info(info)
112+
self.assertTupleEqual(c.rgb, col.rgb)
113+
114+
115+
class MetaInfoFilterApplyTest(unittest.TestCase):
116+
def setUp(self) -> None:
117+
self.infos = (
118+
IntersectionInfo(ray_depth=0.3),
119+
ExtendedIntersectionInfo(ray_depth=0.3),
120+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={}),
121+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 0.0}),
122+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 0.5}),
123+
ExtendedIntersectionInfo(ray_depth=0.3, meta_data={"key": 1.0}),
124+
)
125+
self.info_matrix = IntersectionInfoMatrix([list(self.infos)])
126+
self.filter = MetaInfoFilter(
127+
meta_data_key="key", min_value=0.0, max_value=1.0
128+
)
129+
130+
def test_apply(self) -> None:
131+
"""Test filter application."""
132+
image = self.filter.apply(info_matrix=self.info_matrix)
133+
for pixel_y, info in enumerate(self.infos):
134+
self.assertTupleEqual(
135+
image.getpixel((0, pixel_y)),
136+
self.filter.color_for_info(info).rgb,
137+
)
138+
139+
140+
if __name__ == "__main__":
141+
unittest.main()

‎src/nerte/render/ray_depth_filter.py

+186
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""
2+
Module for the ray depth filter - a false-color filter to display the raw ray depth data.
3+
"""
4+
5+
6+
from typing import Optional
7+
8+
import math
9+
import numpy as np
10+
from PIL import Image
11+
12+
from nerte.values.color import Color, Colors
13+
from nerte.values.intersection_info import IntersectionInfo
14+
from nerte.render.image_filter_renderer import (
15+
IntersectionInfoMatrix,
16+
Filter,
17+
color_for_normalized_value,
18+
color_miss_reason,
19+
)
20+
21+
22+
def _is_finite(mat: np.ndarray) -> np.ndarray:
23+
"""Return boolean matrix where True indicates a finite value."""
24+
return np.logical_and(
25+
np.logical_not(np.isnan(mat)), np.logical_not(np.isinf(mat))
26+
)
27+
28+
29+
class RayDepthFilter(Filter):
30+
# pylint: disable=R0903
31+
"""
32+
False-color filter for displaying rays based their depth.
33+
"""
34+
35+
def __init__( # pylint: disable=R0913
36+
self,
37+
min_ray_depth: Optional[float] = None,
38+
max_ray_depth: Optional[float] = None,
39+
max_color_value: float = 1.0,
40+
):
41+
if min_ray_depth is not None and not 0.0 <= min_ray_depth < math.inf:
42+
raise ValueError(
43+
f"Cannot construct ray depth filter with"
44+
f" min_ray_depth={min_ray_depth}. Value must be positive or zero."
45+
)
46+
if max_ray_depth is not None and not 0.0 <= max_ray_depth < math.inf:
47+
raise ValueError(
48+
f"Cannot construct ray depth filter with"
49+
f" max_ray_depth={max_ray_depth}. Value must be positive or zero."
50+
)
51+
if (
52+
min_ray_depth is not None
53+
and max_ray_depth is not None
54+
and min_ray_depth >= max_ray_depth
55+
):
56+
raise ValueError(
57+
f"Cannot construct ray depth filter with"
58+
f" min_ray_depth={min_ray_depth} and max_ray_depth={max_ray_depth}."
59+
f" min_ray_depth must be smaller than max_ray_depth."
60+
)
61+
if not 0.0 < max_color_value <= 1.0:
62+
raise ValueError(
63+
f"Cannot construct ray depth filter with"
64+
f" max_color_value={max_color_value}."
65+
f" Value must be in between 0.0 (excluding) and 1.0"
66+
f" (including)."
67+
)
68+
69+
self.min_ray_depth = min_ray_depth
70+
self.max_ray_depth = max_ray_depth
71+
self.max_color_value = max_color_value # 0.0...1.0
72+
73+
def _normalized_ray_depths(self, ray_depths: np.ndarray) -> np.ndarray:
74+
"""
75+
Returns ray depth matrix normalized to values from zero to one on a
76+
logarithmic scale.
77+
78+
The minimal and maximal ray depth is either calculated from the inupt
79+
or overwritten by the renderer iff it provides min_ray_depth or
80+
max_ray_depth respectively.
81+
82+
Note: inf and nan values are preserved.
83+
"""
84+
85+
# add float epsilon to avoid 0.0 due to usage of log
86+
ray_depths = ray_depths + np.finfo(float).eps
87+
# use logarithmic scale (inf and nan are preserved)
88+
ray_depths = np.log(ray_depths)
89+
90+
# boolean matrix where True indicates a finite value
91+
finite_values = _is_finite(ray_depths)
92+
93+
# calculate min and max ray depth
94+
if self.min_ray_depth is None:
95+
min_ray_depth = np.min( # type: ignore[no-untyped-call]
96+
ray_depths,
97+
where=finite_values,
98+
initial=np.inf,
99+
)
100+
else:
101+
# overwrite
102+
min_ray_depth = self.min_ray_depth
103+
if self.max_ray_depth is None:
104+
max_ray_depth = np.max( # type: ignore[no-untyped-call]
105+
ray_depths,
106+
where=finite_values,
107+
initial=0.0,
108+
)
109+
else:
110+
# overwrite
111+
max_ray_depth = self.max_ray_depth
112+
113+
min_ray_depth, max_ray_depth = min(min_ray_depth, max_ray_depth), max(
114+
min_ray_depth, max_ray_depth
115+
)
116+
117+
if np.isinf(max_ray_depth):
118+
# all pixels are either inf or nan (no normalization needed)
119+
return ray_depths
120+
121+
# normalize all finite values to [0.0, 1.0]
122+
ray_depth_values = (ray_depths - min_ray_depth) / (
123+
max_ray_depth - min_ray_depth
124+
)
125+
# NOTE: Must use out prameter or inf and nan are not preserved!
126+
np.clip(
127+
ray_depth_values,
128+
0.0,
129+
1.0,
130+
where=finite_values,
131+
out=ray_depth_values, # must use this!
132+
)
133+
return ray_depth_values
134+
135+
def color_for_normalized_ray_depth_value(self, value: float) -> Color:
136+
"""
137+
Returns color assosiated with the normalized ray depth value in
138+
[0.0...1.0].
139+
"""
140+
return color_for_normalized_value(value * self.max_color_value)
141+
142+
def _color_for_pixel(
143+
self, info: IntersectionInfo, pixel_value: float
144+
) -> Color:
145+
if info.hits():
146+
return self.color_for_normalized_ray_depth_value(pixel_value)
147+
miss_reason = info.miss_reason()
148+
if miss_reason is None:
149+
raise RuntimeError(
150+
f"Cannot pick color for intersectio info {info}."
151+
" No miss reason specified despite ray is missing."
152+
)
153+
return color_miss_reason(miss_reason)
154+
155+
def apply(self, info_matrix: IntersectionInfoMatrix) -> Image:
156+
if len(info_matrix) == 0 or len(info_matrix[0]) == 0:
157+
raise ValueError(
158+
"Cannot apply ray depth filter. Intersection info matrix is empty."
159+
)
160+
width = len(info_matrix)
161+
height = len(info_matrix[0])
162+
163+
# initialize image with pink background
164+
image = Image.new(
165+
mode="RGB", size=(width, height), color=Colors.BLACK.rgb
166+
)
167+
# convert info matrix to ray depth matrix
168+
ray_depths_raw = np.full((width, height), math.nan)
169+
for pixel_x in range(width):
170+
for pixel_y in range(height):
171+
info = info_matrix[pixel_x][pixel_y]
172+
if info.hits():
173+
ray_depths_raw[pixel_x, pixel_y] = info.ray_depth()
174+
175+
ray_depth_normalized = self._normalized_ray_depths(ray_depths_raw)
176+
177+
# paint-in pixels
178+
for pixel_x in range(width):
179+
for pixel_y in range(height):
180+
pixel_location = (pixel_x, pixel_y)
181+
info = info_matrix[pixel_x][pixel_y]
182+
pixel_value = ray_depth_normalized[pixel_x, pixel_y]
183+
pixel_color = self._color_for_pixel(info, pixel_value)
184+
image.putpixel(pixel_location, pixel_color.rgb)
185+
186+
return image
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# pylint: disable=R0801
2+
# pylint: disable=C0103
3+
# pylint: disable=C0114
4+
# pylint: disable=C0115
5+
# pylint: disable=C0144
6+
7+
import unittest
8+
9+
import math
10+
11+
from nerte.values.color import Color
12+
from nerte.values.intersection_info import IntersectionInfo
13+
from nerte.render.image_filter_renderer import (
14+
IntersectionInfoMatrix,
15+
color_miss_reason,
16+
)
17+
from nerte.render.ray_depth_filter import RayDepthFilter
18+
19+
20+
class RayDepthFilterConstructorTest(unittest.TestCase):
21+
def setUp(self) -> None:
22+
# ray depth parameters
23+
self.valid_ray_depths = (None, 0.0, 1e-8, 1.0, 1e8)
24+
self.invalid_ray_depths = (math.inf, math.nan, -1e-8, -1.0)
25+
# max color calue
26+
self.valid_max_color_value = (0.1, 0.5, 1.0)
27+
self.invalid_max_color_value = (math.inf, math.nan, 0.0, -1.0)
28+
29+
def test_constructor(self) -> None:
30+
"""Tests the constructor."""
31+
32+
# preconditions
33+
self.assertTrue(len(self.valid_ray_depths) > 0)
34+
self.assertTrue(len(self.invalid_ray_depths) > 0)
35+
36+
RayDepthFilter()
37+
38+
# min/max ray depth
39+
for min_rd in self.valid_ray_depths:
40+
RayDepthFilter(min_ray_depth=min_rd)
41+
for max_rd in self.valid_ray_depths:
42+
RayDepthFilter(max_ray_depth=max_rd)
43+
for min_rd in self.valid_ray_depths:
44+
for max_rd in self.valid_ray_depths:
45+
if (
46+
min_rd is not None
47+
and max_rd is not None
48+
and max_rd <= min_rd
49+
):
50+
with self.assertRaises(ValueError):
51+
RayDepthFilter(
52+
min_ray_depth=min_rd,
53+
max_ray_depth=max_rd,
54+
)
55+
else:
56+
RayDepthFilter(
57+
min_ray_depth=min_rd,
58+
max_ray_depth=max_rd,
59+
)
60+
for min_rd in self.invalid_ray_depths:
61+
with self.assertRaises(ValueError):
62+
RayDepthFilter(min_ray_depth=min_rd)
63+
for max_rd in self.invalid_ray_depths:
64+
with self.assertRaises(ValueError):
65+
RayDepthFilter(max_ray_depth=max_rd)
66+
67+
# max color value
68+
for max_col_val in self.valid_max_color_value:
69+
RayDepthFilter(max_color_value=max_col_val)
70+
for max_col_val in self.invalid_max_color_value:
71+
with self.assertRaises(ValueError):
72+
RayDepthFilter(max_color_value=max_col_val)
73+
74+
75+
class RayDpethFilterProperties(unittest.TestCase):
76+
def setUp(self) -> None:
77+
self.filter_min_ray_depth = RayDepthFilter(
78+
min_ray_depth=1.0,
79+
)
80+
self.filter_max_ray_depth = RayDepthFilter(
81+
max_ray_depth=1.0,
82+
)
83+
self.filter_max_color_value = RayDepthFilter(
84+
max_color_value=0.5,
85+
)
86+
self.color = Color(12, 34, 56)
87+
88+
def test_default_properties(self) -> None:
89+
"""Tests the default properties."""
90+
filtr = RayDepthFilter()
91+
self.assertIsNone(filtr.min_ray_depth)
92+
self.assertIsNone(filtr.max_ray_depth)
93+
self.assertTrue(filtr.max_color_value == 1.0)
94+
95+
def test_ray_depth(self) -> None:
96+
"""Tests the ray depth properties."""
97+
98+
self.assertTrue(self.filter_min_ray_depth.min_ray_depth == 1.0)
99+
self.assertIsNone(self.filter_min_ray_depth.max_ray_depth)
100+
101+
self.assertIsNone(self.filter_max_ray_depth.min_ray_depth)
102+
self.assertTrue(self.filter_max_ray_depth.max_ray_depth == 1.0)
103+
104+
def test_max_color_value(self) -> None:
105+
"""Tests the max color value property."""
106+
self.assertTrue(self.filter_max_color_value.max_color_value == 0.5)
107+
108+
109+
class RayDepthFilterColorForRayDepthTest(unittest.TestCase):
110+
def setUp(self) -> None:
111+
self.valid_normalized_values = (0.0, 0.25, 1.0)
112+
self.invalid_normalized_values = (-1.0, 10.0)
113+
114+
max_color_values = (0.5, 1.0)
115+
self.filters = tuple(
116+
RayDepthFilter(max_color_value=mcv) for mcv in max_color_values
117+
)
118+
119+
def test_color_for_ray_depth(self) -> None:
120+
"""Tests the color for a ray depth in default mode."""
121+
122+
for filtr in self.filters:
123+
124+
for val in self.valid_normalized_values:
125+
color = filtr.color_for_normalized_ray_depth_value(val)
126+
rgb = color.rgb
127+
self.assertTrue(rgb[0] == rgb[1] == rgb[2])
128+
self.assertAlmostEqual(
129+
rgb[0], int(val * filtr.max_color_value * 255)
130+
)
131+
132+
for val in self.invalid_normalized_values:
133+
with self.assertRaises(ValueError):
134+
filtr.color_for_normalized_ray_depth_value(val)
135+
136+
137+
class RayDepthFilterApplyTest(unittest.TestCase):
138+
def setUp(self) -> None:
139+
self.info_matrix = IntersectionInfoMatrix(
140+
[
141+
[
142+
IntersectionInfo(ray_depth=math.e ** 0),
143+
IntersectionInfo(ray_depth=math.e ** 1),
144+
IntersectionInfo(ray_depth=math.e ** 2),
145+
]
146+
+ list(
147+
IntersectionInfo(miss_reason=mr)
148+
for mr in IntersectionInfo.MissReason
149+
)
150+
]
151+
)
152+
self.filter = RayDepthFilter(max_color_value=1.0)
153+
self.pixel_colors = [
154+
Color(0, 0, 0),
155+
Color(127, 127, 127),
156+
Color(255, 255, 255),
157+
] + list(color_miss_reason(mr) for mr in IntersectionInfo.MissReason)
158+
159+
def test_apply(self) -> None:
160+
"""Test filter application."""
161+
image = self.filter.apply(info_matrix=self.info_matrix)
162+
for pixel_y, pixel_color in enumerate(self.pixel_colors):
163+
self.assertTupleEqual(image.getpixel((0, pixel_y)), pixel_color.rgb)
164+
165+
166+
if __name__ == "__main__":
167+
unittest.main()

‎src/nerte/values/color.py

+3
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ class Color:
88
def __init__(self, r: int, g: int, b: int) -> None:
99
self.rgb: tuple[int, int, int] = (r, g, b)
1010

11+
def __repr__(self) -> str:
12+
return f"Color(r={self.rgb[0]},g={self.rgb[1]},b={self.rgb[2]})"
13+
1114

1215
class Colors:
1316
# pylint: disable=R0903
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
"""Module for intersection information with meta data."""
2+
3+
from typing import Optional
4+
5+
import math
6+
7+
from nerte.values.intersection_info import IntersectionInfo
8+
9+
10+
class ExtendedIntersectionInfo(IntersectionInfo):
11+
# pylint: disable=C0115
12+
def __init__(
13+
self,
14+
ray_depth: float = math.inf,
15+
miss_reason: Optional[IntersectionInfo.MissReason] = None,
16+
meta_data: Optional[dict[str, float]] = None,
17+
) -> None:
18+
IntersectionInfo.__init__(self, ray_depth, miss_reason)
19+
20+
# Note: Do not restrict the properties of the metadata any further
21+
# as it costs only time to check and is an unneccessary
22+
# restriction. Meta data is meant to be used freely and also
23+
# experimentally.
24+
self.meta_data = meta_data
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
# pylint: disable=R0801
2+
# pylint: disable=C0103
3+
# pylint: disable=C0114
4+
# pylint: disable=C0115
5+
# pylint: disable=C0144
6+
7+
import unittest
8+
9+
from typing import Optional
10+
11+
import math
12+
13+
from nerte.values.intersection_info import IntersectionInfo
14+
from nerte.values.extended_intersection_info import ExtendedIntersectionInfo
15+
16+
17+
class ExtendedIntersectionInfoConstructorTest(unittest.TestCase):
18+
def test_basic_constructor(self) -> None:
19+
"""Tests basic constructor calls without meta data."""
20+
ExtendedIntersectionInfo()
21+
ExtendedIntersectionInfo(ray_depth=0.0)
22+
ExtendedIntersectionInfo(ray_depth=1.0)
23+
ExtendedIntersectionInfo(ray_depth=math.inf)
24+
ExtendedIntersectionInfo(ray_depth=math.inf, miss_reason=None)
25+
# invalid ray_depth
26+
with self.assertRaises(ValueError):
27+
ExtendedIntersectionInfo(ray_depth=-1.0)
28+
with self.assertRaises(ValueError):
29+
ExtendedIntersectionInfo(ray_depth=math.nan)
30+
# invalid miss_reasons
31+
with self.assertRaises(ValueError):
32+
ExtendedIntersectionInfo(
33+
ray_depth=1.0,
34+
miss_reason=IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD,
35+
)
36+
37+
def test_advanced_constructor(self) -> None:
38+
# pylint: disable=R0201
39+
"""Tests advanced constructor calls with meta data."""
40+
ExtendedIntersectionInfo(meta_data={})
41+
ExtendedIntersectionInfo(meta_data={"": 0.0})
42+
ExtendedIntersectionInfo(meta_data={"a": 1.0})
43+
ExtendedIntersectionInfo(meta_data={"a": 1.0, "b": 2.0})
44+
45+
46+
class ExtendedIntersectionInfoInheritedPropertiesTest(unittest.TestCase):
47+
def setUp(self) -> None:
48+
self.info0 = ExtendedIntersectionInfo()
49+
self.ray_depths = (0.0, 1.0, math.inf, math.inf, math.inf)
50+
self.miss_reasons = (
51+
None,
52+
None,
53+
IntersectionInfo.MissReason.NO_INTERSECTION,
54+
IntersectionInfo.MissReason.NO_INTERSECTION,
55+
IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD,
56+
)
57+
self.infos = tuple(
58+
ExtendedIntersectionInfo(ray_depth=rd, miss_reason=mr)
59+
for rd, mr in zip(self.ray_depths, self.miss_reasons)
60+
)
61+
62+
def test_inherited_default_properties(self) -> None:
63+
"""Tests inherited default properties."""
64+
self.assertFalse(self.info0.hits())
65+
self.assertTrue(self.info0.misses())
66+
self.assertTrue(self.info0.ray_depth() == math.inf)
67+
reason = self.info0.miss_reason()
68+
self.assertIsNotNone(reason)
69+
if reason is not None:
70+
self.assertIs(reason, IntersectionInfo.MissReason.NO_INTERSECTION)
71+
72+
def test_inherited_properties(self) -> None:
73+
"""Tests inherited properties."""
74+
75+
# preconditions
76+
self.assertTrue(len(self.infos) > 0)
77+
self.assertTrue(
78+
len(self.infos) == len(self.ray_depths) == len(self.miss_reasons)
79+
)
80+
81+
for info, ray_depth, miss_reason in zip(
82+
self.infos, self.ray_depths, self.miss_reasons
83+
):
84+
self.assertTrue(info.ray_depth() == ray_depth)
85+
if ray_depth < math.inf:
86+
self.assertTrue(info.hits())
87+
self.assertFalse(info.misses())
88+
self.assertIsNone(info.miss_reason())
89+
else:
90+
self.assertFalse(info.hits())
91+
self.assertTrue(info.misses())
92+
self.assertIs(info.miss_reason(), miss_reason)
93+
94+
95+
class ExtendedIntersectionPropertiesTest(unittest.TestCase):
96+
def setUp(self) -> None:
97+
ray_depths = (0.0, 1.0, math.inf, math.inf, math.inf, math.inf)
98+
miss_reasons = (
99+
None,
100+
None,
101+
IntersectionInfo.MissReason.NO_INTERSECTION,
102+
IntersectionInfo.MissReason.NO_INTERSECTION,
103+
IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD,
104+
IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD,
105+
)
106+
self.meta_datas: tuple[Optional[dict[str, float]], ...] = (
107+
None,
108+
{},
109+
{"": 0.0},
110+
{"a": math.nan},
111+
{"a": 1.0},
112+
{"a": 1.0, "b": 2.0},
113+
)
114+
self.infos = tuple(
115+
ExtendedIntersectionInfo(ray_depth=rd, miss_reason=mr, meta_data=md)
116+
for rd, mr, md in zip(ray_depths, miss_reasons, self.meta_datas)
117+
)
118+
119+
def test_meta_data(self) -> None:
120+
"""Tests meta data attribute."""
121+
122+
# preconditions
123+
self.assertTrue(len(self.infos) > 0)
124+
self.assertTrue(len(self.infos) == len(self.meta_datas))
125+
126+
for info, meta_data in zip(self.infos, self.meta_datas):
127+
self.assertTrue(info.meta_data == meta_data)
128+
129+
130+
if __name__ == "__main__":
131+
unittest.main()

‎src/nerte/values/intersection_info.py

+18
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,22 @@ class IntersectionInfo:
1010
"""Represents the outcome of an intersection test of a ray with a face."""
1111

1212
class MissReason(Enum):
13+
# TODO: redesign: simple numbers do not reflect the hierachy of the
14+
# miss reasons
15+
# E.G. Ray.has_missreason(miss_reason:Ray.MissReason) -> bool
16+
# with behaviour
17+
# info = Info(miss_reason=RAY_INITIALIZED_OUTSIDE_MANIFOLD)
18+
# info.has_missreason(RAY_INITIALIZED_OUTSIDE_MANIFOLD) == TRUE
19+
# info.has_missreason(RAY_LEFT_MANIFOLD) == TRUE
1320
"""All reasons why an intersection test may have failed."""
1421

22+
# bit field like values
23+
UNINIALIZED = 0
1524
NO_INTERSECTION = 1
1625
RAY_LEFT_MANIFOLD = 2
26+
# a ray starting outside the manifold is an edge case of
27+
# a ray reaching the outside of the manifold
28+
RAY_INITIALIZED_OUTSIDE_MANIFOLD = 6 # 4 + 2
1729

1830
# reduce miss reasons to one optinal item
1931
def __init__(
@@ -82,9 +94,15 @@ class IntersectionInfos:
8294
to save resources.
8395
"""
8496

97+
UNINIALIZED = IntersectionInfo(
98+
miss_reason=IntersectionInfo.MissReason.UNINIALIZED,
99+
)
85100
NO_INTERSECTION = IntersectionInfo(
86101
miss_reason=IntersectionInfo.MissReason.NO_INTERSECTION,
87102
)
88103
RAY_LEFT_MANIFOLD = IntersectionInfo(
89104
miss_reason=IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD,
90105
)
106+
RAY_INITIALIZED_OUTSIDE_MANIFOLD = IntersectionInfo(
107+
miss_reason=IntersectionInfo.MissReason.RAY_INITIALIZED_OUTSIDE_MANIFOLD,
108+
)

‎src/nerte/values/intersection_info_unittest.py

+23
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,16 @@ def test_properties(self) -> None:
7575

7676

7777
class IntersectionInfosPropertiesTest(unittest.TestCase):
78+
def test_constant_uninitialized(self) -> None:
79+
"""Tests if constant UNINIALIZED is correct."""
80+
info = IntersectionInfos.UNINIALIZED
81+
self.assertTrue(info.misses())
82+
self.assertTrue(math.isinf(info.ray_depth()))
83+
reason = info.miss_reason()
84+
self.assertIsNotNone(reason)
85+
if reason is not None:
86+
self.assertIs(reason, IntersectionInfo.MissReason.UNINIALIZED)
87+
7888
def test_constant_no_intersection(self) -> None:
7989
"""Tests if constant NO_INTERSECTION is correct."""
8090
info = IntersectionInfos.NO_INTERSECTION
@@ -95,6 +105,19 @@ def test_constant_ray_left_manifold(self) -> None:
95105
if reason is not None:
96106
self.assertIs(reason, IntersectionInfo.MissReason.RAY_LEFT_MANIFOLD)
97107

108+
def test_constant_ray_initialized_outside_manifold(self) -> None:
109+
"""Tests if constant RAY_INITIALIZED_OUTSIDE_MANIFOLD is correct."""
110+
info = IntersectionInfos.RAY_INITIALIZED_OUTSIDE_MANIFOLD
111+
self.assertTrue(info.misses())
112+
self.assertTrue(math.isinf(info.ray_depth()))
113+
reason = info.miss_reason()
114+
self.assertIsNotNone(reason)
115+
if reason is not None:
116+
self.assertIs(
117+
reason,
118+
IntersectionInfo.MissReason.RAY_INITIALIZED_OUTSIDE_MANIFOLD,
119+
)
120+
98121

99122
if __name__ == "__main__":
100123
unittest.main()

0 commit comments

Comments
 (0)
Please sign in to comment.