WUT_Computer_Science/Programming/TRAK/rendering.py
Krzysztof kuhy Rudnicki bd24dd57ea Hold the coursework to the same lint bar as the tooling
kuhy overruled the "do not fix coursework to be better than it was submitted"
rule: git history is the archive, so the working tree can be clean. This lands
the config and the mechanical half of the cleanup -- 9,281 ruff findings under
the fleet config down to 3,686, with no per-file-ignores anywhere.

What is in here:

- pyproject.toml, the first one in this repo. select = ["ALL"], mypy strict,
  and the vendored list mirrored into ruff exclude and coverage omit. Five
  rules are off and each says why; CPY001 is the only judgement call, because
  stamping a copyright header onto 137 university assignments would assert a
  licence that was never claimed. No hook is wired up yet -- a gate lands only
  once its subject is clean.

- ruff format + the safe fixes, then a second pass over the docstring rules
  ruff marks unsafe (they only edit docstring text). 108 files.

- TRAK/sightpy is vendored, not coursework: it is rafael-fuente/Python-Raytracer
  (BSD 3-Clause). Verified against a fresh upstream clone with line endings
  normalised -- 30 of 32 files byte-identical, ray.py and scene.py carrying a
  12- and 13-line local patch that counts rays for the report. 35 files that
  should never have been linted, now excluded and restored.

- WDWR, SPD, MOM and ECRYPT are fully clean, by hand and verified by running
  them: WDWR's three plot scripts print the same statistics as before, MOM's
  PuLP model solves to the same cost. WDWR's graph.py had a 342-character CSV
  header embedded as a string literal, which cannot be wrapped -- the data
  moved to data/pareto_front.csv. Two 0-byte .py stubs are deleted; an empty
  file is not a deliverable and git still has it.

Three harness bugs found by actually running things:

- `run` now forces MPLBACKEND=Agg. TRAK finished its ray trace in 37 seconds
  and then sat for 45 minutes: plt.show() had opened an image viewer and was
  waiting for someone to close the window.
- TRAK's main.py only pops that viewer with --show now, and its runner writes
  to a gitignored build/ via the new --entry-args. The default output path is
  outputs/output.png, which is TRACKED -- a plain run overwrote a submitted
  deliverable.
- course_lib.sh was 357 lines, over this repo's own cap. Split into
  course_probe.sh and course_run.sh.

Verified: ECOTE 26 tests pass, ECRYPT_PROJECT 99 pass, TRAK renders
cornell_box in 37s and exits 0, every module compiles, and every import ruff
removed was checked to be genuinely unused.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172dj34FDpao6wsix8ZuCzE
2026-08-28 19:53:04 +02:00

457 lines
16 KiB
Python

