Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions hrl/calibration/measurement.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from functools import partial
from pathlib import Path

import numpy as np

Expand Down Expand Up @@ -55,12 +56,14 @@ def draw_uniform_square(ihrl, intensity, patch_size=0.5):
)
patch = ihrl.graphics.newTexture(np.array([[intensity]]))
patch.draw(patch_position, (patch_width, patch_height))
ihrl.graphics.flip()


def measure_lut(
ihrl,
intensities=setup_intensities(0.0, 1.0, 2**16),
stim_draw_func=partial(draw_uniform_square, patch_size=0.5),
out_file=None,
n_samples=5,
sleep_time=200,
):
Expand All @@ -75,35 +78,47 @@ def measure_lut(
stim_draw_func : callable, optional
function with signature `(ihrl, intensity)` that draws the stimulus
for each measurement; defaults to `draw_uniform_square(patch_size=0.5)`
out_file : str or Path, optional
path to output file for measurements, by default None (no file output)
n_samples : int
number of photometer readings per intensity level, by default 5
sleep_time : float
time (ms) to wait between photometer readings, by default 200ms
"""
measurements = np.ndarray((len(intensities), n_samples + 1), dtype=float)

for idx_int, intensity in enumerate(intensities):
print(
f"Current Intensity: {intensity:.2f} "
f"[{idx_int:d} of {len(intensities)} "
f"({idx_int / len(intensities) * 100:.1f}%)]"
)
ihrl.results["Intensity"] = intensity

# Draw (update) stimulus
stim_draw_func(ihrl, intensity)
ihrl.graphics.flip()

# Multiple samples for each intensity value
for idx_sample in range(n_samples):
sample = ihrl.photometer.readLuminance(5, int(sleep_time))
ihrl.results[f"Luminance{idx_sample}"] = sample
measurements[idx_int, idx_sample + 1] = sample

# Write measured samples to file
ihrl.writeResultLine()

if ihrl.inputs.checkEscape():
measurements[idx_int, 0] = intensity
if out_file is not None:
out_file = Path(out_file).expanduser().resolve()
np.savetxt(
out_file,
measurements,
delimiter=",",
header="intensity," + ",".join([f"luminance{i}" for i in range(n_samples)]),
comments="",
)

if ihrl.inputs is not None and ihrl.inputs.checkEscape():
break

return measurements


def combine(measurements):
"""Construct an intensity-to-luminance map from (sets of) measurements
Expand Down
10 changes: 7 additions & 3 deletions hrl/luts.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,12 +125,16 @@ def create_lut(
intensity_out = intensity_in^(1/gamma)
luminance = k * intensity_in^gamma + dark
"""
# Linearly spaced input intensities from 0 to 1
x = np.linspace(0.0, 1.0, n)

out = x ** (1 / gamma)
# Linearly spaced luminance value.
# Use linspace, because that's also what we use in the calibration (linearize())
# This way, luminance values are bit-identical, important for testing and reproducibility.
lum = np.linspace(dark, k + dark, n)

lum = k * x**gamma + dark
lum[0] = dark # enforce zero row
# Output intensities: apply inverse gamma correction
out = x ** (1 / gamma)

return np.column_stack([x, out, lum])

Expand Down
97 changes: 97 additions & 0 deletions hrl/photometer/mock.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
"""Mock photometer (for testing) using a lookup table to simulate luminance readings.

Simulated photometer object that adheres to the same interface as the real photometer,
but returns simulated luminance values (based on a LUT) instead of reading from a physical device.

The challenge with mocking a photometer is that it has no knowledge of what
intensity is currently being displayed -- that information travels through the
physical path (screen → photons → sensor).

MockPhotometer solves this by holding ``current_intensity`` as a direct attribute,
which a stimulus draw function should update before ``readLuminance`` is called.

Typical usage::

ihrl.photometer = MockPhotometer(lut)

def mock_draw(ihrl, intensity):
ihrl.photometer.current_intensity = intensity
# draw_uniform_square(ihrl, intensity)

measure_lut(ihrl, stim_draw_func=mock_draw, ...)
"""

import numpy as np

from .photometer import Photometer


class MockPhotometer(Photometer):
"""Photometer that returns simulated luminance values from a LUT.

The currently displayed intensity is tracked via the ``current_intensity``
attribute, which must be updated by the stimulus drawing code before each
call to ``readLuminance``.

Parameters
----------
lut : array-like or callable

@guillermoaguilar guillermoaguilar Jul 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that we can introduce a mock photometer, so be able to test the whole pipeline. But I am not sure about this design decision, to have a LUT as input.

I think it is cleaner to have a gamma value as input (or 3 gamma values in the case of color), and a default range of luminances. Then the mock photometer calculates luminance using a standard gamma function.

I say this because conceptually, an LUT file is the result of the calibration pipeline, not the input. So it can be confusing what is the Photometer actually mocking

Defines the intensity to luminance mapping.

- **array**: shape ``(N, 2)`` or ``(N, 3)``. Column 0 is intensity
(input), the *last* column is luminance (cd/m²). Values are
linearly interpolated.
- **callable**: ``lut(intensity: float) -> float`` returning cd/m².
noise : float, optional
Standard deviation of zero-mean Gaussian noise added to each reading,
in cd/m², by default 0.0 (noiseless).
rng : numpy.random.Generator or int or None, optional
Random number generator (or seed) used for noise. Pass an integer
for reproducible results. By default None (unseeded).

Attributes
----------
current_intensity : float
The intensity value (in [0, 1]) most recently drawn to the screen.
Update this before calling ``readLuminance``.
"""

def __init__(self, lut, noise=0.0, rng=None):
super().__init__()
self.current_intensity = 0.0
self.noise = noise
self.rng = np.random.default_rng(rng)

if callable(lut):
self._lut_func = lut
else:
lut = np.asarray(lut)
if lut.shape[1] == 3:
# Full 3-column LUT (intensity_in, intensity_out, luminance):
# use intensity_out (col 1) as the physical intensity axis.
intensities = lut[:, 1]
else:
intensities = lut[:, 0]
luminances = lut[:, -1]
self._lut_func = lambda x: float(np.interp(x, intensities, luminances))

def readLuminance(self, n=3, slp=None):
"""Return simulated luminance for the currently displayed intensity.

Parameters
----------
n : int
Number of samples to average (mirrors the real photometer API).
slp : int
Sleep time between samples in ms (ignored in mock).

Returns
-------
float
Simulated luminance in cd/m², averaged over ``n`` samples.
"""
base_lum = self._lut_func(self.current_intensity)
if self.noise > 0.0:
samples = base_lum + self.rng.normal(0.0, self.noise, size=n)
return float(np.mean(samples))
return base_lum
38 changes: 27 additions & 11 deletions hrl/util/lut/linearize.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
from pathlib import Path

import numpy as np

Expand All @@ -18,25 +19,40 @@
add_help=False,
parents=[intensities_argparser],
)
parser.add_argument(
"-i",
"--in_file",
default="smooth.csv",
type=Path,
help="path to input measurements csv, by default 'smooth.csv'",
)
parser.add_argument(
"-o",
"--out_file",
default="lut.csv",
type=Path,
help="path for output csv, by default 'lut.csv'",
)


def command(parsed_args):
"""Sample a linear subset of the gamma table"""
n_steps = 2**parsed_args.bit_depth

# Load (smoothed) LUT
lut = np.genfromtxt("smooth.csv", skip_header=1, delimiter=",")
in_file = parsed_args.in_file.expanduser().resolve()
print(f"Loading measurements from {in_file} ...")
measurements = np.genfromtxt(in_file, delimiter=",", skip_header=1)

# Linearize LUT
linearized_lut = hrl.calibration.measurement.linearize(lut, bit_depth=parsed_args.bit_depth)

# Save linearized LUT to file
print("Saving to File...")
out_file = open("lut.csv", "w")
headers = "intensity_in,intensity_out,luminance\n"
out_file.write(headers)
np.savetxt(out_file, linearized_lut, delimiter=",")
out_file.close()
linearized_lut = hrl.calibration.measurement.linearize(
measurements, bit_depth=parsed_args.bit_depth
)

# Write to file
out_file = parsed_args.out_file.expanduser().resolve()
print(f"Saving to {out_file} ...")
headers = "intensity_in,intensity_out,luminance"
np.savetxt(out_file, linearized_lut, delimiter=",", header=headers, comments="")


if __name__ == "__main__":
Expand Down
10 changes: 4 additions & 6 deletions hrl/util/lut/measure.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import argparse
from datetime import timedelta
from functools import partial
from pathlib import Path
from timeit import default_timer as timer

from hrl import HRL
Expand Down Expand Up @@ -58,9 +59,9 @@
parser.add_argument(
"-o",
"--out_file",
type=str,
type=Path,
default="measure.csv",
help="Output filename, by default 'measure.csv'",
help="path to output measurements csv, by default 'measure.csv'",
)


Expand All @@ -71,8 +72,6 @@ def command(parsed_args):
start = timer()

# Initializing HRL
headers = ["Intensity"] + ["Luminance" + str(i) for i in range(parsed_args.n_samples)]

ihrl = HRL(
graphics=parsed_args.graphics,
inputs="keyboard",
Expand All @@ -84,8 +83,6 @@ def command(parsed_args):
wdth_offset=parsed_args.width_offset,
db=True,
scrn=parsed_args.screen,
rfl=parsed_args.out_file,
rhds=headers,
)

# Set up intensity values to be measured
Expand All @@ -105,6 +102,7 @@ def command(parsed_args):
ihrl,
intensities=intensities,
stim_draw_func=partial(draw_uniform_square, patch_size=parsed_args.patch_size),
out_file=parsed_args.out_file,
n_samples=parsed_args.n_samples,
sleep_time=parsed_args.sleep_time,
)
Expand Down
11 changes: 10 additions & 1 deletion hrl/util/lut/plot.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
from pathlib import Path

import matplotlib.pyplot as plt
import numpy as np
Expand All @@ -11,10 +12,18 @@
""",
add_help=False,
)
parser.add_argument(
"-i",
"--in_file",
default="lut.csv",
type=Path,
help="path to LUT csv plot, by default 'lut.csv'",
)


def command(parsed_args):
lut = np.genfromtxt("lut.csv", skip_header=1, delimiter=",")
lut_file = parsed_args.in_file.expanduser().resolve()
lut = np.genfromtxt(lut_file, delimiter=",", skip_header=1)

plt.figure()

Expand Down
36 changes: 28 additions & 8 deletions hrl/util/lut/smooth.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import argparse
from pathlib import Path

import numpy as np

Expand Down Expand Up @@ -36,12 +37,33 @@
nargs="+",
help="kernel for smoothing, by default [0.2, 0.2, 0.2, 0.2, 0.2]",
)
parser.add_argument(
"-i",
"--in_file",
default="measure.csv",
type=Path,
nargs="+",
help="path(s) for input measurement csv(s), by default 'measure.csv'",
)
parser.add_argument(
"-o",
"--out_file",
default="smooth.csv",
type=Path,
help="path for output smoothed measurements csv, by default 'smooth.csv'",
)


def command(parsed_args):
# Load measurement data
files = ["measure.csv"]
measurements = [np.genfromtxt(fl, skip_header=1, delimiter=",") for fl in files]
measurements = []
in_files = parsed_args.in_file
if isinstance(in_files, (str, Path)):
in_files = [in_files]
for file in in_files:
filename = file.expanduser().resolve()
print(f"Loading from {filename} ...")
measurements.append(np.genfromtxt(filename, delimiter=",", skip_header=1))

# Combine
luminance_map = hrl.calibration.measurement.combine(measurements)
Expand All @@ -58,12 +80,10 @@ def command(parsed_args):
)

# Save smoothed LUT to file
print("Saving to File...")
out_filename = "smooth.csv"
out_file = open(out_filename, "w")
out_file.write("intensity_in,luminance\n")
np.savetxt(out_file, table, delimiter=",")
out_file.close()
out_file = parsed_args.out_file.expanduser().resolve()
print(f"Saving to {out_file}...")
header = "intensity_in,luminance"
np.savetxt(out_file, table, delimiter=",", header=header, comments="")


if __name__ == "__main__":
Expand Down
Loading
Loading