WUT_Computer_Science/Programming/TRAK/code/mapa_srodowiska/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

216 lines
6.8 KiB
Python
Executable File

#!/usr/bin/env python3
"""View an OpenEXR environment map from the inside, on a GLUT-textured sphere.
Drag with the left mouse button to look around. The image is loaded as
32-bit float RGB and uploaded as a `GL_RGB32F` texture, so the high dynamic
range survives all the way to the driver.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from pathlib import Path
import Imath
import OpenEXR
from OpenGL import GL, GLU, GLUT
logger = logging.getLogger(__name__)
# How far the camera turns per pixel of mouse drag.
_DEGREES_PER_PIXEL = 0.1
@dataclass
class ViewerState:
"""Camera orientation and mouse-drag state, mutated by the GLUT callbacks."""
angle_x: float = 0.0
angle_y: float = 0.0
distance: float = 2.0
last_x: int = 0
last_y: int = 0
left_down: bool = False
STATE = ViewerState()
def load_hdr_environment_map(filepath: str) -> tuple[int, int, bytes]:
"""Load an HDR environment map from an OpenEXR file.
Returns the width, the height, and the pixels as interleaved float RGB,
flipped vertically so that row 0 is the bottom row OpenGL expects.
"""
if not Path(filepath).exists():
msg = f"File not found: {filepath}"
raise FileNotFoundError(msg)
# Check file permissions
if not os.access(filepath, os.R_OK):
msg = f"File is not readable: {filepath}"
raise PermissionError(msg)
# Open the EXR file
try:
exr_file = OpenEXR.InputFile(filepath)
except Exception as e:
msg = f"Unable to open '{filepath}' for read: {e!s}"
raise OSError(msg) from e
# Get the image dimensions
header = exr_file.header()
dw = header["dataWindow"]
width = dw.max.x - dw.min.x + 1
height = dw.max.y - dw.min.y + 1
# Define the channel names (R, G, B)
channels = ["R", "G", "B"]
# Read the channel data
channel_data = {
channel: exr_file.channel(channel, Imath.PixelType(Imath.PixelType.FLOAT))
for channel in channels
}
# Combine channel data into a single bytes object
hdr_image = bytearray(width * height * 3 * 4) # 3 channels, 4 bytes per float
for i, channel in enumerate(channels):
channel_buffer = channel_data[channel]
for j in range(height):
for k in range(width):
index = (j * width + k) * 3 * 4 + i * 4
hdr_image[index : index + 4] = channel_buffer[
(j * width + k) * 4 : (j * width + k + 1) * 4
]
# Flip the image vertically
flipped_hdr_image = bytearray(width * height * 3 * 4)
row_size = width * 3 * 4
for j in range(height):
src_index = j * row_size
dst_index = (height - 1 - j) * row_size
flipped_hdr_image[dst_index : dst_index + row_size] = hdr_image[
src_index : src_index + row_size
]
return width, height, bytes(flipped_hdr_image)
def display_hdr_image(width: int, height: int, hdr_image: bytes) -> None:
"""Draw one frame: the environment map on the inside of a large sphere."""
GL.glClear(GL.GL_COLOR_BUFFER_BIT | GL.GL_DEPTH_BUFFER_BIT)
# Enable texture mapping
GL.glEnable(GL.GL_TEXTURE_2D)
texture_id = GL.glGenTextures(1)
GL.glBindTexture(GL.GL_TEXTURE_2D, texture_id)
# Set texture parameters
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_S, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_WRAP_T, GL.GL_CLAMP_TO_EDGE)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MIN_FILTER, GL.GL_LINEAR)
GL.glTexParameteri(GL.GL_TEXTURE_2D, GL.GL_TEXTURE_MAG_FILTER, GL.GL_LINEAR)
# Load the texture
GL.glTexImage2D(
GL.GL_TEXTURE_2D,
0,
GL.GL_RGB32F,
width,
height,
0,
GL.GL_RGB,
GL.GL_FLOAT,
hdr_image,
)
# Set up the viewport and projection
window_width = GLUT.glutGet(GLUT.GLUT_WINDOW_WIDTH)
window_height = GLUT.glutGet(GLUT.GLUT_WINDOW_HEIGHT)
GL.glViewport(0, 0, window_width, window_height)
GL.glMatrixMode(GL.GL_PROJECTION)
GL.glLoadIdentity()
GLU.gluPerspective(45.0, window_width / float(window_height), 0.1, 100.0)
GL.glMatrixMode(GL.GL_MODELVIEW)
GL.glLoadIdentity()
# Apply camera transformations
GL.glTranslatef(0.0, 0.0, -STATE.distance)
GL.glRotatef(STATE.angle_y, 1.0, 0.0, 0.0)
GL.glRotatef(STATE.angle_x, 0.0, 1.0, 0.0)
# Draw a textured sphere to simulate being inside the HDR environment
quadric = GLU.gluNewQuadric()
GLU.gluQuadricTexture(quadric, GL.GL_TRUE)
GLU.gluSphere(quadric, 50.0, 50, 50)
GLU.gluDeleteQuadric(quadric)
# Disable texture mapping
GL.glDisable(GL.GL_TEXTURE_2D)
GLUT.glutSwapBuffers()
def mouse_motion(x: int, y: int) -> None:
"""Turn the camera by however far the pointer moved since the last event."""
if STATE.left_down:
dx = x - STATE.last_x
dy = y - STATE.last_y
STATE.angle_x += dx * _DEGREES_PER_PIXEL
STATE.angle_y += dy * _DEGREES_PER_PIXEL
STATE.last_x = x
STATE.last_y = y
GLUT.glutPostRedisplay()
def mouse_button(button: int, state: int, x: int, y: int) -> None:
"""Start a drag on left-button press and end it on release."""
if button == GLUT.GLUT_LEFT_BUTTON:
if state == GLUT.GLUT_DOWN:
STATE.left_down = True
# These two were assigned without a `global` declaration before, so
# they went to locals and were thrown away: the first motion event
# of every drag measured from wherever the previous drag ended.
STATE.last_x = x
STATE.last_y = y
elif state == GLUT.GLUT_UP:
STATE.left_down = False
def main(filepath: str) -> None:
"""Load `filepath` and hand control to the GLUT main loop."""
try:
width, height, hdr_map = load_hdr_environment_map(filepath)
except OSError:
# Covers the not-found, not-readable and unreadable-EXR cases above.
# Anything else is a real fault and should surface as a traceback.
logger.exception("Could not load '%s'", filepath)
return
logger.info("HDR Map Loaded. Dimensions: %s x %s", width, height)
# Initialize GLUT and create window
GLUT.glutInit()
GLUT.glutInitDisplayMode(GLUT.GLUT_DOUBLE | GLUT.GLUT_RGB | GLUT.GLUT_DEPTH)
GLUT.glutInitWindowSize(800, 600) # Set initial window size
GLUT.glutCreateWindow(b"HDR Environment Map")
# Set display callback
GLUT.glutDisplayFunc(lambda: display_hdr_image(width, height, hdr_map))
# Set mouse callbacks
GLUT.glutMotionFunc(mouse_motion)
GLUT.glutMouseFunc(mouse_button)
# Start the GLUT main loop
GLUT.glutMainLoop()
# Example usage
if __name__ == "__main__":
logging.basicConfig(format="%(message)s", level=logging.INFO)
main("lilienstein_4k.exr")