Skip to content

Commit 604d729

Browse files
committed
added second attempt at a global solver, deprecated first version
1 parent 41b6505 commit 604d729

6 files changed

Lines changed: 447 additions & 116 deletions

File tree

json/global_solver_params.json5

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
// Incoming normalized Stokes vector [S1_hat, S2_hat, S3_hat]
3+
S_i_hat : [1.0, 0.0, 0.0], // Incoming polarization state
4+
wavelengths : [650, 550, 450], // R, G, B wavelengths (nm)
5+
C : [3e-9, 3e-9, 3e-9], // stress-optic coefficient for each wavelength (1/Pa)
6+
thickness : 0.01, // thickness of sample in m
7+
8+
// Dataset info
9+
input_filename : "images/test/disk_synthetic_images.tiff",
10+
11+
// Solver selection
12+
solver: "global",
13+
14+
global_solver: {
15+
// Global Solver Configuration
16+
knot_spacing: 5, // Distance between B-spline knots in pixels
17+
spline_degree: 3, // Degree of B-spline surfaces
18+
regularization_weight: 2000.0, // Penalty for coefficient oscillation (higher = smoother)
19+
boundary_weight: 1.0, // Weight for boundary condition enforcement
20+
21+
// Optional: Mask file for boundary conditions
22+
// boundary_mask_file: "path/to/mask.tif",
23+
24+
max_iterations: 100,
25+
tolerance: 1e0
26+
},
27+
28+
output_filename: "images/test/global_reconstruction.tiff",
29+
30+
debug: true
31+
}

photoelastimetry/bspline.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import numpy as np
2+
from scipy.interpolate import BSpline
3+
4+
5+
class BSplineAiry:
6+
"""
7+
Manages a tensor-product B-spline surface for the Airy stress function.
8+
9+
This class pre-computes basis functions to allow fast evaluation of
10+
stress fields (derivatives of the Airy function) from a set of control points.
11+
12+
The Airy stress function phi(x,y) is represented as:
13+
phi(x,y) = sum_i sum_j C_ij * B_i(x) * B_j(y)
14+
15+
Stresses are:
16+
sigma_xx = d^2(phi)/dy^2
17+
sigma_yy = d^2(phi)/dx^2
18+
sigma_xy = -d^2(phi)/dxdy
19+
"""
20+
21+
def __init__(self, shape, knot_spacing, degree=3):
22+
"""
23+
Initialize the B-spline basis for a given image shape.
24+
25+
Parameters
26+
----------
27+
shape : tuple
28+
Image shape (height, width).
29+
knot_spacing : int
30+
Approximate spacing between knots in pixels.
31+
degree : int
32+
Degree of the B-spline (default: 3 for cubic).
33+
"""
34+
self.ny, self.nx = shape
35+
self.degree = degree
36+
self.knot_spacing = knot_spacing
37+
38+
# Generate knots
39+
# We need knots to cover the range [0, n] with sufficient padding for the degree
40+
# interior knots
41+
tx = np.arange(0, self.nx + knot_spacing, knot_spacing)
42+
ty = np.arange(0, self.ny + knot_spacing, knot_spacing)
43+
44+
# Add simpler padding
45+
# Standard way for clamped B-spline is repeating start/end knots degree+1 times
46+
# But for general coverage we just need enough support.
47+
# Let's use the standard "clamped" knot vector construction for the domain [0, L]
48+
49+
def make_knots(length, step):
50+
# Interior knots
51+
interior = np.arange(0, length + step, step)
52+
if interior[-1] < length:
53+
interior = np.append(interior, length)
54+
55+
# Pad with clamped ends
56+
t = np.concatenate(([interior[0]] * degree, interior, [interior[-1]] * degree))
57+
return t
58+
59+
self.tx = make_knots(self.nx, knot_spacing)
60+
self.ty = make_knots(self.ny, knot_spacing)
61+
62+
# Number of coefficients (control points)
63+
self.n_coeffs_x = len(self.tx) - degree - 1
64+
self.n_coeffs_y = len(self.ty) - degree - 1
65+
self.n_coeffs = self.n_coeffs_x * self.n_coeffs_y
66+
67+
# Pre-evaluate basis functions on the pixel grid
68+
# We evaluate at pixel centers
69+
x_grid = np.arange(self.nx)
70+
y_grid = np.arange(self.ny)
71+
72+
# Evaluate basis functions B(x) and derivatives
73+
# This creates matrices of shape (width, n_coeffs_x)
74+
self.Bx, self.dBx, self.ddBx = self._precompute_basis(x_grid, self.tx, self.n_coeffs_x)
75+
self.By, self.dBy, self.ddBy = self._precompute_basis(y_grid, self.ty, self.n_coeffs_y)
76+
77+
def _precompute_basis(self, coords, knots, n_coeffs):
78+
"""
79+
Compute B-spline basis matrix and its 1st and 2nd derivatives.
80+
Returns matrices of shape (len(coords), n_coeffs).
81+
"""
82+
# We use scipy BSpline.design_matrix-like logic but explicit
83+
# We want to know the value of the i-th basis function at each coordinate.
84+
# B_mat[k, i] = B_i(coords[k])
85+
86+
B_mat = np.zeros((len(coords), n_coeffs))
87+
dB_mat = np.zeros((len(coords), n_coeffs))
88+
ddB_mat = np.zeros((len(coords), n_coeffs))
89+
90+
# Iterate over each basis function
91+
# This might be slow for very large grids, but it's done once.
92+
# A faster way is to realize only degree+1 functions are non-zero at any point.
93+
# But optimizing this pre-calc is secondary to the main loop speed.
94+
95+
for i in range(n_coeffs):
96+
# Create a localized BSpline for the i-th basis function
97+
# The coefficient vector is 1 at i and 0 elsewhere
98+
c = np.zeros(n_coeffs)
99+
c[i] = 1.0
100+
spl = BSpline(knots, c, self.degree)
101+
102+
B_mat[:, i] = spl(coords)
103+
dB_mat[:, i] = spl(coords, nu=1)
104+
ddB_mat[:, i] = spl(coords, nu=2)
105+
106+
return B_mat, dB_mat, ddB_mat
107+
108+
def get_stress_fields(self, coeffs_flat):
109+
"""
110+
Compute stress fields from flat coefficient array.
111+
112+
Parameters
113+
----------
114+
coeffs_flat : array-like
115+
Flattened array of coefficients of length n_coeffs_x * n_coeffs_y.
116+
117+
Returns
118+
-------
119+
sigma_xx, sigma_yy, sigma_xy : ndarray
120+
Stress fields of shape (height, width).
121+
"""
122+
C = coeffs_flat.reshape(self.n_coeffs_y, self.n_coeffs_x)
123+
124+
# sigma_xx = d^2(phi)/dy^2 = By'' * C * Bx.T
125+
# Shape: (ny, n_cy) @ (n_cy, n_cx) @ (n_cx, nx) -> (ny, nx)
126+
sigma_xx = self.ddBy @ C @ self.Bx.T
127+
128+
# sigma_yy = d^2(phi)/dx^2 = By * C * Bx''.T
129+
sigma_yy = self.By @ C @ self.ddBx.T
130+
131+
# sigma_xy = -d^2(phi)/dxdy = -(By' * C * Bx'.T)
132+
# Note: In image coordinates (y down), d/dy_img = -d/y_phy.
133+
# The cross derivative term gains a negative sign from the coordinate flip,
134+
# cancelling the negative sign in the Airey definition.
135+
# So sigma_xy = + d^2(phi)/dx_img dy_img
136+
sigma_xy = self.dBy @ C @ self.dBx.T
137+
138+
return sigma_xx, sigma_yy, sigma_xy

