integrated finger process pipeline on remapped 2d image

This commit is contained in:
ykcheungab
2026-06-14 17:48:30 +08:00
parent 667bdf1961
commit 30bebcd439
36 changed files with 3593 additions and 2043 deletions
+5
View File
@@ -0,0 +1,5 @@
from .capacitance import CapacitanceFFTSolver
from .masked_sparse import MaskedSparseSolver
from .rectangular import RectangularFFTSolver
__all__ = ["RectangularFFTSolver", "MaskedSparseSolver", "CapacitanceFFTSolver"]
+3
View File
@@ -0,0 +1,3 @@
"""Shared solver protocol and helpers."""
__all__ = []
+113
View File
@@ -0,0 +1,113 @@
import numpy
from scipy import linalg as dense_linalg
from ..config import MaskedPoissonConfig
from ..domain import MaskedDomain, compute_boundary_mask, sample_boundary_coords
class CapacitanceFFTSolver:
"""Masked Poisson via rectangular FFT bulk solve + capacitance boundary correction."""
def __init__(self, config: MaskedPoissonConfig, bulk_solver):
self.config = config
self.bulk_solver = bulk_solver
self._capfft_cache = None
self._capfft_domain = None
def prepare(self, domain: MaskedDomain):
"""Precompute capacitance matrix for boundary correction.
domain: MaskedDomain with built Laplacian
returns: capacitance cache dict (also stored internally)
"""
self._capfft_domain = domain
self._capfft_cache = self._build_capfft_cache(domain)
return self._capfft_cache
def _build_capfft_cache(self, domain: MaskedDomain):
active = domain.mask
boundary_coords = numpy.argwhere(compute_boundary_mask(active))
boundary_coords = sample_boundary_coords(boundary_coords, self.config)
if boundary_coords.size == 0:
pinned_rc = numpy.array(domain.active_coords[domain.pinned], dtype=numpy.int32)
boundary_coords = pinned_rc.reshape(1, 2)
boundary_active_indices = domain.index_map[boundary_coords[:, 0], boundary_coords[:, 1]]
boundary_count = int(len(boundary_active_indices))
cap_matrix = numpy.zeros((boundary_count, boundary_count), dtype=numpy.float64)
zero_rhs = numpy.zeros(domain.num_active, dtype=numpy.float64)
for col, (row, col_idx) in enumerate(boundary_coords):
charge = numpy.zeros(active.shape, dtype=numpy.float32)
charge[row, col_idx] = 1.0
response = numpy.asarray(self.bulk_solver.solve(charge), dtype=numpy.float64)
residual = domain.residual(response, zero_rhs)
cap_matrix[:, col] = residual[boundary_active_indices]
reg_scale = self.config.reg
if reg_scale <= 0:
reg_scale = 1e-8
reg = reg_scale * max(1.0, float(numpy.linalg.norm(cap_matrix, ord=numpy.inf)))
cap_matrix_reg = cap_matrix + reg * numpy.eye(boundary_count, dtype=numpy.float64)
lu, piv = dense_linalg.lu_factor(cap_matrix_reg)
return {
"boundary_coords": boundary_coords,
"boundary_active_indices": boundary_active_indices,
"boundary_count": boundary_count,
"cap_matrix": cap_matrix,
"cap_matrix_reg": cap_matrix_reg,
"cap_lu": lu,
"cap_piv": piv,
"reg": reg,
}
def get_cache(self, domain: MaskedDomain):
"""Return cached capacitance data, building if needed."""
if self._capfft_cache is None or self._capfft_domain is not domain:
self.prepare(domain)
return self._capfft_cache
def solve(self, f, domain: MaskedDomain):
"""Solve masked Poisson with capacitance-corrected rectangular FFT.
f: (H, W) scalar source field
domain: MaskedDomain with prebuilt Laplacian
returns: (H, W) scalar potential; zero outside mask
"""
rhs_active = domain.prepare_rhs(f)
if rhs_active.size == 0:
return numpy.zeros_like(f, dtype=numpy.float32)
rhs_full = numpy.zeros_like(f, dtype=numpy.float32)
rhs_full[domain.mask] = rhs_active.astype(numpy.float32)
field = numpy.asarray(self.bulk_solver.solve(rhs_full), dtype=numpy.float64)
cap_cache = self.get_cache(domain)
if not cap_cache or cap_cache["boundary_count"] == 0:
field_active = field[domain.mask]
if field_active.size:
field_active = field_active.copy()
field_active -= field_active[domain.pinned]
return domain.scatter(field_active, f)
boundary_rows = cap_cache["boundary_active_indices"]
rhs_scale = max(1.0, float(numpy.linalg.norm(rhs_active)))
for _ in range(self.config.max_iters):
residual = domain.residual(field, rhs_active)
boundary_residual = residual[boundary_rows]
if float(numpy.linalg.norm(boundary_residual)) <= self.config.tol * rhs_scale:
break
alpha = dense_linalg.lu_solve((cap_cache["cap_lu"], cap_cache["cap_piv"]), boundary_residual)
charges = numpy.zeros_like(f, dtype=numpy.float32)
coords = cap_cache["boundary_coords"]
charges[coords[:, 0], coords[:, 1]] = alpha.astype(numpy.float32)
correction = numpy.asarray(self.bulk_solver.solve(charges), dtype=numpy.float64)
field -= correction
field_active = field[domain.mask]
if field_active.size:
field_active = field_active.copy()
field_active -= field_active[domain.pinned]
return domain.scatter(field_active, f)
+21
View File
@@ -0,0 +1,21 @@
import numpy
from ..domain import MaskedDomain
class MaskedSparseSolver:
"""Reference masked Poisson solver via factorized sparse Laplacian."""
def solve(self, f, domain: MaskedDomain):
"""Solve masked Poisson on active domain pixels.
f: (H, W) scalar source field
domain: MaskedDomain with prebuilt Laplacian
returns: (H, W) scalar potential; zero outside mask
"""
rhs = domain.prepare_rhs(f)
if rhs.size == 0:
return numpy.zeros_like(f, dtype=numpy.float32)
sol_active = domain.factor(rhs) # (N,) reduced-system potential
return domain.scatter(sol_active, f)
+530
View File
@@ -0,0 +1,530 @@
import numpy
import timeit
from scipy import signal
from ...backend_availability import (
CUPY_AVAILABLE,
OPENCL_AVAILABLE,
cp,
cp_signal,
cl,
cla,
cl_rfftn,
cl_irfftn,
)
from ..timing import Timer
class RectangularFFTSolver:
"""FFT Poisson solver on a full structured rectangular grid."""
def __init__(self, grid, spacings, poisson_backend="cpu", dtype=numpy.float32, stype="F"):
self.gdims = grid
self.gdx = spacings
self.dim = len(self.gdims)
self.dtype = dtype
self.stype = stype
self.poisson_backend = poisson_backend
self.ready = False
self.G = None
if self.poisson_backend == "cupy":
self._gpu_cache = None
if self.poisson_backend == "opencl":
self._opencl_cache = None
self._opencl_queue = None
self._opencl_context = None
self._opencl_crop_kernel = None
self._opencl_scatter_kernel = None
self._opencl_fused_mul_kernel = None
self._opencl_scatter_batch_kernel = None
self._opencl_crop_batch_kernel = None
self._opencl_batch_mul_kernel = None
self._opencl_last_timing = None
self._opencv_cache = None
def _create_radialGrid(self, verbose):
mtimer = Timer()
if verbose:
print(" - creating distance kernel:", end="")
rdims = tuple(2 * d - 1 for d in self.gdims)
R = numpy.indices(rdims, dtype=self.dtype)
for d in range(self.dim):
R[d] += 1 - self.gdims[d]
R[d] *= self.gdx[d]
if self.dim == 1:
numpy.absolute(R[0], R[0])
R = R[0]
elif self.dim == 2:
numpy.hypot(R[1], R[0], R[0])
R = R[0]
zval = 0.5 * (self.gdx[0] + self.gdx[1]) / 2.0
R[self.gdims[0] - 1, self.gdims[1] - 1] = zval
elif self.dim == 3:
numpy.hypot(R[2], R[1], R[1])
numpy.hypot(R[1], R[0], R[0])
R = R[0]
zval = 0.5 * (self.gdx[0] + self.gdx[1] + self.gdx[2]) / 3.0
R[self.gdims[0] - 1, self.gdims[1] - 1, self.gdims[2] - 1] = zval
if verbose:
print(R.shape, R.min(), R.max())
mtimer.end()
return R
def prepare(self, verbose=False):
"""Precompute Green's function for FFT convolution.
returns: None (sets self.G and self.ready)
"""
gtimer = Timer()
if verbose:
print("\nInitializing rectangular FFT Poisson solver, type =", self.stype)
self.G = None
if self.stype == "F":
self.G = self._create_radialGrid(verbose)
elif self.stype == "S":
raise SyntaxError("Spatial solver not suggested for structured grids")
ltimer = Timer()
if verbose:
print(" - computing the Green's function:")
if self.dim == 1:
numpy.multiply(self.G, 0.5, self.G)
elif self.dim == 2:
numpy.log(self.G, self.G)
numpy.multiply(self.G, (0.5 / numpy.pi), self.G)
elif self.dim == 3:
numpy.reciprocal(self.G, self.G)
numpy.multiply(self.G, (-0.25 / numpy.pi), self.G)
if verbose:
print(self.G.shape, self.G.min(), self.G.max(), end="")
ltimer.end()
self.ready = True
if verbose:
print("Rectangular FFT Poisson solver initialized", end="")
gtimer.end()
def solve(self, f, verbose=False):
"""Solve Poisson on the full rectangular grid via FFT convolution.
f: (H, W) or (Z, Y, X) scalar source field
returns: (H, W) scalar potential, same shape as f
"""
if not self.ready:
self.prepare(verbose)
fshape = f.shape
if self.gdims != fshape:
print(self.gdims, fshape)
raise ValueError("Shape of function should match shape of grid")
gtimer = Timer()
if verbose:
print("\nSolving rectangular Poisson Eq.")
if self.stype == "F":
p = signal.fftconvolve(f, self.G, mode="same")
numpy.multiply(p, numpy.prod(self.gdx), p)
else:
raise SyntaxError("Spatial solver not suggested for structured grids")
if verbose:
print("Rectangular Poisson solver finished")
gtimer.end()
return p
def solve_gpu(self, f, verbose=False, return_gpu=False):
"""GPU FFT Poisson solve using CuPy.
f: (H, W) scalar source field on CPU
return_gpu: if True, return CuPy array instead of numpy
returns: (H, W) scalar potential
"""
if not CUPY_AVAILABLE:
if verbose:
print("CuPy not available, falling back to CPU solver")
return self.solve(f, verbose)
if not self.ready:
self.prepare(verbose)
fshape = f.shape
gtimer = Timer()
if verbose:
print("\nSolving Poisson Eq. on GPU")
if self.gdims != fshape:
print(self.gdims, fshape)
raise ValueError("Shape of function should match shape of grid")
if self.stype == "F":
if self._gpu_cache is None:
if verbose:
print(" - Transferring Green's function to GPU...")
self._gpu_cache = {
"G_gpu": cp.asarray(self.G, dtype=cp.float32),
"gdx_prod": numpy.prod(self.gdx),
}
f_gpu = cp.asarray(f, dtype=cp.float32)
p_gpu = cp_signal.fftconvolve(f_gpu, self._gpu_cache["G_gpu"], mode="same")
p_gpu *= self._gpu_cache["gdx_prod"]
if verbose:
print("Poisson solver (GPU) finished")
gtimer.end()
if return_gpu:
return p_gpu
return cp.asnumpy(p_gpu)
raise SyntaxError("Spatial solver not suggested for structured grids")
def _solve_gpu_internal(self, f_gpu):
"""Internal GPU solve; f_gpu and return value are CuPy arrays."""
if self.stype == "F":
if self._gpu_cache is None:
self._gpu_cache = {
"G_gpu": cp.asarray(self.G, dtype=cp.float32),
"gdx_prod": numpy.prod(self.gdx),
}
p_gpu = cp_signal.fftconvolve(f_gpu, self._gpu_cache["G_gpu"], mode="same")
p_gpu *= self._gpu_cache["gdx_prod"]
return p_gpu
raise SyntaxError("Spatial solver not suggested for structured grids")
def clear_gpu_cache(self):
"""Release CuPy FFT cache buffers."""
if self._gpu_cache is not None:
self._gpu_cache = None
if CUPY_AVAILABLE:
cp.get_default_memory_pool().free_all_blocks()
def _get_opencl_queue(self):
if self._opencl_queue is not None:
return self._opencl_queue
if not OPENCL_AVAILABLE:
raise RuntimeError("OpenCL/pyvkfft is not available")
ctx = None
for platform in cl.get_platforms():
for device in platform.get_devices():
if device.type & cl.device_type.GPU == 0:
continue
ctx = cl.Context(devices=(device,))
break
if ctx is not None:
break
if ctx is None:
raise RuntimeError("No OpenCL GPU device found")
self._opencl_queue = cl.CommandQueue(ctx)
self._opencl_context = ctx
return self._opencl_queue
def _get_opencl_scatter_batch_kernel(self, queue):
if self._opencl_scatter_batch_kernel is not None:
return self._opencl_scatter_batch_kernel
kernel_code = """
__kernel void scatter_batch(
__global const float* src,
__global float* dst,
const int src_width,
const int src_height,
const int dst_width,
const int src_plane,
const int dst_plane
) {
int gid_x = get_global_id(0);
int gid_y = get_global_id(1);
int gid_z = get_global_id(2);
if (gid_x < src_width && gid_y < src_height) {
int src_idx = gid_z * src_plane + gid_y * src_width + gid_x;
int dst_idx = gid_z * dst_plane + gid_y * dst_width + gid_x;
dst[dst_idx] = src[src_idx];
}
}
"""
program = cl.Program(self._opencl_context, kernel_code).build()
self._opencl_scatter_batch_kernel = program.scatter_batch
return self._opencl_scatter_batch_kernel
def _get_opencl_crop_batch_kernel(self, queue):
if self._opencl_crop_batch_kernel is not None:
return self._opencl_crop_batch_kernel
kernel_code = """
__kernel void crop_batch(
__global const float* src,
__global float* dst,
const int src_width,
const int dst_width,
const int dst_height,
const int start_x,
const int start_y,
const float scale,
const int src_plane,
const int dst_plane
) {
int gid_x = get_global_id(0);
int gid_y = get_global_id(1);
int gid_z = get_global_id(2);
if (gid_x < dst_width && gid_y < dst_height) {
int src_idx = gid_z * src_plane + (start_y + gid_y) * src_width + (start_x + gid_x);
int dst_idx = gid_z * dst_plane + gid_y * dst_width + gid_x;
dst[dst_idx] = src[src_idx] * scale;
}
}
"""
program = cl.Program(self._opencl_context, kernel_code).build()
self._opencl_crop_batch_kernel = program.crop_batch
return self._opencl_crop_batch_kernel
def _get_opencl_batch_mul_kernel(self, queue):
if self._opencl_batch_mul_kernel is not None:
return self._opencl_batch_mul_kernel
kernel_code = """
__kernel void batch_complex_mul(
__global float2* fft_batch,
__global const float2* g,
const int n
) {
const int b = get_global_id(0);
const int base = b * n;
const int i0 = get_global_id(1) * 4;
const int i_end = min(i0 + 4, n);
for (int i = i0; i < i_end; i++) {
float2 gv = g[i];
float2 a = fft_batch[base + i];
fft_batch[base + i] = (float2)(fma(a.x, gv.x, -(a.y * gv.y)),
fma(a.x, gv.y, a.y * gv.x));
}
}
"""
program = cl.Program(self._opencl_context, kernel_code).build()
self._opencl_batch_mul_kernel = program.batch_complex_mul
return self._opencl_batch_mul_kernel
def _get_opencl_alloc_host_ptr(self):
ctx = self._opencl_context
mf = cl.mem_flags
def allocator(nbytes):
return cl.Buffer(ctx, mf.READ_WRITE | mf.ALLOC_HOST_PTR, size=nbytes)
return allocator
@staticmethod
def _center_crop_same(full_conv, target_shape):
starts = [(full_conv.shape[i] - target_shape[i]) // 2 for i in range(len(target_shape))]
slices = tuple(slice(starts[i], starts[i] + target_shape[i]) for i in range(len(target_shape)))
return full_conv[slices]
def solve_batch_opencl(self, f1, f2, verbose=False, profile=False):
"""Batched OpenCL FFT Poisson solve for two 2D fields.
f1, f2: (H, W) scalar source fields, same shape
returns: (p1, p2) each (H, W) scalar potential
"""
if not OPENCL_AVAILABLE:
if verbose:
print("OpenCL/pyvkfft not available, falling back to CPU solver")
return self.solve(f1, verbose), self.solve(f2, verbose)
if not self.ready:
self.prepare(verbose)
fshape = f1.shape
if fshape != f2.shape:
raise ValueError("solve_batch_opencl requires inputs with identical shapes")
if self.gdims != fshape:
print(self.gdims, fshape)
raise ValueError("Shape of function should match shape of grid")
timing = {"cache_prepare_ms": 0.0}
t_total = timeit.default_timer()
t0 = timeit.default_timer()
queue = self._get_opencl_queue()
timing["queue_ms"] = (timeit.default_timer() - t0) * 1000.0
t0 = timeit.default_timer()
full_shape = tuple(2 * s - 1 for s in fshape)
timing["host_prepare_ms"] = (timeit.default_timer() - t0) * 1000.0
cache_valid = (
self._opencl_cache is not None
and self._opencl_cache["fshape"] == fshape
and self._opencl_cache["full_shape"] == full_shape
and "f_batch_dev" in self._opencl_cache
)
if not cache_valid:
t0 = timeit.default_timer()
g_host = numpy.asarray(self.G, dtype=numpy.float32)
g_pad = numpy.zeros(full_shape, dtype=numpy.float32)
g_slices = tuple(slice(0, s) for s in g_host.shape)
g_pad[g_slices] = g_host
g_dev = cla.to_device(queue, g_pad)
g_fft_dev = cl_rfftn(g_dev)
if profile:
queue.finish()
same_starts = [fshape[i] - 1 for i in range(self.dim)]
fft_shape = tuple(list(full_shape[:-1]) + [full_shape[-1] // 2 + 1])
fft_n_elements = 1
for d in fft_shape:
fft_n_elements *= d
src_plane = fshape[0] * fshape[1]
dst_plane = full_shape[0] * full_shape[1]
alloc_pinned = self._get_opencl_alloc_host_ptr()
self._opencl_cache = {
"fshape": fshape,
"full_shape": full_shape,
"fft_shape": fft_shape,
"fft_n_elements": fft_n_elements,
"g_fft_dev": g_fft_dev,
"same_starts": same_starts,
"gdx_prod": numpy.prod(self.gdx),
"src_plane": src_plane,
"dst_plane": dst_plane,
"f_batch_dev": cla.zeros(queue, (2,) + full_shape, dtype=numpy.float32),
"host_stage": numpy.empty((2,) + fshape, dtype=numpy.float32),
"f_stage_dev": cla.empty(queue, (2,) + fshape, dtype=numpy.float32),
"f_fft_batch_dev": cla.empty(queue, (2,) + fft_shape, dtype=numpy.complex64),
"conv_batch_dev": cla.empty(queue, (2,) + full_shape, dtype=numpy.float32),
"p_batch_dev": cla.empty(queue, (2,) + fshape, dtype=numpy.float32, allocator=alloc_pinned),
"p1_host": numpy.empty(fshape, dtype=numpy.float32),
"p2_host": numpy.empty(fshape, dtype=numpy.float32),
}
timing["cache_prepare_ms"] = (timeit.default_timer() - t0) * 1000.0
c = self._opencl_cache
fshape_c = c["fshape"]
full_shape_c = c["full_shape"]
t0 = timeit.default_timer()
H, W = fshape_c
f1_f32 = f1 if (f1.flags["C_CONTIGUOUS"] and f1.dtype == numpy.float32) else numpy.ascontiguousarray(f1, dtype=numpy.float32)
f2_f32 = f2 if (f2.flags["C_CONTIGUOUS"] and f2.dtype == numpy.float32) else numpy.ascontiguousarray(f2, dtype=numpy.float32)
host_stage = c["host_stage"]
numpy.copyto(host_stage[0], f1_f32)
numpy.copyto(host_stage[1], f2_f32)
f_stage_dev = c["f_stage_dev"]
cl.enqueue_copy(queue, f_stage_dev.data, host_stage, is_blocking=False)
c["f_batch_dev"].fill(0, queue=queue)
scatter_batch = self._get_opencl_scatter_batch_kernel(queue)
scatter_batch(
queue,
(W, H, 2),
None,
f_stage_dev.data,
c["f_batch_dev"].data,
numpy.int32(W),
numpy.int32(H),
numpy.int32(full_shape_c[1]),
numpy.int32(H * W),
numpy.int32(c["dst_plane"]),
)
if profile:
queue.finish()
timing["h2d_set_ms"] = (timeit.default_timer() - t0) * 1000.0
t0 = timeit.default_timer()
fft_batch = cl_rfftn(c["f_batch_dev"], c["f_fft_batch_dev"], ndim=2)
if profile:
queue.finish()
timing["fft_f_ms"] = (timeit.default_timer() - t0) * 1000.0
t0 = timeit.default_timer()
n_fft = c["fft_n_elements"]
global_n = max(1, (n_fft + 3) // 4)
local_n = min(256, global_n)
global_n = ((global_n + local_n - 1) // local_n) * local_n
batch_mul = self._get_opencl_batch_mul_kernel(queue)
batch_mul(
queue,
(2, global_n),
(1, local_n),
fft_batch.data,
c["g_fft_dev"].data,
numpy.int32(n_fft),
)
if profile:
queue.finish()
timing["mul_ms"] = (timeit.default_timer() - t0) * 1000.0
t0 = timeit.default_timer()
cl_irfftn(fft_batch, c["conv_batch_dev"], ndim=2)
if profile:
queue.finish()
timing["ifft_ms"] = (timeit.default_timer() - t0) * 1000.0
t0 = timeit.default_timer()
crop_batch = self._get_opencl_crop_batch_kernel(queue)
same_starts = c["same_starts"]
crop_batch(
queue,
(fshape_c[1], fshape_c[0], 2),
None,
c["conv_batch_dev"].data,
c["p_batch_dev"].data,
numpy.int32(full_shape_c[1]),
numpy.int32(fshape_c[1]),
numpy.int32(fshape_c[0]),
numpy.int32(same_starts[1]),
numpy.int32(same_starts[0]),
numpy.float32(c["gdx_prod"]),
numpy.int32(c["dst_plane"]),
numpy.int32(c["src_plane"]),
)
if profile:
queue.finish()
timing["crop_scale_ms"] = (timeit.default_timer() - t0) * 1000.0
t0 = timeit.default_timer()
p1 = c["p1_host"]
p2 = c["p2_host"]
mapped_p = c["p_batch_dev"].map_to_host(queue=queue, flags=cl.map_flags.READ, is_blocking=True)
numpy.copyto(p1, mapped_p[0])
numpy.copyto(p2, mapped_p[1])
mapped_p.base.release(queue=queue)
timing["d2h_get_ms"] = (timeit.default_timer() - t0) * 1000.0
timing["total_ms"] = (timeit.default_timer() - t_total) * 1000.0
self._opencl_last_timing = timing
if verbose:
print("Poisson solver (OpenCL FFT batch2) finished")
if profile:
print(
"OpenCL batch2 timing [ms]: "
f"total={timing['total_ms']:.3f}, "
f"queue={timing['queue_ms']:.3f}, "
f"cache={timing['cache_prepare_ms']:.3f}, "
f"host={timing['host_prepare_ms']:.3f}, "
f"h2d={timing['h2d_set_ms']:.3f}, "
f"fft={timing['fft_f_ms']:.3f}, "
f"mul={timing['mul_ms']:.3f}, "
f"ifft={timing['ifft_ms']:.3f}, "
f"d2h={timing['d2h_get_ms']:.3f}, "
f"crop={timing['crop_scale_ms']:.3f}"
)
return p1, p2
def solve_opencl_batch2(self, f1, f2, verbose=False, profile=False):
"""Alias for solve_batch_opencl (backward compatible name)."""
return self.solve_batch_opencl(f1, f2, verbose=verbose, profile=profile)