pycoupole 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MARGALL François
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycoupole
3
+ Version: 0.1.0
4
+ Summary: GPU-accelerated Monte Carlo uncertainty quantification for SVBRDF measurements on the La Coupole setup. Companion code to the associated Optics Express paper and dataset.
5
+ Author-email: François Margall <francois.margall@inria.fr>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://lacoupole.gitlabpages.inria.fr
8
+ Project-URL: Repository, https://gitlab.inria.fr/lacoupole/pycoupole
9
+ Project-URL: Tracker, https://gitlab.inria.fr/lacoupole/pycoupole/-/boards
10
+ Project-URL: Article, https://doi.org/10.1364/OE.587877
11
+ Project-URL: Supplemental, https://doi.org/10.6084/m9.figshare.31418393.v1
12
+ Keywords: monte-carlo,nvidia,uncertainty,metrology,warp,brdf,gum,coupole
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Classifier: Development Status :: 5 - Production/Stable
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE.txt
19
+ Requires-Dist: numpy
20
+ Requires-Dist: warp-lang>=1.14.0
21
+ Dynamic: license-file
22
+
23
+ # PyCoupole
24
+
25
+ GPU-accelerated Monte Carlo uncertainty quantification for SVBRDF measurements
26
+ on the La Coupole setup. Companion code to the associated Optics Express paper
27
+ and measured dataset.
28
+
29
+ ## Interpreting the output
30
+
31
+ `computeBRDFDistribution` returns a probability density, not a histogram of
32
+ counts. The density carries the inverse unit of the BRDF (sr), and integrates
33
+ to 1 over its support — so its values are not bounded by 1 and will be large
34
+ for a sharply peaked distribution. Recover the per-bin probability mass, then
35
+ the moments and credible intervals, as:
36
+
37
+ ```python
38
+ binWidth = brdfValues[1] - brdfValues[0]
39
+ mass = pdf * binWidth # sums to 1
40
+
41
+ mean = float(np.sum(mass * brdfValues))
42
+ std = float(np.sqrt(np.sum(mass * (brdfValues - mean) ** 2)))
43
+
44
+ cdf = np.cumsum(mass)
45
+ median = float(np.interp(0.50, cdf, brdfValues))
46
+ ci95_low = float(np.interp(0.025, cdf, brdfValues))
47
+ ci95_up = float(np.interp(0.975, cdf, brdfValues))
48
+ ```
49
+
50
+ For a well-conditioned acquisition the deterministic value sits near the centre
51
+ of the distribution and the out-of-range fraction (`1 - nbAccepted / nbTotal`)
52
+ is negligible.
53
+
54
+
55
+ ## Citation
56
+ If you use this software, please cite the associated paper as below (see also [CITATION.cff](./CITATION.cff)).
57
+
58
+ > **La Coupole: an SVBRDF measurement device for large and non-planar objects**.
59
+ > *Antoine Lucat, Pierre Mézières, François Margall, Louis De Oliveira,
60
+ > Marjorie Paillet, Arnaud Tizon, Pierre Bénard, Romain Pacanowski*.
61
+ > Optics Express, Vol. 34, Issue 7, pp. 11695-11709 (March 2026).
62
+ > DOI: [10.1364/OE.587877](https://doi.org/10.1364/OE.587877).
63
+
64
+ <details>
65
+ <summary>Associated BibTeX entry</summary>
66
+
67
+ ```bibtex
68
+ @article{Lucat:26,
69
+ author = {Antoine Lucat and Pierre M\'{e}zi\`{e}res and Fran\c{c}ois Margall and Louis De Oliveira and Marjorie Paillet and Arnaud Tizon and Pierre B\'{e}nard and Romain Pacanowski},
70
+ journal = {Optics Express},
71
+ keywords = {Camera calibration; Imaging systems; Light sources; Physiology; Printed circuit boards; Spatial resolution},
72
+ number = {7},
73
+ pages = {11695--11709},
74
+ publisher = {Optica Publishing Group},
75
+ title = {La Coupole: an SVBRDF measurement device for large and non-planar objects},
76
+ volume = {34},
77
+ month = {Apr},
78
+ year = {2026},
79
+ url = {https://opg.optica.org/oe/abstract.cfm?URI=oe-34-7-11695},
80
+ doi = {10.1364/OE.587877},
81
+ }
82
+ ```
83
+
84
+ </details>
85
+
86
+ > [!note]
87
+ > For a detailed derivation of the uncertainty estimation method, including assumptions, propagation steps, and validation, see Section 3 (pp. 14–20) of the associated Supplemental Material (DOI: [10.6084/m9.figshare.31418393.v1](https://doi.org/10.6084/m9.figshare.31418393.v1)).
88
+
89
+ ## License
90
+
91
+ PyCoupole is distributed under the MIT License. See [LICENSE.txt](./LICENSE.txt) for more information.
@@ -0,0 +1,69 @@
1
+ # PyCoupole
2
+
3
+ GPU-accelerated Monte Carlo uncertainty quantification for SVBRDF measurements
4
+ on the La Coupole setup. Companion code to the associated Optics Express paper
5
+ and measured dataset.
6
+
7
+ ## Interpreting the output
8
+
9
+ `computeBRDFDistribution` returns a probability density, not a histogram of
10
+ counts. The density carries the inverse unit of the BRDF (sr), and integrates
11
+ to 1 over its support — so its values are not bounded by 1 and will be large
12
+ for a sharply peaked distribution. Recover the per-bin probability mass, then
13
+ the moments and credible intervals, as:
14
+
15
+ ```python
16
+ binWidth = brdfValues[1] - brdfValues[0]
17
+ mass = pdf * binWidth # sums to 1
18
+
19
+ mean = float(np.sum(mass * brdfValues))
20
+ std = float(np.sqrt(np.sum(mass * (brdfValues - mean) ** 2)))
21
+
22
+ cdf = np.cumsum(mass)
23
+ median = float(np.interp(0.50, cdf, brdfValues))
24
+ ci95_low = float(np.interp(0.025, cdf, brdfValues))
25
+ ci95_up = float(np.interp(0.975, cdf, brdfValues))
26
+ ```
27
+
28
+ For a well-conditioned acquisition the deterministic value sits near the centre
29
+ of the distribution and the out-of-range fraction (`1 - nbAccepted / nbTotal`)
30
+ is negligible.
31
+
32
+
33
+ ## Citation
34
+ If you use this software, please cite the associated paper as below (see also [CITATION.cff](./CITATION.cff)).
35
+
36
+ > **La Coupole: an SVBRDF measurement device for large and non-planar objects**.
37
+ > *Antoine Lucat, Pierre Mézières, François Margall, Louis De Oliveira,
38
+ > Marjorie Paillet, Arnaud Tizon, Pierre Bénard, Romain Pacanowski*.
39
+ > Optics Express, Vol. 34, Issue 7, pp. 11695-11709 (March 2026).
40
+ > DOI: [10.1364/OE.587877](https://doi.org/10.1364/OE.587877).
41
+
42
+ <details>
43
+ <summary>Associated BibTeX entry</summary>
44
+
45
+ ```bibtex
46
+ @article{Lucat:26,
47
+ author = {Antoine Lucat and Pierre M\'{e}zi\`{e}res and Fran\c{c}ois Margall and Louis De Oliveira and Marjorie Paillet and Arnaud Tizon and Pierre B\'{e}nard and Romain Pacanowski},
48
+ journal = {Optics Express},
49
+ keywords = {Camera calibration; Imaging systems; Light sources; Physiology; Printed circuit boards; Spatial resolution},
50
+ number = {7},
51
+ pages = {11695--11709},
52
+ publisher = {Optica Publishing Group},
53
+ title = {La Coupole: an SVBRDF measurement device for large and non-planar objects},
54
+ volume = {34},
55
+ month = {Apr},
56
+ year = {2026},
57
+ url = {https://opg.optica.org/oe/abstract.cfm?URI=oe-34-7-11695},
58
+ doi = {10.1364/OE.587877},
59
+ }
60
+ ```
61
+
62
+ </details>
63
+
64
+ > [!note]
65
+ > For a detailed derivation of the uncertainty estimation method, including assumptions, propagation steps, and validation, see Section 3 (pp. 14–20) of the associated Supplemental Material (DOI: [10.6084/m9.figshare.31418393.v1](https://doi.org/10.6084/m9.figshare.31418393.v1)).
66
+
67
+ ## License
68
+
69
+ PyCoupole is distributed under the MIT License. See [LICENSE.txt](./LICENSE.txt) for more information.
@@ -0,0 +1,13 @@
1
+ # Import the two end-user functions used for BRDF and uncertainties
2
+ from .brdf import computeBRDFDeterministic, computeBRDFDistribution
3
+
4
+ __all__ = ["computeBRDFDeterministic", "computeBRDFDistribution",
5
+ "computeBRDFDeterministicFromCSV", "computeBRDFDistributionFromCSV",]
6
+
7
+
8
+ # Dyanmic version based on metadata (obtained from repo tag)
9
+ from importlib.metadata import version, PackageNotFoundError
10
+ try:
11
+ __version__ = version("pycoupole")
12
+ except PackageNotFoundError:
13
+ __version__ = "?.?.?"
@@ -0,0 +1,206 @@
1
+ import numpy as np, warp as wp
2
+
3
+
4
+ from .core._utils import _pickDevice, _vec2ui, _vec3d
5
+ from .core._kernels import _sampleBRDF, _sampleBRDFDistribution
6
+
7
+
8
+ def computeBRDFDeterministic(
9
+ pixelID, channelID, pixelValueLSB, exposureTimeS,
10
+ lightCenter, lightNormal, triangleCenter, triangleNormal,
11
+ device=None
12
+ ):
13
+ """Compute the deterministic (point-estimate) BRDF for a single pixel-channel sample.
14
+
15
+ Evaluates the measurement equation once with all uncertainty sources
16
+ disabled, i.e. every input held at its nominal (mean) value. This is the
17
+ central value around which `computeBRDFDistribution` propagates uncertainties.
18
+
19
+ Parameters
20
+ ----------
21
+ pixelID : tuple of int
22
+ Pixel coordinates (column, row) in the sensor frame.
23
+ channelID : int
24
+ Colour channel index (0, 1, 2 for R, G, B).
25
+ pixelValueLSB : int
26
+ Raw pixel value in least-significant-bit units (digital number).
27
+ exposureTimeS : float
28
+ Exposure time of the acquisition, in seconds.
29
+ lightCenter, triangleCenter : array_like of float, shape (3,)
30
+ 3D positions of the light-source centre and of the measured surface
31
+ element, in the scene coordinate system.
32
+ lightNormal, triangleNormal : array_like of float, shape (3,)
33
+ Unit normals of the light source and of the measured surface element.
34
+ device : str, optional
35
+ Warp device string (e.g. "cuda:0", "cpu"). Defaults to the first
36
+ available CUDA device, falling back to CPU.
37
+
38
+ Returns
39
+ -------
40
+ float
41
+ The BRDF value, in inverse steradians (sr^-1).
42
+
43
+ See Also
44
+ --------
45
+ computeBRDFDistribution : Monte Carlo uncertainty distribution of this value.
46
+ """
47
+
48
+ device = _pickDevice(device)
49
+
50
+ output = wp.empty(1, dtype=wp.float64, device=device)
51
+ wp.launch(
52
+ kernel = _sampleBRDF,
53
+ dim = 1,
54
+ inputs = [
55
+ _vec2ui(pixelID), int(channelID), int(pixelValueLSB), float(exposureTimeS),
56
+ _vec3d(lightCenter), _vec3d(lightNormal), _vec3d(triangleCenter), _vec3d(triangleNormal),
57
+ True, 0
58
+ ],
59
+ outputs = [output],
60
+ device = device,
61
+ )
62
+ wp.synchronize_device(device)
63
+ return float(output.numpy()[0])
64
+
65
+
66
+ def computeBRDFDistribution(
67
+ pixelID, channelID, pixelValueLSB, exposureTimeS,
68
+ lightCenter, lightNormal, triangleCenter, triangleNormal,
69
+ nbBins: int = 1000,
70
+ pilotSize: int = 100_000,
71
+ batchSize: int = 10_000_000,
72
+ maxBatches: int = 10,
73
+ outlierCutoff: float = 10.0,
74
+ seed: int = 1,
75
+ device: str = None
76
+ ):
77
+ """Estimate the BRDF uncertainty distribution by Monte Carlo propagation.
78
+
79
+ Propagates the measurement uncertainties (calibration factor, Spectralon
80
+ reflectance, ...) through the BRDF measurement equation by repeated
81
+ stochastic sampling, and returns the resulting probability density.
82
+
83
+ The computation runs in two passes:
84
+
85
+ 1. Pilot pass -- `pilotSize` samples are drawn to locate the distribution.
86
+ The histogram upper bound is set to ``outlierCutoff * median(pilot)``;
87
+ the lower bound is 0.
88
+ 2. Sampling pass -- exactly `maxBatches` batches of `batchSize` samples each
89
+ are accumulated into a fixed-range histogram. Each batch uses a disjoint
90
+ RNG seed, so batches are independent draws.
91
+
92
+ Parameters
93
+ ----------
94
+ pixelID, channelID, pixelValueLSB, exposureTimeS :
95
+ Acquisition descriptors; see `computeBRDFDeterministic`.
96
+ lightCenter, lightNormal, triangleCenter, triangleNormal : array_like, shape (3,)
97
+ Measurement geometry; see `computeBRDFDeterministic`.
98
+ nbBins : int, default 1000
99
+ Number of histogram bins spanning the distribution support.
100
+ pilotSize : int, default 100_000
101
+ Number of samples in the pilot pass used to set the histogram range.
102
+ batchSize : int, default 10_000_000
103
+ Number of samples drawn per batch in the sampling pass.
104
+ maxBatches : int, default 10
105
+ Number of batches drawn. Total sample count is ``maxBatches * batchSize``.
106
+ outlierCutoff : float, default 10.0
107
+ Multiple of the pilot median used as the histogram upper bound. Samples
108
+ beyond this bound are counted as out-of-range and excluded from the density.
109
+ seed : int, default 1
110
+ Base RNG seed for the sampling pass; batch ``i`` uses ``seed + 1 + i``.
111
+ The pilot pass uses a fixed, disjoint seed independent of this value.
112
+ device : str, optional
113
+ Warp device string. Defaults to the first available CUDA device,
114
+ falling back to CPU.
115
+
116
+ Returns
117
+ -------
118
+ dict
119
+ pdf : numpy.ndarray, shape (nbBins,)
120
+ Probability density at each bin centre, in steradians (sr, the
121
+ inverse of the BRDF unit). Normalized so the area under the curve
122
+ integrates to 1 over the histogram support; equivalently, the density
123
+ conditioned on a sample lying in range.
124
+ brdfValues : numpy.ndarray, shape (nbBins,)
125
+ Bin-centre BRDF values, in inverse steradians (sr^-1).
126
+ nbTotal : int
127
+ Total number of samples drawn (``maxBatches * batchSize``).
128
+ nbAccepted : int
129
+ Number of samples that fell within the histogram range. The
130
+ out-of-range fraction is ``1 - nbAccepted / nbTotal``; a large value
131
+ indicates the histogram range is mis-set.
132
+
133
+ Notes
134
+ -----
135
+ The returned density is *conditional* on samples lying within the histogram
136
+ support. When the out-of-range fraction is negligible (the expected regime),
137
+ it coincides with the unconditional density. A large rejection fraction
138
+ signals that `outlierCutoff` should be widened or the pilot range revisited.
139
+
140
+ See Also
141
+ --------
142
+ computeBRDFDeterministic : Deterministic point estimate of the BRDF.
143
+ """
144
+
145
+ device = _pickDevice(device)
146
+
147
+ # We'll start by a first pass as a pilot
148
+ # This allows us to see the range of the
149
+ # output distribution
150
+ pilot = wp.empty(pilotSize, dtype=wp.float64, device=device)
151
+ wp.launch(
152
+ kernel = _sampleBRDF,
153
+ dim = pilotSize,
154
+ inputs = [
155
+ _vec2ui(pixelID), int(channelID), int(pixelValueLSB), float(exposureTimeS),
156
+ _vec3d(lightCenter), _vec3d(lightNormal), _vec3d(triangleCenter), _vec3d(triangleNormal),
157
+ False, 0
158
+ ],
159
+ outputs = [pilot],
160
+ device = device,
161
+ )
162
+ wp.synchronize_device(device)
163
+
164
+ histogramMin = 0.0
165
+ histogramMax = outlierCutoff * float(np.median(pilot.numpy()))
166
+
167
+ # The second pass will be the true Monte Carlo sampling
168
+ histogram = wp.zeros(nbBins, dtype=wp.int64, device=device)
169
+ nbAccepted = wp.zeros(1, dtype=wp.int64, device=device)
170
+
171
+ for batchID in range(maxBatches):
172
+ # We'll use a disjoint seed per batch,
173
+ # that is distinct from the pilot seed
174
+ batchSeed = seed + 1 + batchID
175
+
176
+ wp.launch(
177
+ kernel = _sampleBRDFDistribution,
178
+ dim = batchSize,
179
+ inputs = [
180
+ _vec2ui(pixelID), int(channelID), int(pixelValueLSB), float(exposureTimeS),
181
+ _vec3d(lightCenter), _vec3d(lightNormal), _vec3d(triangleCenter), _vec3d(triangleNormal),
182
+ batchSeed, histogramMin, histogramMax, nbBins
183
+ ],
184
+ outputs = [histogram, nbAccepted],
185
+ device = device,
186
+ )
187
+ wp.synchronize_device(device)
188
+
189
+ nbTotal = (batchID + 1) * batchSize
190
+
191
+ counts = histogram.numpy()
192
+ binEdges = np.linspace(histogramMin, histogramMax, nbBins + 1)
193
+ brdfValues = 0.5 * (binEdges[:-1] + binEdges[1:]) # bin centers
194
+ binWidth = (histogramMax - histogramMin) / nbBins
195
+
196
+ # Normalize to a probability density: the area under the curve integrates to 1
197
+ # over the histogram support (counts.sum() == nbAccepted, the in-range samples)
198
+ inRange = counts.sum()
199
+ pdf = counts / (inRange * binWidth) if inRange > 0 else np.zeros_like(counts, dtype=np.float64)
200
+
201
+ return {
202
+ "pdf": pdf,
203
+ "brdfValues": brdfValues,
204
+ "nbTotal": nbTotal,
205
+ "nbAccepted": int(nbAccepted.numpy()[0]),
206
+ }
@@ -0,0 +1,373 @@
1
+ import warp as wp
2
+
3
+
4
+ # Camera properties
5
+ cameraDSNUE = wp.float64(2.) # DSNU (Dark Signal Non-Uniformity), in electrons
6
+ cameraPRNUPercent = wp.float64(0.0127) # PRNU (Photo Response Non-Uniformity), in percent
7
+ conversionFactorADC = wp.float64(3.33) # ADC (Analog-to-Digital) factor, in electrons/bit
8
+ cameraDarkIntensity = wp.float64(22.) # Dark intensity (in electrons/second)
9
+ cameraReadoutNoiseE = wp.float64(13.) # Readout noise (in electrons)
10
+ cameraSmallestTick = wp.float64(0.000019) # Smallest camera tick (in seconds)
11
+
12
+ # Flat field correction parameters (RGB channels)
13
+ flatFieldAlpha0 = wp.vec3d(-0.11834731, -0.12480557, -0.11442123)
14
+ flatFieldAlpha1 = wp.vec3d( 0.00827793, 0.00580036, 0.02310389)
15
+ flatFieldAlpha2 = wp.vec3d(-0.06498365, -0.06220726, -0.03930921)
16
+ flatFieldAlpha3 = wp.vec3d(-0.02969467, -0.01976859, -0.00969820)
17
+ flatFieldAlpha4 = wp.vec3d( 0.99646296, 0.99836206, 0.99823554)
18
+
19
+ flatFieldSigma0 = wp.vec3d(1.86561084e-04, 1.08971155e-04, 0.00084640)
20
+ flatFieldSigma1 = wp.vec3d(9.63867472e-05, 5.62999256e-05, 0.00043729)
21
+ flatFieldSigma2 = wp.vec3d(1.86500416e-04, 1.08935718e-04, 0.00084612)
22
+ flatFieldSigma3 = wp.vec3d(9.63710605e-05, 5.62907629e-05, 0.00043722)
23
+ flatFieldSigma4 = wp.vec3d(1.04160470e-04, 6.08405915e-05, 0.00047256)
24
+
25
+ # Uncertainty associated to the LEDs
26
+ uncertaintyLEDPosition = wp.float64(0.0007) # in meters (700 µm)
27
+ sizeLEDSide = wp.float64(0.007) # in meters (7 mm)
28
+ # Uncertainty is uniformly distributed between ± 40 µm (so ± 20 µm on half size)
29
+ # This value has been obtained by measuring the LED with a caliper (ref DIN 862)
30
+ uncertaintyLEDSide = wp.float64(0.000040) # in meters (40 µm)
31
+
32
+ # Uncertainty associated to the 3D scan
33
+ reflectanceSpatialUncertainty = wp.float64(0.00020) # in meters (200 µm)
34
+ geometricalSpatialUncertainty = wp.float64(0.00003) # in meters (30 µm)
35
+
36
+ # Uncertainty associated to the triangle
37
+ # it is supposed to be equilateral, with
38
+ # a length of 200 µm
39
+ circumscribedCircleTriangleRadius = wp.float64(0.000115470053) # R = 200 µm * sqrt(3) / 3
40
+
41
+ # Spectralon uncertainty is 0.98 ± [-0.0054 : 0.0054]
42
+ # More precisely, what does the LabSphere calibration
43
+ # certificate say is:
44
+ # 0.98 ± [-0.0054 : 0.0054] at 95 % confidence level
45
+ # This can be modelled with a gaussian distribution,
46
+ # with mean 0.98 and standard deviation 0.0054 / 2.
47
+ spectralonReflectance = wp.float64(0.98)
48
+ spectralonStandardDev = wp.float64(0.0054 / 2.)
49
+
50
+ # Calibration factor uncertainty (RBG channels)
51
+ calibrationFactorMean = wp.vec3d(12554958902.215567, 17434401087.369884, 12713082781.469217)
52
+ calibrationFactorStd = wp.vec3d(705685.39513860860, 702675.41479146033, 707437.30615753750)
53
+
54
+ # Color balancing (RGB)
55
+ colorBalance = wp.vec3d(1. / 0.9105837051966023, 1. / 0.83213626924054, 1. / 0.9071877745806616)
56
+
57
+
58
+ @wp.func
59
+ def _getPixelValue(
60
+ pixelValueLSB: wp.float64, # LSB value of the pixel (in bits)
61
+ exposureTimeS: wp.float64, # Pixel exposure time (in seconds)
62
+ noUncertainty: wp.bool,
63
+ rngState: wp.uint32) -> wp.float64:
64
+
65
+ if noUncertainty:
66
+ return pixelValueLSB
67
+
68
+ # 0. DAC (Digital-to-Analog) conversion
69
+ # From LSB value to electrons number
70
+ pixelValueElectrons = pixelValueLSB * conversionFactorADC
71
+
72
+ # 1. PRNU (Photo Response Non-Uniformity) noise
73
+ # Since it is given by the manufacturer as a
74
+ # maximum limit, we'll use a uniform distrib
75
+ PRNU = wp.float64(1.0) + (wp.float64(2.0) * wp.float64(wp.randf(rngState)) - wp.float64(1.0)) * cameraPRNUPercent
76
+
77
+ # 2. Shot noise (Photon counting noise). It is the fundamental
78
+ # limit on noise performance in light detection systems. It
79
+ # can be well representated using a Poisson distribution if
80
+ # we have computed the signal in terms of electrons number.
81
+ shotNoise = wp.float64(wp.poisson(rngState, wp.float32(pixelValueElectrons * PRNU)))
82
+
83
+ # 3. Dark noise (thermal noise). Somestimes called reset noise
84
+ # or kTC noise. This can also be represented using Poisson.
85
+ darkNoise = wp.float64(wp.poisson(rngState, wp.float32(cameraDarkIntensity * exposureTimeS)))
86
+
87
+ # 4. Read noise, sometimes called the output amplifier noise.
88
+ # More precisely, it can be separated in two parts, white
89
+ # noise (also known as Johnson noise), and flicker noise.
90
+ # We will follow here a Gaussian rule.
91
+ readoutNoise = wp.float64(wp.randn(rngState)) * cameraReadoutNoiseE
92
+
93
+ # 5. DSNU (Dark Signal Non-Uniformity)
94
+ # Can be represented with Gaussian.
95
+ DSNU = wp.float64(wp.randn(rngState)) * cameraDSNUE
96
+
97
+ # Quantify using ADC (Analog-to-Digital) conversion
98
+ value = wp.round((shotNoise + darkNoise + readoutNoise + DSNU) / conversionFactorADC)
99
+
100
+ # 6. Quantization uncertainty, using a uniform
101
+ # distribution of width that is equal to 1.
102
+ value += wp.float64(wp.randf(rngState)) - wp.float64(0.5)
103
+
104
+ return value
105
+
106
+ @wp.func
107
+ def _getFlatFieldCorrection(
108
+ pixelID : wp.vec2ui,
109
+ channelID: wp.uint32,
110
+ noUncertainty: wp.bool,
111
+ rngState : wp.uint32) -> wp.float64:
112
+
113
+ x = wp.float64(2. * (wp.float32(pixelID[0]) / 4096.) - 1.)
114
+ y = wp.float64(2. * (wp.float32(pixelID[1]) / 3072.) - 1.)
115
+
116
+ if noUncertainty:
117
+ return (flatFieldAlpha0[channelID] * x * x +
118
+ flatFieldAlpha1[channelID] * x +
119
+ flatFieldAlpha2[channelID] * y * y +
120
+ flatFieldAlpha3[channelID] * y +
121
+ flatFieldAlpha4[channelID])
122
+
123
+ alpha0 = flatFieldAlpha0[channelID] + flatFieldSigma0[channelID] * wp.float64(wp.randn(rngState))
124
+ alpha1 = flatFieldAlpha1[channelID] + flatFieldSigma1[channelID] * wp.float64(wp.randn(rngState))
125
+ alpha2 = flatFieldAlpha2[channelID] + flatFieldSigma2[channelID] * wp.float64(wp.randn(rngState))
126
+ alpha3 = flatFieldAlpha3[channelID] + flatFieldSigma3[channelID] * wp.float64(wp.randn(rngState))
127
+ alpha4 = flatFieldAlpha4[channelID] + flatFieldSigma4[channelID] * wp.float64(wp.randn(rngState))
128
+
129
+ return alpha0 * x * x + alpha1 * x + alpha2 * y * y + alpha3 * y + alpha4
130
+
131
+ @wp.func
132
+ def _branchlessONB(normal: wp.vec3d):
133
+ # Branchless orthonormal tangent frame (u, v) from a normal n
134
+ # Duff et al., 2017, JCGT. (n, u, v) right-handed orthonormal
135
+ # Requires |n| = 1.
136
+ x, y, z = normal[0], normal[1], normal[2]
137
+
138
+ sign = wp.copysign(wp.float64(1.), z)
139
+
140
+ a = -wp.float64(1.) / (sign + z)
141
+ b = x * y * a
142
+
143
+ u = wp.vec3d(wp.float64(1.) + sign * x * x * a, sign * b, -sign * x)
144
+ v = wp.vec3d(b, sign + y * y * a, -y)
145
+
146
+ return u, v
147
+
148
+ @wp.func
149
+ def _getLEDNormal(
150
+ normal : wp.vec3d, # LED normal
151
+ noUncertainty: wp.bool,
152
+ rngState: wp.uint32) -> wp.vec3d:
153
+
154
+ if noUncertainty:
155
+ return normal
156
+
157
+ # Orthonormal tangent frame
158
+ u, v = _branchlessONB(normal)
159
+
160
+ radius = wp.float64(0.250) # 25 cm radius
161
+ angleInit = wp.float64(wp.randf(rngState) * wp.tau) # [0; 2]
162
+ angle120 = angleInit + wp.float64(2.0943951023931953)
163
+ angle240 = angleInit + wp.float64(4.1887902047863905)
164
+
165
+ p0 = radius * (wp.cos(angleInit) * u + wp.sin(angleInit) * v)
166
+ p1 = radius * (wp.cos(angle120) * u + wp.sin(angle120) * v)
167
+ p2 = radius * (wp.cos(angle240) * u + wp.sin(angle240) * v)
168
+
169
+ # Every vertex (LED) has its own uncertainty that we will add
170
+ p0 += wp.vec3d(wp.sample_unit_sphere(rngState)) * uncertaintyLEDPosition
171
+ p1 += wp.vec3d(wp.sample_unit_sphere(rngState)) * uncertaintyLEDPosition
172
+ p2 += wp.vec3d(wp.sample_unit_sphere(rngState)) * uncertaintyLEDPosition
173
+
174
+ ledNormal = wp.normalize(wp.cross(p1 - p0, p2 - p0))
175
+
176
+ # Should we flip the new normal to be sure it will
177
+ # point in the same direction as the original one?
178
+ flipFactor = wp.copysign(wp.float64(1.), wp.dot(ledNormal, normal))
179
+ return flipFactor * ledNormal
180
+
181
+ @wp.func
182
+ def _getTriangleNormal(
183
+ normal : wp.vec3d, # Triangle normal
184
+ noUncertainty: wp.bool,
185
+ rngState: wp.uint32) -> wp.vec3d:
186
+
187
+ if noUncertainty:
188
+ return normal
189
+
190
+ # Orthonormal tangent frame
191
+ u, v = _branchlessONB(normal)
192
+
193
+ angleInit = wp.float64(wp.randf(rngState) * wp.tau) # [0; 2]
194
+ angle120 = angleInit + wp.float64(2.0943951023931953)
195
+ angle240 = angleInit + wp.float64(4.1887902047863905)
196
+
197
+ p0 = circumscribedCircleTriangleRadius * (wp.cos(angleInit) * u + wp.sin(angleInit) * v)
198
+ p1 = circumscribedCircleTriangleRadius * (wp.cos(angle120) * u + wp.sin(angle120) * v)
199
+ p2 = circumscribedCircleTriangleRadius * (wp.cos(angle240) * u + wp.sin(angle240) * v)
200
+
201
+ # Every vertex has its own uncertainty that we will add
202
+ p0 += wp.vec3d(wp.sample_unit_sphere(rngState)) * geometricalSpatialUncertainty
203
+ p1 += wp.vec3d(wp.sample_unit_sphere(rngState)) * geometricalSpatialUncertainty
204
+ p2 += wp.vec3d(wp.sample_unit_sphere(rngState)) * geometricalSpatialUncertainty
205
+
206
+ triangleNormal = wp.normalize(wp.cross(p1 - p0, p2 - p0))
207
+
208
+ # Should we flip the new normal to be sure it will
209
+ # point in the same direction as the original one?
210
+ flipFactor = wp.copysign(wp.float64(1.), wp.dot(triangleNormal, normal))
211
+ return flipFactor * triangleNormal
212
+
213
+ @wp.func
214
+ def _getCalibrationFactor(
215
+ channelID: wp.uint32,
216
+ noUncertainty: wp.bool,
217
+ rngState: wp.uint32) -> wp.float64:
218
+ # See Eq. S19 of Supplemental Document
219
+ # DOI: 10.6084/m9.figshare.31418393.v1
220
+
221
+ # The calibration factor corresponds to the second term in teal
222
+ # This one is independant of the measurement configuration. The
223
+ # two terms composing it are the numerator and the denominator.
224
+
225
+ if noUncertainty:
226
+ numerator = spectralonReflectance / wp.float64(wp.pi)
227
+ denominator = calibrationFactorMean[channelID]
228
+ return colorBalance[channelID] * numerator / denominator
229
+
230
+ numerator = spectralonReflectance * (wp.float64(1.) + spectralonStandardDev * wp.float64(wp.randn(rngState))) / wp.float64(wp.pi)
231
+ denominator = calibrationFactorMean[channelID] + calibrationFactorStd[channelID] * wp.float64(wp.randn(rngState))
232
+
233
+ return colorBalance[channelID] * numerator / denominator
234
+
235
+ @wp.func
236
+ def _getEdgeContribution(
237
+ x : wp.vec3d,
238
+ p0Vertex: wp.vec3d,
239
+ p1Vertex: wp.vec3d) -> wp.vec3d:
240
+ # See Eq. 14 of Supplemental Document:
241
+ # DOI: 10.6084/m9.figshare.31418393.v1
242
+ v0 = wp.normalize(p0Vertex - x)
243
+ v1 = wp.normalize(p1Vertex - x)
244
+
245
+ cross = wp.normalize(wp.cross(v0, v1))
246
+
247
+ return wp.acos(wp.dot(v0, v1)) * cross
248
+
249
+ @wp.func
250
+ def _getGeometricVector(
251
+ x : wp.vec3d,
252
+ p0Vertex: wp.vec3d,
253
+ p1Vertex: wp.vec3d,
254
+ p2Vertex: wp.vec3d,
255
+ p3Vertex: wp.vec3d) -> wp.vec3d:
256
+ # See Eq. S14 of Supplemental Document
257
+ # DOI: 10.6084/m9.figshare.31418393.v1
258
+ return (_getEdgeContribution(x, p0Vertex, p1Vertex) +
259
+ _getEdgeContribution(x, p1Vertex, p2Vertex) +
260
+ _getEdgeContribution(x, p2Vertex, p3Vertex) +
261
+ _getEdgeContribution(x, p3Vertex, p0Vertex))
262
+
263
+ @wp.func
264
+ def _getGeometricFactor(
265
+ initialLightCenter: wp.vec3d,
266
+ initialLightNormal: wp.vec3d,
267
+ initialTriangleCenter: wp.vec3d,
268
+ initialTriangleNormal: wp.vec3d,
269
+ noUncertainty: wp.bool,
270
+ rngState: wp.uint32) -> wp.float64:
271
+ # See Eq. S19 of Supplemental Document
272
+ # DOI: 10.6084/m9.figshare.31418393.v1
273
+
274
+ triangleNormal = _getTriangleNormal(initialTriangleNormal, noUncertainty, rngState)
275
+ lightNormal = _getLEDNormal(initialLightNormal, noUncertainty, rngState)
276
+
277
+ if noUncertainty:
278
+ triangleCenter = initialTriangleCenter
279
+ lightCenter = initialLightCenter
280
+ halfSizeLED = sizeLEDSide * wp.float64(0.5)
281
+
282
+ # LED corners (deterministic)
283
+ u, v = _branchlessONB(lightNormal)
284
+ u *= halfSizeLED
285
+ v *= halfSizeLED
286
+ p0Light = lightCenter - u - v
287
+ p1Light = lightCenter + u - v
288
+ p2Light = lightCenter + u + v
289
+ p3Light = lightCenter - u + v
290
+
291
+ # No surface sampling — use centers
292
+ lightPosition = lightCenter
293
+ xPosition = triangleCenter
294
+ else:
295
+ triangleCenter = initialTriangleCenter + wp.vec3d(wp.sample_unit_sphere(rngState)) * reflectanceSpatialUncertainty
296
+ lightCenter = initialLightCenter + wp.vec3d(wp.sample_unit_sphere(rngState)) * uncertaintyLEDPosition
297
+ halfSizeLED = (sizeLEDSide + wp.float64(2. * wp.randf(rngState) - 1.) * uncertaintyLEDSide) * wp.float64(0.5)
298
+
299
+ # LED corners
300
+ u = wp.cross(lightNormal, wp.vec3d(0., 0., 1.))
301
+ v = wp.cross(lightNormal, u)
302
+ u, v = _branchlessONB(lightNormal)
303
+ u *= halfSizeLED
304
+ v *= halfSizeLED
305
+ p0Light = lightCenter - u - v
306
+ p1Light = lightCenter + u - v
307
+ p2Light = lightCenter + u + v
308
+ p3Light = lightCenter - u + v
309
+
310
+ # Uniform sample on LED square
311
+ uv = wp.vec2d(wp.vec2f(wp.randf(rngState), wp.randf(rngState)))
312
+ lightPosition = p0Light + wp.float64(2.) * u * uv[0] + wp.float64(2.) * v * uv[1]
313
+
314
+ # Uniform sample on equilateral triangle (centroid = triangleCenter)
315
+ a, b = _branchlessONB(triangleNormal)
316
+ a *= wp.float64(0.0002)
317
+ b *= wp.float64(0.0002)
318
+ T0 = triangleCenter - wp.float64(0.5) * a - wp.float64(wp.sqrt(3.) / 6.) * b
319
+ T1 = triangleCenter + wp.float64(0.5) * a - wp.float64(wp.sqrt(3.) / 6.) * b
320
+ T2 = triangleCenter + wp.float64((wp.sqrt(3.) / 3.)) * b
321
+ ab = wp.vec2d(wp.sample_triangle(rngState))
322
+ xPosition = T0 * (wp.float64(1.) - ab[0] - ab[1]) + T1 * ab[0] + T2 * ab[1]
323
+
324
+ # Compute geometric vector
325
+ geometricVector = _getGeometricVector(xPosition, p0Light, p1Light, p2Light, p3Light)
326
+
327
+ # Compute direction from surface to light.
328
+ lightDirection = wp.normalize(lightPosition - xPosition)
329
+
330
+ return wp.abs(wp.dot(geometricVector, triangleNormal) * wp.abs(wp.dot(lightNormal, lightDirection)))
331
+
332
+ @wp.func
333
+ def _getBRDFValue(
334
+ pixelID : wp.vec2ui,
335
+ channelID : wp.uint32,
336
+ pixelValueLSB: wp.uint16,
337
+ exposureTimeS: wp.float64,
338
+
339
+ lightCenter : wp.vec3d,
340
+ lightNormal : wp.vec3d,
341
+ triangleCenter: wp.vec3d,
342
+ triangleNormal: wp.vec3d,
343
+
344
+ noUncertainty: wp.bool,
345
+ rngState: wp.uint32) -> wp.float64:
346
+
347
+ # Adding an uncertainty over the exposure time
348
+ # Let's take the smallest camera tick possible
349
+ actualExposureS = exposureTimeS
350
+ if not noUncertainty:
351
+ actualExposureS = exposureTimeS + cameraSmallestTick * wp.float64((2. * wp.randf(rngState) - 1.))
352
+ actualExposureS = wp.max(actualExposureS, cameraSmallestTick)
353
+
354
+ # Compute the flat field correction with uncertainty
355
+ flatFieldCorrection = _getFlatFieldCorrection(pixelID, channelID, noUncertainty, rngState)
356
+
357
+ # Compute the pixel value with uncertainty
358
+ pixelValueLSBf = _getPixelValue(wp.float64(pixelValueLSB), actualExposureS, noUncertainty, rngState)
359
+
360
+ # f_# number (or aperture) squared
361
+ # is supposed without uncertainty.
362
+ apertureSquared = wp.float64(64.) # 8²
363
+
364
+ # Compute the uncertainty of the calibration factor
365
+ calibrationFactor = _getCalibrationFactor(channelID, noUncertainty, rngState)
366
+
367
+ # Compute the geometric factor
368
+ geometricFactor = _getGeometricFactor(lightCenter, lightNormal, triangleCenter, triangleNormal, noUncertainty, rngState)
369
+
370
+ # See Eq. S19 of Supplemental Document
371
+ # DOI: 10.6084/m9.figshare.31418393.v1
372
+ BRDF = (pixelValueLSBf / flatFieldCorrection) * apertureSquared * calibrationFactor / (actualExposureS * geometricFactor)
373
+ return BRDF
@@ -0,0 +1,55 @@
1
+ import warp as wp
2
+
3
+ from ._functions import _getBRDFValue
4
+
5
+
6
+ @wp.kernel
7
+ def _sampleBRDF(
8
+ pixelID: wp.vec2ui,
9
+ channelID: wp.uint32,
10
+ pixelValueLSB: wp.uint16,
11
+ exposureTimeS: wp.float64,
12
+ lightCenter: wp.vec3d,
13
+ lightNormal: wp.vec3d,
14
+ triangleCenter: wp.vec3d,
15
+ triangleNormal: wp.vec3d,
16
+ removeAllUncertainties: wp.bool,
17
+ seed: int,
18
+ samples: wp.array(dtype=wp.float64)
19
+ ):
20
+ ID = wp.tid()
21
+ rngState = wp.rand_init(seed, ID)
22
+
23
+ samples[ID] = _getBRDFValue(pixelID, channelID, pixelValueLSB, exposureTimeS,
24
+ lightCenter, lightNormal, triangleCenter, triangleNormal,
25
+ removeAllUncertainties, rngState)
26
+
27
+ @wp.kernel
28
+ def _sampleBRDFDistribution(
29
+ pixelID: wp.vec2ui,
30
+ channelID: wp.uint32,
31
+ pixelValueLSB: wp.uint16,
32
+ exposureTimeS: wp.float64,
33
+ lightCenter: wp.vec3d,
34
+ lightNormal: wp.vec3d,
35
+ triangleCenter: wp.vec3d,
36
+ triangleNormal: wp.vec3d,
37
+ seed: int,
38
+ histogramMin: wp.float64,
39
+ histogramMax: wp.float64,
40
+ nbBins: wp.int32,
41
+
42
+ histogram: wp.array(dtype=wp.int64),
43
+ nbAccepted: wp.array(dtype=wp.int64),
44
+ ):
45
+ ID = wp.tid()
46
+ rngState = wp.rand_init(seed, ID)
47
+
48
+ brdf = _getBRDFValue(pixelID, channelID, pixelValueLSB, exposureTimeS,
49
+ lightCenter, lightNormal, triangleCenter, triangleNormal,
50
+ False, rngState)
51
+
52
+ if brdf >= histogramMin and brdf < histogramMax:
53
+ binID = wp.int32((brdf - histogramMin) / (histogramMax - histogramMin) * wp.float64(nbBins))
54
+ wp.atomic_add(histogram, binID, wp.int64(1))
55
+ wp.atomic_add(nbAccepted, 0, wp.int64(1))
@@ -0,0 +1,13 @@
1
+ import warp as wp
2
+
3
+
4
+ def _pickDevice(device):
5
+ if device is not None:
6
+ return device
7
+ return "cuda:0" if wp.is_cuda_available() else "cpu"
8
+
9
+ def _vec3d(vec):
10
+ return wp.vec3d(float(vec[0]), float(vec[1]), float(vec[2]))
11
+
12
+ def _vec2ui(vec):
13
+ return wp.vec2ui(int(vec[0]), int(vec[1]))
@@ -0,0 +1,104 @@
1
+ import csv
2
+ from pathlib import Path
3
+
4
+ from .brdf import computeBRDFDeterministic, computeBRDFDistribution
5
+
6
+
7
+ def _readRows(path, rows):
8
+ if rows is None:
9
+ wanted = None
10
+ elif isinstance(rows, int):
11
+ wanted = {rows}
12
+ else:
13
+ wanted = set(rows)
14
+
15
+ selected = []
16
+ with Path(path).open(newline="") as f:
17
+ reader = csv.DictReader(f)
18
+ for index, record in enumerate(reader):
19
+ if wanted is None or index in wanted:
20
+ selected.append((index, record))
21
+ if wanted is not None and len(selected) == len(wanted):
22
+ break # stop early once every requested row is found
23
+
24
+ if wanted is not None and len(selected) != len(wanted):
25
+ found = {index for index, _ in selected}
26
+ missing = sorted(wanted - found)
27
+ raise IndexError(f"CSV row(s) not found in {path}: {missing}")
28
+
29
+ return selected
30
+
31
+ def _rowToKwargs(row):
32
+ return dict(
33
+ pixelID = (int(row["i"]), int(row["j"])),
34
+ channelID = int(row["color"]),
35
+ pixelValueLSB = int(row["valuePixel"]),
36
+ exposureTimeS = float(row["exposure"]),
37
+ lightCenter = (float(row["l.x"]), float(row["l.y"]), float(row["l.z"])),
38
+ lightNormal = (float(row["nl.x"]), float(row["nl.y"]), float(row["nl.z"])),
39
+ triangleCenter = (float(row["x.x"]), float(row["x.y"]), float(row["x.z"])),
40
+ triangleNormal = (float(row["nt.x"]), float(row["nt.y"]), float(row["nt.z"])),
41
+ )
42
+
43
+
44
+ def computeBRDFDeterministicFromCSV(path, rows=None, device=None):
45
+ """Run the deterministic BRDF estimate on selected rows of a measurement CSV
46
+
47
+ Parameters
48
+ ----------
49
+ path : str or pathlib.Path
50
+ Path to the measurement CSV.
51
+ rows : int, iterable of int, or None, default None
52
+ Zero-based data-row indices to process. A single int processes one row;
53
+ None processes every row.
54
+ device : str, optional
55
+ Warp device string. Defaults to the first available CUDA device,
56
+ falling back to CPU.
57
+
58
+ Returns
59
+ -------
60
+ list of (int, float)
61
+ Pairs of (row index, BRDF value in sr^-1), in increasing index order.
62
+
63
+ Notes
64
+ -----
65
+ This runs one single-thread GPU launch per row, which is convenient but not
66
+ efficient for very large files. A GPU parallelized version may be done soon
67
+ """
68
+
69
+ return [(index, computeBRDFDeterministic(device=device, **_rowToKwargs(record)))
70
+ for index, record in _readRows(path, rows)]
71
+
72
+
73
+ def computeBRDFDistributionFromCSV(path, row, device=None, **kwargs):
74
+ """Run the Monte Carlo BRDF distribution on a single row of a measurement CSV.
75
+
76
+ Unlike the deterministic helper, this accepts exactly one row: a single
77
+ distribution already saturates the GPU, and per-row results are typically
78
+ inspected or stored individually rather than aggregated.
79
+
80
+ Parameters
81
+ ----------
82
+ path : str or pathlib.Path
83
+ Path to the measurement CSV.
84
+ row : int
85
+ Zero-based data-row index to process.
86
+ device : str, optional
87
+ Warp device string. Defaults to the first available CUDA device,
88
+ falling back to CPU.
89
+ **kwargs
90
+ Forwarded to `computeBRDFDistribution` (e.g. nbBins, batchSize, seed).
91
+
92
+ Returns
93
+ -------
94
+ dict
95
+ The distribution dict returned by `computeBRDFDistribution`
96
+ (keys: pdf, brdfValues, nbTotal, nbAccepted).
97
+ """
98
+
99
+ if not isinstance(row, int):
100
+ raise TypeError("computeBRDFDistributionFromCSV processes single row; got"
101
+ f" {type(row).__name__}. Loop in Python for multiple rows.")
102
+
103
+ (_, record), = _readRows(path, row)
104
+ return computeBRDFDistribution(device=device, **_rowToKwargs(record), **kwargs)
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.4
2
+ Name: pycoupole
3
+ Version: 0.1.0
4
+ Summary: GPU-accelerated Monte Carlo uncertainty quantification for SVBRDF measurements on the La Coupole setup. Companion code to the associated Optics Express paper and dataset.
5
+ Author-email: François Margall <francois.margall@inria.fr>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://lacoupole.gitlabpages.inria.fr
8
+ Project-URL: Repository, https://gitlab.inria.fr/lacoupole/pycoupole
9
+ Project-URL: Tracker, https://gitlab.inria.fr/lacoupole/pycoupole/-/boards
10
+ Project-URL: Article, https://doi.org/10.1364/OE.587877
11
+ Project-URL: Supplemental, https://doi.org/10.6084/m9.figshare.31418393.v1
12
+ Keywords: monte-carlo,nvidia,uncertainty,metrology,warp,brdf,gum,coupole
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: Topic :: Scientific/Engineering :: Physics
15
+ Classifier: Development Status :: 5 - Production/Stable
16
+ Requires-Python: >=3.10
17
+ Description-Content-Type: text/markdown
18
+ License-File: LICENSE.txt
19
+ Requires-Dist: numpy
20
+ Requires-Dist: warp-lang>=1.14.0
21
+ Dynamic: license-file
22
+
23
+ # PyCoupole
24
+
25
+ GPU-accelerated Monte Carlo uncertainty quantification for SVBRDF measurements
26
+ on the La Coupole setup. Companion code to the associated Optics Express paper
27
+ and measured dataset.
28
+
29
+ ## Interpreting the output
30
+
31
+ `computeBRDFDistribution` returns a probability density, not a histogram of
32
+ counts. The density carries the inverse unit of the BRDF (sr), and integrates
33
+ to 1 over its support — so its values are not bounded by 1 and will be large
34
+ for a sharply peaked distribution. Recover the per-bin probability mass, then
35
+ the moments and credible intervals, as:
36
+
37
+ ```python
38
+ binWidth = brdfValues[1] - brdfValues[0]
39
+ mass = pdf * binWidth # sums to 1
40
+
41
+ mean = float(np.sum(mass * brdfValues))
42
+ std = float(np.sqrt(np.sum(mass * (brdfValues - mean) ** 2)))
43
+
44
+ cdf = np.cumsum(mass)
45
+ median = float(np.interp(0.50, cdf, brdfValues))
46
+ ci95_low = float(np.interp(0.025, cdf, brdfValues))
47
+ ci95_up = float(np.interp(0.975, cdf, brdfValues))
48
+ ```
49
+
50
+ For a well-conditioned acquisition the deterministic value sits near the centre
51
+ of the distribution and the out-of-range fraction (`1 - nbAccepted / nbTotal`)
52
+ is negligible.
53
+
54
+
55
+ ## Citation
56
+ If you use this software, please cite the associated paper as below (see also [CITATION.cff](./CITATION.cff)).
57
+
58
+ > **La Coupole: an SVBRDF measurement device for large and non-planar objects**.
59
+ > *Antoine Lucat, Pierre Mézières, François Margall, Louis De Oliveira,
60
+ > Marjorie Paillet, Arnaud Tizon, Pierre Bénard, Romain Pacanowski*.
61
+ > Optics Express, Vol. 34, Issue 7, pp. 11695-11709 (March 2026).
62
+ > DOI: [10.1364/OE.587877](https://doi.org/10.1364/OE.587877).
63
+
64
+ <details>
65
+ <summary>Associated BibTeX entry</summary>
66
+
67
+ ```bibtex
68
+ @article{Lucat:26,
69
+ author = {Antoine Lucat and Pierre M\'{e}zi\`{e}res and Fran\c{c}ois Margall and Louis De Oliveira and Marjorie Paillet and Arnaud Tizon and Pierre B\'{e}nard and Romain Pacanowski},
70
+ journal = {Optics Express},
71
+ keywords = {Camera calibration; Imaging systems; Light sources; Physiology; Printed circuit boards; Spatial resolution},
72
+ number = {7},
73
+ pages = {11695--11709},
74
+ publisher = {Optica Publishing Group},
75
+ title = {La Coupole: an SVBRDF measurement device for large and non-planar objects},
76
+ volume = {34},
77
+ month = {Apr},
78
+ year = {2026},
79
+ url = {https://opg.optica.org/oe/abstract.cfm?URI=oe-34-7-11695},
80
+ doi = {10.1364/OE.587877},
81
+ }
82
+ ```
83
+
84
+ </details>
85
+
86
+ > [!note]
87
+ > For a detailed derivation of the uncertainty estimation method, including assumptions, propagation steps, and validation, see Section 3 (pp. 14–20) of the associated Supplemental Material (DOI: [10.6084/m9.figshare.31418393.v1](https://doi.org/10.6084/m9.figshare.31418393.v1)).
88
+
89
+ ## License
90
+
91
+ PyCoupole is distributed under the MIT License. See [LICENSE.txt](./LICENSE.txt) for more information.
@@ -0,0 +1,14 @@
1
+ LICENSE.txt
2
+ README.md
3
+ pyproject.toml
4
+ pycoupole/__init__.py
5
+ pycoupole/brdf.py
6
+ pycoupole/io.py
7
+ pycoupole.egg-info/PKG-INFO
8
+ pycoupole.egg-info/SOURCES.txt
9
+ pycoupole.egg-info/dependency_links.txt
10
+ pycoupole.egg-info/requires.txt
11
+ pycoupole.egg-info/top_level.txt
12
+ pycoupole/core/_functions.py
13
+ pycoupole/core/_kernels.py
14
+ pycoupole/core/_utils.py
@@ -0,0 +1,2 @@
1
+ numpy
2
+ warp-lang>=1.14.0
@@ -0,0 +1 @@
1
+ pycoupole
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools >= 64", "setuptools-git-versioning >= 2.0, < 3"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pycoupole"
7
+ dynamic = ["version"]
8
+ dependencies = [
9
+ "numpy",
10
+ "warp-lang>=1.14.0"
11
+ ]
12
+ requires-python = ">=3.10"
13
+ authors = [
14
+ { name = "François Margall", email = "francois.margall@inria.fr" },
15
+ ]
16
+ description = "GPU-accelerated Monte Carlo uncertainty quantification for SVBRDF measurements on the La Coupole setup. Companion code to the associated Optics Express paper and dataset."
17
+ readme = "README.md"
18
+ license = "MIT"
19
+ license-files = ["LICEN[CS]E.*"]
20
+ keywords = ["monte-carlo", "nvidia", "uncertainty", "metrology", "warp", "brdf", "gum", "coupole"]
21
+ classifiers = [
22
+ "Intended Audience :: Science/Research",
23
+ "Topic :: Scientific/Engineering :: Physics",
24
+ "Development Status :: 5 - Production/Stable"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://lacoupole.gitlabpages.inria.fr"
28
+ Repository = "https://gitlab.inria.fr/lacoupole/pycoupole"
29
+ Tracker = "https://gitlab.inria.fr/lacoupole/pycoupole/-/boards"
30
+ Article = "https://doi.org/10.1364/OE.587877"
31
+ Supplemental = "https://doi.org/10.6084/m9.figshare.31418393.v1"
32
+
33
+ [tool.setuptools.packages.find]
34
+ where = ["."]
35
+ include = ["pycoupole*"]
36
+
37
+ [tool.setuptools-git-versioning]
38
+ enabled = true
39
+ dirty_template = "{tag}"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+