photoelastimetry/disk.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -477,7 +477,7 @@ def post_process_synthetic_data(
477477

478478
# Disk and load parameters
479479
R = 0.01 # Radius of the disk (m)
480-
P = 0.2 # Total load per unit thickness (N/m)
480+
P = 1.0 # Total load per unit thickness (N/m)
481481

482482
with open("json/test.json5", "r") as f:
483483
params = json5.load(f)

photoelastimetry/main.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@
77

88
import photoelastimetry.io
99
import photoelastimetry.plotting
10-
import photoelastimetry.solver.equilibrium_solver
10+
import photoelastimetry.solver.global_solver
1111
import photoelastimetry.solver.intensity_solver
1212
import photoelastimetry.solver.stokes_solver
1313

14+
# import photoelastimetry.solver.equilibrium_solver
15+
1416

1517
def image_to_stress(params, output_filename=None):
1618
"""
@@ -125,8 +127,22 @@ def image_to_stress(params, output_filename=None):
125127
S_I_HAT,
126128
n_jobs=n_jobs,
127129
)
130+
elif params.get("solver") == "global":
131+
# Global solver specific logic
132+
gs_params = params.get("global_solver", {})
133+
134+
boundary_mask = None
135+
if "boundary_mask_file" in gs_params:
136+
import tifffile
137+
138+
if os.path.exists(gs_params["boundary_mask_file"]):
139+
boundary_mask = tifffile.imread(gs_params["boundary_mask_file"]) > 0
140+
141+
stress_map = photoelastimetry.solver.global_solver.recover_stress_global(
142+
data, WAVELENGTHS, C_VALUES, NU, L, S_I_HAT, boundary_mask=boundary_mask, **gs_params
143+
)
128144
else:
129-
raise ValueError("Solver type not recognized. Use 'stokes', 'intensity', or 'equilibrium'.")
145+
raise ValueError("Solver type not recognized. Use 'stokes', 'intensity', 'equilibrium', or 'global'.")
130146

131147
if params.get("output_filename") is not None:
132148
output_filename = params["output_filename"]

photoelastimetry/solver/equilibrium_solver.py renamed to photoelastimetry/solver/equilibrium_solver.py.DEPRECATED

Lines changed: 2 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
method, this approach:
77

88
1. Ensures mechanical equilibrium by construction (through Airy stress function)
9-
2. Enforces smoothness globally via regularization
10-
3. Avoids local minima by solving a single global optimization problem
9+
2. Enforces smoothness globally via regularisation
10+
3. Avoids local minima by solving a single global optimisation problem
1111
4. Provides more stable results by incorporating spatial coupling
1212

1313
The Airy stress function φ(x,y) relates to stresses via:
@@ -30,117 +30,6 @@
3030
from photoelastimetry.solver import stokes_solver
3131

3232

33-
def build_finite_difference_operators(nx, ny, dx=1.0, dy=1.0):
34-
"""
35-
Build sparse finite difference operators for computing derivatives.
36-
37-
Uses central differences for interior points and forward/backward
38-
differences at boundaries.
39-
40-
Parameters
41-
----------
42-
nx : int
43-
Number of grid points in x direction.
44-
ny : int
45-
Number of grid points in y direction.
46-
dx : float, optional
47-
Grid spacing in x direction (default: 1.0).
48-
dy : float, optional
49-
Grid spacing in y direction (default: 1.0).
50-
51-
Returns
52-
-------
53-
D2x : scipy.sparse matrix
54-
Second derivative operator in x direction (∂²/∂x²).
55-
D2y : scipy.sparse matrix
56-
Second derivative operator in y direction (∂²/∂y²).
57-
Dxy : scipy.sparse matrix
58-
Mixed derivative operator (∂²/∂x∂y).
59-
L : scipy.sparse matrix
60-
Laplacian operator (∇²).
61-
"""
62-
from scipy.sparse import diags
63-
from scipy.sparse import eye as speye
64-
from scipy.sparse import kron
65-
66-
# 1D second derivative operator (central differences)
67-
# [1, -2, 1] / dx^2
68-
diag_1d = np.array([1.0, -2.0, 1.0])
69-
offsets_1d = np.array([-1, 0, 1])
70-
71-
# Build 1D operators
72-
D2_1d_x = diags(diag_1d, offsets_1d, shape=(nx, nx)) / dx**2
73-
D2_1d_y = diags(diag_1d, offsets_1d, shape=(ny, ny)) / dy**2
74-
75-
# 2D operators using Kronecker products
76-
# For a field organized as [φ(0,0), φ(1,0), ..., φ(nx-1,0), φ(0,1), ...]
77-
I_x = speye(nx)
78-
I_y = speye(ny)
79-
80-
# ∂²/∂x² operator
81-
D2x = kron(I_y, D2_1d_x)
82-
83-
# ∂²/∂y² operator
84-
D2y = kron(D2_1d_y, I_x)
85-
86-
# Mixed derivative ∂²/∂x∂y
87-
# First derivative operators
88-
diag_d1 = np.array([-0.5, 0.0, 0.5])
89-
offsets_d1 = np.array([-1, 0, 1])
90-
91-
Dx_1d = diags(diag_d1, offsets_d1, shape=(nx, nx)) / dx
92-
Dy_1d = diags(diag_d1, offsets_d1, shape=(ny, ny)) / dy
93-
94-
Dx = kron(I_y, Dx_1d)
95-
Dy = kron(Dy_1d, I_x)
96-
97-
# Mixed derivative: ∂/∂x(∂/∂y)
98-
Dxy = Dx @ Dy
99-
100-
# Laplacian (for regularization)
101-
L = D2x + D2y
102-
103-
return D2x, D2y, Dxy, L
104-
105-
106-
def airy_to_stress(phi, D2x, D2y, Dxy):
107-
"""
108-
Convert Airy stress function to stress components.
109-
110-
Parameters
111-
----------
112-
phi : ndarray
113-
Airy stress function values on grid (flattened or 2D).
114-
D2x : scipy.sparse matrix
115-
Second derivative operator in x direction.
116-
D2y : scipy.sparse matrix
117-
Second derivative operator in y direction.
118-
Dxy : scipy.sparse matrix
119-
Mixed derivative operator.
120-
121-
Returns
122-
-------
123-
sigma_xx : ndarray
124-
Normal stress in x direction (same shape as phi).
125-
sigma_yy : ndarray
126-
Normal stress in y direction (same shape as phi).
127-
sigma_xy : ndarray
128-
Shear stress (same shape as phi).
129-
"""
130-
phi_flat = phi.flatten()
131-
132-
# σ_xx = ∂²φ/∂y²
133-
sigma_xx = D2y @ phi_flat
134-
135-
# σ_yy = ∂²φ/∂x²
136-
sigma_yy = D2x @ phi_flat
137-
138-
# σ_xy = -∂²φ/∂x∂y
139-
sigma_xy = -Dxy @ phi_flat
140-
141-
return sigma_xx, sigma_yy, sigma_xy
142-
143-
14433
def compute_global_residual(
14534
phi,
14635
image_stack,

0 commit comments

Comments
 (0)