# PI_code_test_FNO_inference.py # ========================================================= # ✅ Inference ONLY for Encoder+FNO (no DDP, no training) # ✅ Can read BOTH MATLAB v7 (scipy.io.loadmat) and v7.3 (HDF5 via h5py) # ✅ Added: accurate inference timing (CUDA synchronized) + save timing into .mat # # Input .mat must contain (names can vary, see candidates below): # - Rimg : (N,1,512,512) OR (512,512,N) OR (512,512) OR (1,512,512) # - coords: (M,2) in [-1,1] (or (1,M,2)) # # Output .mat: # - E_pred : (N,M,2) float32 (Re, Im) # - coords_xy : (M,2) float32 # - Rimg : (N,1,512,512) float32 # - meta_* : timing + shapes + ckpt path # ========================================================= import argparse import math import time from pathlib import Path import numpy as np import torch import torch.nn as nn from scipy.io import loadmat, savemat # ------------------------- # Utils # ------------------------- def make_gn(num_channels: int, max_groups: int = 8) -> nn.GroupNorm: for g in [max_groups, 4, 2, 1]: if num_channels % g == 0: return nn.GroupNorm(num_groups=g, num_channels=num_channels) return nn.GroupNorm(num_groups=1, num_channels=num_channels) def _pick_key(d, keys): for k in keys: if k in d: return k return None def _as_numpy_from_h5(obj): """ Convert h5py dataset to numpy. MATLAB v7.3 stores arrays in column-major and often with transposed dims; We will reshape/transpose later in the Rimg handler. """ import numpy as _np return _np.array(obj) def load_mat_auto(mat_path: str): """ Load .mat file: - MATLAB v7.2 and below: scipy.io.loadmat - MATLAB v7.3 (HDF5): h5py Returns: dict-like mapping name -> numpy array """ mat_path = str(mat_path) try: data = loadmat(mat_path) # remove matlab meta keys if exist return {k: v for k, v in data.items() if not k.startswith("__")} except NotImplementedError: import h5py data = {} with h5py.File(mat_path, "r") as f: for k in f.keys(): data[k] = _as_numpy_from_h5(f[k]) return data def load_meta_inference_mat(mat_in: str): """ Returns: Rimg_np: (N,1,512,512) float32 coords_np: (M,2) float32 """ data = load_mat_auto(mat_in) # candidates (you can add more if needed) k_r = _pick_key(data, ["Rimg", "rimg", "geom", "geometry", "M"]) k_c = _pick_key(data, ["coords", "coord", "coords_xy", "xy"]) if k_r is None or k_c is None: raise KeyError( f"[meta_inference] mat must contain geometry + coords.\n" f"Found keys: {list(data.keys())}\n" f"Geometry candidates: [Rimg,rimg,geom,geometry,M]\n" f"Coords candidates: [coords,coord,coords_xy,xy]" ) Rimg_np = np.array(data[k_r]) coords_np = np.array(data[k_c]) # ---- coords -> (M,2) coords_np = np.squeeze(coords_np) # MATLAB v7.3 (h5py) sometimes gives (2,M) for a (M,2) saved matrix if coords_np.ndim == 2 and coords_np.shape[0] == 2 and coords_np.shape[1] != 2: coords_np = coords_np.T if coords_np.ndim != 2 or coords_np.shape[1] != 2: raise ValueError(f"[meta_inference] coords must be (M,2). Got {coords_np.shape}") coords_np = coords_np.astype(np.float32) # ---- Rimg -> (N,1,512,512) Rimg_np = np.squeeze(Rimg_np) if Rimg_np.ndim == 2: # (512,512) Rimg_np = Rimg_np[None, None, :, :] elif Rimg_np.ndim == 3: # could be (512,512,N) OR (N,512,512) OR (1,512,512) if Rimg_np.shape[0] == 1 and Rimg_np.shape[1] == 512 and Rimg_np.shape[2] == 512: Rimg_np = Rimg_np[:, None, :, :] # (1,1,512,512) elif Rimg_np.shape[0] == 512 and Rimg_np.shape[1] == 512: # (512,512,N) -> (N,1,512,512) Rimg_np = np.transpose(Rimg_np, (2, 0, 1))[:, None, :, :] elif Rimg_np.shape[1] == 512 and Rimg_np.shape[2] == 512: # (N,512,512) -> (N,1,512,512) Rimg_np = Rimg_np[:, None, :, :] else: raise ValueError(f"[meta_inference] Unrecognized Rimg 3D shape: {Rimg_np.shape}") elif Rimg_np.ndim == 4: # could be (N,1,512,512) OR (1,512,512,N) OR (512,512,1,N) etc. if Rimg_np.shape[1] == 1 and Rimg_np.shape[2] == 512 and Rimg_np.shape[3] == 512: # (N,1,512,512) pass elif Rimg_np.shape[0] == 1 and Rimg_np.shape[1] == 512 and Rimg_np.shape[2] == 512: # (1,512,512,N) -> (N,1,512,512) Rimg_np = np.transpose(Rimg_np, (3, 0, 1, 2)) elif Rimg_np.shape[0] == 512 and Rimg_np.shape[1] == 512 and Rimg_np.shape[2] == 1: # (512,512,1,N) -> (N,1,512,512) Rimg_np = np.transpose(Rimg_np, (3, 2, 0, 1)) else: raise ValueError(f"[meta_inference] Unrecognized Rimg 4D shape: {Rimg_np.shape}") else: raise ValueError(f"[meta_inference] Unrecognized Rimg shape: {Rimg_np.shape}") if Rimg_np.shape[1] != 1 or Rimg_np.shape[-2:] != (512, 512): raise ValueError(f"[meta_inference] Rimg must be (N,1,512,512). Got {Rimg_np.shape}") return Rimg_np.astype(np.float32), coords_np.astype(np.float32) # ------------------------- # FNO core # ------------------------- class FFTConv2d(nn.Module): def __init__(self, in_channels: int, out_channels: int, modes: int): super().__init__() self.out_channels = out_channels self.modes = int(modes) self.w = nn.Parameter( torch.rand(in_channels, out_channels, 2 * self.modes, self.modes, 2) / (in_channels * out_channels) ) @staticmethod def cmul2d(a, b): return torch.einsum("bixy,ioxy->boxy", a, b) def forward(self, x: torch.Tensor) -> torch.Tensor: B, C, H, W = x.shape device = x.device x_ft = torch.fft.rfft2(x) # (B,C,H,Wf) Wf = x_ft.size(-1) my = min(self.modes, H // 2) mx = min(self.modes, Wf) out_ft = torch.zeros(B, self.out_channels, H, Wf, dtype=torch.cfloat, device=device) x_ft = torch.fft.fftshift(x_ft, dim=-2) cy = H // 2 a = x_ft[..., cy - my: cy + my, :mx] # (B,inC,2*my,mx) w_c = torch.view_as_complex(self.w) # (inC,outC,2*modes,modes) b = w_c[..., self.modes - my: self.modes + my, :mx] out_ft[..., cy - my: cy + my, :mx] = self.cmul2d(a, b) out_ft = torch.fft.ifftshift(out_ft, dim=-2) x = torch.fft.irfft2(out_ft, s=(H, W)) return x class FNOBlock2d(nn.Module): def __init__(self, modes: int, width: int, norm: str = "gn", dropout: float = 0.10, layerscale=0.10): super().__init__() self.fftconv = FFTConv2d(width, width, modes) self.conv = nn.Conv2d(width, width, 1, bias=False) self.norm = nn.BatchNorm2d(width) if norm == "bn" else make_gn(width) self.act = nn.GELU() self.drop = nn.Dropout2d(p=float(dropout)) if dropout and dropout > 0 else nn.Identity() self.gamma = nn.Parameter(layerscale * torch.ones(1, width, 1, 1)) if layerscale and layerscale > 0 else None def forward(self, x): y = self.fftconv(x) + self.conv(x) x = x + (self.gamma * y if self.gamma is not None else y) x = self.norm(x) x = self.act(x) x = self.drop(x) return x class FNOModel2d(nn.Module): def __init__( self, modes=6, width=24, blocks=4, padding=0, in_channels=3, out_channels=2, norm="gn", dropout=0.10, layerscale=0.10, ): super().__init__() self.conv_in = nn.Conv2d(in_channels, width, 1, bias=True) self.padding = int(padding) self.pad_in = nn.ConstantPad2d(self.padding, 0.0) if self.padding > 0 else nn.Identity() self.pad_out = nn.ConstantPad2d(-self.padding, 0.0) if self.padding > 0 else nn.Identity() self.fno_blocks = nn.Sequential( *[FNOBlock2d(modes, width, norm=norm, dropout=dropout, layerscale=layerscale) for _ in range(blocks)] ) self.conv_out = nn.Sequential( nn.Conv2d(width, width, 1), nn.GELU(), nn.Dropout2d(dropout), nn.Conv2d(width, out_channels, 1), ) def forward(self, x): x = self.conv_in(x) x = self.pad_in(x) x = self.fno_blocks(x) x = self.pad_out(x) x = self.conv_out(x) return x # ------------------------- # Geometry Encoder # ------------------------- class GeomEncoder(nn.Module): def __init__(self, out_ch=8, drop=0.10): super().__init__() self.prepool = nn.AvgPool2d(kernel_size=6, stride=6) # 512 -> ~85 self.net = nn.Sequential( nn.Conv2d(1, 16, 3, padding=1, bias=False), make_gn(16), nn.GELU(), nn.Dropout2d(drop), nn.Conv2d(16, 24, 3, stride=2, padding=1, bias=False), # ~85 -> ~43 make_gn(24), nn.GELU(), nn.Dropout2d(drop), nn.Conv2d(24, 32, 3, stride=2, padding=1, bias=False), # ~43 -> ~22 make_gn(32), nn.GELU(), nn.Dropout2d(drop), nn.Conv2d(32, out_ch, 1, bias=True), make_gn(out_ch), nn.GELU(), ) self.pool_to = None def forward(self, x_img, Ny, Nx): h = self.prepool(x_img) h = self.net(h) if (self.pool_to is None) or (self.pool_to.output_size != (Ny, Nx)): self.pool_to = nn.AdaptiveAvgPool2d((Ny, Nx)) return self.pool_to(h) # ------------------------- # Optional Fourier features (OFF by default) # ------------------------- class FourierFeatures2D(nn.Module): def __init__(self, K=2, scale=1.0): super().__init__() self.K = int(K) self.scale = float(scale) freqs = torch.linspace(1.0, self.K, steps=self.K) * self.scale self.register_buffer("freqs", freqs, persistent=False) def forward(self, xy): x = xy[:, 0:1] y = xy[:, 1:2] f = self.freqs.view(1, self.K, 1, 1) x_proj = 2 * math.pi * x * f y_proj = 2 * math.pi * y * f return torch.cat([torch.sin(x_proj), torch.cos(x_proj), torch.sin(y_proj), torch.cos(y_proj)], dim=1) # ------------------------- # Regressor: Encoder + (xy [+ optional Fourier]) + FNO # ------------------------- class FNOFieldRegressor_EncoderFNO(nn.Module): def __init__( self, modes=6, width=24, blocks=4, padding=0, norm="gn", dropout=0.10, layerscale=0.10, geom_ch=8, use_fourier=False, fourier_K=2, fourier_scale=1.0, dropout_xy=0.05, ): super().__init__() self.encoder = GeomEncoder(out_ch=geom_ch, drop=0.10) self.use_fourier = bool(use_fourier) self.fourier = FourierFeatures2D(K=fourier_K, scale=fourier_scale) if self.use_fourier else None self.drop_xy = nn.Dropout2d(dropout_xy) if dropout_xy and dropout_xy > 0 else nn.Identity() in_ch = geom_ch + 2 + (4 * fourier_K if self.use_fourier else 0) self.fno = FNOModel2d( modes=modes, width=width, blocks=blocks, padding=padding, in_channels=in_ch, out_channels=2, norm=norm, dropout=dropout, layerscale=layerscale, ) self._cached_key = None self._cached_xy = None # (1,2,Ny,Nx) @staticmethod def _sanitize_coords(coords: torch.Tensor) -> torch.Tensor: if coords.dim() == 4: coords = coords[:, 0, :, :] return coords @staticmethod def _coords_to_xy_grid(coords: torch.Tensor): coords0 = coords[0] if coords.dim() == 3 else coords # (M,2) x = coords0[:, 0] y = coords0[:, 1] xs = torch.unique(x) ys = torch.unique(y) xs, _ = torch.sort(xs) ys, _ = torch.sort(ys) Ny, Nx = ys.numel(), xs.numel() yy, xx = torch.meshgrid(ys, xs, indexing="ij") xy = torch.stack([xx, yy], dim=0).unsqueeze(0) # (1,2,Ny,Nx) return xy, int(Ny), int(Nx) def forward(self, x_img: torch.Tensor, coords: torch.Tensor): B = x_img.shape[0] device = x_img.device dtype = x_img.dtype coords = self._sanitize_coords(coords).to(device=device) xy, Ny, Nx = self._coords_to_xy_grid(coords) key = (Ny, Nx, device.type, getattr(device, "index", None), str(dtype)) if self._cached_key != key: self._cached_key = key self._cached_xy = xy.to(device=device, dtype=dtype) xy_b = self._cached_xy.expand(B, -1, -1, -1) xy_b = self.drop_xy(xy_b) geom_feat = self.encoder(x_img, Ny, Nx) if self.use_fourier: ff = self.fourier(xy_b) inp = torch.cat([geom_feat, xy_b, ff], dim=1) else: inp = torch.cat([geom_feat, xy_b], dim=1) out = self.fno(inp) # (B,2,Ny,Nx) out = out.permute(0, 2, 3, 1).contiguous().view(B, Ny * Nx, 2) return out # ------------------------- # Inference # ------------------------- @torch.no_grad() def run_inference( mat_in: str, ckpt: str, mat_out: str, device: str, batch_size: int, modes: int, width: int, blocks: int, padding: int, dropout: float, layerscale: float, geom_ch: int, use_fourier: bool, fourier_K: int, fourier_scale: float, dropout_xy: float, ): Rimg_np, coords_np = load_meta_inference_mat(mat_in) N = int(Rimg_np.shape[0]) M = int(coords_np.shape[0]) dev = torch.device(device if torch.cuda.is_available() else "cpu") model = FNOFieldRegressor_EncoderFNO( modes=modes, width=width, blocks=blocks, padding=padding, norm="gn", dropout=dropout, layerscale=layerscale, geom_ch=geom_ch, use_fourier=use_fourier, fourier_K=fourier_K, fourier_scale=fourier_scale, dropout_xy=dropout_xy, ).to(dev) # load weights try: state = torch.load(ckpt, map_location="cpu", weights_only=True) except TypeError: state = torch.load(ckpt, map_location="cpu") model.load_state_dict(state, strict=True) model.eval() coords_t = torch.from_numpy(coords_np).to(dev).unsqueeze(0) # (1,M,2) # ---- timing helpers (CUDA safe) def _sync(): if dev.type == "cuda": torch.cuda.synchronize(dev) # warmup (recommended) warmup_steps = min(2, (N + batch_size - 1) // batch_size) for wi in range(warmup_steps): s = wi * batch_size e = min(N, s + batch_size) x_img = torch.from_numpy(Rimg_np[s:e]).to(dev, dtype=torch.float32) coords_b = coords_t.expand(e - s, -1, -1).contiguous() _ = model(x_img, coords_b) _sync() preds = [] t_total0 = time.perf_counter() forward_time_sum = 0.0 num_samples_done = 0 for s in range(0, N, batch_size): e = min(N, s + batch_size) x_img = torch.from_numpy(Rimg_np[s:e]).to(dev, dtype=torch.float32) coords_b = coords_t.expand(e - s, -1, -1).contiguous() _sync() t0 = time.perf_counter() pred = model(x_img, coords_b) # (B,M,2) _sync() t1 = time.perf_counter() forward_time_sum += (t1 - t0) num_samples_done += (e - s) preds.append(pred.detach().cpu().numpy().astype(np.float32)) t_total1 = time.perf_counter() total_time = t_total1 - t_total0 avg_time_per_sample = total_time / max(num_samples_done, 1) avg_forward_per_sample = forward_time_sum / max(num_samples_done, 1) fps_total = num_samples_done / max(total_time, 1e-12) fps_forward = num_samples_done / max(forward_time_sum, 1e-12) E_pred = np.concatenate(preds, axis=0) # (N,M,2) print("========== Inference Timing ==========") print(f"[Time] total elapsed : {total_time:.6f} s") print(f"[Time] total forward (model) : {forward_time_sum:.6f} s") print(f"[Time] avg elapsed / sample : {avg_time_per_sample*1e3:.3f} ms") print(f"[Time] avg forward / sample : {avg_forward_per_sample*1e3:.3f} ms") print(f"[Perf] throughput (total) : {fps_total:.3f} samples/s") print(f"[Perf] throughput (forward) : {fps_forward:.3f} samples/s") print("======================================") savemat( mat_out, { "E_pred": E_pred, "coords_xy": coords_np.astype(np.float32), "meta_ckpt": str(ckpt), "meta_N": np.int64(N), "meta_M": np.int64(M), # timing meta "meta_time_total_s": np.float64(total_time), "meta_time_forward_s": np.float64(forward_time_sum), "meta_ms_per_sample_total": np.float64(avg_time_per_sample * 1e3), "meta_ms_per_sample_forward": np.float64(avg_forward_per_sample * 1e3), "meta_throughput_total_sps": np.float64(fps_total), "meta_throughput_forward_sps": np.float64(fps_forward), }, ) print(f"[OK] mat_in : {mat_in}") print(f"[OK] ckpt : {ckpt}") print(f"[OK] mat_out : {mat_out}") print(f"[OK] shapes : Rimg={Rimg_np.shape}, coords={coords_np.shape}, E_pred={E_pred.shape}") def parse_args(): p = argparse.ArgumentParser("Inference only: Encoder+FNO") p.add_argument("--mat_in", type=str, default="meta_inference.mat") p.add_argument("--ckpt", type=str, default="fno_ddpsafe_best.pth") p.add_argument("--mat_out", type=str, default="meta_inference_pred.mat") p.add_argument("--device", type=str, default="cuda:0") p.add_argument("--batch_size", type=int, default=16) # must match training hyperparameters p.add_argument("--modes", type=int, default=6) p.add_argument("--width", type=int, default=24) p.add_argument("--blocks", type=int, default=4) p.add_argument("--padding", type=int, default=0) p.add_argument("--dropout", type=float, default=0.10) p.add_argument("--layerscale", type=float, default=0.10) p.add_argument("--geom_ch", type=int, default=8) p.add_argument("--use_fourier", action="store_true") p.add_argument("--fourier_K", type=int, default=2) p.add_argument("--fourier_scale", type=float, default=1.0) p.add_argument("--dropout_xy", type=float, default=0.05) return p.parse_args() def main(): args = parse_args() if not Path(args.mat_in).exists(): raise FileNotFoundError(f"Input mat not found: {args.mat_in}") if not Path(args.ckpt).exists(): raise FileNotFoundError(f"Checkpoint not found: {args.ckpt}") run_inference( mat_in=args.mat_in, ckpt=args.ckpt, mat_out=args.mat_out, device=args.device, batch_size=args.batch_size, modes=args.modes, width=args.width, blocks=args.blocks, padding=args.padding, dropout=args.dropout, layerscale=args.layerscale, geom_ch=args.geom_ch, use_fourier=bool(args.use_fourier), fourier_K=args.fourier_K, fourier_scale=args.fourier_scale, dropout_xy=args.dropout_xy, ) if __name__ == "__main__": main()