WUT_Computer_Science/Programming/TRAK/code/photonmapping/python/main.py
Krzysztof kuhy Rudnicki 24f800d15a TRAK: zero findings, and a byte-identical render proving it
494 ruff findings to 0, and mypy --strict is clean too -- the first course in
this campaign where that is true of both.

Two real bugs turned up on the way, neither of which ruff can see:

- `main.py` passed `args.environment_map` to `ray_trace`, and argparse only
  ever defined `--environment`. Every `--algorithm ray_tracing0` run died with
  AttributeError before rendering a pixel, so `rendering.py` -- the largest
  file in the course -- had no way to be exercised at all.
- `mouse_button` in the environment-map viewer assigned `mouse_last_x/y`
  without declaring them `global`, so the drag origin went to locals and was
  thrown away. Measured against HEAD: a 10x5 px drag turned the camera by
  11.0/20.5 degrees because it measured from (0, 0); it is 1.0/0.5 now, which
  is what the 0.1 degrees-per-pixel constant says it should be.

Fixing the first one is what made the rest safe. `ray_tracing0` has no RNG
anywhere in it, so it renders byte-identically every time, and the whole
refactor of `rendering.py` is verified by one md5 comparison:
02484bdb6b6c1ddeff317d7e5566580f, 248619 rays, before and after. The
cornell_box path cannot do that -- its ray count drifts by ~20 between
identical runs.

Three files were over the 250-line cap and are now split, geometry away from
the algorithm that uses it: rendering.py 462 -> 137 + raytracer.py,
photon_mapping.py 261 -> 160 + photon_geometry.py, and
code/photonmapping/python/main.py 257 -> 203 + geometry.py. No TRAK source
file is over the cap now.

The two photon mappers do use numpy's legacy global RNG, so NPY002 could not
be bit-identical. Each was done in two steps: restructure first, verified
identical under an external seed (digests 7220370f60206a62 and
21b4771504ab2351, and an identical 828 photon traces), then the swap to
`default_rng` on its own, checked against the before/after distributions.

Verified by running, not by reading:

- cornell_box through `./run.sh run Programming/TRAK`: 24.27s, 13294 rays
- all five scenes build after `from sightpy import *` became explicit imports
- the standalone photon mapper end to end: 81.4s, 38742 photon traces
- the EXR loader byte for byte: a0c3890350ce6101247c6948, 6291456 bytes
- the GLUT viewer under xvfb-run, which drew a real 1024x512 frame

`pyproject.toml` gains a mypy override for Imath, OpenEXR, OpenGL and
matplotlib. Those four ship neither stubs nor py.typed, and without it mypy
cannot import them at all and so reports nothing else about the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 07:54:51 +02:00