#!/usr/bin/env python3
"""Renders an image using raytracing."""
import time
import matplotlib.pyplot as plt
import numpy as np
def ray_trace(
num_spheres, environment, image_width=400, image_height=300, output_file="fig.png"
):
IMAGE_WIDTH = image_width
IMAGE_HEIGHT = image_height
def normalize(vector):
"""Normalize a vector.
Parameters
----------
vector (numpy.ndarray): The input vector to be normalized.
Returns
-------
numpy.ndarray: The normalized vector.
"""
vector /= np.linalg.norm(vector)
return vector
def intersect_plane(ray_origin, ray_direction, plane_point, plane_normal):
"""Calculate the intersection of a ray with a plane.
Parameters
----------
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
ray_direction (numpy.ndarray): A normalized 3D vector representing the
direction of the ray.
plane_point (numpy.ndarray): A 3D point representing a point on the plane.
plane_normal (numpy.ndarray): A normalized 3D vector representing
the normal of the plane.
Returns
-------
float: The distance from the origin ray_origin to the intersection
point with the plane.
Returns +inf if there is no intersection or if the intersection is
behind the origin.
"""
denom = np.dot(ray_direction, plane_normal)
if np.abs(denom) < 1e-6:
return np.inf
d = np.dot(plane_point - ray_origin, plane_normal) / denom
if d < 0:
return np.inf
return d
def intersect_sphere(ray_origin, ray_direction, sphere_center, sphere_radius):
"""Calculate the intersection of a ray with a sphere.
Parameters
----------
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
ray_direction (numpy.ndarray): A normalized 3D vector representing the
direction of the ray.
sphere_center (numpy.ndarray): A 3D point representing
the center of the sphere.
sphere_radius (float): The radius of the sphere.
Returns
-------
float: The distance from the origin ray_origin to the intersection
point with the sphere.
Returns +inf if there is no intersection or if the intersection is
behind the origin.
"""
a = np.dot(ray_direction, ray_direction)
origin_to_center = ray_origin - sphere_center
b = 2 * np.dot(ray_direction, origin_to_center)
radius_squared = sphere_radius * sphere_radius
c = np.dot(origin_to_center, origin_to_center) - radius_squared
disc = b * b - 4 * a * c
return calculate_sphere_intersection(a, b, c, disc)
def calculate_sphere_intersection(a, b, c, disc):
"""Calculate the
intersection distance of a ray with a sphere using the quadratic formula.
Parameters
----------
a (float): Coefficient of t^2 in the quadratic equation.
b (float): Coefficient of t in the quadratic equation.
c (float): Constant term in the quadratic equation.
disc (float): Discriminant of the quadratic equation.
Returns
-------
float:
The distance from the origin to the intersection point with the sphere.
Returns +inf if there is no intersection
or if the intersection is behind the origin.
"""
if disc > 0:
distance_squared = np.sqrt(disc)
# q is used to find the roots of the quadratic equation
if b < 0:
q = (-b - distance_squared) / 2.0
else:
q = (-b + distance_squared) / 2.0
t0 = q / a
t1 = c / q
t0, t1 = min(t0, t1), max(t0, t1)
if t1 >= 0:
return t1 if t0 < 0 else t0
return np.inf
def intersect(ray_origin, ray_direction, object_):
"""Calculate the intersection of a ray with an object.
Parameters
----------
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
ray_direction (numpy.ndarray): A normalized 3D vector representing the
direction of the ray.
obj (dict): A dictionary representing the object with keys
'type', 'position', 'normal' (for planes), and 'radius' (for spheres).
Returns
-------
float: The distance from the origin ray_origin to the intersection
point with the object.
Returns +inf if there is no intersection or if the intersection is
behind the origin.
"""
if object_["type"] == "plane":
return intersect_plane(
ray_origin, ray_direction, object_["position"], object_["normal"]
)
# object_['type'] == 'sphere':
return intersect_sphere(
ray_origin, ray_direction, object_["position"], object_["radius"]
)
def get_normal(object_, intersection_point):
"""Calculate the normal at the intersection point on the object.
Parameters
----------
obj (dict): A dictionary representing the object with keys
'type' and 'position'.
intersection_point (numpy.ndarray): A 3D point representing the
intersection point on the object.
Returns
-------
numpy.ndarray: The normal vector at the intersection point.
"""
if object_["type"] == "sphere":
normal = normalize(intersection_point - object_["position"])
elif object_["type"] == "plane":
normal = object_["normal"]
else:
raise ValueError(f"Unknown object type: {object_['type']}")
return normal
def get_color(object_, intersection_point):
"""Get the color of the object at the intersection point.
Parameters
----------
object_ (dict): A dictionary representing the object with a key 'color'.
intersection_point (numpy.ndarray): A 3D point representing the
intersection point on the object.
Returns
-------
numpy.ndarray: The color of the object at the intersection point.
"""
color = object_["color"]
if not hasattr(color, "__len__"):
color = color(intersection_point)
return color
def trace_ray(ray_origin, ray_direction):
"""Trace a ray and find the color at the intersection point.
Parameters
----------
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
ray_direction (numpy.ndarray):
A normalized 3D vector representing the direction of the ray.
Returns
-------
tuple: A tuple containing the object,
intersection point, normal at the intersection,
and the color at the intersection point.
Returns None if there is no intersection.
"""
t, obj_idx = find_intersection(ray_origin, ray_direction)
if t == np.inf:
return None
object_, intersection_point = get_intersection_details(
ray_origin, ray_direction, t, obj_idx
)
normal, color = (
get_normal(object_, intersection_point),
get_color(object_, intersection_point),
)
if is_shadowed(intersection_point, normal, obj_idx):
return None
return compute_color(object_, intersection_point, normal, color, ray_origin)
def find_intersection(ray_origin, ray_direction):
"""Find the intersection of a ray with the objects in the scene.
Parameters
----------
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
ray_direction (numpy.ndarray):
A normalized 3D vector representing the direction of the ray.
Returns
-------
tuple: A tuple containing the distance to the intersection point
and the index of the intersected object.
"""
t = np.inf
obj_idx = -1
for index, object_ in enumerate(scene):
t_obj = intersect(ray_origin, ray_direction, object_)
if t_obj < t:
t, obj_idx = t_obj, index
return t, obj_idx
def get_intersection_details(ray_origin, ray_direction, t, obj_idx):
"""Get the details of the intersection point on the object.
Parameters
----------
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
ray_direction (numpy.ndarray):
A normalized 3D vector representing the direction of the ray.
t (float): The distance to the intersection point.
obj_idx (int): The index of the intersected object in the scene.
Returns
-------
tuple: A tuple containing the intersected object
and the intersection point.
"""
object_ = scene[obj_idx]
intersection_point = ray_origin + ray_direction * t
return object_, intersection_point
def is_shadowed(intersection_point, normal, obj_idx):
"""Determine if the intersection point is in shadow.
Parameters
----------
intersection_point (numpy.ndarray):
A 3D point representing the intersection point on the object.
normal (numpy.ndarray): The normal vector at the intersection point.
obj_idx (int): The index of the intersected object in the scene.
Returns
-------
bool: True if the intersection point is in shadow, False otherwise.
"""
to_light = normalize(L - intersection_point)
shadow_intersections = [
intersect(intersection_point + normal * 0.0001, to_light, obj_sh)
for k, obj_sh in enumerate(scene)
if k != obj_idx
]
return shadow_intersections and min(shadow_intersections) < np.inf
def compute_color(object_, intersection_point, normal, color, ray_origin):
"""Compute the color at the intersection point using shading techniques.
Parameters
----------
object_ (dict): A dictionary representing the intersected object.
intersection_point (numpy.ndarray):
A 3D point representing the intersection point on the object.
normal (numpy.ndarray): The normal vector at the intersection point.
color (numpy.ndarray): The base color of the object.
ray_origin (numpy.ndarray): A 3D point representing the origin of the ray.
Returns
-------
tuple:
A tuple containing the intersected object, intersection point, normal,
and the computed color.
"""
to_light = normalize(L - intersection_point)
to_origin = normalize(ray_origin - intersection_point)
color_ray = AMBIENT
diffuse_intensity = object_.get("diffuse_c", DIFFUSE_C) * max(
np.dot(normal, to_light), 0
)
color_ray += diffuse_intensity * color
half_vector = normalize(to_light + to_origin)
specular_intensity = (
object_.get("specular_c", SPECULAR_C)
* max(np.dot(normal, half_vector), 0) ** SPECULAR_K
)
color_ray += specular_intensity * color_light
return object_, intersection_point, normal, color_ray
def add_sphere(position, radius, color):
"""Create a dictionary representing a sphere object.
Parameters
----------
position (list or numpy.ndarray):
A 3D point representing the position of the sphere.
radius (float): The radius of the sphere.
color (list or numpy.ndarray): The color of the sphere.
Returns
-------
dict: A dictionary representing the sphere object.
"""
return {
"type": "sphere",
"position": np.array(position),
"radius": np.array(radius),
"color": np.array(color),
"reflection": 0.5,
}
def add_plane(position, normal):
"""Create a dictionary representing a plane object.
Parameters
----------
position (list or numpy.ndarray):
A 3D point representing a point on the plane.
normal (list or numpy.ndarray):
A normalized 3D vector representing the normal of the plane.
Returns
-------
dict: A dictionary representing the plane object.
"""
return {
"type": "plane",
"position": np.array(position),
"normal": np.array(normal),
"color": lambda M: (
color_plane0
if (int(M[0] * 2) % 2) == (int(M[2] * 2) % 2)
else color_plane1
),
"diffuse_c": 0.75,
"specular_c": 0.5,
"reflection": 0.25,
}
scene = []
# List of objects.
color_plane0 = 1.0 * np.ones(3)
color_plane1 = 0.0 * np.ones(3)
scene.append(add_plane([0.0, -0.5, 0.0], [0.0, 1.0, 0.0]))
base_radius = 1 / np.sqrt(num_spheres) # Im więcej kul, tym mniejsze
base_distance = 4.5 / num_spheres
for i in range(num_spheres):
# Wyliczanie pozycji każdej kuli
x = (i - num_spheres // 2) * base_distance
y = 0.1
z = 1.0 + i * 0.5
# Dynamiczny kolor (gradient na podstawie indeksu)
color = np.array([i / num_spheres, (num_spheres - i) / num_spheres, 0.5])
# Dodanie kuli do sceny
scene.append(add_sphere([x, y, z], base_radius, color))
# Light position and color.
L = np.array([5.0, 5.0, -10.0])
color_light = np.ones(3)
# Default light and material parameters.
AMBIENT = 0.05
DIFFUSE_C = 1.0
SPECULAR_C = 1.0
SPECULAR_K = 50
DEPTH_MAX = 5 # Maximum number of light reflections.
col = np.zeros(3) # Current color.
camera_origin = np.array([0.0, 0.35, -1.0]) # Camera.
Q = np.array([0.0, 0.0, 0.0]) # Camera pointing to.
img = np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3))
r = float(IMAGE_WIDTH) / IMAGE_HEIGHT
# Screen coordinates: x0, y0, x1, y1.
S = (-1.0, -1.0 / r + 0.25, 1.0, 1.0 / r + 0.25)
renderTime = time.time()
reflections = 0
rays = 0
initialRays = 0
# Loop through all pixels.
for i, x in enumerate(np.linspace(S[0], S[2], IMAGE_WIDTH)):
if i % 10 == 0:
print(round(i / float(IMAGE_WIDTH) * 100, 2), "%")
for j, y in enumerate(np.linspace(S[1], S[3], IMAGE_HEIGHT)):
col[:] = 0
Q[:2] = (x, y)
D = normalize(Q - camera_origin)
DEPTH = 0
rayO, rayD = camera_origin, D
REFLECTION = 1.0
initialRays += 1
# Loop through initial and secondary rays.
while DEPTH < DEPTH_MAX:
traced = trace_ray(rayO, rayD)
rays += 1
if not traced:
break
reflections += 1
obj, M, N, col_ray = traced
# Reflection: create a new ray.
rayO, rayD = M + N * 0.0001, normalize(rayD - 2 * np.dot(rayD, N) * N)
DEPTH += 1
col += REFLECTION * col_ray
REFLECTION *= obj.get("reflection", 1.0)
img[IMAGE_HEIGHT - j - 1, i, :] = np.clip(col, 0, 1)
renderTime = time.time() - renderTime
plt.imsave(output_file, img)
print(
f"Image saved as {output_file}\n"
f"resolution: {IMAGE_WIDTH}x{IMAGE_HEIGHT}\n"
f"render time: {round(renderTime, 2)} s\n"
f"reflections: {reflections}\n"
f"rays (initial): {initialRays}\n"
f"rays (secondary): {rays - initialRays}\n"
f"rays (total): {rays}"
)