205 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""Standalone photon mapper: scatter photons from the light, then gather them.
The lab version, kept whole: the scene, the light and the photon map are
module-level state, and running the file emits photons, renders a 100x100
image and shows it. `Programming/TRAK/photon_mapping.py` is the later
parameterised rewrite of the same algorithm.
"""
from __future__ import annotations
import logging
import time
from typing import cast
import matplotlib.pyplot as plt
import numpy as np
from geometry import Photon, Plane, Sphere, Surface, Vector3
logger = logging.getLogger(__name__)
# Shadow rays start this far off the surface they were cast from.
_SHADOW_OFFSET = 1e-5
# Fraction of its power a photon keeps across one diffuse bounce.
_BOUNCE_ABSORPTION = 0.8
# One generator for the whole module, matching the module-level scene below.
_rng = np.random.default_rng()
# Scene setup
sphere = Sphere(Vector3(0, 0, -5), 1.0, np.array([1, 0, 0])) # Red sphere
plane = Plane(
Vector3(0, -1, 0), Vector3(0, 1, 0), np.array([0.5, 0.5, 0.5])
) # Gray plane
objects: list[Surface] = [sphere, plane]
# Light source
light_position = Vector3(-5, 5, -5)
light_power = np.array([1, 1, 1]) * 1000 # Intense white light
# Photon map
photon_map: list[tuple[Vector3, np.ndarray]] = []
# Parameters
num_photons = 10000 # Number of photons to emit
max_depth = 5 # Maximum number of bounces
gather_radius = 0.5 # Radius for radiance estimation
def random_unit_vector() -> Vector3:
"""Draw a direction uniformly over the sphere."""
theta = _rng.uniform(0, 2 * np.pi)
z = _rng.uniform(-1, 1)
r = np.sqrt(1 - z * z)
return Vector3(r * np.cos(theta), r * np.sin(theta), z)
def random_hemisphere_direction(normal: Vector3) -> Vector3:
"""Draw a direction uniformly over the hemisphere around `normal`."""
direction = random_unit_vector()
if direction.dot(normal) < 0:
direction = Vector3(-direction.x, -direction.y, -direction.z)
return direction
def nearest_hit(
ray_origin: Vector3, ray_direction: Vector3
) -> tuple[Surface, Vector3, Vector3] | None:
"""Return the closest surface the ray meets, with its point and normal."""
closest_t = np.inf
hit_object = None
hit_info = None
# Find the nearest intersection
for obj in objects:
result = obj.intersect(ray_origin, ray_direction)
if result:
t, hit_point, normal = result
if t < closest_t:
closest_t = t
hit_object = obj
hit_info = (hit_point, normal)
if hit_object is None or hit_info is None:
return None
return (hit_object, hit_info[0], hit_info[1])
def trace_photon(photon: Photon, depth: int) -> int:
"""Bounce one photon, recording each landing, and return the traces made.
The count is what the original `global PHOTONS` accumulated: one per call,
bounces included.
"""
if depth > max_depth:
return 1
hit = nearest_hit(photon.position, photon.direction)
if hit is None:
return 1
_, hit_point, normal = hit
photon_map.append((hit_point, photon.power))
# Diffuse reflection
photon.position = hit_point
photon.direction = random_hemisphere_direction(normal)
# Absorb some power
photon.power = photon.power * _BOUNCE_ABSORPTION # Simple absorption
return 1 + trace_photon(photon, depth + 1)
def emit_photons() -> int:
"""Emit `num_photons` from the light and return the total traces made."""
traces = 0
for _ in range(num_photons):
# Emit photons in random directions from the light source
direction = random_unit_vector()
power = light_power / num_photons
traces += trace_photon(Photon(light_position, direction, power), 0)
return traces
def compute_direct_light(point: Vector3, normal: Vector3) -> np.ndarray:
"""Return the light arriving straight from the source, or black if shadowed."""
# Simple Lambertian reflection from light source
direction_to_light = (light_position - point).normalize()
# Shadow ray
shadow_origin = point + normal * _SHADOW_OFFSET
for obj in objects:
if obj.intersect(shadow_origin, direction_to_light):
return np.array([0, 0, 0])
intensity = max(0, normal.dot(direction_to_light))
return intensity * light_power / (4 * np.pi * (light_position - point).norm() ** 2)
def estimate_radiance(point: Vector3, normal: Vector3) -> np.ndarray:
"""Gather the photons within `gather_radius` of `point` into a radiance."""
accumulated_power = np.array([0.0, 0.0, 0.0])
for photon_pos, photon_power in photon_map:
distance = (photon_pos - point).norm()
if distance < gather_radius:
weight = max(0, normal.dot((photon_pos - point).normalize()))
accumulated_power += photon_power * weight
area = np.pi * gather_radius**2
return accumulated_power / (area * num_photons)
def trace_ray(ray_origin: Vector3, ray_direction: Vector3) -> np.ndarray:
"""Shade one camera ray with direct light plus gathered photons."""
hit = nearest_hit(ray_origin, ray_direction)
if hit is None:
return np.array([0, 0, 0]) # Background color
hit_object, hit_point, normal = hit
direct_light = compute_direct_light(hit_point, normal)
indirect_light = estimate_radiance(hit_point, normal)
return cast("np.ndarray", hit_object.color * (direct_light + indirect_light))
def render_image(width: int, height: int) -> np.ndarray:
"""Shoot one camera ray per pixel and return the `height` x `width` image."""
aspect_ratio = width / height
fov = np.pi / 3 # 60 degrees field of view
image = np.zeros((height, width, 3))
for y in range(height):
for x in range(width):
# Convert pixel coordinate to camera ray
px = (2 * (x + 0.5) / width - 1) * np.tan(fov / 2) * aspect_ratio
py = (1 - 2 * (y + 0.5) / height) * np.tan(fov / 2)
ray_direction = Vector3(px, py, -1).normalize()
image[y, x, :] = np.clip(trace_ray(Vector3(0, 0, 0), ray_direction), 0, 1)
return image
def main() -> None:
"""Emit photons, render the image and show it."""
logger.info("Emitting photons...")
photon_traces = emit_photons()
logger.info("Rendering image...")
width = 100
height = 100
t0 = time.time()
image = render_image(width, height)
logger.info(
"Render Took: %ss\nresolution: %sx%s\nphotons (emitted): %s\n"
"photons (reflected): %s\nphotons (total): %s\nrays: %s",
round(time.time() - t0, 2),
width,
height,
num_photons,
photon_traces - num_photons,
photon_traces,
width * height,
)
# Display the image
plt.imshow(image)
plt.axis("off")
plt.show()
# Main execution
if __name__ == "__main__":
logging.basicConfig(format="%(message)s", level=logging.INFO)
main()